Compare commits
51 Commits
main
...
dyn-var-sr
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0de04c14fe | ||
|
|
e576e3fc40 | ||
|
|
cbfb172f0f | ||
|
|
b4bca92ab4 | ||
|
|
af80c3e494 | ||
|
|
1253e1b02f | ||
|
|
9251e660ab | ||
|
|
29e32e31aa | ||
|
|
2611b12edb | ||
|
|
c660afe020 | ||
|
|
82a321e3f3 | ||
|
|
6b1f49936d | ||
|
|
90d35eed84 | ||
|
|
13c8e7c5c3 | ||
|
|
b893e39666 | ||
|
|
2234bc83ce | ||
|
|
333544cd56 | ||
|
|
a23422d2ef | ||
|
|
86d926a51a | ||
|
|
b1ca927bad | ||
|
|
2f5963d797 | ||
|
|
c088b36e9e | ||
|
|
3369e9e599 | ||
|
|
d45269381f | ||
|
|
7fa420e13d | ||
|
|
ae08b14f1f | ||
|
|
8b00cb5a55 | ||
|
|
0b272c2618 | ||
|
|
80eee52a3f | ||
|
|
efc1834b01 | ||
|
|
8a3d79ef04 | ||
|
|
9271abed4e | ||
|
|
f40c40fb88 | ||
|
|
47ea3cf2f2 | ||
|
|
a3ca0a1c43 | ||
|
|
bc1cefa5f4 | ||
|
|
d2c438de78 | ||
|
|
9dfc5a1103 | ||
|
|
1cfc7acfcb | ||
|
|
03624ef50d | ||
|
|
3922431278 | ||
|
|
9eee119f5f | ||
|
|
6f54f2c398 | ||
|
|
f41fd4f8a8 | ||
|
|
b43c1d400b | ||
|
|
df69d73572 | ||
|
|
3c1daedae9 | ||
|
|
a9ec8f4a04 | ||
|
|
5a3a7eb29f | ||
|
|
274fd8b51f | ||
|
|
57c8381f68 |
114
frontend/src/api/dynamicVariables/__tests__/getFieldKeys.test.ts
Normal file
114
frontend/src/api/dynamicVariables/__tests__/getFieldKeys.test.ts
Normal 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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
}),
|
||||
});
|
||||
});
|
||||
});
|
||||
34
frontend/src/api/dynamicVariables/getFieldKeys.ts
Normal file
34
frontend/src/api/dynamicVariables/getFieldKeys.ts
Normal 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;
|
||||
80
frontend/src/api/dynamicVariables/getFieldValues.ts
Normal file
80
frontend/src/api/dynamicVariables/getFieldValues.ts
Normal 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;
|
||||
@@ -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>),
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
})),
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -69,6 +69,7 @@ export interface GridCardGraphProps {
|
||||
};
|
||||
customOnRowClick?: (record: RowData) => void;
|
||||
customTimeRangeWindowForCoRelation?: string | undefined;
|
||||
dynamicVariableToWidgetsMap?: Record<string, string[]>;
|
||||
}
|
||||
|
||||
export interface GetGraphVisibilityStateOnLegendClickProps {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
})),
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
@@ -167,7 +167,7 @@ describe('VariableItem', () => {
|
||||
</MockQueryClientProvider>,
|
||||
);
|
||||
|
||||
expect(screen.getByTitle('ALL')).toBeInTheDocument();
|
||||
expect(screen.getByText('ALL')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('calls useEffect when the component mounts', () => {
|
||||
|
||||
@@ -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' }}>
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -14,3 +14,5 @@ export function variablePropsToPayloadVariables(
|
||||
|
||||
return payloadVariables;
|
||||
}
|
||||
|
||||
export const ALL_SELECT_VALUE = '__ALL__';
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
}),
|
||||
]),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
@@ -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 },
|
||||
}));
|
||||
};
|
||||
|
||||
|
||||
55
frontend/src/hooks/dashboard/useGetDynamicVariables.tsx
Normal file
55
frontend/src/hooks/dashboard/useGetDynamicVariables.tsx
Normal 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 };
|
||||
};
|
||||
@@ -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]);
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
35
frontend/src/hooks/dynamicVariables/useGetFieldKeys.ts
Normal file
35
frontend/src/hooks/dynamicVariables/useGetFieldKeys.ts
Normal 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,
|
||||
});
|
||||
63
frontend/src/hooks/dynamicVariables/useGetFieldValues.ts
Normal file
63
frontend/src/hooks/dynamicVariables/useGetFieldValues.ts
Normal 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,
|
||||
});
|
||||
@@ -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,
|
||||
]);
|
||||
};
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -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[];
|
||||
}
|
||||
|
||||
@@ -45,6 +45,7 @@ export interface IDashboardContext {
|
||||
| null
|
||||
| undefined,
|
||||
allSelected: boolean,
|
||||
isDynamic?: boolean,
|
||||
) => void;
|
||||
variablesToGetUpdated: string[];
|
||||
setVariablesToGetUpdated: React.Dispatch<React.SetStateAction<string[]>>;
|
||||
|
||||
@@ -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;
|
||||
|
||||
23
frontend/src/types/api/dynamicVariables/getFieldKeys.ts
Normal file
23
frontend/src/types/api/dynamicVariables/getFieldKeys.ts
Normal 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;
|
||||
}
|
||||
20
frontend/src/types/api/dynamicVariables/getFieldValues.ts
Normal file
20
frontend/src/types/api/dynamicVariables/getFieldValues.ts
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user