mirror of
https://github.com/SigNoz/signoz.git
synced 2026-02-09 19:22:21 +00:00
Compare commits
4 Commits
test/uplot
...
platform-p
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4f0f00bb80 | ||
|
|
813cec2225 | ||
|
|
f9a9e6905f | ||
|
|
f9d38fd73c |
@@ -12,8 +12,6 @@ export interface MockUPlotInstance {
|
||||
export interface MockUPlotPaths {
|
||||
spline: jest.Mock;
|
||||
bars: jest.Mock;
|
||||
linear: jest.Mock;
|
||||
stepped: jest.Mock;
|
||||
}
|
||||
|
||||
// Create mock instance methods
|
||||
@@ -25,23 +23,10 @@ const createMockUPlotInstance = (): MockUPlotInstance => ({
|
||||
setSeries: jest.fn(),
|
||||
});
|
||||
|
||||
// Path builder: (self, seriesIdx, idx0, idx1) => paths or null
|
||||
const createMockPathBuilder = (name: string): jest.Mock =>
|
||||
jest.fn(() => ({
|
||||
name, // To test if the correct pathBuilder is used
|
||||
stroke: jest.fn(),
|
||||
fill: jest.fn(),
|
||||
clip: jest.fn(),
|
||||
}));
|
||||
|
||||
// Create mock paths - linear, spline, stepped needed by UPlotSeriesBuilder.getPathBuilder
|
||||
const mockPaths = {
|
||||
spline: jest.fn(() => createMockPathBuilder('spline')),
|
||||
bars: jest.fn(() => createMockPathBuilder('bars')),
|
||||
linear: jest.fn(() => createMockPathBuilder('linear')),
|
||||
stepped: jest.fn((opts?: { align?: number }) =>
|
||||
createMockPathBuilder(`stepped-(${opts?.align ?? 0})`),
|
||||
),
|
||||
// Create mock paths
|
||||
const mockPaths: MockUPlotPaths = {
|
||||
spline: jest.fn(),
|
||||
bars: jest.fn(),
|
||||
};
|
||||
|
||||
// Mock static methods
|
||||
|
||||
@@ -140,7 +140,10 @@ const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
|
||||
}, [selectedValues, allAvailableValues, enableAllSelection]);
|
||||
|
||||
// Define allOptionShown earlier in the code
|
||||
const allOptionShown = value === ALL_SELECTED_VALUE;
|
||||
const allOptionShown = useMemo(
|
||||
() => value === ALL_SELECTED_VALUE || value === 'ALL',
|
||||
[value],
|
||||
);
|
||||
|
||||
// Value passed to the underlying Ant Select component
|
||||
const displayValue = useMemo(
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import { memo, useEffect, useMemo } from 'react';
|
||||
import { memo, useMemo } from 'react';
|
||||
import { commaValuesParser } from 'lib/dashboardVariables/customCommaValuesParser';
|
||||
import sortValues from 'lib/dashboardVariables/sortVariableValues';
|
||||
|
||||
import SelectVariableInput from './SelectVariableInput';
|
||||
import { useDashboardVariableSelectHelper } from './useDashboardVariableSelectHelper';
|
||||
import { VariableItemProps } from './VariableItem';
|
||||
import { customVariableSelectStrategy } from './variableSelectStrategy/customVariableSelectStrategy';
|
||||
|
||||
type CustomVariableInputProps = Pick<
|
||||
VariableItemProps,
|
||||
@@ -30,31 +29,16 @@ function CustomVariableInput({
|
||||
onChange,
|
||||
onDropdownVisibleChange,
|
||||
handleClear,
|
||||
applyDefaultIfNeeded,
|
||||
} = useDashboardVariableSelectHelper({
|
||||
variableData,
|
||||
optionsData,
|
||||
onValueUpdate,
|
||||
strategy: customVariableSelectStrategy,
|
||||
});
|
||||
|
||||
// Apply default on mount — options are available synchronously for custom variables
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
useEffect(applyDefaultIfNeeded, []);
|
||||
|
||||
const selectOptions = useMemo(
|
||||
() =>
|
||||
optionsData.map((option) => ({
|
||||
label: option.toString(),
|
||||
value: option.toString(),
|
||||
})),
|
||||
[optionsData],
|
||||
);
|
||||
|
||||
return (
|
||||
<SelectVariableInput
|
||||
variableId={variableData.id}
|
||||
options={selectOptions}
|
||||
options={optionsData}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
onDropdownVisibleChange={onDropdownVisibleChange}
|
||||
|
||||
@@ -13,6 +13,7 @@ import { AppState } from 'store/reducers';
|
||||
import { IDashboardVariable } from 'types/api/dashboard/getAll';
|
||||
import { GlobalReducer } from 'types/reducer/globalTime';
|
||||
|
||||
import DynamicVariableSelection from './DynamicVariableSelection';
|
||||
import { onUpdateVariableNode } from './util';
|
||||
import VariableItem from './VariableItem';
|
||||
|
||||
@@ -152,7 +153,14 @@ function DashboardVariableSelection(): JSX.Element | null {
|
||||
{sortedVariablesArray.map((variable) => {
|
||||
const key = `${variable.name}${variable.id}${variable.order}`;
|
||||
|
||||
return (
|
||||
return variable.type === 'DYNAMIC' ? (
|
||||
<DynamicVariableSelection
|
||||
key={key}
|
||||
existingVariables={dashboardVariables}
|
||||
variableData={variable}
|
||||
onValueUpdate={onValueUpdate}
|
||||
/>
|
||||
) : (
|
||||
<VariableItem
|
||||
key={key}
|
||||
existingVariables={dashboardVariables}
|
||||
|
||||
@@ -1,343 +0,0 @@
|
||||
import { memo, useCallback, useMemo, useState } from 'react';
|
||||
import { useQuery } from 'react-query';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { getFieldValues } from 'api/dynamicVariables/getFieldValues';
|
||||
import { SOMETHING_WENT_WRONG } from 'constants/api';
|
||||
import { DEBOUNCE_DELAY } from 'constants/queryBuilderFilterConfig';
|
||||
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
|
||||
import useDebounce from 'hooks/useDebounce';
|
||||
import { isEmpty } from 'lodash-es';
|
||||
import { AppState } from 'store/reducers';
|
||||
import { GlobalReducer } from 'types/reducer/globalTime';
|
||||
import { isRetryableError as checkIfRetryableError } from 'utils/errorUtils';
|
||||
|
||||
import SelectVariableInput from './SelectVariableInput';
|
||||
import { useDashboardVariableSelectHelper } from './useDashboardVariableSelectHelper';
|
||||
import { getOptionsForDynamicVariable } from './util';
|
||||
import { VariableItemProps } from './VariableItem';
|
||||
import { dynamicVariableSelectStrategy } from './variableSelectStrategy/dynamicVariableSelectStrategy';
|
||||
|
||||
import './DashboardVariableSelection.styles.scss';
|
||||
|
||||
type DynamicVariableInputProps = Pick<
|
||||
VariableItemProps,
|
||||
'variableData' | 'onValueUpdate' | 'existingVariables'
|
||||
>;
|
||||
|
||||
// eslint-disable-next-line sonarjs/cognitive-complexity
|
||||
function DynamicVariableInput({
|
||||
variableData,
|
||||
onValueUpdate,
|
||||
existingVariables,
|
||||
}: DynamicVariableInputProps): JSX.Element {
|
||||
const [optionsData, setOptionsData] = useState<(string | number | boolean)[]>(
|
||||
[],
|
||||
);
|
||||
|
||||
const [errorMessage, setErrorMessage] = useState<null | string>(null);
|
||||
const [isRetryableError, setIsRetryableError] = useState<boolean>(true);
|
||||
|
||||
const [isComplete, setIsComplete] = useState<boolean>(false);
|
||||
|
||||
const [filteredOptionsData, setFilteredOptionsData] = useState<
|
||||
(string | number | boolean)[]
|
||||
>([]);
|
||||
|
||||
const [relatedValues, setRelatedValues] = useState<string[]>([]);
|
||||
const [originalRelatedValues, setOriginalRelatedValues] = useState<string[]>(
|
||||
[],
|
||||
);
|
||||
|
||||
// Track dropdown open state for auto-checking new values
|
||||
const [isDropdownOpen, setIsDropdownOpen] = useState<boolean>(false);
|
||||
|
||||
const [apiSearchText, setApiSearchText] = useState<string>('');
|
||||
|
||||
const debouncedApiSearchText = useDebounce(apiSearchText, DEBOUNCE_DELAY);
|
||||
|
||||
// Build a memoized list of all currently available option strings (normalized + related)
|
||||
const allAvailableOptionStrings = useMemo(
|
||||
() => [
|
||||
...new Set([
|
||||
...optionsData.map((v) => v.toString()),
|
||||
...relatedValues.map((v) => v.toString()),
|
||||
]),
|
||||
],
|
||||
[optionsData, relatedValues],
|
||||
);
|
||||
|
||||
const {
|
||||
value,
|
||||
tempSelection,
|
||||
setTempSelection,
|
||||
handleClear,
|
||||
enableSelectAll,
|
||||
defaultValue,
|
||||
applyDefaultIfNeeded,
|
||||
onChange,
|
||||
onDropdownVisibleChange,
|
||||
} = useDashboardVariableSelectHelper({
|
||||
variableData,
|
||||
optionsData,
|
||||
onValueUpdate,
|
||||
strategy: dynamicVariableSelectStrategy,
|
||||
allAvailableOptionStrings,
|
||||
});
|
||||
|
||||
// Create a dependency key from all dynamic variables
|
||||
const dynamicVariablesKey = useMemo(() => {
|
||||
if (!existingVariables) {
|
||||
return 'no_variables';
|
||||
}
|
||||
|
||||
const dynamicVars = Object.values(existingVariables)
|
||||
.filter((v) => v.type === 'DYNAMIC')
|
||||
.map(
|
||||
(v) => `${v.name || 'unnamed'}:${JSON.stringify(v.selectedValue || null)}`,
|
||||
)
|
||||
.join('|');
|
||||
|
||||
return dynamicVars || 'no_dynamic_variables';
|
||||
}, [existingVariables]);
|
||||
|
||||
const { maxTime, minTime } = useSelector<AppState, GlobalReducer>(
|
||||
(state) => state.globalTime,
|
||||
);
|
||||
|
||||
// existing query is the query made from the other dynamic variables around this one with there current values
|
||||
// for e.g. k8s.namespace.name IN ["zeus", "gene"] AND doc_op_type IN ["test"]
|
||||
// eslint-disable-next-line sonarjs/cognitive-complexity
|
||||
const existingQuery = useMemo(() => {
|
||||
if (!existingVariables || !variableData.dynamicVariablesAttribute) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const queryParts: string[] = [];
|
||||
|
||||
Object.entries(existingVariables).forEach(([, variable]) => {
|
||||
// Skip the current variable being processed
|
||||
if (variable.id === variableData.id) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Only include dynamic variables that have selected values and are not selected as ALL
|
||||
if (
|
||||
variable.type === 'DYNAMIC' &&
|
||||
variable.dynamicVariablesAttribute &&
|
||||
variable.selectedValue &&
|
||||
!isEmpty(variable.selectedValue) &&
|
||||
(variable.showALLOption ? !variable.allSelected : true)
|
||||
) {
|
||||
const attribute = variable.dynamicVariablesAttribute;
|
||||
const values = Array.isArray(variable.selectedValue)
|
||||
? variable.selectedValue
|
||||
: [variable.selectedValue];
|
||||
|
||||
// Filter out empty values and convert to strings
|
||||
const validValues = values
|
||||
.filter((val) => val !== null && val !== undefined && val !== '')
|
||||
.map((val) => val.toString());
|
||||
|
||||
if (validValues.length > 0) {
|
||||
// Format values for query - wrap strings in quotes, keep numbers as is
|
||||
const formattedValues = validValues.map((val) => {
|
||||
// Check if value is a number
|
||||
const numValue = Number(val);
|
||||
if (!Number.isNaN(numValue) && Number.isFinite(numValue)) {
|
||||
return val; // Keep as number
|
||||
}
|
||||
// Escape single quotes and wrap in quotes
|
||||
return `'${val.replace(/'/g, "\\'")}'`;
|
||||
});
|
||||
|
||||
if (formattedValues.length === 1) {
|
||||
queryParts.push(`${attribute} = ${formattedValues[0]}`);
|
||||
} else {
|
||||
queryParts.push(`${attribute} IN [${formattedValues.join(', ')}]`);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return queryParts.join(' AND ');
|
||||
}, [
|
||||
existingVariables,
|
||||
variableData.id,
|
||||
variableData.dynamicVariablesAttribute,
|
||||
]);
|
||||
|
||||
// Wrap the hook's onDropdownVisibleChange to also track isDropdownOpen and handle cleanup
|
||||
const handleSelectDropdownVisibilityChange = useCallback(
|
||||
(visible: boolean): void => {
|
||||
setIsDropdownOpen(visible);
|
||||
|
||||
onDropdownVisibleChange(visible);
|
||||
|
||||
if (!visible) {
|
||||
setFilteredOptionsData(optionsData);
|
||||
setRelatedValues(originalRelatedValues);
|
||||
setApiSearchText('');
|
||||
}
|
||||
},
|
||||
[onDropdownVisibleChange, optionsData, originalRelatedValues],
|
||||
);
|
||||
|
||||
const { isLoading, refetch } = useQuery(
|
||||
[
|
||||
REACT_QUERY_KEY.DASHBOARD_BY_ID,
|
||||
variableData.name || `variable_${variableData.id}`,
|
||||
dynamicVariablesKey,
|
||||
minTime,
|
||||
maxTime,
|
||||
debouncedApiSearchText,
|
||||
variableData.dynamicVariablesSource,
|
||||
variableData.dynamicVariablesAttribute,
|
||||
],
|
||||
{
|
||||
enabled:
|
||||
variableData.type === 'DYNAMIC' &&
|
||||
!!variableData.dynamicVariablesSource &&
|
||||
!!variableData.dynamicVariablesAttribute,
|
||||
queryFn: () =>
|
||||
getFieldValues(
|
||||
variableData.dynamicVariablesSource?.toLowerCase() === 'all telemetry'
|
||||
? undefined
|
||||
: (variableData.dynamicVariablesSource?.toLowerCase() as
|
||||
| 'traces'
|
||||
| 'logs'
|
||||
| 'metrics'),
|
||||
variableData.dynamicVariablesAttribute,
|
||||
debouncedApiSearchText,
|
||||
minTime,
|
||||
maxTime,
|
||||
existingQuery,
|
||||
),
|
||||
onSuccess: (data) => {
|
||||
const newNormalizedValues = data.data?.normalizedValues || [];
|
||||
const newRelatedValues = data.data?.relatedValues || [];
|
||||
|
||||
if (!debouncedApiSearchText) {
|
||||
setOptionsData(newNormalizedValues);
|
||||
setIsComplete(data.data?.complete || false);
|
||||
}
|
||||
setFilteredOptionsData(newNormalizedValues);
|
||||
setRelatedValues(newRelatedValues);
|
||||
setOriginalRelatedValues(newRelatedValues);
|
||||
|
||||
// Only run auto-check logic when necessary to avoid performance issues
|
||||
if (variableData.allSelected && isDropdownOpen) {
|
||||
// Build the latest full list from API (normalized + related)
|
||||
const latestValues = [
|
||||
...new Set([
|
||||
...newNormalizedValues.map((v) => v.toString()),
|
||||
...newRelatedValues.map((v) => v.toString()),
|
||||
]),
|
||||
];
|
||||
|
||||
// Update temp selection to exactly reflect latest API values when ALL is active
|
||||
const currentStrings = Array.isArray(tempSelection)
|
||||
? tempSelection.map((v) => v.toString())
|
||||
: tempSelection
|
||||
? [tempSelection.toString()]
|
||||
: [];
|
||||
const areSame =
|
||||
currentStrings.length === latestValues.length &&
|
||||
latestValues.every((v) => currentStrings.includes(v));
|
||||
if (!areSame) {
|
||||
setTempSelection(latestValues);
|
||||
}
|
||||
}
|
||||
|
||||
// Apply default if no value is selected (e.g., new variable, first load)
|
||||
if (!debouncedApiSearchText) {
|
||||
const allNewOptions = [
|
||||
...new Set([
|
||||
...newNormalizedValues.map((v) => v.toString()),
|
||||
...newRelatedValues.map((v) => v.toString()),
|
||||
]),
|
||||
];
|
||||
applyDefaultIfNeeded(allNewOptions);
|
||||
}
|
||||
},
|
||||
onError: (error: any) => {
|
||||
if (error) {
|
||||
let message = SOMETHING_WENT_WRONG;
|
||||
if (error?.message) {
|
||||
message = error?.message;
|
||||
} else {
|
||||
message =
|
||||
'Please make sure configuration is valid and you have required setup and permissions';
|
||||
}
|
||||
setErrorMessage(message);
|
||||
|
||||
// Check if error is retryable (5xx) or not (4xx)
|
||||
const isRetryable = checkIfRetryableError(error);
|
||||
setIsRetryableError(isRetryable);
|
||||
}
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const handleRetry = useCallback((): void => {
|
||||
setErrorMessage(null);
|
||||
setIsRetryableError(true);
|
||||
refetch();
|
||||
}, [refetch]);
|
||||
|
||||
const handleSearch = useCallback(
|
||||
(text: string) => {
|
||||
if (isComplete) {
|
||||
if (!text) {
|
||||
setFilteredOptionsData(optionsData);
|
||||
setRelatedValues(originalRelatedValues);
|
||||
return;
|
||||
}
|
||||
|
||||
const lowerText = text.toLowerCase();
|
||||
setFilteredOptionsData(
|
||||
optionsData.filter((option) =>
|
||||
option.toString().toLowerCase().includes(lowerText),
|
||||
),
|
||||
);
|
||||
setRelatedValues(
|
||||
originalRelatedValues.filter((val) =>
|
||||
val.toLowerCase().includes(lowerText),
|
||||
),
|
||||
);
|
||||
} else {
|
||||
setApiSearchText(text);
|
||||
}
|
||||
},
|
||||
[isComplete, optionsData, originalRelatedValues],
|
||||
);
|
||||
|
||||
const selectOptions = useMemo(
|
||||
() =>
|
||||
getOptionsForDynamicVariable(filteredOptionsData || [], relatedValues || []),
|
||||
[filteredOptionsData, relatedValues],
|
||||
);
|
||||
|
||||
return (
|
||||
<SelectVariableInput
|
||||
variableId={variableData.id}
|
||||
options={selectOptions}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
onDropdownVisibleChange={handleSelectDropdownVisibilityChange}
|
||||
onClear={handleClear}
|
||||
enableSelectAll={enableSelectAll}
|
||||
defaultValue={defaultValue}
|
||||
isMultiSelect={variableData.multiSelect}
|
||||
// dynamic variable specific + API related props
|
||||
loading={isLoading}
|
||||
errorMessage={errorMessage}
|
||||
onRetry={handleRetry}
|
||||
isDynamicVariable
|
||||
showRetryButton={isRetryableError}
|
||||
showIncompleteDataMessage={!isComplete && filteredOptionsData.length > 0}
|
||||
onSearch={handleSearch}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default memo(DynamicVariableInput);
|
||||
@@ -0,0 +1,602 @@
|
||||
/* eslint-disable sonarjs/cognitive-complexity */
|
||||
/* eslint-disable no-nested-ternary */
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useQuery } from 'react-query';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { InfoCircleOutlined } from '@ant-design/icons';
|
||||
import { Tooltip, Typography } from 'antd';
|
||||
import { getFieldValues } from 'api/dynamicVariables/getFieldValues';
|
||||
import { CustomMultiSelect, CustomSelect } from 'components/NewSelect';
|
||||
import { SOMETHING_WENT_WRONG } from 'constants/api';
|
||||
import { DEBOUNCE_DELAY } from 'constants/queryBuilderFilterConfig';
|
||||
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
|
||||
import useDebounce from 'hooks/useDebounce';
|
||||
import { isEmpty, isUndefined } from 'lodash-es';
|
||||
import { AppState } from 'store/reducers';
|
||||
import { IDashboardVariable } from 'types/api/dashboard/getAll';
|
||||
import { GlobalReducer } from 'types/reducer/globalTime';
|
||||
import { isRetryableError as checkIfRetryableError } from 'utils/errorUtils';
|
||||
import { popupContainer } from 'utils/selectPopupContainer';
|
||||
|
||||
import { ALL_SELECT_VALUE } from '../utils';
|
||||
import { SelectItemStyle } from './styles';
|
||||
import {
|
||||
areArraysEqual,
|
||||
getOptionsForDynamicVariable,
|
||||
getSelectValue,
|
||||
uniqueValues,
|
||||
} from './util';
|
||||
|
||||
import './DashboardVariableSelection.styles.scss';
|
||||
|
||||
interface DynamicVariableSelectionProps {
|
||||
variableData: IDashboardVariable;
|
||||
existingVariables: Record<string, IDashboardVariable>;
|
||||
onValueUpdate: (
|
||||
name: string,
|
||||
id: string,
|
||||
arg1: IDashboardVariable['selectedValue'],
|
||||
allSelected: boolean,
|
||||
haveCustomValuesSelected?: boolean,
|
||||
) => void;
|
||||
}
|
||||
|
||||
function DynamicVariableSelection({
|
||||
variableData,
|
||||
onValueUpdate,
|
||||
existingVariables,
|
||||
}: DynamicVariableSelectionProps): JSX.Element {
|
||||
const [optionsData, setOptionsData] = useState<(string | number | boolean)[]>(
|
||||
[],
|
||||
);
|
||||
|
||||
const [errorMessage, setErrorMessage] = useState<null | string>(null);
|
||||
const [isRetryableError, setIsRetryableError] = useState<boolean>(true);
|
||||
|
||||
const [isComplete, setIsComplete] = useState<boolean>(false);
|
||||
|
||||
const [filteredOptionsData, setFilteredOptionsData] = useState<
|
||||
(string | number | boolean)[]
|
||||
>([]);
|
||||
|
||||
const [relatedValues, setRelatedValues] = useState<string[]>([]);
|
||||
const [originalRelatedValues, setOriginalRelatedValues] = useState<string[]>(
|
||||
[],
|
||||
);
|
||||
|
||||
const [tempSelection, setTempSelection] = useState<
|
||||
string | string[] | undefined
|
||||
>(undefined);
|
||||
|
||||
// Track dropdown open state for auto-checking new values
|
||||
const [isDropdownOpen, setIsDropdownOpen] = useState<boolean>(false);
|
||||
|
||||
// Create a dependency key from all dynamic variables
|
||||
const dynamicVariablesKey = useMemo(() => {
|
||||
if (!existingVariables) {
|
||||
return 'no_variables';
|
||||
}
|
||||
|
||||
const dynamicVars = Object.values(existingVariables)
|
||||
.filter((v) => v.type === 'DYNAMIC')
|
||||
.map(
|
||||
(v) => `${v.name || 'unnamed'}:${JSON.stringify(v.selectedValue || null)}`,
|
||||
)
|
||||
.join('|');
|
||||
|
||||
return dynamicVars || 'no_dynamic_variables';
|
||||
}, [existingVariables]);
|
||||
|
||||
const [apiSearchText, setApiSearchText] = useState<string>('');
|
||||
|
||||
const debouncedApiSearchText = useDebounce(apiSearchText, DEBOUNCE_DELAY);
|
||||
|
||||
const { maxTime, minTime } = useSelector<AppState, GlobalReducer>(
|
||||
(state) => state.globalTime,
|
||||
);
|
||||
|
||||
// existing query is the query made from the other dynamic variables around this one with there current values
|
||||
// for e.g. k8s.namespace.name IN ["zeus", "gene"] AND doc_op_type IN ["test"]
|
||||
const existingQuery = useMemo(() => {
|
||||
if (!existingVariables || !variableData.dynamicVariablesAttribute) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const queryParts: string[] = [];
|
||||
|
||||
Object.entries(existingVariables).forEach(([, variable]) => {
|
||||
// Skip the current variable being processed
|
||||
if (variable.id === variableData.id) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Only include dynamic variables that have selected values and are not selected as ALL
|
||||
if (
|
||||
variable.type === 'DYNAMIC' &&
|
||||
variable.dynamicVariablesAttribute &&
|
||||
variable.selectedValue &&
|
||||
!isEmpty(variable.selectedValue) &&
|
||||
(variable.showALLOption ? !variable.allSelected : true)
|
||||
) {
|
||||
const attribute = variable.dynamicVariablesAttribute;
|
||||
const values = Array.isArray(variable.selectedValue)
|
||||
? variable.selectedValue
|
||||
: [variable.selectedValue];
|
||||
|
||||
// Filter out empty values and convert to strings
|
||||
const validValues = values
|
||||
.filter((value) => value !== null && value !== undefined && value !== '')
|
||||
.map((value) => value.toString());
|
||||
|
||||
if (validValues.length > 0) {
|
||||
// Format values for query - wrap strings in quotes, keep numbers as is
|
||||
const formattedValues = validValues.map((value) => {
|
||||
// Check if value is a number
|
||||
const numValue = Number(value);
|
||||
if (!Number.isNaN(numValue) && Number.isFinite(numValue)) {
|
||||
return value; // Keep as number
|
||||
}
|
||||
// Escape single quotes and wrap in quotes
|
||||
return `'${value.replace(/'/g, "\\'")}'`;
|
||||
});
|
||||
|
||||
if (formattedValues.length === 1) {
|
||||
queryParts.push(`${attribute} = ${formattedValues[0]}`);
|
||||
} else {
|
||||
queryParts.push(`${attribute} IN [${formattedValues.join(', ')}]`);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return queryParts.join(' AND ');
|
||||
}, [
|
||||
existingVariables,
|
||||
variableData.id,
|
||||
variableData.dynamicVariablesAttribute,
|
||||
]);
|
||||
|
||||
const { isLoading, refetch } = useQuery(
|
||||
[
|
||||
REACT_QUERY_KEY.DASHBOARD_BY_ID,
|
||||
variableData.name || `variable_${variableData.id}`,
|
||||
dynamicVariablesKey,
|
||||
minTime,
|
||||
maxTime,
|
||||
debouncedApiSearchText,
|
||||
],
|
||||
{
|
||||
enabled: variableData.type === 'DYNAMIC',
|
||||
queryFn: () =>
|
||||
getFieldValues(
|
||||
variableData.dynamicVariablesSource?.toLowerCase() === 'all telemetry'
|
||||
? undefined
|
||||
: (variableData.dynamicVariablesSource?.toLowerCase() as
|
||||
| 'traces'
|
||||
| 'logs'
|
||||
| 'metrics'),
|
||||
variableData.dynamicVariablesAttribute,
|
||||
debouncedApiSearchText,
|
||||
minTime,
|
||||
maxTime,
|
||||
existingQuery,
|
||||
),
|
||||
onSuccess: (data) => {
|
||||
const newNormalizedValues = data.data?.normalizedValues || [];
|
||||
const newRelatedValues = data.data?.relatedValues || [];
|
||||
|
||||
if (!debouncedApiSearchText) {
|
||||
setOptionsData(newNormalizedValues);
|
||||
setIsComplete(data.data?.complete || false);
|
||||
}
|
||||
setFilteredOptionsData(newNormalizedValues);
|
||||
setRelatedValues(newRelatedValues);
|
||||
setOriginalRelatedValues(newRelatedValues);
|
||||
|
||||
// Only run auto-check logic when necessary to avoid performance issues
|
||||
if (variableData.allSelected && isDropdownOpen) {
|
||||
// Build the latest full list from API (normalized + related)
|
||||
const latestValues = [
|
||||
...new Set([
|
||||
...newNormalizedValues.map((v) => v.toString()),
|
||||
...newRelatedValues.map((v) => v.toString()),
|
||||
]),
|
||||
];
|
||||
|
||||
// Update temp selection to exactly reflect latest API values when ALL is active
|
||||
const currentStrings = Array.isArray(tempSelection)
|
||||
? tempSelection.map((v) => v.toString())
|
||||
: tempSelection
|
||||
? [tempSelection.toString()]
|
||||
: [];
|
||||
const areSame =
|
||||
currentStrings.length === latestValues.length &&
|
||||
latestValues.every((v) => currentStrings.includes(v));
|
||||
if (!areSame) {
|
||||
setTempSelection(latestValues);
|
||||
}
|
||||
}
|
||||
},
|
||||
onError: (error: any) => {
|
||||
if (error) {
|
||||
let message = SOMETHING_WENT_WRONG;
|
||||
if (error?.message) {
|
||||
message = error?.message;
|
||||
} else {
|
||||
message =
|
||||
'Please make sure configuration is valid and you have required setup and permissions';
|
||||
}
|
||||
setErrorMessage(message);
|
||||
|
||||
// Check if error is retryable (5xx) or not (4xx)
|
||||
const isRetryable = checkIfRetryableError(error);
|
||||
setIsRetryableError(isRetryable);
|
||||
}
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const handleChange = useCallback(
|
||||
(inputValue: string | string[]): void => {
|
||||
const value = variableData.multiSelect && !inputValue ? [] : inputValue;
|
||||
|
||||
if (
|
||||
value === variableData.selectedValue ||
|
||||
(Array.isArray(value) &&
|
||||
Array.isArray(variableData.selectedValue) &&
|
||||
areArraysEqual(value, variableData.selectedValue))
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (variableData.name) {
|
||||
if (
|
||||
value === ALL_SELECT_VALUE ||
|
||||
(Array.isArray(value) && value.includes(ALL_SELECT_VALUE))
|
||||
) {
|
||||
// For ALL selection in dynamic variables, pass null to avoid storing values
|
||||
// The parent component will handle this appropriately
|
||||
onValueUpdate(variableData.name, variableData.id, null, true);
|
||||
} else {
|
||||
// Build union of available options shown in dropdown (normalized + related)
|
||||
const allAvailableOptionStrings = [
|
||||
...new Set([
|
||||
...optionsData.map((v) => v.toString()),
|
||||
...relatedValues.map((v) => v.toString()),
|
||||
]),
|
||||
];
|
||||
|
||||
const haveCustomValuesSelected =
|
||||
Array.isArray(value) &&
|
||||
!value.every((v) => allAvailableOptionStrings.includes(v.toString()));
|
||||
|
||||
onValueUpdate(
|
||||
variableData.name,
|
||||
variableData.id,
|
||||
value,
|
||||
allAvailableOptionStrings.every((v) => value.includes(v.toString())),
|
||||
haveCustomValuesSelected,
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
[variableData, onValueUpdate, optionsData, relatedValues],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
variableData.dynamicVariablesSource &&
|
||||
variableData.dynamicVariablesAttribute
|
||||
) {
|
||||
refetch();
|
||||
}
|
||||
}, [
|
||||
refetch,
|
||||
variableData.dynamicVariablesSource,
|
||||
variableData.dynamicVariablesAttribute,
|
||||
debouncedApiSearchText,
|
||||
]);
|
||||
|
||||
// Build a memoized list of all currently available option strings (normalized + related)
|
||||
const allAvailableOptionStrings = useMemo(
|
||||
() => [
|
||||
...new Set([
|
||||
...optionsData.map((v) => v.toString()),
|
||||
...relatedValues.map((v) => v.toString()),
|
||||
]),
|
||||
],
|
||||
[optionsData, relatedValues],
|
||||
);
|
||||
|
||||
const handleSearch = useCallback(
|
||||
(text: string) => {
|
||||
if (isComplete) {
|
||||
if (!text) {
|
||||
setFilteredOptionsData(optionsData);
|
||||
setRelatedValues(originalRelatedValues);
|
||||
return;
|
||||
}
|
||||
|
||||
const localFilteredOptionsData: (string | number | boolean)[] = [];
|
||||
optionsData.forEach((option) => {
|
||||
if (option.toString().toLowerCase().includes(text.toLowerCase())) {
|
||||
localFilteredOptionsData.push(option);
|
||||
}
|
||||
});
|
||||
setFilteredOptionsData(localFilteredOptionsData);
|
||||
setRelatedValues(
|
||||
originalRelatedValues.filter((value) =>
|
||||
value.toLowerCase().includes(text.toLowerCase()),
|
||||
),
|
||||
);
|
||||
} else {
|
||||
setApiSearchText(text);
|
||||
}
|
||||
},
|
||||
[isComplete, optionsData, originalRelatedValues],
|
||||
);
|
||||
|
||||
const { selectedValue } = variableData;
|
||||
const selectedValueStringified = useMemo(
|
||||
() => getSelectValue(selectedValue, variableData),
|
||||
[selectedValue, variableData],
|
||||
);
|
||||
|
||||
const enableSelectAll = variableData.multiSelect && variableData.showALLOption;
|
||||
|
||||
const selectValue =
|
||||
variableData.allSelected && enableSelectAll
|
||||
? ALL_SELECT_VALUE
|
||||
: selectedValueStringified;
|
||||
|
||||
// Add a handler for tracking temporary selection changes
|
||||
const handleTempChange = useCallback(
|
||||
(inputValue: string | string[]): void => {
|
||||
// Store the selection in temporary state while dropdown is open
|
||||
const value = variableData.multiSelect && !inputValue ? [] : inputValue;
|
||||
const sanitizedValue = uniqueValues(value);
|
||||
setTempSelection(sanitizedValue);
|
||||
},
|
||||
[variableData.multiSelect],
|
||||
);
|
||||
|
||||
// Handle dropdown visibility changes
|
||||
const handleDropdownVisibleChange = (visible: boolean): void => {
|
||||
// Update dropdown open state for auto-checking
|
||||
setIsDropdownOpen(visible);
|
||||
|
||||
// Initialize temp selection when opening dropdown
|
||||
if (visible) {
|
||||
if (isUndefined(tempSelection) && selectValue === ALL_SELECT_VALUE) {
|
||||
// When ALL is selected, set selection to exactly the latest available values
|
||||
const latestAll = [...allAvailableOptionStrings];
|
||||
setTempSelection(latestAll);
|
||||
} else {
|
||||
setTempSelection(getSelectValue(variableData.selectedValue, variableData));
|
||||
}
|
||||
}
|
||||
// Apply changes when closing dropdown
|
||||
else if (!visible && tempSelection !== undefined) {
|
||||
// Only call handleChange if there's actually a change in the selection
|
||||
const currentValue = variableData.selectedValue;
|
||||
|
||||
// Helper function to check if arrays have the same elements regardless of order
|
||||
const areArraysEqualIgnoreOrder = (a: any[], b: any[]): boolean => {
|
||||
if (a.length !== b.length) {
|
||||
return false;
|
||||
}
|
||||
const sortedA = [...a].sort();
|
||||
const sortedB = [...b].sort();
|
||||
return areArraysEqual(sortedA, sortedB);
|
||||
};
|
||||
|
||||
// If ALL was selected before and remains ALL after, skip updating
|
||||
const wasAllSelected = enableSelectAll && variableData.allSelected;
|
||||
const isAllSelectedAfter =
|
||||
enableSelectAll &&
|
||||
Array.isArray(tempSelection) &&
|
||||
tempSelection.length === allAvailableOptionStrings.length &&
|
||||
allAvailableOptionStrings.every((v) => tempSelection.includes(v));
|
||||
|
||||
if (wasAllSelected && isAllSelectedAfter) {
|
||||
setTempSelection(undefined);
|
||||
return;
|
||||
}
|
||||
|
||||
const hasChanged =
|
||||
tempSelection !== currentValue &&
|
||||
!(
|
||||
Array.isArray(tempSelection) &&
|
||||
Array.isArray(currentValue) &&
|
||||
areArraysEqualIgnoreOrder(tempSelection, currentValue)
|
||||
);
|
||||
|
||||
if (hasChanged) {
|
||||
handleChange(tempSelection);
|
||||
}
|
||||
setTempSelection(undefined);
|
||||
}
|
||||
|
||||
// Always reset filtered data when dropdown closes, regardless of tempSelection state
|
||||
if (!visible) {
|
||||
setFilteredOptionsData(optionsData);
|
||||
setRelatedValues(originalRelatedValues);
|
||||
setApiSearchText('');
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(
|
||||
() => (): void => {
|
||||
// Cleanup on unmount
|
||||
setTempSelection(undefined);
|
||||
setFilteredOptionsData([]);
|
||||
setRelatedValues([]);
|
||||
setApiSearchText('');
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
// eslint-disable-next-line sonarjs/cognitive-complexity
|
||||
const finalSelectedValues = useMemo(() => {
|
||||
if (variableData.multiSelect) {
|
||||
let value = tempSelection || selectedValue;
|
||||
if (isEmpty(value)) {
|
||||
if (variableData.showALLOption) {
|
||||
if (variableData.defaultValue) {
|
||||
value = variableData.defaultValue;
|
||||
} else if (variableData.allSelected) {
|
||||
// If ALL is selected but no stored values, derive from available options
|
||||
// This handles the case where we don't store values in localStorage for ALL
|
||||
value = allAvailableOptionStrings;
|
||||
} else {
|
||||
value = optionsData;
|
||||
}
|
||||
} else if (variableData.defaultValue) {
|
||||
value = variableData.defaultValue;
|
||||
} else {
|
||||
value = optionsData?.[0];
|
||||
}
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
if (isEmpty(selectedValue)) {
|
||||
if (variableData.defaultValue) {
|
||||
return variableData.defaultValue;
|
||||
}
|
||||
return optionsData[0]?.toString();
|
||||
}
|
||||
|
||||
return selectedValue;
|
||||
}, [
|
||||
variableData.multiSelect,
|
||||
variableData.showALLOption,
|
||||
variableData.defaultValue,
|
||||
variableData.allSelected,
|
||||
selectedValue,
|
||||
tempSelection,
|
||||
optionsData,
|
||||
allAvailableOptionStrings,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
(variableData.multiSelect && !(tempSelection || selectValue)) ||
|
||||
isEmpty(selectValue)
|
||||
) {
|
||||
handleChange(finalSelectedValues as string[] | string);
|
||||
}
|
||||
}, [
|
||||
finalSelectedValues,
|
||||
handleChange,
|
||||
selectValue,
|
||||
tempSelection,
|
||||
variableData.multiSelect,
|
||||
]);
|
||||
|
||||
return (
|
||||
<div className="variable-item">
|
||||
<Typography.Text className="variable-name" ellipsis>
|
||||
${variableData.name}
|
||||
{variableData.description && (
|
||||
<Tooltip title={variableData.description}>
|
||||
<InfoCircleOutlined className="info-icon" />
|
||||
</Tooltip>
|
||||
)}
|
||||
</Typography.Text>
|
||||
<div className="variable-value">
|
||||
{variableData.multiSelect ? (
|
||||
<CustomMultiSelect
|
||||
key={variableData.id}
|
||||
options={getOptionsForDynamicVariable(
|
||||
filteredOptionsData || [],
|
||||
relatedValues || [],
|
||||
)}
|
||||
defaultValue={variableData.defaultValue}
|
||||
onChange={handleTempChange}
|
||||
bordered={false}
|
||||
placeholder="Select value"
|
||||
placement="bottomLeft"
|
||||
style={SelectItemStyle}
|
||||
loading={isLoading}
|
||||
showSearch
|
||||
data-testid="variable-select"
|
||||
className="variable-select"
|
||||
popupClassName="dropdown-styles"
|
||||
maxTagCount={2}
|
||||
getPopupContainer={popupContainer}
|
||||
value={
|
||||
(tempSelection || selectValue) === ALL_SELECT_VALUE
|
||||
? 'ALL'
|
||||
: tempSelection || selectValue
|
||||
}
|
||||
onDropdownVisibleChange={handleDropdownVisibleChange}
|
||||
errorMessage={errorMessage}
|
||||
// eslint-disable-next-line react/no-unstable-nested-components
|
||||
maxTagPlaceholder={(omittedValues): JSX.Element => {
|
||||
const maxDisplayValues = 10;
|
||||
const valuesToShow = omittedValues.slice(0, maxDisplayValues);
|
||||
const hasMore = omittedValues.length > maxDisplayValues;
|
||||
const tooltipText =
|
||||
valuesToShow.map(({ value }) => value).join(', ') +
|
||||
(hasMore ? ` + ${omittedValues.length - maxDisplayValues} more` : '');
|
||||
|
||||
return (
|
||||
<Tooltip title={tooltipText}>
|
||||
<span>+ {omittedValues.length} </span>
|
||||
</Tooltip>
|
||||
);
|
||||
}}
|
||||
onClear={(): void => {
|
||||
handleChange([]);
|
||||
}}
|
||||
enableAllSelection={enableSelectAll}
|
||||
maxTagTextLength={30}
|
||||
onSearch={handleSearch}
|
||||
onRetry={(): void => {
|
||||
setErrorMessage(null);
|
||||
setIsRetryableError(true);
|
||||
refetch();
|
||||
}}
|
||||
showIncompleteDataMessage={!isComplete && filteredOptionsData.length > 0}
|
||||
isDynamicVariable
|
||||
showRetryButton={isRetryableError}
|
||||
/>
|
||||
) : (
|
||||
<CustomSelect
|
||||
key={variableData.id}
|
||||
onChange={handleChange}
|
||||
bordered={false}
|
||||
placeholder="Select value"
|
||||
style={SelectItemStyle}
|
||||
loading={isLoading}
|
||||
showSearch
|
||||
data-testid="variable-select"
|
||||
className="variable-select"
|
||||
popupClassName="dropdown-styles"
|
||||
getPopupContainer={popupContainer}
|
||||
options={getOptionsForDynamicVariable(
|
||||
filteredOptionsData || [],
|
||||
relatedValues || [],
|
||||
)}
|
||||
value={selectValue}
|
||||
defaultValue={variableData.defaultValue}
|
||||
errorMessage={errorMessage}
|
||||
onSearch={handleSearch}
|
||||
// eslint-disable-next-line sonarjs/no-identical-functions
|
||||
onRetry={(): void => {
|
||||
setErrorMessage(null);
|
||||
setIsRetryableError(true);
|
||||
refetch();
|
||||
}}
|
||||
showIncompleteDataMessage={!isComplete && filteredOptionsData.length > 0}
|
||||
isDynamicVariable
|
||||
showRetryButton={isRetryableError}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default DynamicVariableSelection;
|
||||
@@ -1,11 +1,13 @@
|
||||
import { memo, useCallback, useMemo, useState } from 'react';
|
||||
import { memo, useCallback, useState } from 'react';
|
||||
import { useQuery } from 'react-query';
|
||||
import { useSelector } from 'react-redux';
|
||||
import dashboardVariablesQuery from 'api/dashboard/variables/dashboardVariablesQuery';
|
||||
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
|
||||
import sortValues from 'lib/dashboardVariables/sortVariableValues';
|
||||
import { isArray, isString } from 'lodash-es';
|
||||
import { IDependencyData } from 'providers/Dashboard/store/dashboardVariables/dashboardVariablesStoreTypes';
|
||||
import { AppState } from 'store/reducers';
|
||||
import { IDashboardVariable } from 'types/api/dashboard/getAll';
|
||||
import { VariableResponseProps } from 'types/api/dashboard/variables/query';
|
||||
import { GlobalReducer } from 'types/reducer/globalTime';
|
||||
|
||||
@@ -13,18 +15,20 @@ import { variablePropsToPayloadVariables } from '../utils';
|
||||
import SelectVariableInput from './SelectVariableInput';
|
||||
import { useDashboardVariableSelectHelper } from './useDashboardVariableSelectHelper';
|
||||
import { areArraysEqual, checkAPIInvocation } from './util';
|
||||
import { VariableItemProps } from './VariableItem';
|
||||
import { queryVariableSelectStrategy } from './variableSelectStrategy/queryVariableSelectStrategy';
|
||||
|
||||
type QueryVariableInputProps = Pick<
|
||||
VariableItemProps,
|
||||
| 'variableData'
|
||||
| 'existingVariables'
|
||||
| 'onValueUpdate'
|
||||
| 'variablesToGetUpdated'
|
||||
| 'setVariablesToGetUpdated'
|
||||
| 'dependencyData'
|
||||
>;
|
||||
interface QueryVariableInputProps {
|
||||
variableData: IDashboardVariable;
|
||||
existingVariables: Record<string, IDashboardVariable>;
|
||||
onValueUpdate: (
|
||||
name: string,
|
||||
id: string,
|
||||
value: IDashboardVariable['selectedValue'],
|
||||
allSelected: boolean,
|
||||
) => void;
|
||||
variablesToGetUpdated: string[];
|
||||
setVariablesToGetUpdated: React.Dispatch<React.SetStateAction<string[]>>;
|
||||
dependencyData: IDependencyData | null;
|
||||
}
|
||||
|
||||
function QueryVariableInput({
|
||||
variableData,
|
||||
@@ -52,15 +56,13 @@ function QueryVariableInput({
|
||||
onChange,
|
||||
onDropdownVisibleChange,
|
||||
handleClear,
|
||||
applyDefaultIfNeeded,
|
||||
} = useDashboardVariableSelectHelper({
|
||||
variableData,
|
||||
optionsData,
|
||||
onValueUpdate,
|
||||
strategy: queryVariableSelectStrategy,
|
||||
});
|
||||
|
||||
const validVariableUpdate = useCallback((): boolean => {
|
||||
const validVariableUpdate = (): boolean => {
|
||||
if (!variableData.name) {
|
||||
return false;
|
||||
}
|
||||
@@ -68,100 +70,86 @@ function QueryVariableInput({
|
||||
variablesToGetUpdated.length &&
|
||||
variablesToGetUpdated[0] === variableData.name,
|
||||
);
|
||||
}, [variableData.name, variablesToGetUpdated]);
|
||||
};
|
||||
|
||||
const getOptions = useCallback(
|
||||
// eslint-disable-next-line sonarjs/cognitive-complexity
|
||||
(variablesRes: VariableResponseProps | null): void => {
|
||||
try {
|
||||
setErrorMessage(null);
|
||||
// eslint-disable-next-line sonarjs/cognitive-complexity
|
||||
const getOptions = (variablesRes: VariableResponseProps | null): void => {
|
||||
try {
|
||||
setErrorMessage(null);
|
||||
|
||||
if (
|
||||
variablesRes?.variableValues &&
|
||||
Array.isArray(variablesRes?.variableValues)
|
||||
) {
|
||||
const newOptionsData = sortValues(
|
||||
variablesRes?.variableValues,
|
||||
variableData.sort,
|
||||
);
|
||||
if (
|
||||
variablesRes?.variableValues &&
|
||||
Array.isArray(variablesRes?.variableValues)
|
||||
) {
|
||||
const newOptionsData = sortValues(
|
||||
variablesRes?.variableValues,
|
||||
variableData.sort,
|
||||
);
|
||||
|
||||
const oldOptionsData = sortValues(optionsData, variableData.sort) as never;
|
||||
const oldOptionsData = sortValues(optionsData, variableData.sort) as never;
|
||||
|
||||
if (!areArraysEqual(newOptionsData, oldOptionsData)) {
|
||||
let valueNotInList = false;
|
||||
if (!areArraysEqual(newOptionsData, oldOptionsData)) {
|
||||
let valueNotInList = false;
|
||||
|
||||
if (isArray(variableData.selectedValue)) {
|
||||
variableData.selectedValue.forEach((val) => {
|
||||
if (!newOptionsData.includes(val)) {
|
||||
valueNotInList = true;
|
||||
}
|
||||
});
|
||||
} else if (
|
||||
isString(variableData.selectedValue) &&
|
||||
!newOptionsData.includes(variableData.selectedValue)
|
||||
) {
|
||||
valueNotInList = true;
|
||||
}
|
||||
if (isArray(variableData.selectedValue)) {
|
||||
variableData.selectedValue.forEach((val) => {
|
||||
if (!newOptionsData.includes(val)) {
|
||||
valueNotInList = true;
|
||||
}
|
||||
});
|
||||
} else if (
|
||||
isString(variableData.selectedValue) &&
|
||||
!newOptionsData.includes(variableData.selectedValue)
|
||||
) {
|
||||
valueNotInList = true;
|
||||
}
|
||||
|
||||
// variablesData.allSelected is added for the case where on change of options we need to update the
|
||||
// local storage
|
||||
// variablesData.allSelected is added for the case where on change of options we need to update the
|
||||
// local storage
|
||||
if (
|
||||
variableData.name &&
|
||||
(validVariableUpdate() || valueNotInList || variableData.allSelected)
|
||||
) {
|
||||
if (
|
||||
variableData.name &&
|
||||
(validVariableUpdate() || valueNotInList || variableData.allSelected)
|
||||
variableData.allSelected &&
|
||||
variableData.multiSelect &&
|
||||
variableData.showALLOption
|
||||
) {
|
||||
if (
|
||||
variableData.allSelected &&
|
||||
variableData.multiSelect &&
|
||||
variableData.showALLOption
|
||||
) {
|
||||
onValueUpdate(variableData.name, variableData.id, newOptionsData, true);
|
||||
onValueUpdate(variableData.name, variableData.id, newOptionsData, true);
|
||||
|
||||
// Update tempSelection to maintain ALL state when dropdown is open
|
||||
if (tempSelection !== undefined) {
|
||||
setTempSelection(newOptionsData.map((option) => option.toString()));
|
||||
}
|
||||
} else {
|
||||
const value = variableData.selectedValue;
|
||||
let allSelected = false;
|
||||
// Update tempSelection to maintain ALL state when dropdown is open
|
||||
if (tempSelection !== undefined) {
|
||||
setTempSelection(newOptionsData.map((option) => option.toString()));
|
||||
}
|
||||
} else {
|
||||
const value = variableData.selectedValue;
|
||||
let allSelected = false;
|
||||
|
||||
if (variableData.multiSelect) {
|
||||
const { selectedValue } = variableData;
|
||||
allSelected =
|
||||
newOptionsData.length > 0 &&
|
||||
Array.isArray(selectedValue) &&
|
||||
newOptionsData.every((option) => selectedValue.includes(option));
|
||||
}
|
||||
if (variableData.multiSelect) {
|
||||
const { selectedValue } = variableData;
|
||||
allSelected =
|
||||
newOptionsData.length > 0 &&
|
||||
Array.isArray(selectedValue) &&
|
||||
newOptionsData.every((option) => selectedValue.includes(option));
|
||||
}
|
||||
|
||||
if (variableData.name && variableData.id) {
|
||||
onValueUpdate(variableData.name, variableData.id, value, allSelected);
|
||||
}
|
||||
if (variableData.name && variableData.id) {
|
||||
onValueUpdate(variableData.name, variableData.id, value, allSelected);
|
||||
}
|
||||
}
|
||||
|
||||
setOptionsData(newOptionsData);
|
||||
// Apply default if no value is selected (e.g., new variable, first load)
|
||||
applyDefaultIfNeeded(newOptionsData);
|
||||
} else {
|
||||
setVariablesToGetUpdated((prev) =>
|
||||
prev.filter((name) => name !== variableData.name),
|
||||
);
|
||||
}
|
||||
|
||||
setOptionsData(newOptionsData);
|
||||
} else {
|
||||
setVariablesToGetUpdated((prev) =>
|
||||
prev.filter((name) => name !== variableData.name),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
},
|
||||
[
|
||||
variableData,
|
||||
optionsData,
|
||||
onValueUpdate,
|
||||
tempSelection,
|
||||
setTempSelection,
|
||||
validVariableUpdate,
|
||||
setVariablesToGetUpdated,
|
||||
applyDefaultIfNeeded,
|
||||
],
|
||||
);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
};
|
||||
|
||||
const { isLoading, refetch } = useQuery(
|
||||
[
|
||||
@@ -174,6 +162,7 @@ function QueryVariableInput({
|
||||
{
|
||||
enabled:
|
||||
variableData &&
|
||||
variableData.type === 'QUERY' &&
|
||||
checkAPIInvocation(
|
||||
variablesToGetUpdated,
|
||||
variableData,
|
||||
@@ -218,19 +207,10 @@ function QueryVariableInput({
|
||||
refetch();
|
||||
}, [refetch]);
|
||||
|
||||
const selectOptions = useMemo(
|
||||
() =>
|
||||
optionsData.map((option) => ({
|
||||
label: option.toString(),
|
||||
value: option.toString(),
|
||||
})),
|
||||
[optionsData],
|
||||
);
|
||||
|
||||
return (
|
||||
<SelectVariableInput
|
||||
variableId={variableData.id}
|
||||
options={selectOptions}
|
||||
options={optionsData}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
onDropdownVisibleChange={onDropdownVisibleChange}
|
||||
|
||||
@@ -3,7 +3,6 @@ import { orange } from '@ant-design/colors';
|
||||
import { WarningOutlined } from '@ant-design/icons';
|
||||
import { Popover, Tooltip, Typography } from 'antd';
|
||||
import { CustomMultiSelect, CustomSelect } from 'components/NewSelect';
|
||||
import { OptionData } from 'components/NewSelect/types';
|
||||
import { popupContainer } from 'utils/selectPopupContainer';
|
||||
|
||||
import { ALL_SELECT_VALUE } from '../utils';
|
||||
@@ -13,7 +12,7 @@ const errorIconStyle = { margin: '0 0.5rem' };
|
||||
|
||||
interface SelectVariableInputProps {
|
||||
variableId: string;
|
||||
options: OptionData[];
|
||||
options: (string | number | boolean)[];
|
||||
value: string | string[] | undefined;
|
||||
enableSelectAll: boolean;
|
||||
isMultiSelect: boolean;
|
||||
@@ -24,17 +23,13 @@ interface SelectVariableInputProps {
|
||||
loading?: boolean;
|
||||
errorMessage?: string | null;
|
||||
onRetry?: () => void;
|
||||
isDynamicVariable?: boolean;
|
||||
showRetryButton?: boolean;
|
||||
showIncompleteDataMessage?: boolean;
|
||||
onSearch?: (searchTerm: string) => void;
|
||||
}
|
||||
|
||||
const MAX_TAG_DISPLAY_VALUES = 10;
|
||||
|
||||
export const renderMaxTagPlaceholder = (
|
||||
function maxTagPlaceholder(
|
||||
omittedValues: { label?: React.ReactNode; value?: string | number }[],
|
||||
): JSX.Element => {
|
||||
): JSX.Element {
|
||||
const valuesToShow = omittedValues.slice(0, MAX_TAG_DISPLAY_VALUES);
|
||||
const hasMore = omittedValues.length > MAX_TAG_DISPLAY_VALUES;
|
||||
const tooltipText =
|
||||
@@ -46,7 +41,7 @@ export const renderMaxTagPlaceholder = (
|
||||
<span>+ {omittedValues.length} </span>
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
function SelectVariableInput({
|
||||
variableId,
|
||||
@@ -61,11 +56,16 @@ function SelectVariableInput({
|
||||
enableSelectAll,
|
||||
isMultiSelect,
|
||||
defaultValue,
|
||||
isDynamicVariable,
|
||||
showRetryButton,
|
||||
showIncompleteDataMessage,
|
||||
onSearch,
|
||||
}: SelectVariableInputProps): JSX.Element {
|
||||
const selectOptions = useMemo(
|
||||
() =>
|
||||
options.map((option) => ({
|
||||
label: option.toString(),
|
||||
value: option.toString(),
|
||||
})),
|
||||
[options],
|
||||
);
|
||||
|
||||
const commonProps = useMemo(
|
||||
() => ({
|
||||
// main props
|
||||
@@ -82,33 +82,23 @@ function SelectVariableInput({
|
||||
showSearch: true,
|
||||
bordered: false,
|
||||
|
||||
// changing props
|
||||
// dynamic props
|
||||
'data-testid': 'variable-select',
|
||||
onChange,
|
||||
loading,
|
||||
options,
|
||||
options: selectOptions,
|
||||
errorMessage,
|
||||
onRetry,
|
||||
|
||||
// dynamic variable only props
|
||||
isDynamicVariable,
|
||||
showRetryButton,
|
||||
showIncompleteDataMessage,
|
||||
onSearch,
|
||||
}),
|
||||
[
|
||||
variableId,
|
||||
defaultValue,
|
||||
onChange,
|
||||
loading,
|
||||
options,
|
||||
selectOptions,
|
||||
value,
|
||||
errorMessage,
|
||||
onRetry,
|
||||
isDynamicVariable,
|
||||
showRetryButton,
|
||||
showIncompleteDataMessage,
|
||||
onSearch,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -120,11 +110,11 @@ function SelectVariableInput({
|
||||
placement="bottomLeft"
|
||||
maxTagCount={2}
|
||||
onDropdownVisibleChange={onDropdownVisibleChange}
|
||||
maxTagPlaceholder={renderMaxTagPlaceholder}
|
||||
maxTagPlaceholder={maxTagPlaceholder}
|
||||
onClear={onClear}
|
||||
enableAllSelection={enableSelectAll}
|
||||
maxTagTextLength={30}
|
||||
allowClear={value !== ALL_SELECT_VALUE}
|
||||
allowClear={value !== ALL_SELECT_VALUE && value !== 'ALL'}
|
||||
/>
|
||||
) : (
|
||||
<CustomSelect {...commonProps} />
|
||||
|
||||
@@ -5,7 +5,6 @@ import { IDependencyData } from 'providers/Dashboard/store/dashboardVariables/da
|
||||
import { IDashboardVariable } from 'types/api/dashboard/getAll';
|
||||
|
||||
import CustomVariableInput from './CustomVariableInput';
|
||||
import DynamicVariableInput from './DynamicVariableInput';
|
||||
import QueryVariableInput from './QueryVariableInput';
|
||||
import TextboxVariableInput from './TextboxVariableInput';
|
||||
|
||||
@@ -17,9 +16,8 @@ export interface VariableItemProps {
|
||||
onValueUpdate: (
|
||||
name: string,
|
||||
id: string,
|
||||
value: IDashboardVariable['selectedValue'],
|
||||
arg1: IDashboardVariable['selectedValue'],
|
||||
allSelected: boolean,
|
||||
haveCustomValuesSelected?: boolean,
|
||||
) => void;
|
||||
variablesToGetUpdated: string[];
|
||||
setVariablesToGetUpdated: React.Dispatch<React.SetStateAction<string[]>>;
|
||||
@@ -70,13 +68,6 @@ function VariableItem({
|
||||
dependencyData={dependencyData}
|
||||
/>
|
||||
)}
|
||||
{variableType === 'DYNAMIC' && (
|
||||
<DynamicVariableInput
|
||||
variableData={variableData}
|
||||
onValueUpdate={onValueUpdate}
|
||||
existingVariables={existingVariables}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -5,7 +5,7 @@ import * as ReactRedux from 'react-redux';
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import { IDashboardVariable } from 'types/api/dashboard/getAll';
|
||||
|
||||
import DynamicVariableInput from '../DynamicVariableInput';
|
||||
import DynamicVariableSelection from '../DynamicVariableSelection';
|
||||
|
||||
// Don't mock the components - use real ones
|
||||
|
||||
@@ -54,7 +54,7 @@ const mockApiResponse = {
|
||||
// Mock scrollIntoView since it's not available in JSDOM
|
||||
window.HTMLElement.prototype.scrollIntoView = jest.fn();
|
||||
|
||||
describe('DynamicVariableInput Component', () => {
|
||||
describe('DynamicVariableSelection Component', () => {
|
||||
const mockOnValueUpdate = jest.fn();
|
||||
|
||||
const mockDynamicVariableData: IDashboardVariable = {
|
||||
@@ -108,13 +108,18 @@ describe('DynamicVariableInput Component', () => {
|
||||
|
||||
it('renders with single select variable correctly', () => {
|
||||
render(
|
||||
<DynamicVariableInput
|
||||
<DynamicVariableSelection
|
||||
variableData={mockDynamicVariableData}
|
||||
existingVariables={mockExistingVariables}
|
||||
onValueUpdate={mockOnValueUpdate}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Verify component renders correctly
|
||||
expect(
|
||||
screen.getByText(`$${mockDynamicVariableData.name}`),
|
||||
).toBeInTheDocument();
|
||||
|
||||
// Verify the selected value is displayed
|
||||
const selectedItem = screen.getByRole('combobox');
|
||||
expect(selectedItem).toBeInTheDocument();
|
||||
@@ -131,13 +136,18 @@ describe('DynamicVariableInput Component', () => {
|
||||
};
|
||||
|
||||
render(
|
||||
<DynamicVariableInput
|
||||
<DynamicVariableSelection
|
||||
variableData={multiSelectWithAllSelected}
|
||||
existingVariables={mockExistingVariables}
|
||||
onValueUpdate={mockOnValueUpdate}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Verify variable name is rendered
|
||||
expect(
|
||||
screen.getByText(`$${multiSelectWithAllSelected.name}`),
|
||||
).toBeInTheDocument();
|
||||
|
||||
// In ALL selected mode, there should be an "ALL" text element
|
||||
expect(screen.getByText('ALL')).toBeInTheDocument();
|
||||
});
|
||||
@@ -154,13 +164,18 @@ describe('DynamicVariableInput Component', () => {
|
||||
});
|
||||
|
||||
render(
|
||||
<DynamicVariableInput
|
||||
<DynamicVariableSelection
|
||||
variableData={mockDynamicVariableData}
|
||||
existingVariables={mockExistingVariables}
|
||||
onValueUpdate={mockOnValueUpdate}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Verify component renders in loading state
|
||||
expect(
|
||||
screen.getByText(`$${mockDynamicVariableData.name}`),
|
||||
).toBeInTheDocument();
|
||||
|
||||
// Open dropdown to see loading text
|
||||
const selectElement = screen.getByRole('combobox');
|
||||
fireEvent.mouseDown(selectElement);
|
||||
@@ -184,13 +199,18 @@ describe('DynamicVariableInput Component', () => {
|
||||
});
|
||||
|
||||
render(
|
||||
<DynamicVariableInput
|
||||
<DynamicVariableSelection
|
||||
variableData={mockDynamicVariableData}
|
||||
existingVariables={mockExistingVariables}
|
||||
onValueUpdate={mockOnValueUpdate}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Verify the component renders
|
||||
expect(
|
||||
screen.getByText(`$${mockDynamicVariableData.name}`),
|
||||
).toBeInTheDocument();
|
||||
|
||||
// For error states, we should check that error handling is in place
|
||||
// Without opening the dropdown as the error message might be handled differently
|
||||
expect(ReactQuery.useQuery).toHaveBeenCalled();
|
||||
@@ -199,7 +219,7 @@ describe('DynamicVariableInput Component', () => {
|
||||
|
||||
it('makes API call to fetch variable values', () => {
|
||||
render(
|
||||
<DynamicVariableInput
|
||||
<DynamicVariableSelection
|
||||
variableData={mockDynamicVariableData}
|
||||
existingVariables={mockExistingVariables}
|
||||
onValueUpdate={mockOnValueUpdate}
|
||||
@@ -215,8 +235,6 @@ describe('DynamicVariableInput Component', () => {
|
||||
'2023-01-01T00:00:00Z', // minTime from useSelector mock
|
||||
'2023-01-02T00:00:00Z', // maxTime from useSelector mock
|
||||
'',
|
||||
'Traces',
|
||||
'service.name',
|
||||
],
|
||||
expect.objectContaining({
|
||||
enabled: true, // Type is 'DYNAMIC'
|
||||
@@ -237,13 +255,16 @@ describe('DynamicVariableInput Component', () => {
|
||||
};
|
||||
|
||||
render(
|
||||
<DynamicVariableInput
|
||||
<DynamicVariableSelection
|
||||
variableData={customVariable}
|
||||
existingVariables={{ ...mockExistingVariables, custom1: customVariable }}
|
||||
onValueUpdate={mockOnValueUpdate}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Verify the component correctly displays the selected value
|
||||
expect(screen.getByText(`$${customVariable.name}`)).toBeInTheDocument();
|
||||
|
||||
// Find the selection item in the component using data-testid
|
||||
const selectElement = screen.getByTestId('variable-select');
|
||||
expect(selectElement).toBeInTheDocument();
|
||||
@@ -63,10 +63,10 @@ describe('VariableItem Default Value Selection Behavior', () => {
|
||||
expect(screen.getByTestId(VARIABLE_SELECT_TESTID)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(await screen.findByText('option1')).toBeInTheDocument();
|
||||
expect(screen.getByText('option1')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('should auto-select first option when no previous and no default', async () => {
|
||||
test('should show placeholder when no previous and no default', async () => {
|
||||
const variable: IDashboardVariable = {
|
||||
id: TEST_VARIABLE_ID,
|
||||
name: TEST_VARIABLE_NAME,
|
||||
@@ -85,8 +85,7 @@ describe('VariableItem Default Value Selection Behavior', () => {
|
||||
expect(screen.getByTestId(VARIABLE_SELECT_TESTID)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// With the new variable select strategy, the first option is auto-selected
|
||||
expect(await screen.findByText('option1')).toBeInTheDocument();
|
||||
expect(screen.getByText('Select value')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -111,7 +110,7 @@ describe('VariableItem Default Value Selection Behavior', () => {
|
||||
expect(screen.getByTestId(VARIABLE_SELECT_TESTID)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(await screen.findByText('ALL')).toBeInTheDocument();
|
||||
expect(screen.getByText('ALL')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -135,7 +134,7 @@ describe('VariableItem Default Value Selection Behavior', () => {
|
||||
expect(screen.getByTestId(VARIABLE_SELECT_TESTID)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(await screen.findByText('Select value')).toBeInTheDocument();
|
||||
expect(screen.getByText('Select value')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,14 +1,8 @@
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { isEmpty } from 'lodash-es';
|
||||
import { IDashboardVariable } from 'types/api/dashboard/getAll';
|
||||
|
||||
import { ALL_SELECT_VALUE } from '../utils';
|
||||
import { areArraysEqual, getSelectValue } from './util';
|
||||
import { VariableSelectStrategy } from './variableSelectStrategy/variableSelectStrategyTypes';
|
||||
import {
|
||||
areArraysEqualIgnoreOrder,
|
||||
uniqueValues,
|
||||
} from './variableSelectStrategy/variableSelectStrategyUtils';
|
||||
|
||||
interface UseDashboardVariableSelectHelperParams {
|
||||
variableData: IDashboardVariable;
|
||||
@@ -18,11 +12,7 @@ interface UseDashboardVariableSelectHelperParams {
|
||||
id: string,
|
||||
value: IDashboardVariable['selectedValue'],
|
||||
allSelected: boolean,
|
||||
haveCustomValuesSelected?: boolean,
|
||||
) => void;
|
||||
strategy: VariableSelectStrategy;
|
||||
/** Override for all available option strings (default: optionsData.map(String)) */
|
||||
allAvailableOptionStrings?: string[];
|
||||
}
|
||||
|
||||
interface UseDashboardVariableSelectHelperReturn {
|
||||
@@ -41,11 +31,6 @@ interface UseDashboardVariableSelectHelperReturn {
|
||||
onChange: (value: string | string[]) => void;
|
||||
onDropdownVisibleChange: (visible: boolean) => void;
|
||||
handleClear: () => void;
|
||||
|
||||
// Default value helpers
|
||||
applyDefaultIfNeeded: (
|
||||
overrideOptions?: (string | number | boolean)[],
|
||||
) => void;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line sonarjs/cognitive-complexity
|
||||
@@ -53,8 +38,6 @@ export function useDashboardVariableSelectHelper({
|
||||
variableData,
|
||||
optionsData,
|
||||
onValueUpdate,
|
||||
strategy,
|
||||
allAvailableOptionStrings,
|
||||
}: UseDashboardVariableSelectHelperParams): UseDashboardVariableSelectHelperReturn {
|
||||
const { selectedValue } = variableData;
|
||||
|
||||
@@ -69,37 +52,11 @@ export function useDashboardVariableSelectHelper({
|
||||
|
||||
const enableSelectAll = variableData.multiSelect && variableData.showALLOption;
|
||||
|
||||
const effectiveAllAvailableOptionStrings = useMemo(
|
||||
() => allAvailableOptionStrings ?? optionsData.map((v) => v.toString()),
|
||||
[allAvailableOptionStrings, optionsData],
|
||||
);
|
||||
|
||||
const selectValue =
|
||||
variableData.allSelected && enableSelectAll
|
||||
? ALL_SELECT_VALUE
|
||||
? 'ALL'
|
||||
: selectedValueStringified;
|
||||
|
||||
const getDefaultValue = useCallback(
|
||||
(overrideOptions?: (string | number | boolean)[]) => {
|
||||
const options = overrideOptions || optionsData;
|
||||
if (variableData.multiSelect) {
|
||||
if (variableData.showALLOption) {
|
||||
return variableData.defaultValue || options.map((o) => o.toString());
|
||||
}
|
||||
return variableData.defaultValue || options?.[0]?.toString();
|
||||
}
|
||||
return variableData.defaultValue || options[0]?.toString();
|
||||
},
|
||||
[
|
||||
variableData.multiSelect,
|
||||
variableData.showALLOption,
|
||||
variableData.defaultValue,
|
||||
optionsData,
|
||||
],
|
||||
);
|
||||
|
||||
const defaultValue = useMemo(() => getDefaultValue(), [getDefaultValue]);
|
||||
|
||||
const handleChange = useCallback(
|
||||
(inputValue: string | string[]): void => {
|
||||
const value = variableData.multiSelect && !inputValue ? [] : inputValue;
|
||||
@@ -112,21 +69,29 @@ export function useDashboardVariableSelectHelper({
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if (variableData.name) {
|
||||
// Check if ALL is effectively selected by comparing with available options
|
||||
const isAllSelected =
|
||||
Array.isArray(value) &&
|
||||
value.length > 0 &&
|
||||
optionsData.every((option) => value.includes(option.toString()));
|
||||
|
||||
strategy.handleChange({
|
||||
value,
|
||||
variableData,
|
||||
optionsData,
|
||||
allAvailableOptionStrings: effectiveAllAvailableOptionStrings,
|
||||
onValueUpdate,
|
||||
});
|
||||
if (isAllSelected && variableData.showALLOption) {
|
||||
// For ALL selection, pass optionsData as the value and set allSelected to true
|
||||
onValueUpdate(variableData.name, variableData.id, optionsData, true);
|
||||
} else {
|
||||
onValueUpdate(variableData.name, variableData.id, value, false);
|
||||
}
|
||||
}
|
||||
},
|
||||
[
|
||||
variableData,
|
||||
optionsData,
|
||||
effectiveAllAvailableOptionStrings,
|
||||
variableData.multiSelect,
|
||||
variableData.selectedValue,
|
||||
variableData.name,
|
||||
variableData.id,
|
||||
variableData.showALLOption,
|
||||
onValueUpdate,
|
||||
strategy,
|
||||
optionsData,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -134,96 +99,79 @@ export function useDashboardVariableSelectHelper({
|
||||
(inputValue: string | string[]): void => {
|
||||
// Store the selection in temporary state while dropdown is open
|
||||
const value = variableData.multiSelect && !inputValue ? [] : inputValue;
|
||||
setTempSelection(uniqueValues(value));
|
||||
setTempSelection(value);
|
||||
},
|
||||
[variableData.multiSelect],
|
||||
);
|
||||
|
||||
// Single select onChange: apply default if value is empty
|
||||
const handleSingleSelectChange = useCallback(
|
||||
(inputValue: string | string[]): void => {
|
||||
if (isEmpty(inputValue)) {
|
||||
if (defaultValue !== undefined) {
|
||||
handleChange(defaultValue as string | string[]);
|
||||
// Apply default value on first render if no selection exists
|
||||
const finalSelectedValues = useMemo(() => {
|
||||
if (variableData.multiSelect) {
|
||||
let value = tempSelection || selectedValue;
|
||||
if (isEmpty(value)) {
|
||||
if (variableData.showALLOption) {
|
||||
if (variableData.defaultValue) {
|
||||
value = variableData.defaultValue;
|
||||
} else {
|
||||
value = optionsData;
|
||||
}
|
||||
} else if (variableData.defaultValue) {
|
||||
value = variableData.defaultValue;
|
||||
} else {
|
||||
value = optionsData?.[0];
|
||||
}
|
||||
return;
|
||||
}
|
||||
handleChange(inputValue);
|
||||
},
|
||||
[handleChange, defaultValue],
|
||||
);
|
||||
|
||||
return value;
|
||||
}
|
||||
if (isEmpty(selectedValue)) {
|
||||
if (variableData.defaultValue) {
|
||||
return variableData.defaultValue;
|
||||
}
|
||||
return optionsData[0]?.toString();
|
||||
}
|
||||
|
||||
return selectedValue;
|
||||
}, [
|
||||
variableData.multiSelect,
|
||||
variableData.showALLOption,
|
||||
variableData.defaultValue,
|
||||
selectedValue,
|
||||
tempSelection,
|
||||
optionsData,
|
||||
]);
|
||||
|
||||
// Apply default values when needed
|
||||
useEffect(() => {
|
||||
if (
|
||||
(variableData.multiSelect && !(tempSelection || selectValue)) ||
|
||||
isEmpty(selectValue)
|
||||
) {
|
||||
handleChange(finalSelectedValues as string[] | string);
|
||||
}
|
||||
}, [
|
||||
finalSelectedValues,
|
||||
handleChange,
|
||||
selectValue,
|
||||
tempSelection,
|
||||
variableData.multiSelect,
|
||||
]);
|
||||
|
||||
// Handle dropdown visibility changes
|
||||
const onDropdownVisibleChange = useCallback(
|
||||
(visible: boolean): void => {
|
||||
// Initialize temp selection when opening dropdown
|
||||
if (visible) {
|
||||
if (variableData.allSelected && enableSelectAll) {
|
||||
// When ALL is selected, show all available options as individually checked
|
||||
setTempSelection([...effectiveAllAvailableOptionStrings]);
|
||||
} else {
|
||||
setTempSelection(getSelectValue(variableData.selectedValue, variableData));
|
||||
}
|
||||
setTempSelection(getSelectValue(variableData.selectedValue, variableData));
|
||||
}
|
||||
// Apply changes when closing dropdown
|
||||
else if (!visible && tempSelection !== undefined) {
|
||||
// If ALL was selected before AND all options remain selected, skip updating
|
||||
const wasAllSelected = enableSelectAll && variableData.allSelected;
|
||||
const isAllSelectedAfter =
|
||||
enableSelectAll &&
|
||||
Array.isArray(tempSelection) &&
|
||||
tempSelection.length === effectiveAllAvailableOptionStrings.length &&
|
||||
effectiveAllAvailableOptionStrings.every((v) => tempSelection.includes(v));
|
||||
|
||||
if (wasAllSelected && isAllSelectedAfter) {
|
||||
setTempSelection(undefined);
|
||||
return;
|
||||
}
|
||||
|
||||
// Apply default if closing with empty selection
|
||||
let valueToApply = tempSelection;
|
||||
if (isEmpty(tempSelection) && defaultValue !== undefined) {
|
||||
valueToApply = defaultValue as string | string[];
|
||||
}
|
||||
|
||||
// Order-agnostic change detection
|
||||
const currentValue = variableData.selectedValue;
|
||||
const hasChanged =
|
||||
valueToApply !== currentValue &&
|
||||
!(
|
||||
Array.isArray(valueToApply) &&
|
||||
Array.isArray(currentValue) &&
|
||||
areArraysEqualIgnoreOrder(valueToApply, currentValue)
|
||||
);
|
||||
|
||||
if (hasChanged) {
|
||||
handleChange(valueToApply);
|
||||
}
|
||||
// Call handleChange with the temporarily stored selection
|
||||
handleChange(tempSelection);
|
||||
setTempSelection(undefined);
|
||||
}
|
||||
},
|
||||
[
|
||||
variableData,
|
||||
enableSelectAll,
|
||||
effectiveAllAvailableOptionStrings,
|
||||
tempSelection,
|
||||
handleChange,
|
||||
defaultValue,
|
||||
],
|
||||
);
|
||||
|
||||
// Explicit function for callers to apply default on mount / data load
|
||||
// Pass overrideOptions when freshly-loaded options aren't in state yet (async callers)
|
||||
const applyDefaultIfNeeded = useCallback(
|
||||
(overrideOptions?: (string | number | boolean)[]): void => {
|
||||
if (isEmpty(selectValue)) {
|
||||
const defaultValueFromOptions = getDefaultValue(overrideOptions);
|
||||
if (defaultValueFromOptions !== undefined) {
|
||||
handleChange(defaultValueFromOptions as string | string[]);
|
||||
}
|
||||
}
|
||||
},
|
||||
[selectValue, handleChange, getDefaultValue],
|
||||
[variableData, tempSelection, handleChange],
|
||||
);
|
||||
|
||||
const handleClear = useCallback((): void => {
|
||||
@@ -234,9 +182,11 @@ export function useDashboardVariableSelectHelper({
|
||||
? tempSelection || selectValue
|
||||
: selectValue;
|
||||
|
||||
const defaultValue = variableData.defaultValue || selectValue;
|
||||
|
||||
const onChange = useMemo(() => {
|
||||
return variableData.multiSelect ? handleTempChange : handleSingleSelectChange;
|
||||
}, [variableData.multiSelect, handleTempChange, handleSingleSelectChange]);
|
||||
return variableData.multiSelect ? handleTempChange : handleChange;
|
||||
}, [variableData.multiSelect, handleTempChange, handleChange]);
|
||||
|
||||
return {
|
||||
tempSelection,
|
||||
@@ -247,6 +197,5 @@ export function useDashboardVariableSelectHelper({
|
||||
value,
|
||||
defaultValue,
|
||||
onChange,
|
||||
applyDefaultIfNeeded,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -363,6 +363,25 @@ export const uniqueOptions = (options: OptionData[]): OptionData[] => {
|
||||
return uniqueOptions;
|
||||
};
|
||||
|
||||
export const uniqueValues = (values: string[] | string): string[] | string => {
|
||||
if (Array.isArray(values)) {
|
||||
const uniqueValues: string[] = [];
|
||||
const seenValues = new Set<string>();
|
||||
|
||||
values.forEach((value) => {
|
||||
if (seenValues.has(value)) {
|
||||
return;
|
||||
}
|
||||
seenValues.add(value);
|
||||
uniqueValues.push(value);
|
||||
});
|
||||
|
||||
return uniqueValues;
|
||||
}
|
||||
|
||||
return values;
|
||||
};
|
||||
|
||||
export const getSelectValue = (
|
||||
selectedValue: IDashboardVariable['selectedValue'],
|
||||
variableData: IDashboardVariable,
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
import { defaultVariableSelectStrategy } from './defaultVariableSelectStrategy';
|
||||
import { VariableSelectStrategy } from './variableSelectStrategyTypes';
|
||||
|
||||
export const customVariableSelectStrategy: VariableSelectStrategy = defaultVariableSelectStrategy;
|
||||
@@ -1,20 +0,0 @@
|
||||
import { VariableSelectStrategy } from './variableSelectStrategyTypes';
|
||||
|
||||
export const defaultVariableSelectStrategy: VariableSelectStrategy = {
|
||||
handleChange({ value, variableData, optionsData, onValueUpdate }) {
|
||||
if (!variableData.name) {
|
||||
return;
|
||||
}
|
||||
|
||||
const isAllSelected =
|
||||
Array.isArray(value) &&
|
||||
value.length > 0 &&
|
||||
optionsData.every((option) => value.includes(option.toString()));
|
||||
|
||||
if (isAllSelected && variableData.showALLOption) {
|
||||
onValueUpdate(variableData.name, variableData.id, optionsData, true);
|
||||
} else {
|
||||
onValueUpdate(variableData.name, variableData.id, value, false);
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -1,37 +0,0 @@
|
||||
import { ALL_SELECT_VALUE } from 'container/DashboardContainer/utils';
|
||||
|
||||
import { VariableSelectStrategy } from './variableSelectStrategyTypes';
|
||||
|
||||
export const dynamicVariableSelectStrategy: VariableSelectStrategy = {
|
||||
handleChange({
|
||||
value,
|
||||
variableData,
|
||||
allAvailableOptionStrings,
|
||||
onValueUpdate,
|
||||
}) {
|
||||
if (!variableData.name) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
value === ALL_SELECT_VALUE ||
|
||||
(Array.isArray(value) && value.includes(ALL_SELECT_VALUE))
|
||||
) {
|
||||
onValueUpdate(variableData.name, variableData.id, null, true);
|
||||
} else {
|
||||
// For ALL selection in dynamic variables, pass null to avoid storing values
|
||||
// The parent component will handle this appropriately
|
||||
const haveCustomValuesSelected =
|
||||
Array.isArray(value) &&
|
||||
!value.every((v) => allAvailableOptionStrings.includes(v.toString()));
|
||||
|
||||
onValueUpdate(
|
||||
variableData.name,
|
||||
variableData.id,
|
||||
value,
|
||||
allAvailableOptionStrings.every((v) => value.includes(v.toString())),
|
||||
haveCustomValuesSelected,
|
||||
);
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -1,4 +0,0 @@
|
||||
import { defaultVariableSelectStrategy } from './defaultVariableSelectStrategy';
|
||||
import { VariableSelectStrategy } from './variableSelectStrategyTypes';
|
||||
|
||||
export const queryVariableSelectStrategy: VariableSelectStrategy = defaultVariableSelectStrategy;
|
||||
@@ -1,17 +0,0 @@
|
||||
import { IDashboardVariable } from 'types/api/dashboard/getAll';
|
||||
|
||||
export interface VariableSelectStrategy {
|
||||
handleChange(params: {
|
||||
value: string | string[];
|
||||
variableData: IDashboardVariable;
|
||||
optionsData: (string | number | boolean)[];
|
||||
allAvailableOptionStrings: string[];
|
||||
onValueUpdate: (
|
||||
name: string,
|
||||
id: string,
|
||||
value: IDashboardVariable['selectedValue'],
|
||||
allSelected: boolean,
|
||||
haveCustomValuesSelected?: boolean,
|
||||
) => void;
|
||||
}): void;
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
import { isEqual } from 'lodash-es';
|
||||
|
||||
export const areArraysEqualIgnoreOrder = (
|
||||
a: (string | number | boolean)[],
|
||||
b: (string | number | boolean)[],
|
||||
): boolean => {
|
||||
if (a.length !== b.length) {
|
||||
return false;
|
||||
}
|
||||
const sortedA = [...a].sort();
|
||||
const sortedB = [...b].sort();
|
||||
return isEqual(sortedA, sortedB);
|
||||
};
|
||||
|
||||
export const uniqueValues = (values: string[] | string): string[] | string => {
|
||||
if (Array.isArray(values)) {
|
||||
const uniqueValues: string[] = [];
|
||||
const seenValues = new Set<string>();
|
||||
|
||||
values.forEach((value) => {
|
||||
if (seenValues.has(value)) {
|
||||
return;
|
||||
}
|
||||
seenValues.add(value);
|
||||
uniqueValues.push(value);
|
||||
});
|
||||
|
||||
return uniqueValues;
|
||||
}
|
||||
|
||||
return values;
|
||||
};
|
||||
@@ -2,7 +2,7 @@
|
||||
/* eslint-disable sonarjs/no-duplicate-string */
|
||||
/* eslint-disable react/jsx-props-no-spreading */
|
||||
import React from 'react';
|
||||
import { QueryClient, QueryClientProvider, useQuery } from 'react-query';
|
||||
import { QueryClient, QueryClientProvider } from 'react-query';
|
||||
import * as ReactRedux from 'react-redux';
|
||||
import {
|
||||
act,
|
||||
@@ -15,22 +15,13 @@ import {
|
||||
import { getFieldValues } from 'api/dynamicVariables/getFieldValues';
|
||||
import { IDashboardVariable } from 'types/api/dashboard/getAll';
|
||||
|
||||
import DynamicVariableInput from '../DashboardVariablesSelection/DynamicVariableInput';
|
||||
import DynamicVariableSelection from '../DashboardVariablesSelection/DynamicVariableSelection';
|
||||
|
||||
// Mock the getFieldValues API
|
||||
jest.mock('api/dynamicVariables/getFieldValues', () => ({
|
||||
getFieldValues: jest.fn(),
|
||||
}));
|
||||
|
||||
// Mock useQuery from react-query
|
||||
jest.mock('react-query', () => {
|
||||
const originalModule = jest.requireActual('react-query');
|
||||
return {
|
||||
...originalModule,
|
||||
useQuery: jest.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
describe('Dynamic Variable Default Behavior', () => {
|
||||
const mockOnValueUpdate = jest.fn();
|
||||
const mockApiResponse = {
|
||||
@@ -68,46 +59,6 @@ describe('Dynamic Variable Default Behavior', () => {
|
||||
// Mock getFieldValues API to return our test data
|
||||
(getFieldValues as jest.Mock).mockResolvedValue(mockApiResponse);
|
||||
|
||||
// Mock useQuery implementation to avoid infinite re-renders
|
||||
// and ensure onSuccess is called once
|
||||
(useQuery as jest.Mock).mockImplementation((key, options) => {
|
||||
const { onSuccess, enabled, queryFn } = options || {};
|
||||
const variableName = key[1];
|
||||
const dynamicVarsKey = key[2];
|
||||
|
||||
React.useEffect(() => {
|
||||
if (enabled !== false) {
|
||||
if (onSuccess) {
|
||||
// For 'services' tests:
|
||||
// 1. "Default to ALL" expectations imply empty options -> [] behavior. This happens when selectedValue is undefined (dynamicVarsKey has 'null').
|
||||
// 2. "ALL Option Special Value" needs full options to render the "ALL" item in dropdown. This happens when selectedValue is defined.
|
||||
if (
|
||||
variableName === 'services' &&
|
||||
typeof dynamicVarsKey === 'string' &&
|
||||
dynamicVarsKey.includes('null')
|
||||
) {
|
||||
onSuccess({
|
||||
...mockApiResponse,
|
||||
data: { ...mockApiResponse.data, normalizedValues: [] },
|
||||
});
|
||||
} else {
|
||||
onSuccess(mockApiResponse);
|
||||
}
|
||||
}
|
||||
if (queryFn) {
|
||||
queryFn();
|
||||
}
|
||||
}
|
||||
}, [enabled, variableName, dynamicVarsKey]); // Only depend on enabled/keys
|
||||
|
||||
return {
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
data: mockApiResponse,
|
||||
refetch: jest.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
jest.spyOn(ReactRedux, 'useSelector').mockReturnValue({
|
||||
minTime: '2023-01-01T00:00:00Z',
|
||||
maxTime: '2023-01-02T00:00:00Z',
|
||||
@@ -133,7 +84,7 @@ describe('Dynamic Variable Default Behavior', () => {
|
||||
|
||||
await act(async () => {
|
||||
renderWithQueryClient(
|
||||
<DynamicVariableInput
|
||||
<DynamicVariableSelection
|
||||
variableData={variableData}
|
||||
existingVariables={{ var1: variableData }}
|
||||
onValueUpdate={mockOnValueUpdate}
|
||||
@@ -169,7 +120,7 @@ describe('Dynamic Variable Default Behavior', () => {
|
||||
|
||||
await act(async () => {
|
||||
renderWithQueryClient(
|
||||
<DynamicVariableInput
|
||||
<DynamicVariableSelection
|
||||
variableData={variableData}
|
||||
existingVariables={{ var1: variableData }}
|
||||
onValueUpdate={mockOnValueUpdate}
|
||||
@@ -213,7 +164,7 @@ describe('Dynamic Variable Default Behavior', () => {
|
||||
|
||||
await act(async () => {
|
||||
renderWithQueryClient(
|
||||
<DynamicVariableInput
|
||||
<DynamicVariableSelection
|
||||
variableData={variableData}
|
||||
existingVariables={{ var1: variableData }}
|
||||
onValueUpdate={mockOnValueUpdate}
|
||||
@@ -221,6 +172,9 @@ describe('Dynamic Variable Default Behavior', () => {
|
||||
);
|
||||
});
|
||||
|
||||
// Component should render without errors
|
||||
expect(screen.getByText('$service')).toBeInTheDocument();
|
||||
|
||||
// Check if the dropdown is present
|
||||
const selectElement = screen.getByRole('combobox');
|
||||
expect(selectElement).toBeInTheDocument();
|
||||
@@ -278,7 +232,7 @@ describe('Dynamic Variable Default Behavior', () => {
|
||||
|
||||
await act(async () => {
|
||||
renderWithQueryClient(
|
||||
<DynamicVariableInput
|
||||
<DynamicVariableSelection
|
||||
variableData={variableData}
|
||||
existingVariables={{ var1: variableData }}
|
||||
onValueUpdate={mockOnValueUpdate}
|
||||
@@ -313,7 +267,7 @@ describe('Dynamic Variable Default Behavior', () => {
|
||||
|
||||
await act(async () => {
|
||||
renderWithQueryClient(
|
||||
<DynamicVariableInput
|
||||
<DynamicVariableSelection
|
||||
variableData={variableData}
|
||||
existingVariables={{ var1: variableData }}
|
||||
onValueUpdate={mockOnValueUpdate}
|
||||
@@ -339,7 +293,7 @@ describe('Dynamic Variable Default Behavior', () => {
|
||||
expect(screen.queryByText('backend')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('sahould default to ALL when no default and no previous selection', async () => {
|
||||
it('should default to ALL when no default and no previous selection', async () => {
|
||||
const variableData: IDashboardVariable = {
|
||||
id: 'var21',
|
||||
name: 'services',
|
||||
@@ -357,7 +311,7 @@ describe('Dynamic Variable Default Behavior', () => {
|
||||
|
||||
await act(async () => {
|
||||
renderWithQueryClient(
|
||||
<DynamicVariableInput
|
||||
<DynamicVariableSelection
|
||||
variableData={variableData}
|
||||
existingVariables={{ var1: variableData }}
|
||||
onValueUpdate={mockOnValueUpdate}
|
||||
@@ -391,7 +345,7 @@ describe('Dynamic Variable Default Behavior', () => {
|
||||
expect(mockOnValueUpdate).toHaveBeenCalledWith(
|
||||
'services',
|
||||
'var21',
|
||||
[],
|
||||
[], // Empty array when allSelected is true
|
||||
true, // allSelected = true
|
||||
false,
|
||||
);
|
||||
@@ -417,7 +371,7 @@ describe('Dynamic Variable Default Behavior', () => {
|
||||
|
||||
await act(async () => {
|
||||
renderWithQueryClient(
|
||||
<DynamicVariableInput
|
||||
<DynamicVariableSelection
|
||||
variableData={variableData}
|
||||
existingVariables={{ var1: variableData }}
|
||||
onValueUpdate={mockOnValueUpdate}
|
||||
@@ -454,7 +408,7 @@ describe('Dynamic Variable Default Behavior', () => {
|
||||
|
||||
await act(async () => {
|
||||
renderWithQueryClient(
|
||||
<DynamicVariableInput
|
||||
<DynamicVariableSelection
|
||||
variableData={variableData}
|
||||
existingVariables={{ var1: variableData }}
|
||||
onValueUpdate={mockOnValueUpdate}
|
||||
@@ -462,6 +416,9 @@ describe('Dynamic Variable Default Behavior', () => {
|
||||
);
|
||||
});
|
||||
|
||||
// Component should render without errors
|
||||
expect(screen.getByText('$services')).toBeInTheDocument();
|
||||
|
||||
// Check if ALL is displayed in the UI (in the main selection area)
|
||||
const allTextElement = screen.getByText('ALL');
|
||||
expect(allTextElement).toBeInTheDocument();
|
||||
|
||||
@@ -103,7 +103,7 @@ export const getTotalLogSizeWidgetData = (): Widgets =>
|
||||
queryName: 'A',
|
||||
reduceTo: ReduceOperators.SUM,
|
||||
spaceAggregation: 'sum',
|
||||
stepInterval: 60,
|
||||
stepInterval: null,
|
||||
timeAggregation: 'increase',
|
||||
},
|
||||
],
|
||||
@@ -140,7 +140,7 @@ export const getTotalTraceSizeWidgetData = (): Widgets =>
|
||||
queryName: 'A',
|
||||
reduceTo: ReduceOperators.SUM,
|
||||
spaceAggregation: 'sum',
|
||||
stepInterval: 60,
|
||||
stepInterval: null,
|
||||
timeAggregation: 'increase',
|
||||
},
|
||||
],
|
||||
@@ -177,7 +177,7 @@ export const getTotalMetricDatapointCountWidgetData = (): Widgets =>
|
||||
queryName: 'A',
|
||||
reduceTo: ReduceOperators.SUM,
|
||||
spaceAggregation: 'sum',
|
||||
stepInterval: 60,
|
||||
stepInterval: null,
|
||||
timeAggregation: 'increase',
|
||||
},
|
||||
],
|
||||
@@ -214,7 +214,7 @@ export const getLogCountWidgetData = (): Widgets =>
|
||||
queryName: 'A',
|
||||
reduceTo: ReduceOperators.AVG,
|
||||
spaceAggregation: 'sum',
|
||||
stepInterval: 60,
|
||||
stepInterval: null,
|
||||
timeAggregation: 'increase',
|
||||
},
|
||||
],
|
||||
@@ -251,7 +251,7 @@ export const getLogSizeWidgetData = (): Widgets =>
|
||||
queryName: 'A',
|
||||
reduceTo: ReduceOperators.AVG,
|
||||
spaceAggregation: 'sum',
|
||||
stepInterval: 60,
|
||||
stepInterval: null,
|
||||
timeAggregation: 'increase',
|
||||
},
|
||||
],
|
||||
@@ -288,7 +288,7 @@ export const getSpanCountWidgetData = (): Widgets =>
|
||||
queryName: 'A',
|
||||
reduceTo: ReduceOperators.AVG,
|
||||
spaceAggregation: 'sum',
|
||||
stepInterval: 60,
|
||||
stepInterval: null,
|
||||
timeAggregation: 'increase',
|
||||
},
|
||||
],
|
||||
@@ -325,7 +325,7 @@ export const getSpanSizeWidgetData = (): Widgets =>
|
||||
queryName: 'A',
|
||||
reduceTo: ReduceOperators.AVG,
|
||||
spaceAggregation: 'sum',
|
||||
stepInterval: 60,
|
||||
stepInterval: null,
|
||||
timeAggregation: 'increase',
|
||||
},
|
||||
],
|
||||
@@ -362,7 +362,7 @@ export const getMetricCountWidgetData = (): Widgets =>
|
||||
queryName: 'A',
|
||||
reduceTo: ReduceOperators.AVG,
|
||||
spaceAggregation: 'sum',
|
||||
stepInterval: 60,
|
||||
stepInterval: null,
|
||||
timeAggregation: 'increase',
|
||||
},
|
||||
],
|
||||
|
||||
@@ -1,412 +0,0 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { UPlotConfigBuilder } from 'lib/uPlotV2/config/UPlotConfigBuilder';
|
||||
import type { AlignedData } from 'uplot';
|
||||
|
||||
import { PlotContextProvider } from '../../context/PlotContext';
|
||||
import UPlotChart from '../UPlotChart';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mocks
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
jest.mock(
|
||||
'container/DashboardContainer/visualization/panels/utils/legendVisibilityUtils',
|
||||
() => ({
|
||||
getStoredSeriesVisibility: jest.fn(),
|
||||
updateSeriesVisibilityToLocalStorage: jest.fn(),
|
||||
}),
|
||||
);
|
||||
|
||||
jest.mock('@sentry/react', () => ({
|
||||
ErrorBoundary: ({ children }: { children: ReactNode }): JSX.Element => (
|
||||
<>{children}</>
|
||||
),
|
||||
}));
|
||||
|
||||
jest.mock('pages/ErrorBoundaryFallback/ErrorBoundaryFallback', () => ({
|
||||
__esModule: true,
|
||||
default: (): JSX.Element => <div>Error Fallback</div>,
|
||||
}));
|
||||
|
||||
interface MockUPlotInstance {
|
||||
root: HTMLDivElement;
|
||||
setData: jest.Mock;
|
||||
setSize: jest.Mock;
|
||||
destroy: jest.Mock;
|
||||
}
|
||||
|
||||
let instances: MockUPlotInstance[] = [];
|
||||
const mockUPlotConstructor = jest.fn();
|
||||
|
||||
jest.mock('uplot', () => {
|
||||
function MockUPlot(
|
||||
opts: Record<string, unknown>,
|
||||
data: unknown,
|
||||
target: HTMLElement,
|
||||
): MockUPlotInstance {
|
||||
mockUPlotConstructor(opts, data, target);
|
||||
|
||||
const rootEl = document.createElement('div');
|
||||
target.appendChild(rootEl);
|
||||
|
||||
const inst: MockUPlotInstance = {
|
||||
root: rootEl,
|
||||
setData: jest.fn(),
|
||||
setSize: jest.fn(),
|
||||
destroy: jest.fn(),
|
||||
};
|
||||
instances.push(inst);
|
||||
return inst;
|
||||
}
|
||||
|
||||
MockUPlot.paths = {
|
||||
spline: jest.fn(() => jest.fn()),
|
||||
bars: jest.fn(() => jest.fn()),
|
||||
linear: jest.fn(() => jest.fn()),
|
||||
stepped: jest.fn(() => jest.fn()),
|
||||
};
|
||||
MockUPlot.tzDate = jest.fn();
|
||||
|
||||
return { __esModule: true, default: MockUPlot };
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const createMockConfig = (): UPlotConfigBuilder => {
|
||||
return ({
|
||||
getConfig: jest.fn().mockReturnValue({
|
||||
series: [{ value: (): string => '' }],
|
||||
axes: [],
|
||||
scales: {},
|
||||
hooks: {},
|
||||
cursor: {},
|
||||
}),
|
||||
getWidgetId: jest.fn().mockReturnValue(undefined),
|
||||
getShouldSaveSelectionPreference: jest.fn().mockReturnValue(false),
|
||||
} as unknown) as UPlotConfigBuilder;
|
||||
};
|
||||
|
||||
const validData: AlignedData = [
|
||||
[1, 2, 3],
|
||||
[10, 20, 30],
|
||||
];
|
||||
const emptyData: AlignedData = [[]];
|
||||
|
||||
const Wrapper = ({ children }: { children: ReactNode }): JSX.Element => (
|
||||
<PlotContextProvider>{children}</PlotContextProvider>
|
||||
);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('UPlotChart', () => {
|
||||
beforeEach(() => {
|
||||
instances = [];
|
||||
mockUPlotConstructor.mockClear();
|
||||
});
|
||||
|
||||
describe('when data is empty', () => {
|
||||
it('displays "No Data" message instead of the chart container', () => {
|
||||
render(
|
||||
<UPlotChart
|
||||
config={createMockConfig()}
|
||||
data={emptyData}
|
||||
width={600}
|
||||
height={400}
|
||||
/>,
|
||||
{ wrapper: Wrapper },
|
||||
);
|
||||
|
||||
expect(screen.getByText('No Data')).toBeInTheDocument();
|
||||
expect(screen.queryByTestId('uplot-main-div')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('sizes the empty-state container to the given width and height', () => {
|
||||
render(
|
||||
<UPlotChart
|
||||
config={createMockConfig()}
|
||||
data={emptyData}
|
||||
width={750}
|
||||
height={350}
|
||||
/>,
|
||||
{ wrapper: Wrapper },
|
||||
);
|
||||
|
||||
const noDataContainer = screen
|
||||
.getByText('No Data')
|
||||
.closest('.uplot-no-data');
|
||||
expect(noDataContainer).toHaveStyle({ width: '750px', height: '350px' });
|
||||
});
|
||||
|
||||
it('does not create a uPlot instance', () => {
|
||||
render(
|
||||
<UPlotChart
|
||||
config={createMockConfig()}
|
||||
data={emptyData}
|
||||
width={600}
|
||||
height={400}
|
||||
/>,
|
||||
{ wrapper: Wrapper },
|
||||
);
|
||||
|
||||
expect(mockUPlotConstructor).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('chart container', () => {
|
||||
it('renders children inside the chart wrapper', () => {
|
||||
render(
|
||||
<UPlotChart
|
||||
config={createMockConfig()}
|
||||
data={validData}
|
||||
width={600}
|
||||
height={400}
|
||||
>
|
||||
<div data-testid="tooltip-plugin">Tooltip</div>
|
||||
</UPlotChart>,
|
||||
{ wrapper: Wrapper },
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('tooltip-plugin')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('plot creation', () => {
|
||||
it('instantiates uPlot with floored dimensions and the container element', () => {
|
||||
render(
|
||||
<UPlotChart
|
||||
config={createMockConfig()}
|
||||
data={validData}
|
||||
width={600.9}
|
||||
height={400.2}
|
||||
/>,
|
||||
{ wrapper: Wrapper },
|
||||
);
|
||||
|
||||
expect(mockUPlotConstructor).toHaveBeenCalledTimes(1);
|
||||
|
||||
const [opts, data, target] = mockUPlotConstructor.mock.calls[0];
|
||||
expect(opts.width).toBe(600);
|
||||
expect(opts.height).toBe(400);
|
||||
expect(data).toBe(validData);
|
||||
expect(target).toBe(screen.getByTestId('uplot-main-div'));
|
||||
});
|
||||
|
||||
it('merges config builder output into the uPlot options', () => {
|
||||
const config = createMockConfig();
|
||||
config.getConfig = jest.fn().mockReturnValue({
|
||||
series: [{ value: (): string => '' }],
|
||||
axes: [{ scale: 'y' }],
|
||||
scales: { y: {} },
|
||||
hooks: {},
|
||||
cursor: { show: true },
|
||||
});
|
||||
|
||||
render(
|
||||
<UPlotChart
|
||||
config={(config as unknown) as UPlotConfigBuilder}
|
||||
data={validData}
|
||||
width={500}
|
||||
height={300}
|
||||
/>,
|
||||
{ wrapper: Wrapper },
|
||||
);
|
||||
|
||||
const [opts] = mockUPlotConstructor.mock.calls[0];
|
||||
expect(opts.width).toBe(500);
|
||||
expect(opts.height).toBe(300);
|
||||
expect(opts.axes).toEqual([{ scale: 'y' }]);
|
||||
expect(opts.cursor).toEqual({ show: true });
|
||||
});
|
||||
|
||||
it('skips creation when width or height is 0', () => {
|
||||
render(
|
||||
<UPlotChart
|
||||
config={createMockConfig()}
|
||||
data={validData}
|
||||
width={0}
|
||||
height={0}
|
||||
/>,
|
||||
{ wrapper: Wrapper },
|
||||
);
|
||||
|
||||
expect(mockUPlotConstructor).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('lifecycle callbacks', () => {
|
||||
it('invokes plotRef with the uPlot instance after creation', () => {
|
||||
const plotRef = jest.fn();
|
||||
|
||||
render(
|
||||
<UPlotChart
|
||||
config={createMockConfig()}
|
||||
data={validData}
|
||||
width={600}
|
||||
height={400}
|
||||
plotRef={plotRef}
|
||||
/>,
|
||||
{ wrapper: Wrapper },
|
||||
);
|
||||
|
||||
expect(plotRef).toHaveBeenCalledTimes(1);
|
||||
expect(plotRef).toHaveBeenCalledWith(instances[0]);
|
||||
});
|
||||
|
||||
it('destroys the instance and notifies callbacks when data becomes empty', () => {
|
||||
const plotRef = jest.fn();
|
||||
const onDestroy = jest.fn();
|
||||
const config = createMockConfig();
|
||||
|
||||
const { rerender } = render(
|
||||
<UPlotChart
|
||||
config={config}
|
||||
data={validData}
|
||||
width={600}
|
||||
height={400}
|
||||
plotRef={plotRef}
|
||||
onDestroy={onDestroy}
|
||||
/>,
|
||||
{ wrapper: Wrapper },
|
||||
);
|
||||
|
||||
const firstInstance = instances[0];
|
||||
plotRef.mockClear();
|
||||
|
||||
rerender(
|
||||
<UPlotChart
|
||||
config={config}
|
||||
data={emptyData}
|
||||
width={600}
|
||||
height={400}
|
||||
plotRef={plotRef}
|
||||
onDestroy={onDestroy}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(onDestroy).toHaveBeenCalledWith(firstInstance);
|
||||
expect(firstInstance.destroy).toHaveBeenCalled();
|
||||
expect(plotRef).toHaveBeenCalledWith(null);
|
||||
expect(screen.getByText('No Data')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('destroys the previous instance before creating a new one on config change', () => {
|
||||
const onDestroy = jest.fn();
|
||||
const config1 = createMockConfig();
|
||||
const config2 = createMockConfig();
|
||||
|
||||
const { rerender } = render(
|
||||
<UPlotChart
|
||||
config={config1}
|
||||
data={validData}
|
||||
width={600}
|
||||
height={400}
|
||||
onDestroy={onDestroy}
|
||||
/>,
|
||||
{ wrapper: Wrapper },
|
||||
);
|
||||
|
||||
const firstInstance = instances[0];
|
||||
|
||||
rerender(
|
||||
<UPlotChart
|
||||
config={config2}
|
||||
data={validData}
|
||||
width={600}
|
||||
height={400}
|
||||
onDestroy={onDestroy}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(onDestroy).toHaveBeenCalledWith(firstInstance);
|
||||
expect(firstInstance.destroy).toHaveBeenCalled();
|
||||
expect(instances).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('prop updates', () => {
|
||||
it('calls setData without recreating the plot when only data changes', () => {
|
||||
const config = createMockConfig();
|
||||
const newData: AlignedData = [
|
||||
[4, 5, 6],
|
||||
[40, 50, 60],
|
||||
];
|
||||
|
||||
const { rerender } = render(
|
||||
<UPlotChart config={config} data={validData} width={600} height={400} />,
|
||||
{ wrapper: Wrapper },
|
||||
);
|
||||
|
||||
const inst = instances[0];
|
||||
|
||||
rerender(
|
||||
<UPlotChart config={config} data={newData} width={600} height={400} />,
|
||||
);
|
||||
|
||||
expect(inst.setData).toHaveBeenCalledWith(newData);
|
||||
expect(mockUPlotConstructor).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('calls setSize with floored values when only dimensions change', () => {
|
||||
const config = createMockConfig();
|
||||
|
||||
const { rerender } = render(
|
||||
<UPlotChart config={config} data={validData} width={600} height={400} />,
|
||||
{ wrapper: Wrapper },
|
||||
);
|
||||
|
||||
const instance = instances[0];
|
||||
|
||||
rerender(
|
||||
<UPlotChart
|
||||
config={config}
|
||||
data={validData}
|
||||
width={800.7}
|
||||
height={500.3}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(instance.setSize).toHaveBeenCalledWith({ width: 800, height: 500 });
|
||||
expect(mockUPlotConstructor).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('recreates the plot when config changes', () => {
|
||||
const config1 = createMockConfig();
|
||||
const config2 = createMockConfig();
|
||||
|
||||
const { rerender } = render(
|
||||
<UPlotChart config={config1} data={validData} width={600} height={400} />,
|
||||
{ wrapper: Wrapper },
|
||||
);
|
||||
|
||||
rerender(
|
||||
<UPlotChart config={config2} data={validData} width={600} height={400} />,
|
||||
);
|
||||
|
||||
expect(mockUPlotConstructor).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('does nothing when all props remain the same', () => {
|
||||
const config = createMockConfig();
|
||||
|
||||
const { rerender } = render(
|
||||
<UPlotChart config={config} data={validData} width={600} height={400} />,
|
||||
{ wrapper: Wrapper },
|
||||
);
|
||||
|
||||
const instance = instances[0];
|
||||
|
||||
rerender(
|
||||
<UPlotChart config={config} data={validData} width={600} height={400} />,
|
||||
);
|
||||
|
||||
expect(mockUPlotConstructor).toHaveBeenCalledTimes(1);
|
||||
expect(instance.setData).not.toHaveBeenCalled();
|
||||
expect(instance.setSize).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -4,7 +4,6 @@ import uPlot, { Axis } from 'uplot';
|
||||
|
||||
import { uPlotXAxisValuesFormat } from '../../uPlotLib/utils/constants';
|
||||
import getGridColor from '../../uPlotLib/utils/getGridColor';
|
||||
import { buildYAxisSizeCalculator } from '../utils/axis';
|
||||
import { AxisProps, ConfigBuilder } from './types';
|
||||
|
||||
const PANEL_TYPES_WITH_X_AXIS_DATETIME_FORMAT = [
|
||||
@@ -115,6 +114,81 @@ export class UPlotAxisBuilder extends ConfigBuilder<AxisProps, Axis> {
|
||||
: undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate axis size from existing size property
|
||||
*/
|
||||
private getExistingAxisSize(
|
||||
self: uPlot,
|
||||
axis: Axis,
|
||||
values: string[] | undefined,
|
||||
axisIdx: number,
|
||||
cycleNum: number,
|
||||
): number {
|
||||
const internalSize = (axis as { _size?: number })._size;
|
||||
if (internalSize !== undefined) {
|
||||
return internalSize;
|
||||
}
|
||||
|
||||
const existingSize = axis.size;
|
||||
if (typeof existingSize === 'function') {
|
||||
return existingSize(self, values ?? [], axisIdx, cycleNum);
|
||||
}
|
||||
|
||||
return existingSize ?? 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate text width for longest value
|
||||
*/
|
||||
private calculateTextWidth(
|
||||
self: uPlot,
|
||||
axis: Axis,
|
||||
values: string[] | undefined,
|
||||
): number {
|
||||
if (!values || values.length === 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Find longest value
|
||||
const longestVal = values.reduce(
|
||||
(acc, val) => (val.length > acc.length ? val : acc),
|
||||
'',
|
||||
);
|
||||
|
||||
if (longestVal === '' || !axis.font?.[0]) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line prefer-destructuring, no-param-reassign
|
||||
self.ctx.font = axis.font[0];
|
||||
return self.ctx.measureText(longestVal).width / devicePixelRatio;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build Y-axis dynamic size calculator
|
||||
*/
|
||||
private buildYAxisSizeCalculator(): uPlot.Axis.Size {
|
||||
return (
|
||||
self: uPlot,
|
||||
values: string[] | undefined,
|
||||
axisIdx: number,
|
||||
cycleNum: number,
|
||||
): number => {
|
||||
const axis = self.axes[axisIdx];
|
||||
|
||||
// Bail out, force convergence
|
||||
if (cycleNum > 1) {
|
||||
return this.getExistingAxisSize(self, axis, values, axisIdx, cycleNum);
|
||||
}
|
||||
|
||||
const gap = this.props.gap ?? 5;
|
||||
let axisSize = (axis.ticks?.size ?? 0) + gap;
|
||||
axisSize += this.calculateTextWidth(self, axis, values);
|
||||
|
||||
return Math.ceil(axisSize);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Build dynamic size calculator for Y-axis
|
||||
*/
|
||||
@@ -128,7 +202,7 @@ export class UPlotAxisBuilder extends ConfigBuilder<AxisProps, Axis> {
|
||||
|
||||
// Y-axis needs dynamic sizing based on text width
|
||||
if (scaleKey === 'y') {
|
||||
return buildYAxisSizeCalculator(this.props.gap ?? 5);
|
||||
return this.buildYAxisSizeCalculator();
|
||||
}
|
||||
|
||||
return undefined;
|
||||
|
||||
@@ -15,33 +15,7 @@ import {
|
||||
* Builder for uPlot series configuration
|
||||
* Handles creation of series settings
|
||||
*/
|
||||
|
||||
/**
|
||||
* Path builders are static and shared across all instances of UPlotSeriesBuilder
|
||||
*/
|
||||
let builders: PathBuilders | null = null;
|
||||
export class UPlotSeriesBuilder extends ConfigBuilder<SeriesProps, Series> {
|
||||
constructor(props: SeriesProps) {
|
||||
super(props);
|
||||
const pathBuilders = uPlot.paths;
|
||||
|
||||
if (!builders) {
|
||||
const linearBuilder = pathBuilders.linear;
|
||||
const splineBuilder = pathBuilders.spline;
|
||||
const steppedBuilder = pathBuilders.stepped;
|
||||
|
||||
if (!linearBuilder || !splineBuilder || !steppedBuilder) {
|
||||
throw new Error('Required uPlot path builders are not available');
|
||||
}
|
||||
builders = {
|
||||
linear: linearBuilder(),
|
||||
spline: splineBuilder(),
|
||||
stepBefore: steppedBuilder({ align: -1 }),
|
||||
stepAfter: steppedBuilder({ align: 1 }),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private buildLineConfig({
|
||||
lineColor,
|
||||
lineWidth,
|
||||
@@ -224,6 +198,8 @@ interface PathBuilders {
|
||||
[key: string]: Series.PathBuilder;
|
||||
}
|
||||
|
||||
let builders: PathBuilders | null = null;
|
||||
|
||||
/**
|
||||
* Get path builder based on draw style and interpolation
|
||||
*/
|
||||
@@ -231,8 +207,23 @@ function getPathBuilder(
|
||||
style: DrawStyle,
|
||||
lineInterpolation?: LineInterpolation,
|
||||
): Series.PathBuilder {
|
||||
const pathBuilders = uPlot.paths;
|
||||
|
||||
if (!builders) {
|
||||
throw new Error('Required uPlot path builders are not available');
|
||||
const linearBuilder = pathBuilders.linear;
|
||||
const splineBuilder = pathBuilders.spline;
|
||||
const steppedBuilder = pathBuilders.stepped;
|
||||
|
||||
if (!linearBuilder || !splineBuilder || !steppedBuilder) {
|
||||
throw new Error('Required uPlot path builders are not available');
|
||||
}
|
||||
|
||||
builders = {
|
||||
linear: linearBuilder(),
|
||||
spline: splineBuilder(),
|
||||
stepBefore: steppedBuilder({ align: -1 }),
|
||||
stepAfter: steppedBuilder({ align: 1 }),
|
||||
};
|
||||
}
|
||||
|
||||
if (style === DrawStyle.Line) {
|
||||
|
||||
@@ -1,346 +0,0 @@
|
||||
import { getToolTipValue } from 'components/Graph/yAxisConfig';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { uPlotXAxisValuesFormat } from 'lib/uPlotLib/utils/constants';
|
||||
import type uPlot from 'uplot';
|
||||
|
||||
import type { AxisProps } from '../types';
|
||||
import { UPlotAxisBuilder } from '../UPlotAxisBuilder';
|
||||
|
||||
jest.mock('components/Graph/yAxisConfig', () => ({
|
||||
getToolTipValue: jest.fn(),
|
||||
}));
|
||||
|
||||
const createAxisProps = (overrides: Partial<AxisProps> = {}): AxisProps => ({
|
||||
scaleKey: 'x',
|
||||
label: 'Time',
|
||||
isDarkMode: false,
|
||||
show: true,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe('UPlotAxisBuilder', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('builds basic axis config with defaults', () => {
|
||||
const builder = new UPlotAxisBuilder(
|
||||
createAxisProps({
|
||||
scaleKey: 'x',
|
||||
label: 'Time',
|
||||
}),
|
||||
);
|
||||
|
||||
const config = builder.getConfig();
|
||||
|
||||
expect(config.scale).toBe('x');
|
||||
expect(config.label).toBe('Time');
|
||||
expect(config.show).toBe(true);
|
||||
expect(config.side).toBe(2);
|
||||
expect(config.gap).toBe(5);
|
||||
|
||||
// Default grid and ticks are created
|
||||
expect(config.grid).toEqual({
|
||||
stroke: 'rgba(0,0,0,0.5)',
|
||||
width: 0.2,
|
||||
show: true,
|
||||
});
|
||||
expect(config.ticks).toEqual({
|
||||
width: 0.3,
|
||||
show: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('merges custom grid config over defaults and respects isDarkMode and isLogScale', () => {
|
||||
const builder = new UPlotAxisBuilder(
|
||||
createAxisProps({
|
||||
isDarkMode: true,
|
||||
isLogScale: true,
|
||||
grid: {
|
||||
width: 1,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const config = builder.getConfig();
|
||||
|
||||
expect(config.grid).toEqual({
|
||||
// stroke falls back to theme-based default when not provided
|
||||
stroke: 'rgba(231,233,237,0.3)',
|
||||
// provided width overrides default
|
||||
width: 1,
|
||||
// show falls back to default when not provided
|
||||
show: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('uses provided ticks config when present and falls back to defaults otherwise', () => {
|
||||
const customTicks = { width: 1, show: false };
|
||||
const withTicks = new UPlotAxisBuilder(
|
||||
createAxisProps({
|
||||
ticks: customTicks,
|
||||
}),
|
||||
);
|
||||
const withoutTicks = new UPlotAxisBuilder(createAxisProps());
|
||||
|
||||
expect(withTicks.getConfig().ticks).toBe(customTicks);
|
||||
expect(withoutTicks.getConfig().ticks).toEqual({
|
||||
width: 0.3,
|
||||
show: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('uses time-based X-axis values formatter for time-series like panels', () => {
|
||||
const builder = new UPlotAxisBuilder(
|
||||
createAxisProps({
|
||||
scaleKey: 'x',
|
||||
panelType: PANEL_TYPES.TIME_SERIES,
|
||||
}),
|
||||
);
|
||||
|
||||
const config = builder.getConfig();
|
||||
|
||||
expect(config.values).toBe(uPlotXAxisValuesFormat);
|
||||
});
|
||||
|
||||
it('does not attach X-axis datetime formatter when panel type is not supported', () => {
|
||||
const builder = new UPlotAxisBuilder(
|
||||
createAxisProps({
|
||||
scaleKey: 'x',
|
||||
panelType: PANEL_TYPES.LIST, // not in PANEL_TYPES_WITH_X_AXIS_DATETIME_FORMAT
|
||||
}),
|
||||
);
|
||||
|
||||
const config = builder.getConfig();
|
||||
|
||||
expect(config.values).toBeUndefined();
|
||||
});
|
||||
|
||||
it('builds Y-axis values formatter that delegates to getToolTipValue', () => {
|
||||
const yBuilder = new UPlotAxisBuilder(
|
||||
createAxisProps({
|
||||
scaleKey: 'y',
|
||||
yAxisUnit: 'ms',
|
||||
decimalPrecision: 3,
|
||||
}),
|
||||
);
|
||||
|
||||
const config = yBuilder.getConfig();
|
||||
expect(typeof config.values).toBe('function');
|
||||
|
||||
(getToolTipValue as jest.Mock).mockImplementation(
|
||||
(value: string, unit?: string, precision?: unknown) =>
|
||||
`formatted:${value}:${unit}:${precision}`,
|
||||
);
|
||||
|
||||
// Simulate uPlot calling the values formatter
|
||||
const valuesFn = (config.values as unknown) as (
|
||||
self: uPlot,
|
||||
vals: unknown[],
|
||||
) => string[];
|
||||
const result = valuesFn({} as uPlot, [1, null, 2, Number.NaN]);
|
||||
|
||||
expect(getToolTipValue).toHaveBeenCalledTimes(2);
|
||||
expect(getToolTipValue).toHaveBeenNthCalledWith(1, '1', 'ms', 3);
|
||||
expect(getToolTipValue).toHaveBeenNthCalledWith(2, '2', 'ms', 3);
|
||||
|
||||
// Null/NaN values should map to empty strings
|
||||
expect(result).toEqual(['formatted:1:ms:3', '', 'formatted:2:ms:3', '']);
|
||||
});
|
||||
|
||||
it('adds dynamic size calculator only for Y-axis when size is not provided', () => {
|
||||
const yBuilder = new UPlotAxisBuilder(
|
||||
createAxisProps({
|
||||
scaleKey: 'y',
|
||||
}),
|
||||
);
|
||||
const xBuilder = new UPlotAxisBuilder(
|
||||
createAxisProps({
|
||||
scaleKey: 'x',
|
||||
}),
|
||||
);
|
||||
|
||||
const yConfig = yBuilder.getConfig();
|
||||
const xConfig = xBuilder.getConfig();
|
||||
|
||||
expect(typeof yConfig.size).toBe('function');
|
||||
expect(xConfig.size).toBeUndefined();
|
||||
});
|
||||
|
||||
it('uses explicit size function when provided', () => {
|
||||
const sizeFn: uPlot.Axis.Size = jest.fn(() => 100) as uPlot.Axis.Size;
|
||||
|
||||
const builder = new UPlotAxisBuilder(
|
||||
createAxisProps({
|
||||
scaleKey: 'y',
|
||||
size: sizeFn,
|
||||
}),
|
||||
);
|
||||
|
||||
const config = builder.getConfig();
|
||||
expect(config.size).toBe(sizeFn);
|
||||
});
|
||||
|
||||
it('builds stroke color based on stroke and isDarkMode', () => {
|
||||
const explicitStroke = new UPlotAxisBuilder(
|
||||
createAxisProps({
|
||||
stroke: '#ff0000',
|
||||
}),
|
||||
);
|
||||
const darkStroke = new UPlotAxisBuilder(
|
||||
createAxisProps({
|
||||
stroke: undefined,
|
||||
isDarkMode: true,
|
||||
}),
|
||||
);
|
||||
const lightStroke = new UPlotAxisBuilder(
|
||||
createAxisProps({
|
||||
stroke: undefined,
|
||||
isDarkMode: false,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(explicitStroke.getConfig().stroke).toBe('#ff0000');
|
||||
expect(darkStroke.getConfig().stroke).toBe('white');
|
||||
expect(lightStroke.getConfig().stroke).toBe('black');
|
||||
});
|
||||
|
||||
it('uses explicit values formatter when provided', () => {
|
||||
const customValues: uPlot.Axis.Values = jest.fn(() => ['a', 'b', 'c']);
|
||||
|
||||
const builder = new UPlotAxisBuilder(
|
||||
createAxisProps({
|
||||
scaleKey: 'y',
|
||||
values: customValues,
|
||||
}),
|
||||
);
|
||||
|
||||
const config = builder.getConfig();
|
||||
|
||||
expect(config.values).toBe(customValues);
|
||||
});
|
||||
|
||||
it('returns undefined values for scaleKey neither x nor y', () => {
|
||||
const builder = new UPlotAxisBuilder(createAxisProps({ scaleKey: 'custom' }));
|
||||
|
||||
const config = builder.getConfig();
|
||||
|
||||
expect(config.values).toBeUndefined();
|
||||
});
|
||||
|
||||
it('includes space in config when provided', () => {
|
||||
const builder = new UPlotAxisBuilder(
|
||||
createAxisProps({ scaleKey: 'y', space: 50 }),
|
||||
);
|
||||
|
||||
const config = builder.getConfig();
|
||||
|
||||
expect(config.space).toBe(50);
|
||||
});
|
||||
|
||||
it('includes PANEL_TYPES.BAR and PANEL_TYPES.PIE in X-axis datetime formatter', () => {
|
||||
const barBuilder = new UPlotAxisBuilder(
|
||||
createAxisProps({
|
||||
scaleKey: 'x',
|
||||
panelType: PANEL_TYPES.BAR,
|
||||
}),
|
||||
);
|
||||
const pieBuilder = new UPlotAxisBuilder(
|
||||
createAxisProps({
|
||||
scaleKey: 'x',
|
||||
panelType: PANEL_TYPES.PIE,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(barBuilder.getConfig().values).toBe(uPlotXAxisValuesFormat);
|
||||
expect(pieBuilder.getConfig().values).toBe(uPlotXAxisValuesFormat);
|
||||
});
|
||||
|
||||
it('invokes Y-axis size calculator and delegates to getExistingAxisSize when cycleNum > 1', () => {
|
||||
const builder = new UPlotAxisBuilder(createAxisProps({ scaleKey: 'y' }));
|
||||
|
||||
const config = builder.getConfig();
|
||||
const sizeFn = config.size;
|
||||
expect(typeof sizeFn).toBe('function');
|
||||
|
||||
const mockAxis = {
|
||||
_size: 80,
|
||||
ticks: { size: 10 },
|
||||
font: ['12px sans-serif'],
|
||||
};
|
||||
const mockSelf = ({
|
||||
axes: [mockAxis],
|
||||
ctx: { measureText: jest.fn(() => ({ width: 60 })), font: '' },
|
||||
} as unknown) as uPlot;
|
||||
|
||||
const result = (sizeFn as (
|
||||
s: uPlot,
|
||||
v: string[],
|
||||
a: number,
|
||||
c: number,
|
||||
) => number)(
|
||||
mockSelf,
|
||||
['100', '200'],
|
||||
0,
|
||||
2, // cycleNum > 1
|
||||
);
|
||||
|
||||
expect(result).toBe(80);
|
||||
});
|
||||
|
||||
it('invokes Y-axis size calculator and computes from text width when cycleNum <= 1', () => {
|
||||
const builder = new UPlotAxisBuilder(
|
||||
createAxisProps({ scaleKey: 'y', gap: 8 }),
|
||||
);
|
||||
|
||||
const config = builder.getConfig();
|
||||
const sizeFn = config.size;
|
||||
expect(typeof sizeFn).toBe('function');
|
||||
|
||||
const mockAxis = {
|
||||
ticks: { size: 12 },
|
||||
font: ['12px sans-serif'],
|
||||
};
|
||||
const measureText = jest.fn(() => ({ width: 48 }));
|
||||
const mockSelf = ({
|
||||
axes: [mockAxis],
|
||||
ctx: {
|
||||
measureText,
|
||||
get font() {
|
||||
return '';
|
||||
},
|
||||
set font(_v: string) {
|
||||
/* noop */
|
||||
},
|
||||
},
|
||||
} as unknown) as uPlot;
|
||||
|
||||
const result = (sizeFn as (
|
||||
s: uPlot,
|
||||
v: string[],
|
||||
a: number,
|
||||
c: number,
|
||||
) => number)(
|
||||
mockSelf,
|
||||
['10', '2000ms'],
|
||||
0,
|
||||
0, // cycleNum <= 1
|
||||
);
|
||||
|
||||
expect(measureText).toHaveBeenCalledWith('2000ms');
|
||||
expect(result).toBeGreaterThanOrEqual(12 + 8);
|
||||
});
|
||||
|
||||
it('merge updates axis props', () => {
|
||||
const builder = new UPlotAxisBuilder(
|
||||
createAxisProps({ scaleKey: 'y', label: 'Original' }),
|
||||
);
|
||||
|
||||
builder.merge({ label: 'Merged', yAxisUnit: 'bytes' });
|
||||
|
||||
const config = builder.getConfig();
|
||||
|
||||
expect(config.label).toBe('Merged');
|
||||
expect(config.values).toBeDefined();
|
||||
});
|
||||
});
|
||||
@@ -1,317 +0,0 @@
|
||||
import uPlot from 'uplot';
|
||||
|
||||
import type { SeriesProps } from '../types';
|
||||
import { DrawStyle, SelectionPreferencesSource } from '../types';
|
||||
import { UPlotConfigBuilder } from '../UPlotConfigBuilder';
|
||||
|
||||
// Mock only the real boundary that hits localStorage
|
||||
jest.mock(
|
||||
'container/DashboardContainer/visualization/panels/utils/legendVisibilityUtils',
|
||||
() => ({
|
||||
getStoredSeriesVisibility: jest.fn(),
|
||||
}),
|
||||
);
|
||||
|
||||
const getStoredSeriesVisibilityMock = jest.requireMock(
|
||||
'container/DashboardContainer/visualization/panels/utils/legendVisibilityUtils',
|
||||
) as {
|
||||
getStoredSeriesVisibility: jest.Mock;
|
||||
};
|
||||
|
||||
describe('UPlotConfigBuilder', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
const createSeriesProps = (
|
||||
overrides: Partial<SeriesProps> = {},
|
||||
): SeriesProps => ({
|
||||
scaleKey: 'y',
|
||||
label: 'Requests',
|
||||
colorMapping: {},
|
||||
drawStyle: DrawStyle.Line,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
it('returns correct save selection preference flag from constructor args', () => {
|
||||
const builder = new UPlotConfigBuilder({
|
||||
shouldSaveSelectionPreference: true,
|
||||
});
|
||||
|
||||
expect(builder.getShouldSaveSelectionPreference()).toBe(true);
|
||||
});
|
||||
|
||||
it('returns widgetId from constructor args', () => {
|
||||
const builder = new UPlotConfigBuilder({ widgetId: 'widget-123' });
|
||||
|
||||
expect(builder.getWidgetId()).toBe('widget-123');
|
||||
});
|
||||
|
||||
it('sets tzDate from constructor and includes it in config', () => {
|
||||
const tzDate = (ts: number): Date => new Date(ts);
|
||||
const builder = new UPlotConfigBuilder({ tzDate });
|
||||
|
||||
const config = builder.getConfig();
|
||||
|
||||
expect(config.tzDate).toBe(tzDate);
|
||||
});
|
||||
|
||||
it('does not call onDragSelect for click without drag (width === 0)', () => {
|
||||
const onDragSelect = jest.fn();
|
||||
const builder = new UPlotConfigBuilder({ onDragSelect });
|
||||
|
||||
const config = builder.getConfig();
|
||||
const setSelectHooks = config.hooks?.setSelect ?? [];
|
||||
expect(setSelectHooks.length).toBe(1);
|
||||
|
||||
const uplotInstance = ({
|
||||
select: { left: 10, width: 0 },
|
||||
posToVal: jest.fn(),
|
||||
} as unknown) as uPlot;
|
||||
|
||||
// Simulate uPlot calling the hook
|
||||
const setSelectHook = setSelectHooks[0];
|
||||
setSelectHook!(uplotInstance);
|
||||
|
||||
expect(onDragSelect).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('calls onDragSelect with start and end times in milliseconds for a drag selection', () => {
|
||||
const onDragSelect = jest.fn();
|
||||
const builder = new UPlotConfigBuilder({ onDragSelect });
|
||||
|
||||
const config = builder.getConfig();
|
||||
const setSelectHooks = config.hooks?.setSelect ?? [];
|
||||
expect(setSelectHooks.length).toBe(1);
|
||||
|
||||
const posToVal = jest
|
||||
.fn()
|
||||
// left position
|
||||
.mockReturnValueOnce(100)
|
||||
// left + width
|
||||
.mockReturnValueOnce(110);
|
||||
|
||||
const uplotInstance = ({
|
||||
select: { left: 50, width: 20 },
|
||||
posToVal,
|
||||
} as unknown) as uPlot;
|
||||
|
||||
const setSelectHook = setSelectHooks[0];
|
||||
setSelectHook!(uplotInstance);
|
||||
|
||||
expect(onDragSelect).toHaveBeenCalledTimes(1);
|
||||
// 100 and 110 seconds converted to milliseconds
|
||||
expect(onDragSelect).toHaveBeenCalledWith(100_000, 110_000);
|
||||
});
|
||||
|
||||
it('adds and removes hooks via addHook, and exposes them through getConfig', () => {
|
||||
const builder = new UPlotConfigBuilder();
|
||||
const drawHook = jest.fn();
|
||||
|
||||
const remove = builder.addHook('draw', drawHook as uPlot.Hooks.Defs['draw']);
|
||||
|
||||
let config = builder.getConfig();
|
||||
expect(config.hooks?.draw).toContain(drawHook);
|
||||
|
||||
// Remove and ensure it no longer appears in config
|
||||
remove();
|
||||
config = builder.getConfig();
|
||||
expect(config.hooks?.draw ?? []).not.toContain(drawHook);
|
||||
});
|
||||
|
||||
it('adds axes, scales, and series and wires them into the final config', () => {
|
||||
const builder = new UPlotConfigBuilder();
|
||||
|
||||
// Add axis and scale
|
||||
builder.addAxis({ scaleKey: 'y', label: 'Requests' });
|
||||
builder.addScale({ scaleKey: 'y' });
|
||||
|
||||
// Add two series – legend indices should start from 1 (0 is the timestamp series)
|
||||
builder.addSeries(createSeriesProps({ label: 'Requests' }));
|
||||
builder.addSeries(createSeriesProps({ label: 'Errors' }));
|
||||
|
||||
const config = builder.getConfig();
|
||||
|
||||
// Axes
|
||||
expect(config.axes).toHaveLength(1);
|
||||
expect(config.axes?.[0].scale).toBe('y');
|
||||
|
||||
// Scales are returned as an object keyed by scaleKey
|
||||
expect(config.scales).toBeDefined();
|
||||
expect(Object.keys(config.scales ?? {})).toContain('y');
|
||||
|
||||
// Series: base timestamp + 2 data series
|
||||
expect(config.series).toHaveLength(3);
|
||||
// Base series (index 0) has a value formatter that returns empty string
|
||||
const baseSeries = config.series?.[0] as { value?: () => string };
|
||||
expect(typeof baseSeries?.value).toBe('function');
|
||||
expect(baseSeries?.value?.()).toBe('');
|
||||
|
||||
// Legend items align with series and carry label and color from series config
|
||||
const legendItems = builder.getLegendItems();
|
||||
expect(Object.keys(legendItems)).toEqual(['1', '2']);
|
||||
expect(legendItems[1].seriesIndex).toBe(1);
|
||||
expect(legendItems[1].label).toBe('Requests');
|
||||
expect(legendItems[2].label).toBe('Errors');
|
||||
});
|
||||
|
||||
it('merges axis when addAxis is called twice with same scaleKey', () => {
|
||||
const builder = new UPlotConfigBuilder();
|
||||
|
||||
builder.addAxis({ scaleKey: 'y', label: 'Requests' });
|
||||
builder.addAxis({ scaleKey: 'y', label: 'Updated Label', show: false });
|
||||
|
||||
const config = builder.getConfig();
|
||||
|
||||
expect(config.axes).toHaveLength(1);
|
||||
expect(config.axes?.[0].label).toBe('Updated Label');
|
||||
expect(config.axes?.[0].show).toBe(false);
|
||||
});
|
||||
|
||||
it('merges scale when addScale is called twice with same scaleKey', () => {
|
||||
const builder = new UPlotConfigBuilder();
|
||||
|
||||
builder.addScale({ scaleKey: 'y', min: 0 });
|
||||
builder.addScale({ scaleKey: 'y', max: 100 });
|
||||
|
||||
const config = builder.getConfig();
|
||||
|
||||
// Only one scale entry for 'y' (merge path used, no duplicate added)
|
||||
expect(config.scales).toBeDefined();
|
||||
const scales = config.scales ?? {};
|
||||
expect(Object.keys(scales)).toEqual(['y']);
|
||||
expect(scales.y?.range).toBeDefined();
|
||||
});
|
||||
|
||||
it('restores visibility state from localStorage when selectionPreferencesSource is LOCAL_STORAGE', () => {
|
||||
const visibilityMap = new Map<string, boolean>([
|
||||
['Requests', true],
|
||||
['Errors', false],
|
||||
]);
|
||||
|
||||
getStoredSeriesVisibilityMock.getStoredSeriesVisibility.mockReturnValue(
|
||||
visibilityMap,
|
||||
);
|
||||
|
||||
const builder = new UPlotConfigBuilder({
|
||||
widgetId: 'widget-1',
|
||||
selectionPreferencesSource: SelectionPreferencesSource.LOCAL_STORAGE,
|
||||
});
|
||||
|
||||
builder.addSeries(createSeriesProps({ label: 'Requests' }));
|
||||
builder.addSeries(createSeriesProps({ label: 'Errors' }));
|
||||
|
||||
const legendItems = builder.getLegendItems();
|
||||
|
||||
// When any series is hidden, legend visibility is driven by the stored map
|
||||
expect(legendItems[1].show).toBe(true);
|
||||
expect(legendItems[2].show).toBe(false);
|
||||
|
||||
const config = builder.getConfig();
|
||||
const [, firstSeries, secondSeries] = config.series ?? [];
|
||||
|
||||
expect(firstSeries?.show).toBe(true);
|
||||
expect(secondSeries?.show).toBe(false);
|
||||
});
|
||||
|
||||
it('does not attempt to read stored visibility when using in-memory preferences', () => {
|
||||
const builder = new UPlotConfigBuilder({
|
||||
widgetId: 'widget-1',
|
||||
selectionPreferencesSource: SelectionPreferencesSource.IN_MEMORY,
|
||||
});
|
||||
|
||||
builder.addSeries(createSeriesProps({ label: 'Requests' }));
|
||||
|
||||
builder.getLegendItems();
|
||||
builder.getConfig();
|
||||
|
||||
expect(
|
||||
getStoredSeriesVisibilityMock.getStoredSeriesVisibility,
|
||||
).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('adds thresholds only once per scale key', () => {
|
||||
const builder = new UPlotConfigBuilder();
|
||||
|
||||
const thresholdsOptions = {
|
||||
scaleKey: 'y',
|
||||
thresholds: [{ thresholdValue: 100 }],
|
||||
};
|
||||
|
||||
builder.addThresholds(thresholdsOptions);
|
||||
builder.addThresholds(thresholdsOptions);
|
||||
|
||||
const config = builder.getConfig();
|
||||
const drawHooks = config.hooks?.draw ?? [];
|
||||
|
||||
// Only a single draw hook should be registered for the same scaleKey
|
||||
expect(drawHooks.length).toBe(1);
|
||||
});
|
||||
|
||||
it('merges cursor configuration with defaults instead of replacing them', () => {
|
||||
const builder = new UPlotConfigBuilder();
|
||||
|
||||
builder.setCursor({
|
||||
drag: { setScale: false },
|
||||
});
|
||||
|
||||
const config = builder.getConfig();
|
||||
|
||||
expect(config.cursor?.drag?.setScale).toBe(false);
|
||||
// Points configuration from DEFAULT_CURSOR_CONFIG should still be present
|
||||
expect(config.cursor?.points).toBeDefined();
|
||||
});
|
||||
|
||||
it('adds plugins and includes them in config', () => {
|
||||
const builder = new UPlotConfigBuilder();
|
||||
const plugin: uPlot.Plugin = {
|
||||
opts: (): void => {},
|
||||
hooks: {},
|
||||
};
|
||||
|
||||
builder.addPlugin(plugin);
|
||||
|
||||
const config = builder.getConfig();
|
||||
|
||||
expect(config.plugins).toContain(plugin);
|
||||
});
|
||||
|
||||
it('sets padding, legend, focus, select, tzDate, bands and includes them in config', () => {
|
||||
const tzDate = (ts: number): Date => new Date(ts);
|
||||
const builder = new UPlotConfigBuilder();
|
||||
|
||||
const bands: uPlot.Band[] = [{ series: [1, 2], fill: (): string => '#000' }];
|
||||
|
||||
builder.setBands(bands);
|
||||
builder.setPadding([10, 20, 30, 40]);
|
||||
builder.setLegend({ show: true, live: true });
|
||||
builder.setFocus({ alpha: 0.5 });
|
||||
builder.setSelect({ left: 0, width: 0, top: 0, height: 0 });
|
||||
builder.setTzDate(tzDate);
|
||||
|
||||
const config = builder.getConfig();
|
||||
|
||||
expect(config.bands).toEqual(bands);
|
||||
expect(config.padding).toEqual([10, 20, 30, 40]);
|
||||
expect(config.legend).toEqual({ show: true, live: true });
|
||||
expect(config.focus).toEqual({ alpha: 0.5 });
|
||||
expect(config.select).toEqual({ left: 0, width: 0, top: 0, height: 0 });
|
||||
expect(config.tzDate).toBe(tzDate);
|
||||
});
|
||||
|
||||
it('does not include plugins when none added', () => {
|
||||
const builder = new UPlotConfigBuilder();
|
||||
|
||||
const config = builder.getConfig();
|
||||
|
||||
expect(config.plugins).toBeUndefined();
|
||||
});
|
||||
|
||||
it('does not include bands when empty', () => {
|
||||
const builder = new UPlotConfigBuilder();
|
||||
|
||||
const config = builder.getConfig();
|
||||
|
||||
expect(config.bands).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -1,236 +0,0 @@
|
||||
import type uPlot from 'uplot';
|
||||
|
||||
import * as scaleUtils from '../../utils/scale';
|
||||
import type { ScaleProps } from '../types';
|
||||
import { DistributionType } from '../types';
|
||||
import { UPlotScaleBuilder } from '../UPlotScaleBuilder';
|
||||
|
||||
const createScaleProps = (overrides: Partial<ScaleProps> = {}): ScaleProps => ({
|
||||
scaleKey: 'y',
|
||||
time: false,
|
||||
auto: undefined,
|
||||
min: undefined,
|
||||
max: undefined,
|
||||
softMin: undefined,
|
||||
softMax: undefined,
|
||||
distribution: DistributionType.Linear,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe('UPlotScaleBuilder', () => {
|
||||
const getFallbackMinMaxSpy = jest.spyOn(
|
||||
scaleUtils,
|
||||
'getFallbackMinMaxTimeStamp',
|
||||
);
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('initializes softMin/softMax correctly when both are 0 (treated as unset)', () => {
|
||||
const builder = new UPlotScaleBuilder(
|
||||
createScaleProps({
|
||||
softMin: 0,
|
||||
softMax: 0,
|
||||
}),
|
||||
);
|
||||
|
||||
// Non-time scale so config path uses thresholds pipeline; we just care that
|
||||
// adjustSoftLimitsWithThresholds receives null soft limits instead of 0/0.
|
||||
const adjustSpy = jest.spyOn(scaleUtils, 'adjustSoftLimitsWithThresholds');
|
||||
|
||||
builder.getConfig();
|
||||
|
||||
expect(adjustSpy).toHaveBeenCalledWith(null, null, undefined, undefined);
|
||||
});
|
||||
|
||||
it('handles time scales using explicit min/max and rounds max down to the previous minute', () => {
|
||||
const min = 1_700_000_000; // seconds
|
||||
const max = 1_700_000_600; // seconds
|
||||
|
||||
const builder = new UPlotScaleBuilder(
|
||||
createScaleProps({
|
||||
scaleKey: 'x',
|
||||
time: true,
|
||||
min,
|
||||
max,
|
||||
}),
|
||||
);
|
||||
|
||||
const config = builder.getConfig();
|
||||
const xScale = config.x;
|
||||
|
||||
expect(xScale.time).toBe(true);
|
||||
expect(xScale.auto).toBe(false);
|
||||
expect(Array.isArray(xScale.range)).toBe(true);
|
||||
|
||||
const [resolvedMin, resolvedMax] = xScale.range as [number, number];
|
||||
|
||||
// min is passed through
|
||||
expect(resolvedMin).toBe(min);
|
||||
|
||||
// max is coerced to "endTime - 1 minute" and rounded down to minute precision
|
||||
const oneMinuteAgoTimestamp = (max - 60) * 1000;
|
||||
const currentDate = new Date(oneMinuteAgoTimestamp);
|
||||
currentDate.setSeconds(0);
|
||||
currentDate.setMilliseconds(0);
|
||||
const expectedMax = Math.floor(currentDate.getTime() / 1000);
|
||||
|
||||
expect(resolvedMax).toBe(expectedMax);
|
||||
});
|
||||
|
||||
it('falls back to getFallbackMinMaxTimeStamp when time scale has no min/max', () => {
|
||||
getFallbackMinMaxSpy.mockReturnValue({
|
||||
fallbackMin: 100,
|
||||
fallbackMax: 200,
|
||||
});
|
||||
|
||||
const builder = new UPlotScaleBuilder(
|
||||
createScaleProps({
|
||||
scaleKey: 'x',
|
||||
time: true,
|
||||
min: undefined,
|
||||
max: undefined,
|
||||
}),
|
||||
);
|
||||
|
||||
const config = builder.getConfig();
|
||||
const [resolvedMin, resolvedMax] = config.x.range as [number, number];
|
||||
|
||||
expect(getFallbackMinMaxSpy).toHaveBeenCalled();
|
||||
expect(resolvedMin).toBe(100);
|
||||
// max is aligned to "fallbackMax - 60 seconds" minute boundary
|
||||
expect(resolvedMax).toBeLessThanOrEqual(200);
|
||||
expect(resolvedMax).toBeGreaterThan(100);
|
||||
});
|
||||
|
||||
it('pipes limits through soft-limit adjustment and log-scale normalization before range config', () => {
|
||||
const adjustSpy = jest.spyOn(scaleUtils, 'adjustSoftLimitsWithThresholds');
|
||||
const normalizeSpy = jest.spyOn(scaleUtils, 'normalizeLogScaleLimits');
|
||||
const getRangeConfigSpy = jest.spyOn(scaleUtils, 'getRangeConfig');
|
||||
|
||||
const thresholds = {
|
||||
scaleKey: 'y',
|
||||
thresholds: [{ thresholdValue: 10 }],
|
||||
yAxisUnit: 'ms',
|
||||
};
|
||||
|
||||
const builder = new UPlotScaleBuilder(
|
||||
createScaleProps({
|
||||
softMin: 1,
|
||||
softMax: 5,
|
||||
min: 0,
|
||||
max: 100,
|
||||
distribution: DistributionType.Logarithmic,
|
||||
thresholds,
|
||||
logBase: 2,
|
||||
padMinBy: 0.1,
|
||||
padMaxBy: 0.2,
|
||||
}),
|
||||
);
|
||||
|
||||
builder.getConfig();
|
||||
|
||||
expect(adjustSpy).toHaveBeenCalledWith(1, 5, thresholds.thresholds, 'ms');
|
||||
expect(normalizeSpy).toHaveBeenCalledWith({
|
||||
distr: DistributionType.Logarithmic,
|
||||
logBase: 2,
|
||||
limits: {
|
||||
min: 0,
|
||||
max: 100,
|
||||
softMin: expect.anything(),
|
||||
softMax: expect.anything(),
|
||||
},
|
||||
});
|
||||
expect(getRangeConfigSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('computes distribution config for non-time scales and wires range function when range is not provided', () => {
|
||||
const createRangeFnSpy = jest.spyOn(scaleUtils, 'createRangeFunction');
|
||||
|
||||
const builder = new UPlotScaleBuilder(
|
||||
createScaleProps({
|
||||
scaleKey: 'y',
|
||||
time: false,
|
||||
distribution: DistributionType.Linear,
|
||||
}),
|
||||
);
|
||||
|
||||
const config = builder.getConfig();
|
||||
const yScale = config.y;
|
||||
|
||||
expect(createRangeFnSpy).toHaveBeenCalled();
|
||||
|
||||
// range should be a function when not provided explicitly
|
||||
expect(typeof yScale.range).toBe('function');
|
||||
|
||||
// distribution config should be applied
|
||||
expect(yScale.distr).toBeDefined();
|
||||
expect(yScale.log).toBeDefined();
|
||||
});
|
||||
|
||||
it('respects explicit range function when provided on props', () => {
|
||||
const explicitRange: uPlot.Scale.Range = jest.fn(() => [
|
||||
0,
|
||||
10,
|
||||
]) as uPlot.Scale.Range;
|
||||
|
||||
const builder = new UPlotScaleBuilder(
|
||||
createScaleProps({
|
||||
scaleKey: 'y',
|
||||
range: explicitRange,
|
||||
}),
|
||||
);
|
||||
|
||||
const config = builder.getConfig();
|
||||
const yScale = config.y;
|
||||
|
||||
expect(yScale.range).toBe(explicitRange);
|
||||
});
|
||||
|
||||
it('derives auto flag when not explicitly provided, based on hasFixedRange and time', () => {
|
||||
const getRangeConfigSpy = jest.spyOn(scaleUtils, 'getRangeConfig');
|
||||
|
||||
const builder = new UPlotScaleBuilder(
|
||||
createScaleProps({
|
||||
min: 0,
|
||||
max: 100,
|
||||
time: false,
|
||||
}),
|
||||
);
|
||||
|
||||
const config = builder.getConfig();
|
||||
const yScale = config.y;
|
||||
|
||||
expect(getRangeConfigSpy).toHaveBeenCalled();
|
||||
// For non-time scale with fixed min/max, hasFixedRange is true → auto should remain false
|
||||
expect(yScale.auto).toBe(false);
|
||||
});
|
||||
|
||||
it('merge updates internal min/max/soft limits while preserving other props', () => {
|
||||
const builder = new UPlotScaleBuilder(
|
||||
createScaleProps({
|
||||
scaleKey: 'y',
|
||||
min: 0,
|
||||
max: 10,
|
||||
softMin: 1,
|
||||
softMax: 9,
|
||||
time: false,
|
||||
}),
|
||||
);
|
||||
|
||||
builder.merge({
|
||||
min: 2,
|
||||
softMax: undefined,
|
||||
});
|
||||
|
||||
expect(builder.props.min).toBe(2);
|
||||
expect(builder.props.softMax).toBe(undefined);
|
||||
expect(builder.props.max).toBe(10);
|
||||
expect(builder.props.softMin).toBe(1);
|
||||
expect(builder.props.time).toBe(false);
|
||||
expect(builder.props.scaleKey).toBe('y');
|
||||
expect(builder.props.distribution).toBe(DistributionType.Linear);
|
||||
expect(builder.props.thresholds).toBe(undefined);
|
||||
});
|
||||
});
|
||||
@@ -1,300 +0,0 @@
|
||||
import { themeColors } from 'constants/theme';
|
||||
import uPlot from 'uplot';
|
||||
|
||||
import type { SeriesProps } from '../types';
|
||||
import {
|
||||
DrawStyle,
|
||||
LineInterpolation,
|
||||
LineStyle,
|
||||
VisibilityMode,
|
||||
} from '../types';
|
||||
import { UPlotSeriesBuilder } from '../UPlotSeriesBuilder';
|
||||
|
||||
const createBaseProps = (
|
||||
overrides: Partial<SeriesProps> = {},
|
||||
): SeriesProps => ({
|
||||
scaleKey: 'y',
|
||||
label: 'Requests',
|
||||
colorMapping: {},
|
||||
drawStyle: DrawStyle.Line,
|
||||
isDarkMode: false,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
interface MockPath extends uPlot.Series.Paths {
|
||||
name?: string;
|
||||
}
|
||||
|
||||
describe('UPlotSeriesBuilder', () => {
|
||||
it('maps basic props into uPlot series config', () => {
|
||||
const builder = new UPlotSeriesBuilder(
|
||||
createBaseProps({
|
||||
label: 'Latency',
|
||||
spanGaps: true,
|
||||
show: false,
|
||||
}),
|
||||
);
|
||||
|
||||
const config = builder.getConfig();
|
||||
|
||||
expect(config.scale).toBe('y');
|
||||
expect(config.label).toBe('Latency');
|
||||
expect(config.spanGaps).toBe(true);
|
||||
expect(config.show).toBe(false);
|
||||
expect(config.pxAlign).toBe(true);
|
||||
expect(typeof config.value).toBe('function');
|
||||
});
|
||||
|
||||
it('uses explicit lineColor when provided, regardless of mapping', () => {
|
||||
const builder = new UPlotSeriesBuilder(
|
||||
createBaseProps({
|
||||
lineColor: '#ff00ff',
|
||||
colorMapping: { Requests: '#00ff00' },
|
||||
}),
|
||||
);
|
||||
|
||||
const config = builder.getConfig();
|
||||
|
||||
expect(config.stroke).toBe('#ff00ff');
|
||||
});
|
||||
|
||||
it('falls back to theme colors when no label is provided', () => {
|
||||
const darkBuilder = new UPlotSeriesBuilder(
|
||||
createBaseProps({
|
||||
label: undefined,
|
||||
isDarkMode: true,
|
||||
lineColor: undefined,
|
||||
}),
|
||||
);
|
||||
const lightBuilder = new UPlotSeriesBuilder(
|
||||
createBaseProps({
|
||||
label: undefined,
|
||||
isDarkMode: false,
|
||||
lineColor: undefined,
|
||||
}),
|
||||
);
|
||||
|
||||
const darkConfig = darkBuilder.getConfig();
|
||||
const lightConfig = lightBuilder.getConfig();
|
||||
|
||||
expect(darkConfig.stroke).toBe(themeColors.white);
|
||||
expect(lightConfig.stroke).toBe(themeColors.black);
|
||||
});
|
||||
|
||||
it('uses colorMapping when available and no explicit lineColor is provided', () => {
|
||||
const builder = new UPlotSeriesBuilder(
|
||||
createBaseProps({
|
||||
label: 'Requests',
|
||||
colorMapping: { Requests: '#123456' },
|
||||
lineColor: undefined,
|
||||
}),
|
||||
);
|
||||
|
||||
const config = builder.getConfig();
|
||||
|
||||
expect(config.stroke).toBe('#123456');
|
||||
});
|
||||
|
||||
it('passes through a custom pathBuilder when provided', () => {
|
||||
const customPaths = (jest.fn() as unknown) as uPlot.Series.PathBuilder;
|
||||
|
||||
const builder = new UPlotSeriesBuilder(
|
||||
createBaseProps({
|
||||
pathBuilder: customPaths,
|
||||
}),
|
||||
);
|
||||
|
||||
const config = builder.getConfig();
|
||||
|
||||
expect(config.paths).toBe(customPaths);
|
||||
});
|
||||
|
||||
it('does not build line paths when drawStyle is Points, but still renders points', () => {
|
||||
const builder = new UPlotSeriesBuilder(
|
||||
createBaseProps({
|
||||
drawStyle: DrawStyle.Points,
|
||||
pointSize: 4,
|
||||
lineWidth: 2,
|
||||
lineColor: '#aa00aa',
|
||||
}),
|
||||
);
|
||||
|
||||
const config = builder.getConfig();
|
||||
|
||||
expect(typeof config.paths).toBe('function');
|
||||
expect(config.paths && config.paths({} as uPlot, 1, 0, 10)).toBeNull();
|
||||
|
||||
expect(config.points).toBeDefined();
|
||||
expect(config.points?.stroke).toBe('#aa00aa');
|
||||
expect(config.points?.fill).toBe('#aa00aa');
|
||||
expect(config.points?.show).toBe(true);
|
||||
expect(config.points?.size).toBe(4);
|
||||
});
|
||||
|
||||
it('derives point size based on lineWidth and pointSize', () => {
|
||||
const smallPointsBuilder = new UPlotSeriesBuilder(
|
||||
createBaseProps({
|
||||
lineWidth: 4,
|
||||
pointSize: 2,
|
||||
}),
|
||||
);
|
||||
const largePointsBuilder = new UPlotSeriesBuilder(
|
||||
createBaseProps({
|
||||
lineWidth: 2,
|
||||
pointSize: 4,
|
||||
}),
|
||||
);
|
||||
|
||||
const smallConfig = smallPointsBuilder.getConfig();
|
||||
const largeConfig = largePointsBuilder.getConfig();
|
||||
|
||||
expect(smallConfig.points?.size).toBeUndefined();
|
||||
expect(largeConfig.points?.size).toBe(4);
|
||||
});
|
||||
|
||||
it('uses pointsBuilder when provided instead of default visibility logic', () => {
|
||||
const pointsBuilder: uPlot.Series.Points.Show = jest.fn(
|
||||
() => true,
|
||||
) as uPlot.Series.Points.Show;
|
||||
|
||||
const builder = new UPlotSeriesBuilder(
|
||||
createBaseProps({
|
||||
pointsBuilder,
|
||||
drawStyle: DrawStyle.Line,
|
||||
}),
|
||||
);
|
||||
|
||||
const config = builder.getConfig();
|
||||
|
||||
expect(config.points?.show).toBe(pointsBuilder);
|
||||
});
|
||||
|
||||
it('respects VisibilityMode for point visibility when no custom pointsBuilder is given', () => {
|
||||
const neverPointsBuilder = new UPlotSeriesBuilder(
|
||||
createBaseProps({
|
||||
drawStyle: DrawStyle.Line,
|
||||
showPoints: VisibilityMode.Never,
|
||||
}),
|
||||
);
|
||||
const alwaysPointsBuilder = new UPlotSeriesBuilder(
|
||||
createBaseProps({
|
||||
drawStyle: DrawStyle.Line,
|
||||
showPoints: VisibilityMode.Always,
|
||||
}),
|
||||
);
|
||||
|
||||
const neverConfig = neverPointsBuilder.getConfig();
|
||||
const alwaysConfig = alwaysPointsBuilder.getConfig();
|
||||
|
||||
expect(neverConfig.points?.show).toBe(false);
|
||||
expect(alwaysConfig.points?.show).toBe(true);
|
||||
});
|
||||
|
||||
it('applies LineStyle.Dashed and lineCap to line config', () => {
|
||||
const builder = new UPlotSeriesBuilder(
|
||||
createBaseProps({
|
||||
lineStyle: LineStyle.Dashed,
|
||||
lineCap: 'round' as CanvasLineCap,
|
||||
}),
|
||||
);
|
||||
|
||||
const config = builder.getConfig();
|
||||
|
||||
expect(config.dash).toEqual([10, 10]);
|
||||
expect(config.cap).toBe('round');
|
||||
});
|
||||
|
||||
it('builds default paths for Line drawStyle and invokes the path builder', () => {
|
||||
const builder = new UPlotSeriesBuilder(
|
||||
createBaseProps({
|
||||
drawStyle: DrawStyle.Line,
|
||||
lineInterpolation: LineInterpolation.Linear,
|
||||
}),
|
||||
);
|
||||
|
||||
const config = builder.getConfig();
|
||||
|
||||
const result = config.paths?.({} as uPlot, 1, 0, 10);
|
||||
expect((result as MockPath).name).toBe('linear');
|
||||
});
|
||||
|
||||
it('uses StepBefore and StepAfter interpolation for line paths', () => {
|
||||
const stepBeforeBuilder = new UPlotSeriesBuilder(
|
||||
createBaseProps({
|
||||
drawStyle: DrawStyle.Line,
|
||||
lineInterpolation: LineInterpolation.StepBefore,
|
||||
}),
|
||||
);
|
||||
const stepAfterBuilder = new UPlotSeriesBuilder(
|
||||
createBaseProps({
|
||||
drawStyle: DrawStyle.Line,
|
||||
lineInterpolation: LineInterpolation.StepAfter,
|
||||
}),
|
||||
);
|
||||
|
||||
const stepBeforeConfig = stepBeforeBuilder.getConfig();
|
||||
const stepAfterConfig = stepAfterBuilder.getConfig();
|
||||
const stepBeforePath = stepBeforeConfig.paths?.({} as uPlot, 1, 0, 5);
|
||||
const stepAfterPath = stepAfterConfig.paths?.({} as uPlot, 1, 0, 5);
|
||||
expect((stepBeforePath as MockPath).name).toBe('stepped-(-1)');
|
||||
expect((stepAfterPath as MockPath).name).toBe('stepped-(1)');
|
||||
});
|
||||
|
||||
it('defaults to spline interpolation when lineInterpolation is Spline or undefined', () => {
|
||||
const splineBuilder = new UPlotSeriesBuilder(
|
||||
createBaseProps({
|
||||
drawStyle: DrawStyle.Line,
|
||||
lineInterpolation: LineInterpolation.Spline,
|
||||
}),
|
||||
);
|
||||
const defaultBuilder = new UPlotSeriesBuilder(
|
||||
createBaseProps({ drawStyle: DrawStyle.Line }),
|
||||
);
|
||||
|
||||
const splineConfig = splineBuilder.getConfig();
|
||||
const defaultConfig = defaultBuilder.getConfig();
|
||||
|
||||
const splinePath = splineConfig.paths?.({} as uPlot, 1, 0, 10);
|
||||
const defaultPath = defaultConfig.paths?.({} as uPlot, 1, 0, 10);
|
||||
|
||||
expect((splinePath as MockPath).name).toBe('spline');
|
||||
expect((defaultPath as MockPath).name).toBe('spline');
|
||||
});
|
||||
|
||||
it('preserves spanGaps true when provided as boolean', () => {
|
||||
const builder = new UPlotSeriesBuilder(createBaseProps({ spanGaps: true }));
|
||||
|
||||
const config = builder.getConfig();
|
||||
expect(config.spanGaps).toBe(true);
|
||||
});
|
||||
|
||||
it('uses generateColor when label has no colorMapping and no lineColor', () => {
|
||||
const builder = new UPlotSeriesBuilder(
|
||||
createBaseProps({
|
||||
label: 'CustomSeries',
|
||||
colorMapping: {},
|
||||
lineColor: undefined,
|
||||
}),
|
||||
);
|
||||
|
||||
const config = builder.getConfig();
|
||||
expect(config.stroke).toBe('#E64A3C');
|
||||
});
|
||||
|
||||
it('passes through pointsFilter when provided', () => {
|
||||
const pointsFilter: uPlot.Series.Points.Filter = jest.fn(
|
||||
(_self, _seriesIdx, _show) => null,
|
||||
);
|
||||
|
||||
const builder = new UPlotSeriesBuilder(
|
||||
createBaseProps({
|
||||
pointsFilter,
|
||||
drawStyle: DrawStyle.Line,
|
||||
}),
|
||||
);
|
||||
|
||||
const config = builder.getConfig();
|
||||
|
||||
expect(config.points?.filter).toBe(pointsFilter);
|
||||
});
|
||||
});
|
||||
@@ -1,80 +0,0 @@
|
||||
import { Axis } from 'uplot';
|
||||
|
||||
/**
|
||||
* Calculate text width for longest value
|
||||
*/
|
||||
export function calculateTextWidth(
|
||||
self: uPlot,
|
||||
axis: Axis,
|
||||
values: string[] | undefined,
|
||||
): number {
|
||||
if (!values || values.length === 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Find longest value
|
||||
const longestVal = values.reduce(
|
||||
(acc, val) => (val.length > acc.length ? val : acc),
|
||||
'',
|
||||
);
|
||||
|
||||
if (longestVal === '' || !axis.font?.[0]) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
self.ctx.font = axis.font[0];
|
||||
return self.ctx.measureText(longestVal).width / devicePixelRatio;
|
||||
}
|
||||
|
||||
export function getExistingAxisSize({
|
||||
uplotInstance,
|
||||
axis,
|
||||
values,
|
||||
axisIdx,
|
||||
cycleNum,
|
||||
}: {
|
||||
uplotInstance: uPlot;
|
||||
axis: Axis;
|
||||
values?: string[];
|
||||
axisIdx: number;
|
||||
cycleNum: number;
|
||||
}): number {
|
||||
const internalSize = (axis as { _size?: number })._size;
|
||||
if (internalSize !== undefined) {
|
||||
return internalSize;
|
||||
}
|
||||
|
||||
const existingSize = axis.size;
|
||||
if (typeof existingSize === 'function') {
|
||||
return existingSize(uplotInstance, values ?? [], axisIdx, cycleNum);
|
||||
}
|
||||
|
||||
return existingSize ?? 0;
|
||||
}
|
||||
|
||||
export function buildYAxisSizeCalculator(gap: number): uPlot.Axis.Size {
|
||||
return (
|
||||
self: uPlot,
|
||||
values: string[] | undefined,
|
||||
axisIdx: number,
|
||||
cycleNum: number,
|
||||
): number => {
|
||||
const axis = self.axes[axisIdx];
|
||||
|
||||
// Bail out, force convergence
|
||||
if (cycleNum > 1) {
|
||||
return getExistingAxisSize({
|
||||
uplotInstance: self,
|
||||
axis,
|
||||
values,
|
||||
axisIdx,
|
||||
cycleNum,
|
||||
});
|
||||
}
|
||||
|
||||
let axisSize = (axis.ticks?.size ?? 0) + gap;
|
||||
axisSize += calculateTextWidth(self, axis, values);
|
||||
|
||||
return Math.ceil(axisSize);
|
||||
};
|
||||
}
|
||||
@@ -208,7 +208,18 @@ func (q *querier) QueryRange(ctx context.Context, orgID valuer.UUID, req *qbtype
|
||||
event.GroupByApplied = len(spec.GroupBy) > 0
|
||||
|
||||
if spec.Source == telemetrytypes.SourceMeter {
|
||||
spec.StepInterval = qbtypes.Step{Duration: time.Second * time.Duration(querybuilder.RecommendedStepIntervalForMeter(req.Start, req.End))}
|
||||
if spec.StepInterval.Seconds() == 0 {
|
||||
spec.StepInterval = qbtypes.Step{Duration: time.Second * time.Duration(querybuilder.RecommendedStepIntervalForMeter(req.Start, req.End))}
|
||||
}
|
||||
|
||||
if spec.StepInterval.Seconds() < float64(querybuilder.MinAllowedStepIntervalForMeter(req.Start, req.End)) {
|
||||
newStep := qbtypes.Step{
|
||||
Duration: time.Second * time.Duration(querybuilder.MinAllowedStepIntervalForMeter(req.Start, req.End)),
|
||||
}
|
||||
// TODO[@vikrantgupta25]: add this warning after the issue for intelligent bar chart aggregations is fixed.
|
||||
// intervalWarnings = append(intervalWarnings, fmt.Sprintf(intervalWarn, spec.Name, spec.StepInterval.Seconds(), newStep.Duration.Seconds()))
|
||||
spec.StepInterval = newStep
|
||||
}
|
||||
} else {
|
||||
if spec.StepInterval.Seconds() == 0 {
|
||||
spec.StepInterval = qbtypes.Step{
|
||||
|
||||
@@ -89,6 +89,31 @@ func RecommendedStepIntervalForMeter(start, end uint64) uint64 {
|
||||
return recommended
|
||||
}
|
||||
|
||||
func MinAllowedStepIntervalForMeter(start, end uint64) uint64 {
|
||||
start = ToNanoSecs(start)
|
||||
end = ToNanoSecs(end)
|
||||
|
||||
step := (end - start) / RecommendedNumberOfPoints / 1e9
|
||||
|
||||
// for meter queries the minimum step interval allowed is 1 hour as this is our granularity
|
||||
if step < 3600 {
|
||||
return 3600
|
||||
}
|
||||
|
||||
// return the nearest lower multiple of 3600 ( 1 hour )
|
||||
minAllowed := step - step%3600
|
||||
|
||||
// if the time range is greater than 1 month set the step interval to be multiple of 1 day
|
||||
if end-start >= uint64(30*24*time.Hour.Nanoseconds()) {
|
||||
if minAllowed < 86400 {
|
||||
minAllowed = 86400
|
||||
} else {
|
||||
minAllowed = uint64(math.Round(float64(minAllowed)/86400)) * 86400
|
||||
}
|
||||
}
|
||||
return minAllowed
|
||||
}
|
||||
|
||||
func RecommendedStepIntervalForMetric(start, end uint64) uint64 {
|
||||
start = ToNanoSecs(start)
|
||||
end = ToNanoSecs(end)
|
||||
|
||||
Reference in New Issue
Block a user