Compare commits

...

51 Commits

Author SHA1 Message Date
SagarRajput-7
0de04c14fe feat: improved performance around multiselect component and added confirm modal for apply to all 2025-09-03 14:19:19 +05:30
SagarRajput-7
e576e3fc40 feat: checked for variable id instead of variable key for refetch 2025-09-03 09:08:13 +05:30
SagarRajput-7
cbfb172f0f feat: added more space for search in multiselect component 2025-09-02 22:33:01 +05:30
SagarRajput-7
b4bca92ab4 feat: reverted only - all updated area implementation 2025-09-02 22:18:00 +05:30
SagarRajput-7
af80c3e494 feat: fixed inconsist search implementations 2025-09-02 21:39:20 +05:30
SagarRajput-7
1253e1b02f feat: fixed infinite loop because of dependency of frequently changing object ref in var table 2025-09-02 20:39:46 +05:30
SagarRajput-7
9251e660ab feat: trucate + n more tooltip content to 10 2025-09-02 17:40:34 +05:30
SagarRajput-7
29e32e31aa feat: handled all state distinction and carry forward in existing variables 2025-09-02 16:59:07 +05:30
SagarRajput-7
2611b12edb feat: fix dropdown closing doesn't reset us back to our all available values when we have a search 2025-09-02 14:01:57 +05:30
SagarRajput-7
c660afe020 feat: modified only/all click behaviour and set all selection always true for dynamic variable 2025-09-02 14:01:57 +05:30
SagarRajput-7
82a321e3f3 feat: aded variable name auto-update based on attribute name entered for dynamic variables 2025-09-02 14:01:57 +05:30
SagarRajput-7
6b1f49936d feat: resolved variable tables infinite loop update error 2025-09-02 14:01:57 +05:30
SagarRajput-7
90d35eed84 feat: optimized localstorage for all selection in dynamic variable and updated __all__ case 2025-09-02 14:01:57 +05:30
SagarRajput-7
13c8e7c5c3 feat: added check to prevent api and updates calls with same payload 2025-09-02 14:01:57 +05:30
SagarRajput-7
b893e39666 feat: added beta and not rec. tag in variable tabs 2025-09-02 14:01:57 +05:30
SagarRajput-7
2234bc83ce feat: added option for regex in the component, disabled for now 2025-09-02 14:01:57 +05:30
SagarRajput-7
333544cd56 feat: change value to searchtext in values API 2025-09-02 14:01:57 +05:30
SagarRajput-7
a23422d2ef feat: added empty name validation in variable creation 2025-09-02 14:01:57 +05:30
SagarRajput-7
86d926a51a feat: fixed variable tabel reordering issue 2025-09-02 14:01:57 +05:30
SagarRajput-7
b1ca927bad feat: updated panel wait and refetch logic and ALL option selection 2025-09-02 14:01:57 +05:30
SagarRajput-7
2f5963d797 fix: fixed typechecks 2025-09-02 14:01:57 +05:30
SagarRajput-7
c088b36e9e feat: sanitized data storage and removed duplicates 2025-09-02 14:01:57 +05:30
SagarRajput-7
3369e9e599 feat: added relatedValues and existing query in param related changes 2025-09-02 14:01:57 +05:30
SagarRajput-7
d45269381f feat: added retries for dyn variable and fixed on-enter selection issue 2025-09-02 14:01:57 +05:30
SagarRajput-7
7fa420e13d feat: implemented where clause suggestion in new qb v5 2025-09-02 14:01:57 +05:30
SagarRajput-7
ae08b14f1f feat: added test cases for dynamic variable and add/remove panel feat 2025-09-02 14:01:57 +05:30
SagarRajput-7
8b00cb5a55 feat: correct the variable addition to panel format for new qb expression 2025-09-02 14:01:57 +05:30
SagarRajput-7
0b272c2618 feat: added type in the variables in query_range payload for dynamic 2025-09-02 14:01:57 +05:30
SagarRajput-7
80eee52a3f fix: added migration to filter expression for crud operations of variable 2025-09-02 14:01:57 +05:30
SagarRajput-7
efc1834b01 feat: light-mode styles 2025-09-02 14:01:57 +05:30
SagarRajput-7
8a3d79ef04 feat: added button loader for apply-all 2025-09-02 14:01:57 +05:30
SagarRajput-7
9271abed4e feat: refectch only related and affected panels in case of dynamic variables 2025-09-02 14:01:57 +05:30
SagarRajput-7
f40c40fb88 feat: added apply to all and variable removal logical 2025-09-02 14:01:57 +05:30
SagarRajput-7
47ea3cf2f2 feat: show labels in widget selector 2025-09-02 14:01:56 +05:30
SagarRajput-7
a3ca0a1c43 feat: added widgetselector on variable creation 2025-09-02 14:01:56 +05:30
SagarRajput-7
bc1cefa5f4 feat: added ability to add/remove variable filter to one or more existing panels 2025-09-02 14:01:56 +05:30
SagarRajput-7
d2c438de78 feat: fixed test cases 2025-09-02 14:01:12 +05:30
SagarRajput-7
9dfc5a1103 feat: corrected the regex matcher for resolved titles 2025-09-02 14:01:12 +05:30
SagarRajput-7
1cfc7acfcb feat: code refactor 2025-09-02 14:01:12 +05:30
SagarRajput-7
03624ef50d feat: added test case for querybuildersearchv2 suggestion changes 2025-09-02 14:01:12 +05:30
SagarRajput-7
3922431278 feat: added test cases for hooks and api call functions 2025-09-02 14:01:12 +05:30
SagarRajput-7
9eee119f5f feat: added dynamic variable suggestion in where clause 2025-09-02 14:01:10 +05:30
SagarRajput-7
6f54f2c398 Merge branch 'main' into dyn-variables 2025-09-02 14:00:01 +05:30
SagarRajput-7
f41fd4f8a8 Merge branch 'main' into dyn-variables 2025-08-26 12:59:09 +05:30
SagarRajput-7
b43c1d400b feat: fixed test case 2025-08-23 22:03:59 +05:30
SagarRajput-7
df69d73572 Merge branch 'main' into dyn-variables 2025-08-23 21:45:10 +05:30
SagarRajput-7
3c1daedae9 feat: fix typo 2025-08-23 21:44:33 +05:30
SagarRajput-7
a9ec8f4a04 feat: fix lint and test cases 2025-08-23 21:42:35 +05:30
SagarRajput-7
5a3a7eb29f Merge branch 'main' into dyn-variables 2025-08-22 09:13:12 +05:30
SagarRajput-7
274fd8b51f feat: added dynamic variable to the dashboard details (#7755)
* feat: added dynamic variable to the dashboard details

* feat: added new component to existing variables

* feat: added enhancement to multiselect and select for dyn-variables

* feat: added refetch method between all dynamic-variables

* feat: correct error handling

* feat: correct error handling

* feat: enforced non-empty selectedvalues and default value

* feat: added client and server side searches

* feat: retry on error

* feat: correct error handling

* feat: handle defautl value in existing variables

* feat: lowercase the source for payload

* feat: fixed the incorrect assignment of active indices

* feat: improved handling of all option

* feat: improved the ALL option visuals

* feat: handled default value enforcement in existing variables

* feat: added unix time to values call

* feat: added incomplete data message and info to search

* feat: changed dashboard panel call handling with existing variables

* feat: adjusted the response type and data with the new API schema for values

* feat: code refactor

* feat: made dyn-variable option as the default

* feat: added test cases for dyn variable creation and completion

* feat: updated test cases
2025-08-21 10:11:10 +05:30
SagarRajput-7
57c8381f68 feat: added dynamic variables creation flow (#7541)
* feat: added dynamic variables creation flow

* feat: added keys and value apis and hooks

* feat: added api and select component changes

* feat: added keys fetching and preview values

* feat: added dynamic variable to variable items

* feat: handled value persistence and tab switches

* feat: added default value and formed a schema for dyn-variables

* feat: added client and server side searches

* feat: corrected the initial load getfieldKey api

* feat: removed fetch on mount restriction
2025-08-21 10:10:59 +05:30
60 changed files with 5808 additions and 590 deletions

View File

@@ -0,0 +1,114 @@
/* eslint-disable sonarjs/no-duplicate-string */
import { ApiBaseInstance } from 'api';
import { getFieldKeys } from '../getFieldKeys';
// Mock the API instance
jest.mock('api', () => ({
ApiBaseInstance: {
get: jest.fn(),
},
}));
describe('getFieldKeys API', () => {
beforeEach(() => {
jest.clearAllMocks();
});
const mockSuccessResponse = {
data: {
status: 'success',
data: {
keys: {
'service.name': [],
'http.status_code': [],
},
complete: true,
},
},
};
it('should call API with correct parameters when no args provided', async () => {
// Mock successful API response
(ApiBaseInstance.get as jest.Mock).mockResolvedValueOnce(mockSuccessResponse);
// Call function with no parameters
await getFieldKeys();
// Verify API was called correctly with empty params object
expect(ApiBaseInstance.get).toHaveBeenCalledWith('/fields/keys', {
params: {},
});
});
it('should call API with signal parameter when provided', async () => {
// Mock successful API response
(ApiBaseInstance.get as jest.Mock).mockResolvedValueOnce(mockSuccessResponse);
// Call function with signal parameter
await getFieldKeys('traces');
// Verify API was called with signal parameter
expect(ApiBaseInstance.get).toHaveBeenCalledWith('/fields/keys', {
params: { signal: 'traces' },
});
});
it('should call API with name parameter when provided', async () => {
// Mock successful API response
(ApiBaseInstance.get as jest.Mock).mockResolvedValueOnce({
data: {
status: 'success',
data: {
keys: { service: [] },
complete: false,
},
},
});
// Call function with name parameter
await getFieldKeys(undefined, 'service');
// Verify API was called with name parameter
expect(ApiBaseInstance.get).toHaveBeenCalledWith('/fields/keys', {
params: { name: 'service' },
});
});
it('should call API with both signal and name when provided', async () => {
// Mock successful API response
(ApiBaseInstance.get as jest.Mock).mockResolvedValueOnce({
data: {
status: 'success',
data: {
keys: { service: [] },
complete: false,
},
},
});
// Call function with both parameters
await getFieldKeys('logs', 'service');
// Verify API was called with both parameters
expect(ApiBaseInstance.get).toHaveBeenCalledWith('/fields/keys', {
params: { signal: 'logs', name: 'service' },
});
});
it('should return properly formatted response', async () => {
// Mock API to return our response
(ApiBaseInstance.get as jest.Mock).mockResolvedValueOnce(mockSuccessResponse);
// Call the function
const result = await getFieldKeys('traces');
// Verify the returned structure matches our expected format
expect(result).toEqual({
statusCode: 200,
error: null,
message: 'success',
payload: mockSuccessResponse.data.data,
});
});
});

View File

@@ -0,0 +1,209 @@
/* eslint-disable sonarjs/no-duplicate-string */
import { ApiBaseInstance } from 'api';
import { getFieldValues } from '../getFieldValues';
// Mock the API instance
jest.mock('api', () => ({
ApiBaseInstance: {
get: jest.fn(),
},
}));
describe('getFieldValues API', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('should call the API with correct parameters (no options)', async () => {
// Mock API response
(ApiBaseInstance.get as jest.Mock).mockResolvedValueOnce({
data: {
status: 'success',
data: {
values: {
stringValues: ['frontend', 'backend'],
},
complete: true,
},
},
});
// Call function without parameters
await getFieldValues();
// Verify API was called correctly with empty params
expect(ApiBaseInstance.get).toHaveBeenCalledWith('/fields/values', {
params: {},
});
});
it('should call the API with signal parameter', async () => {
// Mock API response
(ApiBaseInstance.get as jest.Mock).mockResolvedValueOnce({
data: {
status: 'success',
data: {
values: {
stringValues: ['frontend', 'backend'],
},
complete: true,
},
},
});
// Call function with signal parameter
await getFieldValues('traces');
// Verify API was called with signal parameter
expect(ApiBaseInstance.get).toHaveBeenCalledWith('/fields/values', {
params: { signal: 'traces' },
});
});
it('should call the API with name parameter', async () => {
// Mock API response
(ApiBaseInstance.get as jest.Mock).mockResolvedValueOnce({
data: {
status: 'success',
data: {
values: {
stringValues: ['frontend', 'backend'],
},
complete: true,
},
},
});
// Call function with name parameter
await getFieldValues(undefined, 'service.name');
// Verify API was called with name parameter
expect(ApiBaseInstance.get).toHaveBeenCalledWith('/fields/values', {
params: { name: 'service.name' },
});
});
it('should call the API with value parameter', async () => {
// Mock API response
(ApiBaseInstance.get as jest.Mock).mockResolvedValueOnce({
data: {
status: 'success',
data: {
values: {
stringValues: ['frontend'],
},
complete: false,
},
},
});
// Call function with value parameter
await getFieldValues(undefined, 'service.name', 'front');
// Verify API was called with value parameter
expect(ApiBaseInstance.get).toHaveBeenCalledWith('/fields/values', {
params: { name: 'service.name', value: 'front' },
});
});
it('should call the API with time range parameters', async () => {
// Mock API response
(ApiBaseInstance.get as jest.Mock).mockResolvedValueOnce({
data: {
status: 'success',
data: {
values: {
stringValues: ['frontend', 'backend'],
},
complete: true,
},
},
});
// Call function with time range parameters
const startUnixMilli = 1625097600000000; // Note: nanoseconds
const endUnixMilli = 1625184000000000;
await getFieldValues(
'logs',
'service.name',
undefined,
startUnixMilli,
endUnixMilli,
);
// Verify API was called with time range parameters (converted to milliseconds)
expect(ApiBaseInstance.get).toHaveBeenCalledWith('/fields/values', {
params: {
signal: 'logs',
name: 'service.name',
startUnixMilli: '1625097600', // Should be converted to seconds (divided by 1000000)
endUnixMilli: '1625184000', // Should be converted to seconds (divided by 1000000)
},
});
});
it('should normalize the response values', async () => {
// Mock API response with multiple value types
const mockResponse = {
data: {
status: 'success',
data: {
values: {
stringValues: ['frontend', 'backend'],
numberValues: [200, 404],
boolValues: [true, false],
},
complete: true,
},
},
};
(ApiBaseInstance.get as jest.Mock).mockResolvedValueOnce(mockResponse);
// Call the function
const result = await getFieldValues('traces', 'mixed.values');
// Verify the response has normalized values array
expect(result.payload?.normalizedValues).toContain('frontend');
expect(result.payload?.normalizedValues).toContain('backend');
expect(result.payload?.normalizedValues).toContain('200');
expect(result.payload?.normalizedValues).toContain('404');
expect(result.payload?.normalizedValues).toContain('true');
expect(result.payload?.normalizedValues).toContain('false');
expect(result.payload?.normalizedValues?.length).toBe(6);
});
it('should return a properly formatted success response', async () => {
// Create mock response
const mockApiResponse = {
data: {
status: 'success',
data: {
values: {
stringValues: ['frontend', 'backend'],
},
complete: true,
},
},
};
// Mock API to return our response
(ApiBaseInstance.get as jest.Mock).mockResolvedValueOnce(mockApiResponse);
// Call the function
const result = await getFieldValues('traces', 'service.name');
// Verify the returned structure
expect(result).toEqual({
statusCode: 200,
error: null,
message: 'success',
payload: expect.objectContaining({
values: expect.any(Object),
normalizedValues: expect.any(Array),
complete: true,
}),
});
});
});

View File

@@ -0,0 +1,34 @@
import { ApiBaseInstance } from 'api';
import { ErrorResponse, SuccessResponse } from 'types/api';
import { FieldKeyResponse } from 'types/api/dynamicVariables/getFieldKeys';
/**
* Get field keys for a given signal type
* @param signal Type of signal (traces, logs, metrics)
* @param name Optional search text
*/
export const getFieldKeys = async (
signal?: 'traces' | 'logs' | 'metrics',
name?: string,
): Promise<SuccessResponse<FieldKeyResponse> | ErrorResponse> => {
const params: Record<string, string> = {};
if (signal) {
params.signal = encodeURIComponent(signal);
}
if (name) {
params.name = encodeURIComponent(name);
}
const response = await ApiBaseInstance.get('/fields/keys', { params });
return {
statusCode: 200,
error: null,
message: response.data.status,
payload: response.data.data,
};
};
export default getFieldKeys;

View File

@@ -0,0 +1,80 @@
import { ApiBaseInstance } from 'api';
import { ErrorResponse, SuccessResponse } from 'types/api';
import { FieldValueResponse } from 'types/api/dynamicVariables/getFieldValues';
/**
* Get field values for a given signal type and field name
* @param signal Type of signal (traces, logs, metrics)
* @param name Name of the attribute for which values are being fetched
* @param value Optional search text
*/
export const getFieldValues = async (
signal?: 'traces' | 'logs' | 'metrics',
name?: string,
searchText?: string,
startUnixMilli?: number,
endUnixMilli?: number,
existingQuery?: string,
): Promise<SuccessResponse<FieldValueResponse> | ErrorResponse> => {
const params: Record<string, string> = {};
if (signal) {
params.signal = encodeURIComponent(signal);
}
if (name) {
params.name = encodeURIComponent(name);
}
if (searchText) {
params.searchText = encodeURIComponent(searchText);
}
if (startUnixMilli) {
params.startUnixMilli = Math.floor(startUnixMilli / 1000000).toString();
}
if (endUnixMilli) {
params.endUnixMilli = Math.floor(endUnixMilli / 1000000).toString();
}
if (existingQuery) {
params.existingQuery = existingQuery;
}
const response = await ApiBaseInstance.get('/fields/values', { params });
// Normalize values from different types (stringValues, boolValues, etc.)
if (response.data?.data?.values) {
const allValues: string[] = [];
Object.entries(response.data.data.values).forEach(
([key, valueArray]: [string, any]) => {
// Skip RelatedValues as they should be kept separate
if (key === 'relatedValues') {
return;
}
if (Array.isArray(valueArray)) {
allValues.push(...valueArray.map(String));
}
},
);
// Add a normalized values array to the response
response.data.data.normalizedValues = allValues;
// Add relatedValues to the response as per FieldValueResponse
if (response.data.data.values.relatedValues) {
response.data.data.relatedValues = response.data.data.values.relatedValues;
}
}
return {
statusCode: 200,
error: null,
message: response.data.status,
payload: response.data.data,
};
};
export default getFieldValues;

View File

@@ -24,6 +24,7 @@ import {
TelemetryFieldKey,
TraceAggregation,
VariableItem,
VariableType,
} from 'types/api/v5/queryRange';
import { EQueryType } from 'types/common/dashboard';
import { DataSource } from 'types/common/queryBuilder';
@@ -406,6 +407,7 @@ export const prepareQueryRangePayloadV5 = ({
formatForWeb,
originalGraphType,
fillGaps,
dynamicVariables,
}: GetQueryResultsProps): PrepareQueryRangePayloadV5Result => {
let legendMap: Record<string, string> = {};
const requestType = mapPanelTypeToRequestType(graphType);
@@ -497,7 +499,12 @@ export const prepareQueryRangePayloadV5 = ({
fillGaps: fillGaps || false,
},
variables: Object.entries(variables).reduce((acc, [key, value]) => {
acc[key] = { value };
acc[key] = {
value,
type: dynamicVariables
?.find((v) => v.name === key)
?.type?.toLowerCase() as VariableType,
};
return acc;
}, {} as Record<string, VariableItem>),
};

View File

@@ -13,6 +13,7 @@ import {
CustomTimeType,
Time,
} from 'container/TopNav/DateTimeSelectionV2/config';
import { useGetDynamicVariables } from 'hooks/dashboard/useGetDynamicVariables';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useIsDarkMode } from 'hooks/useDarkMode';
import { useResizeObserver } from 'hooks/useDimensions';
@@ -71,6 +72,8 @@ function Metrics({
[hostName, timeRange.startTime, timeRange.endTime, dotMetricsEnabled],
);
const { dynamicVariables } = useGetDynamicVariables();
const queries = useQueries(
queryPayloads.map((payload, index) => ({
queryKey: ['host-metrics', payload, ENTITY_VERSION_V4, 'HOST'],
@@ -78,7 +81,8 @@ function Metrics({
signal,
}: QueryFunctionContext): Promise<
SuccessResponse<MetricRangePayloadProps>
> => GetMetricQueryRange(payload, ENTITY_VERSION_V4, signal),
> =>
GetMetricQueryRange(payload, ENTITY_VERSION_V4, dynamicVariables, signal),
enabled: !!payload && visibilities[index],
keepPreviousData: true,
})),

View File

@@ -23,11 +23,13 @@ import React, {
useRef,
useState,
} from 'react';
import { Virtuoso } from 'react-virtuoso';
import { popupContainer } from 'utils/selectPopupContainer';
import { CustomMultiSelectProps, CustomTagProps, OptionData } from './types';
import {
filterOptionsBySearch,
handleScrollToBottom,
prioritizeOrAddOptionForMultiSelect,
SPACEKEY,
} from './utils';
@@ -37,7 +39,7 @@ enum ToggleTagValue {
All = 'All',
}
const ALL_SELECTED_VALUE = '__all__'; // Constant for the special value
const ALL_SELECTED_VALUE = '__ALL__'; // Constant for the special value
const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
placeholder = 'Search...',
@@ -62,6 +64,10 @@ const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
allowClear = false,
onRetry,
maxTagTextLength,
onDropdownVisibleChange,
showIncompleteDataMessage = false,
showLabels = false,
enableRegexOption = false,
...rest
}) => {
// ===== State & Refs =====
@@ -78,6 +84,8 @@ const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
const optionRefs = useRef<Record<number, HTMLDivElement | null>>({});
const [visibleOptions, setVisibleOptions] = useState<OptionData[]>([]);
const isClickInsideDropdownRef = useRef(false);
const justOpenedRef = useRef<boolean>(false);
const [isScrolledToBottom, setIsScrolledToBottom] = useState(false);
// Convert single string value to array for consistency
const selectedValues = useMemo(
@@ -124,6 +132,12 @@ const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
return allAvailableValues.every((val) => selectedValues.includes(val));
}, [selectedValues, allAvailableValues, enableAllSelection]);
// Define allOptionShown earlier in the code
const allOptionShown = useMemo(
() => value === ALL_SELECTED_VALUE || value === 'ALL',
[value],
);
// Value passed to the underlying Ant Select component
const displayValue = useMemo(
() => (isAllSelected ? [ALL_SELECTED_VALUE] : selectedValues),
@@ -132,10 +146,18 @@ const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
// ===== Internal onChange Handler =====
const handleInternalChange = useCallback(
(newValue: string | string[]): void => {
(newValue: string | string[], directCaller?: boolean): void => {
// Ensure newValue is an array
const currentNewValue = Array.isArray(newValue) ? newValue : [];
if (
(allOptionShown || isAllSelected) &&
!directCaller &&
currentNewValue.length === 0
) {
return;
}
if (!onChange) return;
// Case 1: Cleared (empty array or undefined)
@@ -144,7 +166,7 @@ const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
return;
}
// Case 2: "__all__" is selected (means select all actual values)
// Case 2: "__ALL__" is selected (means select all actual values)
if (currentNewValue.includes(ALL_SELECTED_VALUE)) {
const allActualOptions = allAvailableValues.map(
(v) => options.flat().find((o) => o.value === v) || { label: v, value: v },
@@ -175,7 +197,14 @@ const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
}
}
},
[onChange, allAvailableValues, options, enableAllSelection],
[
allOptionShown,
isAllSelected,
onChange,
allAvailableValues,
options,
enableAllSelection,
],
);
// ===== Existing Callbacks (potentially needing adjustment later) =====
@@ -510,13 +539,46 @@ const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
}
// Normal single value handling
setSearchText(value.trim());
const trimmedValue = value.trim();
setSearchText(trimmedValue);
if (!isOpen) {
setIsOpen(true);
justOpenedRef.current = true;
}
if (onSearch) onSearch(value.trim());
// Reset active index when search changes if dropdown is open
if (isOpen && trimmedValue) {
setActiveIndex(-1);
// see if the trimmed value matched any option and set that active index
const matchedOption = filteredOptions.find(
(option) =>
option.label.toLowerCase() === trimmedValue.toLowerCase() ||
option.value?.toLowerCase() === trimmedValue.toLowerCase(),
);
if (matchedOption) {
setActiveIndex(1);
} else {
// check if the trimmed value is a regex pattern and set that active index
const isRegex =
trimmedValue.startsWith('.*') && trimmedValue.endsWith('.*');
if (isRegex && enableRegexOption) {
setActiveIndex(0);
} else {
setActiveIndex(enableRegexOption ? 1 : 0);
}
}
}
if (onSearch) onSearch(trimmedValue);
},
[onSearch, isOpen, selectedValues, onChange],
[
onSearch,
isOpen,
selectedValues,
onChange,
filteredOptions,
enableRegexOption,
],
);
// ===== UI & Rendering Functions =====
@@ -528,28 +590,34 @@ const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
(text: string, searchQuery: string): React.ReactNode => {
if (!searchQuery || !highlightSearch) return text;
const parts = text.split(
new RegExp(
`(${searchQuery.replace(/[.*+?^${}()|[\\]\\]/g, '\\$&')})`,
'gi',
),
);
return (
<>
{parts.map((part, i) => {
// Create a unique key that doesn't rely on array index
const uniqueKey = `${text.substring(0, 3)}-${part.substring(0, 3)}-${i}`;
try {
const parts = text.split(
new RegExp(
`(${searchQuery.replace(/[-[\]{}()*+?.,\\^$|#\s]/g, '\\$&')})`,
'gi',
),
);
return (
<>
{parts.map((part, i) => {
// Create a unique key that doesn't rely on array index
const uniqueKey = `${text.substring(0, 3)}-${part.substring(0, 3)}-${i}`;
return part.toLowerCase() === searchQuery.toLowerCase() ? (
<span key={uniqueKey} className="highlight-text">
{part}
</span>
) : (
part
);
})}
</>
);
return part.toLowerCase() === searchQuery.toLowerCase() ? (
<span key={uniqueKey} className="highlight-text">
{part}
</span>
) : (
part
);
})}
</>
);
} catch (error) {
// If regex fails, return the original text without highlighting
console.error('Error in text highlighting:', error);
return text;
}
},
[highlightSearch],
);
@@ -560,10 +628,10 @@ const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
if (isAllSelected) {
// If all are selected, deselect all
handleInternalChange([]);
handleInternalChange([], true);
} else {
// Otherwise, select all
handleInternalChange([ALL_SELECTED_VALUE]);
handleInternalChange([ALL_SELECTED_VALUE], true);
}
}, [options, isAllSelected, handleInternalChange]);
@@ -738,6 +806,26 @@ const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
// Enhanced keyboard navigation with support for maxTagCount
const handleKeyDown = useCallback(
(e: React.KeyboardEvent<HTMLElement>): void => {
// Simple early return if ALL is selected - block all possible keyboard interactions
// that could remove the ALL tag, but still allow dropdown navigation and search
if (
(allOptionShown || isAllSelected) &&
(e.key === 'Backspace' || e.key === 'Delete')
) {
// Only prevent default if the input is empty or cursor is at start position
const activeElement = document.activeElement as HTMLInputElement;
const isInputActive = activeElement?.tagName === 'INPUT';
const isInputEmpty = isInputActive && !activeElement?.value;
const isCursorAtStart =
isInputActive && activeElement?.selectionStart === 0;
if (isInputEmpty || isCursorAtStart) {
e.preventDefault();
e.stopPropagation();
return;
}
}
// Get flattened list of all selectable options
const getFlatOptions = (): OptionData[] => {
if (!visibleOptions) return [];
@@ -752,13 +840,13 @@ const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
if (hasAll) {
flatList.push({
label: 'ALL',
value: '__all__', // Special value for the ALL option
value: ALL_SELECTED_VALUE, // Special value for the ALL option
type: 'defined',
});
}
// Add Regex to flat list
if (!isEmpty(searchText)) {
if (!isEmpty(searchText) && enableRegexOption) {
// Only add regex wrapper if it doesn't already look like a regex pattern
const isAlreadyRegex =
searchText.startsWith('.*') && searchText.endsWith('.*');
@@ -784,6 +872,17 @@ const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
const flatOptions = getFlatOptions();
// If we just opened the dropdown and have options, set first option as active
if (justOpenedRef.current && flatOptions.length > 0) {
setActiveIndex(0);
justOpenedRef.current = false;
}
// If no option is active but we have options and dropdown is open, activate the first one
if (isOpen && activeIndex === -1 && flatOptions.length > 0) {
setActiveIndex(0);
}
// Get the active input element to check cursor position
const activeElement = document.activeElement as HTMLInputElement;
const isInputActive = activeElement?.tagName === 'INPUT';
@@ -1129,7 +1228,7 @@ const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
// If there's an active option in the dropdown, prioritize selecting it
if (activeIndex >= 0 && activeIndex < flatOptions.length) {
const selectedOption = flatOptions[activeIndex];
if (selectedOption.value === '__all__') {
if (selectedOption.value === ALL_SELECTED_VALUE) {
handleSelectAll();
} else if (selectedOption.value && onChange) {
const newValues = selectedValues.includes(selectedOption.value)
@@ -1159,6 +1258,10 @@ const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
e.preventDefault();
setIsOpen(false);
setActiveIndex(-1);
// Call onDropdownVisibleChange when Escape is pressed to close dropdown
if (onDropdownVisibleChange) {
onDropdownVisibleChange(false);
}
break;
case SPACEKEY:
@@ -1168,7 +1271,7 @@ const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
const selectedOption = flatOptions[activeIndex];
// Check if it's the ALL option
if (selectedOption.value === '__all__') {
if (selectedOption.value === ALL_SELECTED_VALUE) {
handleSelectAll();
} else if (selectedOption.value && onChange) {
const newValues = selectedValues.includes(selectedOption.value)
@@ -1214,7 +1317,7 @@ const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
e.stopPropagation();
e.preventDefault();
setIsOpen(true);
setActiveIndex(0);
justOpenedRef.current = true; // Set flag to initialize active option on next render
setActiveChipIndex(-1);
break;
@@ -1260,9 +1363,14 @@ const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
}
},
[
allOptionShown,
isAllSelected,
isOpen,
activeIndex,
getVisibleChipIndices,
getLastVisibleChipIndex,
selectedChips,
isSelectionMode,
isOpen,
activeChipIndex,
selectedValues,
visibleOptions,
@@ -1278,10 +1386,9 @@ const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
startSelection,
selectionEnd,
extendSelection,
activeIndex,
onDropdownVisibleChange,
handleSelectAll,
getVisibleChipIndices,
getLastVisibleChipIndex,
enableRegexOption,
],
);
@@ -1306,6 +1413,14 @@ const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
setIsOpen(false);
}, []);
// Add a scroll handler for the dropdown
const handleDropdownScroll = useCallback(
(e: React.UIEvent<HTMLDivElement>): void => {
setIsScrolledToBottom(handleScrollToBottom(e));
},
[],
);
// Custom dropdown render with sections support
const customDropdownRender = useCallback((): React.ReactElement => {
// Process options based on current search
@@ -1324,7 +1439,7 @@ const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
const customOptions: OptionData[] = [];
// add regex options first since they appear first in the UI
if (!isEmpty(searchText)) {
if (!isEmpty(searchText) && enableRegexOption) {
// Only add regex wrapper if it doesn't already look like a regex pattern
const isAlreadyRegex =
searchText.startsWith('.*') && searchText.endsWith('.*');
@@ -1347,8 +1462,17 @@ const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
});
}
// Now add all custom options at the beginning
const enhancedNonSectionOptions = [...customOptions, ...nonSectionOptions];
// Now add all custom options at the beginning, removing duplicates based on value
const allOptions = [...customOptions, ...nonSectionOptions];
const seenValues = new Set<string>();
const enhancedNonSectionOptions = allOptions.filter((option) => {
const value = option.value || '';
if (seenValues.has(value)) {
return false;
}
seenValues.add(value);
return true;
});
const allOptionValues = getAllAvailableValues(processedOptions);
const allOptionsSelected =
@@ -1382,6 +1506,7 @@ const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
onMouseDown={handleDropdownMouseDown}
onClick={handleDropdownClick}
onKeyDown={handleKeyDown}
onScroll={handleDropdownScroll}
onBlur={handleBlur}
role="listbox"
aria-multiselectable="true"
@@ -1439,7 +1564,19 @@ const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
{/* Non-section options when not searching */}
{enhancedNonSectionOptions.length > 0 && (
<div className="no-section-options">
{mapOptions(enhancedNonSectionOptions)}
<Virtuoso
style={{
minHeight: Math.min(300, enhancedNonSectionOptions.length * 40),
maxHeight: enhancedNonSectionOptions.length * 40,
}}
data={enhancedNonSectionOptions}
itemContent={(index, item): React.ReactNode =>
(mapOptions([item]) as unknown) as React.ReactElement
}
totalCount={enhancedNonSectionOptions.length}
itemSize={(): number => 40}
overscan={5}
/>
</div>
)}
@@ -1452,23 +1589,40 @@ const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
{section.label}
</div>
<div role="group" aria-label={`${section.label} options`}>
{section.options && mapOptions(section.options)}
<Virtuoso
style={{
minHeight: Math.min(300, (section.options?.length || 0) * 40),
maxHeight: (section.options?.length || 0) * 40,
}}
data={section.options || []}
itemContent={(index, item): React.ReactNode =>
(mapOptions([item]) as unknown) as React.ReactElement
}
totalCount={section.options?.length || 0}
itemSize={(): number => 40}
overscan={5}
/>
</div>
</div>
) : null,
) : (
<div key={section.label} />
),
)}
{/* Navigation help footer */}
<div className="navigation-footer" role="note">
{!loading && !errorMessage && !noDataMessage && (
<section className="navigate">
<ArrowDown size={8} className="icons" />
<ArrowUp size={8} className="icons" />
<ArrowLeft size={8} className="icons" />
<ArrowRight size={8} className="icons" />
<span className="keyboard-text">to navigate</span>
</section>
)}
{!loading &&
!errorMessage &&
!noDataMessage &&
!(showIncompleteDataMessage && isScrolledToBottom) && (
<section className="navigate">
<ArrowDown size={8} className="icons" />
<ArrowUp size={8} className="icons" />
<ArrowLeft size={8} className="icons" />
<ArrowRight size={8} className="icons" />
<span className="keyboard-text">to navigate</span>
</section>
)}
{loading && (
<div className="navigation-loading">
<div className="navigation-icons">
@@ -1482,21 +1636,33 @@ const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
<div className="navigation-text">
{errorMessage || SOMETHING_WENT_WRONG}
</div>
<div className="navigation-icons">
<ReloadOutlined
twoToneColor={Color.BG_CHERRY_400}
onClick={(e): void => {
e.stopPropagation();
if (onRetry) onRetry();
}}
/>
</div>
{onRetry && (
<div className="navigation-icons">
<ReloadOutlined
twoToneColor={Color.BG_CHERRY_400}
onClick={(e): void => {
e.stopPropagation();
onRetry();
}}
/>
</div>
)}
</div>
)}
{noDataMessage && !loading && (
<div className="navigation-text">{noDataMessage}</div>
)}
{showIncompleteDataMessage &&
isScrolledToBottom &&
!loading &&
!errorMessage && (
<div className="navigation-text-incomplete">
Use search for more options
</div>
)}
{noDataMessage &&
!loading &&
!(showIncompleteDataMessage && isScrolledToBottom) &&
!errorMessage && <div className="navigation-text">{noDataMessage}</div>}
</div>
</div>
);
@@ -1513,6 +1679,7 @@ const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
handleDropdownMouseDown,
handleDropdownClick,
handleKeyDown,
handleDropdownScroll,
handleBlur,
activeIndex,
loading,
@@ -1522,8 +1689,32 @@ const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
renderOptionWithIndex,
handleSelectAll,
onRetry,
showIncompleteDataMessage,
isScrolledToBottom,
enableRegexOption,
]);
// Custom handler for dropdown visibility changes
const handleDropdownVisibleChange = useCallback(
(visible: boolean): void => {
setIsOpen(visible);
if (visible) {
justOpenedRef.current = true;
setActiveIndex(0);
setActiveChipIndex(-1);
} else {
setSearchText('');
setActiveIndex(-1);
// Don't clear activeChipIndex when dropdown closes to maintain tag focus
}
// Pass through to the parent component's handler if provided
if (onDropdownVisibleChange) {
onDropdownVisibleChange(visible);
}
},
[onDropdownVisibleChange],
);
// ===== Side Effects =====
// Clear search when dropdown closes
@@ -1585,55 +1776,16 @@ const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
// Custom Tag Render (needs significant updates)
const tagRender = useCallback(
(props: CustomTagProps): React.ReactElement => {
const { label, value, closable, onClose } = props;
const { label: labelProp, value, closable, onClose } = props;
const label = showLabels
? options.find((option) => option.value === value)?.label || labelProp
: labelProp;
// If the display value is the special ALL value, render the ALL tag
if (value === ALL_SELECTED_VALUE && isAllSelected) {
const handleAllTagClose = (
e: React.MouseEvent | React.KeyboardEvent,
): void => {
e.stopPropagation();
e.preventDefault();
handleInternalChange([]); // Clear selection when ALL tag is closed
};
const handleAllTagKeyDown = (e: React.KeyboardEvent): void => {
if (e.key === 'Enter' || e.key === SPACEKEY) {
handleAllTagClose(e);
}
// Prevent Backspace/Delete propagation if needed, handle in main keydown handler
};
return (
<div
className={cx('ant-select-selection-item', {
'ant-select-selection-item-active': activeChipIndex === 0, // Treat ALL tag as index 0 when active
'ant-select-selection-item-selected': selectedChips.includes(0),
})}
style={
activeChipIndex === 0 || selectedChips.includes(0)
? {
borderColor: Color.BG_ROBIN_500,
backgroundColor: Color.BG_SLATE_400,
}
: undefined
}
>
<span className="ant-select-selection-item-content">ALL</span>
{closable && (
<span
className="ant-select-selection-item-remove"
onClick={handleAllTagClose}
onKeyDown={handleAllTagKeyDown}
role="button"
tabIndex={0}
aria-label="Remove ALL tag (deselect all)"
>
×
</span>
)}
</div>
);
if (allOptionShown) {
// Don't render a visible tag - will be shown as placeholder
return <div style={{ display: 'none' }} />;
}
// If not isAllSelected, render individual tags using previous logic
@@ -1713,52 +1865,69 @@ const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
// Fallback for safety, should not be reached
return <div />;
},
[
isAllSelected,
handleInternalChange,
activeChipIndex,
selectedChips,
selectedValues,
maxTagCount,
],
// eslint-disable-next-line react-hooks/exhaustive-deps
[isAllSelected, activeChipIndex, selectedChips, selectedValues, maxTagCount],
);
// Simple onClear handler to prevent clearing ALL
const onClearHandler = useCallback((): void => {
// Skip clearing if ALL is selected
if (allOptionShown || isAllSelected) {
return;
}
// Normal clear behavior
handleInternalChange([], true);
if (onClear) onClear();
}, [onClear, handleInternalChange, allOptionShown, isAllSelected]);
// ===== Component Rendering =====
return (
<Select
ref={selectRef}
className={cx('custom-multiselect', className, {
'has-selection': selectedChips.length > 0 && !isAllSelected,
'is-all-selected': isAllSelected,
<div
className={cx('custom-multiselect-wrapper', {
'all-selected': allOptionShown || isAllSelected,
})}
placeholder={placeholder}
mode="multiple"
showSearch
filterOption={false}
onSearch={handleSearch}
value={displayValue}
onChange={handleInternalChange}
onClear={(): void => handleInternalChange([])}
onDropdownVisibleChange={setIsOpen}
open={isOpen}
defaultActiveFirstOption={defaultActiveFirstOption}
popupMatchSelectWidth={dropdownMatchSelectWidth}
allowClear={allowClear}
getPopupContainer={getPopupContainer ?? popupContainer}
suffixIcon={<DownOutlined style={{ cursor: 'default' }} />}
dropdownRender={customDropdownRender}
menuItemSelectedIcon={null}
popupClassName={cx('custom-multiselect-dropdown-container', popupClassName)}
notFoundContent={<div className="empty-message">{noDataMessage}</div>}
onKeyDown={handleKeyDown}
tagRender={tagRender as any}
placement={placement}
listHeight={300}
searchValue={searchText}
maxTagTextLength={maxTagTextLength}
maxTagCount={isAllSelected ? 1 : maxTagCount}
{...rest}
/>
>
{(allOptionShown || isAllSelected) && !searchText && (
<div className="all-text">ALL</div>
)}
<Select
ref={selectRef}
className={cx('custom-multiselect', className, {
'has-selection': selectedChips.length > 0 && !isAllSelected,
'is-all-selected': isAllSelected,
})}
placeholder={placeholder}
mode="multiple"
showSearch
filterOption={false}
onSearch={handleSearch}
value={displayValue}
onChange={(newValue): void => {
handleInternalChange(newValue, false);
}}
onClear={onClearHandler}
onDropdownVisibleChange={handleDropdownVisibleChange}
open={isOpen}
defaultActiveFirstOption={defaultActiveFirstOption}
popupMatchSelectWidth={dropdownMatchSelectWidth}
allowClear={allowClear}
getPopupContainer={getPopupContainer ?? popupContainer}
suffixIcon={<DownOutlined style={{ cursor: 'default' }} />}
dropdownRender={customDropdownRender}
menuItemSelectedIcon={null}
popupClassName={cx('custom-multiselect-dropdown-container', popupClassName)}
notFoundContent={<div className="empty-message">{noDataMessage}</div>}
onKeyDown={handleKeyDown}
tagRender={tagRender as any}
placement={placement}
listHeight={300}
searchValue={searchText}
maxTagTextLength={maxTagTextLength}
maxTagCount={isAllSelected ? undefined : maxTagCount}
{...rest}
/>
</div>
);
};

View File

@@ -29,6 +29,7 @@ import { popupContainer } from 'utils/selectPopupContainer';
import { CustomSelectProps, OptionData } from './types';
import {
filterOptionsBySearch,
handleScrollToBottom,
prioritizeOrAddOptionForSingleSelect,
SPACEKEY,
} from './utils';
@@ -57,17 +58,29 @@ const CustomSelect: React.FC<CustomSelectProps> = ({
errorMessage,
allowClear = false,
onRetry,
showIncompleteDataMessage = false,
...rest
}) => {
// ===== State & Refs =====
const [isOpen, setIsOpen] = useState(false);
const [searchText, setSearchText] = useState('');
const [activeOptionIndex, setActiveOptionIndex] = useState<number>(-1);
const [isScrolledToBottom, setIsScrolledToBottom] = useState(false);
// Refs for element access and scroll behavior
const selectRef = useRef<BaseSelectRef>(null);
const dropdownRef = useRef<HTMLDivElement>(null);
const optionRefs = useRef<Record<number, HTMLDivElement | null>>({});
// Flag to track if dropdown just opened
const justOpenedRef = useRef<boolean>(false);
// Add a scroll handler for the dropdown
const handleDropdownScroll = useCallback(
(e: React.UIEvent<HTMLDivElement>): void => {
setIsScrolledToBottom(handleScrollToBottom(e));
},
[],
);
// ===== Option Filtering & Processing Utilities =====
@@ -130,23 +143,33 @@ const CustomSelect: React.FC<CustomSelectProps> = ({
(text: string, searchQuery: string): React.ReactNode => {
if (!searchQuery || !highlightSearch) return text;
const parts = text.split(new RegExp(`(${searchQuery})`, 'gi'));
return (
<>
{parts.map((part, i) => {
// Create a deterministic but unique key
const uniqueKey = `${text.substring(0, 3)}-${part.substring(0, 3)}-${i}`;
try {
const parts = text.split(
new RegExp(
`(${searchQuery.replace(/[-[\]{}()*+?.,\\^$|#\s]/g, '\\$&')})`,
'gi',
),
);
return (
<>
{parts.map((part, i) => {
// Create a deterministic but unique key
const uniqueKey = `${text.substring(0, 3)}-${part.substring(0, 3)}-${i}`;
return part.toLowerCase() === searchQuery.toLowerCase() ? (
<span key={uniqueKey} className="highlight-text">
{part}
</span>
) : (
part
);
})}
</>
);
return part.toLowerCase() === searchQuery.toLowerCase() ? (
<span key={uniqueKey} className="highlight-text">
{part}
</span>
) : (
part
);
})}
</>
);
} catch (error) {
console.error('Error in text highlighting:', error);
return text;
}
},
[highlightSearch],
);
@@ -246,9 +269,14 @@ const CustomSelect: React.FC<CustomSelectProps> = ({
const trimmedValue = value.trim();
setSearchText(trimmedValue);
// Reset active option index when search changes
if (isOpen) {
setActiveOptionIndex(0);
}
if (onSearch) onSearch(trimmedValue);
},
[onSearch],
[onSearch, isOpen],
);
/**
@@ -272,14 +300,23 @@ const CustomSelect: React.FC<CustomSelectProps> = ({
const flatList: OptionData[] = [];
// Process options
let processedOptions = isEmpty(value)
? filteredOptions
: prioritizeOrAddOptionForSingleSelect(filteredOptions, value);
if (!isEmpty(searchText)) {
processedOptions = filterOptionsBySearch(processedOptions, searchText);
}
const { sectionOptions, nonSectionOptions } = splitOptions(
isEmpty(value)
? filteredOptions
: prioritizeOrAddOptionForSingleSelect(filteredOptions, value),
processedOptions,
);
// Add custom option if needed
if (!isEmpty(searchText) && !isLabelPresent(filteredOptions, searchText)) {
if (
!isEmpty(searchText) &&
!isLabelPresent(processedOptions, searchText)
) {
flatList.push({
label: searchText,
value: searchText,
@@ -300,33 +337,52 @@ const CustomSelect: React.FC<CustomSelectProps> = ({
const options = getFlatOptions();
// If we just opened the dropdown and have options, set first option as active
if (justOpenedRef.current && options.length > 0) {
setActiveOptionIndex(0);
justOpenedRef.current = false;
}
// If no option is active but we have options, activate the first one
if (activeOptionIndex === -1 && options.length > 0) {
setActiveOptionIndex(0);
}
switch (e.key) {
case 'ArrowDown':
e.preventDefault();
setActiveOptionIndex((prev) =>
prev < options.length - 1 ? prev + 1 : 0,
);
if (options.length > 0) {
setActiveOptionIndex((prev) =>
prev < options.length - 1 ? prev + 1 : 0,
);
}
break;
case 'ArrowUp':
e.preventDefault();
setActiveOptionIndex((prev) =>
prev > 0 ? prev - 1 : options.length - 1,
);
if (options.length > 0) {
setActiveOptionIndex((prev) =>
prev > 0 ? prev - 1 : options.length - 1,
);
}
break;
case 'Tab':
// Tab navigation with Shift key support
if (e.shiftKey) {
e.preventDefault();
setActiveOptionIndex((prev) =>
prev > 0 ? prev - 1 : options.length - 1,
);
if (options.length > 0) {
setActiveOptionIndex((prev) =>
prev > 0 ? prev - 1 : options.length - 1,
);
}
} else {
e.preventDefault();
setActiveOptionIndex((prev) =>
prev < options.length - 1 ? prev + 1 : 0,
);
if (options.length > 0) {
setActiveOptionIndex((prev) =>
prev < options.length - 1 ? prev + 1 : 0,
);
}
}
break;
@@ -339,6 +395,7 @@ const CustomSelect: React.FC<CustomSelectProps> = ({
onChange(selectedOption.value, selectedOption);
setIsOpen(false);
setActiveOptionIndex(-1);
setSearchText('');
}
} else if (!isEmpty(searchText)) {
// Add custom value when no option is focused
@@ -351,6 +408,7 @@ const CustomSelect: React.FC<CustomSelectProps> = ({
onChange(customOption.value, customOption);
setIsOpen(false);
setActiveOptionIndex(-1);
setSearchText('');
}
}
break;
@@ -359,6 +417,7 @@ const CustomSelect: React.FC<CustomSelectProps> = ({
e.preventDefault();
setIsOpen(false);
setActiveOptionIndex(-1);
setSearchText('');
break;
case ' ': // Space key
@@ -369,6 +428,7 @@ const CustomSelect: React.FC<CustomSelectProps> = ({
onChange(selectedOption.value, selectedOption);
setIsOpen(false);
setActiveOptionIndex(-1);
setSearchText('');
}
}
break;
@@ -379,7 +439,7 @@ const CustomSelect: React.FC<CustomSelectProps> = ({
// Open dropdown when Down or Tab is pressed while closed
e.preventDefault();
setIsOpen(true);
setActiveOptionIndex(0);
justOpenedRef.current = true; // Set flag to initialize active option on next render
}
},
[
@@ -444,6 +504,7 @@ const CustomSelect: React.FC<CustomSelectProps> = ({
className="custom-select-dropdown"
onClick={handleDropdownClick}
onKeyDown={handleKeyDown}
onScroll={handleDropdownScroll}
role="listbox"
tabIndex={-1}
aria-activedescendant={
@@ -454,7 +515,6 @@ const CustomSelect: React.FC<CustomSelectProps> = ({
<div className="no-section-options">
{nonSectionOptions.length > 0 && mapOptions(nonSectionOptions)}
</div>
{/* Section options */}
{sectionOptions.length > 0 &&
sectionOptions.map((section) =>
@@ -472,13 +532,16 @@ const CustomSelect: React.FC<CustomSelectProps> = ({
{/* Navigation help footer */}
<div className="navigation-footer" role="note">
{!loading && !errorMessage && !noDataMessage && (
<section className="navigate">
<ArrowDown size={8} className="icons" />
<ArrowUp size={8} className="icons" />
<span className="keyboard-text">to navigate</span>
</section>
)}
{!loading &&
!errorMessage &&
!noDataMessage &&
!(showIncompleteDataMessage && isScrolledToBottom) && (
<section className="navigate">
<ArrowDown size={8} className="icons" />
<ArrowUp size={8} className="icons" />
<span className="keyboard-text">to navigate</span>
</section>
)}
{loading && (
<div className="navigation-loading">
<div className="navigation-icons">
@@ -492,21 +555,33 @@ const CustomSelect: React.FC<CustomSelectProps> = ({
<div className="navigation-text">
{errorMessage || SOMETHING_WENT_WRONG}
</div>
<div className="navigation-icons">
<ReloadOutlined
twoToneColor={Color.BG_CHERRY_400}
onClick={(e): void => {
e.stopPropagation();
if (onRetry) onRetry();
}}
/>
</div>
{onRetry && (
<div className="navigation-icons">
<ReloadOutlined
twoToneColor={Color.BG_CHERRY_400}
onClick={(e): void => {
e.stopPropagation();
onRetry();
}}
/>
</div>
)}
</div>
)}
{noDataMessage && !loading && (
<div className="navigation-text">{noDataMessage}</div>
)}
{showIncompleteDataMessage &&
isScrolledToBottom &&
!loading &&
!errorMessage && (
<div className="navigation-text-incomplete">
Use search for more options
</div>
)}
{noDataMessage &&
!loading &&
!(showIncompleteDataMessage && isScrolledToBottom) &&
!errorMessage && <div className="navigation-text">{noDataMessage}</div>}
</div>
</div>
);
@@ -520,6 +595,7 @@ const CustomSelect: React.FC<CustomSelectProps> = ({
isLabelPresent,
handleDropdownClick,
handleKeyDown,
handleDropdownScroll,
activeOptionIndex,
loading,
errorMessage,
@@ -527,8 +603,22 @@ const CustomSelect: React.FC<CustomSelectProps> = ({
dropdownRender,
renderOptionWithIndex,
onRetry,
showIncompleteDataMessage,
isScrolledToBottom,
]);
// Handle dropdown visibility changes
const handleDropdownVisibleChange = useCallback((visible: boolean): void => {
setIsOpen(visible);
if (visible) {
justOpenedRef.current = true;
setActiveOptionIndex(0);
} else {
setSearchText('');
setActiveOptionIndex(-1);
}
}, []);
// ===== Side Effects =====
// Clear search text when dropdown closes
@@ -582,7 +672,7 @@ const CustomSelect: React.FC<CustomSelectProps> = ({
onSearch={handleSearch}
value={value}
onChange={onChange}
onDropdownVisibleChange={setIsOpen}
onDropdownVisibleChange={handleDropdownVisibleChange}
open={isOpen}
options={optionsWithHighlight}
defaultActiveFirstOption={defaultActiveFirstOption}

View File

@@ -35,12 +35,50 @@ $custom-border-color: #2c3044;
width: 100%;
position: relative;
&.is-all-selected {
.ant-select-selection-search-input {
caret-color: transparent;
}
.ant-select-selection-placeholder {
opacity: 1 !important;
color: var(--bg-vanilla-400) !important;
font-weight: 500;
visibility: visible !important;
pointer-events: none;
z-index: 2;
.lightMode & {
color: rgba(0, 0, 0, 0.85) !important;
}
}
&.ant-select-focused .ant-select-selection-placeholder {
opacity: 0.45 !important;
}
}
.all-selected-text {
position: absolute;
left: 12px;
top: 50%;
transform: translateY(-50%);
color: var(--bg-vanilla-400);
z-index: 1;
pointer-events: none;
.lightMode & {
color: rgba(0, 0, 0, 0.85);
}
}
.ant-select-selector {
max-height: 200px;
overflow: auto;
scrollbar-width: thin;
background-color: var(--bg-ink-400);
border-color: var(--bg-slate-400);
cursor: text;
&::-webkit-scrollbar {
width: 6px;
@@ -56,6 +94,16 @@ $custom-border-color: #2c3044;
}
}
// Ensure adequate space for input area
.ant-select-selection-search {
min-width: 60px !important;
flex: 1 1 auto;
.ant-select-selection-search-input {
min-width: 60px !important;
cursor: text;
}
}
&.ant-select-focused {
.ant-select-selector {
border-color: var(--bg-robin-500);
@@ -158,7 +206,7 @@ $custom-border-color: #2c3044;
// Custom dropdown styles for single select
.custom-select-dropdown {
padding: 8px 0 0 0;
max-height: 500px;
max-height: 300px;
overflow-y: auto;
overflow-x: hidden;
scrollbar-width: thin;
@@ -276,6 +324,10 @@ $custom-border-color: #2c3044;
font-size: 12px;
}
.navigation-text-incomplete {
color: var(--bg-amber-600) !important;
}
.navigation-error {
.navigation-text,
.navigation-icons {
@@ -322,7 +374,7 @@ $custom-border-color: #2c3044;
// Custom dropdown styles for multi-select
.custom-multiselect-dropdown {
padding: 8px 0 0 0;
max-height: 500px;
max-height: 350px;
overflow-y: auto;
overflow-x: hidden;
scrollbar-width: thin;
@@ -355,6 +407,7 @@ $custom-border-color: #2c3044;
.select-group {
margin-bottom: 12px;
overflow: hidden;
margin-top: 4px;
.group-label {
font-weight: 500;
@@ -637,6 +690,7 @@ $custom-border-color: #2c3044;
.ant-select-selector {
background-color: var(--bg-vanilla-100);
border-color: #e9e9e9;
cursor: text; // Make entire selector clickable for input focus
&::-webkit-scrollbar-thumb {
background-color: #ccc;
@@ -647,6 +701,20 @@ $custom-border-color: #2c3044;
}
}
.ant-select-selection-search {
min-width: 60px !important;
flex: 1 1 auto;
.ant-select-selection-search-input {
min-width: 60px !important;
cursor: text;
}
}
.ant-select-selector {
cursor: text;
}
.ant-select-selection-placeholder {
color: rgba(0, 0, 0, 0.45);
}
@@ -656,6 +724,10 @@ $custom-border-color: #2c3044;
border: 1px solid #e8e8e8;
color: rgba(0, 0, 0, 0.85);
font-size: 12px !important;
height: 20px;
line-height: 18px;
.ant-select-selection-item-content {
color: rgba(0, 0, 0, 0.85);
}
@@ -836,3 +908,38 @@ $custom-border-color: #2c3044;
}
}
}
.custom-multiselect-wrapper {
position: relative;
width: 100%;
&.all-selected {
.all-text {
position: absolute;
left: 12px;
top: 50%;
transform: translateY(-50%);
color: var(--bg-vanilla-400);
font-weight: 500;
z-index: 2;
pointer-events: none;
transition: opacity 0.2s ease, visibility 0.2s ease;
.lightMode & {
color: rgba(0, 0, 0, 0.85);
}
}
&:focus-within .all-text {
opacity: 0.45;
}
.ant-select-selection-search-input {
caret-color: auto;
}
.ant-select-selection-placeholder {
display: none;
}
}
}

View File

@@ -24,9 +24,10 @@ export interface CustomSelectProps extends Omit<SelectProps, 'options'> {
highlightSearch?: boolean;
placement?: 'topLeft' | 'topRight' | 'bottomLeft' | 'bottomRight';
popupMatchSelectWidth?: boolean;
errorMessage?: string;
errorMessage?: string | null;
allowClear?: SelectProps['allowClear'];
onRetry?: () => void;
showIncompleteDataMessage?: boolean;
}
export interface CustomTagProps {
@@ -51,10 +52,14 @@ export interface CustomMultiSelectProps
getPopupContainer?: (triggerNode: HTMLElement) => HTMLElement;
dropdownRender?: (menu: React.ReactElement) => React.ReactElement;
highlightSearch?: boolean;
errorMessage?: string;
errorMessage?: string | null;
popupClassName?: string;
placement?: 'topLeft' | 'topRight' | 'bottomLeft' | 'bottomRight';
maxTagCount?: number;
allowClear?: SelectProps['allowClear'];
onRetry?: () => void;
maxTagTextLength?: number;
showIncompleteDataMessage?: boolean;
showLabels?: boolean;
enableRegexOption?: boolean;
}

View File

@@ -1,4 +1,6 @@
/* eslint-disable sonarjs/cognitive-complexity */
import { uniqueOptions } from 'container/NewDashboard/DashboardVariablesSelection/util';
import { OptionData } from './types';
export const SPACEKEY = ' ';
@@ -98,8 +100,10 @@ export const prioritizeOrAddOptionForMultiSelect = (
label: labels?.[value] ?? value, // Use provided label or default to value
}));
const flatOutSelectedOptions = uniqueOptions([...newOptions, ...foundOptions]);
// Add found & new options to the top
return [...newOptions, ...foundOptions, ...filteredOptions];
return [...flatOutSelectedOptions, ...filteredOptions];
};
/**
@@ -133,3 +137,15 @@ export const filterOptionsBySearch = (
})
.filter(Boolean) as OptionData[];
};
/**
* Utility function to handle dropdown scroll and detect when scrolled to bottom
* Returns true when scrolled to within 20px of the bottom
*/
export const handleScrollToBottom = (
e: React.UIEvent<HTMLDivElement>,
): boolean => {
const { scrollTop, scrollHeight, clientHeight } = e.currentTarget;
// Consider "scrolled to bottom" when within 20px of the bottom or at the bottom
return scrollHeight - scrollTop - clientHeight < 20;
};

View File

@@ -27,6 +27,7 @@ import {
QUERY_BUILDER_OPERATORS_BY_KEY_TYPE,
queryOperatorSuggestions,
} from 'constants/antlrQueryConstants';
import { useGetDynamicVariables } from 'hooks/dashboard/useGetDynamicVariables';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useIsDarkMode } from 'hooks/useDarkMode';
import useDebounce from 'hooks/useDebounce';
@@ -161,13 +162,7 @@ function QuerySearch({
const { handleRunQuery } = useQueryBuilder();
// const {
// data: queryKeySuggestions,
// refetch: refetchQueryKeySuggestions,
// } = useGetQueryKeySuggestions({
// signal: dataSource,
// name: searchText || '',
// });
const { dynamicVariables } = useGetDynamicVariables();
// Add back the generateOptions function and useEffect
const generateOptions = (keys: {
@@ -982,6 +977,25 @@ function QuerySearch({
option.label.toLowerCase().includes(searchText),
);
// Add dynamic variables suggestions for the current key
const variableName = dynamicVariables.find(
(variable) => variable?.dynamicVariablesAttribute === keyName,
)?.name;
if (variableName) {
const variableValue = `$${variableName}`;
const variableOption = {
label: variableValue,
type: 'variable',
apply: variableValue,
};
// Add variable suggestion at the beginning if it matches the search text
if (variableValue.toLowerCase().includes(searchText.toLowerCase())) {
options = [variableOption, ...options];
}
}
// Trigger fetch only if needed
const shouldFetch =
// Fetch only if key is available
@@ -1034,6 +1048,9 @@ function QuerySearch({
} else if (option.type === 'array') {
// Arrays are already formatted as arrays
processedOption.apply = option.label;
} else if (option.type === 'variable') {
// Variables should be used as-is (they already have the $ prefix)
processedOption.apply = option.label;
}
return processedOption;

View File

@@ -38,6 +38,13 @@ const isArrayOperator = (operator: string): boolean => {
return arrayOperators.includes(operator);
};
const isVariable = (value: string | string[] | number | boolean): boolean => {
if (Array.isArray(value)) {
return value.some((v) => typeof v === 'string' && v.trim().startsWith('$'));
}
return typeof value === 'string' && value.trim().startsWith('$');
};
/**
* Format a value for the expression string
* @param value - The value to format
@@ -48,6 +55,10 @@ const formatValueForExpression = (
value: string[] | string | number | boolean,
operator?: string,
): string => {
if (isVariable(value)) {
return String(value);
}
// For IN operators, ensure value is always an array
if (isArrayOperator(operator || '')) {
const arrayValue = Array.isArray(value) ? value : [value];

View File

@@ -13,7 +13,6 @@ import { isEqual } from 'lodash-es';
import isEmpty from 'lodash-es/isEmpty';
import { useDashboard } from 'providers/Dashboard/Dashboard';
import { memo, useEffect, useMemo, useRef, useState } from 'react';
import { useQueryClient } from 'react-query';
import { useDispatch, useSelector } from 'react-redux';
import { UpdateTimeInterval } from 'store/actions';
import { AppState } from 'store/reducers';
@@ -53,6 +52,7 @@ function GridCardGraph({
customTimeRange,
customOnRowClick,
customTimeRangeWindowForCoRelation,
dynamicVariableToWidgetsMap,
}: GridCardGraphProps): JSX.Element {
const dispatch = useDispatch();
const [errorMessage, setErrorMessage] = useState<string>();
@@ -62,14 +62,13 @@ function GridCardGraph({
const {
toScrollWidgetId,
setToScrollWidgetId,
variablesToGetUpdated,
setDashboardQueryRangeCalled,
variablesToGetUpdated,
} = useDashboard();
const { minTime, maxTime, selectedTime: globalSelectedInterval } = useSelector<
AppState,
GlobalReducer
>((state) => state.globalTime);
const queryClient = useQueryClient();
const handleBackNavigation = (): void => {
const searchParams = new URLSearchParams(window.location.search);
@@ -120,11 +119,7 @@ function GridCardGraph({
const isEmptyWidget =
widget?.id === PANEL_TYPES.EMPTY_WIDGET || isEmpty(widget);
const queryEnabledCondition =
isVisible &&
!isEmptyWidget &&
isQueryEnabled &&
isEmpty(variablesToGetUpdated);
const queryEnabledCondition = isVisible && !isEmptyWidget && isQueryEnabled;
const [requestData, setRequestData] = useState<GetQueryResultsProps>(() => {
if (widget.panelTypes !== PANEL_TYPES.LIST) {
@@ -163,22 +158,24 @@ function GridCardGraph({
};
});
useEffect(() => {
if (variablesToGetUpdated.length > 0) {
queryClient.cancelQueries([
maxTime,
minTime,
globalSelectedInterval,
variables,
widget?.query,
widget?.panelTypes,
widget.timePreferance,
widget.fillSpans,
requestData,
]);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [variablesToGetUpdated]);
// TODO [vikrantgupta25] remove this useEffect with refactor as this is prone to race condition
// this is added to tackle the case of async communication between VariableItem.tsx and GridCard.tsx
// useEffect(() => {
// if (variablesToGetUpdated.length > 0) {
// queryClient.cancelQueries([
// maxTime,
// minTime,
// globalSelectedInterval,
// variables,
// widget?.query,
// widget?.panelTypes,
// widget.timePreferance,
// widget.fillSpans,
// requestData,
// ]);
// }
// // eslint-disable-next-line react-hooks/exhaustive-deps
// }, [variablesToGetUpdated]);
useEffect(() => {
if (!isEqual(updatedQuery, requestData.query)) {
@@ -199,6 +196,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,
@@ -218,15 +236,29 @@ function GridCardGraph({
maxTime,
minTime,
globalSelectedInterval,
variables,
widget?.query,
widget?.panelTypes,
widget.timePreferance,
widget.fillSpans,
requestData,
variables
? Object.entries(variables).reduce((acc, [id, variable]) => {
if (
variable.type !== 'DYNAMIC' ||
(dynamicVariableToWidgetsMap?.[variable.id] &&
dynamicVariableToWidgetsMap?.[variable.id].includes(widget.id))
) {
return { ...acc, [id]: variable.selectedValue };
}
return acc;
}, {})
: {},
...(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 (
@@ -239,7 +271,7 @@ function GridCardGraph({
return failureCount < 2;
},
keepPreviousData: true,
enabled: queryEnabledCondition,
enabled: queryEnabledCondition && !nonDynamicVariableChainToken,
refetchOnMount: false,
onError: (error) => {
const errorMessage =

View File

@@ -69,6 +69,7 @@ export interface GridCardGraphProps {
};
customOnRowClick?: (record: RowData) => void;
customTimeRangeWindowForCoRelation?: string | undefined;
dynamicVariableToWidgetsMap?: Record<string, string[]>;
}
export interface GetGraphVisibilityStateOnLegendClickProps {

View File

@@ -11,7 +11,9 @@ import { QueryParams } from 'constants/query';
import { PANEL_GROUP_TYPES, PANEL_TYPES } from 'constants/queryBuilder';
import { themeColors } from 'constants/theme';
import { DEFAULT_ROW_NAME } from 'container/NewDashboard/DashboardDescription/utils';
import { useGetDynamicVariables } from 'hooks/dashboard/useGetDynamicVariables';
import { useUpdateDashboard } from 'hooks/dashboard/useUpdateDashboard';
import { createDynamicVariableToWidgetsMap } from 'hooks/dashboard/utils';
import useComponentPermission from 'hooks/useComponentPermission';
import { useIsDarkMode } from 'hooks/useDarkMode';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
@@ -83,6 +85,17 @@ function GraphLayout(props: GraphLayoutProps): JSX.Element {
const isDarkMode = useIsDarkMode();
// Add dynamic variables mapping at parent level
const { dynamicVariables } = useGetDynamicVariables();
const dynamicVariableToWidgetsMap = useMemo(
() =>
createDynamicVariableToWidgetsMap(
dynamicVariables,
(widgets as Widgets[]) || [],
),
[dynamicVariables, widgets],
);
const [dashboardLayout, setDashboardLayout] = useState<Layout[]>([]);
const [isSettingsModalOpen, setIsSettingsModalOpen] = useState<boolean>(false);
@@ -584,6 +597,7 @@ function GraphLayout(props: GraphLayoutProps): JSX.Element {
version={ENTITY_VERSION_V5}
onDragSelect={onDragSelect}
dataAvailable={checkIfDataExists}
dynamicVariableToWidgetsMap={dynamicVariableToWidgetsMap}
/>
</Card>
</CardContainer>

View File

@@ -2,6 +2,7 @@ import { getSubstituteVars } from 'api/dashboard/substitute_vars';
import { prepareQueryRangePayloadV5 } from 'api/v5/v5';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { timePreferenceType } from 'container/NewWidget/RightContainer/timeItems';
import { useGetDynamicVariables } from 'hooks/dashboard/useGetDynamicVariables';
import { getDashboardVariables } from 'lib/dashbaordVariables/getDashboardVariables';
import { mapQueryDataFromApi } from 'lib/newQueryBuilder/queryBuilderMappers/mapQueryDataFromApi';
import { useCallback } from 'react';
@@ -34,6 +35,8 @@ function useUpdatedQuery(): UseUpdatedQueryResult {
const queryRangeMutation = useMutation(getSubstituteVars);
const { dynamicVariables } = useGetDynamicVariables();
const getUpdatedQuery = useCallback(
async ({
widgetConfig,
@@ -47,6 +50,7 @@ function useUpdatedQuery(): UseUpdatedQueryResult {
globalSelectedInterval,
variables: getDashboardVariables(selectedDashboard?.data?.variables),
originalGraphType: widgetConfig.panelTypes,
dynamicVariables,
});
// Execute query and process results
@@ -55,7 +59,7 @@ function useUpdatedQuery(): UseUpdatedQueryResult {
// Map query data from API response
return mapQueryDataFromApi(queryResult.data.compositeQuery);
},
[globalSelectedInterval, queryRangeMutation],
[dynamicVariables, globalSelectedInterval, queryRangeMutation],
);
return {

View File

@@ -15,6 +15,7 @@ import {
CustomTimeType,
Time,
} from 'container/TopNav/DateTimeSelectionV2/config';
import { useGetDynamicVariables } from 'hooks/dashboard/useGetDynamicVariables';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useIsDarkMode } from 'hooks/useDarkMode';
import { useResizeObserver } from 'hooks/useDimensions';
@@ -98,6 +99,8 @@ function EntityMetrics<T>({
],
);
const { dynamicVariables } = useGetDynamicVariables();
const queries = useQueries(
queryPayloads.map((payload, index) => ({
queryKey: [queryKey, payload, ENTITY_VERSION_V4, category],
@@ -105,7 +108,8 @@ function EntityMetrics<T>({
signal,
}: QueryFunctionContext): Promise<
SuccessResponse<MetricRangePayloadProps>
> => GetMetricQueryRange(payload, ENTITY_VERSION_V4, signal),
> =>
GetMetricQueryRange(payload, ENTITY_VERSION_V4, dynamicVariables, signal),
enabled: !!payload && visibilities[index],
keepPreviousData: true,
})),

View File

@@ -50,8 +50,10 @@ jest.mock('container/InfraMonitoringK8s/commonUtils', () => ({
}));
const mockUseQueries = jest.fn();
const mockUseQuery = jest.fn();
jest.mock('react-query', () => ({
useQueries: (queryConfigs: any[]): any[] => mockUseQueries(queryConfigs),
useQuery: (config: any): any => mockUseQuery(config),
}));
jest.mock('hooks/useDarkMode', () => ({
@@ -302,6 +304,20 @@ describe('EntityMetrics', () => {
beforeEach(() => {
jest.clearAllMocks();
mockUseQueries.mockReturnValue(mockQueries);
mockUseQuery.mockReturnValue({
data: {
data: {
data: {
variables: {},
title: 'Test Dashboard',
},
id: 'test-dashboard-id',
},
},
isLoading: false,
isError: false,
refetch: jest.fn(),
});
});
it('should render metrics with data', () => {

View File

@@ -70,6 +70,17 @@
gap: 3px;
color: red;
}
.apply-to-all-button {
width: min-content;
height: 22px;
border-radius: 2px;
display: flex;
padding: 0px 6px;
align-items: center;
gap: 3px;
background: var(--bg-slate-400);
}
}
}
@@ -112,6 +123,10 @@
.edit-variable-button {
background: var(--bg-vanilla-300);
}
.apply-to-all-button {
background: var(--bg-vanilla-300);
}
}
}

View File

@@ -0,0 +1,42 @@
.dynamic-variable-container {
display: grid;
grid-template-columns: 1fr 32px 200px;
gap: 32px;
align-items: center;
width: 100%;
margin: 24px 0;
.ant-select {
.ant-select-selector {
border-radius: 4px;
border: 1px solid var(--bg-slate-400);
}
}
.ant-input {
border-radius: 4px;
border: 1px solid var(--bg-slate-400);
max-width: 300px;
}
.dynamic-variable-from-text {
font-family: 'Space Mono';
font-size: 13px;
font-weight: 500;
white-space: nowrap;
}
}
.lightMode {
.dynamic-variable-container {
.ant-select {
.ant-select-selector {
border: 1px solid var(--bg-vanilla-300);
}
}
.ant-input {
border: 1px solid var(--bg-vanilla-300);
}
}
}

View File

@@ -0,0 +1,181 @@
import './DynamicVariable.styles.scss';
import { Select, Typography } from 'antd';
import CustomSelect from 'components/NewSelect/CustomSelect';
import { DEBOUNCE_DELAY } from 'constants/queryBuilderFilterConfig';
import { useGetFieldKeys } from 'hooks/dynamicVariables/useGetFieldKeys';
import useDebounce from 'hooks/useDebounce';
import {
Dispatch,
SetStateAction,
useCallback,
useEffect,
useMemo,
useState,
} from 'react';
import { FieldKey } from 'types/api/dynamicVariables/getFieldKeys';
enum AttributeSource {
ALL_SOURCES = 'All Sources',
LOGS = 'Logs',
METRICS = 'Metrics',
TRACES = 'Traces',
}
function DynamicVariable({
setDynamicVariablesSelectedValue,
dynamicVariablesSelectedValue,
}: {
setDynamicVariablesSelectedValue: Dispatch<
SetStateAction<
| {
name: string;
value: string;
}
| undefined
>
>;
dynamicVariablesSelectedValue:
| {
name: string;
value: string;
}
| undefined;
}): JSX.Element {
const sources = [
AttributeSource.ALL_SOURCES,
AttributeSource.LOGS,
AttributeSource.TRACES,
AttributeSource.METRICS,
];
const [attributeSource, setAttributeSource] = useState<AttributeSource>();
const [attributes, setAttributes] = useState<Record<string, FieldKey[]>>({});
const [selectedAttribute, setSelectedAttribute] = useState<string>();
const [apiSearchText, setApiSearchText] = useState<string>('');
const [errorMessage, setErrorMessage] = useState<string>();
const debouncedApiSearchText = useDebounce(apiSearchText, DEBOUNCE_DELAY);
const [filteredAttributes, setFilteredAttributes] = useState<
Record<string, FieldKey[]>
>({});
useEffect(() => {
if (dynamicVariablesSelectedValue?.name) {
setSelectedAttribute(dynamicVariablesSelectedValue.name);
}
if (dynamicVariablesSelectedValue?.value) {
setAttributeSource(dynamicVariablesSelectedValue.value as AttributeSource);
}
}, [
dynamicVariablesSelectedValue?.name,
dynamicVariablesSelectedValue?.value,
]);
const { data, error, isLoading, refetch } = useGetFieldKeys({
signal:
attributeSource === AttributeSource.ALL_SOURCES
? undefined
: (attributeSource?.toLowerCase() as 'traces' | 'logs' | 'metrics'),
name: debouncedApiSearchText,
});
const isComplete = useMemo(() => data?.payload?.complete === true, [data]);
useEffect(() => {
if (data) {
const newAttributes = data.payload?.keys ?? {};
setAttributes(newAttributes);
setFilteredAttributes(newAttributes);
}
}, [data]);
// refetch when attributeSource changes
useEffect(() => {
if (attributeSource) {
refetch();
}
}, [attributeSource, refetch, debouncedApiSearchText]);
// Handle search based on whether we have complete data or not
const handleSearch = useCallback(
(text: string) => {
if (isComplete) {
// If complete is true, do client-side filtering
if (!text) {
setFilteredAttributes(attributes);
return;
}
const filtered: Record<string, FieldKey[]> = {};
Object.keys(attributes).forEach((key) => {
if (key.toLowerCase().includes(text.toLowerCase())) {
filtered[key] = attributes[key];
}
});
setFilteredAttributes(filtered);
} else {
// If complete is false, debounce the API call
setApiSearchText(text);
}
},
[attributes, isComplete],
);
// update setDynamicVariablesSelectedValue with debounce when attribute and source is selected
useEffect(() => {
if (selectedAttribute || attributeSource) {
setDynamicVariablesSelectedValue({
name: selectedAttribute || dynamicVariablesSelectedValue?.name || '',
value:
attributeSource ||
dynamicVariablesSelectedValue?.value ||
AttributeSource.ALL_SOURCES,
});
}
}, [
selectedAttribute,
attributeSource,
setDynamicVariablesSelectedValue,
dynamicVariablesSelectedValue?.name,
dynamicVariablesSelectedValue?.value,
]);
const errorText = (error as any)?.message || errorMessage;
return (
<div className="dynamic-variable-container">
<CustomSelect
placeholder="Select an Attribute"
options={Object.keys(filteredAttributes).map((key) => ({
label: key,
value: key,
}))}
loading={isLoading}
status={errorText ? 'error' : undefined}
onChange={(value): void => {
setSelectedAttribute(value);
}}
showSearch
errorMessage={errorText as any}
value={selectedAttribute || dynamicVariablesSelectedValue?.name}
onSearch={handleSearch}
onRetry={(): void => {
// reset error message
setErrorMessage(undefined);
refetch();
}}
/>
<Typography className="dynamic-variable-from-text">from</Typography>
<Select
placeholder="Source"
defaultValue={AttributeSource.ALL_SOURCES}
options={sources.map((source) => ({ label: source, value: source }))}
onChange={(value): void => setAttributeSource(value as AttributeSource)}
value={attributeSource || dynamicVariablesSelectedValue?.value}
/>
</div>
);
}
export default DynamicVariable;

View File

@@ -0,0 +1,376 @@
/* eslint-disable react/jsx-props-no-spreading */
/* eslint-disable sonarjs/no-duplicate-string */
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { useGetFieldKeys } from 'hooks/dynamicVariables/useGetFieldKeys';
import DynamicVariable from '../DynamicVariable';
// Mock scrollIntoView since it's not available in JSDOM
window.HTMLElement.prototype.scrollIntoView = jest.fn();
// Mock dependencies
jest.mock('hooks/dynamicVariables/useGetFieldKeys', () => ({
useGetFieldKeys: jest.fn(),
}));
jest.mock('hooks/useDebounce', () => ({
__esModule: true,
default: (value: any): any => value, // Return the same value without debouncing for testing
}));
describe('DynamicVariable Component', () => {
const mockSetDynamicVariablesSelectedValue = jest.fn();
const ATTRIBUTE_PLACEHOLDER = 'Select an Attribute';
const LOADING_TEXT = 'We are updating the values...';
const DEFAULT_PROPS = {
setDynamicVariablesSelectedValue: mockSetDynamicVariablesSelectedValue,
dynamicVariablesSelectedValue: undefined,
};
const mockFieldKeysResponse = {
payload: {
keys: {
'service.name': [],
'http.status_code': [],
duration: [],
},
complete: true,
},
statusCode: 200,
};
beforeEach(() => {
jest.clearAllMocks();
// Default mock implementation
(useGetFieldKeys as jest.Mock).mockReturnValue({
data: mockFieldKeysResponse,
error: null,
isLoading: false,
refetch: jest.fn(),
});
});
// Helper function to get the attribute select element
const getAttributeSelect = (): HTMLElement =>
screen.getAllByRole('combobox')[0];
// Helper function to get the source select element
const getSourceSelect = (): HTMLElement => screen.getAllByRole('combobox')[1];
it('renders with default state', () => {
render(<DynamicVariable {...DEFAULT_PROPS} />);
// Check for main components
expect(screen.getByText(ATTRIBUTE_PLACEHOLDER)).toBeInTheDocument();
expect(screen.getByText('All Sources')).toBeInTheDocument();
expect(screen.getByText('from')).toBeInTheDocument();
});
it('uses existing values from dynamicVariablesSelectedValue prop', () => {
const selectedValue = {
name: 'service.name',
value: 'Logs',
};
render(
<DynamicVariable
setDynamicVariablesSelectedValue={mockSetDynamicVariablesSelectedValue}
dynamicVariablesSelectedValue={selectedValue}
/>,
);
// Verify values are set
expect(screen.getByText('service.name')).toBeInTheDocument();
expect(screen.getByText('Logs')).toBeInTheDocument();
});
it('shows loading state when fetching data', () => {
(useGetFieldKeys as jest.Mock).mockReturnValue({
data: null,
error: null,
isLoading: true,
refetch: jest.fn(),
});
render(<DynamicVariable {...DEFAULT_PROPS} />);
// Open the CustomSelect dropdown
const attributeSelectElement = getAttributeSelect();
fireEvent.mouseDown(attributeSelectElement);
// Should show loading state
expect(screen.getByText(LOADING_TEXT)).toBeInTheDocument();
});
it('shows error message when API fails', () => {
const errorMessage = 'Failed to fetch field keys';
(useGetFieldKeys as jest.Mock).mockReturnValue({
data: null,
error: { message: errorMessage },
isLoading: false,
refetch: jest.fn(),
});
render(<DynamicVariable {...DEFAULT_PROPS} />);
// Open the CustomSelect dropdown
const attributeSelectElement = getAttributeSelect();
fireEvent.mouseDown(attributeSelectElement);
// Should show error message
expect(screen.getByText(errorMessage)).toBeInTheDocument();
});
it('updates filteredAttributes when data is loaded', async () => {
render(<DynamicVariable {...DEFAULT_PROPS} />);
// Open the CustomSelect dropdown
const attributeSelectElement = getAttributeSelect();
fireEvent.mouseDown(attributeSelectElement);
// Wait for options to appear in the dropdown
await waitFor(() => {
// Looking for option-content elements inside the CustomSelect dropdown
const options = document.querySelectorAll('.option-content');
expect(options.length).toBeGreaterThan(0);
// Check if all expected options are present
let foundServiceName = false;
let foundHttpStatusCode = false;
let foundDuration = false;
options.forEach((option) => {
const text = option.textContent?.trim();
if (text === 'service.name') foundServiceName = true;
if (text === 'http.status_code') foundHttpStatusCode = true;
if (text === 'duration') foundDuration = true;
});
expect(foundServiceName).toBe(true);
expect(foundHttpStatusCode).toBe(true);
expect(foundDuration).toBe(true);
});
});
it('calls setDynamicVariablesSelectedValue when attribute is selected', async () => {
render(<DynamicVariable {...DEFAULT_PROPS} />);
// Open the attribute dropdown
const attributeSelectElement = getAttributeSelect();
fireEvent.mouseDown(attributeSelectElement);
// Wait for options to appear, then click on service.name
await waitFor(() => {
// Need to find the option-item containing service.name
const serviceNameOption = screen.getByText('service.name');
expect(serviceNameOption).not.toBeNull();
expect(serviceNameOption?.textContent).toBe('service.name');
// Click on the option-item that contains service.name
const optionElement = serviceNameOption?.closest('.option-item');
if (optionElement) {
fireEvent.click(optionElement);
}
});
// Check if the setter was called with the correct value
expect(mockSetDynamicVariablesSelectedValue).toHaveBeenCalledWith({
name: 'service.name',
value: 'All Sources',
});
});
it('calls setDynamicVariablesSelectedValue when source is selected', () => {
const mockRefetch = jest.fn();
(useGetFieldKeys as jest.Mock).mockReturnValue({
data: mockFieldKeysResponse,
error: null,
isLoading: false,
refetch: mockRefetch,
});
render(<DynamicVariable {...DEFAULT_PROPS} />);
// Get the Select component
const select = screen
.getByText('All Sources')
.closest('div[class*="ant-select"]');
expect(select).toBeInTheDocument();
// Directly call the onChange handler by simulating the Select's onChange
// Find the props.onChange of the Select component and call it directly
fireEvent.mouseDown(select as HTMLElement);
// Use a more specific selector to find the "Logs" option
const optionsContainer = document.querySelector(
'.rc-virtual-list-holder-inner',
);
expect(optionsContainer).not.toBeNull();
// Find the option with Logs text content
const logsOption = Array.from(
optionsContainer?.querySelectorAll('.ant-select-item-option-content') || [],
)
.find((element) => element.textContent === 'Logs')
?.closest('.ant-select-item-option');
expect(logsOption).not.toBeNull();
// Click on it
if (logsOption) {
fireEvent.click(logsOption);
}
// Check if the setter was called with the correct value
expect(mockSetDynamicVariablesSelectedValue).toHaveBeenCalledWith(
expect.objectContaining({
value: 'Logs',
}),
);
});
it('filters attributes locally when complete is true', async () => {
render(<DynamicVariable {...DEFAULT_PROPS} />);
// Open the attribute dropdown
const attributeSelectElement = getAttributeSelect();
fireEvent.mouseDown(attributeSelectElement);
// Mock the filter function behavior
const attributeKeys = Object.keys(mockFieldKeysResponse.payload.keys);
// Only "http.status_code" should match the filter
const expectedFilteredKeys = attributeKeys.filter((key) =>
key.includes('http'),
);
// Verify our expected filtering logic
expect(expectedFilteredKeys).toContain('http.status_code');
expect(expectedFilteredKeys).not.toContain('service.name');
expect(expectedFilteredKeys).not.toContain('duration');
// Now verify the component's filtering ability by inputting the search text
const inputElement = screen
.getAllByRole('combobox')[0]
.querySelector('input');
if (inputElement) {
fireEvent.change(inputElement, { target: { value: 'http' } });
}
});
it('triggers API call when complete is false and search text changes', async () => {
const mockRefetch = jest.fn();
// Set up the mock to indicate that data is not complete
// and needs to be fetched from the server
(useGetFieldKeys as jest.Mock).mockReturnValue({
data: {
payload: {
keys: {
'http.status_code': [],
},
complete: false, // This indicates server-side filtering is needed
},
},
error: null,
isLoading: false,
refetch: mockRefetch,
});
// Render with Logs as the initial source
render(
<DynamicVariable
{...DEFAULT_PROPS}
dynamicVariablesSelectedValue={{
name: '',
value: 'Logs',
}}
/>,
);
// Clear any initial calls
mockRefetch.mockClear();
// Now test the search functionality
const attributeSelectElement = getAttributeSelect();
fireEvent.mouseDown(attributeSelectElement);
// Find the input element and simulate typing
const inputElement = document.querySelector(
'.ant-select-selection-search-input',
);
if (inputElement) {
// Simulate typing in the search input
fireEvent.change(inputElement, { target: { value: 'http' } });
// Verify that the input has the correct value
expect((inputElement as HTMLInputElement).value).toBe('http');
// Wait for the effect to run and verify refetch was called
await waitFor(
() => {
expect(mockRefetch).toHaveBeenCalled();
},
{ timeout: 3000 },
); // Increase timeout to give more time for the effect to run
}
});
it('triggers refetch when attributeSource changes', async () => {
const mockRefetch = jest.fn();
(useGetFieldKeys as jest.Mock).mockReturnValue({
data: mockFieldKeysResponse,
error: null,
isLoading: false,
refetch: mockRefetch,
});
render(<DynamicVariable {...DEFAULT_PROPS} />);
// Clear any initial calls
mockRefetch.mockClear();
// Find and click on the source select to open dropdown
const sourceSelectElement = getSourceSelect();
fireEvent.mouseDown(sourceSelectElement);
// Find and click on the "Metrics" option
const metricsOption = screen.getByText('Metrics');
fireEvent.click(metricsOption);
// Wait for the effect to run
await waitFor(() => {
// Verify that refetch was called after source selection
expect(mockRefetch).toHaveBeenCalled();
});
});
it('shows retry button when error occurs', () => {
const mockRefetch = jest.fn();
(useGetFieldKeys as jest.Mock).mockReturnValue({
data: null,
error: { message: 'Failed to fetch field keys' },
isLoading: false,
refetch: mockRefetch,
});
render(<DynamicVariable {...DEFAULT_PROPS} />);
// Open the attribute dropdown
const attributeSelectElement = getAttributeSelect();
fireEvent.mouseDown(attributeSelectElement);
// Find and click reload icon (retry button)
const reloadIcon = screen.getByLabelText('reload');
fireEvent.click(reloadIcon);
// Should trigger refetch
expect(mockRefetch).toHaveBeenCalled();
});
});

View File

@@ -99,8 +99,8 @@
}
.variable-type-btn-group {
display: flex;
width: 342px;
display: grid;
grid-template-columns: max-content max-content max-content max-content;
height: 32px;
flex-shrink: 0;
border-radius: 2px;
@@ -113,12 +113,14 @@
height: 32px;
flex-shrink: 0;
border-radius: 2px 0px 0px 2px;
width: 100%;
}
.variable-type-btn {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
}
.variable-type-btn + .variable-type-btn {
@@ -199,6 +201,37 @@
}
}
.default-value-section {
display: grid;
grid-template-columns: max-content 1fr;
.default-value-description {
color: var(--bg-vanilla-400);
font-family: Inter;
font-size: 11px;
font-style: normal;
font-weight: 400;
line-height: 18px;
letter-spacing: -0.06px;
}
}
.dynamic-variable-section {
justify-content: space-between;
margin-bottom: 0;
.typography-variables {
color: var(--bg-vanilla-400);
font-family: Inter;
font-size: 14px;
font-style: normal;
font-weight: 400;
line-height: 20px; /* 142.857% */
letter-spacing: -0.07px;
width: 339px;
}
}
.variable-textbox-section {
justify-content: space-between;
margin-bottom: 0;
@@ -446,6 +479,18 @@
}
}
.default-value-section {
.default-value-description {
color: var(--bg-ink-400);
}
}
.dynamic-variable-section {
.typography-variables {
color: var(--bg-ink-400);
}
}
.variable-textbox-section {
.typography-variables {
color: var(--bg-ink-400);

View File

@@ -1,4 +1,3 @@
import { QueryClient, QueryClientProvider } from 'react-query';
import { fireEvent, render, screen, waitFor } from 'tests/test-utils';
import {
IDashboardVariable,
@@ -75,23 +74,6 @@ const TEST_VAR_DESCRIPTIONS = {
const SAVE_BUTTON_TEXT = 'Save Variable';
const UNIQUE_NAME_PLACEHOLDER = 'Unique name of the variable';
// Create QueryClient for wrapping the component
const createTestQueryClient = (): QueryClient =>
new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
});
// Wrapper component with QueryClientProvider
const wrapper = ({ children }: { children: React.ReactNode }): JSX.Element => (
<QueryClientProvider client={createTestQueryClient()}>
{children}
</QueryClientProvider>
);
// Basic variable data for testing
const basicVariableData: IDashboardVariable = {
id: TEST_VAR_IDS.VAR1,
@@ -118,7 +100,6 @@ const renderVariableItem = (
validateName={validateNameFn}
mode={VARIABLE_MODE}
/>,
{ wrapper } as any,
);
};

View File

@@ -6,7 +6,9 @@ import { Button, Collapse, Input, Select, Switch, Tag, Typography } from 'antd';
import dashboardVariablesQuery from 'api/dashboard/variables/dashboardVariablesQuery';
import cx from 'classnames';
import Editor from 'components/Editor';
import { CustomSelect } from 'components/NewSelect';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import { useGetFieldValues } from 'hooks/dynamicVariables/useGetFieldValues';
import { commaValuesParser } from 'lib/dashbaordVariables/customCommaValuesParser';
import sortValues from 'lib/dashbaordVariables/sortVariableValues';
import { map } from 'lodash-es';
@@ -16,16 +18,20 @@ import {
ClipboardType,
DatabaseZap,
LayoutList,
Pyramid,
X,
} from 'lucide-react';
import { useCallback, useEffect, useState } from 'react';
import { useQuery } from 'react-query';
import { useSelector } from 'react-redux';
import { AppState } from 'store/reducers';
import {
IDashboardVariable,
TSortVariableValuesType,
TVariableQueryType,
VariableSortTypeArr,
} from 'types/api/dashboard/getAll';
import { GlobalReducer } from 'types/reducer/globalTime';
import { v4 as generateUUID } from 'uuid';
import {
@@ -34,7 +40,9 @@ import {
} from '../../../DashboardVariablesSelection/util';
import { variablePropsToPayloadVariables } from '../../../utils';
import { TVariableMode } from '../types';
import DynamicVariable from './DynamicVariable/DynamicVariable';
import { LabelContainer, VariableItemRow } from './styles';
import { WidgetSelector } from './WidgetSelector';
const { Option } = Select;
@@ -57,11 +65,15 @@ function VariableItem({
const [variableName, setVariableName] = useState<string>(
variableData.name || '',
);
const [
hasUserManuallyChangedName,
setHasUserManuallyChangedName,
] = useState<boolean>(false);
const [variableDescription, setVariableDescription] = useState<string>(
variableData.description || '',
);
const [queryType, setQueryType] = useState<TVariableQueryType>(
variableData.type || 'QUERY',
variableData.type || 'DYNAMIC',
);
const [variableQueryValue, setVariableQueryValue] = useState<string>(
variableData.queryValue || '',
@@ -85,11 +97,100 @@ function VariableItem({
variableData.showALLOption || false,
);
const [previewValues, setPreviewValues] = useState<string[]>([]);
const [variableDefaultValue, setVariableDefaultValue] = useState<string>(
(variableData.defaultValue as string) || '',
);
const [
dynamicVariablesSelectedValue,
setDynamicVariablesSelectedValue,
] = useState<{ name: string; value: string }>();
useEffect(() => {
if (
variableData.dynamicVariablesAttribute &&
variableData.dynamicVariablesSource
) {
setDynamicVariablesSelectedValue({
name: variableData.dynamicVariablesAttribute,
value: variableData.dynamicVariablesSource,
});
}
}, [
variableData.dynamicVariablesAttribute,
variableData.dynamicVariablesSource,
]);
// Error messages
const [errorName, setErrorName] = useState<boolean>(false);
const [errorNameMessage, setErrorNameMessage] = useState<string>('');
const [errorPreview, setErrorPreview] = useState<string | null>(null);
// Auto-set variable name to selected attribute name in creation mode when user hasn't manually changed it
useEffect(() => {
if (
mode === 'ADD' && // Only in creation mode
queryType === 'DYNAMIC' && // Only for dynamic variables
dynamicVariablesSelectedValue?.name && // Attribute is selected
!hasUserManuallyChangedName // User hasn't manually changed the name
) {
const newName = dynamicVariablesSelectedValue.name;
setVariableName(newName);
// Trigger validation for the auto-set name
if (!validateName(newName)) {
setErrorName(true);
setErrorNameMessage('Variable name already exists');
} else {
setErrorName(false);
setErrorNameMessage('');
}
}
}, [
mode,
queryType,
dynamicVariablesSelectedValue?.name,
hasUserManuallyChangedName,
validateName,
]);
const REQUIRED_NAME_MESSAGE = 'Variable name is required';
// Initialize error state for empty name
useEffect(() => {
if (!variableName.trim()) {
setErrorName(true);
}
}, [variableName]);
const { maxTime, minTime } = useSelector<AppState, GlobalReducer>(
(state) => state.globalTime,
);
const { data: fieldValues } = useGetFieldValues({
signal:
dynamicVariablesSelectedValue?.value === 'All Sources'
? undefined
: (dynamicVariablesSelectedValue?.value?.toLowerCase() as
| 'traces'
| 'logs'
| 'metrics'),
name: dynamicVariablesSelectedValue?.name || '',
enabled:
!!dynamicVariablesSelectedValue?.name &&
!!dynamicVariablesSelectedValue?.value,
startUnixMilli: minTime,
endUnixMilli: maxTime,
});
const [selectedWidgets, setSelectedWidgets] = useState<string[]>([]);
useEffect(() => {
if (queryType === 'DYNAMIC') {
setSelectedWidgets(variableData?.dynamicVariablesWidgetIds || []);
}
}, [queryType, variableData?.dynamicVariablesWidgetIds]);
useEffect(() => {
if (queryType === 'CUSTOM') {
setPreviewValues(
@@ -110,6 +211,28 @@ function VariableItem({
variableSortType,
]);
useEffect(() => {
if (
queryType === 'DYNAMIC' &&
fieldValues &&
dynamicVariablesSelectedValue?.name &&
dynamicVariablesSelectedValue?.value
) {
setPreviewValues(
sortValues(
fieldValues.payload?.normalizedValues || [],
variableSortType,
) as never,
);
}
}, [
fieldValues,
variableSortType,
queryType,
dynamicVariablesSelectedValue?.name,
dynamicVariablesSelectedValue?.value,
]);
const handleSave = (): void => {
// Check for cyclic dependencies
const newVariable = {
@@ -120,15 +243,26 @@ function VariableItem({
customValue: variableCustomValue,
textboxValue: variableTextboxValue,
multiSelect: variableMultiSelect,
showALLOption: variableShowALLOption,
showALLOption: queryType === 'DYNAMIC' ? true : variableShowALLOption,
sort: variableSortType,
...(queryType === 'TEXTBOX' && {
selectedValue: (variableData.selectedValue ||
variableTextboxValue) as never,
}),
...(queryType !== 'TEXTBOX' && {
defaultValue: variableDefaultValue as never,
}),
modificationUUID: generateUUID(),
id: variableData.id || generateUUID(),
order: variableData.order,
...(queryType === 'DYNAMIC' && {
dynamicVariablesAttribute: dynamicVariablesSelectedValue?.name,
dynamicVariablesSource: dynamicVariablesSelectedValue?.value,
}),
...(queryType === 'DYNAMIC' && {
dynamicVariablesWidgetIds:
selectedWidgets?.length > 0 ? selectedWidgets : [],
}),
};
const allVariables = [...Object.values(existingVariables), newVariable];
@@ -223,17 +357,29 @@ function VariableItem({
placeholder="Unique name of the variable"
value={variableName}
className="name-input"
onChange={(e): void => {
setVariableName(e.target.value);
setErrorName(
!validateName(e.target.value) && e.target.value !== variableData.name,
);
onChange={({ target: { value } }): void => {
setVariableName(value);
setHasUserManuallyChangedName(true); // Mark that user has manually changed the name
// Check for empty name
if (!value.trim()) {
setErrorName(true);
setErrorNameMessage(REQUIRED_NAME_MESSAGE);
}
// Check for duplicate name
else if (!validateName(value) && value !== variableData.name) {
setErrorName(true);
setErrorNameMessage('Variable name already exists');
}
// No errors
else {
setErrorName(false);
setErrorNameMessage('');
}
}}
/>
<div>
<Typography.Text type="warning">
{errorName ? 'Variable name already exists' : ''}
</Typography.Text>
<Typography.Text type="warning">{errorNameMessage}</Typography.Text>
</div>
</div>
</VariableItemRow>
@@ -258,18 +404,25 @@ function VariableItem({
<div className="variable-type-btn-group">
<Button
type="text"
icon={<DatabaseZap size={14} />}
icon={<Pyramid size={14} />}
className={cx(
// eslint-disable-next-line sonarjs/no-duplicate-string
'variable-type-btn',
queryType === 'QUERY' ? 'selected' : '',
queryType === 'DYNAMIC' ? 'selected' : '',
)}
onClick={(): void => {
setQueryType('QUERY');
setQueryType('DYNAMIC');
setPreviewValues([]);
// Reset manual change flag if no name is entered
if (!variableName.trim()) {
setHasUserManuallyChangedName(false);
}
}}
>
Query
Dynamic
<Tag bordered={false} className="sidenav-beta-tag" color="geekblue">
Beta
</Tag>
</Button>
<Button
type="text"
@@ -281,6 +434,10 @@ function VariableItem({
onClick={(): void => {
setQueryType('TEXTBOX');
setPreviewValues([]);
// Reset manual change flag if no name is entered
if (!variableName.trim()) {
setHasUserManuallyChangedName(false);
}
}}
>
Textbox
@@ -295,12 +452,46 @@ function VariableItem({
onClick={(): void => {
setQueryType('CUSTOM');
setPreviewValues([]);
// Reset manual change flag if no name is entered
if (!variableName.trim()) {
setHasUserManuallyChangedName(false);
}
}}
>
Custom
</Button>
<Button
type="text"
icon={<DatabaseZap size={14} />}
className={cx(
// eslint-disable-next-line sonarjs/no-duplicate-string
'variable-type-btn',
queryType === 'QUERY' ? 'selected' : '',
)}
onClick={(): void => {
setQueryType('QUERY');
setPreviewValues([]);
// Reset manual change flag if no name is entered
if (!variableName.trim()) {
setHasUserManuallyChangedName(false);
}
}}
>
Query
<Tag bordered={false} className="sidenav-beta-tag" color="warning">
Not Recommended
</Tag>
</Button>
</div>
</VariableItemRow>
{queryType === 'DYNAMIC' && (
<div className="variable-dynamic-section">
<DynamicVariable
setDynamicVariablesSelectedValue={setDynamicVariablesSelectedValue}
dynamicVariablesSelectedValue={dynamicVariablesSelectedValue}
/>
</div>
)}
{queryType === 'QUERY' && (
<div className="query-container">
<LabelContainer>
@@ -388,7 +579,9 @@ function VariableItem({
/>
</VariableItemRow>
)}
{(queryType === 'QUERY' || queryType === 'CUSTOM') && (
{(queryType === 'QUERY' ||
queryType === 'CUSTOM' ||
queryType === 'DYNAMIC') && (
<>
<VariableItemRow className="variables-preview-section">
<LabelContainer style={{ width: '100%' }}>
@@ -444,7 +637,7 @@ function VariableItem({
}}
/>
</VariableItemRow>
{variableMultiSelect && (
{variableMultiSelect && queryType !== 'DYNAMIC' && (
<VariableItemRow className="all-option-section">
<LabelContainer>
<Typography className="typography-variables">
@@ -457,8 +650,40 @@ function VariableItem({
/>
</VariableItemRow>
)}
<VariableItemRow className="default-value-section">
<LabelContainer>
<Typography className="typography-variables">Default Value</Typography>
<Typography className="default-value-description">
{queryType === 'QUERY'
? 'Click Test Run Query to see the values or add custom value'
: 'Select a value from the preview values or add custom value'}
</Typography>
</LabelContainer>
<CustomSelect
placeholder="Select a default value"
value={variableDefaultValue}
onChange={(value): void => setVariableDefaultValue(value)}
options={previewValues.map((value) => ({
label: value,
value,
}))}
/>
</VariableItemRow>
</>
)}
{queryType === 'DYNAMIC' && (
<VariableItemRow className="dynamic-variable-section">
<LabelContainer>
<Typography className="typography-variables">
Select Panels to apply this variable
</Typography>
</LabelContainer>
<WidgetSelector
selectedWidgets={selectedWidgets}
setSelectedWidgets={setSelectedWidgets}
/>
</VariableItemRow>
)}
</div>
</div>
<div className="variable-item-footer">

View File

@@ -0,0 +1,50 @@
import { CustomMultiSelect } from 'components/NewSelect';
import { PANEL_GROUP_TYPES } from 'constants/queryBuilder';
import { generateGridTitle } from 'container/GridPanelSwitch/utils';
import { useDashboard } from 'providers/Dashboard/Dashboard';
export function WidgetSelector({
selectedWidgets,
setSelectedWidgets,
}: {
selectedWidgets: string[];
setSelectedWidgets: (widgets: string[]) => void;
}): JSX.Element {
const { selectedDashboard } = useDashboard();
// Get layout IDs for cross-referencing
const layoutIds = new Set(
(selectedDashboard?.data?.layout || []).map((item) => item.i),
);
// Filter and deduplicate widgets by ID, keeping only those with layout entries
// and excluding row widgets since they are not panels that can have variables
const widgets = Object.values(
(selectedDashboard?.data?.widgets || []).reduce(
(acc: Record<string, any>, widget) => {
if (
widget.id &&
layoutIds.has(widget.id) &&
widget.panelTypes !== PANEL_GROUP_TYPES.ROW
) {
acc[widget.id] = widget;
}
return acc;
},
{},
),
);
return (
<CustomMultiSelect
placeholder="Select Panels"
options={widgets.map((widget) => ({
label: generateGridTitle(widget.title),
value: widget.id,
}))}
value={selectedWidgets}
onChange={(value): void => setSelectedWidgets(value as string[])}
showLabels
/>
);
}

View File

@@ -0,0 +1,199 @@
/* eslint-disable sonarjs/cognitive-complexity */
import {
convertFiltersToExpressionWithExistingQuery,
removeKeysFromExpression,
} from 'components/QueryBuilderV2/utils';
import { cloneDeep, isArray, isEmpty } from 'lodash-es';
import { Dashboard, Widgets } from 'types/api/dashboard/getAll';
import {
IBuilderQuery,
TagFilterItem,
} from 'types/api/queryBuilder/queryBuilderData';
/**
* Updates the query filters in a builder query by appending new tag filters
*/
const updateQueryFilters = (
queryData: IBuilderQuery,
filter: TagFilterItem,
): IBuilderQuery => {
const existingFilters = queryData.filters?.items || [];
// addition | update
const currentFilterKey = filter.key?.key;
const valueToAdd = filter.value.toString();
const newItems: TagFilterItem[] = [];
existingFilters.forEach((existingFilter) => {
const newFilter = cloneDeep(existingFilter);
if (
newFilter.key?.key === currentFilterKey &&
!(isArray(newFilter.value) && newFilter.value.includes(valueToAdd)) &&
newFilter.value !== valueToAdd
) {
if (isEmpty(newFilter.value)) {
newFilter.value = valueToAdd;
newFilter.op = 'IN';
} else {
newFilter.value = (isArray(newFilter.value)
? [...newFilter.value, valueToAdd]
: [newFilter.value, valueToAdd]) as string[] | string;
newFilter.op = 'IN';
}
}
newItems.push(newFilter);
});
// if yet the filter key doesn't get added then add it
if (!newItems.find((item) => item.key?.key === currentFilterKey)) {
newItems.push(filter);
}
const newFilterToUpdate = {
...queryData.filters,
items: newItems,
op: queryData.filters?.op || 'AND',
};
return {
...queryData,
...convertFiltersToExpressionWithExistingQuery(
newFilterToUpdate,
queryData.filter?.expression,
),
};
};
/**
* Updates a single widget by adding filters to its query
*/
const updateSingleWidget = (
widget: Widgets,
filter: TagFilterItem,
): Widgets => {
if (!widget.query?.builder?.queryData || isEmpty(filter)) {
return widget;
}
return {
...widget,
query: {
...widget.query,
builder: {
...widget.query.builder,
queryData: widget.query.builder.queryData.map(
(queryData) => updateQueryFilters(queryData, filter), // todo - Sagar: check for multiple query or not
),
},
},
};
};
const removeIfPresent = (
queryData: IBuilderQuery,
filter: TagFilterItem,
): IBuilderQuery => {
const existingFilters = queryData.filters?.items || [];
// addition | update
const currentFilterKey = filter.key?.key;
const valueToAdd = filter.value.toString();
const newItems: TagFilterItem[] = [];
existingFilters.forEach((existingFilter) => {
const newFilter = cloneDeep(existingFilter);
if (newFilter.key?.key === currentFilterKey) {
if (isArray(newFilter.value) && newFilter.value.includes(valueToAdd)) {
newFilter.value = newFilter.value.filter((value) => value !== valueToAdd);
} else if (newFilter.value === valueToAdd) {
return;
}
}
newItems.push(newFilter);
});
return {
...queryData,
filters: {
...queryData.filters,
items: newItems,
op: queryData.filters?.op || 'AND',
},
filter: {
...queryData.filter,
expression: removeKeysFromExpression(
queryData.filter?.expression ?? '',
filter.key?.key ? [filter.key.key] : [],
),
},
};
};
const updateAfterRemoval = (
widget: Widgets,
filter: TagFilterItem,
): Widgets => {
if (!widget.query?.builder?.queryData || isEmpty(filter)) {
return widget;
}
// remove the filters where the current filter is available as value as this widget is not selected anymore, hence removal
return {
...widget,
query: {
...widget.query,
builder: {
...widget.query.builder,
queryData: widget.query.builder.queryData.map(
(queryData) => removeIfPresent(queryData, filter), // todo - Sagar: check for multiple query or not
),
},
},
};
};
/**
* A function that takes a dashboard configuration and a list of tag filters
* and returns an updated dashboard with the filters appended to widget queries.
*
* @param dashboard The dashboard configuration
* @param filters Array of tag filters to apply to widgets
* @param widgetIds Optional array of widget IDs to filter which widgets get updated
* @returns Updated dashboard configuration with filters applied
*/
export const addTagFiltersToDashboard = (
dashboard: Dashboard | undefined,
filter: TagFilterItem,
widgetIds?: string[],
applyToAll?: boolean,
): Dashboard | undefined => {
if (!dashboard || isEmpty(filter)) {
return dashboard;
}
// Create a deep copy to avoid mutating the original dashboard
const updatedDashboard = cloneDeep(dashboard);
// Process each widget to add filters
if (updatedDashboard.data.widgets) {
updatedDashboard.data.widgets = updatedDashboard.data.widgets.map(
(widget) => {
// Only apply to widgets with 'query' property
if ('query' in widget) {
// If widgetIds is provided, only update widgets with matching IDs
if (!applyToAll && widgetIds && !widgetIds.includes(widget.id)) {
// removal if needed
return updateAfterRemoval(widget as Widgets, filter);
}
return updateSingleWidget(widget as Widgets, filter);
}
return widget;
},
);
}
return updatedDashboard;
};

View File

@@ -15,13 +15,20 @@ import { CSS } from '@dnd-kit/utilities';
import { Button, Modal, Row, Space, Table, Typography } from 'antd';
import { RowProps } from 'antd/lib';
import { convertVariablesToDbFormat } from 'container/NewDashboard/DashboardVariablesSelection/util';
import { useAddDynamicVariableToPanels } from 'hooks/dashboard/useAddDynamicVariableToPanels';
import { useGetDynamicVariables } from 'hooks/dashboard/useGetDynamicVariables';
import { useUpdateDashboard } from 'hooks/dashboard/useUpdateDashboard';
import { createDynamicVariableToWidgetsMap } from 'hooks/dashboard/utils';
import { useNotifications } from 'hooks/useNotifications';
import { PenLine, Trash2 } from 'lucide-react';
import { useDashboard } from 'providers/Dashboard/Dashboard';
import React, { useEffect, useRef, useState } from 'react';
import React, { useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Dashboard, IDashboardVariable } from 'types/api/dashboard/getAll';
import {
Dashboard,
IDashboardVariable,
Widgets,
} from 'types/api/dashboard/getAll';
import { TVariableMode } from './types';
import VariableItem from './VariableItem/VariableItem';
@@ -52,8 +59,10 @@ function TableRow({ children, ...props }: RowProps): JSX.Element {
// eslint-disable-next-line react/jsx-props-no-spreading
<tr {...props} ref={setNodeRef} style={style} {...attributes}>
{React.Children.map(children, (child) => {
if ((child as React.ReactElement).key === 'name') {
return React.cloneElement(child as React.ReactElement, {
const childElement = child as React.ReactElement;
if (childElement.key === 'name') {
return React.cloneElement(childElement, {
key: 'name-with-drag',
children: (
<div className="variable-name-drag">
<HolderOutlined
@@ -68,7 +77,7 @@ function TableRow({ children, ...props }: RowProps): JSX.Element {
});
}
return child;
return childElement;
})}
</tr>
);
@@ -81,6 +90,8 @@ function VariablesSetting({
}): JSX.Element {
const variableToDelete = useRef<IDashboardVariable | null>(null);
const [deleteVariableModal, setDeleteVariableModal] = useState(false);
const variableToApplyToAll = useRef<IDashboardVariable | null>(null);
const [applyToAllModal, setApplyToAllModal] = useState(false);
const { t } = useTranslation(['dashboard']);
@@ -88,7 +99,12 @@ function VariablesSetting({
const { notifications } = useNotifications();
const { variables = {} } = selectedDashboard?.data || {};
const variables = useMemo(() => selectedDashboard?.data?.variables || {}, [
selectedDashboard?.data?.variables,
]);
const widgets = useMemo(() => selectedDashboard?.data?.widgets || [], [
selectedDashboard?.data?.widgets,
]);
const [variablesTableData, setVariablesTableData] = useState<any>([]);
const [variblesOrderArr, setVariablesOrderArr] = useState<number[]>([]);
@@ -127,6 +143,19 @@ function VariablesSetting({
const updateMutation = useUpdateDashboard();
const addDynamicVariableToPanels = useAddDynamicVariableToPanels();
const { dynamicVariables } = useGetDynamicVariables();
const dynamicVariableToWidgetsMap = useMemo(
() =>
createDynamicVariableToWidgetsMap(
dynamicVariables,
(widgets as Widgets[]) || [],
),
[dynamicVariables, widgets],
);
useEffect(() => {
const tableRowData = [];
const variableOrderArr = [];
@@ -157,24 +186,48 @@ function VariablesSetting({
tableRowData.sort((a, b) => a.order - b.order);
variableOrderArr.sort((a, b) => a - b);
setVariablesTableData(tableRowData);
// Apply dynamic variables widget IDs if available
const processedTableRowData = tableRowData.map(
(variable: IDashboardVariable) => {
if (variable.type === 'DYNAMIC') {
return {
...variable,
dynamicVariablesWidgetIds: dynamicVariableToWidgetsMap[variable.id] || [],
};
}
return variable;
},
);
setVariablesTableData(processedTableRowData);
setVariablesOrderArr(variableOrderArr);
setExistingVariableNamesMap(variableNamesMap);
}, [variables]);
}, [variables, dynamicVariableToWidgetsMap]);
const updateVariables = (
updatedVariablesData: Dashboard['data']['variables'],
currentRequestedId?: string,
applyToAll?: boolean,
): void => {
if (!selectedDashboard) {
return;
}
const newDashboard =
(currentRequestedId &&
addDynamicVariableToPanels(
selectedDashboard,
updatedVariablesData[currentRequestedId || ''],
applyToAll,
)) ||
selectedDashboard;
updateMutation.mutateAsync(
{
id: selectedDashboard.id,
data: {
...selectedDashboard.data,
...newDashboard.data,
variables: updatedVariablesData,
},
},
@@ -202,6 +255,7 @@ function VariablesSetting({
const onVariableSaveHandler = (
mode: TVariableMode,
variableData: IDashboardVariable,
applyToAll?: boolean,
): void => {
const updatedVariableData = {
...variableData,
@@ -225,7 +279,7 @@ function VariablesSetting({
const variables = convertVariablesToDbFormat(newVariablesArr);
setVariablesTableData(newVariablesArr);
updateVariables(variables);
updateVariables(variables, variableData?.id, applyToAll);
onDoneVariableViewMode();
};
@@ -251,6 +305,28 @@ function VariablesSetting({
setDeleteVariableModal(false);
};
const onApplyToAllHandler = (variable: IDashboardVariable): void => {
variableToApplyToAll.current = variable;
setApplyToAllModal(true);
};
const handleApplyToAllConfirm = (): void => {
if (variableToApplyToAll.current) {
onVariableSaveHandler(
variableViewMode || 'EDIT',
variableToApplyToAll.current,
true,
);
}
variableToApplyToAll.current = null;
setApplyToAllModal(false);
};
const handleApplyToAllCancel = (): void => {
variableToApplyToAll.current = null;
setApplyToAllModal(false);
};
const validateVariableName = (name: string): boolean =>
!existingVariableNamesMap[name];
@@ -271,6 +347,16 @@ function VariablesSetting({
{variable.description}
</Typography.Text>
<Space className="actions-btns">
{variable.type === 'DYNAMIC' && (
<Button
type="text"
onClick={(): void => onApplyToAllHandler(variable)}
className="apply-to-all-button"
loading={updateMutation.isLoading}
>
<Typography.Text>Apply to all</Typography.Text>
</Button>
)}
<Button
type="text"
onClick={(): void => onVariableViewModeEnter('EDIT', variable)}
@@ -415,6 +501,25 @@ function VariablesSetting({
?
</Typography.Text>
</Modal>
<Modal
title="Apply variable to all panels"
centered
open={applyToAllModal}
onOk={handleApplyToAllConfirm}
onCancel={handleApplyToAllCancel}
okText="Apply to all"
cancelText="Cancel"
okButtonProps={{ danger: true }}
>
<Typography.Text>
Are you sure you want to apply variable{' '}
<span className="delete-variable-name">
{variableToApplyToAll?.current?.name}
</span>{' '}
to all panels? This action may affect panels where this variable is not
applicable.
</Typography.Text>
</Modal>
</>
);
}

View File

@@ -1,6 +1,6 @@
import './DashboardVariableSelection.styles.scss';
import { Alert, Row } from 'antd';
import { Row } from 'antd';
import { isEmpty } from 'lodash-es';
import { useDashboard } from 'providers/Dashboard/Dashboard';
import { memo, useEffect, useState } from 'react';
@@ -9,6 +9,7 @@ import { AppState } from 'store/reducers';
import { IDashboardVariable } from 'types/api/dashboard/getAll';
import { GlobalReducer } from 'types/reducer/globalTime';
import DynamicVariableSelection from './DynamicVariableSelection';
import {
buildDependencies,
buildDependencyGraph,
@@ -99,16 +100,23 @@ function DashboardVariableSelection(): JSX.Element | null {
[JSON.stringify(dependencyData?.order), minTime, maxTime],
);
// Performance optimization: For dynamic variables with allSelected=true, we don't store
// individual values in localStorage since we can always derive them from available options.
// This makes localStorage much lighter and more efficient.
const onValueUpdate = (
name: string,
id: string,
value: IDashboardVariable['selectedValue'],
allSelected: boolean,
// isMountedCall?: boolean,
haveCustomValuesSelected?: boolean,
// eslint-disable-next-line sonarjs/cognitive-complexity
): void => {
if (id) {
updateLocalStorageDashboardVariables(name, value, allSelected);
// For dynamic variables, only store in localStorage when NOT allSelected
// This makes localStorage much lighter by avoiding storing all individual values
const variable = variables?.[id] || variables?.[name];
const isDynamic = variable?.type === 'DYNAMIC';
updateLocalStorageDashboardVariables(name, value, allSelected, isDynamic);
if (selectedDashboard) {
setSelectedDashboard((prev) => {
@@ -121,6 +129,7 @@ function DashboardVariableSelection(): JSX.Element | null {
...oldVariables[id],
selectedValue: value,
allSelected,
haveCustomValuesSelected,
};
}
if (oldVariables?.[name]) {
@@ -128,6 +137,7 @@ function DashboardVariableSelection(): JSX.Element | null {
...oldVariables[name],
selectedValue: value,
allSelected,
haveCustomValuesSelected,
};
}
return {
@@ -170,22 +180,22 @@ function DashboardVariableSelection(): JSX.Element | null {
);
return (
<>
{dependencyData?.hasCycle && (
<Alert
message={`Circular dependency detected: ${dependencyData?.cycleNodes?.join(
' → ',
)}`}
type="error"
showIcon
className="cycle-error-alert"
/>
)}
<Row style={{ display: 'flex', gap: '12px' }}>
{orderBasedSortedVariables &&
Array.isArray(orderBasedSortedVariables) &&
orderBasedSortedVariables.length > 0 &&
orderBasedSortedVariables.map((variable) => (
<Row style={{ display: 'flex', gap: '12px' }}>
{orderBasedSortedVariables &&
Array.isArray(orderBasedSortedVariables) &&
orderBasedSortedVariables.length > 0 &&
orderBasedSortedVariables.map((variable) =>
variable.type === 'DYNAMIC' ? (
<DynamicVariableSelection
key={`${variable.name}${variable.id}${variable.order}`}
existingVariables={variables}
variableData={{
name: variable.name,
...variable,
}}
onValueUpdate={onValueUpdate}
/>
) : (
<VariableItem
key={`${variable.name}${variable.id}}${variable.order}`}
existingVariables={variables}
@@ -198,9 +208,9 @@ function DashboardVariableSelection(): JSX.Element | null {
setVariablesToGetUpdated={setVariablesToGetUpdated}
dependencyData={dependencyData}
/>
))}
</Row>
</>
),
)}
</Row>
);
}

View File

@@ -0,0 +1,585 @@
/* eslint-disable sonarjs/cognitive-complexity */
/* eslint-disable no-nested-ternary */
import './DashboardVariableSelection.styles.scss';
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 { useCallback, useEffect, useMemo, useState } from 'react';
import { useQuery } from 'react-query';
import { useSelector } from 'react-redux';
import { AppState } from 'store/reducers';
import { IDashboardVariable } from 'types/api/dashboard/getAll';
import { GlobalReducer } from 'types/reducer/globalTime';
import { popupContainer } from 'utils/selectPopupContainer';
import { ALL_SELECT_VALUE } from '../utils';
import { SelectItemStyle } from './styles';
import {
areArraysEqual,
getOptionsForDynamicVariable,
uniqueValues,
} from './util';
import { getSelectValue } from './VariableItem';
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 [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 sources'
? undefined
: (variableData.dynamicVariablesSource?.toLowerCase() as
| 'traces'
| 'logs'
| 'metrics'),
variableData.dynamicVariablesAttribute,
debouncedApiSearchText,
minTime,
maxTime,
existingQuery,
),
onSuccess: (data) => {
const newNormalizedValues = data.payload?.normalizedValues || [];
const newRelatedValues = data.payload?.relatedValues || [];
if (!debouncedApiSearchText) {
setOptionsData(newNormalizedValues);
setIsComplete(data.payload?.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);
}
},
},
);
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);
refetch();
}}
showIncompleteDataMessage={!isComplete && filteredOptionsData.length > 0}
/>
) : (
<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}
onRetry={(): void => {
setErrorMessage(null);
refetch();
}}
showIncompleteDataMessage={!isComplete && filteredOptionsData.length > 0}
/>
)}
</div>
</div>
);
}
export default DynamicVariableSelection;

View File

@@ -167,7 +167,7 @@ describe('VariableItem', () => {
</MockQueryClientProvider>,
);
expect(screen.getByTitle('ALL')).toBeInTheDocument();
expect(screen.getByText('ALL')).toBeInTheDocument();
});
test('calls useEffect when the component mounts', () => {

View File

@@ -8,23 +8,14 @@ import './DashboardVariableSelection.styles.scss';
import { orange } from '@ant-design/colors';
import { InfoCircleOutlined, WarningOutlined } from '@ant-design/icons';
import {
Checkbox,
Input,
Popover,
Select,
Tag,
Tooltip,
Typography,
} from 'antd';
import { CheckboxChangeEvent } from 'antd/es/checkbox';
import { Input, Popover, Tooltip, Typography } from 'antd';
import dashboardVariablesQuery from 'api/dashboard/variables/dashboardVariablesQuery';
import { CustomMultiSelect, CustomSelect } from 'components/NewSelect';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import { commaValuesParser } from 'lib/dashbaordVariables/customCommaValuesParser';
import sortValues from 'lib/dashbaordVariables/sortVariableValues';
import { debounce, isArray, isString } from 'lodash-es';
import map from 'lodash-es/map';
import { ChangeEvent, memo, useEffect, useMemo, useState } from 'react';
import { debounce, isArray, isEmpty, isString } from 'lodash-es';
import { memo, useCallback, useEffect, useMemo, useState } from 'react';
import { useQuery } from 'react-query';
import { useSelector } from 'react-redux';
import { AppState } from 'store/reducers';
@@ -33,17 +24,10 @@ import { VariableResponseProps } from 'types/api/dashboard/variables/query';
import { GlobalReducer } from 'types/reducer/globalTime';
import { popupContainer } from 'utils/selectPopupContainer';
import { variablePropsToPayloadVariables } from '../utils';
import { ALL_SELECT_VALUE, variablePropsToPayloadVariables } from '../utils';
import { SelectItemStyle } from './styles';
import { areArraysEqual, checkAPIInvocation, IDependencyData } from './util';
const ALL_SELECT_VALUE = '__ALL__';
enum ToggleTagValue {
Only = 'Only',
All = 'All',
}
interface VariableItemProps {
variableData: IDashboardVariable;
existingVariables: Record<string, IDashboardVariable>;
@@ -58,7 +42,7 @@ interface VariableItemProps {
dependencyData: IDependencyData | null;
}
const getSelectValue = (
export const getSelectValue = (
selectedValue: IDashboardVariable['selectedValue'],
variableData: IDashboardVariable,
): string | string[] | undefined => {
@@ -83,6 +67,9 @@ function VariableItem({
const [optionsData, setOptionsData] = useState<(string | number | boolean)[]>(
[],
);
const [tempSelection, setTempSelection] = useState<
string | string[] | undefined
>(undefined);
const { maxTime, minTime } = useSelector<AppState, GlobalReducer>(
(state) => state.globalTime,
@@ -139,6 +126,7 @@ function VariableItem({
valueNotInList = true;
}
}
// variablesData.allSelected is added for the case where on change of options we need to update the
// local storage
if (
@@ -146,28 +134,32 @@ function VariableItem({
variableData.name &&
(validVariableUpdate() || valueNotInList || variableData.allSelected)
) {
let value = variableData.selectedValue;
let allSelected = false;
// The default value for multi-select is ALL and first value for
// single select
if (valueNotInList) {
if (variableData.multiSelect) {
value = newOptionsData;
allSelected = true;
} else {
[value] = newOptionsData;
}
} else if (variableData.multiSelect) {
const { selectedValue } = variableData;
allSelected =
newOptionsData.length > 0 &&
Array.isArray(selectedValue) &&
selectedValue.length === newOptionsData.length &&
newOptionsData.every((option) => selectedValue.includes(option));
}
if (
variableData.allSelected &&
variableData.multiSelect &&
variableData.showALLOption
) {
onValueUpdate(variableData.name, variableData.id, newOptionsData, true);
if (variableData && variableData?.name && variableData?.id) {
onValueUpdate(variableData.name, variableData.id, value, allSelected);
// 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 && variableData?.name && variableData?.id) {
onValueUpdate(variableData.name, variableData.id, value, allSelected);
}
}
}
@@ -191,7 +183,7 @@ function VariableItem({
}
};
const { isLoading } = useQuery(
const { isLoading, refetch } = useQuery(
[
REACT_QUERY_KEY.DASHBOARD_BY_ID,
variableData.name || '',
@@ -242,26 +234,62 @@ function VariableItem({
},
);
const handleChange = (inputValue: string | string[]): void => {
const value = variableData.multiSelect && !inputValue ? [] : inputValue;
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))
value === variableData.selectedValue ||
(Array.isArray(value) &&
Array.isArray(variableData.selectedValue) &&
areArraysEqual(value, variableData.selectedValue))
) {
onValueUpdate(variableData.name, variableData.id, optionsData, true);
} else {
onValueUpdate(variableData.name, variableData.id, value, false);
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()));
if (isAllSelected && variableData.showALLOption) {
// For ALL selection, pass null to avoid storing values
onValueUpdate(variableData.name, variableData.id, optionsData, true);
} else {
onValueUpdate(variableData.name, variableData.id, value, false);
}
}
},
[
variableData.multiSelect,
variableData.selectedValue,
variableData.name,
variableData.id,
onValueUpdate,
optionsData,
variableData.showALLOption,
],
);
// Add a handler for tracking temporary selection changes
const handleTempChange = (inputValue: string | string[]): void => {
// Store the selection in temporary state while dropdown is open
const value = variableData.multiSelect && !inputValue ? [] : inputValue;
setTempSelection(value);
};
// Handle dropdown visibility changes
const handleDropdownVisibleChange = (visible: boolean): void => {
// Initialize temp selection when opening dropdown
if (visible) {
setTempSelection(getSelectValue(variableData.selectedValue, variableData));
}
// Apply changes when closing dropdown
else if (!visible && tempSelection !== undefined) {
// Call handleChange with the temporarily stored selection
handleChange(tempSelection);
setTempSelection(undefined);
}
};
@@ -281,10 +309,58 @@ function VariableItem({
? 'ALL'
: selectedValueStringified;
const mode: 'multiple' | undefined =
variableData.multiSelect && !variableData.allSelected
? 'multiple'
: undefined;
// Apply default value on first render if no selection exists
// 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 {
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,
selectedValue,
tempSelection,
optionsData,
]);
useEffect(() => {
if (
(variableData.multiSelect && !(tempSelection || selectValue)) ||
isEmpty(selectValue)
) {
handleChange(finalSelectedValues as string[] | string);
}
}, [
finalSelectedValues,
handleChange,
selectValue,
tempSelection,
variableData.multiSelect,
]);
useEffect(() => {
// Fetch options for CUSTOM Type
@@ -294,113 +370,6 @@ function VariableItem({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [variableData.type, variableData.customValue]);
const checkAll = (e: MouseEvent): void => {
e.stopPropagation();
e.preventDefault();
const isChecked =
variableData.allSelected || selectValue?.includes(ALL_SELECT_VALUE);
if (isChecked) {
handleChange([]);
} else {
handleChange(ALL_SELECT_VALUE);
}
};
const handleOptionSelect = (
e: CheckboxChangeEvent,
option: string | number | boolean,
): void => {
const newSelectedValue = Array.isArray(selectedValue)
? ((selectedValue.filter(
(val) => val.toString() !== option.toString(),
) as unknown) as string[])
: [];
if (
!e.target.checked &&
Array.isArray(selectedValueStringified) &&
selectedValueStringified.includes(option.toString())
) {
if (newSelectedValue.length === 1) {
handleChange(newSelectedValue[0].toString());
return;
}
handleChange(newSelectedValue);
} else if (!e.target.checked && selectedValue === option.toString()) {
handleChange(ALL_SELECT_VALUE);
} else if (newSelectedValue.length === optionsData.length - 1) {
handleChange(ALL_SELECT_VALUE);
}
};
const [optionState, setOptionState] = useState({
tag: '',
visible: false,
});
function currentToggleTagValue({
option,
}: {
option: string;
}): ToggleTagValue {
if (
option.toString() === selectValue ||
(Array.isArray(selectValue) &&
selectValue?.includes(option.toString()) &&
selectValue.length === 1)
) {
return ToggleTagValue.All;
}
return ToggleTagValue.Only;
}
function handleToggle(e: ChangeEvent, option: string): void {
e.stopPropagation();
const mode = currentToggleTagValue({ option: option as string });
const isChecked =
variableData.allSelected ||
option.toString() === selectValue ||
(Array.isArray(selectValue) && selectValue?.includes(option.toString()));
if (isChecked) {
if (mode === ToggleTagValue.Only && variableData.multiSelect) {
handleChange([option.toString()]);
} else if (!variableData.multiSelect) {
handleChange(option.toString());
} else {
handleChange(ALL_SELECT_VALUE);
}
} else {
handleChange(option.toString());
}
}
function retProps(
option: string,
): {
onMouseOver: () => void;
onMouseOut: () => void;
} {
return {
onMouseOver: (): void =>
setOptionState({
tag: option.toString(),
visible: true,
}),
onMouseOut: (): void =>
setOptionState({
tag: option.toString(),
visible: false,
}),
};
}
const ensureValidOption = (option: string): boolean =>
!(
currentToggleTagValue({ option }) === ToggleTagValue.All && !enableSelectAll
);
return (
<div className="variable-item">
<Typography.Text className="variable-name" ellipsis>
@@ -428,105 +397,90 @@ function VariableItem({
}}
/>
) : (
!errorMessage &&
optionsData && (
<Select
optionsData &&
(variableData.multiSelect ? (
<CustomMultiSelect
key={
selectValue && Array.isArray(selectValue)
? selectValue.join(' ')
: selectValue || variableData.id
}
defaultValue={selectValue}
onChange={handleChange}
options={optionsData.map((option) => ({
label: option.toString(),
value: option.toString(),
}))}
defaultValue={variableData.defaultValue || selectValue}
onChange={handleTempChange}
bordered={false}
placeholder="Select value"
placement="bottomLeft"
mode={mode}
style={SelectItemStyle}
loading={isLoading}
showSearch
data-testid="variable-select"
className="variable-select"
popupClassName="dropdown-styles"
maxTagCount={4}
maxTagCount={2}
getPopupContainer={popupContainer}
value={tempSelection || selectValue}
onDropdownVisibleChange={handleDropdownVisibleChange}
errorMessage={errorMessage}
// eslint-disable-next-line react/no-unstable-nested-components
tagRender={(props): JSX.Element => (
<Tag closable onClose={props.onClose}>
{props.value}
</Tag>
)}
// eslint-disable-next-line react/no-unstable-nested-components
maxTagPlaceholder={(omittedValues): JSX.Element => (
<Tooltip title={omittedValues.map(({ value }) => value).join(', ')}>
<span>+ {omittedValues.length} </span>
</Tooltip>
)}
allowClear={selectValue !== ALL_SELECT_VALUE && selectValue !== 'ALL'}
>
{enableSelectAll && (
<Select.Option data-testid="option-ALL" value={ALL_SELECT_VALUE}>
<div className="all-label" onClick={(e): void => checkAll(e as any)}>
<Checkbox checked={variableData.allSelected} />
ALL
</div>
</Select.Option>
)}
{map(optionsData, (option) => (
<Select.Option
data-testid={`option-${option}`}
key={option.toString()}
value={option}
>
<div
className={variableData.multiSelect ? 'dropdown-checkbox-label' : ''}
>
{variableData.multiSelect && (
<Checkbox
onChange={(e): void => {
e.stopPropagation();
e.preventDefault();
handleOptionSelect(e, option);
}}
checked={
variableData.allSelected ||
option.toString() === selectValue ||
(Array.isArray(selectValue) &&
selectValue?.includes(option.toString()))
}
/>
)}
<div
className="dropdown-value"
{...retProps(option as string)}
onClick={(e): void => handleToggle(e as any, option as string)}
>
<Typography.Text
ellipsis={{
tooltip: {
placement: variableData.multiSelect ? 'top' : 'right',
autoAdjustOverflow: true,
},
}}
className="option-text"
>
{option.toString()}
</Typography.Text>
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` : '');
{variableData.multiSelect &&
optionState.tag === option.toString() &&
optionState.visible &&
ensureValidOption(option as string) && (
<Typography.Text className="toggle-tag-label">
{currentToggleTagValue({ option: option as string })}
</Typography.Text>
)}
</div>
</div>
</Select.Option>
))}
</Select>
)
return (
<Tooltip title={tooltipText}>
<span>+ {omittedValues.length} </span>
</Tooltip>
);
}}
onClear={(): void => {
handleChange([]);
}}
enableAllSelection={enableSelectAll}
maxTagTextLength={30}
allowClear={selectValue !== ALL_SELECT_VALUE && selectValue !== 'ALL'}
onRetry={(): void => {
setErrorMessage(null);
refetch();
}}
/>
) : (
<CustomSelect
key={
selectValue && Array.isArray(selectValue)
? selectValue.join(' ')
: selectValue || variableData.id
}
defaultValue={variableData.defaultValue || selectValue}
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={optionsData.map((option) => ({
label: option.toString(),
value: option.toString(),
}))}
value={selectValue}
errorMessage={errorMessage}
onRetry={(): void => {
setErrorMessage(null);
refetch();
}}
/>
))
)}
{variableData.type !== 'TEXTBOX' && errorMessage && (
<span style={{ margin: '0 0.5rem' }}>

View File

@@ -0,0 +1,274 @@
/* eslint-disable react/jsx-props-no-spreading */
/* eslint-disable sonarjs/no-duplicate-string */
import { fireEvent, render, screen } from '@testing-library/react';
import * as ReactQuery from 'react-query';
import * as ReactRedux from 'react-redux';
import { IDashboardVariable } from 'types/api/dashboard/getAll';
import DynamicVariableSelection from '../DynamicVariableSelection';
// Don't mock the components - use real ones
// Mock for useQuery
const mockQueryResult = {
data: undefined,
error: null,
isError: false,
isIdle: false,
isLoading: false,
isPreviousData: false,
isSuccess: true,
status: 'success',
isFetched: true,
isFetchingNextPage: false,
isFetchingPreviousPage: false,
isPlaceholderData: false,
isPaused: false,
isRefetchError: false,
isRefetching: false,
isStale: false,
isLoadingError: false,
isFetching: false,
isFetchedAfterMount: true,
dataUpdatedAt: 0,
errorUpdatedAt: 0,
failureCount: 0,
refetch: jest.fn(),
remove: jest.fn(),
fetchNextPage: jest.fn(),
fetchPreviousPage: jest.fn(),
hasNextPage: false,
hasPreviousPage: false,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any;
// Sample data for testing
const mockApiResponse = {
payload: {
normalizedValues: ['frontend', 'backend', 'database'],
complete: true,
},
statusCode: 200,
};
// Mock scrollIntoView since it's not available in JSDOM
window.HTMLElement.prototype.scrollIntoView = jest.fn();
describe('DynamicVariableSelection Component', () => {
const mockOnValueUpdate = jest.fn();
const mockDynamicVariableData: IDashboardVariable = {
id: 'var1',
name: 'service',
type: 'DYNAMIC',
dynamicVariablesAttribute: 'service.name',
dynamicVariablesSource: 'Traces',
selectedValue: 'frontend',
multiSelect: false,
showALLOption: false,
allSelected: false,
description: '',
sort: 'DISABLED',
};
const mockMultiSelectDynamicVariableData: IDashboardVariable = {
...mockDynamicVariableData,
id: 'var2',
name: 'services',
multiSelect: true,
selectedValue: ['frontend', 'backend'],
showALLOption: true,
};
const mockExistingVariables: Record<string, IDashboardVariable> = {
var1: mockDynamicVariableData,
var2: mockMultiSelectDynamicVariableData,
};
beforeEach(() => {
jest.clearAllMocks();
mockOnValueUpdate.mockClear();
// Mock useSelector
const useSelectorSpy = jest.spyOn(ReactRedux, 'useSelector');
useSelectorSpy.mockReturnValue({
minTime: '2023-01-01T00:00:00Z',
maxTime: '2023-01-02T00:00:00Z',
});
// Mock useQuery with success state
const useQuerySpy = jest.spyOn(ReactQuery, 'useQuery');
useQuerySpy.mockReturnValue({
...mockQueryResult,
data: mockApiResponse,
isLoading: false,
error: null,
});
});
it('renders with single select variable correctly', () => {
render(
<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();
// CustomSelect doesn't use the 'mode' attribute for single select
expect(selectedItem).not.toHaveAttribute('mode');
});
it('renders with multi select variable correctly', () => {
// First set up allSelected to true to properly test the ALL display
const multiSelectWithAllSelected = {
...mockMultiSelectDynamicVariableData,
allSelected: true,
};
render(
<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();
});
it('shows loading state correctly', () => {
// Mock loading state
jest.spyOn(ReactQuery, 'useQuery').mockReturnValue({
...mockQueryResult,
data: null,
isLoading: true,
isFetching: true,
isSuccess: false,
status: 'loading',
});
render(
<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);
// The loading text should appear in the dropdown
expect(screen.getByText('We are updating the values...')).toBeInTheDocument();
});
it('handles error state correctly', () => {
const errorMessage = 'Failed to fetch data';
// Mock error state
jest.spyOn(ReactQuery, 'useQuery').mockReturnValue({
...mockQueryResult,
data: null,
isLoading: false,
isSuccess: false,
isError: true,
status: 'error',
error: { message: errorMessage },
});
render(
<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();
// We don't need to check refetch as it might be called during component initialization
});
it('makes API call to fetch variable values', () => {
render(
<DynamicVariableSelection
variableData={mockDynamicVariableData}
existingVariables={mockExistingVariables}
onValueUpdate={mockOnValueUpdate}
/>,
);
// Verify the useQuery hook was called with expected parameters
expect(ReactQuery.useQuery).toHaveBeenCalledWith(
[
'DASHBOARD_BY_ID',
mockDynamicVariableData.name,
'service:"frontend"|services:["frontend","backend"]', // The actual dynamicVariablesKey
'2023-01-01T00:00:00Z', // minTime from useSelector mock
'2023-01-02T00:00:00Z', // maxTime from useSelector mock
],
expect.objectContaining({
enabled: true, // Type is 'DYNAMIC'
queryFn: expect.any(Function),
onSuccess: expect.any(Function),
onError: expect.any(Function),
}),
);
});
it('has the correct selected value', () => {
// Use a different variable configuration to test different behavior
const customVariable = {
...mockDynamicVariableData,
id: 'custom1',
name: 'customService',
selectedValue: 'backend',
};
render(
<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();
// Check that the selected value is displayed in the select element
expect(selectElement).toHaveTextContent('backend');
});
});

View File

@@ -1,3 +1,4 @@
import { OptionData } from 'components/NewSelect/types';
import { isEmpty, isNull } from 'lodash-es';
import { Dashboard, IDashboardVariable } from 'types/api/dashboard/getAll';
@@ -294,3 +295,74 @@ export const checkAPIInvocation = (
variablesToGetUpdated[0] === variableData.name
);
};
export const getOptionsForDynamicVariable = (
normalizedValues: (string | number | boolean)[],
relatedValues: string[],
): OptionData[] => {
const options: OptionData[] = [];
if (relatedValues.length > 0) {
// Add Related Values group
options.push({
label: 'Related Values',
value: 'relatedValues',
options: relatedValues.map((option) => ({
label: option.toString(),
value: option.toString(),
})),
});
// Add All Values group (complete union - shows everything)
options.push({
label: 'All Values',
value: 'allValues',
options: normalizedValues.map((option) => ({
label: option.toString(),
value: option.toString(),
})),
});
return options;
}
return normalizedValues.map((option) => ({
label: option.toString(),
value: option.toString(),
}));
};
export const uniqueOptions = (options: OptionData[]): OptionData[] => {
const uniqueOptions: OptionData[] = [];
const seenValues = new Set<string>();
options.forEach((option) => {
const value = option.value || '';
if (seenValues.has(value)) {
return;
}
seenValues.add(value);
uniqueOptions.push(option);
});
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;
};

View File

@@ -0,0 +1,475 @@
/* eslint-disable sonarjs/no-identical-functions */
/* eslint-disable sonarjs/no-duplicate-string */
/* eslint-disable react/jsx-props-no-spreading */
import {
fireEvent,
render,
RenderResult,
screen,
waitFor,
} from '@testing-library/react';
import { getFieldValues } from 'api/dynamicVariables/getFieldValues';
import React from 'react';
import { QueryClient, QueryClientProvider } from 'react-query';
import * as ReactRedux from 'react-redux';
import { IDashboardVariable } from 'types/api/dashboard/getAll';
import DynamicVariableSelection from '../DashboardVariablesSelection/DynamicVariableSelection';
// Mock the getFieldValues API
jest.mock('api/dynamicVariables/getFieldValues', () => ({
getFieldValues: jest.fn(),
}));
describe('Dynamic Variable Default Behavior', () => {
const mockOnValueUpdate = jest.fn();
const mockApiResponse = {
statusCode: 200,
error: null,
message: 'success',
payload: {
values: {
stringValues: ['frontend', 'backend', 'database', 'cache'],
},
normalizedValues: ['frontend', 'backend', 'database', 'cache'],
complete: true,
},
};
let queryClient: QueryClient;
const renderWithQueryClient = (component: React.ReactElement): RenderResult =>
render(
<QueryClientProvider client={queryClient}>{component}</QueryClientProvider>,
);
beforeEach(() => {
// Mock scrollIntoView for JSDOM environment
window.HTMLElement.prototype.scrollIntoView = jest.fn();
jest.clearAllMocks();
// Create a new QueryClient for each test
queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
});
// Mock getFieldValues API to return our test data
(getFieldValues as jest.Mock).mockResolvedValue(mockApiResponse);
jest.spyOn(ReactRedux, 'useSelector').mockReturnValue({
minTime: '2023-01-01T00:00:00Z',
maxTime: '2023-01-02T00:00:00Z',
});
});
describe('Single Select Default Values', () => {
it('should use default value when no previous selection exists', () => {
const variableData: IDashboardVariable = {
id: 'var1',
name: 'service',
type: 'DYNAMIC',
multiSelect: false,
defaultValue: 'backend' as any,
selectedValue: undefined,
dynamicVariablesAttribute: 'service.name',
dynamicVariablesSource: 'Traces',
description: '',
sort: 'DISABLED',
showALLOption: false,
allSelected: false,
};
renderWithQueryClient(
<DynamicVariableSelection
variableData={variableData}
existingVariables={{ var1: variableData }}
onValueUpdate={mockOnValueUpdate}
/>,
);
// Should call onValueUpdate with default value
expect(mockOnValueUpdate).toHaveBeenCalledWith(
'service',
'var1',
'backend',
true,
false,
);
});
it('should preserve previous selection over default value', () => {
const variableData: IDashboardVariable = {
id: 'var1',
name: 'service',
type: 'DYNAMIC',
multiSelect: false,
defaultValue: 'backend' as any,
selectedValue: 'frontend',
dynamicVariablesAttribute: 'service.name',
dynamicVariablesSource: 'Traces',
description: '',
sort: 'DISABLED',
showALLOption: false,
allSelected: false,
};
renderWithQueryClient(
<DynamicVariableSelection
variableData={variableData}
existingVariables={{ var1: variableData }}
onValueUpdate={mockOnValueUpdate}
/>,
);
// Should NOT call onValueUpdate since previous selection exists
expect(mockOnValueUpdate).not.toHaveBeenCalledWith();
// Check if the previous selection 'frontend' is displayed in the UI
// For single select, the value should be visible in the select component
const selectElement = screen.getByRole('combobox');
expect(selectElement).toBeInTheDocument();
// Open dropdown to check if 'frontend' is selected
fireEvent.mouseDown(selectElement);
// Check if 'frontend' option is present and selected in dropdown
const frontendOption = screen.getByRole('option', { name: 'frontend' });
expect(frontendOption).toHaveClass('selected');
// Verify that 'backend' (default value) is NOT present in the dropdown
// since previous selection 'frontend' takes priority
expect(screen.queryByText('backend')).not.toBeInTheDocument();
});
it('should use first value when no default and no previous selection', async () => {
const variableData: IDashboardVariable = {
id: 'var1',
name: 'service',
type: 'DYNAMIC',
multiSelect: false,
defaultValue: undefined,
selectedValue: undefined,
dynamicVariablesAttribute: 'service.name',
dynamicVariablesSource: 'Traces',
description: '',
sort: 'DISABLED',
showALLOption: false,
allSelected: false,
};
renderWithQueryClient(
<DynamicVariableSelection
variableData={variableData}
existingVariables={{ var1: variableData }}
onValueUpdate={mockOnValueUpdate}
/>,
);
// Component should render without errors
expect(screen.getByText('$service')).toBeInTheDocument();
// Check if the dropdown is present
const selectElement = screen.getByRole('combobox');
expect(selectElement).toBeInTheDocument();
// Wait for API call to complete and data to be loaded
await waitFor(() => {
expect(getFieldValues).toHaveBeenCalledWith(
'traces',
'service.name',
'',
'2023-01-01T00:00:00Z',
'2023-01-02T00:00:00Z',
);
});
// Open dropdown to check available options
fireEvent.mouseDown(selectElement);
// Wait for dropdown to populate with API data
await waitFor(() => {
expect(
screen.getByRole('option', { name: 'frontend' }),
).toBeInTheDocument();
});
expect(screen.getByRole('option', { name: 'backend' })).toBeInTheDocument();
expect(screen.getByRole('option', { name: 'database' })).toBeInTheDocument();
expect(screen.getByRole('option', { name: 'cache' })).toBeInTheDocument();
// Check if the first value 'frontend' is selected (active)
const frontendOption = screen.getByRole('option', { name: 'frontend' });
expect(frontendOption).toHaveClass('active');
});
});
describe('Multi Select Default Values - ALL Enabled', () => {
it('should use default value when no previous selection exists', () => {
const variableData: IDashboardVariable = {
id: 'var1',
name: 'services',
type: 'DYNAMIC',
multiSelect: true,
showALLOption: true,
defaultValue: ['backend', 'database'] as any,
selectedValue: undefined,
dynamicVariablesAttribute: 'service.name',
dynamicVariablesSource: 'Traces',
description: '',
sort: 'DISABLED',
allSelected: false,
};
renderWithQueryClient(
<DynamicVariableSelection
variableData={variableData}
existingVariables={{ var1: variableData }}
onValueUpdate={mockOnValueUpdate}
/>,
);
expect(mockOnValueUpdate).toHaveBeenCalledWith(
'services',
'var1',
['backend', 'database'],
true,
true,
);
});
it('should preserve previous selection over default', async () => {
const variableData: IDashboardVariable = {
id: 'var1',
name: 'services',
type: 'DYNAMIC',
multiSelect: true,
showALLOption: true,
defaultValue: ['backend'] as any,
selectedValue: ['frontend', 'cache'],
dynamicVariablesAttribute: 'service.name',
dynamicVariablesSource: 'Traces',
description: '',
sort: 'DISABLED',
allSelected: false,
};
renderWithQueryClient(
<DynamicVariableSelection
variableData={variableData}
existingVariables={{ var1: variableData }}
onValueUpdate={mockOnValueUpdate}
/>,
);
// Should NOT call onValueUpdate since previous selection exists
expect(mockOnValueUpdate).not.toHaveBeenCalledWith();
// The component shows "ALL" because the previous selection ['frontend', 'cache']
// is treated as all available values (the component considers this equivalent to all selected)
expect(screen.getByText('ALL')).toBeInTheDocument();
// Verify that the ALL selection wrapper is present
const allSelectedWrapper = screen
.getByText('ALL')
.closest('.custom-multiselect-wrapper');
expect(allSelectedWrapper).toHaveClass('all-selected');
// Verify that individual values are not displayed when ALL is shown
expect(screen.queryByText('frontend')).not.toBeInTheDocument();
expect(screen.queryByText('cache')).not.toBeInTheDocument();
expect(screen.queryByText('backend')).not.toBeInTheDocument();
// Open the dropdown to see which specific options are selected in the dropdown
const selectElement = screen.getByRole('combobox');
fireEvent.mouseDown(selectElement);
// Wait for API data to be loaded and dropdown to populate
await waitFor(
() => {
// Check if any options are visible in dropdown - there might be checkboxes or selectable items
const dropdownElements = screen.queryAllByRole('option');
// If options are available, verify the selection states
if (dropdownElements.length > 0) {
// Frontend and cache should be selected (checked) in dropdown
// Backend (default) should NOT be selected since previous selection takes priority
const frontendOption = screen.queryByRole('option', { name: 'frontend' });
const cacheOption = screen.queryByRole('option', { name: 'cache' });
const backendOption = screen.queryByRole('option', { name: 'backend' });
if (frontendOption) expect(frontendOption).toHaveClass('selected');
if (cacheOption) expect(cacheOption).toHaveClass('selected');
if (backendOption) expect(backendOption).not.toHaveClass('selected');
}
},
{ timeout: 1000 },
);
});
it('should default to ALL when no default and no previous selection', () => {
const variableData: IDashboardVariable = {
id: 'var21',
name: 'services',
type: 'DYNAMIC',
multiSelect: true,
showALLOption: true,
defaultValue: undefined,
selectedValue: undefined,
dynamicVariablesAttribute: 'service.name',
dynamicVariablesSource: 'Traces',
description: '',
sort: 'DISABLED',
allSelected: false,
};
renderWithQueryClient(
<DynamicVariableSelection
variableData={variableData}
existingVariables={{ var1: variableData }}
onValueUpdate={mockOnValueUpdate}
/>,
);
// Open dropdown to check if ALL option is selected
const selectElement = screen.getByRole('combobox');
fireEvent.mouseDown(selectElement);
// Check if ALL option is present in dropdown
const allOption = screen.getByText('ALL');
expect(allOption).toBeInTheDocument();
// Verify that the ALL option is available for selection
const allOptionContainer = allOption.closest(
'.option-item, .ant-select-item-option',
);
expect(allOptionContainer).toBeInTheDocument();
// Check if the checkbox exists (it should be unchecked initially)
const checkbox = allOptionContainer?.querySelector(
'input[type="checkbox"]',
) as HTMLInputElement;
expect(checkbox).toBeInTheDocument();
// Should call onValueUpdate with all values (ALL selection)
expect(mockOnValueUpdate).toHaveBeenCalledWith(
'services',
'var21',
[], // Empty array when allSelected is true
true, // allSelected = true
false,
);
});
});
describe('Multi Select Default Values - ALL Disabled', () => {
it('should use default value over first value when provided', () => {
const variableData: IDashboardVariable = {
id: 'var1',
name: 'services',
type: 'DYNAMIC',
multiSelect: true,
showALLOption: false,
defaultValue: ['database', 'cache'] as any,
selectedValue: undefined,
dynamicVariablesAttribute: 'service.name',
dynamicVariablesSource: 'Traces',
description: '',
sort: 'DISABLED',
allSelected: false,
};
renderWithQueryClient(
<DynamicVariableSelection
variableData={variableData}
existingVariables={{ var1: variableData }}
onValueUpdate={mockOnValueUpdate}
/>,
);
// Should call onValueUpdate with default values
expect(mockOnValueUpdate).toHaveBeenCalledWith(
'services',
'var1',
['database', 'cache'],
true,
true,
);
});
});
describe('ALL Option Special Value', () => {
it('should display ALL correctly when all values are selected', async () => {
const variableData: IDashboardVariable = {
id: 'var1',
name: 'services',
type: 'DYNAMIC',
multiSelect: true,
showALLOption: true,
allSelected: true,
selectedValue: ['frontend', 'backend', 'database', 'cache'],
dynamicVariablesAttribute: 'service.name',
dynamicVariablesSource: 'Traces',
description: '',
sort: 'DISABLED',
};
renderWithQueryClient(
<DynamicVariableSelection
variableData={variableData}
existingVariables={{ var1: variableData }}
onValueUpdate={mockOnValueUpdate}
/>,
);
// 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();
// Verify that the ALL selection wrapper is present and has correct class
const allSelectedWrapper = allTextElement.closest(
'.custom-multiselect-wrapper',
);
expect(allSelectedWrapper).toHaveClass('all-selected');
// Open dropdown to check if ALL option is selected/active
const selectElement = screen.getByRole('combobox');
fireEvent.mouseDown(selectElement);
// Wait for API data to be loaded and dropdown to populate
await waitFor(() => {
expect(getFieldValues).toHaveBeenCalledWith(
'traces',
'service.name',
'',
'2023-01-01T00:00:00Z',
'2023-01-02T00:00:00Z',
);
});
// Check if ALL option is present in dropdown and selected
// Use getAllByText to get all ALL elements and find the one in dropdown
const allElements = screen.getAllByText('ALL');
expect(allElements.length).toBeGreaterThan(1); // Should have ALL in UI and dropdown
// Find the ALL option in the dropdown (should have the 'all-option' class)
const dropdownAllOption = screen.getByRole('option', { name: 'ALL' });
expect(dropdownAllOption).toBeInTheDocument();
expect(dropdownAllOption).toHaveClass('all-option');
expect(dropdownAllOption).toHaveClass('selected');
// Check if the checkbox for ALL option is checked
const checkbox = dropdownAllOption.querySelector(
'input[type="checkbox"]',
) as HTMLInputElement;
expect(checkbox).toBeInTheDocument();
expect(checkbox.checked).toBe(true);
});
});
});

View File

@@ -0,0 +1,155 @@
/* eslint-disable react/jsx-props-no-spreading */
import { QueryClient, QueryClientProvider } from 'react-query';
import { Provider } from 'react-redux';
import configureStore from 'redux-mock-store';
// Import dependency building functions
import {
buildDependencies,
buildDependencyGraph,
} from '../DashboardVariablesSelection/util';
// Mock scrollIntoView since it's not available in JSDOM
window.HTMLElement.prototype.scrollIntoView = jest.fn();
function createMockStore(globalTime?: any): any {
return configureStore([])(() => ({
globalTime: globalTime || {
minTime: '2023-01-01T00:00:00Z',
maxTime: '2023-01-02T00:00:00Z',
},
}));
}
// Mock the dashboard provider
const mockDashboard = {
data: {
variables: {
env: {
id: 'env',
name: 'env',
type: 'DYNAMIC',
selectedValue: 'production',
order: 1,
dynamicVariablesAttribute: 'environment',
dynamicVariablesSource: 'Traces',
},
service: {
id: 'service',
name: 'service',
type: 'QUERY',
queryValue: 'SELECT DISTINCT service_name WHERE env = $env',
selectedValue: 'api-service',
order: 2,
},
},
},
};
// 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,
}),
}));
interface TestWrapperProps {
store: any;
children: React.ReactNode;
}
function TestWrapper({ store, children }: TestWrapperProps): JSX.Element {
return (
<Provider store={store || createMockStore()}>
<QueryClientProvider client={new QueryClient()}>
{children}
</QueryClientProvider>
</Provider>
);
}
TestWrapper.displayName = 'TestWrapper';
describe('Dynamic Variables Integration Tests', () => {
beforeEach(() => {
jest.clearAllMocks();
});
describe('Variable Dependencies and Updates', () => {
it('should build dependency graph correctly for variables', async () => {
// Convert mock dashboard variables to array format expected by the functions
const { variables } = mockDashboard.data as any;
const variablesArray = Object.values(variables) as any[];
// Test the actual dependency building logic
const dependencies = buildDependencies(variablesArray);
const dependencyData = buildDependencyGraph(dependencies);
// Verify the dependency graph structure
expect(dependencies).toBeDefined();
expect(dependencyData).toBeDefined();
expect(dependencyData.order).toBeDefined();
expect(dependencyData.graph).toBeDefined();
expect(dependencyData.hasCycle).toBeDefined();
// Verify that service depends on env (based on queryValue containing $env)
// The dependencies object shows which variables depend on each variable
// So dependencies.env should contain 'service' because service references $env
expect(dependencies.env).toContain('service');
// Verify the topological order (env should come before service)
expect(dependencyData.order).toContain('env');
expect(dependencyData.order).toContain('service');
expect(dependencyData.order.indexOf('env')).toBeLessThan(
dependencyData.order.indexOf('service'),
);
// Verify no cycles in this simple case
expect(dependencyData.hasCycle).toBe(false);
});
it('should handle circular dependency detection', () => {
// Create variables with circular dependency
const circularVariables = [
{
id: 'var1',
name: 'var1',
type: 'QUERY',
queryValue: 'SELECT * WHERE field = $var2',
order: 1,
},
{
id: 'var2',
name: 'var2',
type: 'QUERY',
queryValue: 'SELECT * WHERE field = $var1',
order: 2,
},
];
// Test the actual circular dependency detection logic
const dependencies = buildDependencies(circularVariables as any);
const dependencyData = buildDependencyGraph(dependencies);
// Verify that circular dependency is detected
expect(dependencyData.hasCycle).toBe(true);
expect(dependencyData.cycleNodes).toBeDefined();
expect(dependencyData.cycleNodes).toContain('var1');
expect(dependencyData.cycleNodes).toContain('var2');
// Verify the dependency structure
expect(dependencies.var1).toContain('var2');
expect(dependencies.var2).toContain('var1');
// Verify that topological order is incomplete due to cycle
expect(dependencyData.order.length).toBeLessThan(2);
});
});
});

View File

@@ -0,0 +1,528 @@
/* eslint-disable sonarjs/no-duplicate-string */
/* eslint-disable react/jsx-props-no-spreading */
import { fireEvent, render, screen } from '@testing-library/react';
import { renderHook } from '@testing-library/react';
import {
Dashboard,
IDashboardVariable,
Widgets,
} from 'types/api/dashboard/getAll';
import { useAddDynamicVariableToPanels } from '../../../hooks/dashboard/useAddDynamicVariableToPanels';
import { WidgetSelector } from '../DashboardSettings/Variables/VariableItem/WidgetSelector';
// Mock scrollIntoView since it's not available in JSDOM
window.HTMLElement.prototype.scrollIntoView = jest.fn();
// Constants to avoid duplication
const CPU_USAGE_TEXT = 'CPU Usage';
const MEMORY_USAGE_TEXT = 'Memory Usage';
const ROW_WIDGET_TEXT = 'Row Widget';
// Helper function to create variable config
const createVariableConfig = (
name: string,
attribute: string,
widgetIds: string[],
): IDashboardVariable => ({
id: `var_${name}`,
name,
type: 'DYNAMIC',
description: '',
sort: 'DISABLED',
multiSelect: false,
showALLOption: false,
dynamicVariablesAttribute: attribute,
dynamicVariablesWidgetIds: widgetIds,
});
const mockDashboard = {
data: {
widgets: [
{
id: 'widget1',
title: CPU_USAGE_TEXT,
panelTypes: 'GRAPH',
},
{
id: 'widget2',
title: MEMORY_USAGE_TEXT,
panelTypes: 'TABLE',
},
{
id: 'widget3',
title: ROW_WIDGET_TEXT,
panelTypes: 'ROW', // Should be filtered out
},
],
layout: [{ i: 'widget1' }, { i: 'widget2' }, { i: 'widget3' }],
},
};
// Mock dependencies
jest.mock('providers/Dashboard/Dashboard', () => ({
useDashboard: (): any => ({
selectedDashboard: mockDashboard,
}),
}));
jest.mock('constants/queryBuilder', () => ({
PANEL_GROUP_TYPES: {
ROW: 'ROW',
},
PANEL_TYPES: {
TIME_SERIES: 'graph',
VALUE: 'value',
TABLE: 'table',
LIST: 'list',
TRACE: 'trace',
BAR: 'bar',
PIE: 'pie',
HISTOGRAM: 'histogram',
EMPTY_WIDGET: 'EMPTY_WIDGET',
},
initialQueriesMap: {
metrics: {
builder: {
queryData: [{}],
},
},
logs: {
builder: {
queryData: [{}],
},
},
traces: {
builder: {
queryData: [{}],
},
},
},
}));
jest.mock('container/GridPanelSwitch/utils', () => ({
generateGridTitle: (title: string): string => title || 'Untitled Panel',
}));
describe('Panel Management Tests', () => {
describe('WidgetSelector Component', () => {
const mockSetSelectedWidgets = jest.fn();
beforeEach(() => {
jest.clearAllMocks();
});
it('should display panel titles using generateGridTitle', () => {
render(
<WidgetSelector
selectedWidgets={[]}
setSelectedWidgets={mockSetSelectedWidgets}
/>,
);
// Open the dropdown to see options
const selectElement = screen.getByRole('combobox');
fireEvent.mouseDown(selectElement);
// Should show panel titles (excluding ROW widgets) in dropdown
expect(screen.getByText(CPU_USAGE_TEXT)).toBeInTheDocument();
expect(screen.getByText(MEMORY_USAGE_TEXT)).toBeInTheDocument();
expect(screen.queryByText(ROW_WIDGET_TEXT)).not.toBeInTheDocument();
});
it('should filter out row widgets and widgets without layout', () => {
const modifiedDashboard = {
...mockDashboard,
data: {
...mockDashboard.data,
widgets: [
...mockDashboard.data.widgets,
{
id: 'widget4',
title: 'Orphaned Widget',
panelTypes: 'GRAPH',
}, // No layout entry
],
},
};
// Temporarily mock the dashboard
jest.doMock('providers/Dashboard/Dashboard', () => ({
useDashboard: (): any => ({
selectedDashboard: modifiedDashboard,
}),
}));
render(
<WidgetSelector
selectedWidgets={[]}
setSelectedWidgets={mockSetSelectedWidgets}
/>,
);
// Should not show orphaned widget
expect(screen.queryByText('Orphaned Widget')).not.toBeInTheDocument();
});
it('should show selected widgets correctly', () => {
render(
<WidgetSelector
selectedWidgets={['widget1', 'widget2']}
setSelectedWidgets={mockSetSelectedWidgets}
/>,
);
// Component should show ALL text when all widgets are selected
expect(screen.getByText('ALL')).toBeInTheDocument();
// Check if the dropdown shows selected state correctly
const selectElement = screen.getByRole('combobox');
fireEvent.mouseDown(selectElement);
// Should show the selected panels in the dropdown
expect(screen.getByText('CPU Usage')).toBeInTheDocument();
expect(screen.getByText('Memory Usage')).toBeInTheDocument();
// Check if the specific options (CPU Usage, Memory Usage) are properly selected/checked
const cpuOption = screen.getByText('CPU Usage');
const memoryOption = screen.getByText('Memory Usage');
// Find the specific checkboxes for CPU Usage and Memory Usage
// Navigate from the text to find the associated checkbox
const cpuContainer = cpuOption.closest(
'.ant-select-item-option, .option-item',
);
const memoryContainer = memoryOption.closest(
'.ant-select-item-option, .option-item',
);
const cpuCheckbox = cpuContainer?.querySelector(
'input[type="checkbox"]',
) as HTMLInputElement;
const memoryCheckbox = memoryContainer?.querySelector(
'input[type="checkbox"]',
) as HTMLInputElement;
// Verify that the specific checkboxes for our selected widgets are checked
expect(cpuCheckbox).toBeInTheDocument();
expect(memoryCheckbox).toBeInTheDocument();
expect(cpuCheckbox.checked).toBe(true);
expect(memoryCheckbox.checked).toBe(true);
// Also verify the checkbox wrappers have the checked class
const cpuCheckboxWrapper = cpuCheckbox?.closest('.ant-checkbox');
const memoryCheckboxWrapper = memoryCheckbox?.closest('.ant-checkbox');
expect(cpuCheckboxWrapper).toHaveClass('ant-checkbox-checked');
expect(memoryCheckboxWrapper).toHaveClass('ant-checkbox-checked');
// Additional verification: ensure these are the correct options by checking their labels
expect(cpuOption).toBeInTheDocument();
expect(memoryOption).toBeInTheDocument();
});
});
describe('useAddDynamicVariableToPanels Hook', () => {
it('should add tag filters to specific selected panels', () => {
const { result } = renderHook(() => useAddDynamicVariableToPanels());
const addDynamicVariableToPanels = result.current;
const dashboard: Dashboard = {
data: {
widgets: [
{
id: 'widget1',
query: {
builder: {
queryData: [
{
filter: { expression: '' },
filters: { items: [] },
},
],
},
},
},
],
},
} as any;
const variableConfig = createVariableConfig('service', 'service.name', [
'widget1',
]);
const updatedDashboard = addDynamicVariableToPanels(
dashboard,
variableConfig,
false,
);
// Verify tag filter was added to the specific widget using new filter expression format
const widget = updatedDashboard?.data?.widgets?.[0] as any;
const queryData = widget?.query?.builder?.queryData?.[0];
// Check that filter expression contains the variable reference
expect(queryData.filter.expression).toContain('service.name in $service');
// Check that filters array also contains the filter item
const filters = queryData.filters.items;
expect(filters).toContainEqual({
id: expect.any(String),
key: {
id: expect.any(String),
key: 'service.name',
dataType: 'string',
isColumn: false,
isJSON: false,
type: '',
},
op: 'IN',
value: '$service',
});
});
it('should apply to all panels when applyToAll is true', () => {
const { result } = renderHook(() => useAddDynamicVariableToPanels());
const addDynamicVariableToPanels = result.current;
const dashboard: Dashboard = {
data: {
widgets: [
{
id: 'widget1',
query: {
builder: {
queryData: [
{
filter: { expression: '' },
filters: { items: [] },
},
],
},
},
},
{
id: 'widget2',
query: {
builder: {
queryData: [
{
filter: { expression: '' },
filters: { items: [] },
},
],
},
},
},
],
},
} as any;
const variableConfig = createVariableConfig('service', 'service.name', [
'widget1',
]); // Only specified widget1
const updatedDashboard = addDynamicVariableToPanels(
dashboard,
variableConfig,
true, // Apply to all
);
// Both widgets should have the filter expression
const widget1QueryData = (updatedDashboard?.data?.widgets?.[0] as Widgets)
?.query?.builder?.queryData?.[0];
const widget2QueryData = (updatedDashboard?.data?.widgets?.[1] as Widgets)
?.query?.builder?.queryData?.[0];
// Check filter expressions
expect(widget1QueryData?.filter?.expression).toContain(
'service.name in $service',
);
expect(widget2QueryData?.filter?.expression).toContain(
'service.name in $service',
);
// Check filters arrays
const widget1Filters = widget1QueryData?.filters?.items;
const widget2Filters = widget2QueryData?.filters?.items;
expect(widget1Filters).toContainEqual({
id: expect.any(String),
key: {
id: expect.any(String),
key: 'service.name',
dataType: 'string',
isColumn: false,
isJSON: false,
type: '',
},
op: 'IN',
value: '$service',
});
expect(widget2Filters).toContainEqual({
id: expect.any(String),
key: {
id: expect.any(String),
key: 'service.name',
dataType: 'string',
isColumn: false,
isJSON: false,
type: '',
},
op: 'IN',
value: '$service',
});
});
it('should validate tag filter format with variable name', () => {
const { result } = renderHook(() => useAddDynamicVariableToPanels());
const addDynamicVariableToPanels = result.current;
const dashboard: Dashboard = {
data: {
widgets: [
{
id: 'widget1',
query: {
builder: {
queryData: [
{
filters: { items: [] },
filter: { expression: '' },
},
],
},
},
},
],
},
} as any;
const variableConfig = {
name: 'custom_service_var',
dynamicVariablesAttribute: 'service.name',
dynamicVariablesWidgetIds: ['widget1'],
};
const updatedDashboard = addDynamicVariableToPanels(
dashboard,
variableConfig as any,
false,
);
const filters = (updatedDashboard?.data?.widgets?.[0] as Widgets)?.query
?.builder?.queryData?.[0]?.filters?.items;
const filterExpression = (updatedDashboard?.data?.widgets?.[0] as Widgets)
?.query?.builder?.queryData?.[0]?.filter?.expression;
expect(filterExpression).toContain('service.name in $custom_service_var');
expect(filters).toContainEqual(
expect.objectContaining({
value: '$custom_service_var', // Should use exact variable name
}),
);
});
it('should handle empty widget selection gracefully', () => {
const { result } = renderHook(() => useAddDynamicVariableToPanels());
const addDynamicVariableToPanels = result.current;
const dashboard: Dashboard = {
data: { widgets: [] },
} as any;
const variableConfig = {
name: 'service',
dynamicVariablesAttribute: 'service.name',
dynamicVariablesWidgetIds: [], // Empty selection
};
const updatedDashboard = addDynamicVariableToPanels(
dashboard,
variableConfig as any,
false,
);
// Should return dashboard unchanged
expect(updatedDashboard).toEqual(dashboard);
});
it('should handle undefined dashboard gracefully', () => {
const { result } = renderHook(() => useAddDynamicVariableToPanels());
const addDynamicVariableToPanels = result.current;
const variableConfig = {
name: 'service',
dynamicVariablesAttribute: 'service.name',
dynamicVariablesWidgetIds: ['widget1'],
};
const updatedDashboard = addDynamicVariableToPanels(
undefined,
variableConfig as any,
false,
);
// Should return undefined
expect(updatedDashboard).toBeUndefined();
});
it('should preserve existing filters when adding new variable filter', () => {
const { result } = renderHook(() => useAddDynamicVariableToPanels());
const addDynamicVariableToPanels = result.current;
const dashboard: Dashboard = {
data: {
widgets: [
{
id: 'widget1',
query: {
builder: {
queryData: [
{
filter: {
expression: "service.name IN ['adservice']",
},
},
],
},
},
},
],
},
} as any;
const variableConfig = {
name: 'host.name',
dynamicVariablesAttribute: 'host.name',
dynamicVariablesWidgetIds: ['widget1'],
description: '',
type: 'DYNAMIC',
queryValue: '',
customValue: '',
textboxValue: '',
multiSelect: false,
showALLOption: false,
sort: 'DISABLED',
defaultValue: '3',
modificationUUID: 'bd3a85ab-1393-4783-971e-c252adfd4920',
id: '6b6f526a-6404-46fc-8d87-dc53e7ba8e1f',
order: 0,
dynamicVariablesSource: 'Traces',
};
const updatedDashboard = addDynamicVariableToPanels(
dashboard,
variableConfig as any,
false,
);
const filterExpression = (updatedDashboard?.data?.widgets?.[0] as Widgets)
?.query?.builder?.queryData?.[0]?.filter?.expression;
expect(filterExpression).toContain(
"service.name IN ['adservice'] host.name in $host.name",
);
});
});
});

View File

@@ -0,0 +1,241 @@
/* eslint-disable react/jsx-props-no-spreading */
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from 'react-query';
import { Provider } from 'react-redux';
import configureStore from 'redux-mock-store';
import { IDashboardVariable } from 'types/api/dashboard/getAll';
import VariableItem from '../DashboardSettings/Variables/VariableItem/VariableItem';
// Mock dependencies
jest.mock('api/dashboard/variables/dashboardVariablesQuery');
jest.mock('hooks/dynamicVariables/useGetFieldValues', () => ({
useGetFieldValues: (): any => ({
data: {
payload: {
normalizedValues: ['frontend', 'backend', 'database'],
},
},
isLoading: false,
error: null,
}),
}));
jest.mock('components/Editor', () => {
function MockEditor({
value,
onChange,
}: {
value: string;
onChange: (value: string) => void;
}): JSX.Element {
return (
<textarea
data-testid="sql-editor"
value={value}
onChange={(e): void => onChange(e.target.value)}
/>
);
}
MockEditor.displayName = 'MockEditor';
return MockEditor;
});
const mockStore = configureStore([])(() => ({
globalTime: {
minTime: '2023-01-01T00:00:00Z',
maxTime: '2023-01-02T00:00:00Z',
},
}));
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
function TestWrapper({ children }: { children: React.ReactNode }): JSX.Element {
return (
<Provider store={mockStore}>
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
</Provider>
);
}
TestWrapper.displayName = 'TestWrapper';
describe('VariableItem Component - Creation Flow', () => {
const mockOnSave = jest.fn();
const mockOnCancel = jest.fn();
const mockValidateName = jest.fn();
// Constants to avoid string duplication
const VARIABLE_NAME_PLACEHOLDER = 'Unique name of the variable';
const VARIABLE_DESCRIPTION_PLACEHOLDER =
'Enter a description for the variable';
const SAVE_BUTTON_TEXT = 'Save Variable';
const DISCARD_BUTTON_TEXT = 'Discard';
const defaultProps = {
variableData: {} as IDashboardVariable,
existingVariables: {},
onCancel: mockOnCancel,
onSave: mockOnSave,
validateName: mockValidateName,
mode: 'ADD' as const,
};
beforeEach(() => {
jest.clearAllMocks();
mockValidateName.mockReturnValue(true);
});
describe('Dynamic Variable Creation', () => {
it('should switch between variable types correctly', () => {
render(
<TestWrapper>
<VariableItem {...defaultProps} />
</TestWrapper>,
);
// Test switching to different variable types
const textboxButton = screen.getByText('Textbox');
fireEvent.click(textboxButton);
// Check if the button has the selected class or is in selected state
expect(textboxButton.closest('button')).toHaveClass('selected');
const customButton = screen.getByText('Custom');
fireEvent.click(customButton);
expect(customButton.closest('button')).toHaveClass('selected');
const queryButton = screen.getByText('Query');
fireEvent.click(queryButton);
expect(queryButton.closest('button')).toHaveClass('selected');
// Verify SQL editor appears for QUERY type
expect(screen.getByTestId('sql-editor')).toBeInTheDocument();
});
it('should validate variable name and show errors', () => {
mockValidateName.mockReturnValue(false);
render(
<TestWrapper>
<VariableItem {...defaultProps} />
</TestWrapper>,
);
const nameInput = screen.getByPlaceholderText(VARIABLE_NAME_PLACEHOLDER);
fireEvent.change(nameInput, { target: { value: 'duplicate_name' } });
// Should show error message
expect(screen.getByText('Variable name already exists')).toBeInTheDocument();
// Save button should be disabled
const saveButton = screen.getByText(SAVE_BUTTON_TEXT);
expect(saveButton.closest('button')).toBeDisabled();
});
it('should detect and prevent cyclic dependencies', async () => {
const existingVariables = {
var1: {
id: 'var1',
name: 'var1',
queryValue: 'SELECT * WHERE field = $var2',
type: 'QUERY',
description: '',
sort: 'DISABLED',
multiSelect: false,
showALLOption: false,
} as IDashboardVariable,
var2: {
id: 'var2',
name: 'var2',
queryValue: 'SELECT * WHERE field = $var1',
type: 'QUERY',
description: '',
sort: 'DISABLED',
multiSelect: false,
showALLOption: false,
} as IDashboardVariable,
};
render(
<TestWrapper>
<VariableItem {...defaultProps} existingVariables={existingVariables} />
</TestWrapper>,
);
// Fill in name and create a variable that would create cycle
const nameInput = screen.getByPlaceholderText(VARIABLE_NAME_PLACEHOLDER);
fireEvent.change(nameInput, { target: { value: 'var3' } });
// Switch to QUERY type
const queryButton = screen.getByText('Query');
fireEvent.click(queryButton);
// Add query that creates dependency
const sqlEditor = screen.getByTestId('sql-editor');
fireEvent.change(sqlEditor, {
target: { value: 'SELECT * WHERE field = $var1' },
});
// Try to save
const saveButton = screen.getByText(SAVE_BUTTON_TEXT);
fireEvent.click(saveButton);
// Should show cycle detection error
await waitFor(() => {
expect(
screen.getByText(/Circular dependency detected/),
).toBeInTheDocument();
});
});
it('should handle cancel button functionality', () => {
render(
<TestWrapper>
<VariableItem {...defaultProps} />
</TestWrapper>,
);
// Fill in some data
const nameInput = screen.getByPlaceholderText(VARIABLE_NAME_PLACEHOLDER);
fireEvent.change(nameInput, { target: { value: 'test_variable' } });
// Click cancel
const cancelButton = screen.getByText(DISCARD_BUTTON_TEXT);
fireEvent.click(cancelButton);
// Should call onCancel
expect(mockOnCancel).toHaveBeenCalledWith(expect.any(Object));
});
it('should persist form fields when switching between variable types', () => {
render(
<TestWrapper>
<VariableItem {...defaultProps} />
</TestWrapper>,
);
// Fill in name and description
const nameInput = screen.getByPlaceholderText(VARIABLE_NAME_PLACEHOLDER);
const descriptionInput = screen.getByPlaceholderText(
VARIABLE_DESCRIPTION_PLACEHOLDER,
);
fireEvent.change(nameInput, { target: { value: 'persistent_var' } });
fireEvent.change(descriptionInput, {
target: { value: 'Persistent description' },
});
// Switch to TEXTBOX type
const textboxButton = screen.getByText('Textbox');
fireEvent.click(textboxButton);
// Switch back to DYNAMIC
const dynamicButton = screen.getByText('Dynamic');
fireEvent.click(dynamicButton);
// Name and description should be preserved
expect(nameInput).toHaveValue('persistent_var');
expect(descriptionInput).toHaveValue('Persistent description');
});
});
});

View File

@@ -14,3 +14,5 @@ export function variablePropsToPayloadVariables(
return payloadVariables;
}
export const ALL_SELECT_VALUE = '__ALL__';

View File

@@ -12,6 +12,7 @@ import {
} from 'constants/queryBuilder';
import { DEBOUNCE_DELAY } from 'constants/queryBuilderFilterConfig';
import { LogsExplorerShortcuts } from 'constants/shortcuts/logsExplorerShortcuts';
import { useGetDynamicVariables } from 'hooks/dashboard/useGetDynamicVariables';
import { useKeyboardHotkeys } from 'hooks/hotkeys/useKeyboardHotkeys';
import { WhereClauseConfig } from 'hooks/queryBuilder/useAutoComplete';
import { useGetAggregateKeys } from 'hooks/queryBuilder/useGetAggregateKeys';
@@ -246,6 +247,8 @@ function QueryBuilderSearchV2(
return false;
}, [currentState, query.aggregateAttribute?.dataType, query.dataSource]);
const { dynamicVariables } = useGetDynamicVariables();
const { data, isFetching } = useGetAggregateKeys(
{
searchText: searchValue?.split(' ')[0],
@@ -788,6 +791,19 @@ function QueryBuilderSearchV2(
const dataType = currentFilterItem?.key?.dataType || DataTypes.String;
const key = DATA_TYPE_VS_ATTRIBUTE_VALUES_KEY[dataType];
values.push(...(attributeValues?.payload?.[key] || []));
// here we want to suggest the variable name matching with the key here, we will go over the dynamic variables for the keys
const variableName = dynamicVariables.find(
(variable) =>
variable?.dynamicVariablesAttribute === currentFilterItem?.key?.key,
)?.name;
if (variableName) {
const variableValue = `$${variableName}`;
if (!values.includes(variableValue)) {
values.unshift(variableValue);
}
}
}
setDropdownOptions(
@@ -807,6 +823,8 @@ function QueryBuilderSearchV2(
searchValue,
suggestionsData?.payload?.attributes,
operatorConfigKey,
currentFilterItem?.key?.key,
dynamicVariables,
]);
// keep the query in sync with the selected tags in logs explorer page

View File

@@ -1,3 +1,4 @@
/* eslint-disable sonarjs/no-duplicate-string */
/* eslint-disable react/jsx-props-no-spreading */
import {
act,
@@ -90,6 +91,11 @@ const renderWithContext = (props = {}): RenderResult => {
);
};
// Constants to fix linter errors
const TYPE_TAG = 'tag';
const IS_COLUMN_FALSE = false;
const IS_JSON_FALSE = false;
const mockAggregateKeysData = {
payload: {
attributeKeys: [
@@ -97,9 +103,17 @@ const mockAggregateKeysData = {
// eslint-disable-next-line sonarjs/no-duplicate-string
key: 'http.status',
dataType: DataTypes.String,
type: 'tag',
type: TYPE_TAG,
id: 'http.status--string--tag--false',
},
{
key: 'service.name',
dataType: DataTypes.String,
type: TYPE_TAG,
isColumn: IS_COLUMN_FALSE,
isJSON: IS_JSON_FALSE,
id: 'service.name--string--tag--false',
},
],
},
};
@@ -125,6 +139,34 @@ jest.mock('hooks/queryBuilder/useGetAggregateValues', () => ({
})),
}));
// Mock the dynamic variables hook to test variable suggestion feature
const mockDynamicVariables = [
{
id: 'var1',
name: 'service',
type: 'DYNAMIC',
dynamicVariablesAttribute: 'service.name',
dynamicVariablesSource: 'Traces',
selectedValue: 'frontend',
multiSelect: false,
showALLOption: false,
allSelected: false,
description: '',
sort: 'DISABLED',
dashboardName: 'Test Dashboard',
dashboardId: 'dashboard-123',
},
];
jest.mock('hooks/dashboard/useGetDynamicVariables', () => ({
useGetDynamicVariables: jest.fn(() => ({
dynamicVariables: mockDynamicVariables,
isLoading: false,
isError: false,
refetch: jest.fn(),
})),
}));
jest.mock('hooks/useSafeNavigate', () => ({
useSafeNavigate: (): any => ({
safeNavigate: jest.fn(),
@@ -193,3 +235,66 @@ describe('Suggestion Key -> Operator -> Value Flow', () => {
);
});
});
describe('Dynamic Variable Suggestions', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('should suggest dynamic variable when key matches a variable attribute', async () => {
const { container } = renderWithContext();
// Get the combobox input
const combobox = container.querySelector(
'.query-builder-search-v2 .ant-select-selection-search-input',
) as HTMLInputElement;
// Focus and type to trigger key suggestions for service.name
await act(async () => {
fireEvent.focus(combobox);
fireEvent.change(combobox, { target: { value: 'service.' } });
});
// Wait for dropdown to appear
await screen.findByRole('listbox');
// Select service.name key from suggestions
const serviceNameOption = await screen.findByText('service.name');
await act(async () => {
fireEvent.click(serviceNameOption);
});
// Select equals operator
await act(async () => {
const equalsOption = screen.getByText('=');
fireEvent.click(equalsOption);
});
// Should show value suggestions including the dynamic variable
// For 'service.name', we expect to see '$service' as the first suggestion
const variableSuggestion = await screen.findByText('$service');
expect(variableSuggestion).toBeInTheDocument();
// Regular values should still be shown
expect(screen.getByText('200')).toBeInTheDocument();
expect(screen.getByText('404')).toBeInTheDocument();
// Select the variable suggestion
await act(async () => {
fireEvent.click(variableSuggestion);
});
// Verify the query was updated with the variable as value
expect(mockOnChange).toHaveBeenCalledWith(
expect.objectContaining({
items: expect.arrayContaining([
expect.objectContaining({
key: expect.objectContaining({ key: 'service.name' }),
op: '=',
value: '$service',
}),
]),
}),
);
});
});

View File

@@ -0,0 +1,226 @@
import { renderHook, waitFor } from '@testing-library/react';
import { useDashboard } from 'providers/Dashboard/Dashboard';
import { ReactNode } from 'react';
import { QueryClient, QueryClientProvider, useQuery } from 'react-query';
import { useGetDynamicVariables } from '../useGetDynamicVariables';
// Mock the dependencies
jest.mock('react-query', () => ({
...jest.requireActual('react-query'),
useQuery: jest.fn(),
}));
jest.mock('providers/Dashboard/Dashboard', () => ({
useDashboard: jest.fn(),
}));
// Sample dashboard data with variables
const mockDashboardData = {
data: {
data: {
title: 'Test Dashboard',
variables: {
var1: {
id: 'var1',
name: 'service',
type: 'DYNAMIC',
dynamicVariablesAttribute: 'service.name',
dynamicVariablesSource: 'Traces',
selectedValue: 'frontend',
multiSelect: false,
showALLOption: false,
allSelected: false,
description: '',
sort: 'DISABLED',
},
var2: {
id: 'var2',
name: 'status',
type: 'DYNAMIC',
dynamicVariablesAttribute: 'http.status_code',
dynamicVariablesSource: 'Traces',
selectedValue: '200',
multiSelect: false,
showALLOption: false,
allSelected: false,
description: '',
sort: 'DISABLED',
},
var3: {
id: 'var3',
name: 'interval',
type: 'CUSTOM', // Not DYNAMIC - should be filtered out
customValue: '5m',
multiSelect: false,
showALLOption: false,
allSelected: false,
description: '',
sort: 'DISABLED',
},
},
},
id: 'dashboard-123',
loading: false,
error: null,
},
};
// Mock refetch function
const mockRefetch = jest.fn();
// Constants
const DASHBOARD_ID = 'dashboard-123';
// Create a wrapper for the renderHook function with the QueryClientProvider
const createWrapper = (): React.FC<{ children: ReactNode }> => {
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
});
// Define as function declaration to fix linter error
function Wrapper({ children }: { children: ReactNode }): JSX.Element {
return (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
}
return Wrapper;
};
describe('useGetDynamicVariables', () => {
beforeEach(() => {
jest.clearAllMocks();
// Mock the useDashboard hook
(useDashboard as jest.Mock).mockReturnValue({
dashboardId: DASHBOARD_ID,
});
// Mock the useQuery hook for successful response
(useQuery as jest.Mock).mockReturnValue({
data: mockDashboardData,
isLoading: false,
isError: false,
refetch: mockRefetch,
});
});
it('should return dynamic variables from the dashboard', async () => {
const { result } = renderHook(() => useGetDynamicVariables(), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(result.current.dynamicVariables).toHaveLength(2); // Only DYNAMIC type variables
expect(result.current.dynamicVariables[0].name).toBe('service');
expect(result.current.dynamicVariables[1].name).toBe('status');
expect(result.current.isLoading).toBe(false);
expect(result.current.isError).toBe(false);
});
// Verify each dynamic variable has dashboard info
expect(result.current.dynamicVariables[0].dashboardName).toBe(
'Test Dashboard',
);
expect(result.current.dynamicVariables[0].dashboardId).toBe(DASHBOARD_ID);
});
it('should use dashboardId from props if provided', async () => {
const customDashboardId = 'custom-dashboard-id';
renderHook(() => useGetDynamicVariables({ dashboardId: customDashboardId }), {
wrapper: createWrapper(),
});
// Check that useQuery was called with the custom dashboardId
expect(useQuery).toHaveBeenCalledWith(
expect.objectContaining({
queryKey: expect.arrayContaining(['DASHBOARD_BY_ID', customDashboardId]),
}),
);
});
it('should return empty array when dashboard has no variables', async () => {
// Mock no variables in dashboard
(useQuery as jest.Mock).mockReturnValue({
data: {
data: { title: 'Empty Dashboard' },
id: 'dashboard-empty',
loading: false,
error: null,
},
isLoading: false,
isError: false,
refetch: mockRefetch,
});
const { result } = renderHook(() => useGetDynamicVariables(), {
wrapper: createWrapper(),
});
expect(result.current.dynamicVariables).toHaveLength(0);
});
it('should return empty array when dashboard is null', async () => {
// Mock null dashboard data
(useQuery as jest.Mock).mockReturnValue({
data: null,
isLoading: false,
isError: false,
refetch: mockRefetch,
});
const { result } = renderHook(() => useGetDynamicVariables(), {
wrapper: createWrapper(),
});
expect(result.current.dynamicVariables).toHaveLength(0);
});
it('should handle loading state', async () => {
// Mock loading state
(useQuery as jest.Mock).mockReturnValue({
data: null,
isLoading: true,
isError: false,
refetch: mockRefetch,
});
const { result } = renderHook(() => useGetDynamicVariables(), {
wrapper: createWrapper(),
});
expect(result.current.isLoading).toBe(true);
expect(result.current.dynamicVariables).toHaveLength(0);
});
it('should handle error state', async () => {
// Mock error state
(useQuery as jest.Mock).mockReturnValue({
data: null,
isLoading: false,
isError: true,
refetch: mockRefetch,
});
const { result } = renderHook(() => useGetDynamicVariables(), {
wrapper: createWrapper(),
});
expect(result.current.isError).toBe(true);
expect(result.current.dynamicVariables).toHaveLength(0);
});
it('should call refetch when returned function is called', async () => {
const { result } = renderHook(() => useGetDynamicVariables(), {
wrapper: createWrapper(),
});
result.current.refetch();
expect(mockRefetch).toHaveBeenCalledTimes(1);
});
});

View File

@@ -181,4 +181,32 @@ describe('useGetResolvedText', () => {
expect(result.current.fullText).toBe(text);
expect(result.current.truncatedText).toBe(text);
});
it('should handle complex variable names with improved patterns', () => {
const text = 'API: $api.v1.endpoint Config: $config.database.host';
const variables = {
'api.v1.endpoint': '/users',
'config.database.host': 'localhost:5432',
};
const { result } = renderHookWithProps({ text, variables });
expect(result.current.fullText).toBe('API: /users Config: localhost:5432');
expect(result.current.truncatedText).toBe(
'API: /users Config: localhost:5432',
);
});
it('should stop at punctuation boundaries correctly', () => {
const text = 'Status: $service.name, Error: $error.type;';
const variables = {
'service.name': 'web-api',
'error.type': 'timeout',
};
const { result } = renderHookWithProps({ text, variables });
expect(result.current.fullText).toBe('Status: web-api, Error: timeout;');
expect(result.current.truncatedText).toBe('Status: web-api, Error: timeout;');
});
});

View File

@@ -0,0 +1,49 @@
import { addTagFiltersToDashboard } from 'container/NewDashboard/DashboardSettings/Variables/addTagFiltersToDashboard';
import { useCallback } from 'react';
import { Dashboard, IDashboardVariable } from 'types/api/dashboard/getAll';
import { TagFilterItem } from 'types/api/queryBuilder/queryBuilderData';
import { getFiltersFromKeyValue } from './utils';
/**
* A hook that returns a function to add dynamic variables to dashboard panels as tag filters.
*
* @returns A function that, when given a dashboard and variable config, returns the updated dashboard.
*/
export const useAddDynamicVariableToPanels = (): ((
dashboard: Dashboard | undefined,
variableConfig: IDashboardVariable,
applyToAll?: boolean,
) => Dashboard | undefined) =>
useCallback(
(
dashboard: Dashboard | undefined,
variableConfig: IDashboardVariable,
applyToAll?: boolean,
): Dashboard | undefined => {
if (!variableConfig) return dashboard;
const {
dynamicVariablesAttribute,
name,
dynamicVariablesWidgetIds,
} = variableConfig;
const tagFilters: TagFilterItem = getFiltersFromKeyValue(
dynamicVariablesAttribute || '',
`$${name}`,
'',
'IN',
);
return addTagFiltersToDashboard(
dashboard,
tagFilters,
dynamicVariablesWidgetIds,
applyToAll,
);
},
[],
);
export default useAddDynamicVariableToPanels;

View File

@@ -21,6 +21,7 @@ interface UseDashboardVariablesFromLocalStorageReturn {
id: string,
selectedValue: IDashboardVariable['selectedValue'],
allSelected: boolean,
isDynamic?: boolean,
) => void;
}
@@ -88,10 +89,17 @@ export const useDashboardVariablesFromLocalStorage = (
id: string,
selectedValue: IDashboardVariable['selectedValue'],
allSelected: boolean,
isDynamic?: boolean,
): void => {
setCurrentDashboard((prev) => ({
...prev,
[id]: { selectedValue, allSelected },
[id]:
isDynamic && allSelected
? {
selectedValue: (undefined as unknown) as IDashboardVariable['selectedValue'],
allSelected: true,
}
: { selectedValue, allSelected },
}));
};

View File

@@ -0,0 +1,55 @@
import getDashboard from 'api/v1/dashboards/id/get';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import { useDashboard } from 'providers/Dashboard/Dashboard';
import { useMemo } from 'react';
import { useQuery } from 'react-query';
import { IDashboardVariable } from 'types/api/dashboard/getAll';
export interface DynamicVariable extends IDashboardVariable {
dashboardName: string;
dashboardId: string;
}
interface UseGetDynamicVariablesProps {
dashboardId?: string;
}
export const useGetDynamicVariables = (
props?: UseGetDynamicVariablesProps,
): {
dynamicVariables: DynamicVariable[];
isLoading: boolean;
isError: boolean;
refetch: () => void;
} => {
const { dashboardId: dashboardIdFromProps } = props || {};
const { dashboardId: dashboardIdFromDashboard } = useDashboard();
const dashboardId = dashboardIdFromProps || dashboardIdFromDashboard;
const { data: dashboard, isLoading, isError, refetch } = useQuery({
queryFn: () => getDashboard({ id: dashboardId }),
queryKey: [REACT_QUERY_KEY.DASHBOARD_BY_ID, dashboardId],
});
const dynamicVariables = useMemo(() => {
if (!dashboard?.data?.data?.variables) return [];
const variables: DynamicVariable[] = [];
Object.entries(dashboard.data?.data?.variables).forEach(([, variable]) => {
if (variable.type === 'DYNAMIC') {
variables.push({
...variable,
dashboardName: dashboard.data?.data?.title,
dashboardId: dashboard?.data?.id,
});
}
});
return variables;
}, [dashboard]);
return { dynamicVariables, isLoading, isError, refetch };
};

View File

@@ -82,12 +82,11 @@ function useGetResolvedText({
const combinedPattern = useMemo(() => {
const escapedMatcher = matcher.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const varNamePattern = '[a-zA-Z_\\-][a-zA-Z0-9_.\\-]*';
const variablePatterns = [
`\\{\\{\\s*?\\.(${varNamePattern})\\s*?\\}\\}`, // {{.var}}
`\\{\\{\\s*(${varNamePattern})\\s*\\}\\}`, // {{var}}
`${escapedMatcher}(${varNamePattern})`, // matcher + var.name
`\\[\\[\\s*(${varNamePattern})\\s*\\]\\]`, // [[var]]
`\\{\\{\\s*?\\.([^\\s}]+?)\\s*?\\}\\}`, // {{.var}}
`\\{\\{\\s*([^\\s}]+?)\\s*\\}\\}`, // {{var}}
`${escapedMatcher}([^\\s.,;)\\]}>]+(?:\\.[^\\s.,;)\\]}>]+)*)`, // $var.name.path - allows dots but stops at punctuation
`\\[\\[\\s*([^\\s\\]]+?)\\s*\\]\\]`, // [[var]]
];
return new RegExp(variablePatterns.join('|'), 'g');
}, [matcher]);

View File

@@ -2,8 +2,17 @@ import { TelemetryFieldKey } from 'api/v5/v5';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { convertKeysToColumnFields } from 'container/LogsExplorerList/utils';
import { placeWidgetAtBottom } from 'container/NewWidget/utils';
import { Dashboard } from 'types/api/dashboard/getAll';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { isArray } from 'lodash-es';
import { Dashboard, Widgets } from 'types/api/dashboard/getAll';
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
import {
IBuilderQuery,
Query,
TagFilterItem,
} from 'types/api/queryBuilder/queryBuilderData';
import { v4 as uuidv4 } from 'uuid';
import { DynamicVariable } from './useGetDynamicVariables';
const baseLogsSelectedColumns = {
dataType: 'string',
@@ -56,3 +65,77 @@ export const addEmptyWidgetInDashboardJSONWithQuery = (
},
};
};
export const getFiltersFromKeyValue = (
key: string,
value: string | number,
type?: string,
op?: string,
dataType?: DataTypes,
): TagFilterItem => ({
id: uuidv4(),
key: {
key,
dataType: dataType || DataTypes.String,
type: type || '',
id: `${key}--${dataType || DataTypes.String}--${type || ''}`,
},
op: op || '=',
value: value.toString(),
});
export const createDynamicVariableToWidgetsMap = (
dynamicVariables: DynamicVariable[],
widgets: Widgets[],
// eslint-disable-next-line sonarjs/cognitive-complexity
): Record<string, string[]> => {
const dynamicVariableToWidgetsMap: Record<string, string[]> = {};
// Initialize map with empty arrays for each variable
dynamicVariables.forEach((variable) => {
if (variable.id) {
dynamicVariableToWidgetsMap[variable.id] = [];
}
});
// Check each widget for usage of dynamic variables
if (Array.isArray(widgets)) {
widgets.forEach((widget) => {
if (widget.query?.builder?.queryData) {
widget.query.builder.queryData.forEach((queryData: IBuilderQuery) => {
// Check filter items for dynamic variables
queryData.filters?.items?.forEach((filter: TagFilterItem) => {
// For each filter, check if it uses any dynamic variable
dynamicVariables.forEach((variable) => {
if (
variable.dynamicVariablesAttribute &&
filter.key?.key === variable.dynamicVariablesAttribute &&
((isArray(filter.value) &&
filter.value.includes(`$${variable.name}`)) ||
filter.value === `$${variable.name}`) &&
!dynamicVariableToWidgetsMap[variable.id].includes(widget.id)
) {
dynamicVariableToWidgetsMap[variable.id].push(widget.id);
}
});
});
// Check filter expression for dynamic variables
if (queryData.filter?.expression) {
dynamicVariables.forEach((variable) => {
if (
variable.dynamicVariablesAttribute &&
queryData.filter?.expression?.includes(`$${variable.name}`) &&
!dynamicVariableToWidgetsMap[variable.id].includes(widget.id)
) {
dynamicVariableToWidgetsMap[variable.id].push(widget.id);
}
});
}
});
}
});
}
return dynamicVariableToWidgetsMap;
};

View File

@@ -0,0 +1,35 @@
import { getFieldKeys } from 'api/dynamicVariables/getFieldKeys';
import { useQuery, UseQueryResult } from 'react-query';
import { ErrorResponse, SuccessResponse } from 'types/api';
import { FieldKeyResponse } from 'types/api/dynamicVariables/getFieldKeys';
interface UseGetFieldKeysProps {
/** Type of signal (traces, logs, metrics) */
signal?: 'traces' | 'logs' | 'metrics';
/** Optional search text */
name?: string;
/** Whether the query should be enabled */
enabled?: boolean;
}
/**
* Hook to fetch field keys for a given signal type
*
* If 'complete' in the response is true:
* - All subsequent searches should be local (client has complete list)
*
* If 'complete' is false:
* - All subsequent searches should use the API (passing the name param)
*/
export const useGetFieldKeys = ({
signal,
name,
enabled = true,
}: UseGetFieldKeysProps): UseQueryResult<
SuccessResponse<FieldKeyResponse> | ErrorResponse
> =>
useQuery<SuccessResponse<FieldKeyResponse> | ErrorResponse>({
queryKey: ['fieldKeys', signal, name],
queryFn: () => getFieldKeys(signal, name),
enabled,
});

View File

@@ -0,0 +1,63 @@
import { getFieldValues } from 'api/dynamicVariables/getFieldValues';
import { useQuery, UseQueryResult } from 'react-query';
import { ErrorResponse, SuccessResponse } from 'types/api';
import { FieldValueResponse } from 'types/api/dynamicVariables/getFieldValues';
interface UseGetFieldValuesProps {
/** Type of signal (traces, logs, metrics) */
signal?: 'traces' | 'logs' | 'metrics';
/** Name of the attribute for which values are being fetched */
name: string;
/** Optional search text */
searchText?: string;
/** Whether the query should be enabled */
enabled?: boolean;
/** Start Unix Milli */
startUnixMilli?: number;
/** End Unix Milli */
endUnixMilli?: number;
/** Existing query */
existingQuery?: string;
}
/**
* Hook to fetch field values for a given signal type and field name
*
* If 'complete' in the response is true:
* - All subsequent searches should be local (client has complete list)
*
* If 'complete' is false:
* - All subsequent searches should use the API (passing the value param)
*/
export const useGetFieldValues = ({
signal,
name,
searchText,
startUnixMilli,
endUnixMilli,
enabled = true,
existingQuery,
}: UseGetFieldValuesProps): UseQueryResult<
SuccessResponse<FieldValueResponse> | ErrorResponse
> =>
useQuery<SuccessResponse<FieldValueResponse> | ErrorResponse>({
queryKey: [
'fieldValues',
signal,
name,
searchText,
startUnixMilli,
endUnixMilli,
existingQuery,
],
queryFn: () =>
getFieldValues(
signal,
name,
searchText,
startUnixMilli,
endUnixMilli,
existingQuery,
),
enabled,
});

View File

@@ -7,6 +7,7 @@ import { QueryParams } from 'constants/query';
import ROUTES from 'constants/routes';
import { MenuItemKeys } from 'container/GridCardLayout/WidgetHeader/contants';
import { ThresholdProps } from 'container/NewWidget/RightContainer/Threshold/types';
import { useGetDynamicVariables } from 'hooks/dashboard/useGetDynamicVariables';
import { useNotifications } from 'hooks/useNotifications';
import { getDashboardVariables } from 'lib/dashbaordVariables/getDashboardVariables';
import history from 'lib/history';
@@ -36,6 +37,8 @@ const useCreateAlerts = (
const { selectedDashboard } = useDashboard();
const { dynamicVariables } = useGetDynamicVariables();
return useCallback(() => {
if (!widget) return;
@@ -64,6 +67,7 @@ const useCreateAlerts = (
selectedTime: widget.timePreferance,
variables: getDashboardVariables(selectedDashboard?.data.variables),
originalGraphType: widget.panelTypes,
dynamicVariables,
});
queryRangeMutation.mutate(queryPayload, {
onSuccess: (data) => {
@@ -92,6 +96,7 @@ const useCreateAlerts = (
selectedDashboard?.data.variables,
selectedDashboard?.data.version,
widget,
dynamicVariables,
]);
};

View File

@@ -2,6 +2,7 @@ import { isAxiosError } from 'axios';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import { updateBarStepInterval } from 'container/GridCardLayout/utils';
import { useGetDynamicVariables } from 'hooks/dashboard/useGetDynamicVariables';
import {
GetMetricQueryRange,
GetQueryResultsProps,
@@ -35,6 +36,8 @@ export const useGetQueryRange: UseGetQueryRange = (
options,
headers,
) => {
const { dynamicVariables } = useGetDynamicVariables();
const newRequestData: GetQueryResultsProps = useMemo(() => {
const firstQueryData = requestData.query.builder?.queryData[0];
const isListWithSingleTimestampOrder =
@@ -138,7 +141,13 @@ export const useGetQueryRange: UseGetQueryRange = (
APIError | Error
>({
queryFn: async ({ signal }) =>
GetMetricQueryRange(modifiedRequestData, version, signal, headers),
GetMetricQueryRange(
modifiedRequestData,
version,
dynamicVariables,
signal,
headers,
),
...options,
retry,
queryKey,

View File

@@ -3,6 +3,7 @@ import {
getTagToken,
} from 'container/QueryBuilder/filters/QueryBuilderSearch/utils';
import { Option } from 'container/QueryBuilder/type';
import { useGetDynamicVariables } from 'hooks/dashboard/useGetDynamicVariables';
import { isEmpty } from 'lodash-es';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
@@ -24,10 +25,19 @@ export const useOptions = (
result: string[],
isFetching: boolean,
whereClauseConfig?: WhereClauseConfig,
// eslint-disable-next-line sonarjs/cognitive-complexity
): Option[] => {
const [options, setOptions] = useState<Option[]>([]);
const operators = useOperators(key, keys);
// get matching dynamic variables to suggest
const { dynamicVariables } = useGetDynamicVariables();
const variableName = dynamicVariables.find(
(variable) => variable?.dynamicVariablesAttribute === key,
)?.name;
const variableAsValue = variableName ? `$${variableName}` : '';
const getLabel = useCallback(
(data: BaseAutocompleteData): Option['label'] => data?.key,
[],
@@ -57,7 +67,13 @@ export const useOptions = (
const getOptionsWithValidOperator = useCallback(
(key: string, results: string[], searchValue: string) => {
const hasAllResults = results.every((value) => result.includes(value));
const values = getKeyOpValue(results);
let newResults = results;
if (!isEmpty(variableAsValue)) {
newResults = [variableAsValue, ...newResults];
}
const values = getKeyOpValue(newResults);
return hasAllResults
? [
@@ -74,7 +90,7 @@ export const useOptions = (
...values,
];
},
[getKeyOpValue, result],
[getKeyOpValue, result, variableAsValue],
);
const getKeyOperatorOptions = useCallback(
@@ -122,7 +138,10 @@ export const useOptions = (
newOptions = getKeyOperatorOptions(key);
} else if (key && operator) {
if (isMulti) {
newOptions = results.map((item) => ({
const resultsWithVariable = isEmpty(variableAsValue)
? results
: [variableAsValue, ...results];
newOptions = resultsWithVariable.map((item) => ({
label: checkCommaInValue(String(item)),
value: String(item),
}));
@@ -155,6 +174,7 @@ export const useOptions = (
getKeyOperatorOptions,
getOptionsWithValidOperator,
isFetching,
variableAsValue,
]);
return useMemo(

View File

@@ -23,7 +23,13 @@ export const getDashboardVariables = (
Object.entries(variables).forEach(([, value]) => {
if (value?.name) {
variablesTuple[value.name] = value?.selectedValue;
variablesTuple[value.name] =
value?.type === 'DYNAMIC' &&
value?.allSelected &&
value?.showALLOption &&
value?.multiSelect
? '__all__'
: value?.selectedValue;
}
});

View File

@@ -27,6 +27,7 @@ import { DataSource } from 'types/common/queryBuilder';
import { prepareQueryRangePayload } from './prepareQueryRangePayload';
import { QueryData } from 'types/api/widgets/getQuery';
import { createAggregation } from 'api/v5/queryRange/prepareQueryRangePayloadV5';
import { DynamicVariable } from 'hooks/dashboard/useGetDynamicVariables';
/**
* Validates if metric name is available for METRICS data source
@@ -188,6 +189,7 @@ export const getLegend = (
export async function GetMetricQueryRange(
props: GetQueryResultsProps,
version: string,
dynamicVariables?: DynamicVariable[],
signal?: AbortSignal,
headers?: Record<string, string>,
isInfraMonitoring?: boolean,
@@ -233,7 +235,10 @@ export async function GetMetricQueryRange(
}
if (version === ENTITY_VERSION_V5) {
const v5Result = prepareQueryRangePayloadV5(props);
const v5Result = prepareQueryRangePayloadV5({
...props,
dynamicVariables,
});
legendMap = v5Result.legendMap;
// atleast one query should be there to make call to v5 api
@@ -363,4 +368,5 @@ export interface GetQueryResultsProps {
end?: number;
step?: number;
originalGraphType?: PANEL_TYPES;
dynamicVariables?: DynamicVariable[];
}

View File

@@ -45,6 +45,7 @@ export interface IDashboardContext {
| null
| undefined,
allSelected: boolean,
isDynamic?: boolean,
) => void;
variablesToGetUpdated: string[];
setVariablesToGetUpdated: React.Dispatch<React.SetStateAction<string[]>>;

View File

@@ -9,7 +9,12 @@ import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { IField } from '../logs/fields';
import { TelemetryFieldKey } from '../v5/queryRange';
export const VariableQueryTypeArr = ['QUERY', 'TEXTBOX', 'CUSTOM'] as const;
export const VariableQueryTypeArr = [
'QUERY',
'TEXTBOX',
'CUSTOM',
'DYNAMIC',
] as const;
export type TVariableQueryType = typeof VariableQueryTypeArr[number];
export const VariableSortTypeArr = ['DISABLED', 'ASC', 'DESC'] as const;
@@ -46,6 +51,11 @@ export interface IDashboardVariable {
modificationUUID?: string;
allSelected?: boolean;
change?: boolean;
defaultValue?: string;
dynamicVariablesAttribute?: string;
dynamicVariablesSource?: string;
haveCustomValuesSelected?: boolean;
dynamicVariablesWidgetIds?: string[];
}
export interface Dashboard {
id: string;

View File

@@ -0,0 +1,23 @@
/**
* Response from the field keys API
*/
export interface FieldKeyResponse {
/** List of field keys returned */
keys?: Record<string, FieldKey[]>;
/** Indicates if the returned list is complete */
complete?: boolean;
}
/**
* Field key data structure
*/
export interface FieldKey {
/** Key name */
name?: string;
/** Data type of the field */
fieldDataType?: string;
/** Signal type */
signal?: string;
/** Field context */
fieldContext?: string;
}

View File

@@ -0,0 +1,20 @@
export interface TelemetryFieldValues {
StringValues?: string[];
NumberValues?: number[];
RelatedValues?: string[];
[key: string]: string[] | number[] | boolean[] | undefined;
}
/**
* Response from the field values API
*/
export interface FieldValueResponse {
/** List of field values returned by type */
values: TelemetryFieldValues;
/** Normalized values combined from all types */
normalizedValues?: string[];
/** Related values from the field */
relatedValues?: string[];
/** Indicates if the returned list is complete */
complete: boolean;
}