mirror of
https://github.com/SigNoz/signoz.git
synced 2026-07-02 21:00:38 +01:00
Compare commits
6 Commits
fix/public
...
fix/panel-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
30e5b68e43 | ||
|
|
093eda7deb | ||
|
|
77f7a2cceb | ||
|
|
c36226050e | ||
|
|
a72484f12c | ||
|
|
71eabac1e7 |
@@ -328,6 +328,11 @@
|
||||
{
|
||||
"name": "immer",
|
||||
"message": "[State mgmt] Direct immer usage is deprecated. Use Zustand (which integrates immer via the immer middleware) instead."
|
||||
},
|
||||
{
|
||||
"name": "api/generated/services/dashboard",
|
||||
"importNames": ["patchDashboardV2", "usePatchDashboardV2"],
|
||||
"message": "[dashboard-v2] Don't call patchDashboardV2/usePatchDashboardV2 directly — use useOptimisticPatch().patchAsync so spec edits update the react-query cache optimistically and reconcile on settle."
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -66,7 +66,6 @@ function WidgetGraphComponent({
|
||||
customOnRowClick,
|
||||
customTimeRangeWindowForCoRelation,
|
||||
enableDrillDown,
|
||||
hidePagination,
|
||||
}: WidgetGraphComponentProps): JSX.Element {
|
||||
const { safeNavigate } = useSafeNavigate();
|
||||
const [deleteModal, setDeleteModal] = useState(false);
|
||||
@@ -431,7 +430,6 @@ function WidgetGraphComponent({
|
||||
customSeries={customSeries}
|
||||
customOnRowClick={customOnRowClick}
|
||||
enableDrillDown={enableDrillDown}
|
||||
hidePagination={hidePagination}
|
||||
onColumnWidthsChange={onColumnWidthsChange}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -42,8 +42,6 @@ export interface WidgetGraphComponentProps {
|
||||
customOnRowClick?: (record: RowData) => void;
|
||||
customTimeRangeWindowForCoRelation?: string | undefined;
|
||||
enableDrillDown?: boolean;
|
||||
/** Hide list-panel pagination controls (e.g. public dashboards, where paging isn't supported). */
|
||||
hidePagination?: boolean;
|
||||
}
|
||||
|
||||
export interface GridCardGraphProps {
|
||||
|
||||
@@ -34,7 +34,6 @@ function LogsPanelComponent({
|
||||
setRequestData,
|
||||
queryResponse,
|
||||
onColumnWidthsChange,
|
||||
hidePagination,
|
||||
}: LogsPanelComponentProps): JSX.Element {
|
||||
const [pageSize, setPageSize] = useState<number>(10);
|
||||
const [offset, setOffset] = useState<number>(0);
|
||||
@@ -159,7 +158,7 @@ function LogsPanelComponent({
|
||||
/>
|
||||
</OverlayScrollbar>
|
||||
</div>
|
||||
{!hidePagination && !widget.query.builder.queryData[0].limit && (
|
||||
{!widget.query.builder.queryData[0].limit && (
|
||||
<div className="controller">
|
||||
<Controls
|
||||
totalCount={totalCount}
|
||||
@@ -199,7 +198,6 @@ export type LogsPanelComponentProps = {
|
||||
>;
|
||||
widget: Widgets;
|
||||
onColumnWidthsChange?: (widths: Record<string, number>) => void;
|
||||
hidePagination?: boolean;
|
||||
};
|
||||
|
||||
export default LogsPanelComponent;
|
||||
|
||||
@@ -9,7 +9,6 @@ function ListPanelWrapper({
|
||||
queryResponse,
|
||||
setRequestData,
|
||||
onColumnWidthsChange,
|
||||
hidePagination,
|
||||
}: PanelWrapperProps): JSX.Element {
|
||||
const dataSource = widget.query.builder?.queryData[0]?.dataSource;
|
||||
|
||||
@@ -24,7 +23,6 @@ function ListPanelWrapper({
|
||||
queryResponse={queryResponse}
|
||||
setRequestData={setRequestData}
|
||||
onColumnWidthsChange={onColumnWidthsChange}
|
||||
hidePagination={hidePagination}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -34,7 +32,6 @@ function ListPanelWrapper({
|
||||
queryResponse={queryResponse}
|
||||
setRequestData={setRequestData}
|
||||
onColumnWidthsChange={onColumnWidthsChange}
|
||||
hidePagination={hidePagination}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -26,7 +26,6 @@ function PanelWrapper({
|
||||
panelMode,
|
||||
enableDrillDown = false,
|
||||
onColumnWidthsChange,
|
||||
hidePagination,
|
||||
}: PanelWrapperProps): JSX.Element {
|
||||
const Component = PanelTypeVsPanelWrapper[
|
||||
selectedGraph || widget.panelTypes
|
||||
@@ -77,7 +76,6 @@ function PanelWrapper({
|
||||
enableDrillDown={enableDrillDown}
|
||||
onColumnWidthsChange={onColumnWidthsChange}
|
||||
groupByPerQuery={groupByPerQuery}
|
||||
hidePagination={hidePagination}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -32,7 +32,6 @@ export type PanelWrapperProps = {
|
||||
panelMode: PanelMode;
|
||||
onColumnWidthsChange?: (widths: Record<string, number>) => void;
|
||||
groupByPerQuery?: Record<string, BaseAutocompleteData[]>;
|
||||
hidePagination?: boolean;
|
||||
};
|
||||
|
||||
export type TooltipData = {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { memo, useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { memo, useCallback, useMemo, useRef } from 'react';
|
||||
import { ENTITY_VERSION_V5 } from 'constants/app';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import EmptyWidget from 'container/GridCardLayout/EmptyWidget';
|
||||
@@ -6,12 +6,11 @@ import WidgetGraphComponent from 'container/GridCardLayout/GridCard/WidgetGraphC
|
||||
import { populateMultipleResults } from 'container/NewWidget/LeftContainer/WidgetGraph/util';
|
||||
import { useGetQueryRange } from 'hooks/queryBuilder/useGetQueryRange';
|
||||
import { GetQueryResultsProps } from 'lib/dashboard/getQueryResults';
|
||||
import { isEqual } from 'lodash-es';
|
||||
import { Widgets } from 'types/api/dashboard/getAll';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
import { getGraphType } from 'utils/getGraphType';
|
||||
import { getSortedSeriesData } from 'utils/getSortedSeriesData';
|
||||
|
||||
import { getPublicPanelRequestData } from './utils';
|
||||
|
||||
function Panel({
|
||||
widget,
|
||||
index,
|
||||
@@ -28,27 +27,54 @@ function Panel({
|
||||
const graphRef = useRef<HTMLDivElement>(null);
|
||||
const updatedQuery = widget?.query;
|
||||
|
||||
// State (not memo) so LIST panels get a setRequestData — ListPanelWrapper
|
||||
// renders nothing without one.
|
||||
const [requestData, setRequestData] = useState<GetQueryResultsProps>(() =>
|
||||
getPublicPanelRequestData({ widget, startTime, endTime }),
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isEqual(updatedQuery, requestData.query)) {
|
||||
setRequestData((prev) => ({
|
||||
...prev,
|
||||
const requestData: GetQueryResultsProps = useMemo(() => {
|
||||
if (widget.panelTypes !== PANEL_TYPES.LIST) {
|
||||
return {
|
||||
selectedTime: widget?.timePreferance,
|
||||
graphType: getGraphType(widget.panelTypes),
|
||||
query: updatedQuery,
|
||||
}));
|
||||
variables: {}, // we are not supporting variables in public dashboards
|
||||
fillGaps: widget.fillSpans,
|
||||
formatForWeb: widget.panelTypes === PANEL_TYPES.TABLE,
|
||||
start: startTime,
|
||||
end: endTime,
|
||||
originalGraphType: widget.panelTypes,
|
||||
};
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [updatedQuery]);
|
||||
|
||||
const initialDataSource = updatedQuery.builder.queryData[0].dataSource;
|
||||
const updatedQueryForList = {
|
||||
...updatedQuery,
|
||||
builder: {
|
||||
...updatedQuery.builder,
|
||||
queryData: updatedQuery.builder.queryData.map((qd, i) =>
|
||||
i === 0 ? { ...qd, pageSize: 10 } : qd,
|
||||
),
|
||||
},
|
||||
};
|
||||
|
||||
return {
|
||||
query: updatedQueryForList,
|
||||
graphType: PANEL_TYPES.LIST,
|
||||
selectedTime: widget.timePreferance || 'GLOBAL_TIME',
|
||||
tableParams: {
|
||||
pagination: {
|
||||
offset: 0,
|
||||
limit: updatedQuery.builder.queryData[0].limit || 0,
|
||||
},
|
||||
// we do not need select columns in case of logs
|
||||
selectColumns:
|
||||
initialDataSource === DataSource.TRACES && widget.selectedTracesFields,
|
||||
},
|
||||
fillGaps: widget.fillSpans,
|
||||
start: startTime,
|
||||
end: endTime,
|
||||
};
|
||||
}, [widget, updatedQuery, startTime, endTime]);
|
||||
|
||||
const queryResponse = useGetQueryRange(
|
||||
{
|
||||
...requestData,
|
||||
start: startTime,
|
||||
end: endTime,
|
||||
originalGraphType: widget?.panelTypes,
|
||||
},
|
||||
ENTITY_VERSION_V5,
|
||||
@@ -114,8 +140,6 @@ function Panel({
|
||||
headerMenuList={[]}
|
||||
isWarning={false}
|
||||
isFetchingResponse={queryResponse.isFetching || queryResponse.isLoading}
|
||||
setRequestData={setRequestData}
|
||||
hidePagination
|
||||
onDragSelect={onDragSelect}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -19,14 +19,9 @@ jest.mock('hooks/queryBuilder/useGetQueryRange', () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
const widgetGraphProps = jest.fn();
|
||||
|
||||
jest.mock('container/GridCardLayout/GridCard/WidgetGraphComponent', () => ({
|
||||
__esModule: true,
|
||||
default: (props: { setRequestData?: unknown }): JSX.Element => {
|
||||
widgetGraphProps(props);
|
||||
return <div data-testid="widget-graph" />;
|
||||
},
|
||||
default: (): JSX.Element => <div data-testid="widget-graph" />,
|
||||
}));
|
||||
|
||||
const buildWidget = (id: string): Widgets =>
|
||||
@@ -44,22 +39,6 @@ const buildWidget = (id: string): Widgets =>
|
||||
describe('Public dashboard Panel', () => {
|
||||
beforeEach(() => {
|
||||
useGetQueryRangeMock.mockClear();
|
||||
widgetGraphProps.mockClear();
|
||||
});
|
||||
|
||||
it('forwards a setRequestData setter so LIST panels render (bug 3646)', () => {
|
||||
render(
|
||||
<Panel
|
||||
widget={buildWidget('widget-a')}
|
||||
index={0}
|
||||
dashboardId="dash-1"
|
||||
startTime={100}
|
||||
endTime={200}
|
||||
/>,
|
||||
);
|
||||
|
||||
const props = widgetGraphProps.mock.calls[0][0];
|
||||
expect(typeof props.setRequestData).toBe('function');
|
||||
});
|
||||
|
||||
it('keys each panel by widget id + index so identical queries do not collide (bug 5503)', () => {
|
||||
|
||||
@@ -1,53 +0,0 @@
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { GetQueryResultsProps } from 'lib/dashboard/getQueryResults';
|
||||
import { Widgets } from 'types/api/dashboard/getAll';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
import { getGraphType } from 'utils/getGraphType';
|
||||
|
||||
// Builds the useGetQueryRange payload for a public-dashboard panel, mirroring the
|
||||
// authenticated GridCard.
|
||||
export const getPublicPanelRequestData = ({
|
||||
widget,
|
||||
startTime,
|
||||
endTime,
|
||||
}: {
|
||||
widget: Widgets;
|
||||
startTime: number;
|
||||
endTime: number;
|
||||
}): GetQueryResultsProps => {
|
||||
const updatedQuery = widget?.query;
|
||||
|
||||
if (widget.panelTypes !== PANEL_TYPES.LIST) {
|
||||
return {
|
||||
selectedTime: widget?.timePreferance,
|
||||
graphType: getGraphType(widget.panelTypes),
|
||||
query: updatedQuery,
|
||||
variables: {}, // we are not supporting variables in public dashboards
|
||||
fillGaps: widget.fillSpans,
|
||||
formatForWeb: widget.panelTypes === PANEL_TYPES.TABLE,
|
||||
start: startTime,
|
||||
end: endTime,
|
||||
originalGraphType: widget.panelTypes,
|
||||
};
|
||||
}
|
||||
|
||||
const initialDataSource = updatedQuery.builder.queryData[0].dataSource;
|
||||
|
||||
return {
|
||||
query: updatedQuery,
|
||||
graphType: PANEL_TYPES.LIST,
|
||||
selectedTime: widget.timePreferance || 'GLOBAL_TIME',
|
||||
tableParams: {
|
||||
pagination: {
|
||||
offset: 0,
|
||||
limit: updatedQuery.builder.queryData[0].limit || 0,
|
||||
},
|
||||
// we do not need select columns in case of logs
|
||||
selectColumns:
|
||||
initialDataSource === DataSource.TRACES && widget.selectedTracesFields,
|
||||
},
|
||||
fillGaps: widget.fillSpans,
|
||||
start: startTime,
|
||||
end: endTime,
|
||||
};
|
||||
};
|
||||
@@ -37,7 +37,6 @@ function TracesTableComponent({
|
||||
queryResponse,
|
||||
setRequestData,
|
||||
onColumnWidthsChange,
|
||||
hidePagination,
|
||||
}: TracesTableComponentProps): JSX.Element {
|
||||
const [pagination, setPagination] = useState<Pagination>({
|
||||
offset: 0,
|
||||
@@ -140,36 +139,34 @@ function TracesTableComponent({
|
||||
/>
|
||||
</OverlayScrollbar>
|
||||
</div>
|
||||
{!hidePagination && (
|
||||
<div className="controller">
|
||||
<Controls
|
||||
totalCount={totalCount}
|
||||
perPageOptions={PER_PAGE_OPTIONS}
|
||||
isLoading={queryResponse.isFetching}
|
||||
offset={pagination.offset}
|
||||
countPerPage={pagination.limit}
|
||||
handleNavigatePrevious={(): void => {
|
||||
handlePaginationChange({
|
||||
...pagination,
|
||||
offset: pagination.offset - pagination.limit,
|
||||
});
|
||||
}}
|
||||
handleNavigateNext={(): void => {
|
||||
handlePaginationChange({
|
||||
...pagination,
|
||||
offset: pagination.offset + pagination.limit,
|
||||
});
|
||||
}}
|
||||
handleCountItemsPerPageChange={(value): void => {
|
||||
handlePaginationChange({
|
||||
...pagination,
|
||||
limit: value,
|
||||
offset: 0,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="controller">
|
||||
<Controls
|
||||
totalCount={totalCount}
|
||||
perPageOptions={PER_PAGE_OPTIONS}
|
||||
isLoading={queryResponse.isFetching}
|
||||
offset={pagination.offset}
|
||||
countPerPage={pagination.limit}
|
||||
handleNavigatePrevious={(): void => {
|
||||
handlePaginationChange({
|
||||
...pagination,
|
||||
offset: pagination.offset - pagination.limit,
|
||||
});
|
||||
}}
|
||||
handleNavigateNext={(): void => {
|
||||
handlePaginationChange({
|
||||
...pagination,
|
||||
offset: pagination.offset + pagination.limit,
|
||||
});
|
||||
}}
|
||||
handleCountItemsPerPageChange={(value): void => {
|
||||
handlePaginationChange({
|
||||
...pagination,
|
||||
limit: value,
|
||||
offset: 0,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -181,7 +178,6 @@ export type TracesTableComponentProps = {
|
||||
>;
|
||||
widget: Widgets;
|
||||
setRequestData: Dispatch<SetStateAction<GetQueryResultsProps>>;
|
||||
hidePagination?: boolean;
|
||||
onColumnWidthsChange?: (widths: Record<string, number>) => void;
|
||||
};
|
||||
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
import { renderHook } from '@testing-library/react';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { AllTheProviders } from 'tests/test-utils';
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
import { useLogsData } from '../useLogsData';
|
||||
|
||||
describe('useLogsData', () => {
|
||||
// Public dashboards redact the widget query (orderBy/filter/limit stripped),
|
||||
// so a LIST query can arrive with no orderBy — the hook must not crash on it.
|
||||
it('does not crash when the query has no orderBy', () => {
|
||||
const stagedQuery = {
|
||||
builder: {
|
||||
queryData: [{ dataSource: 'logs', queryName: 'A', disabled: false }],
|
||||
},
|
||||
} as unknown as Query;
|
||||
|
||||
const { result } = renderHook(
|
||||
() =>
|
||||
useLogsData({
|
||||
result: undefined,
|
||||
panelType: PANEL_TYPES.LIST,
|
||||
stagedQuery,
|
||||
}),
|
||||
{ wrapper: AllTheProviders },
|
||||
);
|
||||
|
||||
expect(result.current.logs).toStrictEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -71,7 +71,7 @@ export const useLogsData = ({
|
||||
}, [logs.length, listQuery]);
|
||||
|
||||
const orderByTimestamp: OrderByPayload | null = useMemo(() => {
|
||||
const timestampOrderBy = listQuery?.orderBy?.find(
|
||||
const timestampOrderBy = listQuery?.orderBy.find(
|
||||
(item) => item.columnName === 'timestamp',
|
||||
);
|
||||
|
||||
|
||||
@@ -4,7 +4,6 @@ import { toast } from '@signozhq/ui/sonner';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import {
|
||||
lockDashboardV2,
|
||||
patchDashboardV2,
|
||||
unlockDashboardV2,
|
||||
} from 'api/generated/services/dashboard';
|
||||
import type {
|
||||
@@ -18,6 +17,7 @@ import { useErrorModal } from 'providers/ErrorModalProvider';
|
||||
import APIError from 'types/api/error';
|
||||
|
||||
import { useCreatePanel } from '../hooks/useCreatePanel';
|
||||
import { useOptimisticPatch } from '../hooks/useOptimisticPatch';
|
||||
import PanelTypeSelectionModal from '../PanelsAndSectionsLayout/Panel/PanelTypeSelectionModal/PanelTypeSelectionModal';
|
||||
import DashboardActions from './DashboardActions/DashboardActions';
|
||||
import DashboardInfo from './DashboardInfo/DashboardInfo';
|
||||
@@ -51,8 +51,14 @@ function DashboardPageToolbar(props: DashboardPageToolbarProps): JSX.Element {
|
||||
|
||||
const { user } = useAppContext();
|
||||
const { showErrorModal } = useErrorModal();
|
||||
const { isPickerOpen, openPicker, closePicker, createPanel } =
|
||||
useCreatePanel();
|
||||
const { patchAsync } = useOptimisticPatch();
|
||||
const {
|
||||
isPickerOpen,
|
||||
openPicker,
|
||||
closePicker,
|
||||
createPanel,
|
||||
targetLayoutIndex,
|
||||
} = useCreatePanel();
|
||||
|
||||
const isAuthor =
|
||||
!!user?.email && !!dashboard.createdBy && dashboard.createdBy === user.email;
|
||||
@@ -88,14 +94,13 @@ function DashboardPageToolbar(props: DashboardPageToolbarProps): JSX.Element {
|
||||
value: next,
|
||||
},
|
||||
];
|
||||
await patchDashboardV2({ id }, patch);
|
||||
await patchAsync(patch);
|
||||
toast.success('Dashboard renamed successfully');
|
||||
refetch();
|
||||
} catch (error) {
|
||||
showErrorModal(error as APIError);
|
||||
}
|
||||
},
|
||||
[id, refetch, showErrorModal],
|
||||
[id, patchAsync, showErrorModal],
|
||||
);
|
||||
|
||||
const { isEditing, draft, setDraft, startEdit, cancel, commit } =
|
||||
@@ -153,6 +158,7 @@ function DashboardPageToolbar(props: DashboardPageToolbarProps): JSX.Element {
|
||||
open={isPickerOpen}
|
||||
onClose={closePicker}
|
||||
onSelect={createPanel}
|
||||
defaultLayoutIndex={targetLayoutIndex}
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { patchDashboardV2 } from 'api/generated/services/dashboard';
|
||||
import type {
|
||||
DashboardtypesGettableDashboardV2DTO,
|
||||
DashboardtypesJSONPatchOperationDTO,
|
||||
@@ -9,7 +8,7 @@ import { isEqual } from 'lodash-es';
|
||||
import { useErrorModal } from 'providers/ErrorModalProvider';
|
||||
import APIError from 'types/api/error';
|
||||
|
||||
import { useDashboardStore } from '../../store/useDashboardStore';
|
||||
import { useOptimisticPatch } from '../../hooks/useOptimisticPatch';
|
||||
import CrossPanelSync from './CrossPanelSync/CrossPanelSync';
|
||||
import DashboardInfoForm from './DashboardInfoForm/DashboardInfoForm';
|
||||
import UnsavedChangesFooter from './UnsavedChangesFooter/UnsavedChangesFooter';
|
||||
@@ -23,7 +22,7 @@ interface OverviewProps {
|
||||
function Overview({ dashboard }: OverviewProps): JSX.Element {
|
||||
const id = dashboard.id;
|
||||
|
||||
const refetch = useDashboardStore((s) => s.refetch);
|
||||
const { patchAsync } = useOptimisticPatch();
|
||||
|
||||
const title = dashboard.spec.display.name;
|
||||
const description = dashboard.spec.display.description ?? '';
|
||||
@@ -96,15 +95,14 @@ function Overview({ dashboard }: OverviewProps): JSX.Element {
|
||||
|
||||
try {
|
||||
setIsSaving(true);
|
||||
await patchDashboardV2({ id }, ops);
|
||||
await patchAsync(ops);
|
||||
toast.success('Dashboard updated');
|
||||
refetch();
|
||||
} catch (error) {
|
||||
showErrorModal(error as APIError);
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
}, [id, buildPatch, refetch, showErrorModal]);
|
||||
}, [buildPatch, patchAsync, showErrorModal]);
|
||||
|
||||
useEffect(() => {
|
||||
let numberOfUnsavedChanges = 0;
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { useCallback, useState } from 'react';
|
||||
import { patchDashboardV2 } from 'api/generated/services/dashboard';
|
||||
import { toast } from '@signozhq/ui/sonner';
|
||||
import { useErrorModal } from 'providers/ErrorModalProvider';
|
||||
import APIError from 'types/api/error';
|
||||
|
||||
import { useOptimisticPatch } from '../../hooks/useOptimisticPatch';
|
||||
import { useDashboardStore } from '../../store/useDashboardStore';
|
||||
import { formModelToDto } from './variableAdapters';
|
||||
import type { VariableFormModel } from './variableFormModel';
|
||||
@@ -14,14 +14,9 @@ interface UseSaveVariables {
|
||||
isSaving: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Persists the dashboard's variable list via a single `/spec/variables` patch,
|
||||
* then refetches. Mirrors the General-settings save flow (patch → toast →
|
||||
* refetch → surface errors).
|
||||
*/
|
||||
export function useSaveVariables(): UseSaveVariables {
|
||||
const dashboardId = useDashboardStore((s) => s.dashboardId);
|
||||
const refetch = useDashboardStore((s) => s.refetch);
|
||||
const { patchAsync } = useOptimisticPatch();
|
||||
const { showErrorModal } = useErrorModal();
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
|
||||
@@ -33,9 +28,8 @@ export function useSaveVariables(): UseSaveVariables {
|
||||
const dtos = variables.map(formModelToDto);
|
||||
try {
|
||||
setIsSaving(true);
|
||||
await patchDashboardV2({ id: dashboardId }, buildVariablesPatch(dtos));
|
||||
await patchAsync(buildVariablesPatch(dtos));
|
||||
toast.success('Variables updated');
|
||||
refetch();
|
||||
return true;
|
||||
} catch (error) {
|
||||
showErrorModal(error as APIError);
|
||||
@@ -44,7 +38,7 @@ export function useSaveVariables(): UseSaveVariables {
|
||||
setIsSaving(false);
|
||||
}
|
||||
},
|
||||
[dashboardId, refetch, showErrorModal],
|
||||
[dashboardId, patchAsync, showErrorModal],
|
||||
);
|
||||
|
||||
return { save, isSaving };
|
||||
|
||||
@@ -1,40 +1,36 @@
|
||||
import { renderHook } from '@testing-library/react';
|
||||
import {
|
||||
getGetDashboardV2QueryKey,
|
||||
usePatchDashboardV2,
|
||||
} from 'api/generated/services/dashboard';
|
||||
import type { DashboardtypesPanelSpecDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import { usePanelEditorSave } from '../usePanelEditorSave';
|
||||
|
||||
const mockInvalidateQueries = jest.fn();
|
||||
const mockPatchAsync = jest.fn().mockResolvedValue(undefined);
|
||||
let mockIsPatching = false;
|
||||
jest.mock('../../../hooks/useOptimisticPatch', () => ({
|
||||
useOptimisticPatch: (): {
|
||||
patchAsync: jest.Mock;
|
||||
isPatching: boolean;
|
||||
error: Error | null;
|
||||
} => ({ patchAsync: mockPatchAsync, isPatching: mockIsPatching, error: null }),
|
||||
}));
|
||||
|
||||
// The hook reads getQueryData only for the isNew branch; a stub client is enough here.
|
||||
jest.mock('react-query', () => ({
|
||||
useQueryClient: (): { invalidateQueries: jest.Mock } => ({
|
||||
invalidateQueries: mockInvalidateQueries,
|
||||
useQueryClient: (): { getQueryData: jest.Mock } => ({
|
||||
getQueryData: jest.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('api/generated/services/dashboard', () => ({
|
||||
usePatchDashboardV2: jest.fn(),
|
||||
getGetDashboardV2QueryKey: jest.fn(() => ['/api/v2/dashboards/dash-1']),
|
||||
}));
|
||||
|
||||
const mockUsePatch = usePatchDashboardV2 as unknown as jest.Mock;
|
||||
const mockGetQueryKey = getGetDashboardV2QueryKey as unknown as jest.Mock;
|
||||
|
||||
describe('usePanelEditorSave', () => {
|
||||
const mutateAsync = jest.fn().mockResolvedValue(undefined);
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockUsePatch.mockReturnValue({
|
||||
mutateAsync,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
});
|
||||
mockIsPatching = false;
|
||||
});
|
||||
|
||||
it('emits an add patch replacing the whole panel spec and invalidates the dashboard query', async () => {
|
||||
it('optimistically patches an add replacing the whole panel spec', async () => {
|
||||
const { result } = renderHook(() =>
|
||||
usePanelEditorSave({ dashboardId: 'dash-1', panelId: 'panel-9' }),
|
||||
);
|
||||
@@ -50,28 +46,17 @@ describe('usePanelEditorSave', () => {
|
||||
|
||||
await result.current.save(spec);
|
||||
|
||||
expect(mutateAsync).toHaveBeenCalledWith({
|
||||
pathParams: { id: 'dash-1' },
|
||||
data: [
|
||||
{
|
||||
op: 'add',
|
||||
path: '/spec/panels/panel-9/spec',
|
||||
value: spec,
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(mockGetQueryKey).toHaveBeenCalledWith({ id: 'dash-1' });
|
||||
expect(mockInvalidateQueries).toHaveBeenCalledWith([
|
||||
'/api/v2/dashboards/dash-1',
|
||||
expect(mockPatchAsync).toHaveBeenCalledWith([
|
||||
{
|
||||
op: 'add',
|
||||
path: '/spec/panels/panel-9/spec',
|
||||
value: spec,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('surfaces the mutation loading state as isSaving', () => {
|
||||
mockUsePatch.mockReturnValue({
|
||||
mutateAsync,
|
||||
isLoading: true,
|
||||
error: null,
|
||||
});
|
||||
it('surfaces the patch in-flight state as isSaving', () => {
|
||||
mockIsPatching = true;
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
usePanelEditorSave({ dashboardId: 'dash-1', panelId: 'panel-9' }),
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
import { useCallback } from 'react';
|
||||
import { useQueryClient } from 'react-query';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
import {
|
||||
getGetDashboardV2QueryKey,
|
||||
usePatchDashboardV2,
|
||||
} from 'api/generated/services/dashboard';
|
||||
import { getGetDashboardV2QueryKey } from 'api/generated/services/dashboard';
|
||||
import {
|
||||
type DashboardtypesJSONPatchOperationDTO,
|
||||
type DashboardtypesPanelSpecDTO,
|
||||
@@ -13,6 +10,7 @@ import {
|
||||
type GetDashboardV2200,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import { useOptimisticPatch } from '../../hooks/useOptimisticPatch';
|
||||
import { createPanelOps } from '../../patchOps';
|
||||
|
||||
interface UsePanelEditorSaveArgs {
|
||||
@@ -43,15 +41,14 @@ export function usePanelEditorSave({
|
||||
layoutIndex,
|
||||
}: UsePanelEditorSaveArgs): UsePanelEditorSaveApi {
|
||||
const queryClient = useQueryClient();
|
||||
const { mutateAsync, isLoading, error } = usePatchDashboardV2();
|
||||
const { patchAsync, isPatching, error } = useOptimisticPatch(dashboardId);
|
||||
|
||||
const save = useCallback(
|
||||
async (spec: DashboardtypesPanelSpecDTO): Promise<void> => {
|
||||
const dashboardQueryKey = getGetDashboardV2QueryKey({ id: dashboardId });
|
||||
|
||||
let ops: DashboardtypesJSONPatchOperationDTO[];
|
||||
if (isNew) {
|
||||
// Resolve the target section against the freshest dashboard we have.
|
||||
const dashboardQueryKey = getGetDashboardV2QueryKey({ id: dashboardId });
|
||||
const cached =
|
||||
queryClient.getQueryData<GetDashboardV2200>(dashboardQueryKey);
|
||||
ops = createPanelOps({
|
||||
@@ -70,11 +67,11 @@ export function usePanelEditorSave({
|
||||
];
|
||||
}
|
||||
|
||||
await mutateAsync({ pathParams: { id: dashboardId }, data: ops });
|
||||
await queryClient.invalidateQueries(dashboardQueryKey);
|
||||
// Optimistic cache write + settle refetch (replaces the manual invalidate).
|
||||
await patchAsync(ops);
|
||||
},
|
||||
[dashboardId, panelId, isNew, layoutIndex, mutateAsync, queryClient],
|
||||
[dashboardId, panelId, isNew, layoutIndex, patchAsync, queryClient],
|
||||
);
|
||||
|
||||
return { save, isSaving: isLoading, error: (error as Error) ?? null };
|
||||
return { save, isSaving: isPatching, error };
|
||||
}
|
||||
|
||||
@@ -1,22 +1,69 @@
|
||||
.panelTypeSection {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.grid {
|
||||
align-self: stretch;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.typeButton {
|
||||
.panelTypeCard {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
border: 1px solid var(--l2-border);
|
||||
background: var(--l2-background);
|
||||
padding: 12px;
|
||||
background: var(--bg-ink-400, #0b0c0e);
|
||||
border: 1px solid var(--l1-border);
|
||||
gap: 12px;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
border-radius: 4px;
|
||||
color: var(--l1-foreground);
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
transition:
|
||||
transform 180ms ease,
|
||||
border-color 180ms ease;
|
||||
|
||||
&:hover {
|
||||
border-color: var(--bg-robin-500);
|
||||
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;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.footerPicker {
|
||||
// Take all the width left over by the (natural-width) confirm button.
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.pickerLabel {
|
||||
color: var(--l3-foreground);
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
@@ -1,45 +1,135 @@
|
||||
import { Modal } from 'antd';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Color } from '@signozhq/design-tokens';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { DialogWrapper } from '@signozhq/ui/dialog';
|
||||
import cx from 'classnames';
|
||||
|
||||
import { useDashboardSections } from '../../../hooks/useDashboardSections';
|
||||
import type { PanelKind } from '../../../Panels/types/panelKind';
|
||||
import { PANEL_TYPES } from './constants';
|
||||
import SectionPicker from './SectionPicker';
|
||||
import { buildSectionOptions, resolveDefaultSectionValue } from './utils';
|
||||
import styles from './PanelTypeSelectionModal.module.scss';
|
||||
import { Plus } from '@signozhq/icons';
|
||||
|
||||
interface PanelTypeSelectionModalProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onSelect: (panelKind: PanelKind) => void;
|
||||
onSelect: (panelKind: PanelKind, layoutIndex?: number) => void;
|
||||
/** Section the picker opens on; omit → the untitled root / first section. */
|
||||
defaultLayoutIndex?: number;
|
||||
}
|
||||
|
||||
/** Fake loader shown on the confirm button before navigating to the editor. */
|
||||
const CONFIRM_LOADER_MS = 500;
|
||||
|
||||
function PanelTypeSelectionModal({
|
||||
open,
|
||||
onClose,
|
||||
onSelect,
|
||||
defaultLayoutIndex,
|
||||
}: PanelTypeSelectionModalProps): JSX.Element {
|
||||
const sections = useDashboardSections();
|
||||
const options = useMemo(() => buildSectionOptions(sections), [sections]);
|
||||
const hasSectionPicker = options.length > 1;
|
||||
|
||||
const [selectedValue, setSelectedValue] = useState('');
|
||||
const [selectedPanelKind, setSelectedPanelKind] = useState<PanelKind | null>(
|
||||
null,
|
||||
);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
const clearTimer = useCallback((): void => {
|
||||
if (timerRef.current !== null) {
|
||||
clearTimeout(timerRef.current);
|
||||
timerRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Seed the target section on open; cancel a pending navigation on close.
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setSelectedValue(resolveDefaultSectionValue(options, defaultLayoutIndex));
|
||||
setSelectedPanelKind(null);
|
||||
setIsSubmitting(false);
|
||||
} else {
|
||||
clearTimer();
|
||||
}
|
||||
}, [open, options, defaultLayoutIndex, clearTimer]);
|
||||
|
||||
useEffect(() => clearTimer, [clearTimer]);
|
||||
|
||||
const handleConfirm = (): void => {
|
||||
if (selectedPanelKind === null || isSubmitting) {
|
||||
return;
|
||||
}
|
||||
setIsSubmitting(true);
|
||||
const layoutIndex = selectedValue === '' ? undefined : Number(selectedValue);
|
||||
timerRef.current = setTimeout(() => {
|
||||
onSelect(selectedPanelKind, layoutIndex);
|
||||
}, CONFIRM_LOADER_MS);
|
||||
};
|
||||
|
||||
// Footer is always shown; the confirm button is disabled until a panel type is picked.
|
||||
const footer = (
|
||||
<div className={styles.footerActions}>
|
||||
{hasSectionPicker && (
|
||||
<div className={styles.footerPicker}>
|
||||
<span className={styles.pickerLabel}>Add panel to</span>
|
||||
<SectionPicker
|
||||
options={options}
|
||||
value={selectedValue}
|
||||
onChange={setSelectedValue}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<Button
|
||||
color="primary"
|
||||
size="md"
|
||||
disabled={selectedPanelKind === null}
|
||||
loading={isSubmitting}
|
||||
prefix={<Plus size={16} />}
|
||||
onClick={handleConfirm}
|
||||
testId="panel-type-confirm"
|
||||
>
|
||||
Add Panel
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
<DialogWrapper
|
||||
open={open}
|
||||
title="Select a panel type"
|
||||
onCancel={onClose}
|
||||
footer={null}
|
||||
destroyOnClose
|
||||
onOpenChange={(isOpen): void => {
|
||||
if (!isOpen) {
|
||||
onClose();
|
||||
}
|
||||
}}
|
||||
title="New Panel"
|
||||
footer={footer}
|
||||
>
|
||||
<div className={styles.grid}>
|
||||
{PANEL_TYPES.map(({ panelKind, label, Icon }) => (
|
||||
<Button
|
||||
key={panelKind}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
className={styles.typeButton}
|
||||
data-testid={`panel-type-${panelKind}`}
|
||||
onClick={(): void => onSelect(panelKind)}
|
||||
>
|
||||
<Icon size={14} />
|
||||
{label}
|
||||
</Button>
|
||||
))}
|
||||
<div className={styles.panelTypeSection}>
|
||||
<span className={styles.pickerLabel}>Select panel type</span>
|
||||
<div className={styles.grid}>
|
||||
{PANEL_TYPES.map(({ panelKind, label, Icon }) => (
|
||||
<button
|
||||
key={panelKind}
|
||||
type="button"
|
||||
className={cx(styles.panelTypeCard, {
|
||||
[styles.panelTypeCardSelected]: panelKind === selectedPanelKind,
|
||||
})}
|
||||
data-testid={`panel-type-${panelKind}`}
|
||||
aria-pressed={panelKind === selectedPanelKind}
|
||||
onClick={(): void => setSelectedPanelKind(panelKind)}
|
||||
>
|
||||
<Icon size={24} color={Color.BG_ROBIN_400} />
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
</DialogWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
.select {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.dropdown {
|
||||
min-width: 260px;
|
||||
}
|
||||
|
||||
.rootIcon {
|
||||
color: var(--bg-robin-400);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.sectionIcon {
|
||||
color: var(--l3-foreground);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.triggerValue {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.triggerLabel {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.optionRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
.optionText {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.optionLabel {
|
||||
color: var(--l1-foreground);
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.optionDescription {
|
||||
color: var(--l3-foreground);
|
||||
font-size: 12px;
|
||||
line-height: 1.2;
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import { useMemo } from 'react';
|
||||
// eslint-disable-next-line signoz/no-antd-components
|
||||
import { Select } from 'antd';
|
||||
|
||||
import type { SectionOption } from './types';
|
||||
import styles from './SectionPicker.module.scss';
|
||||
|
||||
interface SectionPickerProps {
|
||||
options: SectionOption[];
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
}
|
||||
|
||||
function SectionPicker({
|
||||
options,
|
||||
value,
|
||||
onChange,
|
||||
}: SectionPickerProps): JSX.Element {
|
||||
// `selectedLabel` (one line) shows in the trigger; `label` (two lines) in the list.
|
||||
const selectOptions = useMemo(
|
||||
() =>
|
||||
options.map((option) => {
|
||||
const iconClass = option.isRoot ? styles.rootIcon : styles.sectionIcon;
|
||||
return {
|
||||
value: option.value,
|
||||
selectedLabel: (
|
||||
<span className={styles.triggerValue}>
|
||||
<option.Icon size={16} className={iconClass} />
|
||||
<span className={styles.triggerLabel}>{option.label}</span>
|
||||
</span>
|
||||
),
|
||||
label: (
|
||||
<span
|
||||
className={styles.optionRow}
|
||||
data-testid={`panel-section-option-${option.layoutIndex}`}
|
||||
>
|
||||
<option.Icon size={16} className={iconClass} />
|
||||
<span className={styles.optionText}>
|
||||
<span className={styles.optionLabel}>{option.label}</span>
|
||||
<span className={styles.optionDescription}>{option.description}</span>
|
||||
</span>
|
||||
</span>
|
||||
),
|
||||
};
|
||||
}),
|
||||
[options],
|
||||
);
|
||||
|
||||
return (
|
||||
<Select<string>
|
||||
className={styles.select}
|
||||
popupClassName={styles.dropdown}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
data-testid="panel-section-select"
|
||||
optionLabelProp="selectedLabel"
|
||||
getPopupContainer={(trigger): HTMLElement =>
|
||||
trigger.parentElement ?? document.body
|
||||
}
|
||||
options={selectOptions}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default SectionPicker;
|
||||
@@ -14,3 +14,16 @@ export interface PanelType {
|
||||
/** Icon component — the consumer renders it and controls size/color/etc. */
|
||||
Icon: ComponentType<IconProps>;
|
||||
}
|
||||
|
||||
export interface SectionOption {
|
||||
/** The section's `layoutIndex`, stringified for the Select value. */
|
||||
value: string;
|
||||
layoutIndex: number;
|
||||
/** Section title, or "Dashboard (root)" for the untitled top-level layout. */
|
||||
label: string;
|
||||
/** Caption under the label. */
|
||||
description: string;
|
||||
/** Untitled top-level layout (has no section header). */
|
||||
isRoot: boolean;
|
||||
Icon: ComponentType<IconProps>;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import { LayoutDashboard, Rows2 } from '@signozhq/icons';
|
||||
|
||||
import type { DashboardSection } from '../../../utils';
|
||||
import type { SectionOption } from './types';
|
||||
|
||||
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"). */
|
||||
export function buildSectionOptions(
|
||||
sections: DashboardSection[],
|
||||
): SectionOption[] {
|
||||
return sections.map((section) => {
|
||||
const isRoot = !section.title;
|
||||
return {
|
||||
value: String(section.layoutIndex),
|
||||
layoutIndex: section.layoutIndex,
|
||||
label: isRoot ? ROOT_LABEL : (section.title as string),
|
||||
description: isRoot ? ROOT_DESCRIPTION : SECTION_DESCRIPTION,
|
||||
isRoot,
|
||||
Icon: isRoot ? LayoutDashboard : Rows2,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
export function resolveDefaultSectionValue(
|
||||
options: SectionOption[],
|
||||
defaultLayoutIndex: number | undefined,
|
||||
): string {
|
||||
const fallback = options[0]?.value ?? '';
|
||||
if (defaultLayoutIndex === undefined) {
|
||||
return fallback;
|
||||
}
|
||||
const target = String(defaultLayoutIndex);
|
||||
return options.some((option) => option.value === target) ? target : fallback;
|
||||
}
|
||||
@@ -1,12 +1,15 @@
|
||||
import { renderHook } from '@testing-library/react';
|
||||
import { patchDashboardV2 } from 'api/generated/services/dashboard';
|
||||
|
||||
import { useDashboardStore } from '../../../../store/useDashboardStore';
|
||||
import type { DashboardSection } from '../../../../utils';
|
||||
import { useClonePanel } from '../useClonePanel';
|
||||
|
||||
jest.mock('api/generated/services/dashboard', () => ({
|
||||
patchDashboardV2: jest.fn().mockResolvedValue(undefined),
|
||||
const mockPatchAsync = jest.fn().mockResolvedValue(undefined);
|
||||
jest.mock('../../../../hooks/useOptimisticPatch', () => ({
|
||||
useOptimisticPatch: (): { patchAsync: jest.Mock; isPatching: boolean } => ({
|
||||
patchAsync: mockPatchAsync,
|
||||
isPatching: false,
|
||||
}),
|
||||
}));
|
||||
|
||||
const mockToastPromise = jest.fn();
|
||||
@@ -16,8 +19,6 @@ jest.mock('@signozhq/ui/sonner', () => ({
|
||||
|
||||
jest.mock('uuid', () => ({ v4: (): string => 'cloned-id' }));
|
||||
|
||||
const mockPatch = patchDashboardV2 as unknown as jest.Mock;
|
||||
|
||||
const sourcePanel = {
|
||||
kind: 'Panel',
|
||||
spec: {
|
||||
@@ -45,7 +46,7 @@ function sections(): DashboardSection[] {
|
||||
describe('useClonePanel', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
useDashboardStore.setState({ dashboardId: 'dash-1', refetch: jest.fn() });
|
||||
useDashboardStore.setState({ dashboardId: 'dash-1' });
|
||||
});
|
||||
|
||||
it('patches an add of the deep-copied spec + a new item under the same section', async () => {
|
||||
@@ -53,7 +54,7 @@ describe('useClonePanel', () => {
|
||||
|
||||
await result.current({ panelId: 'p1', layoutIndex: 0 });
|
||||
|
||||
expect(mockPatch).toHaveBeenCalledWith({ id: 'dash-1' }, [
|
||||
expect(mockPatchAsync).toHaveBeenCalledWith([
|
||||
{
|
||||
op: 'add',
|
||||
path: '/spec/panels/cloned-id',
|
||||
@@ -92,7 +93,7 @@ describe('useClonePanel', () => {
|
||||
|
||||
await result.current({ panelId: 'p1', layoutIndex: 0 });
|
||||
|
||||
const ops = mockPatch.mock.calls[0][1];
|
||||
const ops = mockPatchAsync.mock.calls[0][0];
|
||||
// Room in the last row (4 + 4 = 8 ≤ 12 cols) → sits to the right at y:0.
|
||||
expect(ops[1].value).toMatchObject({ x: 4, y: 0, width: 4, height: 5 });
|
||||
});
|
||||
@@ -102,7 +103,7 @@ describe('useClonePanel', () => {
|
||||
|
||||
await result.current({ panelId: 'p1', layoutIndex: 0 });
|
||||
|
||||
const ops = mockPatch.mock.calls[0][1];
|
||||
const ops = mockPatchAsync.mock.calls[0][0];
|
||||
expect(ops[0].value).toStrictEqual(sourcePanel);
|
||||
expect(ops[0].value).not.toBe(sourcePanel);
|
||||
});
|
||||
@@ -112,7 +113,7 @@ describe('useClonePanel', () => {
|
||||
|
||||
await result.current({ panelId: 'missing', layoutIndex: 0 });
|
||||
|
||||
expect(mockPatch).not.toHaveBeenCalled();
|
||||
expect(mockPatchAsync).not.toHaveBeenCalled();
|
||||
expect(mockToastPromise).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -132,7 +133,7 @@ describe('useClonePanel', () => {
|
||||
});
|
||||
|
||||
it('swallows a patch rejection (toast owns the error UX) — does not throw', async () => {
|
||||
mockPatch.mockRejectedValueOnce(new Error('boom'));
|
||||
mockPatchAsync.mockRejectedValueOnce(new Error('boom'));
|
||||
const { result } = renderHook(() => useClonePanel({ sections: sections() }));
|
||||
|
||||
await expect(
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { renderHook } from '@testing-library/react';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { Querybuildertypesv5VariableTypeDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { useDashboardStore } from 'pages/DashboardPageV2/DashboardContainer/store/useDashboardStore';
|
||||
|
||||
@@ -18,12 +19,55 @@ jest.mock('hooks/useSafeNavigate', () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
// The V5→V1 query→URL translation is covered by buildCreateAlertUrl's own tests;
|
||||
// stub it so this asserts only the hook's side effects (analytics + navigation).
|
||||
jest.mock('../../utils/buildCreateAlertUrl', () => ({
|
||||
buildCreateAlertUrl: (): string => '/alerts/new?composite=1',
|
||||
const mockToastError = jest.fn();
|
||||
jest.mock('@signozhq/ui/sonner', () => ({
|
||||
toast: { error: (...args: unknown[]): void => mockToastError(...args) },
|
||||
}));
|
||||
|
||||
jest.mock('react-redux', () => ({
|
||||
useSelector: (selector: (state: unknown) => unknown): unknown =>
|
||||
selector({ globalTime: { minTime: 1_000_000, maxTime: 2_000_000 } }),
|
||||
}));
|
||||
|
||||
const mockSubstituteVars = jest.fn();
|
||||
jest.mock('api/generated/services/querier', () => ({
|
||||
useReplaceVariables: (): { mutate: jest.Mock } => ({
|
||||
mutate: mockSubstituteVars,
|
||||
}),
|
||||
}));
|
||||
|
||||
// Stub the builders so this asserts only the hook's orchestration.
|
||||
jest.mock('../../utils/buildCreateAlertUrl', () => ({
|
||||
buildCreateAlertUrl: (): string => '/alerts/new?composite=sync',
|
||||
buildAlertUrl: (): string => '/alerts/new?composite=substituted',
|
||||
readPanelUnit: (): string | undefined => undefined,
|
||||
}));
|
||||
|
||||
// Keep the real exports (getPanelQueryType reads them); stub only the builder.
|
||||
const mockBuildQueryRangeRequest = jest.fn((_args?: unknown) => ({
|
||||
request: 'payload',
|
||||
}));
|
||||
jest.mock(
|
||||
'pages/DashboardPageV2/DashboardContainer/queryV5/buildQueryRangeRequest',
|
||||
() => ({
|
||||
...jest.requireActual(
|
||||
'pages/DashboardPageV2/DashboardContainer/queryV5/buildQueryRangeRequest',
|
||||
),
|
||||
buildQueryRangeRequest: (args: unknown): unknown =>
|
||||
mockBuildQueryRangeRequest(args),
|
||||
}),
|
||||
);
|
||||
|
||||
jest.mock(
|
||||
'pages/DashboardPageV2/DashboardContainer/queryV5/persesQueryAdapters',
|
||||
() => ({
|
||||
...jest.requireActual(
|
||||
'pages/DashboardPageV2/DashboardContainer/queryV5/persesQueryAdapters',
|
||||
),
|
||||
envelopesToQuery: (): unknown => ({ resolved: 'query' }),
|
||||
}),
|
||||
);
|
||||
|
||||
const mockLogEvent = logEvent as jest.Mock;
|
||||
|
||||
const panel = {
|
||||
@@ -38,17 +82,7 @@ const panel = {
|
||||
describe('useCreateAlertFromPanel', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
useDashboardStore.setState({ dashboardId: 'dash-1' });
|
||||
});
|
||||
|
||||
it('opens the seeded alert builder in a new tab', () => {
|
||||
const { result } = renderHook(() => useCreateAlertFromPanel());
|
||||
|
||||
result.current(panel, 'panel-1');
|
||||
|
||||
expect(mockSafeNavigate).toHaveBeenCalledWith('/alerts/new?composite=1', {
|
||||
newTab: true,
|
||||
});
|
||||
useDashboardStore.setState({ dashboardId: 'dash-1', resolvedVariables: {} });
|
||||
});
|
||||
|
||||
it('logs the create-alert action with panel and dashboard context (V1 parity)', () => {
|
||||
@@ -66,4 +100,80 @@ describe('useCreateAlertFromPanel', () => {
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
describe('with no variable selections', () => {
|
||||
it('seeds the alert synchronously without a substitute round-trip', () => {
|
||||
const { result } = renderHook(() => useCreateAlertFromPanel());
|
||||
|
||||
result.current(panel, 'panel-1');
|
||||
|
||||
expect(mockSubstituteVars).not.toHaveBeenCalled();
|
||||
expect(mockSafeNavigate).toHaveBeenCalledWith('/alerts/new?composite=sync', {
|
||||
newTab: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('with variable selections', () => {
|
||||
beforeEach(() => {
|
||||
useDashboardStore.setState({
|
||||
dashboardId: 'dash-1',
|
||||
resolvedVariables: {
|
||||
'dash-1': {
|
||||
service: {
|
||||
type: Querybuildertypesv5VariableTypeDTO.query,
|
||||
value: 'checkout',
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('substitutes variables before seeding, then opens the resolved alert', () => {
|
||||
const { result } = renderHook(() => useCreateAlertFromPanel());
|
||||
|
||||
result.current(panel, 'panel-1');
|
||||
|
||||
// Round-trips the panel's queries + resolved variables.
|
||||
expect(mockBuildQueryRangeRequest).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
queries: panel.spec.queries,
|
||||
panelType: PANEL_TYPES.TIME_SERIES,
|
||||
variables: { service: { type: 'query', value: 'checkout' } },
|
||||
}),
|
||||
);
|
||||
expect(mockSubstituteVars).toHaveBeenCalledWith(
|
||||
{ data: { request: 'payload' } },
|
||||
expect.objectContaining({
|
||||
onSuccess: expect.any(Function),
|
||||
onError: expect.any(Function),
|
||||
}),
|
||||
);
|
||||
// Nothing opens until the round-trip resolves.
|
||||
expect(mockSafeNavigate).not.toHaveBeenCalled();
|
||||
|
||||
const { onSuccess } = mockSubstituteVars.mock.calls[0][1];
|
||||
onSuccess({ data: { compositeQuery: { queries: [{ type: 'builder' }] } } });
|
||||
|
||||
expect(mockSafeNavigate).toHaveBeenCalledWith(
|
||||
'/alerts/new?composite=substituted',
|
||||
{ newTab: true },
|
||||
);
|
||||
});
|
||||
|
||||
it('notifies and does not navigate when substitution fails', () => {
|
||||
const { result } = renderHook(() => useCreateAlertFromPanel());
|
||||
|
||||
result.current(panel, 'panel-1');
|
||||
|
||||
const { onError } = mockSubstituteVars.mock.calls[0][1];
|
||||
onError();
|
||||
|
||||
expect(mockToastError).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
expect.objectContaining({ description: expect.any(String) }),
|
||||
);
|
||||
expect(mockSafeNavigate).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,8 +3,7 @@ import { toast } from '@signozhq/ui/sonner';
|
||||
import { cloneDeep } from 'lodash-es';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
|
||||
import { patchDashboardV2 } from 'api/generated/services/dashboard';
|
||||
|
||||
import { useOptimisticPatch } from '../../../hooks/useOptimisticPatch';
|
||||
import {
|
||||
addPanelToSectionOps,
|
||||
findFreeSlot,
|
||||
@@ -32,7 +31,7 @@ export function useClonePanel({
|
||||
sections,
|
||||
}: Params): (args: ClonePanelArgs) => Promise<void> {
|
||||
const dashboardId = useDashboardStore((s) => s.dashboardId);
|
||||
const refetch = useDashboardStore((s) => s.refetch);
|
||||
const { patchAsync } = useOptimisticPatch();
|
||||
|
||||
return useCallback(
|
||||
async ({ panelId, layoutIndex }: ClonePanelArgs): Promise<void> => {
|
||||
@@ -45,8 +44,7 @@ export function useClonePanel({
|
||||
const newPanelId = uuid();
|
||||
const { x, y } = findFreeSlot(section.items, source.width);
|
||||
|
||||
const clone = patchDashboardV2(
|
||||
{ id: dashboardId },
|
||||
const clone = patchAsync(
|
||||
addPanelToSectionOps({
|
||||
panelId: newPanelId,
|
||||
panel: cloneDeep(source.panel),
|
||||
@@ -68,15 +66,14 @@ export function useClonePanel({
|
||||
position: 'top-center',
|
||||
});
|
||||
|
||||
// Refetch only on success; toast.promise owns the error UX, so swallow
|
||||
// the rejection to avoid an unhandled rejection.
|
||||
// toast.promise owns the error UX; swallow here to avoid an unhandled
|
||||
// rejection (the optimistic cache write + settle refetch handle state).
|
||||
try {
|
||||
await clone;
|
||||
refetch();
|
||||
} catch {
|
||||
// no-op
|
||||
}
|
||||
},
|
||||
[sections, dashboardId, refetch],
|
||||
[sections, dashboardId, patchAsync],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,18 +1,32 @@
|
||||
import { useCallback } from 'react';
|
||||
// eslint-disable-next-line no-restricted-imports -- global time still lives in redux
|
||||
import { useSelector } from 'react-redux';
|
||||
import { toast } from '@signozhq/ui/sonner';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import { useReplaceVariables } from 'api/generated/services/querier';
|
||||
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { SOMETHING_WENT_WRONG } from 'constants/api';
|
||||
import { useSafeNavigate } from 'hooks/useSafeNavigate';
|
||||
import { PANEL_KIND_TO_PANEL_TYPE } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/panelKind';
|
||||
import { getPanelQueryType } from 'pages/DashboardPageV2/DashboardContainer/Panels/utils/getPanelQueryType';
|
||||
import { buildQueryRangeRequest } from 'pages/DashboardPageV2/DashboardContainer/queryV5/buildQueryRangeRequest';
|
||||
import { envelopesToQuery } from 'pages/DashboardPageV2/DashboardContainer/queryV5/persesQueryAdapters';
|
||||
import { selectResolvedVariables } from 'pages/DashboardPageV2/DashboardContainer/store/slices/variableSelectionSlice';
|
||||
import { useDashboardStore } from 'pages/DashboardPageV2/DashboardContainer/store/useDashboardStore';
|
||||
import { AppState } from 'store/reducers';
|
||||
import { GlobalReducer } from 'types/reducer/globalTime';
|
||||
|
||||
import { buildCreateAlertUrl } from '../utils/buildCreateAlertUrl';
|
||||
import {
|
||||
buildAlertUrl,
|
||||
buildCreateAlertUrl,
|
||||
readPanelUnit,
|
||||
} from '../utils/buildCreateAlertUrl';
|
||||
|
||||
/**
|
||||
* Returns a callback that opens the alert builder in a new tab, seeded from a
|
||||
* panel's query, and logs the action — mirroring V1's `useCreateAlerts`
|
||||
* ('dashboardView' caller). The panel is supplied at call time so the callback
|
||||
* stays stable across panels (and the dashboard's react-query refetches).
|
||||
* Callback that seeds the alert builder from a panel's query in a new tab (V1 parity
|
||||
* with `useCreateAlerts`; panel supplied at call time so the callback stays stable).
|
||||
* With variable selections, resolves them via `/substitute_vars` first; otherwise
|
||||
* seeds synchronously (the round-trip would be a no-op).
|
||||
*/
|
||||
export function useCreateAlertFromPanel(): (
|
||||
panel: DashboardtypesPanelDTO,
|
||||
@@ -20,18 +34,61 @@ export function useCreateAlertFromPanel(): (
|
||||
) => void {
|
||||
const { safeNavigate } = useSafeNavigate();
|
||||
const dashboardId = useDashboardStore((s) => s.dashboardId);
|
||||
const variables = useDashboardStore(selectResolvedVariables(dashboardId));
|
||||
const { maxTime, minTime } = useSelector<AppState, GlobalReducer>(
|
||||
(state) => state.globalTime,
|
||||
);
|
||||
const { mutate: substituteVars } = useReplaceVariables();
|
||||
|
||||
return useCallback(
|
||||
(panel: DashboardtypesPanelDTO, panelId: string): void => {
|
||||
const panelType = PANEL_KIND_TO_PANEL_TYPE[panel.spec.plugin.kind];
|
||||
|
||||
void logEvent('Dashboard Detail: Panel action', {
|
||||
action: 'createAlerts',
|
||||
panelType: PANEL_KIND_TO_PANEL_TYPE[panel.spec.plugin.kind],
|
||||
panelType,
|
||||
dashboardId,
|
||||
widgetId: panelId,
|
||||
queryType: getPanelQueryType(panel),
|
||||
});
|
||||
safeNavigate(buildCreateAlertUrl(panel), { newTab: true });
|
||||
|
||||
if (Object.keys(variables).length === 0) {
|
||||
safeNavigate(buildCreateAlertUrl(panel), { newTab: true });
|
||||
return;
|
||||
}
|
||||
|
||||
// Redux global time is nanoseconds; the request DTO takes epoch ms.
|
||||
const request = buildQueryRangeRequest({
|
||||
queries: panel.spec.queries,
|
||||
panelType,
|
||||
startMs: minTime / 1e6,
|
||||
endMs: maxTime / 1e6,
|
||||
variables,
|
||||
});
|
||||
|
||||
substituteVars(
|
||||
{ data: request },
|
||||
{
|
||||
onSuccess: (response) => {
|
||||
const query = envelopesToQuery(
|
||||
response.data.compositeQuery?.queries ?? [],
|
||||
panelType,
|
||||
);
|
||||
const url = buildAlertUrl(
|
||||
query,
|
||||
panelType,
|
||||
readPanelUnit(panel.spec.plugin),
|
||||
);
|
||||
safeNavigate(url, { newTab: true });
|
||||
},
|
||||
onError: () => {
|
||||
toast.error(SOMETHING_WENT_WRONG, {
|
||||
description: 'Failed to create alert from panel',
|
||||
});
|
||||
},
|
||||
},
|
||||
);
|
||||
},
|
||||
[dashboardId, safeNavigate],
|
||||
[dashboardId, variables, minTime, maxTime, substituteVars, safeNavigate],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { useCallback } from 'react';
|
||||
|
||||
import { patchDashboardV2 } from 'api/generated/services/dashboard';
|
||||
import { useErrorModal } from 'providers/ErrorModalProvider';
|
||||
import APIError from 'types/api/error';
|
||||
|
||||
import { useOptimisticPatch } from '../../../hooks/useOptimisticPatch';
|
||||
import { removePanelOp, replaceSectionItemsOp } from '../../../patchOps';
|
||||
import { useDashboardStore } from '../../../store/useDashboardStore';
|
||||
import type { DashboardSection } from '../../../utils';
|
||||
@@ -25,7 +25,7 @@ export function useDeletePanel({
|
||||
sections,
|
||||
}: Params): (args: DeletePanelArgs) => Promise<void> {
|
||||
const dashboardId = useDashboardStore((s) => s.dashboardId);
|
||||
const refetch = useDashboardStore((s) => s.refetch);
|
||||
const { patchAsync } = useOptimisticPatch();
|
||||
const { showErrorModal } = useErrorModal();
|
||||
|
||||
return useCallback(
|
||||
@@ -40,15 +40,14 @@ export function useDeletePanel({
|
||||
|
||||
const nextItems = section.items.filter((i) => i.id !== panelId);
|
||||
try {
|
||||
await patchDashboardV2({ id: dashboardId }, [
|
||||
await patchAsync([
|
||||
replaceSectionItemsOp(layoutIndex, nextItems),
|
||||
removePanelOp(panelId),
|
||||
]);
|
||||
refetch();
|
||||
} catch (error) {
|
||||
showErrorModal(error as APIError);
|
||||
}
|
||||
},
|
||||
[sections, dashboardId, refetch, showErrorModal],
|
||||
[sections, dashboardId, patchAsync, showErrorModal],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { useCallback } from 'react';
|
||||
|
||||
import { patchDashboardV2 } from 'api/generated/services/dashboard';
|
||||
import { useErrorModal } from 'providers/ErrorModalProvider';
|
||||
import APIError from 'types/api/error';
|
||||
|
||||
import { useOptimisticPatch } from '../../../hooks/useOptimisticPatch';
|
||||
import { movePanelBetweenSectionsOps } from '../../../patchOps';
|
||||
import { useDashboardStore } from '../../../store/useDashboardStore';
|
||||
import type { DashboardSection } from '../../../utils';
|
||||
@@ -27,7 +27,7 @@ export function useMovePanelToSection({
|
||||
sections,
|
||||
}: Params): (args: MovePanelArgs) => Promise<void> {
|
||||
const dashboardId = useDashboardStore((s) => s.dashboardId);
|
||||
const refetch = useDashboardStore((s) => s.refetch);
|
||||
const { patchAsync } = useOptimisticPatch();
|
||||
const { showErrorModal } = useErrorModal();
|
||||
|
||||
return useCallback(
|
||||
@@ -60,8 +60,7 @@ export function useMovePanelToSection({
|
||||
const targetItems = [...target.items, { ...moved, x: 0, y: nextY }];
|
||||
|
||||
try {
|
||||
await patchDashboardV2(
|
||||
{ id: dashboardId },
|
||||
await patchAsync(
|
||||
movePanelBetweenSectionsOps({
|
||||
sourceIndex: fromLayoutIndex,
|
||||
sourceItems,
|
||||
@@ -69,11 +68,10 @@ export function useMovePanelToSection({
|
||||
targetItems,
|
||||
}),
|
||||
);
|
||||
refetch();
|
||||
} catch (error) {
|
||||
showErrorModal(error as APIError);
|
||||
}
|
||||
},
|
||||
[sections, dashboardId, refetch, showErrorModal],
|
||||
[sections, dashboardId, patchAsync, showErrorModal],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,11 +5,14 @@ import type {
|
||||
import { YAxisSource } from 'components/YAxisUnitSelector/types';
|
||||
import { ENTITY_VERSION_V5 } from 'constants/app';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { PANEL_KIND_TO_PANEL_TYPE } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/panelKind';
|
||||
import { fromPerses } from 'pages/DashboardPageV2/DashboardContainer/queryV5/persesQueryAdapters';
|
||||
import type { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
function readPanelUnit(
|
||||
/** The panel's configured y-axis unit, for the kinds that carry one. */
|
||||
export function readPanelUnit(
|
||||
plugin: DashboardtypesPanelPluginDTO,
|
||||
): string | undefined {
|
||||
switch (plugin.kind) {
|
||||
@@ -24,20 +27,17 @@ function readPanelUnit(
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the `/alerts/new` URL that seeds the alert builder from a panel's query,
|
||||
* mirroring V1's `useCreateAlerts`: the panel's V5 queries are translated to the
|
||||
* V1 `Query` the alert page reads from `compositeQuery`, tagged with the panel
|
||||
* type, entity version, and a `dashboards` source.
|
||||
*
|
||||
* Unlike V1 there is no `/substitute_vars` round-trip — V2 has no query-variable
|
||||
* plumbing yet, so any dashboard-variable references travel through verbatim.
|
||||
* Assembles the `/alerts/new` URL from a ready V1 `Query`: the alert page reads it
|
||||
* from `compositeQuery`, tagged with the panel type, entity version, and a
|
||||
* `dashboards` source.
|
||||
*/
|
||||
export function buildCreateAlertUrl(panel: DashboardtypesPanelDTO): string {
|
||||
const panelType = PANEL_KIND_TO_PANEL_TYPE[panel.spec.plugin.kind];
|
||||
const query = fromPerses(panel.spec.queries, panelType);
|
||||
|
||||
const unit = readPanelUnit(panel.spec.plugin);
|
||||
export function buildAlertUrl(
|
||||
query: Query,
|
||||
panelType: PANEL_TYPES,
|
||||
unit?: string,
|
||||
): string {
|
||||
if (unit) {
|
||||
// eslint-disable-next-line no-param-reassign
|
||||
query.unit = unit;
|
||||
}
|
||||
|
||||
@@ -52,3 +52,15 @@ export function buildCreateAlertUrl(panel: DashboardtypesPanelDTO): string {
|
||||
|
||||
return `${ROUTES.ALERTS_NEW}?${params.toString()}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Seeds the alert builder from a panel's query — the no-variable path, so any
|
||||
* dashboard-variable references travel through verbatim. When the dashboard has
|
||||
* selections, `useCreateAlertFromPanel` runs a `/substitute_vars` round-trip first
|
||||
* and assembles the URL from the resolved queries via {@link buildAlertUrl}.
|
||||
*/
|
||||
export function buildCreateAlertUrl(panel: DashboardtypesPanelDTO): string {
|
||||
const panelType = PANEL_KIND_TO_PANEL_TYPE[panel.spec.plugin.kind];
|
||||
const query = fromPerses(panel.spec.queries, panelType);
|
||||
return buildAlertUrl(query, panelType, readPanelUnit(panel.spec.plugin));
|
||||
}
|
||||
|
||||
@@ -29,8 +29,13 @@ interface SectionProps {
|
||||
|
||||
function Section({ section, sections, dragHandle }: SectionProps): JSX.Element {
|
||||
const isEditable = useDashboardStore((s) => s.isEditable);
|
||||
const { isPickerOpen, openPicker, closePicker, createPanel } =
|
||||
useCreatePanel();
|
||||
const {
|
||||
isPickerOpen,
|
||||
openPicker,
|
||||
closePicker,
|
||||
createPanel,
|
||||
targetLayoutIndex,
|
||||
} = useCreatePanel();
|
||||
const [isDeleteOpen, setIsDeleteOpen] = useState(false);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
// Placeholder signal for lazy panel query-loading (consumed in a later PR):
|
||||
@@ -141,6 +146,7 @@ function Section({ section, sections, dragHandle }: SectionProps): JSX.Element {
|
||||
open={isPickerOpen}
|
||||
onClose={closePicker}
|
||||
onSelect={createPanel}
|
||||
defaultLayoutIndex={targetLayoutIndex}
|
||||
/>
|
||||
<ConfirmDeleteDialog
|
||||
open={isDeleteOpen}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { useCallback, useState } from 'react';
|
||||
|
||||
import { patchDashboardV2 } from 'api/generated/services/dashboard';
|
||||
import type { DashboardtypesLayoutDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { useErrorModal } from 'providers/ErrorModalProvider';
|
||||
import APIError from 'types/api/error';
|
||||
|
||||
import { useOptimisticPatch } from '../../../hooks/useOptimisticPatch';
|
||||
import {
|
||||
addSectionOp,
|
||||
newGridLayout,
|
||||
@@ -15,9 +15,9 @@ import { useDashboardStore } from '../../../store/useDashboardStore';
|
||||
const SECTION_SELECTOR = '[data-testid^="dashboard-section-"]';
|
||||
|
||||
/**
|
||||
* Waits (via rAF) for the refetch to render the appended section, then scrolls
|
||||
* it into view. Polls because `refetch` resolves before React commits the new
|
||||
* section to the DOM; bails after ~40 frames.
|
||||
* 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);
|
||||
@@ -49,7 +49,7 @@ interface Result {
|
||||
*/
|
||||
export function useAddSection({ layouts }: Params): Result {
|
||||
const dashboardId = useDashboardStore((s) => s.dashboardId);
|
||||
const refetch = useDashboardStore((s) => s.refetch);
|
||||
const { patchAsync } = useOptimisticPatch();
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const { showErrorModal } = useErrorModal();
|
||||
|
||||
@@ -66,8 +66,7 @@ export function useAddSection({ layouts }: Params): Result {
|
||||
const prevSectionCount = document.querySelectorAll(SECTION_SELECTOR).length;
|
||||
try {
|
||||
setIsSaving(true);
|
||||
await patchDashboardV2({ id: dashboardId }, [op]);
|
||||
refetch();
|
||||
await patchAsync([op]);
|
||||
scrollToNewSection(prevSectionCount);
|
||||
} catch (error) {
|
||||
showErrorModal(error as APIError);
|
||||
@@ -75,7 +74,7 @@ export function useAddSection({ layouts }: Params): Result {
|
||||
setIsSaving(false);
|
||||
}
|
||||
},
|
||||
[layouts, dashboardId, refetch, showErrorModal],
|
||||
[layouts, dashboardId, patchAsync, showErrorModal],
|
||||
);
|
||||
|
||||
return { addSection, isSaving };
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { useCallback, useState } from 'react';
|
||||
|
||||
import { patchDashboardV2 } from 'api/generated/services/dashboard';
|
||||
import type { DashboardtypesJSONPatchOperationDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { useErrorModal } from 'providers/ErrorModalProvider';
|
||||
import APIError from 'types/api/error';
|
||||
|
||||
import { useOptimisticPatch } from '../../../hooks/useOptimisticPatch';
|
||||
import { removePanelOp, removeSectionOp } from '../../../patchOps';
|
||||
import { useDashboardStore } from '../../../store/useDashboardStore';
|
||||
import type { DashboardSection } from '../../../utils';
|
||||
@@ -24,7 +24,7 @@ interface Result {
|
||||
*/
|
||||
export function useDeleteSection({ section }: Params): Result {
|
||||
const dashboardId = useDashboardStore((s) => s.dashboardId);
|
||||
const refetch = useDashboardStore((s) => s.refetch);
|
||||
const { patchAsync } = useOptimisticPatch();
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const { showErrorModal } = useErrorModal();
|
||||
|
||||
@@ -38,14 +38,13 @@ export function useDeleteSection({ section }: Params): Result {
|
||||
ops.push(removeSectionOp(section.layoutIndex));
|
||||
try {
|
||||
setIsSaving(true);
|
||||
await patchDashboardV2({ id: dashboardId }, ops);
|
||||
refetch();
|
||||
await patchAsync(ops);
|
||||
} catch (error) {
|
||||
showErrorModal(error as APIError);
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
}, [section, dashboardId, refetch, showErrorModal]);
|
||||
}, [section, dashboardId, patchAsync, showErrorModal]);
|
||||
|
||||
return { deleteSection, isSaving };
|
||||
}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { useCallback, useState } from 'react';
|
||||
|
||||
import { patchDashboardV2 } from 'api/generated/services/dashboard';
|
||||
import type { DashboardtypesJSONPatchOperationDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { useErrorModal } from 'providers/ErrorModalProvider';
|
||||
import APIError from 'types/api/error';
|
||||
|
||||
import { useOptimisticPatch } from '../../../hooks/useOptimisticPatch';
|
||||
import { addSectionOp, titleUntitledSectionOp } from '../../../patchOps';
|
||||
import { useDashboardStore } from '../../../store/useDashboardStore';
|
||||
import type { DashboardSection } from '../../../utils';
|
||||
@@ -26,7 +26,7 @@ interface Result {
|
||||
*/
|
||||
export function useFirstSectionMigration({ sections }: Params): Result {
|
||||
const dashboardId = useDashboardStore((s) => s.dashboardId);
|
||||
const refetch = useDashboardStore((s) => s.refetch);
|
||||
const { patchAsync } = useOptimisticPatch();
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const { showErrorModal } = useErrorModal();
|
||||
|
||||
@@ -49,15 +49,14 @@ export function useFirstSectionMigration({ sections }: Params): Result {
|
||||
|
||||
try {
|
||||
setIsSaving(true);
|
||||
await patchDashboardV2({ id: dashboardId }, ops);
|
||||
refetch();
|
||||
await patchAsync(ops);
|
||||
} catch (error) {
|
||||
showErrorModal(error as APIError);
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
},
|
||||
[sections, dashboardId, refetch, showErrorModal],
|
||||
[sections, dashboardId, patchAsync, showErrorModal],
|
||||
);
|
||||
|
||||
return { migrate, isSaving };
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { useCallback, useState } from 'react';
|
||||
import type { Layout } from 'react-grid-layout';
|
||||
|
||||
import { patchDashboardV2 } from 'api/generated/services/dashboard';
|
||||
import { useErrorModal } from 'providers/ErrorModalProvider';
|
||||
import APIError from 'types/api/error';
|
||||
|
||||
import { useOptimisticPatch } from '../../../hooks/useOptimisticPatch';
|
||||
import { replaceSectionItemsOp } from '../../../patchOps';
|
||||
import { useDashboardStore } from '../../../store/useDashboardStore';
|
||||
import type { GridItem } from '../../../utils';
|
||||
@@ -65,7 +65,7 @@ function hasGeometryChanged(next: GridItem[], prev: GridItem[]): boolean {
|
||||
*/
|
||||
export function usePersistLayout({ layoutIndex, items }: Params): Result {
|
||||
const dashboardId = useDashboardStore((s) => s.dashboardId);
|
||||
const refetch = useDashboardStore((s) => s.refetch);
|
||||
const { patchAsync } = useOptimisticPatch();
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const { showErrorModal } = useErrorModal();
|
||||
|
||||
@@ -80,17 +80,14 @@ export function usePersistLayout({ layoutIndex, items }: Params): Result {
|
||||
}
|
||||
try {
|
||||
setIsSaving(true);
|
||||
await patchDashboardV2({ id: dashboardId }, [
|
||||
replaceSectionItemsOp(layoutIndex, nextItems),
|
||||
]);
|
||||
refetch();
|
||||
await patchAsync([replaceSectionItemsOp(layoutIndex, nextItems)]);
|
||||
} catch (error) {
|
||||
showErrorModal(error as APIError);
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
},
|
||||
[dashboardId, items, layoutIndex, refetch, showErrorModal],
|
||||
[dashboardId, items, layoutIndex, patchAsync, showErrorModal],
|
||||
);
|
||||
|
||||
return { handleLayoutChange, isSaving };
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { useCallback, useState } from 'react';
|
||||
|
||||
import { patchDashboardV2 } from 'api/generated/services/dashboard';
|
||||
import { useErrorModal } from 'providers/ErrorModalProvider';
|
||||
import APIError from 'types/api/error';
|
||||
|
||||
import { useOptimisticPatch } from '../../../hooks/useOptimisticPatch';
|
||||
import { renameSectionOp } from '../../../patchOps';
|
||||
import { useDashboardStore } from '../../../store/useDashboardStore';
|
||||
|
||||
@@ -19,7 +19,7 @@ interface Result {
|
||||
/** Renames a section's title via `replace /spec/layouts/<i>/spec/display/title`. */
|
||||
export function useRenameSection({ layoutIndex }: Params): Result {
|
||||
const dashboardId = useDashboardStore((s) => s.dashboardId);
|
||||
const refetch = useDashboardStore((s) => s.refetch);
|
||||
const { patchAsync } = useOptimisticPatch();
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const { showErrorModal } = useErrorModal();
|
||||
|
||||
@@ -31,10 +31,7 @@ export function useRenameSection({ layoutIndex }: Params): Result {
|
||||
}
|
||||
try {
|
||||
setIsSaving(true);
|
||||
await patchDashboardV2({ id: dashboardId }, [
|
||||
renameSectionOp(layoutIndex, trimmed),
|
||||
]);
|
||||
refetch();
|
||||
await patchAsync([renameSectionOp(layoutIndex, trimmed)]);
|
||||
return true;
|
||||
} catch (error) {
|
||||
showErrorModal(error as APIError);
|
||||
@@ -43,7 +40,7 @@ export function useRenameSection({ layoutIndex }: Params): Result {
|
||||
setIsSaving(false);
|
||||
}
|
||||
},
|
||||
[dashboardId, layoutIndex, refetch, showErrorModal],
|
||||
[dashboardId, layoutIndex, patchAsync, showErrorModal],
|
||||
);
|
||||
|
||||
return { rename, isSaving };
|
||||
|
||||
@@ -9,11 +9,11 @@ import {
|
||||
} from '@dnd-kit/core';
|
||||
import { arrayMove, sortableKeyboardCoordinates } from '@dnd-kit/sortable';
|
||||
|
||||
import { patchDashboardV2 } from 'api/generated/services/dashboard';
|
||||
import type { DashboardtypesLayoutDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { useErrorModal } from 'providers/ErrorModalProvider';
|
||||
import APIError from 'types/api/error';
|
||||
|
||||
import { useOptimisticPatch } from '../../../hooks/useOptimisticPatch';
|
||||
import { reorderLayoutsOp } from '../../../patchOps';
|
||||
import { useDashboardStore } from '../../../store/useDashboardStore';
|
||||
import type { DashboardSection } from '../../../utils';
|
||||
@@ -43,7 +43,7 @@ interface Result {
|
||||
*/
|
||||
export function useSectionDragReorder({ sections, layouts }: Params): Result {
|
||||
const dashboardId = useDashboardStore((s) => s.dashboardId);
|
||||
const refetch = useDashboardStore((s) => s.refetch);
|
||||
const { patchAsync } = useOptimisticPatch();
|
||||
const [activeId, setActiveId] = useState<string | null>(null);
|
||||
const [localOrderIds, setLocalOrderIds] = useState<string[] | null>(null);
|
||||
const { showErrorModal } = useErrorModal();
|
||||
@@ -99,14 +99,13 @@ export function useSectionDragReorder({ sections, layouts }: Params): Result {
|
||||
.filter((l): l is DashboardtypesLayoutDTO => l !== undefined);
|
||||
|
||||
try {
|
||||
await patchDashboardV2({ id: dashboardId }, [reorderLayoutsOp(newLayouts)]);
|
||||
refetch();
|
||||
await patchAsync([reorderLayoutsOp(newLayouts)]);
|
||||
} catch (error) {
|
||||
setLocalOrderIds(null); // revert optimistic order on failure
|
||||
showErrorModal(error as APIError);
|
||||
}
|
||||
},
|
||||
[orderedSections, layouts, dashboardId, refetch, showErrorModal],
|
||||
[orderedSections, layouts, dashboardId, patchAsync, showErrorModal],
|
||||
);
|
||||
|
||||
const activeSection = useMemo(
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
import { renderHook } from '@testing-library/react';
|
||||
import { useMutation, useQueryClient } from 'react-query';
|
||||
// eslint-disable-next-line no-restricted-imports -- the hook's own test mocks and asserts the underlying patchDashboardV2 call.
|
||||
import { patchDashboardV2 } from 'api/generated/services/dashboard';
|
||||
import type { GetDashboardV2200 } from 'api/generated/services/sigNoz.schemas';
|
||||
import { DashboardtypesPatchOpDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import { useOptimisticPatch } from '../useOptimisticPatch';
|
||||
|
||||
const QUERY_KEY = ['/api/v2/dashboards/dash-1'];
|
||||
|
||||
jest.mock('react-query', () => ({
|
||||
useMutation: jest.fn(),
|
||||
useQueryClient: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('api/generated/services/dashboard', () => ({
|
||||
patchDashboardV2: jest.fn(),
|
||||
getGetDashboardV2QueryKey: jest.fn(() => ['/api/v2/dashboards/dash-1']),
|
||||
}));
|
||||
|
||||
jest.mock('../../store/useDashboardStore', () => ({
|
||||
useDashboardStore: jest.fn(
|
||||
(selector: (s: { dashboardId: string }) => unknown) =>
|
||||
selector({ dashboardId: 'dash-1' }),
|
||||
),
|
||||
}));
|
||||
|
||||
const queryClient = {
|
||||
cancelQueries: jest.fn().mockResolvedValue(undefined),
|
||||
getQueryData: jest.fn(),
|
||||
setQueryData: jest.fn(),
|
||||
invalidateQueries: jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
let captured: { fn: (ops: any) => unknown; options: any };
|
||||
|
||||
function dashboardEnvelope(name: string): GetDashboardV2200 {
|
||||
return {
|
||||
data: { spec: { display: { name } } },
|
||||
} as unknown as GetDashboardV2200;
|
||||
}
|
||||
|
||||
const replaceNameOp = {
|
||||
op: DashboardtypesPatchOpDTO.replace,
|
||||
path: '/spec/display/name',
|
||||
value: 'B',
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
(useQueryClient as jest.Mock).mockReturnValue(queryClient);
|
||||
(useMutation as jest.Mock).mockImplementation((fn, options) => {
|
||||
captured = { fn, options };
|
||||
return { mutateAsync: jest.fn(), isLoading: false };
|
||||
});
|
||||
renderHook(() => useOptimisticPatch());
|
||||
});
|
||||
|
||||
describe('useOptimisticPatch', () => {
|
||||
it('mutationFn sends the ops to patchDashboardV2 for the current dashboard', () => {
|
||||
captured.fn([replaceNameOp]);
|
||||
expect(patchDashboardV2).toHaveBeenCalledWith({ id: 'dash-1' }, [
|
||||
replaceNameOp,
|
||||
]);
|
||||
});
|
||||
|
||||
it('onMutate cancels fetches, snapshots, and writes the patched dashboard to the cache', async () => {
|
||||
const previous = dashboardEnvelope('A');
|
||||
queryClient.getQueryData.mockReturnValue(previous);
|
||||
|
||||
const context = await captured.options.onMutate([replaceNameOp]);
|
||||
|
||||
expect(queryClient.cancelQueries).toHaveBeenCalledWith(QUERY_KEY);
|
||||
// Optimistic write reflects the op immediately.
|
||||
expect(queryClient.setQueryData).toHaveBeenCalledWith(QUERY_KEY, {
|
||||
data: { spec: { display: { name: 'B' } } },
|
||||
});
|
||||
// Snapshot returned for rollback; original left untouched.
|
||||
expect(context).toStrictEqual({ previous });
|
||||
expect(previous.data).toStrictEqual({ spec: { display: { name: 'A' } } });
|
||||
});
|
||||
|
||||
it('onMutate is a no-op write when there is no cached dashboard', async () => {
|
||||
queryClient.getQueryData.mockReturnValue(undefined);
|
||||
const context = await captured.options.onMutate([replaceNameOp]);
|
||||
expect(queryClient.setQueryData).not.toHaveBeenCalled();
|
||||
expect(context).toStrictEqual({ previous: undefined });
|
||||
});
|
||||
|
||||
it('onError rolls the cache back to the snapshot', () => {
|
||||
const previous = dashboardEnvelope('A');
|
||||
captured.options.onError(new Error('boom'), [replaceNameOp], { previous });
|
||||
expect(queryClient.setQueryData).toHaveBeenCalledWith(QUERY_KEY, previous);
|
||||
});
|
||||
|
||||
it('onError without a snapshot does not touch the cache', () => {
|
||||
captured.options.onError(new Error('boom'), [replaceNameOp], {});
|
||||
expect(queryClient.setQueryData).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('onSettled invalidates the dashboard query to reconcile', () => {
|
||||
captured.options.onSettled();
|
||||
expect(queryClient.invalidateQueries).toHaveBeenCalledWith(QUERY_KEY);
|
||||
});
|
||||
});
|
||||
@@ -12,7 +12,10 @@ interface UseCreatePanelResult {
|
||||
/** Pass the target section's layout index; omit → last/new section. */
|
||||
openPicker: (layoutIndex?: number) => void;
|
||||
closePicker: () => void;
|
||||
createPanel: (panelKind: PanelKind) => 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;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -38,16 +41,23 @@ export function useCreatePanel(): UseCreatePanelResult {
|
||||
}, []);
|
||||
|
||||
const createPanel = useCallback(
|
||||
(panelKind: PanelKind): void => {
|
||||
(panelKind: PanelKind, targetIndex?: number): void => {
|
||||
setIsPickerOpen(false);
|
||||
const path = generatePath(ROUTES.DASHBOARD_PANEL_EDITOR, {
|
||||
dashboardId,
|
||||
panelId: NEW_PANEL_ID,
|
||||
});
|
||||
safeNavigate(`${path}${newPanelSearch(panelKind, layoutIndex)}`);
|
||||
const target = targetIndex ?? layoutIndex;
|
||||
safeNavigate(`${path}${newPanelSearch(panelKind, target)}`);
|
||||
},
|
||||
[safeNavigate, dashboardId, layoutIndex],
|
||||
);
|
||||
|
||||
return { isPickerOpen, openPicker, closePicker, createPanel };
|
||||
return {
|
||||
isPickerOpen,
|
||||
openPicker,
|
||||
closePicker,
|
||||
targetLayoutIndex: layoutIndex,
|
||||
createPanel,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useGetDashboardV2 } from 'api/generated/services/dashboard';
|
||||
|
||||
import { useDashboardStore } from '../store/useDashboardStore';
|
||||
import { type DashboardSection, layoutsToSections } from '../utils';
|
||||
|
||||
/**
|
||||
* The current dashboard's sections, read from the already-loaded dashboard
|
||||
* query. The page fetches via useGetDashboardV2 keyed by id, so this reuses that
|
||||
* cache (no extra request) instead of prop-drilling the section list.
|
||||
*/
|
||||
export function useDashboardSections(): DashboardSection[] {
|
||||
const dashboardId = useDashboardStore((s) => s.dashboardId);
|
||||
const { data } = useGetDashboardV2({ id: dashboardId });
|
||||
const spec = data?.data?.spec;
|
||||
|
||||
return useMemo(
|
||||
() => layoutsToSections(spec?.layouts, spec?.panels),
|
||||
[spec?.layouts, spec?.panels],
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import { useMutation, useQueryClient } from 'react-query';
|
||||
import {
|
||||
getGetDashboardV2QueryKey,
|
||||
// eslint-disable-next-line no-restricted-imports -- this hook is the one sanctioned caller of patchDashboardV2; everything else goes through patchAsync.
|
||||
patchDashboardV2,
|
||||
} from 'api/generated/services/dashboard';
|
||||
import type {
|
||||
DashboardtypesJSONPatchOperationDTO,
|
||||
GetDashboardV2200,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import APIError from 'types/api/error';
|
||||
|
||||
import { applyJsonPatch } from '../optimistic/applyJsonPatch';
|
||||
import { useDashboardStore } from '../store/useDashboardStore';
|
||||
|
||||
/** Cached dashboard snapshot, kept for rollback on error. */
|
||||
interface OptimisticPatchContext {
|
||||
previous?: GetDashboardV2200;
|
||||
}
|
||||
|
||||
export interface UseOptimisticPatch {
|
||||
patchAsync: (ops: DashboardtypesJSONPatchOperationDTO[]) => Promise<unknown>;
|
||||
isPatching: boolean;
|
||||
error: Error | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Central optimistic mutation for V2 dashboard spec edits: writes the ops to the
|
||||
* cached dashboard immediately, rolls back on error, reconciles on settle.
|
||||
* `dashboardId` defaults to the edit-context store; the panel editor passes its own.
|
||||
*/
|
||||
export function useOptimisticPatch(
|
||||
dashboardIdOverride?: string,
|
||||
): UseOptimisticPatch {
|
||||
const storeDashboardId = useDashboardStore((s) => s.dashboardId);
|
||||
const dashboardId = dashboardIdOverride ?? storeDashboardId;
|
||||
const queryClient = useQueryClient();
|
||||
const queryKey = getGetDashboardV2QueryKey({ id: dashboardId });
|
||||
|
||||
const mutation = useMutation<
|
||||
Awaited<ReturnType<typeof patchDashboardV2>>,
|
||||
APIError,
|
||||
DashboardtypesJSONPatchOperationDTO[],
|
||||
OptimisticPatchContext
|
||||
>((ops) => patchDashboardV2({ id: dashboardId }, ops), {
|
||||
onMutate: async (ops) => {
|
||||
await queryClient.cancelQueries(queryKey);
|
||||
const previous = queryClient.getQueryData<GetDashboardV2200>(queryKey);
|
||||
if (previous?.data) {
|
||||
// Ops are rooted at the DTO's `/spec`, so patch `.data`, keep the envelope.
|
||||
queryClient.setQueryData<GetDashboardV2200>(queryKey, {
|
||||
...previous,
|
||||
data: applyJsonPatch(previous.data, ops),
|
||||
});
|
||||
}
|
||||
return { previous };
|
||||
},
|
||||
onError: (_error, _ops, context) => {
|
||||
if (context?.previous) {
|
||||
queryClient.setQueryData(queryKey, context.previous);
|
||||
}
|
||||
},
|
||||
onSettled: () => {
|
||||
void queryClient.invalidateQueries(queryKey);
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
patchAsync: mutation.mutateAsync,
|
||||
isPatching: mutation.isLoading,
|
||||
error: mutation.error ?? null,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
import type { DashboardtypesJSONPatchOperationDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { DashboardtypesPatchOpDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import { applyJsonPatch } from '../applyJsonPatch';
|
||||
|
||||
const { add, replace, remove, move, test: testOp } = DashboardtypesPatchOpDTO;
|
||||
|
||||
function op(
|
||||
o: DashboardtypesPatchOpDTO,
|
||||
path: string,
|
||||
value?: unknown,
|
||||
): DashboardtypesJSONPatchOperationDTO {
|
||||
return { op: o, path, value };
|
||||
}
|
||||
|
||||
// A trimmed dashboard-spec shape; the applier is structural, so this stands in
|
||||
// for the full DTO.
|
||||
function spec(): Record<string, unknown> {
|
||||
return {
|
||||
spec: {
|
||||
display: { name: 'dash' },
|
||||
panels: { p1: { spec: { display: { name: 'A' } } } },
|
||||
layouts: [
|
||||
{ spec: { display: { title: 'S1' }, items: [{ x: 0 }] } },
|
||||
{ spec: { items: [] } },
|
||||
],
|
||||
variables: [{ name: 'env' }],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe('applyJsonPatch', () => {
|
||||
it('does not mutate the input document', () => {
|
||||
const doc = spec();
|
||||
const snapshot = JSON.stringify(doc);
|
||||
applyJsonPatch(doc, [op(replace, '/spec/display/name', 'renamed')]);
|
||||
expect(JSON.stringify(doc)).toBe(snapshot);
|
||||
});
|
||||
|
||||
it('replaces a leaf string', () => {
|
||||
const next = applyJsonPatch(spec(), [
|
||||
op(replace, '/spec/layouts/0/spec/display/title', 'S1-renamed'),
|
||||
]);
|
||||
const layouts = (next.spec as any).layouts;
|
||||
expect(layouts[0].spec.display.title).toBe('S1-renamed');
|
||||
});
|
||||
|
||||
it('adds a new object member (panel by id)', () => {
|
||||
const next = applyJsonPatch(spec(), [
|
||||
op(add, '/spec/panels/p2', { spec: { display: { name: 'B' } } }),
|
||||
]);
|
||||
expect((next.spec as any).panels.p2.spec.display.name).toBe('B');
|
||||
// existing member untouched
|
||||
expect((next.spec as any).panels.p1.spec.display.name).toBe('A');
|
||||
});
|
||||
|
||||
it('appends to an array with the "-" token', () => {
|
||||
const next = applyJsonPatch(spec(), [
|
||||
op(add, '/spec/layouts/-', { spec: { items: [] } }),
|
||||
]);
|
||||
expect((next.spec as any).layouts).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('appends an item into a nested section array', () => {
|
||||
const next = applyJsonPatch(spec(), [
|
||||
op(add, '/spec/layouts/1/spec/items/-', { x: 5 }),
|
||||
]);
|
||||
expect((next.spec as any).layouts[1].spec.items).toStrictEqual([{ x: 5 }]);
|
||||
});
|
||||
|
||||
it('replaces a whole array', () => {
|
||||
const next = applyJsonPatch(spec(), [
|
||||
op(replace, '/spec/variables', [{ name: 'region' }, { name: 'pod' }]),
|
||||
]);
|
||||
expect((next.spec as any).variables).toStrictEqual([
|
||||
{ name: 'region' },
|
||||
{ name: 'pod' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('removes an array element by index (section)', () => {
|
||||
const next = applyJsonPatch(spec(), [op(remove, '/spec/layouts/0')]);
|
||||
const layouts = (next.spec as any).layouts;
|
||||
expect(layouts).toHaveLength(1);
|
||||
expect(layouts[0].spec.items).toStrictEqual([]);
|
||||
});
|
||||
|
||||
it('removes an object member (panel by id)', () => {
|
||||
const next = applyJsonPatch(spec(), [op(remove, '/spec/panels/p1')]);
|
||||
expect((next.spec as any).panels).toStrictEqual({});
|
||||
});
|
||||
|
||||
it('adds a missing object parent for an add op (title untitled section)', () => {
|
||||
const next = applyJsonPatch(spec(), [
|
||||
op(add, '/spec/layouts/1/spec/display', { title: 'S2' }),
|
||||
]);
|
||||
expect((next.spec as any).layouts[1].spec.display).toStrictEqual({
|
||||
title: 'S2',
|
||||
});
|
||||
});
|
||||
|
||||
it('is lenient: remove on a missing path is a no-op', () => {
|
||||
const next = applyJsonPatch(spec(), [op(remove, '/spec/panels/ghost')]);
|
||||
expect((next.spec as any).panels.p1).toBeDefined();
|
||||
});
|
||||
|
||||
it('is lenient: a path through a missing node is skipped', () => {
|
||||
const next = applyJsonPatch(spec(), [op(replace, '/spec/nope/deep/leaf', 1)]);
|
||||
expect(next).toStrictEqual(spec());
|
||||
});
|
||||
|
||||
it('unescapes ~1 and ~0 in reference tokens', () => {
|
||||
const doc = { spec: { m: { 'a/b': 1, 'c~d': 2 } } };
|
||||
const next = applyJsonPatch(doc, [
|
||||
op(replace, '/spec/m/a~1b', 9),
|
||||
op(replace, '/spec/m/c~0d', 8),
|
||||
]);
|
||||
expect(next.spec.m).toStrictEqual({ 'a/b': 9, 'c~d': 8 });
|
||||
});
|
||||
|
||||
it('applies multiple ops in order', () => {
|
||||
const next = applyJsonPatch(spec(), [
|
||||
op(add, '/spec/panels/p2', { spec: {} }),
|
||||
op(remove, '/spec/panels/p1'),
|
||||
op(replace, '/spec/display/name', 'z'),
|
||||
]);
|
||||
expect(Object.keys((next.spec as any).panels)).toStrictEqual(['p2']);
|
||||
expect((next.spec as any).display.name).toBe('z');
|
||||
});
|
||||
|
||||
it('treats move/copy/test as no-ops', () => {
|
||||
const next = applyJsonPatch(spec(), [
|
||||
op(move, '/spec/display/name'),
|
||||
op(testOp, '/spec/display/name', 'dash'),
|
||||
]);
|
||||
expect(next).toStrictEqual(spec());
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,146 @@
|
||||
import type { DashboardtypesJSONPatchOperationDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { DashboardtypesPatchOpDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { cloneDeep } from 'lodash-es';
|
||||
|
||||
/**
|
||||
* Applies the RFC-6902 ops our `patchOps` builders emit to a document, so a
|
||||
* dashboard edit can be reflected in the react-query cache optimistically before
|
||||
* the server responds. Pure: deep-clones and returns a new document, never
|
||||
* mutating the input.
|
||||
*
|
||||
* Deliberately lenient — mirrors the backend's apply (a `remove`/`replace` on a
|
||||
* missing path is a no-op, `add` creates missing object parents) rather than
|
||||
* throwing as strict RFC-6902 would. This is safe because the mutation always
|
||||
* refetches on settle, so any mis-applied edge op self-corrects; the applier only
|
||||
* needs to be right for the common case to kill the perceived lag.
|
||||
*
|
||||
* Scope: `add` / `replace` / `remove` (the only ops the builders produce).
|
||||
* `move` / `copy` / `test` are never emitted, so they are treated as no-ops.
|
||||
*/
|
||||
export function applyJsonPatch<T>(
|
||||
doc: T,
|
||||
ops: DashboardtypesJSONPatchOperationDTO[],
|
||||
): T {
|
||||
const next = cloneDeep(doc);
|
||||
ops.forEach((op) => applyOperation(next as unknown, op));
|
||||
return next;
|
||||
}
|
||||
|
||||
type JsonRecord = Record<string, unknown>;
|
||||
|
||||
function isArray(value: unknown): value is unknown[] {
|
||||
return Array.isArray(value);
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is JsonRecord {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
/** Unescape one JSON-Pointer reference token (RFC-6901): `~1`→`/`, `~0`→`~`. */
|
||||
function unescapeToken(token: string): string {
|
||||
return token.replace(/~1/g, '/').replace(/~0/g, '~');
|
||||
}
|
||||
|
||||
/** Parse a JSON Pointer into its reference tokens (`""`/`"/"` → root, `[]`). */
|
||||
function parsePointer(path: string): string[] {
|
||||
if (!path || path === '/') {
|
||||
return [];
|
||||
}
|
||||
return path.slice(1).split('/').map(unescapeToken);
|
||||
}
|
||||
|
||||
/**
|
||||
* Walks to the container that holds the pointer's last token. Returns `undefined`
|
||||
* when the path can't be resolved (lenient skip). For `add`, missing intermediate
|
||||
* object nodes are created (backend parity); array steps are never auto-created.
|
||||
*/
|
||||
function navigateToParent(
|
||||
root: unknown,
|
||||
tokens: string[],
|
||||
createMissing: boolean,
|
||||
): unknown {
|
||||
let current: unknown = root;
|
||||
for (let i = 0; i < tokens.length - 1; i += 1) {
|
||||
const token = tokens[i];
|
||||
if (isArray(current)) {
|
||||
const index = token === '-' ? current.length : Number(token);
|
||||
current = current[index];
|
||||
} else if (isRecord(current)) {
|
||||
if (current[token] === undefined && createMissing) {
|
||||
current[token] = {};
|
||||
}
|
||||
current = current[token];
|
||||
} else {
|
||||
return undefined;
|
||||
}
|
||||
if (current === undefined || current === null) {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
return current;
|
||||
}
|
||||
|
||||
/** `add`: array-insert (`-` = append) or object-set. */
|
||||
function addAt(parent: unknown, key: string, value: unknown): void {
|
||||
if (isArray(parent)) {
|
||||
const index = key === '-' ? parent.length : Number(key);
|
||||
parent.splice(index, 0, value);
|
||||
} else if (isRecord(parent)) {
|
||||
parent[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
/** `replace`: overwrite an in-range array index or an object key. */
|
||||
function replaceAt(parent: unknown, key: string, value: unknown): void {
|
||||
if (isArray(parent)) {
|
||||
const index = Number(key);
|
||||
if (index >= 0 && index < parent.length) {
|
||||
parent[index] = value;
|
||||
}
|
||||
} else if (isRecord(parent)) {
|
||||
parent[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
/** `remove`: splice an in-range array index or delete an object key (lenient). */
|
||||
function removeAt(parent: unknown, key: string): void {
|
||||
if (isArray(parent)) {
|
||||
const index = Number(key);
|
||||
if (index >= 0 && index < parent.length) {
|
||||
parent.splice(index, 1);
|
||||
}
|
||||
} else if (isRecord(parent)) {
|
||||
delete parent[key];
|
||||
}
|
||||
}
|
||||
|
||||
function applyOperation(
|
||||
root: unknown,
|
||||
op: DashboardtypesJSONPatchOperationDTO,
|
||||
): void {
|
||||
const tokens = parsePointer(op.path);
|
||||
// Whole-document ops would need to reassign the root reference — our builders
|
||||
// never target root, so skip rather than complicate the contract.
|
||||
if (tokens.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const parent = navigateToParent(
|
||||
root,
|
||||
tokens,
|
||||
op.op === DashboardtypesPatchOpDTO.add,
|
||||
);
|
||||
if (parent === undefined || parent === null) {
|
||||
return;
|
||||
}
|
||||
const key = tokens[tokens.length - 1];
|
||||
|
||||
// move / copy / test are never emitted by our builders → no-op (reconciled by refetch).
|
||||
if (op.op === DashboardtypesPatchOpDTO.add) {
|
||||
addAt(parent, key, op.value);
|
||||
} else if (op.op === DashboardtypesPatchOpDTO.replace) {
|
||||
replaceAt(parent, key, op.value);
|
||||
} else if (op.op === DashboardtypesPatchOpDTO.remove) {
|
||||
removeAt(parent, key);
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,13 @@
|
||||
import type { DashboardtypesQueryDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import type {
|
||||
DashboardtypesQueryDTO,
|
||||
Querybuildertypesv5QueryEnvelopeDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import type { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { EQueryType } from 'types/common/dashboard';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import { fromPerses, toPerses } from '../persesQueryAdapters';
|
||||
import { envelopesToQuery, fromPerses, toPerses } from '../persesQueryAdapters';
|
||||
|
||||
/** A bare perses query (single plugin, not wrapped in a CompositeQuery). */
|
||||
function bareQuery(
|
||||
@@ -58,6 +61,26 @@ describe('persesQueryAdapters', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('envelopesToQuery', () => {
|
||||
it('returns the metrics default for an empty envelope list', () => {
|
||||
expect(envelopesToQuery([], PANEL_TYPES.TIME_SERIES)).toStrictEqual(
|
||||
initialQueriesMap[DataSource.METRICS],
|
||||
);
|
||||
});
|
||||
|
||||
it('maps a promql envelope to a PromQL query', () => {
|
||||
const envelopes: Querybuildertypesv5QueryEnvelopeDTO[] = [
|
||||
{
|
||||
type: 'promql',
|
||||
spec: { name: 'A', query: 'up', disabled: false },
|
||||
} as unknown as Querybuildertypesv5QueryEnvelopeDTO,
|
||||
];
|
||||
expect(envelopesToQuery(envelopes, PANEL_TYPES.TIME_SERIES).queryType).toBe(
|
||||
EQueryType.PROM,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('toPerses', () => {
|
||||
it('wraps the query in a single signoz/CompositeQuery keyed to the panel request type', () => {
|
||||
const result = toPerses(
|
||||
|
||||
@@ -74,14 +74,14 @@ export function deriveQueryType(
|
||||
}
|
||||
|
||||
/**
|
||||
* Perses panel queries → V1 `Query` (to seed the query builder), via the V5 envelope
|
||||
* list + `mapQueryDataFromApi`. An empty panel opens on a fresh metrics builder query.
|
||||
* V5 query-envelope list → V1 `Query`, via `mapQueryDataFromApi`. An empty list opens
|
||||
* on a fresh metrics builder query. Used by `fromPerses` and by the envelopes a
|
||||
* `/substitute_vars` round-trip returns with dashboard variables resolved.
|
||||
*/
|
||||
export function fromPerses(
|
||||
queries: DashboardtypesQueryDTO[],
|
||||
export function envelopesToQuery(
|
||||
envelopes: Querybuildertypesv5QueryEnvelopeDTO[],
|
||||
panelType: PANEL_TYPES,
|
||||
): Query {
|
||||
const envelopes = toQueryEnvelopes(queries);
|
||||
if (envelopes.length === 0) {
|
||||
return initialQueriesMap[DataSource.METRICS];
|
||||
}
|
||||
@@ -99,6 +99,17 @@ export function fromPerses(
|
||||
return mapQueryDataFromApi(composite);
|
||||
}
|
||||
|
||||
/**
|
||||
* Perses panel queries → V1 `Query` (to seed the query builder), via the V5 envelope
|
||||
* list + `mapQueryDataFromApi`. An empty panel opens on a fresh metrics builder query.
|
||||
*/
|
||||
export function fromPerses(
|
||||
queries: DashboardtypesQueryDTO[],
|
||||
panelType: PANEL_TYPES,
|
||||
): Query {
|
||||
return envelopesToQuery(toQueryEnvelopes(queries), panelType);
|
||||
}
|
||||
|
||||
/**
|
||||
* V1 `Query` → perses panel queries (to write the builder result back to the editor
|
||||
* draft). Wrapped in a single `signoz/CompositeQuery` to satisfy the
|
||||
|
||||
Reference in New Issue
Block a user