Compare commits

..

3 Commits

Author SHA1 Message Date
Abhi kumar
cc10b488d9 Merge branch 'main' into test/uplot-utils-test 2026-02-09 20:00:13 +05:30
Abhi kumar
7eec0edfd0 Merge branch 'main' into test/uplot-utils-test 2026-02-09 19:33:49 +05:30
Abhi Kumar
6f5a0258f2 test: added tests for uplotv2 utils 2026-02-09 19:28:54 +05:30
21 changed files with 593 additions and 684 deletions

View File

@@ -78,10 +78,12 @@ function TestWrapper({ children }: { children: React.ReactNode }): JSX.Element {
describe('VariableItem Integration Tests', () => {
let user: ReturnType<typeof userEvent.setup>;
let mockOnValueUpdate: jest.Mock;
let mockSetVariablesToGetUpdated: jest.Mock;
beforeEach(() => {
user = userEvent.setup();
mockOnValueUpdate = jest.fn();
mockSetVariablesToGetUpdated = jest.fn();
jest.clearAllMocks();
});
@@ -100,6 +102,9 @@ describe('VariableItem Integration Tests', () => {
variableData={variable}
existingVariables={{}}
onValueUpdate={mockOnValueUpdate}
variablesToGetUpdated={[]}
setVariablesToGetUpdated={mockSetVariablesToGetUpdated}
dependencyData={null}
/>
</TestWrapper>,
);
@@ -145,6 +150,9 @@ describe('VariableItem Integration Tests', () => {
variableData={variable}
existingVariables={{}}
onValueUpdate={mockOnValueUpdate}
variablesToGetUpdated={[]}
setVariablesToGetUpdated={mockSetVariablesToGetUpdated}
dependencyData={null}
/>
</TestWrapper>,
);
@@ -187,6 +195,9 @@ describe('VariableItem Integration Tests', () => {
variableData={variable}
existingVariables={{}}
onValueUpdate={mockOnValueUpdate}
variablesToGetUpdated={[]}
setVariablesToGetUpdated={mockSetVariablesToGetUpdated}
dependencyData={null}
/>
</TestWrapper>,
);
@@ -236,6 +247,9 @@ describe('VariableItem Integration Tests', () => {
variableData={variable}
existingVariables={{}}
onValueUpdate={mockOnValueUpdate}
variablesToGetUpdated={[]}
setVariablesToGetUpdated={mockSetVariablesToGetUpdated}
dependencyData={null}
/>
</TestWrapper>,
);
@@ -258,6 +272,9 @@ describe('VariableItem Integration Tests', () => {
variableData={variable}
existingVariables={{}}
onValueUpdate={mockOnValueUpdate}
variablesToGetUpdated={[]}
setVariablesToGetUpdated={mockSetVariablesToGetUpdated}
dependencyData={null}
/>
</TestWrapper>,
);
@@ -291,6 +308,9 @@ describe('VariableItem Integration Tests', () => {
variableData={variable}
existingVariables={{}}
onValueUpdate={mockOnValueUpdate}
variablesToGetUpdated={[]}
setVariablesToGetUpdated={mockSetVariablesToGetUpdated}
dependencyData={null}
/>
</TestWrapper>,
);
@@ -324,6 +344,9 @@ describe('VariableItem Integration Tests', () => {
variableData={variable}
existingVariables={{}}
onValueUpdate={mockOnValueUpdate}
variablesToGetUpdated={[]}
setVariablesToGetUpdated={mockSetVariablesToGetUpdated}
dependencyData={null}
/>
</TestWrapper>,
);
@@ -346,6 +369,9 @@ describe('VariableItem Integration Tests', () => {
variableData={variable}
existingVariables={{}}
onValueUpdate={mockOnValueUpdate}
variablesToGetUpdated={[]}
setVariablesToGetUpdated={mockSetVariablesToGetUpdated}
dependencyData={null}
/>
</TestWrapper>,
);
@@ -379,6 +405,9 @@ describe('VariableItem Integration Tests', () => {
variableData={variable}
existingVariables={{}}
onValueUpdate={mockOnValueUpdate}
variablesToGetUpdated={[]}
setVariablesToGetUpdated={mockSetVariablesToGetUpdated}
dependencyData={null}
/>
</TestWrapper>,
);
@@ -432,6 +461,9 @@ describe('VariableItem Integration Tests', () => {
variableData={variable}
existingVariables={{}}
onValueUpdate={mockOnValueUpdate}
variablesToGetUpdated={[]}
setVariablesToGetUpdated={mockSetVariablesToGetUpdated}
dependencyData={null}
/>
</TestWrapper>,
);
@@ -476,6 +508,9 @@ describe('VariableItem Integration Tests', () => {
variableData={variable}
existingVariables={{}}
onValueUpdate={mockOnValueUpdate}
variablesToGetUpdated={[]}
setVariablesToGetUpdated={mockSetVariablesToGetUpdated}
dependencyData={null}
/>
</TestWrapper>,
);
@@ -513,6 +548,9 @@ describe('VariableItem Integration Tests', () => {
variableData={variable}
existingVariables={{}}
onValueUpdate={mockOnValueUpdate}
variablesToGetUpdated={[]}
setVariablesToGetUpdated={mockSetVariablesToGetUpdated}
dependencyData={null}
/>
</TestWrapper>,
);
@@ -544,6 +582,9 @@ describe('VariableItem Integration Tests', () => {
variableData={variable}
existingVariables={{}}
onValueUpdate={mockOnValueUpdate}
variablesToGetUpdated={[]}
setVariablesToGetUpdated={mockSetVariablesToGetUpdated}
dependencyData={null}
/>
</TestWrapper>,
);

View File

@@ -9,15 +9,11 @@ import {
import useVariablesFromUrl from 'hooks/dashboard/useVariablesFromUrl';
import { useDashboard } from 'providers/Dashboard/Dashboard';
import { initializeDefaultVariables } from 'providers/Dashboard/initializeDefaultVariables';
import { getVariableFetchContext } from 'providers/Dashboard/store/dashboardVariables/dashboardVariablesStore';
import {
enqueueDescendants,
enqueueFetchAll,
} from 'providers/Dashboard/store/variableFetchStore';
import { AppState } from 'store/reducers';
import { IDashboardVariable } from 'types/api/dashboard/getAll';
import { GlobalReducer } from 'types/reducer/globalTime';
import { onUpdateVariableNode } from './util';
import VariableItem from './VariableItem';
import './DashboardVariableSelection.styles.scss';
@@ -26,6 +22,8 @@ function DashboardVariableSelection(): JSX.Element | null {
const {
setSelectedDashboard,
updateLocalStorageDashboardVariables,
variablesToGetUpdated,
setVariablesToGetUpdated,
} = useDashboard();
const { updateUrlVariable, getUrlVariables } = useVariablesFromUrl();
@@ -59,7 +57,9 @@ function DashboardVariableSelection(): JSX.Element | null {
// Trigger refetch when dependency order changes or global time changes
useEffect(() => {
enqueueFetchAll(getVariableFetchContext());
if (dependencyData?.order && dependencyData.order.length > 0) {
setVariablesToGetUpdated(dependencyData?.order || []);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [dependencyOrderKey, minTime, maxTime]);
@@ -121,14 +121,29 @@ function DashboardVariableSelection(): JSX.Element | null {
return prev;
});
// Cascade: enqueue query-type descendants for refetching
enqueueDescendants(name, getVariableFetchContext());
if (dependencyData) {
const updatedVariables: string[] = [];
onUpdateVariableNode(
name,
dependencyData.graph,
dependencyData.order,
(node) => updatedVariables.push(node),
);
setVariablesToGetUpdated((prev) => [
...new Set([...prev, ...updatedVariables.filter((v) => v !== name)]),
]);
} else {
setVariablesToGetUpdated((prev) => prev.filter((v) => v !== name));
}
},
[
// This can be removed
dashboardVariables,
updateLocalStorageDashboardVariables,
dependencyData,
updateUrlVariable,
setSelectedDashboard,
setVariablesToGetUpdated,
],
);
@@ -143,6 +158,9 @@ function DashboardVariableSelection(): JSX.Element | null {
existingVariables={dashboardVariables}
variableData={variable}
onValueUpdate={onValueUpdate}
variablesToGetUpdated={variablesToGetUpdated}
setVariablesToGetUpdated={setVariablesToGetUpdated}
dependencyData={dependencyData}
/>
);
})}

View File

@@ -5,15 +5,8 @@ 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 { useVariableFetchState } from 'hooks/dashboard/useVariableFetchState';
import useDebounce from 'hooks/useDebounce';
import { isEmpty } from 'lodash-es';
import { getVariableFetchContext } from 'providers/Dashboard/store/dashboardVariables/dashboardVariablesStore';
import {
completeFetch,
failFetch,
variableFetchStore,
} from 'providers/Dashboard/store/variableFetchStore';
import { AppState } from 'store/reducers';
import { GlobalReducer } from 'types/reducer/globalTime';
import { isRetryableError as checkIfRetryableError } from 'utils/errorUtils';
@@ -111,12 +104,6 @@ function DynamicVariableInput({
(state) => state.globalTime,
);
const {
isFetching: isVariableFetching,
hasLoaded,
state: fetchState,
} = useVariableFetchState(variableData.name || '');
// 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
@@ -208,9 +195,9 @@ function DynamicVariableInput({
],
{
enabled:
variableData.type === 'DYNAMIC' &&
!!variableData.dynamicVariablesSource &&
!!variableData.dynamicVariablesAttribute &&
(isVariableFetching || (fetchState === 'idle' && hasLoaded)),
!!variableData.dynamicVariablesAttribute,
queryFn: () =>
getFieldValues(
variableData.dynamicVariablesSource?.toLowerCase() === 'all telemetry'
@@ -225,7 +212,6 @@ function DynamicVariableInput({
maxTime,
existingQuery,
),
// eslint-disable-next-line sonarjs/cognitive-complexity
onSuccess: (data) => {
const newNormalizedValues = data.data?.normalizedValues || [];
const newRelatedValues = data.data?.relatedValues || [];
@@ -272,16 +258,6 @@ function DynamicVariableInput({
];
applyDefaultIfNeeded(allNewOptions);
}
// Complete state machine fetch (skip for search/sibling-triggered refetches)
if (variableData.name) {
const currentState = variableFetchStore.getSnapshot().states[
variableData.name
];
if (currentState === 'loading' || currentState === 'revalidating') {
completeFetch(variableData.name, getVariableFetchContext());
}
}
},
onError: (error: any) => {
if (error) {
@@ -298,16 +274,6 @@ function DynamicVariableInput({
const isRetryable = checkIfRetryableError(error);
setIsRetryableError(isRetryable);
}
// Fail state machine fetch (skip for search/sibling-triggered refetches)
if (variableData.name) {
const currentState = variableFetchStore.getSnapshot().states[
variableData.name
];
if (currentState === 'loading' || currentState === 'revalidating') {
failFetch(variableData.name, getVariableFetchContext());
}
}
},
},
);
@@ -370,7 +336,6 @@ function DynamicVariableInput({
showRetryButton={isRetryableError}
showIncompleteDataMessage={!isComplete && filteredOptionsData.length > 0}
onSearch={handleSearch}
waiting={fetchState === 'waiting'}
/>
);
}

View File

@@ -3,14 +3,8 @@ 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 { useVariableFetchState } from 'hooks/dashboard/useVariableFetchState';
import sortValues from 'lib/dashboardVariables/sortVariableValues';
import { isArray, isString } from 'lodash-es';
import { getVariableFetchContext } from 'providers/Dashboard/store/dashboardVariables/dashboardVariablesStore';
import {
completeFetch,
failFetch,
} from 'providers/Dashboard/store/variableFetchStore';
import { AppState } from 'store/reducers';
import { VariableResponseProps } from 'types/api/dashboard/variables/query';
import { GlobalReducer } from 'types/reducer/globalTime';
@@ -18,18 +12,26 @@ import { GlobalReducer } from 'types/reducer/globalTime';
import { variablePropsToPayloadVariables } from '../utils';
import SelectVariableInput from './SelectVariableInput';
import { useDashboardVariableSelectHelper } from './useDashboardVariableSelectHelper';
import { areArraysEqual } from './util';
import { areArraysEqual, checkAPIInvocation } from './util';
import { VariableItemProps } from './VariableItem';
import { queryVariableSelectStrategy } from './variableSelectStrategy/queryVariableSelectStrategy';
type QueryVariableInputProps = Pick<
VariableItemProps,
'variableData' | 'existingVariables' | 'onValueUpdate'
| 'variableData'
| 'existingVariables'
| 'onValueUpdate'
| 'variablesToGetUpdated'
| 'setVariablesToGetUpdated'
| 'dependencyData'
>;
function QueryVariableInput({
variableData,
existingVariables,
variablesToGetUpdated,
setVariablesToGetUpdated,
dependencyData,
onValueUpdate,
}: QueryVariableInputProps): JSX.Element {
const [optionsData, setOptionsData] = useState<(string | number | boolean)[]>(
@@ -41,10 +43,6 @@ function QueryVariableInput({
(state) => state.globalTime,
);
const { isFetching, state: fetchState } = useVariableFetchState(
variableData.name || '',
);
const {
tempSelection,
setTempSelection,
@@ -62,6 +60,16 @@ function QueryVariableInput({
strategy: queryVariableSelectStrategy,
});
const validVariableUpdate = useCallback((): boolean => {
if (!variableData.name) {
return false;
}
return Boolean(
variablesToGetUpdated.length &&
variablesToGetUpdated[0] === variableData.name,
);
}, [variableData.name, variablesToGetUpdated]);
const getOptions = useCallback(
// eslint-disable-next-line sonarjs/cognitive-complexity
(variablesRes: VariableResponseProps | null): void => {
@@ -95,7 +103,12 @@ function QueryVariableInput({
valueNotInList = true;
}
if (variableData.name && (valueNotInList || variableData.allSelected)) {
// 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.allSelected &&
variableData.multiSelect &&
@@ -128,6 +141,10 @@ function QueryVariableInput({
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),
);
}
}
} catch (e) {
@@ -140,6 +157,8 @@ function QueryVariableInput({
onValueUpdate,
tempSelection,
setTempSelection,
validVariableUpdate,
setVariablesToGetUpdated,
applyDefaultIfNeeded,
],
);
@@ -150,9 +169,16 @@ function QueryVariableInput({
variableData.name || '',
`${minTime}`,
`${maxTime}`,
JSON.stringify(dependencyData?.order),
],
{
enabled: variableData && isFetching,
enabled:
variableData &&
checkAPIInvocation(
variablesToGetUpdated,
variableData,
dependencyData?.parentDependencyGraph,
),
queryFn: () =>
dashboardVariablesQuery({
query: variableData.queryValue || '',
@@ -161,9 +187,9 @@ function QueryVariableInput({
refetchOnWindowFocus: false,
onSuccess: (response) => {
getOptions(response.payload);
if (variableData.name) {
completeFetch(variableData.name, getVariableFetchContext());
}
setVariablesToGetUpdated((prev) =>
prev.filter((v) => v !== variableData.name),
);
},
onError: (error: {
details: {
@@ -180,9 +206,9 @@ function QueryVariableInput({
}
setErrorMessage(message);
}
if (variableData.name) {
failFetch(variableData.name, getVariableFetchContext());
}
setVariablesToGetUpdated((prev) =>
prev.filter((v) => v !== variableData.name),
);
},
},
);
@@ -216,7 +242,6 @@ function QueryVariableInput({
loading={isLoading}
errorMessage={errorMessage}
onRetry={handleRetry}
waiting={fetchState === 'waiting'}
/>
);
}

View File

@@ -28,7 +28,6 @@ interface SelectVariableInputProps {
showRetryButton?: boolean;
showIncompleteDataMessage?: boolean;
onSearch?: (searchTerm: string) => void;
waiting?: boolean;
}
const MAX_TAG_DISPLAY_VALUES = 10;
@@ -66,7 +65,6 @@ function SelectVariableInput({
showRetryButton,
showIncompleteDataMessage,
onSearch,
waiting,
}: SelectVariableInputProps): JSX.Element {
const commonProps = useMemo(
() => ({
@@ -80,17 +78,14 @@ function SelectVariableInput({
className: 'variable-select',
popupClassName: 'dropdown-styles',
getPopupContainer: popupContainer,
style: SelectItemStyle,
showSearch: true,
bordered: false,
// changing props
'data-testid': 'variable-select',
onChange,
loading: loading || waiting,
style: {
...SelectItemStyle,
...(waiting && { opacity: 0.5, pointerEvents: 'none' as const }),
},
loading,
options,
errorMessage,
onRetry,
@@ -106,7 +101,6 @@ function SelectVariableInput({
defaultValue,
onChange,
loading,
waiting,
options,
value,
errorMessage,

View File

@@ -47,6 +47,14 @@ describe('VariableItem', () => {
variableData={mockVariableData}
existingVariables={{}}
onValueUpdate={mockOnValueUpdate}
variablesToGetUpdated={[]}
setVariablesToGetUpdated={(): void => {}}
dependencyData={{
order: [],
graph: {},
parentDependencyGraph: {},
hasCycle: false,
}}
/>
</MockQueryClientProvider>,
);
@@ -61,6 +69,14 @@ describe('VariableItem', () => {
variableData={mockVariableData}
existingVariables={{}}
onValueUpdate={mockOnValueUpdate}
variablesToGetUpdated={[]}
setVariablesToGetUpdated={(): void => {}}
dependencyData={{
order: [],
graph: {},
parentDependencyGraph: {},
hasCycle: false,
}}
/>
</MockQueryClientProvider>,
);
@@ -76,6 +92,14 @@ describe('VariableItem', () => {
variableData={mockVariableData}
existingVariables={{}}
onValueUpdate={mockOnValueUpdate}
variablesToGetUpdated={[]}
setVariablesToGetUpdated={(): void => {}}
dependencyData={{
order: [],
graph: {},
parentDependencyGraph: {},
hasCycle: false,
}}
/>
</MockQueryClientProvider>,
);
@@ -109,6 +133,14 @@ describe('VariableItem', () => {
variableData={mockCustomVariableData}
existingVariables={{}}
onValueUpdate={mockOnValueUpdate}
variablesToGetUpdated={[]}
setVariablesToGetUpdated={(): void => {}}
dependencyData={{
order: [],
graph: {},
parentDependencyGraph: {},
hasCycle: false,
}}
/>
</MockQueryClientProvider>,
);
@@ -131,6 +163,14 @@ describe('VariableItem', () => {
variableData={customVariableData}
existingVariables={{}}
onValueUpdate={mockOnValueUpdate}
variablesToGetUpdated={[]}
setVariablesToGetUpdated={(): void => {}}
dependencyData={{
order: [],
graph: {},
parentDependencyGraph: {},
hasCycle: false,
}}
/>
</MockQueryClientProvider>,
);
@@ -145,6 +185,14 @@ describe('VariableItem', () => {
variableData={mockCustomVariableData}
existingVariables={{}}
onValueUpdate={mockOnValueUpdate}
variablesToGetUpdated={[]}
setVariablesToGetUpdated={(): void => {}}
dependencyData={{
order: [],
graph: {},
parentDependencyGraph: {},
hasCycle: false,
}}
/>
</MockQueryClientProvider>,
);

View File

@@ -1,6 +1,7 @@
import { memo } from 'react';
import { InfoCircleOutlined } from '@ant-design/icons';
import { Tooltip, Typography } from 'antd';
import { IDependencyData } from 'providers/Dashboard/store/dashboardVariables/dashboardVariablesStoreTypes';
import { IDashboardVariable } from 'types/api/dashboard/getAll';
import CustomVariableInput from './CustomVariableInput';
@@ -20,12 +21,18 @@ export interface VariableItemProps {
allSelected: boolean,
haveCustomValuesSelected?: boolean,
) => void;
variablesToGetUpdated: string[];
setVariablesToGetUpdated: React.Dispatch<React.SetStateAction<string[]>>;
dependencyData: IDependencyData | null;
}
function VariableItem({
variableData,
onValueUpdate,
existingVariables,
variablesToGetUpdated,
setVariablesToGetUpdated,
dependencyData,
}: VariableItemProps): JSX.Element {
const { name, description, type: variableType } = variableData;
@@ -58,6 +65,9 @@ function VariableItem({
variableData={variableData}
onValueUpdate={onValueUpdate}
existingVariables={existingVariables}
variablesToGetUpdated={variablesToGetUpdated}
setVariablesToGetUpdated={setVariablesToGetUpdated}
dependencyData={dependencyData}
/>
)}
{variableType === 'DYNAMIC' && (

View File

@@ -8,6 +8,14 @@ import '@testing-library/jest-dom/extend-expect';
import VariableItem from '../VariableItem';
const mockOnValueUpdate = jest.fn();
const mockSetVariablesToGetUpdated = jest.fn();
const baseDependencyData = {
order: [],
graph: {},
parentDependencyGraph: {},
hasCycle: false,
};
const TEST_VARIABLE_ID = 'test_variable';
const VARIABLE_SELECT_TESTID = 'variable-select';
@@ -23,6 +31,9 @@ const renderVariableItem = (
variableData={variableData}
existingVariables={{}}
onValueUpdate={mockOnValueUpdate}
variablesToGetUpdated={[]}
setVariablesToGetUpdated={mockSetVariablesToGetUpdated}
dependencyData={baseDependencyData}
/>
</MockQueryClientProvider>,
);

View File

@@ -1,5 +1,5 @@
import { OptionData } from 'components/NewSelect/types';
import { isNull } from 'lodash-es';
import { isEmpty, isNull } from 'lodash-es';
import {
IDashboardVariables,
IDependencyData,
@@ -284,6 +284,33 @@ export const onUpdateVariableNode = (
});
};
export const checkAPIInvocation = (
variablesToGetUpdated: string[],
variableData: IDashboardVariable,
parentDependencyGraph?: VariableGraph,
): boolean => {
if (isEmpty(variableData.name)) {
return false;
}
if (isEmpty(parentDependencyGraph)) {
return false;
}
// if no dependency then true
const haveDependency =
parentDependencyGraph?.[variableData.name || '']?.length > 0;
if (!haveDependency) {
return true;
}
// if variable is in the list and has dependency then check if its the top element in the queue then true else false
return (
variablesToGetUpdated.length > 0 &&
variablesToGetUpdated[0] === variableData.name
);
};
export const getOptionsForDynamicVariable = (
normalizedValues: (string | number | boolean)[],
relatedValues: string[],

View File

@@ -49,11 +49,15 @@ const mockDashboard = {
// Mock the dashboard provider with stable functions to prevent infinite loops
const mockSetSelectedDashboard = jest.fn();
const mockUpdateLocalStorageDashboardVariables = jest.fn();
const mockSetVariablesToGetUpdated = jest.fn();
jest.mock('providers/Dashboard/Dashboard', () => ({
useDashboard: (): any => ({
selectedDashboard: mockDashboard,
setSelectedDashboard: mockSetSelectedDashboard,
updateLocalStorageDashboardVariables: mockUpdateLocalStorageDashboardVariables,
variablesToGetUpdated: ['env'], // Stable initial value
setVariablesToGetUpdated: mockSetVariablesToGetUpdated,
}),
}));

View File

@@ -6,7 +6,6 @@ import { QueryParams } from 'constants/query';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { populateMultipleResults } from 'container/NewWidget/LeftContainer/WidgetGraph/util';
import { CustomTimeType } from 'container/TopNav/DateTimeSelectionV2/types';
import { useDependentVariablesFetching } from 'hooks/dashboard/useVariableFetchState';
import { useGetQueryRange } from 'hooks/queryBuilder/useGetQueryRange';
import { useIntersectionObserver } from 'hooks/useIntersectionObserver';
import { GetQueryResultsProps } from 'lib/dashboard/getQueryResults';
@@ -65,12 +64,8 @@ function GridCardGraph({
toScrollWidgetId,
setToScrollWidgetId,
setDashboardQueryRangeCalled,
variablesToGetUpdated,
} = useDashboard();
const anyDependentVarFetching = useDependentVariablesFetching(
widget.query,
variables,
);
const { minTime, maxTime, selectedTime: globalSelectedInterval } = useSelector<
AppState,
GlobalReducer
@@ -182,6 +177,27 @@ function GridCardGraph({
[requestData.query],
);
// Bring back dependency on variable chaining for panels to refetch,
// but only for non-dynamic variables. We derive a stable token from
// the head of the variablesToGetUpdated queue when it's non-dynamic.
const nonDynamicVariableChainToken = useMemo(() => {
if (!variablesToGetUpdated || variablesToGetUpdated.length === 0) {
return undefined;
}
if (!variables) {
return undefined;
}
const headName = variablesToGetUpdated[0];
const variableObj = Object.values(variables).find(
(variable) => variable?.name === headName,
);
if (variableObj && variableObj.type !== 'DYNAMIC') {
return headName;
}
return undefined;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [variablesToGetUpdated, variables]);
const queryResponse = useGetQueryRange(
{
...requestData,
@@ -221,6 +237,9 @@ function GridCardGraph({
...(customTimeRange && customTimeRange.startTime && customTimeRange.endTime
? [customTimeRange.startTime, customTimeRange.endTime]
: []),
// Include non-dynamic variable chaining token to drive refetches
// only when a non-dynamic variable is at the head of the queue
...(nonDynamicVariableChainToken ? [nonDynamicVariableChainToken] : []),
],
retry(failureCount, error): boolean {
if (
@@ -233,7 +252,7 @@ function GridCardGraph({
return failureCount < 2;
},
keepPreviousData: true,
enabled: queryEnabledCondition && !anyDependentVarFetching,
enabled: queryEnabledCondition && !nonDynamicVariableChainToken,
refetchOnMount: false,
onError: (error) => {
const errorMessage =
@@ -299,7 +318,9 @@ function GridCardGraph({
version={version}
threshold={threshold}
headerMenuList={menuList}
isFetchingResponse={queryResponse.isFetching || anyDependentVarFetching}
isFetchingResponse={
queryResponse.isFetching || variablesToGetUpdated.length > 0
}
setRequestData={setRequestData}
onClickHandler={onClickHandler}
onDragSelect={onDragSelect}

View File

@@ -1,280 +0,0 @@
import {
useCallback,
useEffect,
useMemo,
useRef,
useSyncExternalStore,
} from 'react';
import { IDashboardVariables } from 'providers/Dashboard/store/dashboardVariables/dashboardVariablesStoreTypes';
import {
IVariableFetchStoreState,
VariableFetchState,
variableFetchStore,
} from 'providers/Dashboard/store/variableFetchStore';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { useDashboardVariablesSelector } from './useDashboardVariables';
/**
* Generic selector hook for the variable fetch store.
* Same pattern as useDashboardVariablesSelector.
*/
const useVariableFetchSelector = <T>(
selector: (state: IVariableFetchStoreState) => T,
): T => {
const selectorRef = useRef(selector);
selectorRef.current = selector;
const getSnapshot = useCallback(
() => selectorRef.current(variableFetchStore.getSnapshot()),
[],
);
return useSyncExternalStore(variableFetchStore.subscribe, getSnapshot);
};
interface UseVariableFetchStateReturn {
/** The current fetch state for this variable */
state: VariableFetchState;
/** True if any ancestor variable is not idle (still loading/waiting) */
isBlocked: boolean;
/** True if this variable is actively fetching (loading or revalidating) */
isFetching: boolean;
/** True if this variable has completed at least one fetch cycle */
hasLoaded: boolean;
}
/**
* Per-variable hook that exposes the fetch state of a single variable.
* Reusable by both variable input components and panel components.
*
* Subscribes to both variableFetchStore (for states) and
* dashboardVariablesStore (for parent graph) to compute derived values.
*/
export function useVariableFetchState(
variableName: string,
): UseVariableFetchStateReturn {
const state = useVariableFetchSelector(
(s) => s.states[variableName] || 'idle',
) as VariableFetchState;
const allStates = useVariableFetchSelector((s) => s.states);
const parentGraph = useDashboardVariablesSelector(
(s) => s.dependencyData?.parentDependencyGraph,
);
const lastUpdated = useVariableFetchSelector(
(s) => s.lastUpdated[variableName] || 0,
);
const isFetching = state === 'loading' || state === 'revalidating';
const hasLoaded = lastUpdated > 0;
const isBlocked = useMemo(() => {
const parents = parentGraph?.[variableName] || [];
return parents.some((p) => (allStates[p] || 'idle') !== 'idle');
}, [parentGraph, variableName, allStates]);
return { state, isBlocked, isFetching, hasLoaded };
}
// ---------------------------------------------------------------------------
// Query-text extraction utilities for finding variable references in widgets
// ---------------------------------------------------------------------------
function escapeRegExp(str: string): string {
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
/**
* Collects every text fragment from a Query that may contain variable
* references (filter values, filter expressions, raw SQL, PromQL).
*/
// eslint-disable-next-line sonarjs/cognitive-complexity
function collectQueryTexts(query: Query): string[] {
const texts: string[] = [];
if (query.builder?.queryData) {
for (const qd of query.builder.queryData) {
if (qd.filters?.items) {
for (const item of qd.filters.items) {
if (typeof item.value === 'string') {
texts.push(item.value);
} else if (Array.isArray(item.value)) {
for (const v of item.value) {
if (typeof v === 'string') {
texts.push(v);
}
}
}
}
}
if (qd.filter?.expression) {
texts.push(qd.filter.expression);
}
}
}
if (query.clickhouse_sql) {
for (const cs of query.clickhouse_sql) {
if (cs.query) {
texts.push(cs.query);
}
}
}
if (query.promql) {
for (const pq of query.promql) {
if (pq.query) {
texts.push(pq.query);
}
}
}
return texts;
}
/**
* Returns the names of all dashboard variables referenced in a widget query.
* Supports {{.var}}, {{var}}, $var, and [[var]] syntaxes.
*/
export function getReferencedVariableNames(
query: Query,
variables: IDashboardVariables,
): string[] {
const texts = collectQueryTexts(query);
const allText = texts.join(' ');
if (!allText) {
return [];
}
const names: string[] = [];
Object.values(variables).forEach((v) => {
if (!v.name) {
return;
}
const escaped = escapeRegExp(v.name);
const pattern = new RegExp(
`\\{\\{\\s*\\.?\\s*${escaped}\\s*\\}\\}` +
`|\\$${escaped}(?![a-zA-Z0-9_.\\-])` +
`|\\[\\[\\s*${escaped}\\s*\\]\\]`,
);
if (pattern.test(allText)) {
names.push(v.name);
}
});
return names;
}
// ---------------------------------------------------------------------------
// Panel-targeted hook
// ---------------------------------------------------------------------------
/**
* Returns true when a widget's query should be blocked / show a loader
* because a dependent variable is not ready.
*
* Phase 1 Initial load:
* Checks the fetch-state of every variable referenced by `query`.
* If any dependent variable is non-idle → returns true (show loading).
* Once they all settle the hook transitions to Phase 2.
*
* Phase 2 Post initial load:
* Keeps a map of dependent variable name → serialised selectedValue.
* When a dependent variable's selectedValue changes:
* • allSelected = true → returns true until that variable is idle.
* • allSelected = false → returns false (react-query picks up the
* query-key change automatically).
*/
// eslint-disable-next-line sonarjs/cognitive-complexity
export function useDependentVariablesFetching(
query: Query | undefined,
variables: IDashboardVariables | undefined,
): boolean {
const allStates = useVariableFetchSelector((s) => s.states);
// ---- 1. Derive dependent variable names from the widget query ----------
const dependentVarNames = useMemo(() => {
if (!query || !variables) {
return [] as string[];
}
return getReferencedVariableNames(query, variables);
}, [query, variables]);
// ---- 2. Are any dependent variables currently non-idle? ----------------
const anyDependentNonIdle = useMemo(
() =>
dependentVarNames.some(
(n) => allStates[n] !== undefined && allStates[n] !== 'idle',
),
[dependentVarNames, allStates],
);
// ---- 3. Track initial fetch cycle (non-idle → idle) --------------------
const didStartFetching = useRef(false);
const hasCompletedInitialFetch = useRef(false);
useEffect(() => {
if (anyDependentNonIdle) {
didStartFetching.current = true;
} else if (didStartFetching.current && !hasCompletedInitialFetch.current) {
hasCompletedInitialFetch.current = true;
}
}, [anyDependentNonIdle]);
// ---- 4. Track selectedValues snapshot ----------------------------------
const lastSettledValues = useRef<Record<string, string>>({});
const currentValues = useMemo(() => {
if (!variables) {
return {} as Record<string, string>;
}
const values: Record<string, string> = {};
Object.values(variables).forEach((v) => {
if (v.name && dependentVarNames.includes(v.name)) {
values[v.name] = JSON.stringify(v.selectedValue);
}
});
return values;
}, [variables, dependentVarNames]);
// Update snapshot whenever all dependent vars are idle
useEffect(() => {
if (!anyDependentNonIdle) {
lastSettledValues.current = currentValues;
}
}, [anyDependentNonIdle, currentValues]);
// ---- 5. Compute result ------------------------------------------------
// Phase 1 initial load: block while any dependent variable is loading
if (!hasCompletedInitialFetch.current) {
return anyDependentNonIdle;
}
// Phase 2 post initial load: block only for allSelected vars that
// have a changed value and are still fetching
if (!variables) {
return false;
}
return dependentVarNames.some((name) => {
const variable = Object.values(variables).find((v) => v.name === name);
if (!variable?.allSelected) {
return false;
}
// For allSelected variables: block if their fetch state is non-idle,
// meaning the value (= full option set) is stale or about to change.
const state = allStates[name];
if (!state || state === 'idle') {
return false;
}
// Only block if the value has actually drifted from the last settled
// snapshot — avoids blocking when an unrelated refetch is in progress.
return currentValues[name] !== lastSettledValues.current[name];
});
}

View File

@@ -0,0 +1,62 @@
import { isInvalidPlotValue, normalizePlotValue } from '../dataUtils';
describe('dataUtils', () => {
describe('isInvalidPlotValue', () => {
it('treats null and undefined as invalid', () => {
expect(isInvalidPlotValue(null)).toBe(true);
expect(isInvalidPlotValue(undefined)).toBe(true);
});
it('treats finite numbers as valid and non-finite as invalid', () => {
expect(isInvalidPlotValue(0)).toBe(false);
expect(isInvalidPlotValue(123.45)).toBe(false);
expect(isInvalidPlotValue(Number.NaN)).toBe(true);
expect(isInvalidPlotValue(Infinity)).toBe(true);
expect(isInvalidPlotValue(-Infinity)).toBe(true);
});
it('treats well-formed numeric strings as valid', () => {
expect(isInvalidPlotValue('0')).toBe(false);
expect(isInvalidPlotValue('123.45')).toBe(false);
expect(isInvalidPlotValue('-1')).toBe(false);
});
it('treats Infinity/NaN string variants and non-numeric strings as invalid', () => {
expect(isInvalidPlotValue('+Inf')).toBe(true);
expect(isInvalidPlotValue('-Inf')).toBe(true);
expect(isInvalidPlotValue('Infinity')).toBe(true);
expect(isInvalidPlotValue('-Infinity')).toBe(true);
expect(isInvalidPlotValue('NaN')).toBe(true);
expect(isInvalidPlotValue('not-a-number')).toBe(true);
});
it('treats non-number, non-string values as valid (left to caller)', () => {
expect(isInvalidPlotValue({})).toBe(false);
expect(isInvalidPlotValue([])).toBe(false);
expect(isInvalidPlotValue(true)).toBe(false);
});
});
describe('normalizePlotValue', () => {
it('returns null for invalid values detected by isInvalidPlotValue', () => {
expect(normalizePlotValue(null)).toBeNull();
expect(normalizePlotValue(undefined)).toBeNull();
expect(normalizePlotValue(NaN)).toBeNull();
expect(normalizePlotValue(Infinity)).toBeNull();
expect(normalizePlotValue('-Infinity')).toBeNull();
expect(normalizePlotValue('not-a-number')).toBeNull();
});
it('parses valid numeric strings into numbers', () => {
expect(normalizePlotValue('0')).toBe(0);
expect(normalizePlotValue('123.45')).toBe(123.45);
expect(normalizePlotValue('-1')).toBe(-1);
});
it('passes through valid numbers unchanged', () => {
expect(normalizePlotValue(0)).toBe(0);
expect(normalizePlotValue(123)).toBe(123);
expect(normalizePlotValue(42.5)).toBe(42.5);
});
});
});

View File

@@ -0,0 +1,201 @@
import uPlot from 'uplot';
import { DistributionType } from '../../config/types';
import * as scaleUtils from '../scale';
describe('scale utils', () => {
describe('normalizeLogScaleLimits', () => {
it('returns limits unchanged when distribution is not logarithmic', () => {
const limits = {
min: 1,
max: 100,
softMin: 5,
softMax: 50,
};
const result = scaleUtils.normalizeLogScaleLimits({
distr: DistributionType.Linear,
logBase: 10,
limits,
});
expect(result).toEqual(limits);
});
it('snaps positive limits to powers of the log base when distribution is logarithmic', () => {
const result = scaleUtils.normalizeLogScaleLimits({
distr: DistributionType.Logarithmic,
logBase: 10,
limits: {
min: 3,
max: 900,
softMin: 12,
softMax: 85,
},
});
expect(result.min).toBe(1); // 10^0
expect(result.max).toBe(1000); // 10^3
expect(result.softMin).toBe(10); // 10^1
expect(result.softMax).toBe(100); // 10^2
});
});
describe('getDistributionConfig', () => {
it('returns empty config for time scales', () => {
const config = scaleUtils.getDistributionConfig({
time: true,
distr: DistributionType.Linear,
logBase: 2,
});
expect(config).toEqual({});
});
it('returns linear distribution settings for non-time scales', () => {
const config = scaleUtils.getDistributionConfig({
time: false,
distr: DistributionType.Linear,
logBase: 2,
});
expect(config.distr).toBe(1);
expect(config.log).toBe(2);
});
it('returns log distribution settings for non-time scales', () => {
const config = scaleUtils.getDistributionConfig({
time: false,
distr: DistributionType.Logarithmic,
logBase: 10,
});
expect(config.distr).toBe(3);
expect(config.log).toBe(10);
});
});
describe('getRangeConfig', () => {
it('computes range config and fixed range flags correctly', () => {
const {
rangeConfig,
hardMinOnly,
hardMaxOnly,
hasFixedRange,
} = scaleUtils.getRangeConfig(0, 100, null, null, 0.1, 0.2);
expect(rangeConfig.min).toEqual({
pad: 0.1,
hard: 0,
soft: undefined,
mode: 3,
});
expect(rangeConfig.max).toEqual({
pad: 0.2,
hard: 100,
soft: undefined,
mode: 3,
});
expect(hardMinOnly).toBe(true);
expect(hardMaxOnly).toBe(true);
expect(hasFixedRange).toBe(true);
});
});
describe('createRangeFunction', () => {
it('returns [dataMin, dataMax] when no fixed range and no data', () => {
const params = {
rangeConfig: {} as uPlot.Range.Config,
hardMinOnly: false,
hardMaxOnly: false,
hasFixedRange: false,
min: null,
max: null,
};
const rangeFn = scaleUtils.createRangeFunction(params);
const u = ({
scales: {
y: {
distr: 1,
log: 10,
},
},
} as unknown) as uPlot;
const result = rangeFn(
u,
(null as unknown) as number,
(null as unknown) as number,
'y',
);
expect(result).toEqual([null, null]);
});
it('applies hard min/max for linear scale when only hard limits are set', () => {
const params = {
rangeConfig: {} as uPlot.Range.Config,
hardMinOnly: true,
hardMaxOnly: true,
hasFixedRange: true,
min: 0,
max: 100,
};
const rangeFn = scaleUtils.createRangeFunction(params);
// Use an undefined distr so the range function skips calling uPlot.rangeNum
// and we can focus on the behavior of applyHardLimits.
const u = ({
scales: {
y: {
distr: undefined,
log: 10,
},
},
} as unknown) as uPlot;
const result = rangeFn(u, 10, 20, 'y');
// After applyHardLimits, the returned range should respect configured min/max
expect(result).toEqual([0, 100]);
});
});
describe('adjustSoftLimitsWithThresholds', () => {
it('returns original soft limits when there are no thresholds', () => {
const result = scaleUtils.adjustSoftLimitsWithThresholds(1, 5, [], 'ms');
expect(result).toEqual({ softMin: 1, softMax: 5 });
});
it('expands soft limits to include threshold min/max values', () => {
const result = scaleUtils.adjustSoftLimitsWithThresholds(
3,
6,
[{ thresholdValue: 2 }, { thresholdValue: 8 }],
'ms',
);
// min should be pulled down to the smallest threshold value
expect(result.softMin).toBe(2);
// max should be pushed up to the largest threshold value
expect(result.softMax).toBe(8);
});
});
describe('getFallbackMinMaxTimeStamp', () => {
it('returns a 24-hour window ending at approximately now', () => {
const { fallbackMin, fallbackMax } = scaleUtils.getFallbackMinMaxTimeStamp();
// Difference should be exactly one day in seconds
expect(fallbackMax - fallbackMin).toBe(86400);
// Both should be reasonable timestamps (not NaN or negative)
expect(fallbackMin).toBeGreaterThan(0);
expect(fallbackMax).toBeGreaterThan(fallbackMin);
});
});
});

View File

@@ -0,0 +1,36 @@
import { findMinMaxThresholdValues } from '../threshold';
describe('findMinMaxThresholdValues', () => {
it('returns [null, null] when thresholds array is empty or missing', () => {
expect(findMinMaxThresholdValues([], 'ms')).toEqual([null, null]);
// @ts-expect-error intentional undefined to cover defensive branch
expect(findMinMaxThresholdValues(undefined, 'ms')).toEqual([null, null]);
});
it('returns min and max from thresholdValue when units are not provided', () => {
const thresholds = [
{ thresholdValue: 5 },
{ thresholdValue: 1 },
{ thresholdValue: 10 },
];
const [min, max] = findMinMaxThresholdValues(thresholds);
expect(min).toBe(1);
expect(max).toBe(10);
});
it('ignores thresholds without a value or with unconvertible units', () => {
const thresholds = [
// Should be ignored: convertValue returns null for unknown unit
{ thresholdValue: 100, thresholdUnit: 'unknown-unit' },
// Should be used
{ thresholdValue: 4 },
];
const [min, max] = findMinMaxThresholdValues(thresholds, 'ms');
expect(min).toBe(4);
expect(max).toBe(4);
});
});

View File

@@ -84,6 +84,8 @@ const DashboardContext = createContext<IDashboardContext>({
toScrollWidgetId: '',
setToScrollWidgetId: () => {},
updateLocalStorageDashboardVariables: () => {},
variablesToGetUpdated: [],
setVariablesToGetUpdated: () => {},
dashboardQueryRangeCalled: false,
setDashboardQueryRangeCalled: () => {},
selectedRowWidgetId: '',
@@ -181,6 +183,10 @@ export function DashboardProvider({
exact: true,
});
const [variablesToGetUpdated, setVariablesToGetUpdated] = useState<string[]>(
[],
);
const [layouts, setLayouts] = useState<Layout[]>([]);
const [panelMap, setPanelMap] = useState<
@@ -511,6 +517,8 @@ export function DashboardProvider({
updatedTimeRef,
setToScrollWidgetId,
updateLocalStorageDashboardVariables,
variablesToGetUpdated,
setVariablesToGetUpdated,
dashboardQueryRangeCalled,
setDashboardQueryRangeCalled,
selectedRowWidgetId,
@@ -533,6 +541,8 @@ export function DashboardProvider({
toScrollWidgetId,
updateLocalStorageDashboardVariables,
currentDashboard,
variablesToGetUpdated,
setVariablesToGetUpdated,
dashboardQueryRangeCalled,
setDashboardQueryRangeCalled,
selectedRowWidgetId,

View File

@@ -1,5 +1,4 @@
import createStore from '../store';
import { VariableFetchContext } from '../variableFetchStore';
import { IDashboardVariablesStoreState } from './dashboardVariablesStoreTypes';
import {
computeDerivedValues,
@@ -11,8 +10,6 @@ const initialState: IDashboardVariablesStoreState = {
variables: {},
sortedVariablesArray: [],
dependencyData: null,
variableTypes: {},
dynamicVariableOrder: [],
};
export const dashboardVariablesStore = createStore<IDashboardVariablesStoreState>(
@@ -58,17 +55,3 @@ export function updateDashboardVariablesStore({
updateDerivedValues(draft);
});
}
/**
* Read current store snapshot as VariableFetchContext.
* Used by components to pass context to variableFetchStore actions
* without creating a circular import.
*/
export function getVariableFetchContext(): VariableFetchContext {
const state = dashboardVariablesStore.getSnapshot();
return {
variableTypes: state.variableTypes,
dynamicVariableOrder: state.dynamicVariableOrder,
dependencyData: state.dependencyData,
};
}

View File

@@ -1,7 +1,4 @@
import {
IDashboardVariable,
TVariableQueryType,
} from 'types/api/dashboard/getAll';
import { IDashboardVariable } from 'types/api/dashboard/getAll';
export type VariableGraph = Record<string, string[]>;
@@ -27,12 +24,6 @@ export interface IDashboardVariablesStoreState {
// Derived: dependency data for QUERY variables
dependencyData: IDependencyData | null;
// Derived: variable name → type mapping
variableTypes: Record<string, TVariableQueryType>;
// Derived: display-ordered list of dynamic variable names
dynamicVariableOrder: string[];
}
export interface IUseDashboardVariablesReturn {

View File

@@ -2,10 +2,7 @@ import {
buildDependencies,
buildDependencyGraph,
} from 'container/DashboardContainer/DashboardVariablesSelection/util';
import {
IDashboardVariable,
TVariableQueryType,
} from 'types/api/dashboard/getAll';
import { IDashboardVariable } from 'types/api/dashboard/getAll';
import { initializeVariableFetchStore } from '../variableFetchStore';
import {
@@ -67,42 +64,23 @@ export function buildDependencyData(
}
/**
* Build a variable name → type mapping from sorted variables array
*/
export function buildVariableTypesMap(
sortedVariablesArray: IDashboardVariable[],
): Record<string, TVariableQueryType> {
const types: Record<string, TVariableQueryType> = {};
sortedVariablesArray.forEach((v) => {
if (v.name) {
types[v.name] = v.type;
}
});
return types;
}
/**
* Build display-ordered list of dynamic variable names
*/
export function buildDynamicVariableOrder(
sortedVariablesArray: IDashboardVariable[],
): string[] {
return sortedVariablesArray
.filter((v) => v.type === 'DYNAMIC' && v.name)
.map((v) => v.name as string);
}
/**
* Initialize the variable fetch store with variable names
* Initialize the variable fetch store with the computed dependency data
*/
function initializeFetchStore(
sortedVariablesArray: IDashboardVariable[],
dependencyData: IDependencyData | null,
): void {
const allVariableNames = sortedVariablesArray
.map((v) => v.name)
.filter((name): name is string => !!name);
if (dependencyData) {
const allVariableNames = sortedVariablesArray
.map((v) => v.name)
.filter((name): name is string => !!name);
initializeVariableFetchStore(allVariableNames);
initializeVariableFetchStore(
allVariableNames,
dependencyData.graph,
dependencyData.parentDependencyGraph,
);
}
}
/**
@@ -114,25 +92,15 @@ export function computeDerivedValues(
variables: IDashboardVariablesStoreState['variables'],
): Pick<
IDashboardVariablesStoreState,
| 'sortedVariablesArray'
| 'dependencyData'
| 'variableTypes'
| 'dynamicVariableOrder'
'sortedVariablesArray' | 'dependencyData'
> {
const sortedVariablesArray = buildSortedVariablesArray(variables);
const dependencyData = buildDependencyData(sortedVariablesArray);
const variableTypes = buildVariableTypesMap(sortedVariablesArray);
const dynamicVariableOrder = buildDynamicVariableOrder(sortedVariablesArray);
// Initialize the variable fetch store when dependency data is computed
initializeFetchStore(sortedVariablesArray);
initializeFetchStore(sortedVariablesArray, dependencyData);
return {
sortedVariablesArray,
dependencyData,
variableTypes,
dynamicVariableOrder,
};
return { sortedVariablesArray, dependencyData };
}
/**
@@ -144,11 +112,7 @@ export function updateDerivedValues(
): void {
draft.sortedVariablesArray = buildSortedVariablesArray(draft.variables);
draft.dependencyData = buildDependencyData(draft.sortedVariablesArray);
draft.variableTypes = buildVariableTypesMap(draft.sortedVariablesArray);
draft.dynamicVariableOrder = buildDynamicVariableOrder(
draft.sortedVariablesArray,
);
// Initialize the variable fetch store when dependency data is updated
initializeFetchStore(draft.sortedVariablesArray);
initializeFetchStore(draft.sortedVariablesArray, draft.dependencyData);
}

View File

@@ -1,7 +1,5 @@
import { VariableGraph } from 'container/DashboardContainer/DashboardVariablesSelection/util';
import { TVariableQueryType } from 'types/api/dashboard/getAll';
import { IDashboardVariablesStoreState } from './dashboardVariables/dashboardVariablesStoreTypes';
import createStore from './store';
// Fetch state for each variable
@@ -16,266 +14,44 @@ export interface IVariableFetchStoreState {
// Per-variable fetch state
states: Record<string, VariableFetchState>;
// Track last update timestamp per variable
// Dependency graphs (set once when variables change)
dependencyGraph: VariableGraph; // variable -> children that depend on it
parentGraph: VariableGraph; // variable -> parents it depends on
// Track last update timestamp per variable to trigger re-fetches
lastUpdated: Record<string, number>;
// JSON-serialized selectedValue per variable — used to detect value changes
lastValues: Record<string, string>;
}
/**
* Context from dashboardVariablesStore needed by fetch actions.
* Passed as parameter to avoid circular imports.
*/
export type VariableFetchContext = Pick<
IDashboardVariablesStoreState,
'variableTypes' | 'dynamicVariableOrder' | 'dependencyData'
>;
const initialState: IVariableFetchStoreState = {
states: {},
dependencyGraph: {},
parentGraph: {},
lastUpdated: {},
lastValues: {},
};
export const variableFetchStore = createStore<IVariableFetchStoreState>(
initialState,
);
// ============== Helpers ==============
/**
* BFS to collect all transitive descendants of a node in the dependency graph.
*/
function collectAllDescendants(node: string, graph: VariableGraph): string[] {
const descendants: string[] = [];
const visited = new Set<string>();
const queue = [...(graph[node] || [])];
while (queue.length > 0) {
const current = queue.shift();
if (!current || visited.has(current)) {
continue;
}
visited.add(current);
descendants.push(current);
(graph[current] || []).forEach((child) => {
if (!visited.has(child)) {
queue.push(child);
}
});
}
return descendants;
}
/**
* Check if all query variables are settled (idle or error).
*/
function areAllQueryVarsSettled(
states: Record<string, VariableFetchState>,
variableTypes: Record<string, TVariableQueryType>,
): boolean {
return Object.entries(variableTypes)
.filter(([, type]) => type === 'QUERY')
.every(([name]) => states[name] === 'idle' || states[name] === 'error');
}
/**
* Transition waiting dynamic variables to loading/revalidating.
*/
function unlockDynamicVariables(
draft: IVariableFetchStoreState,
dynamicVariableOrder: string[],
): void {
dynamicVariableOrder.forEach((dynName) => {
if (draft.states[dynName] === 'waiting') {
const hasData = (draft.lastUpdated[dynName] || 0) > 0;
draft.states[dynName] = hasData ? 'revalidating' : 'loading';
}
});
}
// ============== Actions ==============
/**
* Initialize the store with variable names.
* Called when dashboard variables change — sets up state entries.
* Initialize the store with dependency graphs and set initial states
*/
export function initializeVariableFetchStore(variableNames: string[]): void {
export function initializeVariableFetchStore(
variableNames: string[],
dependencyGraph: VariableGraph,
parentGraph: VariableGraph,
): void {
variableFetchStore.update((draft) => {
// Initialize all variables to idle, preserving existing states
draft.dependencyGraph = dependencyGraph;
draft.parentGraph = parentGraph;
// Initialize all variables to idle, preserving existing ready states
variableNames.forEach((name) => {
if (!draft.states[name]) {
draft.states[name] = 'idle';
}
if (!draft.lastValues[name]) {
draft.lastValues[name] = '';
}
});
// Clean up stale entries for variables that no longer exist
const nameSet = new Set(variableNames);
Object.keys(draft.states).forEach((name) => {
if (!nameSet.has(name)) {
delete draft.states[name];
delete draft.lastValues[name];
delete draft.lastUpdated[name];
}
});
});
}
/**
* Mark a variable as completed. Unblocks waiting query-type children.
* If all query variables are now settled, unlocks dynamic variables.
*/
export function completeFetch(name: string, ctx: VariableFetchContext): void {
const { dependencyData, variableTypes, dynamicVariableOrder } = ctx;
variableFetchStore.update((draft) => {
draft.states[name] = 'idle';
draft.lastUpdated[name] = Date.now();
if (!dependencyData) {
return;
}
const { graph } = dependencyData;
// Unblock waiting query-type children
const children = graph[name] || [];
children.forEach((child) => {
if (variableTypes[child] === 'QUERY' && draft.states[child] === 'waiting') {
const hasData = (draft.lastUpdated[child] || 0) > 0;
draft.states[child] = hasData ? 'revalidating' : 'loading';
}
});
// If all query vars are settled, unlock dynamic variables
if (
variableTypes[name] === 'QUERY' &&
areAllQueryVarsSettled(draft.states, variableTypes)
) {
unlockDynamicVariables(draft, dynamicVariableOrder);
}
});
}
/**
* Mark a variable as errored. Sets query-type descendants to idle
* (they can't proceed without this parent).
* If all query variables are now settled, unlocks dynamic variables.
*/
export function failFetch(name: string, ctx: VariableFetchContext): void {
const { dependencyData, variableTypes, dynamicVariableOrder } = ctx;
variableFetchStore.update((draft) => {
draft.states[name] = 'error';
if (!dependencyData) {
return;
}
const { graph } = dependencyData;
// Set query-type descendants to idle (can't fetch without parent)
const descendants = collectAllDescendants(name, graph);
descendants.forEach((desc) => {
if (variableTypes[desc] === 'QUERY') {
draft.states[desc] = 'idle';
}
});
// If all query vars are settled (error counts), unlock dynamic vars
if (
variableTypes[name] === 'QUERY' &&
areAllQueryVarsSettled(draft.states, variableTypes)
) {
unlockDynamicVariables(draft, dynamicVariableOrder);
}
});
}
/**
* Cascade a value change to query-type descendants.
* Called when a user changes a variable's value (not from a fetch cycle).
*
* Direct children whose parents are all settled start immediately.
* Deeper descendants wait until their parents complete (BFS order
* ensures parents are set before children within a single update).
*/
export function enqueueDescendants(
name: string,
ctx: VariableFetchContext,
): void {
const { dependencyData, variableTypes } = ctx;
if (!dependencyData) {
return;
}
const { graph, parentDependencyGraph } = dependencyData;
variableFetchStore.update((draft) => {
const descendants = collectAllDescendants(name, graph);
descendants.forEach((desc) => {
if (variableTypes[desc] !== 'QUERY') {
return;
}
const parents = parentDependencyGraph[desc] || [];
const allParentsSettled = parents.every(
(p) => draft.states[p] === 'idle' || draft.states[p] === 'error',
);
if (allParentsSettled) {
const hasData = (draft.lastUpdated[desc] || 0) > 0;
draft.states[desc] = hasData ? 'revalidating' : 'loading';
} else {
draft.states[desc] = 'waiting';
}
});
});
}
/**
* Start a full fetch cycle for all fetchable variables.
* Called on: initial load, time range change, or dependency graph change.
*
* Query variables with no query-type parents start immediately.
* Query variables with parents get 'waiting'.
* Dynamic variables get 'waiting' — unblocked once all query vars complete.
* If there are no query variables, dynamic vars start immediately.
*/
export function enqueueFetchAll(ctx: VariableFetchContext): void {
const { dependencyData, variableTypes, dynamicVariableOrder } = ctx;
if (!dependencyData) {
return;
}
const { order, parentDependencyGraph } = dependencyData;
variableFetchStore.update((draft) => {
// Query variables: root ones start immediately, dependent ones wait
order.forEach((name) => {
const parents = parentDependencyGraph[name] || [];
const hasQueryParents = parents.some((p) => variableTypes[p] === 'QUERY');
if (hasQueryParents) {
draft.states[name] = 'waiting';
} else {
const hasData = (draft.lastUpdated[name] || 0) > 0;
draft.states[name] = hasData ? 'revalidating' : 'loading';
}
});
// Dynamic variables: always start as 'waiting'
dynamicVariableOrder.forEach((name) => {
draft.states[name] = 'waiting';
});
// If no query variables exist, unlock dynamic vars immediately
if (order.length === 0 && dynamicVariableOrder.length > 0) {
unlockDynamicVariables(draft, dynamicVariableOrder);
}
});
}

View File

@@ -47,6 +47,8 @@ export interface IDashboardContext {
allSelected: boolean,
isDynamic?: boolean,
) => void;
variablesToGetUpdated: string[];
setVariablesToGetUpdated: React.Dispatch<React.SetStateAction<string[]>>;
dashboardQueryRangeCalled: boolean;
setDashboardQueryRangeCalled: (value: boolean) => void;
selectedRowWidgetId: string | null;