mirror of
https://github.com/SigNoz/signoz.git
synced 2026-07-09 08:00:42 +01:00
Compare commits
4 Commits
main
...
fixes/dash
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8fd24b7f15 | ||
|
|
8498bc86d0 | ||
|
|
d4bf7e7b9b | ||
|
|
68673f6717 |
@@ -11,6 +11,8 @@ interface ColumnUnitsProps {
|
||||
columns: TableColumnOption[];
|
||||
/** Current per-column unit map (`formatting.columnUnits`), keyed by column key. */
|
||||
value: Record<string, string>;
|
||||
/** Unit the selected metric was sent with; each column warns if its unit mismatches. */
|
||||
metricUnit?: string;
|
||||
onChange: (next: Record<string, string>) => void;
|
||||
}
|
||||
|
||||
@@ -23,6 +25,7 @@ interface ColumnUnitsProps {
|
||||
function ColumnUnits({
|
||||
columns,
|
||||
value,
|
||||
metricUnit,
|
||||
onChange,
|
||||
}: ColumnUnitsProps): JSX.Element {
|
||||
if (columns.length === 0) {
|
||||
@@ -53,6 +56,7 @@ function ColumnUnits({
|
||||
placeholder="Select unit"
|
||||
source={YAxisSource.DASHBOARDS}
|
||||
value={value[column.key]}
|
||||
initialValue={metricUnit}
|
||||
containerClassName={styles.columnUnitSelector}
|
||||
onChange={(unit): void => setUnit(column.key, unit)}
|
||||
/>
|
||||
|
||||
@@ -81,6 +81,7 @@ function FormattingSection({
|
||||
<ColumnUnits
|
||||
columns={tableColumns}
|
||||
value={value?.columnUnits ?? {}}
|
||||
metricUnit={metricUnit}
|
||||
onChange={(columnUnits): void => onChange({ ...value, columnUnits })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -3,7 +3,7 @@ import userEvent from '@testing-library/user-event';
|
||||
|
||||
import FormattingSection from '../FormattingSection';
|
||||
|
||||
// Auto-seeding is covered by useMetricYAxisUnit's tests; here `metricUnit` is just a prop.
|
||||
// Auto-seeding is covered by useSeedMetricUnit's tests; here `metricUnit` is just a prop.
|
||||
|
||||
// Open the Decimals select (clicking its antd selector) and pick the option with the
|
||||
// given visible label.
|
||||
@@ -100,4 +100,33 @@ describe('FormattingSection', () => {
|
||||
|
||||
expect(screen.queryByLabelText('warning')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('warns when a column unit mismatches the metric unit', () => {
|
||||
// metric sent in seconds, but the column is set to bytes.
|
||||
render(
|
||||
<FormattingSection
|
||||
value={{ columnUnits: { A: 'By' } }}
|
||||
controls={{ columnUnits: true }}
|
||||
tableColumns={[{ key: 'A', label: 'A' }]}
|
||||
metricUnit="s"
|
||||
onChange={jest.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByLabelText('warning')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows no warning when the column unit matches the metric unit', () => {
|
||||
render(
|
||||
<FormattingSection
|
||||
value={{ columnUnits: { A: 's' } }}
|
||||
controls={{ columnUnits: true }}
|
||||
tableColumns={[{ key: 'A', label: 'A' }]}
|
||||
metricUnit="s"
|
||||
onChange={jest.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.queryByLabelText('warning')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,6 +5,7 @@ import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { getPanelDefinition } from 'pages/DashboardPageV2/DashboardContainer/Panels/registry';
|
||||
|
||||
import PanelEditorContainer from '../index';
|
||||
import { useScrollToPanelStore } from '../../store/useScrollToPanelStore';
|
||||
|
||||
/**
|
||||
* Characterization test for the editor's composition: which derived values and
|
||||
@@ -19,7 +20,7 @@ const mockRefetch = jest.fn();
|
||||
const mockCancelQuery = jest.fn();
|
||||
const mockBuildSaveSpec = jest.fn((spec: unknown) => spec);
|
||||
const mockOnChangePanelKind = jest.fn();
|
||||
const mockSave = jest.fn().mockResolvedValue(undefined);
|
||||
const mockSave = jest.fn().mockResolvedValue('panel-1');
|
||||
|
||||
const mockUseDraft = jest.fn();
|
||||
jest.mock('../hooks/usePanelEditorDraft', () => ({
|
||||
@@ -61,8 +62,8 @@ jest.mock('../hooks/useLegendSeries', () => ({
|
||||
jest.mock('../hooks/useTableColumns', () => ({
|
||||
useTableColumns: (): [] => [],
|
||||
}));
|
||||
jest.mock('../hooks/useMetricYAxisUnit', () => ({
|
||||
useMetricYAxisUnit: (): unknown => ({
|
||||
jest.mock('../hooks/useSeedMetricUnit', () => ({
|
||||
useSeedMetricUnit: (): unknown => ({
|
||||
metricUnit: undefined,
|
||||
isLoading: false,
|
||||
}),
|
||||
@@ -104,12 +105,17 @@ jest.mock('@signozhq/ui/sonner', () => ({
|
||||
const mockHeaderProps = jest.fn();
|
||||
jest.mock('../Header/Header', () => ({
|
||||
__esModule: true,
|
||||
default: (props: { onSave: () => void }): JSX.Element => {
|
||||
default: (props: { onSave: () => void; onClose: () => void }): JSX.Element => {
|
||||
mockHeaderProps(props);
|
||||
return (
|
||||
<button type="button" data-testid="editor-save" onClick={props.onSave}>
|
||||
save
|
||||
</button>
|
||||
<>
|
||||
<button type="button" data-testid="editor-save" onClick={props.onSave}>
|
||||
save
|
||||
</button>
|
||||
<button type="button" data-testid="editor-close" onClick={props.onClose}>
|
||||
close
|
||||
</button>
|
||||
</>
|
||||
);
|
||||
},
|
||||
}));
|
||||
@@ -196,7 +202,10 @@ function setup(
|
||||
}
|
||||
|
||||
describe('PanelEditorContainer composition', () => {
|
||||
beforeEach(() => jest.clearAllMocks());
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
useScrollToPanelStore.setState({ scrollToPanelId: null });
|
||||
});
|
||||
|
||||
it('renders the editor shell with preview, query builder, and config pane', () => {
|
||||
const panel = makePanel('signoz/TimeSeriesPanel');
|
||||
@@ -299,6 +308,32 @@ describe('PanelEditorContainer composition', () => {
|
||||
expect(mockSave).toHaveBeenCalledWith(panel.spec);
|
||||
});
|
||||
|
||||
it('marks the saved panel to be scrolled into view on the dashboard', async () => {
|
||||
setup(makePanel('signoz/TimeSeriesPanel'));
|
||||
|
||||
await userEvent.click(screen.getByTestId('editor-save'));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(useScrollToPanelStore.getState().scrollToPanelId).toBe('panel-1'),
|
||||
);
|
||||
});
|
||||
|
||||
it('marks an existing panel to be revealed when the editor is closed', async () => {
|
||||
setup(makePanel('signoz/TimeSeriesPanel'));
|
||||
|
||||
await userEvent.click(screen.getByTestId('editor-close'));
|
||||
|
||||
expect(useScrollToPanelStore.getState().scrollToPanelId).toBe('panel-1');
|
||||
});
|
||||
|
||||
it('does not mark a scroll target when a new, unsaved panel is closed', async () => {
|
||||
setup(makePanel('signoz/TimeSeriesPanel'), { isNew: true });
|
||||
|
||||
await userEvent.click(screen.getByTestId('editor-close'));
|
||||
|
||||
expect(useScrollToPanelStore.getState().scrollToPanelId).toBeNull();
|
||||
});
|
||||
|
||||
it('offers Switch to View Mode for an existing panel', () => {
|
||||
setup(makePanel('signoz/TimeSeriesPanel'));
|
||||
|
||||
|
||||
@@ -1,103 +0,0 @@
|
||||
import { renderHook } from '@testing-library/react';
|
||||
import useGetYAxisUnit from 'hooks/useGetYAxisUnit';
|
||||
|
||||
import { useMetricYAxisUnit } from '../useMetricYAxisUnit';
|
||||
|
||||
jest.mock('hooks/useGetYAxisUnit', () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn(),
|
||||
}));
|
||||
|
||||
const mockUseGetYAxisUnit = useGetYAxisUnit as unknown as jest.Mock;
|
||||
|
||||
function mockMetricUnit(
|
||||
yAxisUnit: string | undefined,
|
||||
isLoading = false,
|
||||
): void {
|
||||
mockUseGetYAxisUnit.mockReturnValue({ yAxisUnit, isLoading, isError: false });
|
||||
}
|
||||
|
||||
describe('useMetricYAxisUnit', () => {
|
||||
beforeEach(() => jest.clearAllMocks());
|
||||
|
||||
it('seeds the unit from the metric on a new panel', () => {
|
||||
mockMetricUnit('bytes');
|
||||
const onSelectUnit = jest.fn();
|
||||
|
||||
renderHook(() =>
|
||||
useMetricYAxisUnit({ isNewPanel: true, unit: undefined, onSelectUnit }),
|
||||
);
|
||||
|
||||
expect(onSelectUnit).toHaveBeenCalledWith('bytes');
|
||||
});
|
||||
|
||||
it('does not seed when not a new panel', () => {
|
||||
mockMetricUnit('bytes');
|
||||
const onSelectUnit = jest.fn();
|
||||
|
||||
renderHook(() =>
|
||||
useMetricYAxisUnit({ isNewPanel: false, unit: undefined, onSelectUnit }),
|
||||
);
|
||||
|
||||
expect(onSelectUnit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not seed when the metric has no unit', () => {
|
||||
mockMetricUnit(undefined);
|
||||
const onSelectUnit = jest.fn();
|
||||
|
||||
renderHook(() =>
|
||||
useMetricYAxisUnit({ isNewPanel: true, unit: undefined, onSelectUnit }),
|
||||
);
|
||||
|
||||
expect(onSelectUnit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not seed when the unit already matches the metric', () => {
|
||||
mockMetricUnit('bytes');
|
||||
const onSelectUnit = jest.fn();
|
||||
|
||||
renderHook(() =>
|
||||
useMetricYAxisUnit({ isNewPanel: true, unit: 'bytes', onSelectUnit }),
|
||||
);
|
||||
|
||||
expect(onSelectUnit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('re-seeds when the resolved metric unit changes', () => {
|
||||
mockMetricUnit('bytes');
|
||||
const onSelectUnit = jest.fn();
|
||||
|
||||
const { rerender } = renderHook(
|
||||
(props: { unit: string | undefined }) =>
|
||||
useMetricYAxisUnit({
|
||||
isNewPanel: true,
|
||||
unit: props.unit,
|
||||
onSelectUnit,
|
||||
}),
|
||||
{ initialProps: { unit: undefined as string | undefined } },
|
||||
);
|
||||
expect(onSelectUnit).toHaveBeenLastCalledWith('bytes');
|
||||
|
||||
// The metric changes; the panel now holds the previously-seeded unit.
|
||||
mockMetricUnit('ms');
|
||||
rerender({ unit: 'bytes' });
|
||||
|
||||
expect(onSelectUnit).toHaveBeenLastCalledWith('ms');
|
||||
});
|
||||
|
||||
it('returns the resolved metric unit and loading state', () => {
|
||||
mockMetricUnit('bytes', true);
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useMetricYAxisUnit({
|
||||
isNewPanel: false,
|
||||
unit: undefined,
|
||||
onSelectUnit: jest.fn(),
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result.current.metricUnit).toBe('bytes');
|
||||
expect(result.current.isLoading).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -24,6 +24,8 @@ jest.mock('api/generated/services/dashboard', () => ({
|
||||
getGetDashboardV2QueryKey: jest.fn(() => ['/api/v2/dashboards/dash-1']),
|
||||
}));
|
||||
|
||||
jest.mock('uuid', () => ({ v4: (): string => 'minted-panel-id' }));
|
||||
|
||||
describe('usePanelEditorSave', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
@@ -44,7 +46,7 @@ describe('usePanelEditorSave', () => {
|
||||
queries: [],
|
||||
} as unknown as DashboardtypesPanelSpecDTO;
|
||||
|
||||
await result.current.save(spec);
|
||||
const savedPanelId = await result.current.save(spec);
|
||||
|
||||
expect(mockPatchAsync).toHaveBeenCalledWith([
|
||||
{
|
||||
@@ -53,6 +55,25 @@ describe('usePanelEditorSave', () => {
|
||||
value: spec,
|
||||
},
|
||||
]);
|
||||
// Editing resolves with the panel's own id.
|
||||
expect(savedPanelId).toBe('panel-9');
|
||||
});
|
||||
|
||||
it('mints and resolves with a fresh id when creating a new panel', async () => {
|
||||
const { result } = renderHook(() =>
|
||||
usePanelEditorSave({ dashboardId: 'dash-1', panelId: 'new', isNew: true }),
|
||||
);
|
||||
|
||||
const spec = {
|
||||
display: { name: 'New panel' },
|
||||
plugin: { kind: 'signoz/TimeSeriesPanel', spec: {} },
|
||||
queries: [],
|
||||
} as unknown as DashboardtypesPanelSpecDTO;
|
||||
|
||||
const savedPanelId = await result.current.save(spec);
|
||||
|
||||
expect(savedPanelId).toBe('minted-panel-id');
|
||||
expect(mockPatchAsync).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('surfaces the patch in-flight state as isSaving', () => {
|
||||
|
||||
@@ -0,0 +1,265 @@
|
||||
import { renderHook } from '@testing-library/react';
|
||||
import type { DashboardtypesPanelSpecDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import useGetYAxisUnit from 'hooks/useGetYAxisUnit';
|
||||
|
||||
import type { TableColumnOption } from '../useTableColumns';
|
||||
import { useSeedMetricUnit } from '../useSeedMetricUnit';
|
||||
|
||||
jest.mock('hooks/useGetYAxisUnit', () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn(),
|
||||
}));
|
||||
|
||||
const mockUseGetYAxisUnit = useGetYAxisUnit as unknown as jest.Mock;
|
||||
|
||||
function mockMetricUnit(
|
||||
yAxisUnit: string | undefined,
|
||||
isLoading = false,
|
||||
): void {
|
||||
mockUseGetYAxisUnit.mockReturnValue({ yAxisUnit, isLoading, isError: false });
|
||||
}
|
||||
|
||||
function makeSpec(formatting?: unknown): DashboardtypesPanelSpecDTO {
|
||||
return {
|
||||
plugin: {
|
||||
kind: 'signoz/TimeSeriesPanel',
|
||||
spec: formatting ? { formatting } : {},
|
||||
},
|
||||
} as unknown as DashboardtypesPanelSpecDTO;
|
||||
}
|
||||
|
||||
function unit(spec: DashboardtypesPanelSpecDTO): unknown {
|
||||
return (spec.plugin.spec as { formatting?: { unit?: unknown } }).formatting
|
||||
?.unit;
|
||||
}
|
||||
|
||||
function columnUnits(spec: DashboardtypesPanelSpecDTO): unknown {
|
||||
return (spec.plugin.spec as { formatting?: { columnUnits?: unknown } })
|
||||
.formatting?.columnUnits;
|
||||
}
|
||||
|
||||
const COLUMNS: TableColumnOption[] = [
|
||||
{ key: 'A', label: 'A' },
|
||||
{ key: 'B', label: 'B' },
|
||||
];
|
||||
|
||||
const NO_COLUMNS: TableColumnOption[] = [];
|
||||
|
||||
describe('useSeedMetricUnit', () => {
|
||||
beforeEach(() => jest.clearAllMocks());
|
||||
|
||||
describe('panel-wide unit (controls.unit)', () => {
|
||||
it('seeds formatting.unit from the metric on a new panel', () => {
|
||||
mockMetricUnit('bytes');
|
||||
const onChangeSpec = jest.fn();
|
||||
|
||||
renderHook(() =>
|
||||
useSeedMetricUnit({
|
||||
isNewPanel: true,
|
||||
formattingControls: { unit: true },
|
||||
columns: NO_COLUMNS,
|
||||
spec: makeSpec(),
|
||||
onChangeSpec,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(unit(onChangeSpec.mock.calls[0][0])).toBe('bytes');
|
||||
});
|
||||
|
||||
it('does not seed when not a new panel', () => {
|
||||
mockMetricUnit('bytes');
|
||||
const onChangeSpec = jest.fn();
|
||||
|
||||
renderHook(() =>
|
||||
useSeedMetricUnit({
|
||||
isNewPanel: false,
|
||||
formattingControls: { unit: true },
|
||||
columns: NO_COLUMNS,
|
||||
spec: makeSpec(),
|
||||
onChangeSpec,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(onChangeSpec).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not seed when the unit already matches the metric', () => {
|
||||
mockMetricUnit('bytes');
|
||||
const onChangeSpec = jest.fn();
|
||||
|
||||
renderHook(() =>
|
||||
useSeedMetricUnit({
|
||||
isNewPanel: true,
|
||||
formattingControls: { unit: true },
|
||||
columns: NO_COLUMNS,
|
||||
spec: makeSpec({ unit: 'bytes' }),
|
||||
onChangeSpec,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(onChangeSpec).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('re-seeds when the resolved metric unit changes', () => {
|
||||
mockMetricUnit('bytes');
|
||||
const onChangeSpec = jest.fn();
|
||||
|
||||
const { rerender } = renderHook(
|
||||
(props: { spec: DashboardtypesPanelSpecDTO }) =>
|
||||
useSeedMetricUnit({
|
||||
isNewPanel: true,
|
||||
formattingControls: { unit: true },
|
||||
columns: NO_COLUMNS,
|
||||
spec: props.spec,
|
||||
onChangeSpec,
|
||||
}),
|
||||
{ initialProps: { spec: makeSpec() } },
|
||||
);
|
||||
expect(unit(onChangeSpec.mock.calls[0][0])).toBe('bytes');
|
||||
|
||||
// Metric changes; the panel now holds the previously-seeded unit.
|
||||
mockMetricUnit('ms');
|
||||
rerender({ spec: makeSpec({ unit: 'bytes' }) });
|
||||
|
||||
expect(unit(onChangeSpec.mock.calls[1][0])).toBe('ms');
|
||||
});
|
||||
});
|
||||
|
||||
describe('per-column units (controls.columnUnits)', () => {
|
||||
it('seeds every value column with the metric unit once columns resolve', () => {
|
||||
mockMetricUnit('bytes');
|
||||
const onChangeSpec = jest.fn();
|
||||
|
||||
const { rerender } = renderHook(
|
||||
(props: { columns: TableColumnOption[] }) =>
|
||||
useSeedMetricUnit({
|
||||
isNewPanel: true,
|
||||
formattingControls: { columnUnits: true },
|
||||
columns: props.columns,
|
||||
spec: makeSpec(),
|
||||
onChangeSpec,
|
||||
}),
|
||||
{ initialProps: { columns: NO_COLUMNS } },
|
||||
);
|
||||
// Waits for results to resolve the columns.
|
||||
expect(onChangeSpec).not.toHaveBeenCalled();
|
||||
|
||||
rerender({ columns: COLUMNS });
|
||||
expect(columnUnits(onChangeSpec.mock.calls[0][0])).toStrictEqual({
|
||||
A: 'bytes',
|
||||
B: 'bytes',
|
||||
});
|
||||
});
|
||||
|
||||
it('never writes formatting.unit for a Table', () => {
|
||||
mockMetricUnit('bytes');
|
||||
const onChangeSpec = jest.fn();
|
||||
|
||||
renderHook(() =>
|
||||
useSeedMetricUnit({
|
||||
isNewPanel: true,
|
||||
formattingControls: { columnUnits: true, decimals: true },
|
||||
columns: COLUMNS,
|
||||
spec: makeSpec(),
|
||||
onChangeSpec,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(unit(onChangeSpec.mock.calls[0][0])).toBeUndefined();
|
||||
});
|
||||
|
||||
it('only fills columns without a unit yet, keeping the user-set one', () => {
|
||||
mockMetricUnit('bytes');
|
||||
const onChangeSpec = jest.fn();
|
||||
|
||||
renderHook(() =>
|
||||
useSeedMetricUnit({
|
||||
isNewPanel: true,
|
||||
formattingControls: { columnUnits: true },
|
||||
columns: COLUMNS,
|
||||
spec: makeSpec({ columnUnits: { A: 'ms' } }),
|
||||
onChangeSpec,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(columnUnits(onChangeSpec.mock.calls[0][0])).toStrictEqual({
|
||||
A: 'ms',
|
||||
B: 'bytes',
|
||||
});
|
||||
});
|
||||
|
||||
it('does not write when every column already has a unit', () => {
|
||||
mockMetricUnit('bytes');
|
||||
const onChangeSpec = jest.fn();
|
||||
|
||||
renderHook(() =>
|
||||
useSeedMetricUnit({
|
||||
isNewPanel: true,
|
||||
formattingControls: { columnUnits: true },
|
||||
columns: COLUMNS,
|
||||
spec: makeSpec({ columnUnits: { A: 'ms', B: 's' } }),
|
||||
onChangeSpec,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(onChangeSpec).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('seeds once and does not re-run after the metric unit changes', () => {
|
||||
mockMetricUnit('bytes');
|
||||
const onChangeSpec = jest.fn();
|
||||
|
||||
const { rerender } = renderHook(
|
||||
(props: { metric: string }) => {
|
||||
mockMetricUnit(props.metric);
|
||||
return useSeedMetricUnit({
|
||||
isNewPanel: true,
|
||||
formattingControls: { columnUnits: true },
|
||||
columns: COLUMNS,
|
||||
spec: makeSpec(),
|
||||
onChangeSpec,
|
||||
});
|
||||
},
|
||||
{ initialProps: { metric: 'bytes' } },
|
||||
);
|
||||
expect(onChangeSpec).toHaveBeenCalledTimes(1);
|
||||
|
||||
rerender({ metric: 'ms' });
|
||||
expect(onChangeSpec).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
it('seeds nothing when the kind has no unit control (Histogram/List)', () => {
|
||||
mockMetricUnit('bytes');
|
||||
const onChangeSpec = jest.fn();
|
||||
|
||||
renderHook(() =>
|
||||
useSeedMetricUnit({
|
||||
isNewPanel: true,
|
||||
formattingControls: undefined,
|
||||
columns: COLUMNS,
|
||||
spec: makeSpec(),
|
||||
onChangeSpec,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(onChangeSpec).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns the resolved metric unit and loading state', () => {
|
||||
mockMetricUnit('bytes', true);
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useSeedMetricUnit({
|
||||
isNewPanel: false,
|
||||
formattingControls: { unit: true },
|
||||
columns: NO_COLUMNS,
|
||||
spec: makeSpec(),
|
||||
onChangeSpec: jest.fn(),
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result.current.metricUnit).toBe('bytes');
|
||||
expect(result.current.isLoading).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -1,36 +0,0 @@
|
||||
import { useEffect } from 'react';
|
||||
import useGetYAxisUnit from 'hooks/useGetYAxisUnit';
|
||||
|
||||
interface UseMetricYAxisUnitArgs {
|
||||
/** Only a new panel auto-seeds; editing never overwrites the saved unit. */
|
||||
isNewPanel: boolean;
|
||||
unit: string | undefined;
|
||||
onSelectUnit: (unit: string) => void;
|
||||
}
|
||||
|
||||
interface UseMetricYAxisUnitResult {
|
||||
metricUnit: string | undefined;
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the selected metric's unit and, on a new panel only, seeds the formatting unit
|
||||
* from it (V1 parity); returns the unit for the selector's mismatch warning.
|
||||
*/
|
||||
export function useMetricYAxisUnit({
|
||||
isNewPanel,
|
||||
unit,
|
||||
onSelectUnit,
|
||||
}: UseMetricYAxisUnitArgs): UseMetricYAxisUnitResult {
|
||||
const { yAxisUnit: metricUnit, isLoading } = useGetYAxisUnit();
|
||||
|
||||
useEffect(() => {
|
||||
if (isNewPanel && metricUnit && metricUnit !== unit) {
|
||||
onSelectUnit(metricUnit);
|
||||
}
|
||||
// Re-seed only when the resolved metric unit changes, not on every unit edit.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [isNewPanel, metricUnit]);
|
||||
|
||||
return { metricUnit, isLoading };
|
||||
}
|
||||
@@ -23,7 +23,8 @@ interface UsePanelEditorSaveArgs {
|
||||
}
|
||||
|
||||
interface UsePanelEditorSaveApi {
|
||||
save: (spec: DashboardtypesPanelSpecDTO) => Promise<void>;
|
||||
/** Resolves with the saved panel's id (freshly minted when creating). */
|
||||
save: (spec: DashboardtypesPanelSpecDTO) => Promise<string>;
|
||||
isSaving: boolean;
|
||||
error: Error | null;
|
||||
}
|
||||
@@ -44,17 +45,20 @@ export function usePanelEditorSave({
|
||||
const { patchAsync, isPatching, error } = useOptimisticPatch(dashboardId);
|
||||
|
||||
const save = useCallback(
|
||||
async (spec: DashboardtypesPanelSpecDTO): Promise<void> => {
|
||||
async (spec: DashboardtypesPanelSpecDTO): Promise<string> => {
|
||||
let ops: DashboardtypesJSONPatchOperationDTO[];
|
||||
// The id a new panel is persisted under (surfaced so the caller can reveal it).
|
||||
let savedPanelId = panelId;
|
||||
if (isNew) {
|
||||
// Resolve the target section against the freshest dashboard we have.
|
||||
const dashboardQueryKey = getGetDashboardV2QueryKey({ id: dashboardId });
|
||||
const cached =
|
||||
queryClient.getQueryData<GetDashboardV2200>(dashboardQueryKey);
|
||||
savedPanelId = uuid();
|
||||
ops = createPanelOps({
|
||||
layouts: cached?.data.spec.layouts ?? [],
|
||||
layoutIndex,
|
||||
panelId: uuid(),
|
||||
panelId: savedPanelId,
|
||||
panel: { kind: DashboardtypesPanelKindDTO.Panel, spec },
|
||||
});
|
||||
} else {
|
||||
@@ -69,6 +73,7 @@ export function usePanelEditorSave({
|
||||
|
||||
// Optimistic cache write + settle refetch (replaces the manual invalidate).
|
||||
await patchAsync(ops);
|
||||
return savedPanelId;
|
||||
},
|
||||
[dashboardId, panelId, isNew, layoutIndex, patchAsync, queryClient],
|
||||
);
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import type { DashboardtypesPanelSpecDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import useGetYAxisUnit from 'hooks/useGetYAxisUnit';
|
||||
import type {
|
||||
SectionControls,
|
||||
SectionKind,
|
||||
} from 'pages/DashboardPageV2/DashboardContainer/Panels/types/sections';
|
||||
|
||||
import { readFormatting, writeFormatting } from '../utils/formattingSpec';
|
||||
import type { TableColumnOption } from './useTableColumns';
|
||||
|
||||
type FormattingControls = SectionControls[SectionKind.Formatting];
|
||||
|
||||
interface UseSeedMetricUnitArgs {
|
||||
/** Only a new panel auto-seeds; editing never overwrites a saved unit. */
|
||||
isNewPanel: boolean;
|
||||
/**
|
||||
* The current kind's Formatting controls — the single source of truth for which
|
||||
* field a metric unit seeds into: `unit` (panel-wide) vs `columnUnits` (Table).
|
||||
* A kind with neither (Histogram/List) seeds nothing.
|
||||
*/
|
||||
formattingControls: FormattingControls | undefined;
|
||||
/** Resolved value columns (Table only; empty before results arrive / for other kinds). */
|
||||
columns: TableColumnOption[];
|
||||
spec: DashboardtypesPanelSpecDTO;
|
||||
onChangeSpec: (next: DashboardtypesPanelSpecDTO) => void;
|
||||
}
|
||||
|
||||
interface UseSeedMetricUnitResult {
|
||||
metricUnit: string | undefined;
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the selected metric's unit and, on a new panel only, seeds it into the panel's
|
||||
* formatting — into `formatting.unit` for kinds with a panel-wide unit control, or into
|
||||
* `formatting.columnUnits` (per value column) for a Table, which has no panel-wide unit.
|
||||
* The kind's Formatting `controls` decide which applies, mirroring `buildPluginSpec`'s
|
||||
* switch-time seeding so the two never diverge. Returns the unit for the FormattingSection's
|
||||
* mismatch warning.
|
||||
*/
|
||||
export function useSeedMetricUnit({
|
||||
isNewPanel,
|
||||
formattingControls,
|
||||
columns,
|
||||
spec,
|
||||
onChangeSpec,
|
||||
}: UseSeedMetricUnitArgs): UseSeedMetricUnitResult {
|
||||
const { yAxisUnit: metricUnit, isLoading } = useGetYAxisUnit();
|
||||
|
||||
const seedsUnit = isNewPanel && !!formattingControls?.unit;
|
||||
const seedsColumnUnits = isNewPanel && !!formattingControls?.columnUnits;
|
||||
|
||||
// Panel-wide unit: seed (and re-seed) whenever the resolved metric unit changes. Kept
|
||||
// off `spec` so a manual unit edit doesn't re-run this and fight the user.
|
||||
useEffect(() => {
|
||||
if (!seedsUnit || !metricUnit || metricUnit === readFormatting(spec)?.unit) {
|
||||
return;
|
||||
}
|
||||
onChangeSpec(writeFormatting(spec, { unit: metricUnit }));
|
||||
// Re-seed only when the resolved metric unit changes, not on every unit edit.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [seedsUnit, metricUnit]);
|
||||
|
||||
// Per-column units (Table): seed once, only for columns without a unit yet, so it
|
||||
// never clobbers a user's edit or a cleared column. Waits for results to resolve them.
|
||||
const seededColumnsRef = useRef(false);
|
||||
useEffect(() => {
|
||||
if (
|
||||
!seedsColumnUnits ||
|
||||
seededColumnsRef.current ||
|
||||
!metricUnit ||
|
||||
columns.length === 0
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const columnUnits = readFormatting(spec)?.columnUnits ?? {};
|
||||
const unset = columns.filter(
|
||||
(column) => columnUnits[column.key] === undefined,
|
||||
);
|
||||
seededColumnsRef.current = true;
|
||||
if (unset.length === 0) {
|
||||
return;
|
||||
}
|
||||
const nextColumnUnits = { ...columnUnits };
|
||||
unset.forEach((column) => {
|
||||
nextColumnUnits[column.key] = metricUnit;
|
||||
});
|
||||
onChangeSpec(writeFormatting(spec, { columnUnits: nextColumnUnits }));
|
||||
// Seed once columns first resolve with a unit; not on later spec edits.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [seedsColumnUnits, metricUnit, columns]);
|
||||
|
||||
return { metricUnit, isLoading };
|
||||
}
|
||||
@@ -8,25 +8,29 @@ import {
|
||||
import { toast } from '@signozhq/ui/sonner';
|
||||
import {
|
||||
type DashboardtypesPanelDTO,
|
||||
type DashboardtypesPanelFormattingDTO,
|
||||
type DashboardtypesPanelSpecDTO,
|
||||
TelemetrytypesSignalDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { PANEL_KIND_TO_PANEL_TYPE } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/panelKind';
|
||||
import {
|
||||
type SectionConfig,
|
||||
type SectionControls,
|
||||
SectionKind,
|
||||
} from 'pages/DashboardPageV2/DashboardContainer/Panels/types/sections';
|
||||
import { getBuilderQueries } from 'pages/DashboardPageV2/DashboardContainer/Panels/utils/getBuilderQueries';
|
||||
|
||||
import { getExecStats } from '../queryV5/v5ResponseData';
|
||||
import { usePanelInteractions } from '../PanelsAndSectionsLayout/Panel/hooks/usePanelInteractions';
|
||||
import { useScrollToPanelStore } from '../store/useScrollToPanelStore';
|
||||
import ConfigPane from './ConfigPane/ConfigPane';
|
||||
import Header from './Header/Header';
|
||||
import layoutStorage from './layoutStorage';
|
||||
import PanelEditorQueryBuilder from './PanelEditorQueryBuilder/PanelEditorQueryBuilder';
|
||||
import PreviewPane from './PreviewPane/PreviewPane';
|
||||
import { useLegendSeries } from './hooks/useLegendSeries';
|
||||
import { useMetricYAxisUnit } from './hooks/useMetricYAxisUnit';
|
||||
import { usePanelEditSession } from './hooks/usePanelEditSession';
|
||||
import { usePanelEditorSave } from './hooks/usePanelEditorSave';
|
||||
import { useSeedMetricUnit } from './hooks/useSeedMetricUnit';
|
||||
import { useSeedNewListColumns } from './hooks/useSeedNewListColumns';
|
||||
import { useSwitchColumnsOnSignalChange } from './hooks/useSwitchColumnsOnSignalChange';
|
||||
import { useSwitchToViewMode } from './hooks/useSwitchToViewMode';
|
||||
@@ -124,32 +128,20 @@ function PanelEditorContainer({
|
||||
|
||||
const panelKind = draft.spec.plugin.kind;
|
||||
|
||||
// At editor level, not the collapsible FormattingSection, so seeding runs while closed.
|
||||
const formattingUnit = (
|
||||
spec.plugin.spec as {
|
||||
formatting?: DashboardtypesPanelFormattingDTO;
|
||||
}
|
||||
).formatting?.unit;
|
||||
const seedFormattingUnit = useCallback(
|
||||
(unit: string): void => {
|
||||
const pluginSpec = spec.plugin.spec as {
|
||||
formatting?: DashboardtypesPanelFormattingDTO;
|
||||
};
|
||||
setSpec({
|
||||
...spec,
|
||||
plugin: {
|
||||
...spec.plugin,
|
||||
spec: { ...pluginSpec, formatting: { ...pluginSpec.formatting, unit } },
|
||||
},
|
||||
} as DashboardtypesPanelSpecDTO);
|
||||
},
|
||||
[spec, setSpec],
|
||||
);
|
||||
const { metricUnit } = useMetricYAxisUnit({
|
||||
isNewPanel: isNew,
|
||||
unit: formattingUnit,
|
||||
onSelectUnit: seedFormattingUnit,
|
||||
});
|
||||
// The current kind's Formatting controls — which unit field (panel-wide `unit` vs
|
||||
// per-column `columnUnits`) a metric unit may seed into. Same source of truth the
|
||||
// switch-time seeding in `buildPluginSpec` reads, so the two stay in lockstep.
|
||||
const formattingControls = useMemo(():
|
||||
| SectionControls[SectionKind.Formatting]
|
||||
| undefined => {
|
||||
const section = panelDefinition.sections.find(
|
||||
(
|
||||
candidate,
|
||||
): candidate is Extract<SectionConfig, { kind: SectionKind.Formatting }> =>
|
||||
candidate.kind === SectionKind.Formatting,
|
||||
);
|
||||
return section?.controls;
|
||||
}, [panelDefinition]);
|
||||
|
||||
// A new panel is savable once it has a query to run — List auto-seeds one; other
|
||||
// kinds open query-less, so there's nothing to save until the user builds one.
|
||||
@@ -188,6 +180,17 @@ function PanelEditorContainer({
|
||||
const legendSeries = useLegendSeries(draft, data);
|
||||
const tableColumns = useTableColumns(draft, data);
|
||||
|
||||
// Resolves the selected metric's unit and, on a new panel, seeds it into the right
|
||||
// formatting field for the kind (panel-wide `unit`, or per-column `columnUnits` for
|
||||
// a Table once results resolve them). `metricUnit` also drives the mismatch warning.
|
||||
const { metricUnit } = useSeedMetricUnit({
|
||||
isNewPanel: isNew,
|
||||
formattingControls,
|
||||
columns: tableColumns,
|
||||
spec,
|
||||
onChangeSpec: setSpec,
|
||||
});
|
||||
|
||||
// Smallest query step interval (seconds) — the floor for the span-gaps
|
||||
// threshold. Undefined until results carry step metadata.
|
||||
const stepInterval = useMemo((): number | undefined => {
|
||||
@@ -203,19 +206,33 @@ function PanelEditorContainer({
|
||||
query: currentQuery,
|
||||
});
|
||||
|
||||
const setScrollToPanelId = useScrollToPanelStore((s) => s.setScrollToPanelId);
|
||||
|
||||
const onSave = useCallback(async (): Promise<void> => {
|
||||
if (!isEditable) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
// Bake the live query into the spec so unstaged edits are saved too.
|
||||
await save(buildSaveSpec(draft.spec));
|
||||
const savedPanelId = await save(buildSaveSpec(draft.spec));
|
||||
toast.success('Panel saved');
|
||||
// Reveal the saved panel once the dashboard re-renders.
|
||||
setScrollToPanelId(savedPanelId);
|
||||
onSaved();
|
||||
} catch {
|
||||
toast.error('Failed to save panel');
|
||||
}
|
||||
}, [isEditable, save, buildSaveSpec, draft.spec, onSaved]);
|
||||
}, [isEditable, save, buildSaveSpec, draft.spec, setScrollToPanelId, onSaved]);
|
||||
|
||||
// Leaving an existing panel's editor (without saving) still returns to it, so
|
||||
// the dashboard lands on that panel rather than scrolled to the top. A new,
|
||||
// unsaved panel has no persisted target, so there's nothing to reveal.
|
||||
const onCloseEditor = useCallback((): void => {
|
||||
if (!isNew) {
|
||||
setScrollToPanelId(panelId);
|
||||
}
|
||||
onClose();
|
||||
}, [isNew, panelId, setScrollToPanelId, onClose]);
|
||||
|
||||
return (
|
||||
<div className={styles.page} data-testid="panel-editor-v2">
|
||||
@@ -227,7 +244,7 @@ function PanelEditorContainer({
|
||||
readOnlyReason={editDisabledReason}
|
||||
onSave={onSave}
|
||||
onSwitchToView={onSwitchToView}
|
||||
onClose={onClose}
|
||||
onClose={onCloseEditor}
|
||||
/>
|
||||
<ResizablePanelGroup
|
||||
id="panel-editor-v2"
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import type { DashboardtypesPanelSpecDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import { readFormatting, writeFormatting } from '../formattingSpec';
|
||||
|
||||
function makeSpec(formatting?: unknown): DashboardtypesPanelSpecDTO {
|
||||
return {
|
||||
plugin: {
|
||||
kind: 'signoz/TimeSeriesPanel',
|
||||
spec: formatting ? { formatting } : {},
|
||||
},
|
||||
} as unknown as DashboardtypesPanelSpecDTO;
|
||||
}
|
||||
|
||||
describe('formattingSpec', () => {
|
||||
it('reads the formatting slice (undefined when absent)', () => {
|
||||
expect(readFormatting(makeSpec())).toBeUndefined();
|
||||
expect(readFormatting(makeSpec({ unit: 'bytes' }))).toStrictEqual({
|
||||
unit: 'bytes',
|
||||
});
|
||||
});
|
||||
|
||||
it('merges the patch into the formatting slice, preserving other fields', () => {
|
||||
const next = writeFormatting(makeSpec({ decimalPrecision: '2' }), {
|
||||
unit: 'bytes',
|
||||
});
|
||||
expect(readFormatting(next)).toStrictEqual({
|
||||
decimalPrecision: '2',
|
||||
unit: 'bytes',
|
||||
});
|
||||
});
|
||||
|
||||
it('does not mutate the input spec', () => {
|
||||
const spec = makeSpec({ unit: 'ms' });
|
||||
writeFormatting(spec, { unit: 'bytes' });
|
||||
expect(readFormatting(spec)).toStrictEqual({ unit: 'ms' });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,27 @@
|
||||
import type { DashboardtypesPanelSpecDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import type { PanelFormattingSlice } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/sections';
|
||||
|
||||
// `spec.plugin.spec` is a discriminated union over panel kinds; these helpers narrow
|
||||
// to the shared `formatting` slice via a single localized cast at the boundary, so
|
||||
// callers read/write it without repeating the spread.
|
||||
|
||||
export function readFormatting(
|
||||
spec: DashboardtypesPanelSpecDTO,
|
||||
): PanelFormattingSlice | undefined {
|
||||
return (spec.plugin.spec as { formatting?: PanelFormattingSlice }).formatting;
|
||||
}
|
||||
|
||||
/** Merges a partial formatting patch into the panel's `formatting` slice. */
|
||||
export function writeFormatting(
|
||||
spec: DashboardtypesPanelSpecDTO,
|
||||
patch: Partial<PanelFormattingSlice>,
|
||||
): DashboardtypesPanelSpecDTO {
|
||||
const pluginSpec = spec.plugin.spec as { formatting?: PanelFormattingSlice };
|
||||
return {
|
||||
...spec,
|
||||
plugin: {
|
||||
...spec.plugin,
|
||||
spec: { ...pluginSpec, formatting: { ...pluginSpec.formatting, ...patch } },
|
||||
},
|
||||
} as DashboardtypesPanelSpecDTO;
|
||||
}
|
||||
@@ -47,9 +47,8 @@ describe('buildPluginSpec', () => {
|
||||
expect(buildPluginSpec([])).toStrictEqual({});
|
||||
});
|
||||
|
||||
it('seeds nothing for sections with no seed (Axes, Buckets, ContextLinks)', () => {
|
||||
it('seeds nothing for sections with no seed (Buckets, ContextLinks)', () => {
|
||||
const sections: SectionConfig[] = [
|
||||
{ kind: SectionKind.Axes, controls: { minMax: true, logScale: true } },
|
||||
{ kind: SectionKind.Buckets, controls: { count: true, width: true } },
|
||||
{ kind: SectionKind.ContextLinks },
|
||||
];
|
||||
@@ -112,6 +111,108 @@ describe('buildPluginSpec', () => {
|
||||
];
|
||||
expect(buildPluginSpec(sections)).toStrictEqual({});
|
||||
});
|
||||
|
||||
it('carries old timePreference / stacking / fillSpans the target declares', () => {
|
||||
const sections: SectionConfig[] = [
|
||||
{
|
||||
kind: SectionKind.Visualization,
|
||||
controls: {
|
||||
switchPanelKind: true,
|
||||
timePreference: true,
|
||||
stacking: true,
|
||||
fillSpans: true,
|
||||
},
|
||||
},
|
||||
];
|
||||
const oldSpec = oldSpecWith({
|
||||
visualization: {
|
||||
timePreference: DashboardtypesTimePreferenceDTO.last_6_hr,
|
||||
stackedBarChart: true,
|
||||
fillSpans: true,
|
||||
},
|
||||
});
|
||||
|
||||
expect(buildPluginSpec(sections, { oldSpec }).visualization).toStrictEqual({
|
||||
timePreference: DashboardtypesTimePreferenceDTO.last_6_hr,
|
||||
stackedBarChart: true,
|
||||
fillSpans: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('drops visualization fields the target does not declare (Bar → TimeSeries)', () => {
|
||||
// TimeSeries has no stacking control, so Bar's stackedBarChart must not carry.
|
||||
const sections: SectionConfig[] = [
|
||||
{
|
||||
kind: SectionKind.Visualization,
|
||||
controls: { switchPanelKind: true, timePreference: true, fillSpans: true },
|
||||
},
|
||||
];
|
||||
const oldSpec = oldSpecWith({
|
||||
visualization: { stackedBarChart: true },
|
||||
});
|
||||
|
||||
expect(buildPluginSpec(sections, { oldSpec }).visualization).toStrictEqual({
|
||||
timePreference: DashboardtypesTimePreferenceDTO.global_time,
|
||||
});
|
||||
});
|
||||
|
||||
it('carries old legend position but never customColors', () => {
|
||||
const sections: SectionConfig[] = [
|
||||
{ kind: SectionKind.Legend, controls: { position: true, colors: true } },
|
||||
];
|
||||
const oldSpec = oldSpecWith({
|
||||
legend: {
|
||||
position: DashboardtypesLegendPositionDTO.right,
|
||||
customColors: { 'series-a': '#F1575F' },
|
||||
},
|
||||
});
|
||||
|
||||
expect(buildPluginSpec(sections, { oldSpec }).legend).toStrictEqual({
|
||||
position: DashboardtypesLegendPositionDTO.right,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('axes seed (carry, gated by controls)', () => {
|
||||
it('carries softMin/softMax/isLogScale when the kind declares both controls', () => {
|
||||
const sections: SectionConfig[] = [
|
||||
{ kind: SectionKind.Axes, controls: { minMax: true, logScale: true } },
|
||||
];
|
||||
const oldSpec = oldSpecWith({
|
||||
axes: { softMin: 0, softMax: 100, isLogScale: true },
|
||||
});
|
||||
|
||||
expect(buildPluginSpec(sections, { oldSpec }).axes).toStrictEqual({
|
||||
softMin: 0,
|
||||
softMax: 100,
|
||||
isLogScale: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('carries only the fields the target controls declare', () => {
|
||||
const sections: SectionConfig[] = [
|
||||
{ kind: SectionKind.Axes, controls: { logScale: true } },
|
||||
];
|
||||
const oldSpec = oldSpecWith({
|
||||
axes: { softMin: 0, softMax: 100, isLogScale: true },
|
||||
});
|
||||
|
||||
expect(buildPluginSpec(sections, { oldSpec }).axes).toStrictEqual({
|
||||
isLogScale: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('skips null soft bounds and seeds nothing on a new panel or empty axes', () => {
|
||||
const sections: SectionConfig[] = [
|
||||
{ kind: SectionKind.Axes, controls: { minMax: true, logScale: true } },
|
||||
];
|
||||
expect(buildPluginSpec(sections)).toStrictEqual({});
|
||||
expect(
|
||||
buildPluginSpec(sections, {
|
||||
oldSpec: oldSpecWith({ axes: { softMin: null, softMax: null } }),
|
||||
}),
|
||||
).toStrictEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
describe('chartAppearance seed', () => {
|
||||
@@ -137,6 +238,30 @@ describe('buildPluginSpec', () => {
|
||||
];
|
||||
expect(buildPluginSpec(sections)).toStrictEqual({});
|
||||
});
|
||||
|
||||
it('carries old values over the defaults, gated by the declared controls', () => {
|
||||
const sections: SectionConfig[] = [
|
||||
{
|
||||
kind: SectionKind.ChartAppearance,
|
||||
controls: { lineStyle: true, lineInterpolation: true, showPoints: true },
|
||||
},
|
||||
];
|
||||
const oldSpec = oldSpecWith({
|
||||
chartAppearance: {
|
||||
lineStyle: DashboardtypesLineStyleDTO.dashed,
|
||||
fillMode: DashboardtypesFillModeDTO.gradient,
|
||||
showPoints: false,
|
||||
},
|
||||
});
|
||||
|
||||
expect(buildPluginSpec(sections, { oldSpec }).chartAppearance).toStrictEqual(
|
||||
{
|
||||
lineStyle: DashboardtypesLineStyleDTO.dashed,
|
||||
lineInterpolation: DashboardtypesLineInterpolationDTO.spline,
|
||||
showPoints: false,
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatting seed (carry, gated by controls)', () => {
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
/** Cross-section of the per-kind spec union; assigned to `plugin.spec` (unknown) at the boundary. */
|
||||
export interface SeededPluginSpec {
|
||||
visualization?: SectionSpecMap[SectionKind.Visualization];
|
||||
axes?: SectionSpecMap[SectionKind.Axes];
|
||||
legend?: SectionSpecMap[SectionKind.Legend];
|
||||
chartAppearance?: SectionSpecMap[SectionKind.ChartAppearance];
|
||||
formatting?: Pick<PanelFormattingSlice, 'unit' | 'decimalPrecision'>;
|
||||
@@ -90,35 +91,103 @@ interface SectionSeed {
|
||||
const SECTION_SEEDS: Partial<Record<SectionKind, SectionSeed>> = {
|
||||
[SectionKind.Visualization]: {
|
||||
specKey: 'visualization',
|
||||
seed: (controls): SectionSpecMap[SectionKind.Visualization] | undefined => {
|
||||
seed: (
|
||||
controls,
|
||||
{ oldSpec },
|
||||
): SectionSpecMap[SectionKind.Visualization] | undefined => {
|
||||
const c = controls as SectionControls[SectionKind.Visualization];
|
||||
return c.timePreference
|
||||
? { timePreference: DashboardtypesTimePreferenceDTO.global_time }
|
||||
: undefined;
|
||||
const old = (
|
||||
oldSpec?.plugin.spec as {
|
||||
visualization?: SectionSpecMap[SectionKind.Visualization];
|
||||
}
|
||||
)?.visualization;
|
||||
const visualization: SectionSpecMap[SectionKind.Visualization] = {
|
||||
...(c.timePreference && {
|
||||
timePreference:
|
||||
old?.timePreference ?? DashboardtypesTimePreferenceDTO.global_time,
|
||||
}),
|
||||
...(c.stacking &&
|
||||
old?.stackedBarChart !== undefined && {
|
||||
stackedBarChart: old.stackedBarChart,
|
||||
}),
|
||||
...(c.fillSpans &&
|
||||
old?.fillSpans !== undefined && { fillSpans: old.fillSpans }),
|
||||
};
|
||||
return Object.keys(visualization).length > 0 ? visualization : undefined;
|
||||
},
|
||||
},
|
||||
[SectionKind.Axes]: {
|
||||
specKey: 'axes',
|
||||
seed: (
|
||||
controls,
|
||||
{ oldSpec },
|
||||
): SectionSpecMap[SectionKind.Axes] | undefined => {
|
||||
const c = controls as SectionControls[SectionKind.Axes];
|
||||
const old = (
|
||||
oldSpec?.plugin.spec as {
|
||||
axes?: SectionSpecMap[SectionKind.Axes];
|
||||
}
|
||||
)?.axes;
|
||||
if (!old) {
|
||||
return undefined;
|
||||
}
|
||||
const axes: SectionSpecMap[SectionKind.Axes] = {
|
||||
...(c.minMax &&
|
||||
typeof old.softMin === 'number' && { softMin: old.softMin }),
|
||||
...(c.minMax &&
|
||||
typeof old.softMax === 'number' && { softMax: old.softMax }),
|
||||
...(c.logScale &&
|
||||
old.isLogScale !== undefined && { isLogScale: old.isLogScale }),
|
||||
};
|
||||
return Object.keys(axes).length > 0 ? axes : undefined;
|
||||
},
|
||||
},
|
||||
[SectionKind.Legend]: {
|
||||
specKey: 'legend',
|
||||
seed: (controls): SectionSpecMap[SectionKind.Legend] | undefined => {
|
||||
seed: (
|
||||
controls,
|
||||
{ oldSpec },
|
||||
): SectionSpecMap[SectionKind.Legend] | undefined => {
|
||||
const c = controls as SectionControls[SectionKind.Legend];
|
||||
const old = (
|
||||
oldSpec?.plugin.spec as {
|
||||
legend?: SectionSpecMap[SectionKind.Legend];
|
||||
}
|
||||
)?.legend;
|
||||
// customColors is keyed by series label, which the new kind may not reproduce.
|
||||
return c.position
|
||||
? { position: DashboardtypesLegendPositionDTO.bottom }
|
||||
? { position: old?.position ?? DashboardtypesLegendPositionDTO.bottom }
|
||||
: undefined;
|
||||
},
|
||||
},
|
||||
[SectionKind.ChartAppearance]: {
|
||||
specKey: 'chartAppearance',
|
||||
seed: (controls): SectionSpecMap[SectionKind.ChartAppearance] | undefined => {
|
||||
seed: (
|
||||
controls,
|
||||
{ oldSpec },
|
||||
): SectionSpecMap[SectionKind.ChartAppearance] | undefined => {
|
||||
const c = controls as SectionControls[SectionKind.ChartAppearance];
|
||||
const old = (
|
||||
oldSpec?.plugin.spec as {
|
||||
chartAppearance?: SectionSpecMap[SectionKind.ChartAppearance];
|
||||
}
|
||||
)?.chartAppearance;
|
||||
const appearance: SectionSpecMap[SectionKind.ChartAppearance] = {};
|
||||
if (c.lineStyle) {
|
||||
appearance.lineStyle = DashboardtypesLineStyleDTO.solid;
|
||||
appearance.lineStyle = old?.lineStyle ?? DashboardtypesLineStyleDTO.solid;
|
||||
}
|
||||
if (c.lineInterpolation) {
|
||||
appearance.lineInterpolation = DashboardtypesLineInterpolationDTO.spline;
|
||||
appearance.lineInterpolation =
|
||||
old?.lineInterpolation ?? DashboardtypesLineInterpolationDTO.spline;
|
||||
}
|
||||
if (c.fillMode) {
|
||||
appearance.fillMode = DashboardtypesFillModeDTO.none;
|
||||
appearance.fillMode = old?.fillMode ?? DashboardtypesFillModeDTO.none;
|
||||
}
|
||||
if (c.showPoints && old?.showPoints !== undefined) {
|
||||
appearance.showPoints = old.showPoints;
|
||||
}
|
||||
if (c.spanGaps && old?.spanGaps !== undefined) {
|
||||
appearance.spanGaps = old.spanGaps;
|
||||
}
|
||||
return Object.keys(appearance).length > 0 ? appearance : undefined;
|
||||
},
|
||||
|
||||
@@ -19,6 +19,10 @@ jest.mock('@signozhq/ui/sonner', () => ({
|
||||
|
||||
jest.mock('uuid', () => ({ v4: (): string => 'cloned-id' }));
|
||||
|
||||
// jsdom has no layout engine, so scrollIntoView is undefined by default.
|
||||
const mockScrollIntoView = jest.fn();
|
||||
window.HTMLElement.prototype.scrollIntoView = mockScrollIntoView;
|
||||
|
||||
const sourcePanel = {
|
||||
kind: 'Panel',
|
||||
spec: {
|
||||
@@ -132,6 +136,29 @@ describe('useClonePanel', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('scrolls the cloned panel into view when the toast auto-closes', async () => {
|
||||
const clonedNode = document.createElement('div');
|
||||
clonedNode.setAttribute('data-panel-root', 'cloned-id');
|
||||
document.body.appendChild(clonedNode);
|
||||
|
||||
const { result } = renderHook(() => useClonePanel({ sections: sections() }));
|
||||
|
||||
await result.current({ panelId: 'p1', layoutIndex: 0 });
|
||||
|
||||
// The scroll is deferred to the toast's auto-close, not fired inline.
|
||||
const { onAutoClose } = mockToastPromise.mock.calls[0][1] as {
|
||||
onAutoClose: () => void;
|
||||
};
|
||||
onAutoClose();
|
||||
|
||||
expect(mockScrollIntoView).toHaveBeenCalledWith({
|
||||
behavior: 'smooth',
|
||||
block: 'center',
|
||||
});
|
||||
|
||||
document.body.removeChild(clonedNode);
|
||||
});
|
||||
|
||||
it('swallows a patch rejection (toast owns the error UX) — does not throw', async () => {
|
||||
mockPatchAsync.mockRejectedValueOnce(new Error('boom'));
|
||||
const { result } = renderHook(() => useClonePanel({ sections: sections() }));
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useCallback } from 'react';
|
||||
import { toast } from '@signozhq/ui/sonner';
|
||||
import { cloneDeep } from 'lodash-es';
|
||||
import { scrollIntoViewWhenReady } from 'utils/scrollIntoViewWhenReady';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
|
||||
import { useOptimisticPatch } from '../../../hooks/useOptimisticPatch';
|
||||
@@ -64,6 +65,14 @@ export function useClonePanel({
|
||||
success: 'Panel cloned',
|
||||
error: 'Failed to clone panel',
|
||||
position: 'top-center',
|
||||
duration: 2000,
|
||||
// Defer the scroll to the toast's auto-close so the "Panel cloned"
|
||||
// confirmation is seen first, then reveal the clone.
|
||||
onAutoClose: () => {
|
||||
scrollIntoViewWhenReady(() =>
|
||||
document.querySelector(`[data-panel-root="${newPanelId}"]`),
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
// toast.promise owns the error UX; swallow here to avoid an unhandled
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useErrorModal } from 'providers/ErrorModalProvider';
|
||||
import APIError from 'types/api/error';
|
||||
|
||||
import { useOptimisticPatch } from '../../../hooks/useOptimisticPatch';
|
||||
import { movePanelBetweenSectionsOps } from '../../../patchOps';
|
||||
import { findFreeSlot, movePanelBetweenSectionsOps } from '../../../patchOps';
|
||||
import { useDashboardStore } from '../../../store/useDashboardStore';
|
||||
import type { DashboardSection } from '../../../utils';
|
||||
|
||||
@@ -20,8 +20,8 @@ interface Params {
|
||||
|
||||
/**
|
||||
* Relocates a panel's item ref from one section to another. The panel itself
|
||||
* stays in `spec.panels`; only the grid item moves, dropped into a free row at
|
||||
* the bottom of the target section. Persisted as one atomic patch.
|
||||
* stays in `spec.panels`; only the grid item moves, dropped into the target
|
||||
* section's first free slot (`findFreeSlot`). Persisted as one atomic patch.
|
||||
*/
|
||||
export function useMovePanelToSection({
|
||||
sections,
|
||||
@@ -52,12 +52,10 @@ export function useMovePanelToSection({
|
||||
}
|
||||
|
||||
const sourceItems = source.items.filter((i) => i.id !== panelId);
|
||||
// Place at a fresh row at the bottom of the target section.
|
||||
const nextY = target.items.reduce(
|
||||
(max, i) => Math.max(max, i.y + i.height),
|
||||
0,
|
||||
);
|
||||
const targetItems = [...target.items, { ...moved, x: 0, y: nextY }];
|
||||
// Reuse the shared placement primitive: fills the last row's free edge
|
||||
// if the panel fits, else drops to a fresh row at the bottom.
|
||||
const { x, y } = findFreeSlot(target.items, moved.width);
|
||||
const targetItems = [...target.items, { ...moved, x, y }];
|
||||
|
||||
try {
|
||||
await patchAsync(
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schem
|
||||
import { useIntersectionObserver } from 'hooks/useIntersectionObserver';
|
||||
|
||||
import Panel, { type PanelActionsConfig } from '../../Panel/Panel';
|
||||
import { useScrollPanelIntoView } from './useScrollPanelIntoView';
|
||||
import styles from './SectionGrid.module.scss';
|
||||
|
||||
const VIEWPORT_OBSERVER_OPTIONS: IntersectionObserverInit = {
|
||||
@@ -31,6 +32,7 @@ function SectionGridItem({
|
||||
VIEWPORT_OBSERVER_OPTIONS,
|
||||
true,
|
||||
);
|
||||
useScrollPanelIntoView(panelId, containerRef);
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className={styles.panelWrapper}>
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import type { RefObject } from 'react';
|
||||
import { renderHook } from '@testing-library/react';
|
||||
|
||||
import { useScrollToPanelStore } from '../../../../store/useScrollToPanelStore';
|
||||
import { useScrollPanelIntoView } from '../useScrollPanelIntoView';
|
||||
|
||||
function refWithScroll(scrollIntoView: jest.Mock): RefObject<HTMLElement> {
|
||||
return {
|
||||
current: { scrollIntoView } as unknown as HTMLElement,
|
||||
} as RefObject<HTMLElement>;
|
||||
}
|
||||
|
||||
describe('useScrollPanelIntoView', () => {
|
||||
beforeEach(() => {
|
||||
useScrollToPanelStore.setState({ scrollToPanelId: null });
|
||||
});
|
||||
|
||||
it('scrolls into view and clears the request when the store targets this panel', () => {
|
||||
const scrollIntoView = jest.fn();
|
||||
useScrollToPanelStore.setState({ scrollToPanelId: 'p1' });
|
||||
|
||||
renderHook(() => useScrollPanelIntoView('p1', refWithScroll(scrollIntoView)));
|
||||
|
||||
expect(scrollIntoView).toHaveBeenCalledWith({
|
||||
behavior: 'smooth',
|
||||
block: 'center',
|
||||
});
|
||||
expect(useScrollToPanelStore.getState().scrollToPanelId).toBeNull();
|
||||
});
|
||||
|
||||
it('does nothing when the store targets a different panel', () => {
|
||||
const scrollIntoView = jest.fn();
|
||||
useScrollToPanelStore.setState({ scrollToPanelId: 'other' });
|
||||
|
||||
renderHook(() => useScrollPanelIntoView('p1', refWithScroll(scrollIntoView)));
|
||||
|
||||
expect(scrollIntoView).not.toHaveBeenCalled();
|
||||
expect(useScrollToPanelStore.getState().scrollToPanelId).toBe('other');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,24 @@
|
||||
import { RefObject, useEffect } from 'react';
|
||||
|
||||
import { useScrollToPanelStore } from '../../../store/useScrollToPanelStore';
|
||||
|
||||
/**
|
||||
* Scrolls this panel into view when the store requests it (e.g. returning from
|
||||
* the editor or after creating a panel), then clears the request so it fires
|
||||
* once. Runs on mount, so it catches the panel as soon as the grid renders it.
|
||||
*/
|
||||
export function useScrollPanelIntoView(
|
||||
panelId: string,
|
||||
ref: RefObject<HTMLElement>,
|
||||
): void {
|
||||
const scrollToPanelId = useScrollToPanelStore((s) => s.scrollToPanelId);
|
||||
const setScrollToPanelId = useScrollToPanelStore((s) => s.setScrollToPanelId);
|
||||
|
||||
useEffect(() => {
|
||||
if (scrollToPanelId !== panelId) {
|
||||
return;
|
||||
}
|
||||
ref.current?.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
setScrollToPanelId(null);
|
||||
}, [scrollToPanelId, panelId, ref, setScrollToPanelId]);
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { useCallback, useState } from 'react';
|
||||
import type { DashboardtypesLayoutDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { useErrorModal } from 'providers/ErrorModalProvider';
|
||||
import APIError from 'types/api/error';
|
||||
import { scrollIntoViewWhenReady } from 'utils/scrollIntoViewWhenReady';
|
||||
|
||||
import { useOptimisticPatch } from '../../../hooks/useOptimisticPatch';
|
||||
import {
|
||||
@@ -14,25 +15,6 @@ import { useDashboardStore } from '../../../store/useDashboardStore';
|
||||
|
||||
const SECTION_SELECTOR = '[data-testid^="dashboard-section-"]';
|
||||
|
||||
/**
|
||||
* Waits (via rAF) for the appended section to render, then scrolls it into view.
|
||||
* Polls because the optimistic cache write commits to the DOM a frame or two after
|
||||
* the patch call; bails after ~40 frames.
|
||||
*/
|
||||
function scrollToNewSection(prevCount: number, attempts = 40): void {
|
||||
const sections = document.querySelectorAll(SECTION_SELECTOR);
|
||||
if (sections.length > prevCount) {
|
||||
sections[sections.length - 1]?.scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
block: 'center',
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (attempts > 0) {
|
||||
requestAnimationFrame(() => scrollToNewSection(prevCount, attempts - 1));
|
||||
}
|
||||
}
|
||||
|
||||
interface Params {
|
||||
layouts: DashboardtypesLayoutDTO[] | undefined | null;
|
||||
}
|
||||
@@ -67,7 +49,13 @@ export function useAddSection({ layouts }: Params): Result {
|
||||
try {
|
||||
setIsSaving(true);
|
||||
await patchAsync([op]);
|
||||
scrollToNewSection(prevSectionCount);
|
||||
// Once the optimistic write appends the new section, scroll to it.
|
||||
scrollIntoViewWhenReady(() => {
|
||||
const sections = document.querySelectorAll(SECTION_SELECTOR);
|
||||
return sections.length > prevSectionCount
|
||||
? sections[sections.length - 1]
|
||||
: null;
|
||||
});
|
||||
} catch (error) {
|
||||
showErrorModal(error as APIError);
|
||||
} finally {
|
||||
|
||||
@@ -7,6 +7,8 @@ import {
|
||||
cloneSectionOps,
|
||||
createDefaultPanel,
|
||||
createPanelOps,
|
||||
findFreeSlot,
|
||||
itemsOverlap,
|
||||
} from '../patchOps';
|
||||
|
||||
function item(y: number, height: number): DashboardGridItemDTO {
|
||||
@@ -143,6 +145,59 @@ describe('createPanelOps', () => {
|
||||
expect(ops[2].path).toBe('/spec/layouts/0/spec/items/-');
|
||||
expect((ops[2].value as DashboardGridItemDTO).y).toBe(0);
|
||||
});
|
||||
|
||||
it('wraps to the bottom when the last-row slot is blocked by a taller earlier-row panel', () => {
|
||||
// Regression: the last row (top-y 6) has room at x:3, but the tall right
|
||||
// panel spans y:0..12 into it. Placing at x:3,y:6 would overlap it, so the
|
||||
// panel must drop to a fresh row at the bottom (y:12) instead.
|
||||
const layouts = [
|
||||
section([
|
||||
itemAt(0, 0, 6, 6),
|
||||
itemAt(6, 0, 6, 12), // tall, reaches down into the last row
|
||||
itemAt(0, 6, 3, 6),
|
||||
]),
|
||||
];
|
||||
const ops = createPanelOps({ layouts, layoutIndex: 0, panelId: 'p1', panel });
|
||||
|
||||
const value = ops[1].value as DashboardGridItemDTO;
|
||||
expect(value.x).toBe(0);
|
||||
expect(value.y).toBe(12);
|
||||
});
|
||||
});
|
||||
|
||||
describe('itemsOverlap', () => {
|
||||
it('is true only when rectangles intersect on both axes', () => {
|
||||
const a = { x: 0, y: 0, width: 6, height: 6 };
|
||||
expect(itemsOverlap(a, { x: 3, y: 3, width: 6, height: 6 })).toBe(true);
|
||||
// Touching edges do not overlap (half-open intervals).
|
||||
expect(itemsOverlap(a, { x: 6, y: 0, width: 6, height: 6 })).toBe(false);
|
||||
expect(itemsOverlap(a, { x: 0, y: 6, width: 6, height: 6 })).toBe(false);
|
||||
// Overlaps on x only (disjoint on y) → no overlap.
|
||||
expect(itemsOverlap(a, { x: 3, y: 6, width: 6, height: 6 })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('findFreeSlot', () => {
|
||||
it('places the first item at the origin', () => {
|
||||
expect(findFreeSlot([], 6)).toStrictEqual({ x: 0, y: 0 });
|
||||
});
|
||||
|
||||
it('fills the right of the last row when it fits and is clear', () => {
|
||||
expect(findFreeSlot([itemAt(0, 0, 6, 6)], 6)).toStrictEqual({ x: 6, y: 0 });
|
||||
});
|
||||
|
||||
it('never returns a slot that overlaps an existing item', () => {
|
||||
const items = [itemAt(0, 0, 6, 6), itemAt(6, 0, 6, 12), itemAt(0, 6, 3, 6)];
|
||||
const slot = findFreeSlot(items, 6);
|
||||
const placed = { ...slot, width: 6, height: 6 };
|
||||
expect(items.some((it) => itemsOverlap(placed, it))).toBe(false);
|
||||
expect(slot).toStrictEqual({ x: 0, y: 12 });
|
||||
});
|
||||
|
||||
it('clamps a too-wide panel to the grid width', () => {
|
||||
// width 20 > 12 cols → clamped to 12, so it wraps below the first row.
|
||||
expect(findFreeSlot([itemAt(0, 0, 6, 6)], 20)).toStrictEqual({ x: 0, y: 6 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('cloneSectionOps', () => {
|
||||
|
||||
@@ -172,9 +172,31 @@ const GRID_COLS = 12;
|
||||
type PlacedItem = Pick<DashboardGridItemDTO, 'x' | 'y' | 'width' | 'height'>;
|
||||
|
||||
/**
|
||||
* Placement for a new grid item: drop it right of the last row if there's room,
|
||||
* else wrap to a fresh row at the bottom. Only the last row is considered (items
|
||||
* sharing the greatest top-y); gaps in earlier rows are left alone.
|
||||
* Whether two grid rectangles intersect on both axes. Mirrors the backend's
|
||||
* overlap check (a patch placing two intersecting items is rejected), so this is
|
||||
* the authority the frontend must satisfy before adding an item.
|
||||
*/
|
||||
export function itemsOverlap(a: PlacedItem, b: PlacedItem): boolean {
|
||||
const ax = a.x ?? 0;
|
||||
const ay = a.y ?? 0;
|
||||
const aw = a.width ?? 0;
|
||||
const ah = a.height ?? 0;
|
||||
const bx = b.x ?? 0;
|
||||
const by = b.y ?? 0;
|
||||
const bw = b.width ?? 0;
|
||||
const bh = b.height ?? 0;
|
||||
return ax < bx + bw && bx < ax + aw && ay < by + bh && by < ay + ah;
|
||||
}
|
||||
|
||||
/**
|
||||
* Placement for a new grid item: drop it right of the last row if it both fits
|
||||
* the grid width and clears every existing item, else wrap to a fresh row at the
|
||||
* bottom. Only the last row is preferred (items sharing the greatest top-y);
|
||||
* gaps in earlier rows are left alone. The overlap guard is what keeps this
|
||||
* safe — a tall panel from an earlier row can reach down into the last row, so
|
||||
* "right of the last row" is not automatically free. The bottom fallback sits
|
||||
* below every item and so can never overlap. This is the single placement
|
||||
* primitive for create / clone / move.
|
||||
*/
|
||||
export function findFreeSlot(
|
||||
items: PlacedItem[],
|
||||
@@ -194,7 +216,17 @@ export function findFreeSlot(
|
||||
.filter((it) => (it.y ?? 0) === lastRowY)
|
||||
.reduce((max, it) => Math.max(max, (it.x ?? 0) + (it.width ?? 0)), 0);
|
||||
|
||||
if (lastRowRightEdge + w <= GRID_COLS) {
|
||||
// height is unbounded downward, so use 1 for the fit probe: overlap on the
|
||||
// y-axis is decided by items reaching below `lastRowY`, not by the new
|
||||
// panel's own height (its top sits at the greatest top-y of all items).
|
||||
const candidate: PlacedItem = {
|
||||
x: lastRowRightEdge,
|
||||
y: lastRowY,
|
||||
width: w,
|
||||
height: 1,
|
||||
};
|
||||
const fitsWidth = lastRowRightEdge + w <= GRID_COLS;
|
||||
if (fitsWidth && !items.some((it) => itemsOverlap(candidate, it))) {
|
||||
return { x: lastRowRightEdge, y: lastRowY };
|
||||
}
|
||||
return { x: 0, y: bottom };
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import { create } from 'zustand';
|
||||
|
||||
/**
|
||||
* Ephemeral cross-route signal: the panel editor records which panel to reveal
|
||||
* on the way back, and the dashboard grid scrolls that panel into view once it
|
||||
* mounts, then clears the id. Kept out of `useDashboardStore` (never persisted)
|
||||
* and off the URL so a refresh doesn't re-trigger the scroll.
|
||||
*/
|
||||
export interface ScrollToPanelStore {
|
||||
scrollToPanelId: string | null;
|
||||
setScrollToPanelId: (panelId: string | null) => void;
|
||||
}
|
||||
|
||||
export const useScrollToPanelStore = create<ScrollToPanelStore>((set) => ({
|
||||
scrollToPanelId: null,
|
||||
setScrollToPanelId: (scrollToPanelId): void => {
|
||||
set({ scrollToPanelId });
|
||||
},
|
||||
}));
|
||||
56
frontend/src/utils/__tests__/scrollIntoViewWhenReady.test.ts
Normal file
56
frontend/src/utils/__tests__/scrollIntoViewWhenReady.test.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import { scrollIntoViewWhenReady } from '../scrollIntoViewWhenReady';
|
||||
|
||||
describe('scrollIntoViewWhenReady', () => {
|
||||
let rafSpy: jest.SpyInstance;
|
||||
|
||||
beforeEach(() => {
|
||||
// Run rAF callbacks synchronously so the poll loop resolves within the test.
|
||||
rafSpy = jest
|
||||
.spyOn(window, 'requestAnimationFrame')
|
||||
.mockImplementation((cb: FrameRequestCallback): number => {
|
||||
cb(0);
|
||||
return 0;
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
rafSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('scrolls the resolved element into view immediately when it is ready', () => {
|
||||
const scrollIntoView = jest.fn();
|
||||
const el = { scrollIntoView } as unknown as Element;
|
||||
|
||||
scrollIntoViewWhenReady(() => el);
|
||||
|
||||
expect(scrollIntoView).toHaveBeenCalledWith({
|
||||
behavior: 'smooth',
|
||||
block: 'center',
|
||||
});
|
||||
expect(rafSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('polls via rAF until the target appears, then scrolls it once', () => {
|
||||
const scrollIntoView = jest.fn();
|
||||
const el = { scrollIntoView } as unknown as Element;
|
||||
let calls = 0;
|
||||
const resolveTarget = jest.fn(() => {
|
||||
calls += 1;
|
||||
return calls >= 3 ? el : null;
|
||||
});
|
||||
|
||||
scrollIntoViewWhenReady(resolveTarget);
|
||||
|
||||
expect(resolveTarget).toHaveBeenCalledTimes(3);
|
||||
expect(scrollIntoView).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('bails after the attempt budget when the target never appears', () => {
|
||||
const resolveTarget = jest.fn(() => null);
|
||||
|
||||
scrollIntoViewWhenReady(resolveTarget, 5);
|
||||
|
||||
// Initial call + 5 rAF retries.
|
||||
expect(resolveTarget).toHaveBeenCalledTimes(6);
|
||||
});
|
||||
});
|
||||
20
frontend/src/utils/scrollIntoViewWhenReady.ts
Normal file
20
frontend/src/utils/scrollIntoViewWhenReady.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
/**
|
||||
* Polls via rAF until `resolveTarget` returns an element, then scrolls it into
|
||||
* view — for nodes that mount a frame or two after the call (e.g. an optimistic
|
||||
* write). Return `null`/`undefined` while not ready; bails after `attempts`.
|
||||
*/
|
||||
export function scrollIntoViewWhenReady(
|
||||
resolveTarget: () => Element | null | undefined,
|
||||
attempts = 40,
|
||||
): void {
|
||||
const target = resolveTarget();
|
||||
if (target) {
|
||||
target.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
return;
|
||||
}
|
||||
if (attempts > 0) {
|
||||
requestAnimationFrame(() =>
|
||||
scrollIntoViewWhenReady(resolveTarget, attempts - 1),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user