mirror of
https://github.com/SigNoz/signoz.git
synced 2026-03-13 16:52:07 +00:00
Compare commits
5 Commits
debug_time
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2316b5be83 | ||
|
|
937ebc1582 | ||
|
|
dcc8173c79 | ||
|
|
4b4ef5ce58 | ||
|
|
5b8d5fbfd3 |
5
.github/CODEOWNERS
vendored
5
.github/CODEOWNERS
vendored
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|
||||||
|
|||||||
@@ -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(',') ?? '';
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 }),
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -24,14 +24,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 +88,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 +101,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 +135,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 +153,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 +183,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 +267,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 +282,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 +333,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 +365,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 +406,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 +426,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 +461,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 +516,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 +535,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 {
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
.threshold-selector-container {
|
.threshold-selector-container {
|
||||||
padding: 12px;
|
|
||||||
padding-bottom: 80px;
|
padding-bottom: 80px;
|
||||||
|
|
||||||
.threshold-select {
|
.threshold-select {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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,
|
||||||
|
|||||||
128
frontend/src/hooks/dashboard/useTransformDashboardVariables.ts
Normal file
128
frontend/src/hooks/dashboard/useTransformDashboardVariables.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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: (
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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.." />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user