mirror of
https://github.com/SigNoz/signoz.git
synced 2026-09-27 13:50:41 +01:00
Compare commits
8 Commits
feat/updat
...
feat/new-p
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
61a6fb5d1f | ||
|
|
c4a6ce85fa | ||
|
|
45a062babc | ||
|
|
276056c4ab | ||
|
|
ffbba01e29 | ||
|
|
01f7f6869a | ||
|
|
de3c3c268f | ||
|
|
9a602d015a |
@@ -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}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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={[]}
|
||||
|
||||
@@ -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)', () => {
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
.nameInput {
|
||||
flex: 0 1 220px;
|
||||
min-width: 120px;
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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),
|
||||
),
|
||||
}));
|
||||
}
|
||||
@@ -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} />,
|
||||
};
|
||||
@@ -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. */
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
@@ -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]);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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)');
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
@@ -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]);
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user