Compare commits

...

21 Commits

Author SHA1 Message Date
Abhi Kumar
52eedce12f fix: failing test 2026-03-13 19:54:49 +05:30
Abhi Kumar
b2c0291c11 fix: transform react-resizable-panels in jest config 2026-03-13 19:38:04 +05:30
Abhi Kumar
83bf21fb6f chore: prettier fmt fix 2026-03-13 19:23:08 +05:30
Abhi Kumar
3443b25791 chore: disabled chart apperance section 2026-03-13 19:21:32 +05:30
Abhi Kumar
6ac88d2a17 Merge branch 'main' of https://github.com/SigNoz/signoz into feat/chart-apperance-section 2026-03-13 19:16:47 +05:30
Abhi Kumar
0c1078c494 chore: fixed styles 2026-03-13 18:59:43 +05:30
Abhi kumar
937ebc1582 feat: added section in panel settings (#10569)
* feat: added section in panel settings

* chore: minor changes

* fix: fixed failing tests

* fix: minor style fixes

* chore: updated the categorisation

* chore: updated styles

* chore: minor styles improvements

* chore: formatting unit section fix
2026-03-13 13:22:10 +00:00
Abhi Kumar
d0ab05a84d chore: formatting unit section fix 2026-03-13 18:28:59 +05:30
Abhi Kumar
d7b681eaf8 chore: minor styles improvements 2026-03-13 18:24:01 +05:30
Abhi Kumar
fa1620f4da chore: updated styles 2026-03-13 18:20:31 +05:30
Abhi Kumar
69a3d214fb chore: updated styles + made panel config resizable 2026-03-13 18:12:47 +05:30
Ashwin Bhatkal
dcc8173c79 fix: variables initial url state (#10579)
* fix: variables-initial-url-state

* chore: add tests
2026-03-13 11:16:47 +00:00
Ashwin Bhatkal
4b4ef5ce58 fix: edit mode variables not persisting value (#10576)
Some checks failed
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
build-staging / staging (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
* fix: edit mode variables not persisting value

* chore: move into hook

* chore: add tests

* chore: fix tests

* chore: move functions
2026-03-13 07:49:40 +00:00
Yunus M
5b8d5fbfd3 Revert "feat: Option to zoom out OR reset zoom in the explorer pages (#10464)" (#10574)
Some checks failed
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
build-staging / staging (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
This reverts commit 557451ed81.
2026-03-12 19:24:49 +00:00
Abhi Kumar
4affdeda56 feat: added fill mode in timeseries 2026-03-13 00:37:25 +05:30
Abhi Kumar
99944cc1de feat: added chart appearance settings in panel 2026-03-12 21:25:58 +05:30
Abhi Kumar
d1bd36e88a chore: updated the categorisation 2026-03-12 19:33:54 +05:30
Abhi Kumar
d26d4ebd31 fix: minor style fixes 2026-03-12 18:44:20 +05:30
Abhi Kumar
771e5bd287 fix: fixed failing tests 2026-03-12 17:22:15 +05:30
Abhi Kumar
bd33304912 chore: minor changes 2026-03-12 17:18:19 +05:30
Abhi Kumar
ca1cc0a4ac feat: added section in panel settings 2026-03-12 15:41:56 +05:30
54 changed files with 1971 additions and 1492 deletions

5
.github/CODEOWNERS vendored
View File

@@ -127,12 +127,15 @@
/frontend/src/pages/DashboardsListPage/ @SigNoz/pulse-frontend /frontend/src/pages/DashboardsListPage/ @SigNoz/pulse-frontend
/frontend/src/container/ListOfDashboard/ @SigNoz/pulse-frontend /frontend/src/container/ListOfDashboard/ @SigNoz/pulse-frontend
# Dashboard Widget Page
/frontend/src/pages/DashboardWidget/ @SigNoz/pulse-frontend
/frontend/src/container/NewWidget/ @SigNoz/pulse-frontend
## Dashboard Page ## Dashboard Page
/frontend/src/pages/DashboardPage/ @SigNoz/pulse-frontend /frontend/src/pages/DashboardPage/ @SigNoz/pulse-frontend
/frontend/src/container/DashboardContainer/ @SigNoz/pulse-frontend /frontend/src/container/DashboardContainer/ @SigNoz/pulse-frontend
/frontend/src/container/GridCardLayout/ @SigNoz/pulse-frontend /frontend/src/container/GridCardLayout/ @SigNoz/pulse-frontend
/frontend/src/container/NewWidget/ @SigNoz/pulse-frontend
## Public Dashboard Page ## Public Dashboard Page

View File

@@ -0,0 +1,29 @@
import { PropsWithChildren } from 'react';
type CommonProps = PropsWithChildren<{
className?: string;
minSize?: number;
maxSize?: number;
defaultSize?: number;
direction?: 'horizontal' | 'vertical';
autoSaveId?: string;
withHandle?: boolean;
}>;
export function ResizablePanelGroup({
children,
className,
}: CommonProps): JSX.Element {
return <div className={className}>{children}</div>;
}
export function ResizablePanel({
children,
className,
}: CommonProps): JSX.Element {
return <div className={className}>{children}</div>;
}
export function ResizableHandle({ className }: CommonProps): JSX.Element {
return <div className={className} />;
}

View File

@@ -14,6 +14,7 @@ const config: Config.InitialOptions = {
'\\.(css|less|scss)$': '<rootDir>/__mocks__/cssMock.ts', '\\.(css|less|scss)$': '<rootDir>/__mocks__/cssMock.ts',
'\\.md$': '<rootDir>/__mocks__/cssMock.ts', '\\.md$': '<rootDir>/__mocks__/cssMock.ts',
'^uplot$': '<rootDir>/__mocks__/uplotMock.ts', '^uplot$': '<rootDir>/__mocks__/uplotMock.ts',
'^@signozhq/resizable$': '<rootDir>/__mocks__/resizableMock.tsx',
'^hooks/useSafeNavigate$': USE_SAFE_NAVIGATE_MOCK_PATH, '^hooks/useSafeNavigate$': USE_SAFE_NAVIGATE_MOCK_PATH,
'^src/hooks/useSafeNavigate$': USE_SAFE_NAVIGATE_MOCK_PATH, '^src/hooks/useSafeNavigate$': USE_SAFE_NAVIGATE_MOCK_PATH,
'^.*/useSafeNavigate$': USE_SAFE_NAVIGATE_MOCK_PATH, '^.*/useSafeNavigate$': USE_SAFE_NAVIGATE_MOCK_PATH,

View File

@@ -64,7 +64,7 @@
"@signozhq/sonner": "0.1.0", "@signozhq/sonner": "0.1.0",
"@signozhq/switch": "0.0.2", "@signozhq/switch": "0.0.2",
"@signozhq/table": "0.3.7", "@signozhq/table": "0.3.7",
"@signozhq/toggle-group": "^0.0.1", "@signozhq/toggle-group": "0.0.1",
"@signozhq/tooltip": "0.0.2", "@signozhq/tooltip": "0.0.2",
"@tanstack/react-table": "8.20.6", "@tanstack/react-table": "8.20.6",
"@tanstack/react-virtual": "3.11.2", "@tanstack/react-virtual": "3.11.2",

View File

@@ -1,31 +1,6 @@
.custom-time-picker { .custom-time-picker {
display: flex; display: flex;
flex-direction: row; flex-direction: column;
align-items: center;
gap: 4px;
.zoom-out-btn {
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
color: var(--foreground);
border: 1px solid var(--border);
border-radius: 2px;
box-shadow: none;
padding: 10px;
height: 33px;
&:hover:not(:disabled) {
color: var(--bg-vanilla-100);
background: var(--primary);
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
}
.timeSelection-input { .timeSelection-input {
&:hover { &:hover {

View File

@@ -16,15 +16,6 @@ jest.mock('react-router-dom', () => {
}; };
}); });
jest.mock('react-redux', () => ({
...jest.requireActual('react-redux'),
useDispatch: jest.fn(() => jest.fn()),
useSelector: jest.fn(() => ({
minTime: 0,
maxTime: Date.now(),
})),
}));
jest.mock('providers/Timezone', () => { jest.mock('providers/Timezone', () => {
const actual = jest.requireActual('providers/Timezone'); const actual = jest.requireActual('providers/Timezone');

View File

@@ -7,11 +7,9 @@ import {
useState, useState,
} from 'react'; } from 'react';
import { useLocation } from 'react-router-dom'; import { useLocation } from 'react-router-dom';
import { Button } from '@signozhq/button';
import { Input, InputRef, Popover, Tooltip } from 'antd'; import { Input, InputRef, Popover, Tooltip } from 'antd';
import cx from 'classnames'; import cx from 'classnames';
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats'; import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
import { QueryParams } from 'constants/query';
import { DateTimeRangeType } from 'container/TopNav/CustomDateTimeModal'; import { DateTimeRangeType } from 'container/TopNav/CustomDateTimeModal';
import { import {
FixedDurationSuggestionOptions, FixedDurationSuggestionOptions,
@@ -19,11 +17,9 @@ import {
RelativeDurationSuggestionOptions, RelativeDurationSuggestionOptions,
} from 'container/TopNav/DateTimeSelectionV2/constants'; } from 'container/TopNav/DateTimeSelectionV2/constants';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import { useZoomOut } from 'hooks/useZoomOut';
import { isValidShortHandDateTimeFormat } from 'lib/getMinMax'; import { isValidShortHandDateTimeFormat } from 'lib/getMinMax';
import { isZoomOutDisabled } from 'lib/zoomOutUtils';
import { defaultTo, isFunction, noop } from 'lodash-es'; import { defaultTo, isFunction, noop } from 'lodash-es';
import { ChevronDown, ChevronUp, ZoomOut } from 'lucide-react'; import { ChevronDown, ChevronUp } from 'lucide-react';
import { useTimezone } from 'providers/Timezone'; import { useTimezone } from 'providers/Timezone';
import { getTimeDifference, validateEpochRange } from 'utils/epochUtils'; import { getTimeDifference, validateEpochRange } from 'utils/epochUtils';
import { popupContainer } from 'utils/selectPopupContainer'; import { popupContainer } from 'utils/selectPopupContainer';
@@ -70,8 +66,6 @@ interface CustomTimePickerProps {
showRecentlyUsed?: boolean; showRecentlyUsed?: boolean;
minTime: number; minTime: number;
maxTime: number; maxTime: number;
/** When true, zoom-out button is hidden (e.g. in drawer/modal time selection) */
isModalTimeSelection?: boolean;
} }
function CustomTimePicker({ function CustomTimePicker({
@@ -94,7 +88,6 @@ function CustomTimePicker({
showRecentlyUsed = true, showRecentlyUsed = true,
minTime, minTime,
maxTime, maxTime,
isModalTimeSelection = false,
}: CustomTimePickerProps): JSX.Element { }: CustomTimePickerProps): JSX.Element {
const [ const [
selectedTimePlaceholderValue, selectedTimePlaceholderValue,
@@ -123,14 +116,6 @@ function CustomTimePicker({
const [isOpenedFromFooter, setIsOpenedFromFooter] = useState(false); const [isOpenedFromFooter, setIsOpenedFromFooter] = useState(false);
const durationMs = (maxTime - minTime) / 1e6;
const zoomOutDisabled = showLiveLogs || isZoomOutDisabled(durationMs);
const handleZoomOut = useZoomOut({
isDisabled: zoomOutDisabled,
urlParamsToDelete: [QueryParams.activeLogId],
});
// function to get selected time in Last 1m, Last 2h, Last 3d, Last 4w format // function to get selected time in Last 1m, Last 2h, Last 3d, Last 4w format
// 1m, 2h, 3d, 4w -> Last 1 minute, Last 2 hours, Last 3 days, Last 4 weeks // 1m, 2h, 3d, 4w -> Last 1 minute, Last 2 hours, Last 3 days, Last 4 weeks
const getSelectedTimeRangeLabelInRelativeFormat = ( const getSelectedTimeRangeLabelInRelativeFormat = (
@@ -646,23 +631,6 @@ function CustomTimePicker({
/> />
</Popover> </Popover>
</Tooltip> </Tooltip>
{!showLiveLogs && !isModalTimeSelection && (
<Tooltip
title={
zoomOutDisabled ? 'Zoom out time range is limited to 1 month' : 'Zoom out'
}
>
<span>
<Button
className="zoom-out-btn"
onClick={handleZoomOut}
disabled={zoomOutDisabled}
data-testid="zoom-out-btn"
prefixIcon={<ZoomOut size={14} />}
/>
</span>
</Tooltip>
)}
</div> </div>
); );
} }

View File

@@ -1,169 +0,0 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { QueryParams } from 'constants/query';
import { GlobalReducer } from 'types/reducer/globalTime';
import CustomTimePicker from '../CustomTimePicker';
const MS_PER_MIN = 60 * 1000;
const NOW_MS = 1705312800000;
const mockDispatch = jest.fn();
const mockSafeNavigate = jest.fn();
const mockUrlQueryDelete = jest.fn();
const mockUrlQuerySet = jest.fn();
interface MockAppState {
globalTime: Pick<GlobalReducer, 'minTime' | 'maxTime'>;
}
jest.mock('react-redux', () => ({
useDispatch: (): jest.Mock => mockDispatch,
useSelector: (selector: (state: MockAppState) => unknown): unknown => {
const mockState: MockAppState = {
globalTime: {
minTime: (NOW_MS - 15 * MS_PER_MIN) * 1e6,
maxTime: NOW_MS * 1e6,
},
};
return selector(mockState);
},
}));
jest.mock('hooks/useSafeNavigate', () => ({
useSafeNavigate: (): { safeNavigate: jest.Mock } => ({
safeNavigate: mockSafeNavigate,
}),
}));
interface MockUrlQuery {
delete: typeof mockUrlQueryDelete;
set: typeof mockUrlQuerySet;
get: () => null;
toString: () => string;
}
jest.mock('hooks/useUrlQuery', () => ({
__esModule: true,
default: (): MockUrlQuery => ({
delete: mockUrlQueryDelete,
set: mockUrlQuerySet,
get: (): null => null,
toString: (): string => 'relativeTime=45m',
}),
}));
jest.mock('providers/Timezone', () => ({
useTimezone: (): { timezone: { value: string; offset: string } } => ({
timezone: { value: 'UTC', offset: 'UTC' },
}),
}));
jest.mock('react-router-dom', () => ({
useLocation: (): { pathname: string } => ({ pathname: '/logs-explorer' }),
}));
const MS_PER_DAY = 24 * 60 * 60 * 1000;
const now = Date.now();
const defaultProps = {
onSelect: jest.fn(),
onError: jest.fn(),
selectedValue: '15m',
selectedTime: '15m',
onValidCustomDateChange: jest.fn(),
open: false,
setOpen: jest.fn(),
items: [
{ value: '15m', label: 'Last 15 minutes' },
{ value: '1h', label: 'Last 1 hour' },
],
minTime: (now - 15 * 60 * 1000) * 1e6,
maxTime: now * 1e6,
};
describe('CustomTimePicker - zoom out button', () => {
beforeEach(() => {
jest.clearAllMocks();
jest.spyOn(Date, 'now').mockReturnValue(NOW_MS);
});
afterEach(() => {
jest.restoreAllMocks();
});
it('should render zoom out button when showLiveLogs is false', () => {
render(<CustomTimePicker {...defaultProps} showLiveLogs={false} />);
expect(screen.getByTestId('zoom-out-btn')).toBeInTheDocument();
});
it('should not render zoom out button when showLiveLogs is true', () => {
render(<CustomTimePicker {...defaultProps} showLiveLogs={true} />);
expect(screen.queryByTestId('zoom-out-btn')).not.toBeInTheDocument();
});
it('should not render zoom out button when isModalTimeSelection is true', () => {
render(
<CustomTimePicker
{...defaultProps}
showLiveLogs={false}
isModalTimeSelection={true}
/>,
);
expect(screen.queryByTestId('zoom-out-btn')).not.toBeInTheDocument();
});
it('should call handleZoomOut when zoom out button is clicked', async () => {
render(<CustomTimePicker {...defaultProps} showLiveLogs={false} />);
const zoomOutBtn = screen.getByTestId('zoom-out-btn');
await userEvent.click(zoomOutBtn);
expect(mockDispatch).toHaveBeenCalled();
expect(mockUrlQuerySet).toHaveBeenCalledWith(QueryParams.relativeTime, '45m');
expect(mockSafeNavigate).toHaveBeenCalledWith(
expect.stringMatching(/\/logs-explorer\?relativeTime=45m/),
);
});
it('should use real ladder logic: 15m range zooms to 45m preset and updates URL', async () => {
render(<CustomTimePicker {...defaultProps} showLiveLogs={false} />);
const zoomOutBtn = screen.getByTestId('zoom-out-btn');
await userEvent.click(zoomOutBtn);
expect(mockUrlQueryDelete).toHaveBeenCalledWith(QueryParams.startTime);
expect(mockUrlQueryDelete).toHaveBeenCalledWith(QueryParams.endTime);
expect(mockUrlQuerySet).toHaveBeenCalledWith(QueryParams.relativeTime, '45m');
expect(mockSafeNavigate).toHaveBeenCalledWith(
expect.stringMatching(/\/logs-explorer\?relativeTime=45m/),
);
expect(mockDispatch).toHaveBeenCalled();
});
it('should delete activeLogId when zoom out is clicked', async () => {
render(<CustomTimePicker {...defaultProps} showLiveLogs={false} />);
const zoomOutBtn = screen.getByTestId('zoom-out-btn');
await userEvent.click(zoomOutBtn);
expect(mockUrlQueryDelete).toHaveBeenCalledWith(QueryParams.activeLogId);
});
it('should disable zoom button when time range is >= 1 month', () => {
const now = Date.now();
render(
<CustomTimePicker
{...defaultProps}
minTime={(now - 31 * MS_PER_DAY) * 1e6}
maxTime={now * 1e6}
showLiveLogs={false}
/>,
);
const zoomOutBtn = screen.getByTestId('zoom-out-btn');
expect(zoomOutBtn).toBeDisabled();
});
});

View File

@@ -9,7 +9,6 @@ import {
} from 'hooks/dashboard/useDashboardVariables'; } from 'hooks/dashboard/useDashboardVariables';
import useVariablesFromUrl from 'hooks/dashboard/useVariablesFromUrl'; import useVariablesFromUrl from 'hooks/dashboard/useVariablesFromUrl';
import { useDashboard } from 'providers/Dashboard/Dashboard'; import { useDashboard } from 'providers/Dashboard/Dashboard';
import { initializeDefaultVariables } from 'providers/Dashboard/initializeDefaultVariables';
import { updateDashboardVariablesStore } from 'providers/Dashboard/store/dashboardVariables/dashboardVariablesStore'; import { updateDashboardVariablesStore } from 'providers/Dashboard/store/dashboardVariables/dashboardVariablesStore';
import { import {
enqueueDescendantsOfVariable, enqueueDescendantsOfVariable,
@@ -30,7 +29,7 @@ function DashboardVariableSelection(): JSX.Element | null {
updateLocalStorageDashboardVariables, updateLocalStorageDashboardVariables,
} = useDashboard(); } = useDashboard();
const { updateUrlVariable, getUrlVariables } = useVariablesFromUrl(); const { updateUrlVariable } = useVariablesFromUrl();
const { dashboardVariables } = useDashboardVariables(); const { dashboardVariables } = useDashboardVariables();
const dashboardId = useDashboardVariablesSelector( const dashboardId = useDashboardVariablesSelector(
@@ -50,15 +49,6 @@ function DashboardVariableSelection(): JSX.Element | null {
(state) => state.globalTime, (state) => state.globalTime,
); );
useEffect(() => {
// Initialize variables with default values if not in URL
initializeDefaultVariables(
dashboardVariables,
getUrlVariables,
updateUrlVariable,
);
}, [getUrlVariables, updateUrlVariable, dashboardVariables]);
// Memoize the order key to avoid unnecessary triggers // Memoize the order key to avoid unnecessary triggers
const variableOrderKey = useMemo(() => { const variableOrderKey = useMemo(() => {
const queryVariableOrderKey = dependencyData?.order?.join(',') ?? ''; const queryVariableOrderKey = dependencyData?.order?.join(',') ?? '';

View File

@@ -224,7 +224,7 @@ describe('TimeSeriesPanel utils', () => {
}); });
}); });
it('uses DrawStyle.Line and VisibilityMode.Never when series has multiple valid points', () => { it('uses DrawStyle.Line and showPoints false when series has multiple valid points', () => {
const apiResponse = createApiResponse([ const apiResponse = createApiResponse([
{ {
metric: {}, metric: {},

View File

@@ -10,9 +10,9 @@ import getLabelName from 'lib/getLabelName';
import { OnClickPluginOpts } from 'lib/uPlotLib/plugins/onClickPlugin'; import { OnClickPluginOpts } from 'lib/uPlotLib/plugins/onClickPlugin';
import { import {
DrawStyle, DrawStyle,
FillMode,
LineInterpolation, LineInterpolation,
LineStyle, LineStyle,
VisibilityMode,
} from 'lib/uPlotV2/config/types'; } from 'lib/uPlotV2/config/types';
import { UPlotConfigBuilder } from 'lib/uPlotV2/config/UPlotConfigBuilder'; import { UPlotConfigBuilder } from 'lib/uPlotV2/config/UPlotConfigBuilder';
import { isInvalidPlotValue } from 'lib/uPlotV2/utils/dataUtils'; import { isInvalidPlotValue } from 'lib/uPlotV2/utils/dataUtils';
@@ -124,12 +124,12 @@ export const prepareUPlotConfig = ({
label: label, label: label,
colorMapping: widget.customLegendColors ?? {}, colorMapping: widget.customLegendColors ?? {},
spanGaps: true, spanGaps: true,
lineStyle: LineStyle.Solid, lineStyle: widget.lineStyle || LineStyle.Solid,
lineInterpolation: LineInterpolation.Spline, lineInterpolation: widget.lineInterpolation || LineInterpolation.Spline,
showPoints: hasSingleValidPoint showPoints:
? VisibilityMode.Always widget.showPoints || hasSingleValidPoint ? true : !!widget.showPoints,
: VisibilityMode.Never,
pointSize: 5, pointSize: 5,
fillMode: widget.fillMode || FillMode.None,
isDarkMode, isDarkMode,
}); });
}); });

View File

@@ -65,6 +65,22 @@
} }
} }
.new-widget-container {
.resizable-panel-left-container {
overflow-x: hidden;
overflow-y: auto;
}
.resizable-panel-right-container {
overflow-y: auto;
min-width: 350px;
}
.widget-resizable-handle {
height: 100vh;
}
}
.lightMode { .lightMode {
.edit-header { .edit-header {
border-bottom: 1px solid var(--bg-vanilla-300); border-bottom: 1px solid var(--bg-vanilla-300);

View File

@@ -1,5 +1,7 @@
.column-unit-selector { .column-unit-selector {
margin-top: 16px; display: flex;
flex-direction: column;
gap: 8px;
.heading { .heading {
color: var(--bg-vanilla-400); color: var(--bg-vanilla-400);
@@ -30,6 +32,11 @@
width: 100%; width: 100%;
} }
} }
&-content {
display: flex;
flex-direction: column;
gap: 12px;
}
} }
.lightMode { .lightMode {

View File

@@ -72,22 +72,24 @@ export function ColumnUnitSelector(
return ( return (
<section className="column-unit-selector"> <section className="column-unit-selector">
<Typography.Text className="heading">Column Units</Typography.Text> <Typography.Text className="heading">Column Units</Typography.Text>
{aggregationQueries.map(({ value, label }) => { <div className="column-unit-selector-content">
const baseQueryName = value.split('.')[0]; {aggregationQueries.map(({ value, label }) => {
return ( const baseQueryName = value.split('.')[0];
<YAxisUnitSelectorV2 return (
value={columnUnits[value] || ''} <YAxisUnitSelectorV2
onSelect={(unitValue: string): void => value={columnUnits[value] || ''}
handleColumnUnitSelect(value, unitValue) onSelect={(unitValue: string): void =>
} handleColumnUnitSelect(value, unitValue)
fieldLabel={label} }
key={value} fieldLabel={label}
selectedQueryName={baseQueryName} key={value}
// Update the column unit value automatically only in create mode selectedQueryName={baseQueryName}
shouldUpdateYAxisUnit={isNewDashboard} // Update the column unit value automatically only in create mode
/> shouldUpdateYAxisUnit={isNewDashboard}
); />
})} );
})}
</div>
</section> </section>
); );
} }

View File

@@ -56,9 +56,6 @@ describe('ContextLinks Component', () => {
/>, />,
); );
// Check that the component renders
expect(screen.getByText('Context Links')).toBeInTheDocument();
// Check that the add button is present // Check that the add button is present
expect( expect(
screen.getByRole('button', { name: /context link/i }), screen.getByRole('button', { name: /context link/i }),

View File

@@ -14,7 +14,7 @@ import {
verticalListSortingStrategy, verticalListSortingStrategy,
} from '@dnd-kit/sortable'; } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities'; import { CSS } from '@dnd-kit/utilities';
import { Button, Modal, Typography } from 'antd'; import { Button, Modal } from 'antd';
import OverlayScrollbar from 'components/OverlayScrollbar/OverlayScrollbar'; import OverlayScrollbar from 'components/OverlayScrollbar/OverlayScrollbar';
import { GripVertical, Pencil, Plus, Trash2 } from 'lucide-react'; import { GripVertical, Pencil, Plus, Trash2 } from 'lucide-react';
import { import {
@@ -134,11 +134,16 @@ function ContextLinks({
return ( return (
<div className="context-links-container"> <div className="context-links-container">
<Typography.Text className="context-links-text">
Context Links
</Typography.Text>
<div className="context-links-list"> <div className="context-links-list">
<Button
type="default"
className="add-context-link-button"
icon={<Plus size={12} />}
style={{ width: '100%' }}
onClick={handleAddContextLink}
>
Add Context Link
</Button>
<OverlayScrollbar> <OverlayScrollbar>
<DndContext <DndContext
sensors={sensors} sensors={sensors}
@@ -160,16 +165,6 @@ function ContextLinks({
</SortableContext> </SortableContext>
</DndContext> </DndContext>
</OverlayScrollbar> </OverlayScrollbar>
{/* button to add context link */}
<Button
type="primary"
className="add-context-link-button"
icon={<Plus size={12} />}
onClick={handleAddContextLink}
>
Context Link
</Button>
</div> </div>
<Modal <Modal

View File

@@ -2,7 +2,6 @@
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 16px; gap: 16px;
margin: 12px;
} }
.context-links-text { .context-links-text {
@@ -110,10 +109,7 @@
} }
.add-context-link-button { .add-context-link-button {
display: flex; width: 100%;
align-items: center;
margin: auto;
width: fit-content;
} }
.lightMode { .lightMode {

View File

@@ -0,0 +1,21 @@
.fill-mode-selector {
.fill-mode-icon {
width: 24px;
height: 24px;
}
.fill-mode-label {
text-transform: uppercase;
font-size: 12px;
font-weight: 500;
color: var(--bg-vanilla-400);
}
}
.lightMode {
.fill-mode-selector {
.fill-mode-label {
color: var(--bg-ink-400);
}
}
}

View File

@@ -0,0 +1,94 @@
import { ToggleGroup, ToggleGroupItem } from '@signozhq/toggle-group';
import { Typography } from 'antd';
import { FillMode } from 'lib/uPlotV2/config/types';
import './FillModeSelector.styles.scss';
interface FillModeSelectorProps {
value: FillMode;
onChange: (value: FillMode) => void;
}
export function FillModeSelector({
value,
onChange,
}: FillModeSelectorProps): JSX.Element {
return (
<section className="fill-mode-selector control-container">
<Typography.Text className="section-heading">Fill mode</Typography.Text>
<ToggleGroup
type="single"
value={value}
variant="outline"
size="lg"
onValueChange={(newValue): void => {
if (newValue) {
onChange(newValue as FillMode);
}
}}
>
<ToggleGroupItem value={FillMode.None} aria-label="None" title="None">
<svg
className="fill-mode-icon"
viewBox="0 0 48 48"
fill="none"
stroke="#888"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<rect x="8" y="16" width="32" height="16" stroke="#888" fill="none" />
</svg>
<Typography.Text className="section-heading-small">None</Typography.Text>
</ToggleGroupItem>
<ToggleGroupItem value={FillMode.Solid} aria-label="Solid" title="Solid">
<svg
className="fill-mode-icon"
viewBox="0 0 48 48"
fill="none"
stroke="#888"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<rect x="8" y="16" width="32" height="16" fill="#888" />
</svg>
<Typography.Text className="section-heading-small">Solid</Typography.Text>
</ToggleGroupItem>
<ToggleGroupItem
value={FillMode.Gradient}
aria-label="Gradient"
title="Gradient"
>
<svg
className="fill-mode-icon"
viewBox="0 0 48 48"
fill="none"
stroke="#888"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<defs>
<linearGradient id="fill-gradient" x1="0" y1="0" x2="1" y2="0">
<stop offset="0%" stopColor="#888" stopOpacity="0.2" />
<stop offset="100%" stopColor="#888" stopOpacity="0.8" />
</linearGradient>
</defs>
<rect
x="8"
y="16"
width="32"
height="16"
fill="url(#fill-gradient)"
stroke="#888"
/>
</svg>
<Typography.Text className="section-heading-small">
Gradient
</Typography.Text>
</ToggleGroupItem>
</ToggleGroup>
</section>
);
}

View File

@@ -0,0 +1,21 @@
.line-interpolation-selector {
.line-interpolation-icon {
width: 24px;
height: 24px;
}
.line-interpolation-label {
text-transform: uppercase;
font-size: 12px;
font-weight: 500;
color: var(--bg-vanilla-400);
}
}
.lightMode {
.line-interpolation-selector {
.line-interpolation-label {
color: var(--bg-ink-400);
}
}
}

View File

@@ -0,0 +1,110 @@
import { ToggleGroup, ToggleGroupItem } from '@signozhq/toggle-group';
import { Typography } from 'antd';
import { LineInterpolation } from 'lib/uPlotV2/config/types';
import './LineInterpolationSelector.styles.scss';
interface LineInterpolationSelectorProps {
value: LineInterpolation;
onChange: (value: LineInterpolation) => void;
}
export function LineInterpolationSelector({
value,
onChange,
}: LineInterpolationSelectorProps): JSX.Element {
return (
<section className="line-interpolation-selector control-container">
<Typography.Text className="section-heading">
Line interpolation
</Typography.Text>
<ToggleGroup
type="single"
value={value}
variant="outline"
size="lg"
onValueChange={(newValue): void => {
if (newValue) {
onChange(newValue as LineInterpolation);
}
}}
>
<ToggleGroupItem
value={LineInterpolation.Linear}
aria-label="Linear"
title="Linear"
>
<svg
className="line-interpolation-icon"
viewBox="0 0 48 48"
fill="none"
stroke="#888"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<circle cx="8" cy="32" r="3" fill="#888" />
<circle cx="24" cy="16" r="3" fill="#888" />
<circle cx="40" cy="32" r="3" fill="#888" />
<path d="M8 32 L24 16 L40 32" stroke="#888" />
</svg>
</ToggleGroupItem>
<ToggleGroupItem value={LineInterpolation.Spline} aria-label="Spline">
<svg
className="line-interpolation-icon"
viewBox="0 0 48 48"
fill="none"
stroke="#888"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<circle cx="8" cy="32" r="3" fill="#888" />
<circle cx="24" cy="16" r="3" fill="#888" />
<circle cx="40" cy="32" r="3" fill="#888" />
<path d="M8 32 C16 8, 32 8, 40 32" />
</svg>
</ToggleGroupItem>
<ToggleGroupItem
value={LineInterpolation.StepAfter}
aria-label="Step After"
>
<svg
className="line-interpolation-icon"
viewBox="0 0 48 48"
fill="none"
stroke="#888"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<circle cx="8" cy="32" r="3" fill="#888" />
<circle cx="24" cy="16" r="3" fill="#888" />
<circle cx="40" cy="32" r="3" fill="#888" />
<path d="M8 32 V16 H24 V32 H40" />
</svg>
</ToggleGroupItem>
<ToggleGroupItem
value={LineInterpolation.StepBefore}
aria-label="Step Before"
>
<svg
className="line-interpolation-icon"
viewBox="0 0 48 48"
fill="none"
stroke="#888"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<circle cx="8" cy="32" r="3" fill="#888" />
<circle cx="24" cy="16" r="3" fill="#888" />
<circle cx="40" cy="32" r="3" fill="#888" />
<path d="M8 32 H24 V16 H40 V32" />
</svg>
</ToggleGroupItem>
</ToggleGroup>
</section>
);
}

View File

@@ -0,0 +1,21 @@
.line-style-selector {
.line-style-icon {
width: 24px;
height: 24px;
}
.line-style-label {
text-transform: uppercase;
font-size: 12px;
font-weight: 500;
color: var(--bg-vanilla-400);
}
}
.lightMode {
.line-style-selector {
.line-style-label {
color: var(--bg-ink-400);
}
}
}

View File

@@ -0,0 +1,66 @@
import { ToggleGroup, ToggleGroupItem } from '@signozhq/toggle-group';
import { Typography } from 'antd';
import { LineStyle } from 'lib/uPlotV2/config/types';
import './LineStyleSelector.styles.scss';
interface LineStyleSelectorProps {
value: LineStyle;
onChange: (value: LineStyle) => void;
}
export function LineStyleSelector({
value,
onChange,
}: LineStyleSelectorProps): JSX.Element {
return (
<section className="line-style-selector control-container">
<Typography.Text className="section-heading">Line style</Typography.Text>
<ToggleGroup
type="single"
value={value}
variant="outline"
size="lg"
onValueChange={(newValue): void => {
if (newValue) {
onChange(newValue as LineStyle);
}
}}
>
<ToggleGroupItem value={LineStyle.Solid} aria-label="Solid" title="Solid">
<svg
className="line-style-icon"
viewBox="0 0 48 48"
fill="none"
stroke="#888"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M8 24 L40 24" />
</svg>
<Typography.Text className="section-heading-small">Solid</Typography.Text>
</ToggleGroupItem>
<ToggleGroupItem
value={LineStyle.Dashed}
aria-label="Dashed"
title="Dashed"
>
<svg
className="line-style-icon"
viewBox="0 0 48 48"
fill="none"
stroke="#888"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
strokeDasharray="6 4"
>
<path d="M8 24 L40 24" />
</svg>
<Typography.Text className="section-heading-small">Dashed</Typography.Text>
</ToggleGroupItem>
</ToggleGroup>
</section>
);
}

View File

@@ -1,6 +1,28 @@
.right-container { .right-container {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
font-family: 'Space Mono';
.section-heading {
color: var(--bg-vanilla-400);
font-size: 13px;
font-style: normal;
font-weight: 400;
line-height: 18px; /* 138.462% */
letter-spacing: 0.52px;
text-transform: uppercase;
}
.section-heading-small {
font-family: 'Space Mono';
color: var(--bg-vanilla-400);
font-size: 12px;
font-style: normal;
font-weight: 400;
word-break: initial;
line-height: 16px; /* 133.333% */
letter-spacing: 0.48px;
}
.header { .header {
display: flex; display: flex;
@@ -24,25 +46,14 @@
letter-spacing: -0.07px; letter-spacing: -0.07px;
} }
} }
.control-container {
.name-description {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
padding: 12px 12px 16px 12px;
border-top: 1px solid var(--bg-slate-500);
border-bottom: 1px solid var(--bg-slate-500);
gap: 8px; gap: 8px;
}
.typography { .name-description {
color: var(--bg-vanilla-400); padding: 0 0 4px 0;
font-family: 'Space Mono';
font-size: 13px;
font-style: normal;
font-weight: 400;
line-height: 18px; /* 138.462% */
letter-spacing: 0.52px;
text-transform: uppercase;
}
.name-input { .name-input {
display: flex; display: flex;
@@ -88,22 +99,26 @@
.panel-config { .panel-config {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
padding: 12px 12px 16px 12px;
gap: 8px;
border-bottom: 1px solid var(--bg-slate-500);
.typography { .toggle-card {
color: var(--bg-vanilla-400); display: flex;
font-family: 'Space Mono'; flex-direction: row;
font-size: 13px; justify-content: space-between;
font-style: normal; align-items: center;
font-weight: 400; padding: 12px;
line-height: 18px; /* 138.462% */ border-radius: 2px;
letter-spacing: 0.52px; border: 1px solid var(--bg-slate-400);
text-transform: uppercase; background: var(--bg-ink-400);
}
.toggle-card-text-container {
display: flex;
flex-direction: column;
gap: 4px;
} }
.panel-type-select { .panel-type-select {
width: 100%;
.ant-select-selector { .ant-select-selector {
display: flex; display: flex;
height: 32px; height: 32px;
@@ -115,98 +130,32 @@
border: 1px solid var(--bg-slate-400); border: 1px solid var(--bg-slate-400);
background: var(--bg-ink-300); background: var(--bg-ink-300);
} }
.select-option {
display: flex;
align-items: center;
gap: 6px;
.icon {
display: flex;
align-items: center;
}
.display {
color: var(--bg-vanilla-100);
font-family: Inter;
font-size: 12px;
font-style: normal;
font-weight: 400;
line-height: 16px; /* 133.333% */
}
}
} }
.fill-gaps { .toggle-card-description {
margin-top: 16px; color: var(--bg-vanilla-400);
display: flex; font-family: Inter;
padding: 12px; font-size: 12px;
justify-content: space-between; font-style: normal;
align-items: center; font-weight: 400;
border-radius: 2px; opacity: 0.6;
border: 1px solid var(--bg-slate-400); line-height: 16px; /* 133.333% */
background: var(--bg-ink-400);
.fill-gaps-text {
color: var(--bg-vanilla-400);
font-family: 'Space Mono';
font-size: 13px;
font-style: normal;
font-weight: 400;
line-height: 18px; /* 138.462% */
letter-spacing: 0.52px;
text-transform: uppercase;
}
} }
.log-scale, .log-scale,
.decimal-precision-selector { .decimal-precision-selector,
margin-top: 16px;
display: flex;
justify-content: space-between;
flex-direction: column;
gap: 8px;
}
.legend-position { .legend-position {
margin-top: 16px;
display: flex;
justify-content: space-between; justify-content: space-between;
flex-direction: column;
gap: 8px;
}
.legend-colors {
margin-top: 16px;
}
.panel-time-text {
margin-top: 16px;
color: var(--bg-vanilla-400);
font-family: 'Space Mono';
font-size: 13px;
font-style: normal;
font-weight: 400;
line-height: 18px; /* 138.462% */
letter-spacing: 0.52px;
text-transform: uppercase;
} }
.y-axis-unit-selector, .y-axis-unit-selector,
.y-axis-unit-selector-v2 { .y-axis-unit-selector-v2 {
margin-top: 16px;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 8px; gap: 8px;
.heading { .heading {
color: var(--bg-vanilla-400); @extend .section-heading;
font-family: 'Space Mono';
font-size: 13px;
font-style: normal;
font-weight: 400;
line-height: 18px; /* 138.462% */
letter-spacing: 0.52px;
text-transform: uppercase;
} }
.input { .input {
@@ -259,7 +208,6 @@
.text { .text {
color: var(--bg-vanilla-400); color: var(--bg-vanilla-400);
font-family: 'Space Mono';
font-size: 12px; font-size: 12px;
font-style: normal; font-style: normal;
font-weight: 400; font-weight: 400;
@@ -278,40 +226,11 @@
} }
.stack-chart { .stack-chart {
margin-top: 16px; flex-direction: row;
display: flex;
justify-content: space-between; justify-content: space-between;
gap: 8px;
.label {
color: var(--bg-vanilla-400);
font-family: 'Space Mono';
font-size: 13px;
font-style: normal;
font-weight: 400;
line-height: 18px; /* 138.462% */
letter-spacing: 0.52px;
text-transform: uppercase;
}
} }
.bucket-config { .bucket-config {
margin-top: 16px;
display: flex;
flex-direction: column;
gap: 8px;
.label {
color: var(--bg-vanilla-400);
font-family: 'Space Mono';
font-size: 13px;
font-style: normal;
font-weight: 400;
line-height: 18px; /* 138.462% */
letter-spacing: 0.52px;
text-transform: uppercase;
}
.bucket-size-label { .bucket-size-label {
margin-top: 8px; margin-top: 8px;
} }
@@ -340,7 +259,6 @@
.label { .label {
color: var(--bg-vanilla-400); color: var(--bg-vanilla-400);
font-family: 'Space Mono';
font-size: 13px; font-size: 13px;
font-style: normal; font-style: normal;
font-weight: 400; font-weight: 400;
@@ -352,16 +270,13 @@
} }
} }
.context-links {
border-bottom: 1px solid var(--bg-slate-500);
}
.alerts { .alerts {
display: flex; display: flex;
padding: 12px;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
border-bottom: 1px solid var(--bg-slate-500); padding: 12px;
min-height: 44px;
border-top: 1px solid var(--bg-slate-500);
cursor: pointer; cursor: pointer;
.left-section { .left-section {
@@ -375,7 +290,6 @@
.alerts-text { .alerts-text {
color: var(--bg-vanilla-400); color: var(--bg-vanilla-400);
font-family: Inter;
font-size: 14px; font-size: 14px;
font-style: normal; font-style: normal;
font-weight: 400; font-weight: 400;
@@ -387,6 +301,16 @@
color: var(--bg-vanilla-400); color: var(--bg-vanilla-400);
} }
} }
.context-links {
padding: 12px 12px 16px 12px;
border-bottom: 1px solid var(--bg-slate-500);
}
.thresholds-section {
padding: 12px 12px 16px 12px;
border-top: 1px solid var(--bg-slate-500);
}
} }
.select-option { .select-option {
@@ -411,6 +335,9 @@
.lightMode { .lightMode {
.right-container { .right-container {
background-color: var(--bg-vanilla-100); background-color: var(--bg-vanilla-100);
.section-heading {
color: var(--bg-ink-400);
}
.header { .header {
.header-text { .header-text {
color: var(--bg-ink-400); color: var(--bg-ink-400);
@@ -418,9 +345,6 @@
} }
.name-description { .name-description {
border-top: 1px solid var(--bg-vanilla-300);
border-bottom: 1px solid var(--bg-vanilla-300);
.typography { .typography {
color: var(--bg-ink-400); color: var(--bg-ink-400);
} }
@@ -441,12 +365,6 @@
} }
.panel-config { .panel-config {
border-bottom: 1px solid var(--bg-vanilla-300);
.typography {
color: var(--bg-ink-400);
}
.panel-type-select { .panel-type-select {
.ant-select-selector { .ant-select-selector {
border: 1px solid var(--bg-vanilla-300); border: 1px solid var(--bg-vanilla-300);
@@ -471,13 +389,16 @@
} }
} }
.fill-gaps { .toggle-card {
border: 1px solid var(--bg-vanilla-300); border: 1px solid var(--bg-vanilla-300);
background: var(--bg-vanilla-300); background: var(--bg-vanilla-300);
.fill-gaps-text { .fill-gaps-text {
color: var(--bg-ink-400); color: var(--bg-ink-400);
} }
.toggle-card-description {
color: var(--bg-ink-400);
}
} }
.bucket-config { .bucket-config {
@@ -530,7 +451,7 @@
} }
.alerts { .alerts {
border-bottom: 1px solid var(--bg-vanilla-300); border-top: 1px solid var(--bg-vanilla-300);
.left-section { .left-section {
.bell-icon { .bell-icon {
@@ -549,6 +470,10 @@
.context-links { .context-links {
border-bottom: 1px solid var(--bg-vanilla-300); border-bottom: 1px solid var(--bg-vanilla-300);
} }
.thresholds-section {
border-top: 1px solid var(--bg-vanilla-300);
}
} }
.select-option { .select-option {

View File

@@ -1,5 +1,4 @@
.threshold-selector-container { .threshold-selector-container {
padding: 12px;
padding-bottom: 80px; padding-bottom: 80px;
.threshold-select { .threshold-select {

View File

@@ -1,10 +1,10 @@
import { useCallback } from 'react'; import { useCallback } from 'react';
import { DndProvider } from 'react-dnd'; import { DndProvider } from 'react-dnd';
import { HTML5Backend } from 'react-dnd-html5-backend'; import { HTML5Backend } from 'react-dnd-html5-backend';
import { Typography } from 'antd'; import { Button } from 'antd';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder'; import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useGetQueryLabels } from 'hooks/useGetQueryLabels'; import { useGetQueryLabels } from 'hooks/useGetQueryLabels';
import { Antenna, Plus } from 'lucide-react'; import { Plus } from 'lucide-react';
import { v4 as uuid } from 'uuid'; import { v4 as uuid } from 'uuid';
import Threshold from './Threshold'; import Threshold from './Threshold';
@@ -68,11 +68,14 @@ function ThresholdSelector({
<DndProvider backend={HTML5Backend}> <DndProvider backend={HTML5Backend}>
<div className="threshold-selector-container"> <div className="threshold-selector-container">
<div className="threshold-select" onClick={addThresholdHandler}> <div className="threshold-select" onClick={addThresholdHandler}>
<div className="left-section"> <Button
<Antenna size={14} className="icon" /> type="default"
<Typography.Text className="text">Thresholds</Typography.Text> icon={<Plus size={14} />}
</div> style={{ width: '100%' }}
<Plus size={14} onClick={addThresholdHandler} className="icon" /> onClick={addThresholdHandler}
>
Add Threshold
</Button>
</div> </div>
{thresholds.map((threshold, idx) => ( {thresholds.map((threshold, idx) => (
<Threshold <Threshold

View File

@@ -6,6 +6,11 @@ import { MemoryRouter } from 'react-router-dom';
import { render as rtlRender, screen } from '@testing-library/react'; import { render as rtlRender, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event'; import userEvent from '@testing-library/user-event';
import { PANEL_TYPES } from 'constants/queryBuilder'; import { PANEL_TYPES } from 'constants/queryBuilder';
import {
FillMode,
LineInterpolation,
LineStyle,
} from 'lib/uPlotV2/config/types';
import { AppContext } from 'providers/App/App'; import { AppContext } from 'providers/App/App';
import { IAppContext } from 'providers/App/types'; import { IAppContext } from 'providers/App/types';
import { ErrorModalProvider } from 'providers/ErrorModalProvider'; import { ErrorModalProvider } from 'providers/ErrorModalProvider';
@@ -165,6 +170,14 @@ describe('RightContainer - Alerts Section', () => {
setContextLinks: jest.fn(), setContextLinks: jest.fn(),
enableDrillDown: false, enableDrillDown: false,
isNewDashboard: false, isNewDashboard: false,
lineInterpolation: LineInterpolation.Spline,
fillMode: FillMode.None,
lineStyle: LineStyle.Solid,
setLineInterpolation: jest.fn(),
setFillMode: jest.fn(),
setLineStyle: jest.fn(),
showPoints: false,
setShowPoints: jest.fn(),
}; };
beforeEach(() => { beforeEach(() => {

View File

@@ -0,0 +1,68 @@
.settings-section {
border-top: 1px solid var(--bg-slate-500);
}
.settings-section-header {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
padding: 12px 12px;
min-height: 44px;
background: transparent;
border: none;
outline: none;
cursor: pointer;
.settings-section-title {
display: flex;
align-items: center;
gap: 8px;
color: var(--bg-vanilla-400);
font-family: 'Space Mono';
font-size: 13px;
font-weight: 400;
text-transform: uppercase;
}
.chevron-icon {
color: var(--bg-vanilla-400);
transition: transform 0.2s ease-in-out;
&.open {
transform: rotate(180deg);
}
}
}
.settings-section-content {
padding: 0 12px 0 12px;
display: flex;
flex-direction: column;
gap: 20px;
max-height: 0;
overflow: hidden;
opacity: 0;
transition: max-height 0.25s ease, opacity 0.25s ease, padding 0.25s ease;
&.open {
padding-bottom: 24px;
max-height: 1000px;
opacity: 1;
}
}
.lightMode {
.settings-section-header {
.chevron-icon {
color: var(--bg-ink-400);
}
.settings-section-title {
color: var(--bg-ink-400);
}
}
.settings-section {
border-top: 1px solid var(--bg-vanilla-300);
}
}

View File

@@ -0,0 +1,51 @@
import { ReactNode, useState } from 'react';
import { ChevronDown } from 'lucide-react';
import './SettingsSection.styles.scss';
export interface SettingsSectionProps {
title: string;
defaultOpen?: boolean;
children: ReactNode;
icon?: ReactNode;
}
function SettingsSection({
title,
defaultOpen = false,
children,
icon,
}: SettingsSectionProps): JSX.Element {
const [isOpen, setIsOpen] = useState(defaultOpen);
const toggleOpen = (): void => {
setIsOpen((prev) => !prev);
};
return (
<section className="settings-section">
<button
type="button"
className="settings-section-header"
onClick={toggleOpen}
>
<span className="settings-section-title">
{icon ? icon : null} {title}
</span>
<ChevronDown
size={16}
className={isOpen ? 'chevron-icon open' : 'chevron-icon'}
/>
</button>
<div
className={
isOpen ? 'settings-section-content open' : 'settings-section-content'
}
>
{children}
</div>
</section>
);
}
export default SettingsSection;

View File

@@ -206,3 +206,59 @@ export const panelTypeVsDecimalPrecision: {
[PANEL_TYPES.TRACE]: false, [PANEL_TYPES.TRACE]: false,
[PANEL_TYPES.EMPTY_WIDGET]: false, [PANEL_TYPES.EMPTY_WIDGET]: false,
} as const; } as const;
export const panelTypeVsLineInterpolation: {
[key in PANEL_TYPES]: boolean;
} = {
[PANEL_TYPES.TIME_SERIES]: true,
[PANEL_TYPES.VALUE]: false,
[PANEL_TYPES.TABLE]: false,
[PANEL_TYPES.LIST]: false,
[PANEL_TYPES.PIE]: false,
[PANEL_TYPES.BAR]: false,
[PANEL_TYPES.HISTOGRAM]: false,
[PANEL_TYPES.TRACE]: false,
[PANEL_TYPES.EMPTY_WIDGET]: false,
} as const;
export const panelTypeVsLineStyle: {
[key in PANEL_TYPES]: boolean;
} = {
[PANEL_TYPES.TIME_SERIES]: true,
[PANEL_TYPES.VALUE]: false,
[PANEL_TYPES.TABLE]: false,
[PANEL_TYPES.LIST]: false,
[PANEL_TYPES.PIE]: false,
[PANEL_TYPES.BAR]: false,
[PANEL_TYPES.HISTOGRAM]: false,
[PANEL_TYPES.TRACE]: false,
[PANEL_TYPES.EMPTY_WIDGET]: false,
} as const;
export const panelTypeVsFillMode: {
[key in PANEL_TYPES]: boolean;
} = {
[PANEL_TYPES.TIME_SERIES]: true,
[PANEL_TYPES.VALUE]: false,
[PANEL_TYPES.TABLE]: false,
[PANEL_TYPES.LIST]: false,
[PANEL_TYPES.PIE]: false,
[PANEL_TYPES.BAR]: false,
[PANEL_TYPES.HISTOGRAM]: false,
[PANEL_TYPES.TRACE]: false,
[PANEL_TYPES.EMPTY_WIDGET]: false,
} as const;
export const panelTypeVsShowPoints: {
[key in PANEL_TYPES]: boolean;
} = {
[PANEL_TYPES.TIME_SERIES]: true,
[PANEL_TYPES.VALUE]: false,
[PANEL_TYPES.TABLE]: false,
[PANEL_TYPES.LIST]: false,
[PANEL_TYPES.PIE]: false,
[PANEL_TYPES.BAR]: false,
[PANEL_TYPES.HISTOGRAM]: false,
[PANEL_TYPES.TRACE]: false,
[PANEL_TYPES.EMPTY_WIDGET]: false,
} as const;

View File

@@ -14,7 +14,6 @@ import {
Input, Input,
InputNumber, InputNumber,
Select, Select,
Space,
Switch, Switch,
Typography, Typography,
} from 'antd'; } from 'antd';
@@ -28,9 +27,22 @@ import { useDashboardVariables } from 'hooks/dashboard/useDashboardVariables';
import useCreateAlerts from 'hooks/queryBuilder/useCreateAlerts'; import useCreateAlerts from 'hooks/queryBuilder/useCreateAlerts';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder'; import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { import {
FillMode,
LineInterpolation,
LineStyle,
} from 'lib/uPlotV2/config/types';
import {
Antenna,
Axis3D,
ConciergeBell, ConciergeBell,
Layers,
LayoutDashboard,
LineChart, LineChart,
Link,
Paintbrush,
Pencil,
Plus, Plus,
SlidersHorizontal,
Spline, Spline,
SquareArrowOutUpRight, SquareArrowOutUpRight,
} from 'lucide-react'; } from 'lucide-react';
@@ -46,17 +58,22 @@ import { DataSource } from 'types/common/queryBuilder';
import { popupContainer } from 'utils/selectPopupContainer'; import { popupContainer } from 'utils/selectPopupContainer';
import { ColumnUnitSelector } from './ColumnUnitSelector/ColumnUnitSelector'; import { ColumnUnitSelector } from './ColumnUnitSelector/ColumnUnitSelector';
import SettingsSection from './components/SettingsSection/SettingsSection';
import { import {
panelTypeVsBucketConfig, panelTypeVsBucketConfig,
panelTypeVsColumnUnitPreferences, panelTypeVsColumnUnitPreferences,
panelTypeVsContextLinks, panelTypeVsContextLinks,
panelTypeVsCreateAlert, panelTypeVsCreateAlert,
panelTypeVsDecimalPrecision, panelTypeVsDecimalPrecision,
panelTypeVsFillMode,
panelTypeVsFillSpan, panelTypeVsFillSpan,
panelTypeVsLegendColors, panelTypeVsLegendColors,
panelTypeVsLegendPosition, panelTypeVsLegendPosition,
panelTypeVsLineInterpolation,
panelTypeVsLineStyle,
panelTypeVsLogScale, panelTypeVsLogScale,
panelTypeVsPanelTimePreferences, panelTypeVsPanelTimePreferences,
panelTypeVsShowPoints,
panelTypeVsSoftMinMax, panelTypeVsSoftMinMax,
panelTypeVsStackingChartPreferences, panelTypeVsStackingChartPreferences,
panelTypeVsThreshold, panelTypeVsThreshold,
@@ -64,7 +81,10 @@ import {
} from './constants'; } from './constants';
import ContextLinks from './ContextLinks'; import ContextLinks from './ContextLinks';
import DashboardYAxisUnitSelectorWrapper from './DashboardYAxisUnitSelectorWrapper'; import DashboardYAxisUnitSelectorWrapper from './DashboardYAxisUnitSelectorWrapper';
import { FillModeSelector } from './FillModeSelector';
import LegendColors from './LegendColors/LegendColors'; import LegendColors from './LegendColors/LegendColors';
import { LineInterpolationSelector } from './LineInterpolationSelector';
import { LineStyleSelector } from './LineStyleSelector';
import ThresholdSelector from './Threshold/ThresholdSelector'; import ThresholdSelector from './Threshold/ThresholdSelector';
import { ThresholdProps } from './Threshold/types'; import { ThresholdProps } from './Threshold/types';
import { timePreferance } from './timeItems'; import { timePreferance } from './timeItems';
@@ -91,6 +111,14 @@ function RightContainer({
setTitle, setTitle,
title, title,
selectedGraph, selectedGraph,
lineInterpolation,
setLineInterpolation,
fillMode,
setFillMode,
lineStyle,
setLineStyle,
showPoints,
setShowPoints,
bucketCount, bucketCount,
bucketWidth, bucketWidth,
stackedBarChart, stackedBarChart,
@@ -167,6 +195,11 @@ function RightContainer({
panelTypeVsContextLinks[selectedGraph] && enableDrillDown; panelTypeVsContextLinks[selectedGraph] && enableDrillDown;
const allowDecimalPrecision = panelTypeVsDecimalPrecision[selectedGraph]; const allowDecimalPrecision = panelTypeVsDecimalPrecision[selectedGraph];
const allowLineInterpolation = panelTypeVsLineInterpolation[selectedGraph];
const allowLineStyle = panelTypeVsLineStyle[selectedGraph];
const allowFillMode = panelTypeVsFillMode[selectedGraph];
const allowShowPoints = panelTypeVsShowPoints[selectedGraph];
const { currentQuery } = useQueryBuilder(); const { currentQuery } = useQueryBuilder();
const [graphTypes, setGraphTypes] = useState<ItemsProps[]>(GraphTypes); const [graphTypes, setGraphTypes] = useState<ItemsProps[]>(GraphTypes);
@@ -178,6 +211,37 @@ function RightContainer({
})); }));
}, [dashboardVariables]); }, [dashboardVariables]);
const isAxisSectionVisible = useMemo(() => allowSoftMinMax || allowLogScale, [
allowSoftMinMax,
allowLogScale,
]);
const isFormattingSectionVisible = useMemo(
() => allowYAxisUnit || allowDecimalPrecision || allowPanelColumnPreference,
[allowYAxisUnit, allowDecimalPrecision, allowPanelColumnPreference],
);
const isLegendSectionVisible = useMemo(
() => allowLegendPosition || allowLegendColors,
[allowLegendPosition, allowLegendColors],
);
const isChartAppearanceSectionVisible = useMemo(
() =>
/**
* Disabled for now as we are not done with other settings in chart appearance section
* TODO: @ahrefabhi Enable this after we are done other settings in chart appearance section
*/
// eslint-disable-next-line sonarjs/no-redundant-boolean
false &&
(allowFillMode ||
allowLineStyle ||
allowLineInterpolation ||
allowShowPoints),
[allowFillMode, allowLineStyle, allowLineInterpolation, allowShowPoints],
);
const updateCursorAndDropdown = (value: string, pos: number): void => { const updateCursorAndDropdown = (value: string, pos: number): void => {
setCursorPos(pos); setCursorPos(pos);
const lastDollar = value.lastIndexOf('$', pos - 1); const lastDollar = value.lastIndexOf('$', pos - 1);
@@ -193,6 +257,15 @@ function RightContainer({
}, 0); }, 0);
}; };
const decimapPrecisionOptions = useMemo(() => {
return [
{ label: '0 decimals', value: PrecisionOptionsEnum.ZERO },
{ label: '1 decimal', value: PrecisionOptionsEnum.ONE },
{ label: '2 decimals', value: PrecisionOptionsEnum.TWO },
{ label: '3 decimals', value: PrecisionOptionsEnum.THREE },
];
}, []);
const handleInputCursor = (): void => { const handleInputCursor = (): void => {
const pos = inputRef.current?.input?.selectionStart ?? 0; const pos = inputRef.current?.input?.selectionStart ?? 0;
updateCursorAndDropdown(inputValue, pos); updateCursorAndDropdown(inputValue, pos);
@@ -263,269 +336,333 @@ function RightContainer({
<div className="right-container"> <div className="right-container">
<section className="header"> <section className="header">
<div className="purple-dot" /> <div className="purple-dot" />
<Typography.Text className="header-text">Panel details</Typography.Text> <Typography.Text className="header-text">Panel Settings</Typography.Text>
</section> </section>
<section className="name-description">
<Typography.Text className="typography">Name</Typography.Text>
<AutoComplete
options={dashboardVariableOptions}
value={inputValue}
onChange={onInputChange}
onSelect={onSelect}
filterOption={filterOption}
style={{ width: '100%' }}
getPopupContainer={popupContainer}
placeholder="Enter the panel name here..."
open={autoCompleteOpen}
>
<Input
rootClassName="name-input"
ref={inputRef}
onSelect={handleInputCursor}
onClick={handleInputCursor}
onBlur={(): void => setAutoCompleteOpen(false)}
/>
</AutoComplete>
<Typography.Text className="typography">Description</Typography.Text>
<TextArea
placeholder="Enter the panel description here..."
bordered
allowClear
value={description}
onChange={(event): void =>
onChangeHandler(setDescription, event.target.value)
}
rootClassName="description-input"
/>
</section>
<section className="panel-config">
<Typography.Text className="typography">Panel Type</Typography.Text>
<Select
onChange={setGraphHandler}
value={selectedGraph}
style={{ width: '100%' }}
className="panel-type-select"
data-testid="panel-change-select"
data-stacking-state={stackedBarChart ? 'true' : 'false'}
>
{graphTypes.map((item) => (
<Option key={item.name} value={item.name}>
<div className="select-option">
<div className="icon">{item.icon}</div>
<Typography.Text className="display">{item.display}</Typography.Text>
</div>
</Option>
))}
</Select>
{allowFillSpans && ( <SettingsSection title="General" defaultOpen icon={<Pencil size={14} />}>
<Space className="fill-gaps"> <section className="name-description control-container">
<Typography className="fill-gaps-text">Fill gaps</Typography> <Typography.Text className="section-heading">Name</Typography.Text>
<Switch <AutoComplete
checked={isFillSpans} options={dashboardVariableOptions}
size="small" value={inputValue}
onChange={(checked): void => setIsFillSpans(checked)} onChange={onInputChange}
onSelect={onSelect}
filterOption={filterOption}
style={{ width: '100%' }}
getPopupContainer={popupContainer}
placeholder="Enter the panel name here..."
open={autoCompleteOpen}
>
<Input
rootClassName="name-input"
ref={inputRef}
onSelect={handleInputCursor}
onClick={handleInputCursor}
onBlur={(): void => setAutoCompleteOpen(false)}
/> />
</Space> </AutoComplete>
)} <Typography.Text className="section-heading">Description</Typography.Text>
<TextArea
{allowPanelTimePreference && ( placeholder="Enter the panel description here..."
<> bordered
<Typography.Text className="panel-time-text"> allowClear
Panel Time Preference value={description}
</Typography.Text> onChange={(event): void =>
<TimePreference onChangeHandler(setDescription, event.target.value)
{...{
selectedTime,
setSelectedTime,
}}
/>
</>
)}
{allowPanelColumnPreference && (
<ColumnUnitSelector
columnUnits={columnUnits}
setColumnUnits={setColumnUnits}
isNewDashboard={isNewDashboard}
/>
)}
{allowYAxisUnit && (
<DashboardYAxisUnitSelectorWrapper
onSelect={setYAxisUnit}
value={yAxisUnit || ''}
fieldLabel={
selectedGraphType === PanelDisplay.VALUE ||
selectedGraphType === PanelDisplay.PIE
? 'Unit'
: 'Y Axis Unit'
} }
// Only update the y-axis unit value automatically in create mode rootClassName="description-input"
shouldUpdateYAxisUnit={isNewDashboard}
/> />
)} </section>
</SettingsSection>
{allowDecimalPrecision && ( <section className="panel-config">
<section className="decimal-precision-selector"> <SettingsSection
<Typography.Text className="typography"> title="Visualization"
Decimal Precision defaultOpen
</Typography.Text> icon={<LayoutDashboard size={14} />}
>
<section className="panel-type control-container">
<Typography.Text className="section-heading">Panel Type</Typography.Text>
<Select <Select
options={[ onChange={setGraphHandler}
{ label: '0 decimals', value: PrecisionOptionsEnum.ZERO }, value={selectedGraph}
{ label: '1 decimal', value: PrecisionOptionsEnum.ONE },
{ label: '2 decimals', value: PrecisionOptionsEnum.TWO },
{ label: '3 decimals', value: PrecisionOptionsEnum.THREE },
{ label: '4 decimals', value: PrecisionOptionsEnum.FOUR },
{ label: 'Full Precision', value: PrecisionOptionsEnum.FULL },
]}
value={decimalPrecision}
style={{ width: '100%' }}
className="panel-type-select" className="panel-type-select"
defaultValue={PrecisionOptionsEnum.TWO} data-testid="panel-change-select"
onChange={(val: PrecisionOption): void => setDecimalPrecision(val)} data-stacking-state={stackedBarChart ? 'true' : 'false'}
/> >
{graphTypes.map((item) => (
<Option key={item.name} value={item.name}>
<div className="select-option">
<div className="icon">{item.icon}</div>
<Typography.Text className="display">{item.display}</Typography.Text>
</div>
</Option>
))}
</Select>
</section> </section>
)}
{allowSoftMinMax && ( {allowPanelTimePreference && (
<section className="soft-min-max"> <section className="panel-time-preference control-container">
<section className="container"> <Typography.Text className="section-heading">
<Typography.Text className="text">Soft Min</Typography.Text> Panel Time Preference
<InputNumber </Typography.Text>
type="number" <TimePreference
value={softMin} {...{
onChange={softMinHandler} selectedTime,
rootClassName="input" setSelectedTime,
}}
/> />
</section> </section>
<section className="container"> )}
<Typography.Text className="text">Soft Max</Typography.Text>
<InputNumber {allowStackingBarChart && (
value={softMax} <section className="stack-chart control-container">
type="number" <Typography.Text className="section-heading">
rootClassName="input" Stack series
onChange={softMaxHandler} </Typography.Text>
<Switch
checked={stackedBarChart}
size="small"
onChange={(checked): void => setStackedBarChart(checked)}
/> />
</section> </section>
</section> )}
{allowFillSpans && (
<section className="fill-gaps toggle-card">
<div className="toggle-card-text-container">
<Typography className="section-heading">Fill gaps</Typography>
<Typography.Text className="toggle-card-description">
Fill gaps in data with 0 for continuity
</Typography.Text>
</div>
<Switch
checked={isFillSpans}
size="small"
onChange={(checked): void => setIsFillSpans(checked)}
/>
</section>
)}
</SettingsSection>
{isFormattingSectionVisible && (
<SettingsSection
title="Formatting & Units"
icon={<SlidersHorizontal size={14} />}
>
{allowYAxisUnit && (
<DashboardYAxisUnitSelectorWrapper
onSelect={setYAxisUnit}
value={yAxisUnit || ''}
fieldLabel={
selectedGraphType === PanelDisplay.VALUE ||
selectedGraphType === PanelDisplay.PIE
? 'Unit'
: 'Y Axis Unit'
}
// Only update the y-axis unit value automatically in create mode
shouldUpdateYAxisUnit={isNewDashboard}
/>
)}
{allowDecimalPrecision && (
<section className="decimal-precision-selector control-container">
<Typography.Text className="typography">
Decimal Precision
</Typography.Text>
<Select
options={decimapPrecisionOptions}
value={decimalPrecision}
style={{ width: '100%' }}
className="panel-type-select"
defaultValue={PrecisionOptionsEnum.TWO}
onChange={(val: PrecisionOption): void => setDecimalPrecision(val)}
/>
</section>
)}
{allowPanelColumnPreference && (
<ColumnUnitSelector
columnUnits={columnUnits}
setColumnUnits={setColumnUnits}
isNewDashboard={isNewDashboard}
/>
)}
</SettingsSection>
)} )}
{allowStackingBarChart && ( {isChartAppearanceSectionVisible && (
<section className="stack-chart"> <SettingsSection title="Chart Appearance" icon={<Paintbrush size={14} />}>
<Typography.Text className="label">Stack series</Typography.Text> {allowFillMode && (
<Switch <FillModeSelector value={fillMode} onChange={setFillMode} />
checked={stackedBarChart} )}
size="small" {allowLineStyle && (
onChange={(checked): void => setStackedBarChart(checked)} <LineStyleSelector value={lineStyle} onChange={setLineStyle} />
/> )}
</section> {allowLineInterpolation && (
<LineInterpolationSelector
value={lineInterpolation}
onChange={setLineInterpolation}
/>
)}
{allowShowPoints && (
<section className="show-points toggle-card">
<div className="toggle-card-text-container">
<Typography.Text className="section-heading">
Show points
</Typography.Text>
<Typography.Text className="toggle-card-description">
Display individual data points on the chart
</Typography.Text>
</div>
<Switch size="small" checked={showPoints} onChange={setShowPoints} />
</section>
)}
</SettingsSection>
)}
{isAxisSectionVisible && (
<SettingsSection title="Axes" icon={<Axis3D size={14} />}>
{allowSoftMinMax && (
<section className="soft-min-max">
<section className="container">
<Typography.Text className="text">Soft Min</Typography.Text>
<InputNumber
type="number"
value={softMin}
onChange={softMinHandler}
rootClassName="input"
/>
</section>
<section className="container">
<Typography.Text className="text">Soft Max</Typography.Text>
<InputNumber
value={softMax}
type="number"
rootClassName="input"
onChange={softMaxHandler}
/>
</section>
</section>
)}
{allowLogScale && (
<section className="log-scale control-container">
<Typography.Text className="section-heading">
Y Axis Scale
</Typography.Text>
<Select
onChange={(value): void =>
setIsLogScale(value === LogScale.LOGARITHMIC)
}
value={isLogScale ? LogScale.LOGARITHMIC : LogScale.LINEAR}
style={{ width: '100%' }}
className="panel-type-select"
defaultValue={LogScale.LINEAR}
>
<Option value={LogScale.LINEAR}>
<div className="select-option">
<div className="icon">
<LineChart size={16} />
</div>
<Typography.Text className="display">Linear</Typography.Text>
</div>
</Option>
<Option value={LogScale.LOGARITHMIC}>
<div className="select-option">
<div className="icon">
<Spline size={16} />
</div>
<Typography.Text className="display">Logarithmic</Typography.Text>
</div>
</Option>
</Select>
</section>
)}
</SettingsSection>
)}
{isLegendSectionVisible && (
<SettingsSection title="Legend" icon={<Layers size={14} />}>
{allowLegendPosition && (
<section className="legend-position control-container">
<Typography.Text className="section-heading">Position</Typography.Text>
<Select
onChange={(value: LegendPosition): void => setLegendPosition(value)}
value={legendPosition}
style={{ width: '100%' }}
className="panel-type-select"
defaultValue={LegendPosition.BOTTOM}
>
<Option value={LegendPosition.BOTTOM}>
<div className="select-option">
<Typography.Text className="display">Bottom</Typography.Text>
</div>
</Option>
<Option value={LegendPosition.RIGHT}>
<div className="select-option">
<Typography.Text className="display">Right</Typography.Text>
</div>
</Option>
</Select>
</section>
)}
{allowLegendColors && (
<section className="legend-colors">
<LegendColors
customLegendColors={customLegendColors}
setCustomLegendColors={setCustomLegendColors}
queryResponse={queryResponse}
/>
</section>
)}
</SettingsSection>
)} )}
{allowBucketConfig && ( {allowBucketConfig && (
<section className="bucket-config"> <SettingsSection title="Histogram / Buckets">
<Typography.Text className="label">Number of buckets</Typography.Text> <section className="bucket-config control-container">
<InputNumber <Typography.Text className="section-heading">
value={bucketCount || null} Number of buckets
type="number"
min={0}
rootClassName="bucket-input"
placeholder="Default: 30"
onChange={(val): void => {
setBucketCount(val || 0);
}}
/>
<Typography.Text className="label bucket-size-label">
Bucket width
</Typography.Text>
<InputNumber
value={bucketWidth || null}
type="number"
precision={2}
placeholder="Default: Auto"
step={0.1}
min={0.0}
rootClassName="bucket-input"
onChange={(val): void => {
setBucketWidth(val || 0);
}}
/>
<section className="combine-hist">
<Typography.Text className="label">
Merge all series into one
</Typography.Text> </Typography.Text>
<Switch <InputNumber
checked={combineHistogram} value={bucketCount || null}
size="small" type="number"
onChange={(checked): void => setCombineHistogram(checked)} min={0}
rootClassName="bucket-input"
placeholder="Default: 30"
onChange={(val): void => {
setBucketCount(val || 0);
}}
/> />
<Typography.Text className="section-heading bucket-size-label">
Bucket width
</Typography.Text>
<InputNumber
value={bucketWidth || null}
type="number"
precision={2}
placeholder="Default: Auto"
step={0.1}
min={0.0}
rootClassName="bucket-input"
onChange={(val): void => {
setBucketWidth(val || 0);
}}
/>
<section className="combine-hist">
<Typography.Text className="section-heading">
Merge all series into one
</Typography.Text>
<Switch
checked={combineHistogram}
size="small"
onChange={(checked): void => setCombineHistogram(checked)}
/>
</section>
</section> </section>
</section> </SettingsSection>
)}
{allowLogScale && (
<section className="log-scale">
<Typography.Text className="typography">Y Axis Scale</Typography.Text>
<Select
onChange={(value): void => setIsLogScale(value === LogScale.LOGARITHMIC)}
value={isLogScale ? LogScale.LOGARITHMIC : LogScale.LINEAR}
style={{ width: '100%' }}
className="panel-type-select"
defaultValue={LogScale.LINEAR}
>
<Option value={LogScale.LINEAR}>
<div className="select-option">
<div className="icon">
<LineChart size={16} />
</div>
<Typography.Text className="display">Linear</Typography.Text>
</div>
</Option>
<Option value={LogScale.LOGARITHMIC}>
<div className="select-option">
<div className="icon">
<Spline size={16} />
</div>
<Typography.Text className="display">Logarithmic</Typography.Text>
</div>
</Option>
</Select>
</section>
)}
{allowLegendPosition && (
<section className="legend-position">
<Typography.Text className="typography">Legend Position</Typography.Text>
<Select
onChange={(value: LegendPosition): void => setLegendPosition(value)}
value={legendPosition}
style={{ width: '100%' }}
className="panel-type-select"
defaultValue={LegendPosition.BOTTOM}
>
<Option value={LegendPosition.BOTTOM}>
<div className="select-option">
<Typography.Text className="display">Bottom</Typography.Text>
</div>
</Option>
<Option value={LegendPosition.RIGHT}>
<div className="select-option">
<Typography.Text className="display">Right</Typography.Text>
</div>
</Option>
</Select>
</section>
)}
{allowLegendColors && (
<section className="legend-colors">
<LegendColors
customLegendColors={customLegendColors}
setCustomLegendColors={setCustomLegendColors}
queryResponse={queryResponse}
/>
</section>
)} )}
</section> </section>
@@ -541,17 +678,25 @@ function RightContainer({
)} )}
{allowContextLinks && ( {allowContextLinks && (
<section className="context-links"> <SettingsSection
title="Context Links"
icon={<Link size={14} />}
defaultOpen={!!contextLinks.linksData.length}
>
<ContextLinks <ContextLinks
contextLinks={contextLinks} contextLinks={contextLinks}
setContextLinks={setContextLinks} setContextLinks={setContextLinks}
selectedWidget={selectedWidget} selectedWidget={selectedWidget}
/> />
</section> </SettingsSection>
)} )}
{allowThreshold && ( {allowThreshold && (
<section> <SettingsSection
title="Thresholds"
icon={<Antenna size={14} />}
defaultOpen={!!thresholds.length}
>
<ThresholdSelector <ThresholdSelector
thresholds={thresholds} thresholds={thresholds}
setThresholds={setThresholds} setThresholds={setThresholds}
@@ -559,7 +704,7 @@ function RightContainer({
selectedGraph={selectedGraph} selectedGraph={selectedGraph}
columnUnits={columnUnits} columnUnits={columnUnits}
/> />
</section> </SettingsSection>
)} )}
</div> </div>
); );
@@ -615,6 +760,14 @@ export interface RightContainerProps {
setContextLinks: Dispatch<SetStateAction<ContextLinksData>>; setContextLinks: Dispatch<SetStateAction<ContextLinksData>>;
enableDrillDown?: boolean; enableDrillDown?: boolean;
isNewDashboard: boolean; isNewDashboard: boolean;
lineInterpolation: LineInterpolation;
setLineInterpolation: Dispatch<SetStateAction<LineInterpolation>>;
fillMode: FillMode;
setFillMode: Dispatch<SetStateAction<FillMode>>;
lineStyle: LineStyle;
setLineStyle: Dispatch<SetStateAction<LineStyle>>;
showPoints: boolean;
setShowPoints: Dispatch<SetStateAction<boolean>>;
} }
RightContainer.defaultProps = { RightContainer.defaultProps = {

View File

@@ -36,7 +36,7 @@ const checkStackSeriesState = (
expect(getByTextUtil(container, 'Stack series')).toBeInTheDocument(); expect(getByTextUtil(container, 'Stack series')).toBeInTheDocument();
const stackSeriesSection = container.querySelector( const stackSeriesSection = container.querySelector(
'section > .stack-chart', '.stack-chart',
) as HTMLElement; ) as HTMLElement;
expect(stackSeriesSection).toBeInTheDocument(); expect(stackSeriesSection).toBeInTheDocument();
@@ -326,7 +326,7 @@ describe('Stacking bar in new panel', () => {
expect(getByText('Stack series')).toBeInTheDocument(); expect(getByText('Stack series')).toBeInTheDocument();
// Verify section exists // Verify section exists
const section = container.querySelector('section > .stack-chart'); const section = container.querySelector('.stack-chart');
expect(section).toBeInTheDocument(); expect(section).toBeInTheDocument();
// Verify switch is present and enabled (ant-switch-checked) // Verify switch is present and enabled (ant-switch-checked)

View File

@@ -6,6 +6,11 @@ import { UseQueryResult } from 'react-query';
import { useSelector } from 'react-redux'; import { useSelector } from 'react-redux';
import { generatePath } from 'react-router-dom'; import { generatePath } from 'react-router-dom';
import { WarningOutlined } from '@ant-design/icons'; import { WarningOutlined } from '@ant-design/icons';
import {
ResizableHandle,
ResizablePanel,
ResizablePanelGroup,
} from '@signozhq/resizable';
import { Button, Flex, Modal, Space, Typography } from 'antd'; import { Button, Flex, Modal, Space, Typography } from 'antd';
import logEvent from 'api/common/logEvent'; import logEvent from 'api/common/logEvent';
import { PrecisionOption, PrecisionOptionsEnum } from 'components/Graph/types'; import { PrecisionOption, PrecisionOptionsEnum } from 'components/Graph/types';
@@ -30,6 +35,11 @@ import useUrlQuery from 'hooks/useUrlQuery';
import createQueryParams from 'lib/createQueryParams'; import createQueryParams from 'lib/createQueryParams';
import { GetQueryResultsProps } from 'lib/dashboard/getQueryResults'; import { GetQueryResultsProps } from 'lib/dashboard/getQueryResults';
import { getDashboardVariables } from 'lib/dashboardVariables/getDashboardVariables'; import { getDashboardVariables } from 'lib/dashboardVariables/getDashboardVariables';
import {
FillMode,
LineInterpolation,
LineStyle,
} from 'lib/uPlotV2/config/types';
import { cloneDeep, defaultTo, isEmpty, isUndefined } from 'lodash-es'; import { cloneDeep, defaultTo, isEmpty, isUndefined } from 'lodash-es';
import { Check, X } from 'lucide-react'; import { Check, X } from 'lucide-react';
import { useScrollToWidgetIdStore } from 'providers/Dashboard/helpers/scrollToWidgetIdHelper'; import { useScrollToWidgetIdStore } from 'providers/Dashboard/helpers/scrollToWidgetIdHelper';
@@ -204,6 +214,18 @@ function NewWidget({
const [legendPosition, setLegendPosition] = useState<LegendPosition>( const [legendPosition, setLegendPosition] = useState<LegendPosition>(
selectedWidget?.legendPosition || LegendPosition.BOTTOM, selectedWidget?.legendPosition || LegendPosition.BOTTOM,
); );
const [lineInterpolation, setLineInterpolation] = useState<LineInterpolation>(
selectedWidget?.lineInterpolation || LineInterpolation.Spline,
);
const [fillMode, setFillMode] = useState<FillMode>(
selectedWidget?.fillMode || FillMode.None,
);
const [lineStyle, setLineStyle] = useState<LineStyle>(
selectedWidget?.lineStyle || LineStyle.Solid,
);
const [showPoints, setShowPoints] = useState<boolean>(
selectedWidget?.showPoints ?? false,
);
const [customLegendColors, setCustomLegendColors] = useState< const [customLegendColors, setCustomLegendColors] = useState<
Record<string, string> Record<string, string>
>(selectedWidget?.customLegendColors || {}); >(selectedWidget?.customLegendColors || {});
@@ -269,6 +291,10 @@ function NewWidget({
softMin, softMin,
softMax, softMax,
fillSpans: isFillSpans, fillSpans: isFillSpans,
lineInterpolation,
fillMode,
lineStyle,
showPoints,
columnUnits, columnUnits,
bucketCount, bucketCount,
stackedBarChart, stackedBarChart,
@@ -304,6 +330,10 @@ function NewWidget({
stackedBarChart, stackedBarChart,
isLogScale, isLogScale,
legendPosition, legendPosition,
lineInterpolation,
fillMode,
lineStyle,
showPoints,
customLegendColors, customLegendColors,
contextLinks, contextLinks,
selectedWidget.columnWidths, selectedWidget.columnWidths,
@@ -439,6 +469,19 @@ function NewWidget({
globalSelectedInterval, globalSelectedInterval,
]); ]);
const navigateToDashboardPage = useCallback(() => {
const params = new URLSearchParams();
const urlVariablesQueryString = query.get(QueryParams.variables);
if (urlVariablesQueryString) {
params.set(QueryParams.variables, urlVariablesQueryString);
}
const search = params.toString() ? `?${params.toString()}` : '';
safeNavigate(generatePath(ROUTES.DASHBOARD, { dashboardId }) + search);
}, [dashboardId, query, safeNavigate]);
const onClickSaveHandler = useCallback(() => { const onClickSaveHandler = useCallback(() => {
if (!selectedDashboard) { if (!selectedDashboard) {
return; return;
@@ -554,9 +597,7 @@ function NewWidget({
updateDashboardMutation.mutateAsync(dashboard, { updateDashboardMutation.mutateAsync(dashboard, {
onSuccess: () => { onSuccess: () => {
setToScrollWidgetId(selectedWidget?.id || ''); setToScrollWidgetId(selectedWidget?.id || '');
safeNavigate({ navigateToDashboardPage();
pathname: generatePath(ROUTES.DASHBOARD, { dashboardId }),
});
}, },
}); });
}, [ }, [
@@ -572,7 +613,7 @@ function NewWidget({
updateDashboardMutation, updateDashboardMutation,
widgets, widgets,
setToScrollWidgetId, setToScrollWidgetId,
safeNavigate, navigateToDashboardPage,
dashboardId, dashboardId,
]); ]);
@@ -581,12 +622,12 @@ function NewWidget({
setDiscardModal(true); setDiscardModal(true);
return; return;
} }
safeNavigate(generatePath(ROUTES.DASHBOARD, { dashboardId })); navigateToDashboardPage();
}, [dashboardId, isQueryModified, safeNavigate]); }, [isQueryModified, navigateToDashboardPage]);
const discardChanges = useCallback(() => { const discardChanges = useCallback(() => {
safeNavigate(generatePath(ROUTES.DASHBOARD, { dashboardId })); navigateToDashboardPage();
}, [dashboardId, safeNavigate]); }, [navigateToDashboardPage]);
const setGraphHandler = (type: PANEL_TYPES): void => { const setGraphHandler = (type: PANEL_TYPES): void => {
setIsLoadingPanelData(true); setIsLoadingPanelData(true);
@@ -728,12 +769,14 @@ function NewWidget({
} }
const widgetId = query.get('widgetId') || ''; const widgetId = query.get('widgetId') || '';
const graphType = query.get('graphType') || ''; const graphType = query.get('graphType') || '';
const variables = query.get(QueryParams.variables) || '';
const queryParams = { const queryParams = {
[QueryParams.expandedWidgetId]: widgetId, [QueryParams.expandedWidgetId]: widgetId,
[QueryParams.graphType]: graphType, [QueryParams.graphType]: graphType,
[QueryParams.compositeQuery]: encodeURIComponent( [QueryParams.compositeQuery]: encodeURIComponent(
JSON.stringify(currentQuery), JSON.stringify(currentQuery),
), ),
[QueryParams.variables]: variables,
}; };
const updatedSearch = createQueryParams(queryParams); const updatedSearch = createQueryParams(queryParams);
@@ -744,7 +787,7 @@ function NewWidget({
}, [query, safeNavigate, dashboardId, currentQuery]); }, [query, safeNavigate, dashboardId, currentQuery]);
return ( return (
<Container> <Container className="new-widget-container">
<div className="edit-header"> <div className="edit-header">
<div className="left-header"> <div className="left-header">
<X <X
@@ -798,81 +841,104 @@ function NewWidget({
</div> </div>
<PanelContainer> <PanelContainer>
<LeftContainerWrapper isDarkMode={useIsDarkMode()}> <ResizablePanelGroup direction="horizontal" autoSaveId="panel-editor">
<OverlayScrollbar> <ResizablePanel
{selectedWidget && ( minSize={70}
<LeftContainer maxSize={80}
selectedGraph={graphType} defaultSize={80}
selectedLogFields={selectedLogFields} className="resizable-panel-left-container"
setSelectedLogFields={setSelectedLogFields} >
selectedTracesFields={selectedTracesFields} <OverlayScrollbar>
setSelectedTracesFields={setSelectedTracesFields} <LeftContainerWrapper isDarkMode={useIsDarkMode()}>
selectedWidget={selectedWidget} {selectedWidget && (
selectedTime={selectedTime} <LeftContainer
requestData={requestData} selectedDashboard={selectedDashboard}
setRequestData={setRequestData} selectedGraph={graphType}
isLoadingPanelData={isLoadingPanelData} selectedLogFields={selectedLogFields}
setQueryResponse={setQueryResponse} setSelectedLogFields={setSelectedLogFields}
enableDrillDown={enableDrillDown} selectedTracesFields={selectedTracesFields}
selectedDashboard={selectedDashboard} setSelectedTracesFields={setSelectedTracesFields}
isNewPanel={isNewPanel} selectedWidget={selectedWidget}
/> selectedTime={selectedTime}
)} requestData={requestData}
</OverlayScrollbar> setRequestData={setRequestData}
</LeftContainerWrapper> isLoadingPanelData={isLoadingPanelData}
setQueryResponse={setQueryResponse}
<RightContainerWrapper> enableDrillDown={enableDrillDown}
<OverlayScrollbar> />
<RightContainer )}
setGraphHandler={setGraphHandler} </LeftContainerWrapper>
title={title} </OverlayScrollbar>
setTitle={setTitle} </ResizablePanel>
description={description} <ResizableHandle withHandle className="widget-resizable-handle" />
setDescription={setDescription} <ResizablePanel
stackedBarChart={stackedBarChart} minSize={20}
setStackedBarChart={setStackedBarChart} maxSize={30}
opacity={opacity} defaultSize={20}
yAxisUnit={yAxisUnit} className="resizable-panel-right-container"
columnUnits={columnUnits} >
setColumnUnits={setColumnUnits} <OverlayScrollbar>
bucketCount={bucketCount} <RightContainerWrapper>
bucketWidth={bucketWidth} <RightContainer
combineHistogram={combineHistogram} setGraphHandler={setGraphHandler}
setCombineHistogram={setCombineHistogram} title={title}
setBucketWidth={setBucketWidth} setTitle={setTitle}
setBucketCount={setBucketCount} description={description}
setOpacity={setOpacity} setDescription={setDescription}
selectedNullZeroValue={selectedNullZeroValue} stackedBarChart={stackedBarChart}
setSelectedNullZeroValue={setSelectedNullZeroValue} setStackedBarChart={setStackedBarChart}
selectedGraph={graphType} lineInterpolation={lineInterpolation}
setSelectedTime={setSelectedTime} setLineInterpolation={setLineInterpolation}
selectedTime={selectedTime} fillMode={fillMode}
setYAxisUnit={setYAxisUnit} setFillMode={setFillMode}
decimalPrecision={decimalPrecision} lineStyle={lineStyle}
setDecimalPrecision={setDecimalPrecision} setLineStyle={setLineStyle}
thresholds={thresholds} showPoints={showPoints}
setThresholds={setThresholds} setShowPoints={setShowPoints}
selectedWidget={selectedWidget} opacity={opacity}
isFillSpans={isFillSpans} yAxisUnit={yAxisUnit}
setIsFillSpans={setIsFillSpans} columnUnits={columnUnits}
isLogScale={isLogScale} setColumnUnits={setColumnUnits}
setIsLogScale={setIsLogScale} bucketCount={bucketCount}
legendPosition={legendPosition} bucketWidth={bucketWidth}
setLegendPosition={setLegendPosition} combineHistogram={combineHistogram}
customLegendColors={customLegendColors} setCombineHistogram={setCombineHistogram}
setCustomLegendColors={setCustomLegendColors} setBucketWidth={setBucketWidth}
queryResponse={queryResponse} setBucketCount={setBucketCount}
softMin={softMin} setOpacity={setOpacity}
setSoftMin={setSoftMin} selectedNullZeroValue={selectedNullZeroValue}
softMax={softMax} setSelectedNullZeroValue={setSelectedNullZeroValue}
setSoftMax={setSoftMax} selectedGraph={graphType}
contextLinks={contextLinks} setSelectedTime={setSelectedTime}
setContextLinks={setContextLinks} selectedTime={selectedTime}
enableDrillDown={enableDrillDown} setYAxisUnit={setYAxisUnit}
isNewDashboard={isNewDashboard} decimalPrecision={decimalPrecision}
/> setDecimalPrecision={setDecimalPrecision}
</OverlayScrollbar> thresholds={thresholds}
</RightContainerWrapper> setThresholds={setThresholds}
selectedWidget={selectedWidget}
isFillSpans={isFillSpans}
setIsFillSpans={setIsFillSpans}
isLogScale={isLogScale}
setIsLogScale={setIsLogScale}
legendPosition={legendPosition}
setLegendPosition={setLegendPosition}
customLegendColors={customLegendColors}
setCustomLegendColors={setCustomLegendColors}
queryResponse={queryResponse}
softMin={softMin}
setSoftMin={setSoftMin}
softMax={softMax}
setSoftMax={setSoftMax}
contextLinks={contextLinks}
setContextLinks={setContextLinks}
enableDrillDown={enableDrillDown}
isNewDashboard={isNewDashboard}
/>
</RightContainerWrapper>
</OverlayScrollbar>
</ResizablePanel>
</ResizablePanelGroup>
</PanelContainer> </PanelContainer>
<Modal <Modal
title={ title={

View File

@@ -10,8 +10,7 @@ export const Container = styled.div`
export const RightContainerWrapper = styled(Col)` export const RightContainerWrapper = styled(Col)`
&&& { &&& {
max-width: 400px; width: 100%;
width: 30%;
overflow-y: auto; overflow-y: auto;
} }
&::-webkit-scrollbar { &::-webkit-scrollbar {

View File

@@ -30,7 +30,6 @@ import { AppState } from 'store/reducers';
import AppActions from 'types/actions'; import AppActions from 'types/actions';
import { GlobalReducer } from 'types/reducer/globalTime'; import { GlobalReducer } from 'types/reducer/globalTime';
import { addCustomTimeRange } from 'utils/customTimeRangeUtils'; import { addCustomTimeRange } from 'utils/customTimeRangeUtils';
import { persistTimeDurationForRoute } from 'utils/metricsTimeStorageUtils';
import { normalizeTimeToMs } from 'utils/timeUtils'; import { normalizeTimeToMs } from 'utils/timeUtils';
import { v4 as uuid } from 'uuid'; import { v4 as uuid } from 'uuid';
@@ -235,7 +234,20 @@ function DateTimeSelection({
const updateLocalStorageForRoutes = useCallback( const updateLocalStorageForRoutes = useCallback(
(value: Time | string): void => { (value: Time | string): void => {
persistTimeDurationForRoute(location.pathname, String(value)); const preRoutes = getLocalStorageKey(LOCALSTORAGE.METRICS_TIME_IN_DURATION);
if (preRoutes !== null) {
const preRoutesObject = JSON.parse(preRoutes);
const preRoute = {
...preRoutesObject,
};
preRoute[location.pathname] = value;
setLocalStorageKey(
LOCALSTORAGE.METRICS_TIME_IN_DURATION,
JSON.stringify(preRoute),
);
}
}, },
[location.pathname], [location.pathname],
); );
@@ -726,7 +738,6 @@ function DateTimeSelection({
showRecentlyUsed={showRecentlyUsed} showRecentlyUsed={showRecentlyUsed}
minTime={minTimeForDateTimePicker} minTime={minTimeForDateTimePicker}
maxTime={maxTimeForDateTimePicker} maxTime={maxTimeForDateTimePicker}
isModalTimeSelection={isModalTimeSelection}
/> />
{showAutoRefresh && selectedTime !== 'custom' && ( {showAutoRefresh && selectedTime !== 'custom' && (

View File

@@ -1,160 +0,0 @@
import { act, renderHook } from '@testing-library/react';
import { QueryParams } from 'constants/query';
import { GlobalReducer } from 'types/reducer/globalTime';
import { useZoomOut } from '../useZoomOut';
const mockDispatch = jest.fn();
const mockSafeNavigate = jest.fn();
const mockUrlQueryDelete = jest.fn();
const mockUrlQuerySet = jest.fn();
const mockUrlQueryToString = jest.fn(() => '');
interface MockAppState {
globalTime: Pick<GlobalReducer, 'minTime' | 'maxTime'>;
}
jest.mock('react-redux', () => ({
useDispatch: (): jest.Mock => mockDispatch,
useSelector: <T>(selector: (state: MockAppState) => T): T => {
const mockState: MockAppState = {
globalTime: {
minTime: 15 * 60 * 1000 * 1e6, // 15 min in nanoseconds
maxTime: 30 * 60 * 1000 * 1e6, // 30 min in nanoseconds (mock for getNextZoomOutRange)
},
};
return selector(mockState);
},
}));
jest.mock('react-router-dom', () => ({
useLocation: (): { pathname: string } => ({ pathname: '/logs-explorer' }),
}));
jest.mock('hooks/useSafeNavigate', () => ({
useSafeNavigate: (): { safeNavigate: typeof mockSafeNavigate } => ({
safeNavigate: mockSafeNavigate,
}),
}));
interface MockUrlQuery {
delete: typeof mockUrlQueryDelete;
set: typeof mockUrlQuerySet;
get: () => null;
toString: typeof mockUrlQueryToString;
}
jest.mock('hooks/useUrlQuery', () => ({
__esModule: true,
default: (): MockUrlQuery => ({
delete: mockUrlQueryDelete,
set: mockUrlQuerySet,
get: (): null => null,
toString: mockUrlQueryToString,
}),
}));
const mockGetNextZoomOutRange = jest.fn();
jest.mock('lib/zoomOutUtils', () => ({
getNextZoomOutRange: (
...args: unknown[]
): ReturnType<typeof mockGetNextZoomOutRange> =>
mockGetNextZoomOutRange(...args),
}));
describe('useZoomOut', () => {
beforeEach(() => {
jest.clearAllMocks();
mockUrlQueryToString.mockReturnValue('relativeTime=45m');
});
it('should do nothing when isDisabled is true', () => {
const { result } = renderHook(() => useZoomOut({ isDisabled: true }));
act(() => {
result.current();
});
expect(mockGetNextZoomOutRange).not.toHaveBeenCalled();
expect(mockDispatch).not.toHaveBeenCalled();
expect(mockSafeNavigate).not.toHaveBeenCalled();
});
it('should do nothing when getNextZoomOutRange returns null', () => {
mockGetNextZoomOutRange.mockReturnValue(null);
const { result } = renderHook(() => useZoomOut());
act(() => {
result.current();
});
expect(mockGetNextZoomOutRange).toHaveBeenCalled();
expect(mockDispatch).not.toHaveBeenCalled();
expect(mockSafeNavigate).not.toHaveBeenCalled();
});
it('should dispatch preset and update URL when result has preset', () => {
mockGetNextZoomOutRange.mockReturnValue({
range: [1000, 2000],
preset: '45m',
});
const { result } = renderHook(() => useZoomOut());
act(() => {
result.current();
});
expect(mockDispatch).toHaveBeenCalledWith(expect.any(Function));
expect(mockUrlQueryDelete).toHaveBeenCalledWith(QueryParams.startTime);
expect(mockUrlQueryDelete).toHaveBeenCalledWith(QueryParams.endTime);
expect(mockUrlQuerySet).toHaveBeenCalledWith(QueryParams.relativeTime, '45m');
expect(mockSafeNavigate).toHaveBeenCalledWith(
expect.stringContaining('/logs-explorer'),
);
});
it('should dispatch custom range and update URL when result has no preset', () => {
mockGetNextZoomOutRange.mockReturnValue({
range: [1000000, 2000000],
preset: null,
});
const { result } = renderHook(() => useZoomOut());
act(() => {
result.current();
});
expect(mockDispatch).toHaveBeenCalledWith(expect.any(Function));
expect(mockUrlQuerySet).toHaveBeenCalledWith(
QueryParams.startTime,
'1000000',
);
expect(mockUrlQuerySet).toHaveBeenCalledWith(QueryParams.endTime, '2000000');
expect(mockUrlQueryDelete).toHaveBeenCalledWith(QueryParams.relativeTime);
expect(mockSafeNavigate).toHaveBeenCalledWith(
expect.stringContaining('/logs-explorer'),
);
});
it('should delete urlParamsToDelete when provided', () => {
mockGetNextZoomOutRange.mockReturnValue({
range: [1000, 2000],
preset: '45m',
});
const { result } = renderHook(() =>
useZoomOut({
urlParamsToDelete: [QueryParams.activeLogId],
}),
);
act(() => {
result.current();
});
expect(mockUrlQueryDelete).toHaveBeenCalledWith(QueryParams.activeLogId);
});
});

View File

@@ -0,0 +1,339 @@
import { renderHook } from '@testing-library/react';
import { useDashboardVariablesFromLocalStorage } from 'hooks/dashboard/useDashboardFromLocalStorage';
import { useTransformDashboardVariables } from 'hooks/dashboard/useTransformDashboardVariables';
import useVariablesFromUrl from 'hooks/dashboard/useVariablesFromUrl';
import { Dashboard, IDashboardVariable } from 'types/api/dashboard/getAll';
jest.mock('hooks/dashboard/useDashboardFromLocalStorage');
jest.mock('hooks/dashboard/useVariablesFromUrl');
const mockUseDashboardVariablesFromLocalStorage = useDashboardVariablesFromLocalStorage as jest.MockedFunction<
typeof useDashboardVariablesFromLocalStorage
>;
const mockUseVariablesFromUrl = useVariablesFromUrl as jest.MockedFunction<
typeof useVariablesFromUrl
>;
const makeVariable = (
overrides: Partial<IDashboardVariable> = {},
): IDashboardVariable => ({
id: 'existing-id',
name: 'env',
description: '',
type: 'QUERY',
sort: 'DISABLED',
multiSelect: false,
showALLOption: false,
selectedValue: 'prod',
...overrides,
});
const makeDashboard = (
variables: Record<string, IDashboardVariable>,
): Dashboard => ({
id: 'dash-1',
createdAt: '',
updatedAt: '',
createdBy: '',
updatedBy: '',
data: {
title: 'Test',
variables,
},
});
const setupHook = (
currentDashboard: Record<string, any> = {},
urlVariables: Record<string, any> = {},
): ReturnType<typeof useTransformDashboardVariables> => {
mockUseDashboardVariablesFromLocalStorage.mockReturnValue({
currentDashboard,
updateLocalStorageDashboardVariables: jest.fn(),
});
mockUseVariablesFromUrl.mockReturnValue({
getUrlVariables: () => urlVariables,
setUrlVariables: jest.fn(),
updateUrlVariable: jest.fn(),
});
const { result } = renderHook(() => useTransformDashboardVariables('dash-1'));
return result.current;
};
describe('useTransformDashboardVariables', () => {
beforeEach(() => jest.clearAllMocks());
describe('order assignment', () => {
it('assigns order starting from 0 to variables that have none', () => {
const { transformDashboardVariables } = setupHook();
const dashboard = makeDashboard({
v1: makeVariable({ id: 'id1', name: 'v1', order: undefined }),
v2: makeVariable({ id: 'id2', name: 'v2', order: undefined }),
});
const result = transformDashboardVariables(dashboard);
const orders = Object.values(result.data.variables).map((v) => v.order);
expect(orders).toContain(0);
expect(orders).toContain(1);
});
it('preserves existing order values', () => {
const { transformDashboardVariables } = setupHook();
const dashboard = makeDashboard({
v1: makeVariable({ id: 'id1', name: 'v1', order: 5 }),
});
const result = transformDashboardVariables(dashboard);
expect(result.data.variables.v1.order).toBe(5);
});
it('assigns unique orders across multiple variables that all lack an order', () => {
const { transformDashboardVariables } = setupHook();
const dashboard = makeDashboard({
v1: makeVariable({ id: 'id1', name: 'v1', order: undefined }),
v2: makeVariable({ id: 'id2', name: 'v2', order: undefined }),
v3: makeVariable({ id: 'id3', name: 'v3', order: undefined }),
});
const result = transformDashboardVariables(dashboard);
const orders = Object.values(result.data.variables).map((v) => v.order);
// All three newly assigned orders must be distinct
expect(new Set(orders).size).toBe(3);
});
});
describe('ID assignment', () => {
it('assigns a UUID to variables that have no id', () => {
const { transformDashboardVariables } = setupHook();
const variable = makeVariable({ name: 'v1' });
(variable as any).id = undefined;
const dashboard = makeDashboard({ v1: variable });
const result = transformDashboardVariables(dashboard);
expect(result.data.variables.v1.id).toMatch(
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i,
);
});
it('preserves existing IDs', () => {
const { transformDashboardVariables } = setupHook();
const dashboard = makeDashboard({
v1: makeVariable({ id: 'keep-me', name: 'v1' }),
});
const result = transformDashboardVariables(dashboard);
expect(result.data.variables.v1.id).toBe('keep-me');
});
});
describe('TEXTBOX backward compatibility', () => {
it('copies textboxValue to defaultValue when defaultValue is missing', () => {
const { transformDashboardVariables } = setupHook();
const dashboard = makeDashboard({
v1: makeVariable({
id: 'id1',
name: 'v1',
type: 'TEXTBOX',
textboxValue: 'hello',
defaultValue: undefined,
order: undefined,
}),
});
const result = transformDashboardVariables(dashboard);
expect(result.data.variables.v1.defaultValue).toBe('hello');
});
it('does not overwrite an existing defaultValue', () => {
const { transformDashboardVariables } = setupHook();
const dashboard = makeDashboard({
v1: makeVariable({
id: 'id1',
name: 'v1',
type: 'TEXTBOX',
textboxValue: 'old',
defaultValue: 'keep',
order: undefined,
}),
});
const result = transformDashboardVariables(dashboard);
expect(result.data.variables.v1.defaultValue).toBe('keep');
});
});
describe('localStorage merge', () => {
it('applies localStorage selectedValue over DB value', () => {
const { transformDashboardVariables } = setupHook({
env: { selectedValue: 'staging', allSelected: false },
});
const dashboard = makeDashboard({
v1: makeVariable({ id: 'id1', name: 'env', selectedValue: 'prod' }),
});
const result = transformDashboardVariables(dashboard);
expect(result.data.variables.v1.selectedValue).toBe('staging');
});
it('applies localStorage allSelected over DB value', () => {
const { transformDashboardVariables } = setupHook({
env: { selectedValue: undefined, allSelected: true },
});
const dashboard = makeDashboard({
v1: makeVariable({
id: 'id1',
name: 'env',
allSelected: false,
showALLOption: true,
}),
});
const result = transformDashboardVariables(dashboard);
expect(result.data.variables.v1.allSelected).toBe(true);
});
});
describe('URL variable override', () => {
it('sets allSelected=true when URL value is __ALL__', () => {
const { transformDashboardVariables } = setupHook(
{ env: { selectedValue: 'prod', allSelected: false } },
{ env: '__ALL__' },
);
const dashboard = makeDashboard({
v1: makeVariable({
id: 'id1',
name: 'env',
showALLOption: true,
allSelected: false,
}),
});
const result = transformDashboardVariables(dashboard);
expect(result.data.variables.v1.allSelected).toBe(true);
});
it('sets selectedValue from URL and clears allSelected when showALLOption is true', () => {
const { transformDashboardVariables } = setupHook(
{ env: { selectedValue: undefined, allSelected: true } },
{ env: 'dev' },
);
const dashboard = makeDashboard({
v1: makeVariable({
id: 'id1',
name: 'env',
showALLOption: true,
allSelected: true,
multiSelect: false,
}),
});
const result = transformDashboardVariables(dashboard);
expect(result.data.variables.v1.selectedValue).toBe('dev');
expect(result.data.variables.v1.allSelected).toBe(false);
});
it('does not set allSelected=false when showALLOption is false', () => {
const { transformDashboardVariables } = setupHook(
{ env: { selectedValue: undefined, allSelected: true } },
{ env: 'dev' },
);
const dashboard = makeDashboard({
v1: makeVariable({
id: 'id1',
name: 'env',
showALLOption: false,
allSelected: true,
multiSelect: false,
}),
});
const result = transformDashboardVariables(dashboard);
expect(result.data.variables.v1.selectedValue).toBe('dev');
expect(result.data.variables.v1.allSelected).toBe(true);
});
it('normalizes array URL value to single value for single-select variable', () => {
const { transformDashboardVariables } = setupHook(
{},
{ env: ['prod', 'dev'] },
);
const dashboard = makeDashboard({
v1: makeVariable({
id: 'id1',
name: 'env',
multiSelect: false,
}),
});
const result = transformDashboardVariables(dashboard);
expect(result.data.variables.v1.selectedValue).toBe('prod');
});
it('wraps single URL value in array for multi-select variable', () => {
const { transformDashboardVariables } = setupHook({}, { env: 'prod' });
const dashboard = makeDashboard({
v1: makeVariable({
id: 'id1',
name: 'env',
multiSelect: true,
}),
});
const result = transformDashboardVariables(dashboard);
expect(result.data.variables.v1.selectedValue).toEqual(['prod']);
});
it('looks up URL variable by variable id when name is absent', () => {
const { transformDashboardVariables } = setupHook(
{},
{ 'var-uuid': 'fallback' },
);
const variable = makeVariable({ id: 'var-uuid', multiSelect: false });
delete variable.name;
const dashboard = makeDashboard({ v1: variable });
const result = transformDashboardVariables(dashboard);
expect(result.data.variables.v1.selectedValue).toBe('fallback');
});
});
describe('edge cases', () => {
it('returns data unchanged when there are no variables', () => {
const { transformDashboardVariables } = setupHook();
const dashboard = makeDashboard({});
const result = transformDashboardVariables(dashboard);
expect(result.data.variables).toEqual({});
});
it('does not mutate the original dashboard', () => {
const { transformDashboardVariables } = setupHook({
env: { selectedValue: 'staging', allSelected: false },
});
const dashboard = makeDashboard({
v1: makeVariable({ id: 'id1', name: 'env', selectedValue: 'prod' }),
});
const originalValue = dashboard.data.variables.v1.selectedValue;
transformDashboardVariables(dashboard);
expect(dashboard.data.variables.v1.selectedValue).toBe(originalValue);
});
});
});

View File

@@ -15,7 +15,7 @@ interface DashboardLocalStorageVariables {
[id: string]: LocalStoreDashboardVariables; [id: string]: LocalStoreDashboardVariables;
} }
interface UseDashboardVariablesFromLocalStorageReturn { export interface UseDashboardVariablesFromLocalStorageReturn {
currentDashboard: LocalStoreDashboardVariables; currentDashboard: LocalStoreDashboardVariables;
updateLocalStorageDashboardVariables: ( updateLocalStorageDashboardVariables: (
id: string, id: string,

View File

@@ -0,0 +1,128 @@
import { ALL_SELECTED_VALUE } from 'components/NewSelect/utils';
import {
useDashboardVariablesFromLocalStorage,
UseDashboardVariablesFromLocalStorageReturn,
} from 'hooks/dashboard/useDashboardFromLocalStorage';
import useVariablesFromUrl, {
UseVariablesFromUrlReturn,
} from 'hooks/dashboard/useVariablesFromUrl';
import { isEmpty } from 'lodash-es';
import { normalizeUrlValueForVariable } from 'providers/Dashboard/normalizeUrlValue';
import { Dashboard, IDashboardVariable } from 'types/api/dashboard/getAll';
import { v4 as generateUUID } from 'uuid';
export function useTransformDashboardVariables(
dashboardId: string,
): Pick<UseVariablesFromUrlReturn, 'getUrlVariables' | 'updateUrlVariable'> &
UseDashboardVariablesFromLocalStorageReturn & {
transformDashboardVariables: (data: Dashboard) => Dashboard;
} {
const {
currentDashboard,
updateLocalStorageDashboardVariables,
} = useDashboardVariablesFromLocalStorage(dashboardId);
const { getUrlVariables, updateUrlVariable } = useVariablesFromUrl();
const mergeDBWithLocalStorage = (
data: Dashboard,
localStorageVariables: any,
): Dashboard => {
const updatedData = data;
if (data && localStorageVariables) {
const updatedVariables = data.data.variables;
const variablesFromUrl = getUrlVariables();
Object.keys(data.data.variables).forEach((variable) => {
const variableData = data.data.variables[variable];
// values from url
const urlVariable = variableData?.name
? variablesFromUrl[variableData?.name] || variablesFromUrl[variableData.id]
: variablesFromUrl[variableData.id];
let updatedVariable = {
...data.data.variables[variable],
...localStorageVariables[variableData.name as any],
};
// respect the url variable if it is set, override the others
if (!isEmpty(urlVariable)) {
if (urlVariable === ALL_SELECTED_VALUE) {
updatedVariable = {
...updatedVariable,
allSelected: true,
};
} else {
// Normalize URL value to match variable's multiSelect configuration
const normalizedValue = normalizeUrlValueForVariable(
urlVariable,
variableData,
);
updatedVariable = {
...updatedVariable,
selectedValue: normalizedValue,
// Only set allSelected to false if showALLOption is available
...(updatedVariable?.showALLOption && { allSelected: false }),
};
}
}
updatedVariables[variable] = updatedVariable;
});
updatedData.data.variables = updatedVariables;
}
return updatedData;
};
// As we do not have order and ID's in the variables object, we have to process variables to add order and ID if they do not exist in the variables object
// eslint-disable-next-line sonarjs/cognitive-complexity
const transformDashboardVariables = (data: Dashboard): Dashboard => {
if (data && data.data && data.data.variables) {
const clonedDashboardData = mergeDBWithLocalStorage(
JSON.parse(JSON.stringify(data)),
currentDashboard,
);
const { variables } = clonedDashboardData.data;
const existingOrders: Set<number> = new Set();
for (const key in variables) {
// eslint-disable-next-line no-prototype-builtins
if (variables.hasOwnProperty(key)) {
const variable: IDashboardVariable = variables[key];
// Check if 'order' property doesn't exist or is undefined
if (variable.order === undefined) {
// Find a unique order starting from 0
let order = 0;
while (existingOrders.has(order)) {
order += 1;
}
variable.order = order;
existingOrders.add(order);
// ! BWC - Specific case for backward compatibility where textboxValue was used instead of defaultValue
if (variable.type === 'TEXTBOX' && !variable.defaultValue) {
variable.defaultValue = variable.textboxValue || '';
}
}
if (variable.id === undefined) {
variable.id = generateUUID();
}
}
}
return clonedDashboardData;
}
return data;
};
return {
transformDashboardVariables,
getUrlVariables,
updateUrlVariable,
currentDashboard,
updateLocalStorageDashboardVariables,
};
}

View File

@@ -11,7 +11,7 @@ export interface LocalStoreDashboardVariables {
| IDashboardVariable['selectedValue']; | IDashboardVariable['selectedValue'];
} }
interface UseVariablesFromUrlReturn { export interface UseVariablesFromUrlReturn {
getUrlVariables: () => LocalStoreDashboardVariables; getUrlVariables: () => LocalStoreDashboardVariables;
setUrlVariables: (variables: LocalStoreDashboardVariables) => void; setUrlVariables: (variables: LocalStoreDashboardVariables) => void;
updateUrlVariable: ( updateUrlVariable: (

View File

@@ -1,79 +0,0 @@
import { useCallback, useRef } from 'react';
// eslint-disable-next-line no-restricted-imports
import { useDispatch, useSelector } from 'react-redux';
import { useLocation } from 'react-router-dom';
import { QueryParams } from 'constants/query';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import useUrlQuery from 'hooks/useUrlQuery';
import { getNextZoomOutRange } from 'lib/zoomOutUtils';
import { UpdateTimeInterval } from 'store/actions';
import { AppState } from 'store/reducers';
import { GlobalReducer } from 'types/reducer/globalTime';
import { persistTimeDurationForRoute } from 'utils/metricsTimeStorageUtils';
export interface UseZoomOutOptions {
/** When true, the zoom out handler does nothing (e.g. when live logs are enabled) */
isDisabled?: boolean;
/** URL params to delete when zooming out (e.g. [QueryParams.activeLogId] for logs) */
urlParamsToDelete?: string[];
}
/**
* Reusable hook for zoom-out functionality in explorers (logs, traces, etc.).
* Computes the next time range using the zoom-out ladder, updates Redux global time,
* and navigates with the new URL params.
*/
const EMPTY_PARAMS: string[] = [];
export function useZoomOut(options: UseZoomOutOptions = {}): () => void {
const { isDisabled = false, urlParamsToDelete = EMPTY_PARAMS } = options;
const urlParamsToDeleteRef = useRef(urlParamsToDelete);
urlParamsToDeleteRef.current = urlParamsToDelete;
const dispatch = useDispatch();
const { minTime, maxTime } = useSelector<AppState, GlobalReducer>(
(state) => state.globalTime,
);
const urlQuery = useUrlQuery();
const location = useLocation();
const { safeNavigate } = useSafeNavigate();
return useCallback((): void => {
if (isDisabled) {
return;
}
const minMs = Math.floor((minTime ?? 0) / 1e6);
const maxMs = Math.floor((maxTime ?? 0) / 1e6);
const result = getNextZoomOutRange(minMs, maxMs);
if (!result) {
return;
}
const [newStartMs, newEndMs] = result.range;
const { preset } = result;
if (preset) {
dispatch(UpdateTimeInterval(preset));
urlQuery.delete(QueryParams.startTime);
urlQuery.delete(QueryParams.endTime);
urlQuery.set(QueryParams.relativeTime, preset);
persistTimeDurationForRoute(location.pathname, preset);
} else {
dispatch(UpdateTimeInterval('custom', [newStartMs, newEndMs]));
urlQuery.set(QueryParams.startTime, String(newStartMs));
urlQuery.set(QueryParams.endTime, String(newEndMs));
urlQuery.delete(QueryParams.relativeTime);
}
for (const param of urlParamsToDeleteRef.current) {
urlQuery.delete(param);
}
safeNavigate(`${location.pathname}?${urlQuery.toString()}`);
}, [
dispatch,
isDisabled,
location.pathname,
maxTime,
minTime,
safeNavigate,
urlQuery,
]);
}

View File

@@ -1,147 +0,0 @@
import {
getNextDurationInLadder,
getNextZoomOutRange,
isZoomOutDisabled,
ZoomOutResult,
} from '../zoomOutUtils';
const MS_PER_MIN = 60 * 1000;
const MS_PER_HOUR = 60 * MS_PER_MIN;
const MS_PER_DAY = 24 * MS_PER_HOUR;
const MS_PER_WEEK = 7 * MS_PER_DAY;
// Fixed "now" for deterministic tests: 2024-01-15 12:00:00 UTC
const NOW_MS = 1705312800000;
describe('zoomOutUtils', () => {
beforeEach(() => {
jest.spyOn(Date, 'now').mockReturnValue(NOW_MS);
});
afterEach(() => {
jest.restoreAllMocks();
});
describe('getNextDurationInLadder', () => {
it('should use 3x zoom out below 15m until reaching 15m', () => {
expect(getNextDurationInLadder(1 * MS_PER_MIN)).toBe(3 * MS_PER_MIN);
expect(getNextDurationInLadder(2 * MS_PER_MIN)).toBe(6 * MS_PER_MIN);
expect(getNextDurationInLadder(3 * MS_PER_MIN)).toBe(9 * MS_PER_MIN);
expect(getNextDurationInLadder(4 * MS_PER_MIN)).toBe(12 * MS_PER_MIN);
expect(getNextDurationInLadder(5 * MS_PER_MIN)).toBe(15 * MS_PER_MIN); // cap at 15m
expect(getNextDurationInLadder(6 * MS_PER_MIN)).toBe(15 * MS_PER_MIN); // 18m capped
});
it('should return next step for each ladder rung from 15m onward', () => {
expect(getNextDurationInLadder(10 * MS_PER_MIN)).toBe(15 * MS_PER_MIN);
expect(getNextDurationInLadder(15 * MS_PER_MIN)).toBe(45 * MS_PER_MIN);
expect(getNextDurationInLadder(45 * MS_PER_MIN)).toBe(2 * MS_PER_HOUR);
expect(getNextDurationInLadder(2 * MS_PER_HOUR)).toBe(7 * MS_PER_HOUR);
expect(getNextDurationInLadder(7 * MS_PER_HOUR)).toBe(21 * MS_PER_HOUR);
expect(getNextDurationInLadder(21 * MS_PER_HOUR)).toBe(1 * MS_PER_DAY);
expect(getNextDurationInLadder(1 * MS_PER_DAY)).toBe(2 * MS_PER_DAY);
expect(getNextDurationInLadder(2 * MS_PER_DAY)).toBe(3 * MS_PER_DAY);
expect(getNextDurationInLadder(3 * MS_PER_DAY)).toBe(1 * MS_PER_WEEK);
expect(getNextDurationInLadder(1 * MS_PER_WEEK)).toBe(2 * MS_PER_WEEK);
expect(getNextDurationInLadder(2 * MS_PER_WEEK)).toBe(30 * MS_PER_DAY);
});
it('should return MAX when at or past 1 month (no wrap)', () => {
expect(getNextDurationInLadder(30 * MS_PER_DAY)).toBe(30 * MS_PER_DAY);
expect(getNextDurationInLadder(31 * MS_PER_DAY)).toBe(30 * MS_PER_DAY);
});
it('should return next step for duration between ladder rungs', () => {
expect(getNextDurationInLadder(1 * MS_PER_HOUR)).toBe(2 * MS_PER_HOUR);
expect(getNextDurationInLadder(5 * MS_PER_HOUR)).toBe(7 * MS_PER_HOUR);
expect(getNextDurationInLadder(12 * MS_PER_HOUR)).toBe(21 * MS_PER_HOUR);
});
});
describe('getNextZoomOutRange', () => {
it('should return null when duration is zero or negative', () => {
expect(getNextZoomOutRange(NOW_MS, NOW_MS)).toBeNull();
expect(getNextZoomOutRange(NOW_MS, NOW_MS - 1000)).toBeNull();
});
it('should return center-anchored range and preset=null when new end does not exceed now (Phase 1)', () => {
// 15m range centered well before now so zoom to 45m keeps end <= now
// Center at now-30m: end = center + 22.5m = now - 7.5m <= now
const centerMs = NOW_MS - 30 * MS_PER_MIN;
const start15m = centerMs - 7.5 * MS_PER_MIN;
const end15m = centerMs + 7.5 * MS_PER_MIN;
const result = getNextZoomOutRange(start15m, end15m) as ZoomOutResult;
expect(result).not.toBeNull();
expect(result.preset).toBeNull(); // Phase 1: preserve center-anchored range, avoid GetMinMax "last X from now"
const [newStart, newEnd] = result.range;
expect(newEnd - newStart).toBe(45 * MS_PER_MIN);
const newCenter = (newStart + newEnd) / 2;
expect(Math.abs(newCenter - centerMs)).toBeLessThan(2000);
expect(newEnd).toBeLessThanOrEqual(NOW_MS + 1000);
});
it('should return end-anchored range when new end would exceed now (Phase 2)', () => {
// 22hr range ending at now - zoom to 1d (24hr) would push end past now
// Next ladder step from 22hr is 1d
const start22h = NOW_MS - 22 * MS_PER_HOUR;
const end22h = NOW_MS;
const result = getNextZoomOutRange(start22h, end22h) as ZoomOutResult;
expect(result).not.toBeNull();
expect(result.preset).toBe('1d');
const [newStart, newEnd] = result.range;
expect(newEnd).toBe(NOW_MS); // End anchored at now
expect(newStart).toBe(NOW_MS - 1 * MS_PER_DAY);
});
it('should return correct preset for each ladder step', () => {
const presets: [number, number, string][] = [
[15 * MS_PER_MIN, 0, '45m'],
[45 * MS_PER_MIN, 0, '2h'],
[2 * MS_PER_HOUR, 0, '7h'],
[7 * MS_PER_HOUR, 0, '21h'],
[21 * MS_PER_HOUR, 0, '1d'],
[1 * MS_PER_DAY, 0, '2d'],
[2 * MS_PER_DAY, 0, '3d'],
[3 * MS_PER_DAY, 0, '1w'],
[1 * MS_PER_WEEK, 0, '2w'],
[2 * MS_PER_WEEK, 0, '1month'],
];
presets.forEach(([durationMs, offset, expectedPreset]) => {
const end = NOW_MS - offset;
const start = end - durationMs;
const result = getNextZoomOutRange(start, end);
expect(result?.preset).toBe(expectedPreset);
});
});
it('isZoomOutDisabled returns true when duration >= 1 month', () => {
expect(isZoomOutDisabled(30 * MS_PER_DAY)).toBe(true);
expect(isZoomOutDisabled(31 * MS_PER_DAY)).toBe(true);
expect(isZoomOutDisabled(29 * MS_PER_DAY)).toBe(false);
expect(isZoomOutDisabled(15 * MS_PER_MIN)).toBe(false);
});
it('should return null when at 1 month (no zoom out beyond max)', () => {
const start1m = NOW_MS - 30 * MS_PER_DAY;
const end1m = NOW_MS;
const result = getNextZoomOutRange(start1m, end1m);
expect(result).toBeNull();
});
it('should zoom out 3x from 5m range to 15m then continue with ladder', () => {
// 5m range ending at now → 3x = 15m
const start5m = NOW_MS - 5 * MS_PER_MIN;
const end5m = NOW_MS;
const result = getNextZoomOutRange(start5m, end5m) as ZoomOutResult;
expect(result).not.toBeNull();
expect(result.preset).toBe('15m');
const [newStart, newEnd] = result.range;
expect(newEnd - newStart).toBe(15 * MS_PER_MIN);
});
});
});

View File

@@ -3,14 +3,15 @@ import { generateColor } from 'lib/uPlotLib/utils/generateColor';
import { calculateWidthBasedOnStepInterval } from 'lib/uPlotV2/utils'; import { calculateWidthBasedOnStepInterval } from 'lib/uPlotV2/utils';
import uPlot, { Series } from 'uplot'; import uPlot, { Series } from 'uplot';
import { generateGradientFill } from '../utils/generateGradientFill';
import { import {
BarAlignment, BarAlignment,
ConfigBuilder, ConfigBuilder,
DrawStyle, DrawStyle,
FillMode,
LineInterpolation, LineInterpolation,
LineStyle, LineStyle,
SeriesProps, SeriesProps,
VisibilityMode,
} from './types'; } from './types';
/** /**
@@ -52,7 +53,7 @@ export class UPlotSeriesBuilder extends ConfigBuilder<SeriesProps, Series> {
}: { }: {
resolvedLineColor: string; resolvedLineColor: string;
}): Partial<Series> { }): Partial<Series> {
const { lineWidth, lineStyle, lineCap, fillColor } = this.props; const { lineWidth, lineStyle, lineCap, fillColor, fillMode } = this.props;
const lineConfig: Partial<Series> = { const lineConfig: Partial<Series> = {
stroke: resolvedLineColor, stroke: resolvedLineColor,
width: lineWidth ?? DEFAULT_LINE_WIDTH, width: lineWidth ?? DEFAULT_LINE_WIDTH,
@@ -66,12 +67,19 @@ export class UPlotSeriesBuilder extends ConfigBuilder<SeriesProps, Series> {
lineConfig.cap = lineCap; lineConfig.cap = lineCap;
} }
if (fillColor) { const finalFillColor = fillColor ?? resolvedLineColor;
lineConfig.fill = fillColor;
} else if (this.props.drawStyle === DrawStyle.Bar) { if (this.props.drawStyle === DrawStyle.Bar) {
lineConfig.fill = resolvedLineColor; lineConfig.fill = finalFillColor;
} else if (this.props.drawStyle === DrawStyle.Histogram) { } else if (this.props.drawStyle === DrawStyle.Histogram) {
lineConfig.fill = `${resolvedLineColor}40`; lineConfig.fill = `${finalFillColor}40`;
} else if (fillMode && fillMode !== FillMode.None) {
if (fillMode === FillMode.Solid) {
lineConfig.fill = finalFillColor;
} else if (fillMode === FillMode.Gradient) {
lineConfig.fill = (self: uPlot): CanvasGradient =>
generateGradientFill(self, finalFillColor, 'rgba(0, 0, 0, 0)');
}
} }
return lineConfig; return lineConfig;
@@ -159,12 +167,8 @@ export class UPlotSeriesBuilder extends ConfigBuilder<SeriesProps, Series> {
pointsConfig.show = pointsBuilder; pointsConfig.show = pointsBuilder;
} else if (drawStyle === DrawStyle.Points) { } else if (drawStyle === DrawStyle.Points) {
pointsConfig.show = true; pointsConfig.show = true;
} else if (showPoints === VisibilityMode.Never) {
pointsConfig.show = false;
} else if (showPoints === VisibilityMode.Always) {
pointsConfig.show = true;
} else { } else {
pointsConfig.show = false; // default to hidden pointsConfig.show = !!showPoints;
} }
return pointsConfig; return pointsConfig;

View File

@@ -2,12 +2,7 @@ import { themeColors } from 'constants/theme';
import uPlot from 'uplot'; import uPlot from 'uplot';
import type { SeriesProps } from '../types'; import type { SeriesProps } from '../types';
import { import { DrawStyle, LineInterpolation, LineStyle } from '../types';
DrawStyle,
LineInterpolation,
LineStyle,
VisibilityMode,
} from '../types';
import { POINT_SIZE_FACTOR, UPlotSeriesBuilder } from '../UPlotSeriesBuilder'; import { POINT_SIZE_FACTOR, UPlotSeriesBuilder } from '../UPlotSeriesBuilder';
const createBaseProps = ( const createBaseProps = (
@@ -168,17 +163,17 @@ describe('UPlotSeriesBuilder', () => {
expect(config.points?.show).toBe(pointsBuilder); expect(config.points?.show).toBe(pointsBuilder);
}); });
it('respects VisibilityMode for point visibility when no custom pointsBuilder is given', () => { it('respects showPoints for point visibility when no custom pointsBuilder is given', () => {
const neverPointsBuilder = new UPlotSeriesBuilder( const neverPointsBuilder = new UPlotSeriesBuilder(
createBaseProps({ createBaseProps({
drawStyle: DrawStyle.Line, drawStyle: DrawStyle.Line,
showPoints: VisibilityMode.Never, showPoints: false,
}), }),
); );
const alwaysPointsBuilder = new UPlotSeriesBuilder( const alwaysPointsBuilder = new UPlotSeriesBuilder(
createBaseProps({ createBaseProps({
drawStyle: DrawStyle.Line, drawStyle: DrawStyle.Line,
showPoints: VisibilityMode.Always, showPoints: true,
}), }),
); );

View File

@@ -122,12 +122,6 @@ export enum LineInterpolation {
StepBefore = 'stepBefore', StepBefore = 'stepBefore',
} }
export enum VisibilityMode {
Always = 'always',
Auto = 'auto',
Never = 'never',
}
/** /**
* Props for configuring lines * Props for configuring lines
*/ */
@@ -163,7 +157,13 @@ export interface BarConfig {
export interface PointsConfig { export interface PointsConfig {
pointColor?: string; pointColor?: string;
pointSize?: number; pointSize?: number;
showPoints?: VisibilityMode; showPoints?: boolean;
}
export enum FillMode {
Solid = 'solid',
Gradient = 'gradient',
None = 'none',
} }
export interface SeriesProps extends LineConfig, PointsConfig, BarConfig { export interface SeriesProps extends LineConfig, PointsConfig, BarConfig {
@@ -177,6 +177,7 @@ export interface SeriesProps extends LineConfig, PointsConfig, BarConfig {
show?: boolean; show?: boolean;
spanGaps?: boolean; spanGaps?: boolean;
fillColor?: string; fillColor?: string;
fillMode?: FillMode;
isDarkMode?: boolean; isDarkMode?: boolean;
stepInterval?: number; stepInterval?: number;
} }

View File

@@ -0,0 +1,18 @@
import uPlot from 'uplot';
export function generateGradientFill(
uPlotInstance: uPlot,
startColor: string,
endColor: string,
): CanvasGradient {
const g = uPlotInstance.ctx.createLinearGradient(
0,
0,
0,
uPlotInstance.bbox.height,
);
g.addColorStop(0, `${startColor}70`);
g.addColorStop(0.6, `${startColor}40`);
g.addColorStop(1, endColor);
return g;
}

View File

@@ -1,139 +0,0 @@
/**
* Custom Time Picker zoom-out ladder:
* - Until 1 day: 15m → 45m → 2hr → 7hr → 21hr
* - Then fixed: 1d → 2d → 3d → 1w → 2w → 1m
* - At 1 month: zoom out is disabled (max range)
*/
import type {
CustomTimeType,
Time,
} from 'container/TopNav/DateTimeSelectionV2/types';
const MS_PER_MIN = 60 * 1000;
const MS_PER_HOUR = 60 * MS_PER_MIN;
const MS_PER_DAY = 24 * MS_PER_HOUR;
const MS_PER_WEEK = 7 * MS_PER_DAY;
const ZOOM_OUT_LADDER_MS: number[] = [
15 * MS_PER_MIN, // 15m
45 * MS_PER_MIN, // 45m
2 * MS_PER_HOUR, // 2hr
7 * MS_PER_HOUR, // 7hr
21 * MS_PER_HOUR, // 21hr
1 * MS_PER_DAY, // 1d
2 * MS_PER_DAY, // 2d
3 * MS_PER_DAY, // 3d
1 * MS_PER_WEEK, // 1w
2 * MS_PER_WEEK, // 2w
30 * MS_PER_DAY, // 1m
];
const LADDER_LAST_INDEX = ZOOM_OUT_LADDER_MS.length - 1;
const MAX_DURATION = ZOOM_OUT_LADDER_MS[LADDER_LAST_INDEX];
const MIN_LADDER_DURATION_MS = ZOOM_OUT_LADDER_MS[0]; // 15m - below this we use 3x
export const MAX_ZOOM_OUT_DURATION_MS = MAX_DURATION;
/** Returns true when zoom out should be disabled (range at or beyond 1 month) */
export function isZoomOutDisabled(durationMs: number): boolean {
return durationMs >= MAX_ZOOM_OUT_DURATION_MS;
}
/** Preset labels for ladder steps supported by GetMinMax (shows "Last 15 minutes" etc. instead of "Custom") */
const PRESET_FOR_DURATION_MS: Record<number, Time | CustomTimeType> = {
[15 * MS_PER_MIN]: '15m',
[45 * MS_PER_MIN]: '45m',
[2 * MS_PER_HOUR]: '2h',
[7 * MS_PER_HOUR]: '7h',
[21 * MS_PER_HOUR]: '21h',
[1 * MS_PER_DAY]: '1d',
[2 * MS_PER_DAY]: '2d',
[3 * MS_PER_DAY]: '3d',
[1 * MS_PER_WEEK]: '1w',
[2 * MS_PER_WEEK]: '2w',
[30 * MS_PER_DAY]: '1month',
};
/**
* Returns the next duration in the zoom-out ladder for the given current duration.
* Below 15m: zoom out 3x until we reach 15m, then continue with the ladder.
* If at or past 1 month, returns MAX_DURATION (no zoom out - button is disabled).
*/
export function getNextDurationInLadder(durationMs: number): number {
if (durationMs >= MAX_DURATION) {
return MAX_DURATION; // No zoom out beyond 1 month
}
// Below 15m: zoom out 3x until we reach 15m
if (durationMs < MIN_LADDER_DURATION_MS) {
const next = durationMs * 3;
return Math.min(next, MIN_LADDER_DURATION_MS);
}
// At or above 15m: use the fixed ladder
for (let i = 0; i < ZOOM_OUT_LADDER_MS.length; i++) {
if (ZOOM_OUT_LADDER_MS[i] > durationMs) {
return ZOOM_OUT_LADDER_MS[i];
}
}
return MAX_DURATION;
}
export interface ZoomOutResult {
range: [number, number];
/** Preset key (e.g. '15m') when range matches a preset - use for display instead of "Custom Date Range" */
preset: Time | CustomTimeType | null;
}
/**
* Computes the next zoomed-out time range.
* Phase 1 (center-anchored): While new end <= now, expand from center.
* Phase 2 (end-anchored at now): When new end would exceed now, anchor end at now and move start backward.
*
* @returns ZoomOutResult with range and preset (or null if no change)
*/
export function getNextZoomOutRange(
startMs: number,
endMs: number,
): ZoomOutResult | null {
const nowMs = Date.now();
const durationMs = endMs - startMs;
if (durationMs <= 0) {
return null;
}
const newDurationMs = getNextDurationInLadder(durationMs);
// No zoom out when already at max (1 month)
if (newDurationMs <= durationMs) {
return null;
}
const centerMs = startMs + durationMs / 2;
const computedEndMs = centerMs + newDurationMs / 2;
let newStartMs: number;
let newEndMs: number;
const isPhase1 = computedEndMs <= nowMs;
if (isPhase1) {
// Phase 1: center-anchored (historical range not ending at now)
newStartMs = centerMs - newDurationMs / 2;
newEndMs = computedEndMs;
} else {
// Phase 2: end-anchored at now
newStartMs = nowMs - newDurationMs;
newEndMs = nowMs;
}
// Phase 2 only: use preset so GetMinMax produces "last X from now".
// Phase 1: preset=null so the center-anchored range is preserved (GetMinMax would discard it).
const preset = isPhase1 ? null : PRESET_FOR_DURATION_MS[newDurationMs] ?? null;
return {
range: [Math.round(newStartMs), Math.round(newEndMs)],
preset,
};
}

View File

@@ -0,0 +1,146 @@
import { Route } from 'react-router-dom';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { rest, server } from 'mocks-server/server';
import { render, screen, waitFor } from 'tests/test-utils';
import DashboardWidget from '../index';
const DASHBOARD_ID = 'dash-1';
const WIDGET_ID = 'widget-abc';
const mockDashboardResponse = {
status: 'success',
data: {
id: DASHBOARD_ID,
createdAt: '2024-01-01T00:00:00Z',
createdBy: 'test',
updatedAt: '2024-01-01T00:00:00Z',
updatedBy: 'test',
isLocked: false,
data: {
collapsableRowsMigrated: true,
description: '',
name: '',
panelMap: {},
tags: [],
title: 'Test Dashboard',
uploadedGrafana: false,
uuid: '',
version: '',
variables: {},
widgets: [],
layout: [],
},
},
};
const mockSafeNavigate = jest.fn();
jest.mock('hooks/useSafeNavigate', () => ({
useSafeNavigate: (): { safeNavigate: jest.Mock } => ({
safeNavigate: mockSafeNavigate,
}),
}));
jest.mock('container/NewWidget', () => ({
__esModule: true,
default: (): JSX.Element => <div data-testid="new-widget">NewWidget</div>,
}));
// nuqs's useQueryState doesn't read from MemoryRouter, so we mock it to return
// controlled values via the `mockQueryState` map below.
const mockQueryState: Record<string, string | null> = {};
jest.mock('nuqs', () => ({
...jest.requireActual('nuqs'),
useQueryState: (key: string): [string | null, jest.Mock] => [
mockQueryState[key] ?? null,
jest.fn(),
],
}));
// Wrap component in a Route so useParams can resolve dashboardId
function renderAtRoute(
queryState: Record<string, string | null> = {},
): ReturnType<typeof render> {
Object.assign(mockQueryState, queryState);
return render(
<Route path="/dashboard/:dashboardId/new">
<DashboardWidget />
</Route>,
undefined,
{ initialRoute: `/dashboard/${DASHBOARD_ID}/new` },
);
}
beforeEach(() => {
mockSafeNavigate.mockClear();
Object.keys(mockQueryState).forEach((k) => delete mockQueryState[k]);
});
describe('DashboardWidget', () => {
it('redirects to dashboard when widgetId is missing', async () => {
renderAtRoute({ graphType: PANEL_TYPES.TIME_SERIES });
await waitFor(() => {
expect(mockSafeNavigate).toHaveBeenCalled();
});
const [navigatedTo] = mockSafeNavigate.mock.calls[0];
expect(navigatedTo).toContain(`/dashboard/${DASHBOARD_ID}`);
});
it('redirects to dashboard when graphType is missing', async () => {
renderAtRoute({ widgetId: WIDGET_ID });
await waitFor(() => {
expect(mockSafeNavigate).toHaveBeenCalled();
});
const [navigatedTo] = mockSafeNavigate.mock.calls[0];
expect(navigatedTo).toContain(`/dashboard/${DASHBOARD_ID}`);
});
it('shows spinner while dashboard is loading', () => {
server.use(
rest.get(
`http://localhost/api/v1/dashboards/${DASHBOARD_ID}`,
(_req, res, ctx) => res(ctx.delay('infinite')),
),
);
renderAtRoute({ widgetId: WIDGET_ID, graphType: PANEL_TYPES.TIME_SERIES });
expect(screen.getByRole('img', { name: 'loading' })).toBeInTheDocument();
});
it('shows error message when dashboard fetch fails', async () => {
server.use(
rest.get(
`http://localhost/api/v1/dashboards/${DASHBOARD_ID}`,
(_req, res, ctx) => res(ctx.status(500), ctx.json({ status: 'error' })),
),
);
renderAtRoute({ widgetId: WIDGET_ID, graphType: PANEL_TYPES.TIME_SERIES });
await waitFor(() => {
expect(screen.getByText('Something went wrong')).toBeInTheDocument();
});
});
it('renders NewWidget when dashboard loads successfully', async () => {
server.use(
rest.get(
`http://localhost/api/v1/dashboards/${DASHBOARD_ID}`,
(_req, res, ctx) => res(ctx.status(200), ctx.json(mockDashboardResponse)),
),
);
renderAtRoute({ widgetId: WIDGET_ID, graphType: PANEL_TYPES.TIME_SERIES });
await waitFor(() => {
expect(screen.getByTestId('new-widget')).toBeInTheDocument();
});
});
});

View File

@@ -1,4 +1,4 @@
import { useEffect, useMemo } from 'react'; import { useEffect, useState } from 'react';
import { useQuery } from 'react-query'; import { useQuery } from 'react-query';
import { generatePath, useParams } from 'react-router-dom'; import { generatePath, useParams } from 'react-router-dom';
import { Card, Typography } from 'antd'; import { Card, Typography } from 'antd';
@@ -11,9 +11,11 @@ import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import ROUTES from 'constants/routes'; import ROUTES from 'constants/routes';
import NewWidget from 'container/NewWidget'; import NewWidget from 'container/NewWidget';
import { isDrilldownEnabled } from 'container/QueryTable/Drilldown/drilldownUtils'; import { isDrilldownEnabled } from 'container/QueryTable/Drilldown/drilldownUtils';
import { useTransformDashboardVariables } from 'hooks/dashboard/useTransformDashboardVariables';
import { useSafeNavigate } from 'hooks/useSafeNavigate'; import { useSafeNavigate } from 'hooks/useSafeNavigate';
import { parseAsStringEnum, useQueryState } from 'nuqs'; import { parseAsStringEnum, useQueryState } from 'nuqs';
import { setDashboardVariablesStore } from 'providers/Dashboard/store/dashboardVariables/dashboardVariablesStore'; import { setDashboardVariablesStore } from 'providers/Dashboard/store/dashboardVariables/dashboardVariablesStore';
import { Dashboard } from 'types/api/dashboard/getAll';
function DashboardWidget(): JSX.Element | null { function DashboardWidget(): JSX.Element | null {
const { dashboardId } = useParams<{ const { dashboardId } = useParams<{
@@ -57,8 +59,15 @@ function DashboardWidgetInternal({
widgetId: string; widgetId: string;
graphType: PANEL_TYPES; graphType: PANEL_TYPES;
}): JSX.Element | null { }): JSX.Element | null {
const [selectedDashboard, setSelectedDashboard] = useState<
Dashboard | undefined
>(undefined);
const { transformDashboardVariables } = useTransformDashboardVariables(
dashboardId,
);
const { const {
data: dashboardResponse,
isFetching: isFetchingDashboardResponse, isFetching: isFetchingDashboardResponse,
isError: isErrorDashboardResponse, isError: isErrorDashboardResponse,
} = useQuery([REACT_QUERY_KEY.DASHBOARD_BY_ID, dashboardId, widgetId], { } = useQuery([REACT_QUERY_KEY.DASHBOARD_BY_ID, dashboardId, widgetId], {
@@ -70,17 +79,15 @@ function DashboardWidgetInternal({
refetchOnWindowFocus: false, refetchOnWindowFocus: false,
cacheTime: DASHBOARD_CACHE_TIME, cacheTime: DASHBOARD_CACHE_TIME,
onSuccess: (response) => { onSuccess: (response) => {
const updatedDashboardData = transformDashboardVariables(response.data);
setSelectedDashboard(updatedDashboardData);
setDashboardVariablesStore({ setDashboardVariablesStore({
dashboardId, dashboardId,
variables: response.data.data.variables, variables: updatedDashboardData.data.variables,
}); });
}, },
}); });
const selectedDashboard = useMemo(() => dashboardResponse?.data, [
dashboardResponse?.data,
]);
if (isFetchingDashboardResponse) { if (isFetchingDashboardResponse) {
return <Spinner tip="Loading.." />; return <Spinner tip="Loading.." />;
} }

View File

@@ -17,21 +17,18 @@ import { useDispatch, useSelector } from 'react-redux';
import { Modal } from 'antd'; import { Modal } from 'antd';
import getDashboard from 'api/v1/dashboards/id/get'; import getDashboard from 'api/v1/dashboards/id/get';
import locked from 'api/v1/dashboards/id/lock'; import locked from 'api/v1/dashboards/id/lock';
import { ALL_SELECTED_VALUE } from 'components/NewSelect/utils';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys'; import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import dayjs, { Dayjs } from 'dayjs'; import dayjs, { Dayjs } from 'dayjs';
import { useDashboardVariablesFromLocalStorage } from 'hooks/dashboard/useDashboardFromLocalStorage'; import { useTransformDashboardVariables } from 'hooks/dashboard/useTransformDashboardVariables';
import useVariablesFromUrl from 'hooks/dashboard/useVariablesFromUrl';
import useTabVisibility from 'hooks/useTabFocus'; import useTabVisibility from 'hooks/useTabFocus';
import { getUpdatedLayout } from 'lib/dashboard/getUpdatedLayout'; import { getUpdatedLayout } from 'lib/dashboard/getUpdatedLayout';
import { getMinMaxForSelectedTime } from 'lib/getMinMax'; import { getMinMaxForSelectedTime } from 'lib/getMinMax';
import { defaultTo, isEmpty } from 'lodash-es'; import { defaultTo } from 'lodash-es';
import isEqual from 'lodash-es/isEqual'; import isEqual from 'lodash-es/isEqual';
import isUndefined from 'lodash-es/isUndefined'; import isUndefined from 'lodash-es/isUndefined';
import omitBy from 'lodash-es/omitBy'; import omitBy from 'lodash-es/omitBy';
import { useAppContext } from 'providers/App/App'; import { useAppContext } from 'providers/App/App';
import { initializeDefaultVariables } from 'providers/Dashboard/initializeDefaultVariables'; import { initializeDefaultVariables } from 'providers/Dashboard/initializeDefaultVariables';
import { normalizeUrlValueForVariable } from 'providers/Dashboard/normalizeUrlValue';
import { useErrorModal } from 'providers/ErrorModalProvider'; import { useErrorModal } from 'providers/ErrorModalProvider';
// eslint-disable-next-line no-restricted-imports // eslint-disable-next-line no-restricted-imports
import { Dispatch } from 'redux'; import { Dispatch } from 'redux';
@@ -39,10 +36,9 @@ import { AppState } from 'store/reducers';
import AppActions from 'types/actions'; import AppActions from 'types/actions';
import { UPDATE_TIME_INTERVAL } from 'types/actions/globalTime'; import { UPDATE_TIME_INTERVAL } from 'types/actions/globalTime';
import { SuccessResponseV2 } from 'types/api'; import { SuccessResponseV2 } from 'types/api';
import { Dashboard, IDashboardVariable } from 'types/api/dashboard/getAll'; import { Dashboard } from 'types/api/dashboard/getAll';
import APIError from 'types/api/error'; import APIError from 'types/api/error';
import { GlobalReducer } from 'types/reducer/globalTime'; import { GlobalReducer } from 'types/reducer/globalTime';
import { v4 as generateUUID } from 'uuid';
import { import {
DASHBOARD_CACHE_TIME, DASHBOARD_CACHE_TIME,
@@ -137,9 +133,10 @@ export function DashboardProvider({
const { const {
currentDashboard, currentDashboard,
updateLocalStorageDashboardVariables, updateLocalStorageDashboardVariables,
} = useDashboardVariablesFromLocalStorage(dashboardId); getUrlVariables,
updateUrlVariable,
const { getUrlVariables, updateUrlVariable } = useVariablesFromUrl(); transformDashboardVariables,
} = useTransformDashboardVariables(dashboardId);
const updatedTimeRef = useRef<Dayjs | null>(null); // Using ref to store the updated time const updatedTimeRef = useRef<Dayjs | null>(null); // Using ref to store the updated time
const modalRef = useRef<any>(null); const modalRef = useRef<any>(null);
@@ -151,99 +148,6 @@ export function DashboardProvider({
const [isDashboardFetching, setIsDashboardFetching] = useState<boolean>(false); const [isDashboardFetching, setIsDashboardFetching] = useState<boolean>(false);
const mergeDBWithLocalStorage = (
data: Dashboard,
localStorageVariables: any,
): Dashboard => {
const updatedData = data;
if (data && localStorageVariables) {
const updatedVariables = data.data.variables;
const variablesFromUrl = getUrlVariables();
Object.keys(data.data.variables).forEach((variable) => {
const variableData = data.data.variables[variable];
// values from url
const urlVariable = variableData?.name
? variablesFromUrl[variableData?.name] || variablesFromUrl[variableData.id]
: variablesFromUrl[variableData.id];
let updatedVariable = {
...data.data.variables[variable],
...localStorageVariables[variableData.name as any],
};
// respect the url variable if it is set, override the others
if (!isEmpty(urlVariable)) {
if (urlVariable === ALL_SELECTED_VALUE) {
updatedVariable = {
...updatedVariable,
allSelected: true,
};
} else {
// Normalize URL value to match variable's multiSelect configuration
const normalizedValue = normalizeUrlValueForVariable(
urlVariable,
variableData,
);
updatedVariable = {
...updatedVariable,
selectedValue: normalizedValue,
// Only set allSelected to false if showALLOption is available
...(updatedVariable?.showALLOption && { allSelected: false }),
};
}
}
updatedVariables[variable] = updatedVariable;
});
updatedData.data.variables = updatedVariables;
}
return updatedData;
};
// As we do not have order and ID's in the variables object, we have to process variables to add order and ID if they do not exist in the variables object
// eslint-disable-next-line sonarjs/cognitive-complexity
const transformDashboardVariables = (data: Dashboard): Dashboard => {
if (data && data.data && data.data.variables) {
const clonedDashboardData = mergeDBWithLocalStorage(
JSON.parse(JSON.stringify(data)),
currentDashboard,
);
const { variables } = clonedDashboardData.data;
const existingOrders: Set<number> = new Set();
for (const key in variables) {
// eslint-disable-next-line no-prototype-builtins
if (variables.hasOwnProperty(key)) {
const variable: IDashboardVariable = variables[key];
// Check if 'order' property doesn't exist or is undefined
if (variable.order === undefined) {
// Find a unique order starting from 0
let order = 0;
while (existingOrders.has(order)) {
order += 1;
}
variable.order = order;
existingOrders.add(order);
// ! BWC - Specific case for backward compatibility where textboxValue was used instead of defaultValue
if (variable.type === 'TEXTBOX' && !variable.defaultValue) {
variable.defaultValue = variable.textboxValue || '';
}
}
if (variable.id === undefined) {
variable.id = generateUUID();
}
}
}
return clonedDashboardData;
}
return data;
};
const dashboardResponse = useQuery( const dashboardResponse = useQuery(
[ [
REACT_QUERY_KEY.DASHBOARD_BY_ID, REACT_QUERY_KEY.DASHBOARD_BY_ID,
@@ -274,13 +178,14 @@ export function DashboardProvider({
}, },
onSuccess: (data: SuccessResponseV2<Dashboard>) => { onSuccess: (data: SuccessResponseV2<Dashboard>) => {
// if the url variable is not set for any variable, set it to the default value const updatedDashboardData = transformDashboardVariables(data?.data);
const variables = data?.data?.data?.variables;
// initialize URL variables after dashboard state is set to avoid race conditions
const variables = updatedDashboardData?.data?.variables;
if (variables) { if (variables) {
initializeDefaultVariables(variables, getUrlVariables, updateUrlVariable); initializeDefaultVariables(variables, getUrlVariables, updateUrlVariable);
} }
const updatedDashboardData = transformDashboardVariables(data?.data);
const updatedDate = dayjs(updatedDashboardData?.updatedAt); const updatedDate = dayjs(updatedDashboardData?.updatedAt);
setIsDashboardLocked(updatedDashboardData?.locked || false); setIsDashboardLocked(updatedDashboardData?.locked || false);

View File

@@ -381,6 +381,7 @@ describe('Dashboard Provider - URL Variables Integration', () => {
multiSelect: false, multiSelect: false,
allSelected: false, allSelected: false,
showALLOption: true, showALLOption: true,
order: 0,
}, },
services: { services: {
id: 'svc-id', id: 'svc-id',
@@ -388,6 +389,7 @@ describe('Dashboard Provider - URL Variables Integration', () => {
multiSelect: true, multiSelect: true,
allSelected: false, allSelected: false,
showALLOption: true, showALLOption: true,
order: 1,
}, },
}, },
mockGetUrlVariables, mockGetUrlVariables,

View File

@@ -5,6 +5,11 @@ import { PANEL_GROUP_TYPES, PANEL_TYPES } from 'constants/queryBuilder';
import { ThresholdProps } from 'container/NewWidget/RightContainer/Threshold/types'; import { ThresholdProps } from 'container/NewWidget/RightContainer/Threshold/types';
import { timePreferenceType } from 'container/NewWidget/RightContainer/timeItems'; import { timePreferenceType } from 'container/NewWidget/RightContainer/timeItems';
import { QueryTableProps } from 'container/QueryTable/QueryTable.intefaces'; import { QueryTableProps } from 'container/QueryTable/QueryTable.intefaces';
import {
FillMode,
LineInterpolation,
LineStyle,
} from 'lib/uPlotV2/config/types';
import { Query } from 'types/api/queryBuilder/queryBuilderData'; import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { IField } from '../logs/fields'; import { IField } from '../logs/fields';
@@ -132,6 +137,10 @@ export interface IBaseWidget {
legendPosition?: LegendPosition; legendPosition?: LegendPosition;
customLegendColors?: Record<string, string>; customLegendColors?: Record<string, string>;
contextLinks?: ContextLinksData; contextLinks?: ContextLinksData;
lineInterpolation?: LineInterpolation;
showPoints?: boolean;
lineStyle?: LineStyle;
fillMode?: FillMode;
} }
export interface Widgets extends IBaseWidget { export interface Widgets extends IBaseWidget {
query: Query; query: Query;

View File

@@ -1,28 +0,0 @@
import getLocalStorageKey from 'api/browser/localstorage/get';
import setLocalStorageKey from 'api/browser/localstorage/set';
import { LOCALSTORAGE } from 'constants/localStorage';
/**
* Updates the stored time duration for a route in localStorage.
* Used by both DateTimeSelectionV2 (manual time pick) and useZoomOut (zoom out button).
*
* @param pathname - The route path (e.g. /infrastructure-monitoring/hosts)
* @param value - The time value to store (preset string like '1w' or JSON string for custom range)
*/
export function persistTimeDurationForRoute(
pathname: string,
value: string,
): void {
const preRoutes = getLocalStorageKey(LOCALSTORAGE.METRICS_TIME_IN_DURATION);
let preRoutesObject: Record<string, string> = {};
try {
preRoutesObject = preRoutes ? JSON.parse(preRoutes) : {};
} catch {
preRoutesObject = {};
}
const preRoute = { ...preRoutesObject, [pathname]: value };
setLocalStorageKey(
LOCALSTORAGE.METRICS_TIME_IN_DURATION,
JSON.stringify(preRoute),
);
}

View File

@@ -5646,7 +5646,7 @@
tailwind-merge "^2.5.2" tailwind-merge "^2.5.2"
tailwindcss-animate "^1.0.7" tailwindcss-animate "^1.0.7"
"@signozhq/toggle-group@^0.0.1": "@signozhq/toggle-group@0.0.1":
version "0.0.1" version "0.0.1"
resolved "https://registry.yarnpkg.com/@signozhq/toggle-group/-/toggle-group-0.0.1.tgz#c82ff1da34e77b24da53c2d595ad6b4a0d1b1de4" resolved "https://registry.yarnpkg.com/@signozhq/toggle-group/-/toggle-group-0.0.1.tgz#c82ff1da34e77b24da53c2d595ad6b4a0d1b1de4"
integrity sha512-871bQayL5MaqsuNOFHKexidu9W2Hlg1y4xmH8C5mGmlfZ4bd0ovJ9OweQrM6Puys3jeMwi69xmJuesYCfKQc1g== integrity sha512-871bQayL5MaqsuNOFHKexidu9W2Hlg1y4xmH8C5mGmlfZ4bd0ovJ9OweQrM6Puys3jeMwi69xmJuesYCfKQc1g==