Compare commits

...

6 Commits

Author SHA1 Message Date
Abhi kumar
c5ef455283 fix: added fix for panel setting scrollbar issue (#10587)
Some checks are pending
build-staging / prepare (push) Waiting to run
build-staging / js-build (push) Blocked by required conditions
build-staging / go-build (push) Blocked by required conditions
build-staging / staging (push) Blocked by required conditions
Release Drafter / update_release_draft (push) Waiting to run
* fix: added fix for panel setting scrollbar issue

* fix: added changes for panel switch
2026-03-13 19:30:49 +00:00
Ishan
2316b5be83 Sig 3634 revert (#10578)
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
* Revert "Revert "feat: Option to zoom out OR reset zoom in the explorer pages (#10464)" (#10574)"

This reverts commit 5b8d5fbfd3.

* fix: stop bubble
2026-03-13 15:29:28 +00:00
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
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
25 changed files with 1256 additions and 538 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

@@ -297,7 +297,11 @@ function CustomTimePicker({
resetErrorStatus(); resetErrorStatus();
}; };
const handleInputPressEnter = (): void => { const handleInputPressEnter = (
event?: React.KeyboardEvent<HTMLInputElement>,
): void => {
event?.preventDefault();
event?.stopPropagation();
// check if the entered time is in the format of 1m, 2h, 3d, 4w // check if the entered time is in the format of 1m, 2h, 3d, 4w
const isTimeDurationShortHandFormat = /^(\d+)([mhdw])$/.test(inputValue); const isTimeDurationShortHandFormat = /^(\d+)([mhdw])$/.test(inputValue);

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

@@ -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

@@ -1,6 +1,7 @@
.right-container { .right-container {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
padding-bottom: 48px;
.header { .header {
display: flex; display: flex;
@@ -24,14 +25,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;
}
.name-description {
padding: 0 0 4px 0;
.typography { .typography {
color: var(--bg-vanilla-400); color: var(--bg-vanilla-400);
@@ -88,9 +89,6 @@
.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 { .typography {
color: var(--bg-vanilla-400); color: var(--bg-vanilla-400);
@@ -104,6 +102,7 @@
} }
.panel-type-select { .panel-type-select {
width: 100%;
.ant-select-selector { .ant-select-selector {
display: flex; display: flex;
height: 32px; height: 32px;
@@ -137,7 +136,6 @@
} }
.fill-gaps { .fill-gaps {
margin-top: 16px;
display: flex; display: flex;
padding: 12px; padding: 12px;
justify-content: space-between; justify-content: space-between;
@@ -156,31 +154,24 @@
letter-spacing: 0.52px; letter-spacing: 0.52px;
text-transform: uppercase; text-transform: uppercase;
} }
.fill-gaps-text-description {
color: var(--bg-vanilla-400);
font-family: Inter;
font-size: 12px;
font-style: normal;
font-weight: 400;
opacity: 0.6;
line-height: 16px; /* 133.333% */
}
} }
.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 { .panel-time-text {
margin-top: 16px;
color: var(--bg-vanilla-400); color: var(--bg-vanilla-400);
font-family: 'Space Mono'; font-family: 'Space Mono';
font-size: 13px; font-size: 13px;
@@ -193,7 +184,6 @@
.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;
@@ -278,11 +268,8 @@
} }
.stack-chart { .stack-chart {
margin-top: 16px; flex-direction: row;
display: flex;
justify-content: space-between; justify-content: space-between;
gap: 8px;
.label { .label {
color: var(--bg-vanilla-400); color: var(--bg-vanilla-400);
font-family: 'Space Mono'; font-family: 'Space Mono';
@@ -296,11 +283,6 @@
} }
.bucket-config { .bucket-config {
margin-top: 16px;
display: flex;
flex-direction: column;
gap: 8px;
.label { .label {
color: var(--bg-vanilla-400); color: var(--bg-vanilla-400);
font-family: 'Space Mono'; font-family: 'Space Mono';
@@ -352,16 +334,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 {
@@ -387,6 +366,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 {
@@ -418,9 +407,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,8 +427,6 @@
} }
.panel-config { .panel-config {
border-bottom: 1px solid var(--bg-vanilla-300);
.typography { .typography {
color: var(--bg-ink-400); color: var(--bg-ink-400);
} }
@@ -478,6 +462,9 @@
.fill-gaps-text { .fill-gaps-text {
color: var(--bg-ink-400); color: var(--bg-ink-400);
} }
.fill-gaps-text-description {
color: var(--bg-ink-400);
}
} }
.bucket-config { .bucket-config {
@@ -530,7 +517,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 +536,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

@@ -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

@@ -14,7 +14,6 @@ import {
Input, Input,
InputNumber, InputNumber,
Select, Select,
Space,
Switch, Switch,
Typography, Typography,
} from 'antd'; } from 'antd';
@@ -28,9 +27,16 @@ 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 {
Antenna,
Axis3D,
ConciergeBell, ConciergeBell,
Layers,
LayoutDashboard,
LineChart, LineChart,
Link,
Pencil,
Plus, Plus,
SlidersHorizontal,
Spline, Spline,
SquareArrowOutUpRight, SquareArrowOutUpRight,
} from 'lucide-react'; } from 'lucide-react';
@@ -46,6 +52,7 @@ 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,
@@ -178,6 +185,21 @@ 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 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 +215,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 +294,297 @@ 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="typography">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="typography">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="typography">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="panel-time-text">
<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="label">Stack series</Typography.Text>
rootClassName="input" <Switch
onChange={softMaxHandler} checked={stackedBarChart}
size="small"
onChange={(checked): void => setStackedBarChart(checked)}
/> />
</section> </section>
</section> )}
{allowFillSpans && (
<section className="fill-gaps">
<div className="fill-gaps-text-container">
<Typography className="fill-gaps-text">Fill gaps</Typography>
<Typography.Text className="fill-gaps-text-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 && ( {isAxisSectionVisible && (
<section className="stack-chart"> <SettingsSection title="Axes" icon={<Axis3D size={14} />}>
<Typography.Text className="label">Stack series</Typography.Text> {allowSoftMinMax && (
<Switch <section className="soft-min-max">
checked={stackedBarChart} <section className="container">
size="small" <Typography.Text className="text">Soft Min</Typography.Text>
onChange={(checked): void => setStackedBarChart(checked)} <InputNumber
/> type="number"
</section> 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="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>
)}
</SettingsSection>
)}
{isLegendSectionVisible && (
<SettingsSection title="Legend" icon={<Layers size={14} />}>
{allowLegendPosition && (
<section className="legend-position control-container">
<Typography.Text className="typography">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="label">Number of buckets</Typography.Text>
value={bucketCount || null} <InputNumber
type="number" value={bucketCount || null}
min={0} type="number"
rootClassName="bucket-input" min={0}
placeholder="Default: 30" rootClassName="bucket-input"
onChange={(val): void => { placeholder="Default: 30"
setBucketCount(val || 0); 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>
<Switch
checked={combineHistogram}
size="small"
onChange={(checked): void => setCombineHistogram(checked)}
/> />
<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>
<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 +600,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 +626,7 @@ function RightContainer({
selectedGraph={selectedGraph} selectedGraph={selectedGraph}
columnUnits={columnUnits} columnUnits={columnUnits}
/> />
</section> </SettingsSection>
)} )}
</div> </div>
); );

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

@@ -439,6 +439,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 +567,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 +583,7 @@ function NewWidget({
updateDashboardMutation, updateDashboardMutation,
widgets, widgets,
setToScrollWidgetId, setToScrollWidgetId,
safeNavigate, navigateToDashboardPage,
dashboardId, dashboardId,
]); ]);
@@ -581,12 +592,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 +739,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);
@@ -822,56 +835,54 @@ function NewWidget({
</LeftContainerWrapper> </LeftContainerWrapper>
<RightContainerWrapper> <RightContainerWrapper>
<OverlayScrollbar> <RightContainer
<RightContainer setGraphHandler={setGraphHandler}
setGraphHandler={setGraphHandler} title={title}
title={title} setTitle={setTitle}
setTitle={setTitle} description={description}
description={description} setDescription={setDescription}
setDescription={setDescription} stackedBarChart={stackedBarChart}
stackedBarChart={stackedBarChart} setStackedBarChart={setStackedBarChart}
setStackedBarChart={setStackedBarChart} opacity={opacity}
opacity={opacity} yAxisUnit={yAxisUnit}
yAxisUnit={yAxisUnit} columnUnits={columnUnits}
columnUnits={columnUnits} setColumnUnits={setColumnUnits}
setColumnUnits={setColumnUnits} bucketCount={bucketCount}
bucketCount={bucketCount} bucketWidth={bucketWidth}
bucketWidth={bucketWidth} combineHistogram={combineHistogram}
combineHistogram={combineHistogram} setCombineHistogram={setCombineHistogram}
setCombineHistogram={setCombineHistogram} setBucketWidth={setBucketWidth}
setBucketWidth={setBucketWidth} setBucketCount={setBucketCount}
setBucketCount={setBucketCount} setOpacity={setOpacity}
setOpacity={setOpacity} selectedNullZeroValue={selectedNullZeroValue}
selectedNullZeroValue={selectedNullZeroValue} setSelectedNullZeroValue={setSelectedNullZeroValue}
setSelectedNullZeroValue={setSelectedNullZeroValue} selectedGraph={graphType}
selectedGraph={graphType} setSelectedTime={setSelectedTime}
setSelectedTime={setSelectedTime} selectedTime={selectedTime}
selectedTime={selectedTime} setYAxisUnit={setYAxisUnit}
setYAxisUnit={setYAxisUnit} decimalPrecision={decimalPrecision}
decimalPrecision={decimalPrecision} setDecimalPrecision={setDecimalPrecision}
setDecimalPrecision={setDecimalPrecision} thresholds={thresholds}
thresholds={thresholds} setThresholds={setThresholds}
setThresholds={setThresholds} selectedWidget={selectedWidget}
selectedWidget={selectedWidget} isFillSpans={isFillSpans}
isFillSpans={isFillSpans} setIsFillSpans={setIsFillSpans}
setIsFillSpans={setIsFillSpans} isLogScale={isLogScale}
isLogScale={isLogScale} setIsLogScale={setIsLogScale}
setIsLogScale={setIsLogScale} legendPosition={legendPosition}
legendPosition={legendPosition} setLegendPosition={setLegendPosition}
setLegendPosition={setLegendPosition} customLegendColors={customLegendColors}
customLegendColors={customLegendColors} setCustomLegendColors={setCustomLegendColors}
setCustomLegendColors={setCustomLegendColors} queryResponse={queryResponse}
queryResponse={queryResponse} softMin={softMin}
softMin={softMin} setSoftMin={setSoftMin}
setSoftMin={setSoftMin} softMax={softMax}
softMax={softMax} setSoftMax={setSoftMax}
setSoftMax={setSoftMax} contextLinks={contextLinks}
contextLinks={contextLinks} setContextLinks={setContextLinks}
setContextLinks={setContextLinks} enableDrillDown={enableDrillDown}
enableDrillDown={enableDrillDown} isNewDashboard={isNewDashboard}
isNewDashboard={isNewDashboard} />
/>
</OverlayScrollbar>
</RightContainerWrapper> </RightContainerWrapper>
</PanelContainer> </PanelContainer>
<Modal <Modal

View File

@@ -15,7 +15,14 @@ export const RightContainerWrapper = styled(Col)`
overflow-y: auto; overflow-y: auto;
} }
&::-webkit-scrollbar { &::-webkit-scrollbar {
width: 0rem; width: 0.3rem;
}
&::-webkit-scrollbar-thumb {
background: rgb(136, 136, 136);
border-radius: 0.625rem;
}
&::-webkit-scrollbar-track {
background: transparent;
} }
`; `;

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

@@ -0,0 +1,143 @@
import { Route } from 'react-router-dom';
import * as getDashboardModule from 'api/v1/dashboards/id/get';
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>,
}));
// Wrap component in a Route so useParams can resolve dashboardId.
// Query params are passed via the URL so useUrlQuery (react-router) can read them.
function renderAtRoute(
queryState: Record<string, string | null> = {},
): ReturnType<typeof render> {
const params = new URLSearchParams();
Object.entries(queryState).forEach(([k, v]) => {
if (v !== null) {
params.set(k, v);
}
});
const search = params.toString() ? `?${params.toString()}` : '';
return render(
<Route path="/dashboard/:dashboardId/new">
<DashboardWidget />
</Route>,
undefined,
{ initialRoute: `/dashboard/${DASHBOARD_ID}/new${search}` },
);
}
beforeEach(() => {
mockSafeNavigate.mockClear();
});
afterEach(() => {
jest.restoreAllMocks();
});
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', () => {
// Spy instead of MSW delay('infinite') to avoid leaving an open network handle.
jest
.spyOn(getDashboardModule, 'default')
.mockReturnValue(new Promise(() => {}));
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,29 +1,34 @@
import { useEffect, useMemo } from 'react'; import { useEffect, useMemo, 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';
import getDashboard from 'api/v1/dashboards/id/get'; import getDashboard from 'api/v1/dashboards/id/get';
import Spinner from 'components/Spinner'; import Spinner from 'components/Spinner';
import { SOMETHING_WENT_WRONG } from 'constants/api'; import { SOMETHING_WENT_WRONG } from 'constants/api';
import { QueryParams } from 'constants/query';
import { PANEL_TYPES } from 'constants/queryBuilder'; import { PANEL_TYPES } from 'constants/queryBuilder';
import { DASHBOARD_CACHE_TIME } from 'constants/queryCacheTime'; import { DASHBOARD_CACHE_TIME } from 'constants/queryCacheTime';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys'; 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 useUrlQuery from 'hooks/useUrlQuery';
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<{
dashboardId: string; dashboardId: string;
}>(); }>();
const [widgetId] = useQueryState('widgetId'); const query = useUrlQuery();
const [graphType] = useQueryState( const { graphType, widgetId } = useMemo(() => {
'graphType', return {
parseAsStringEnum<PANEL_TYPES>(Object.values(PANEL_TYPES)), graphType: query.get(QueryParams.graphType) as PANEL_TYPES,
); widgetId: query.get(QueryParams.widgetId),
};
}, [query]);
const { safeNavigate } = useSafeNavigate(); const { safeNavigate } = useSafeNavigate();
@@ -57,8 +62,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 +82,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,