mirror of
https://github.com/SigNoz/signoz.git
synced 2025-12-18 16:07:10 +00:00
feat: added dynamic variable to the dashboard details (#7755)
* feat: added dynamic variable to the dashboard details * feat: added new component to existing variables * feat: added enhancement to multiselect and select for dyn-variables * feat: added refetch method between all dynamic-variables * feat: correct error handling * feat: correct error handling * feat: enforced non-empty selectedvalues and default value * feat: added client and server side searches * feat: retry on error * feat: correct error handling * feat: handle defautl value in existing variables * feat: lowercase the source for payload * feat: fixed the incorrect assignment of active indices * feat: improved handling of all option * feat: improved the ALL option visuals * feat: handled default value enforcement in existing variables * feat: added unix time to values call * feat: added incomplete data message and info to search * feat: changed dashboard panel call handling with existing variables * feat: adjusted the response type and data with the new API schema for values * feat: code refactor * feat: made dyn-variable option as the default * feat: added test cases for dyn variable creation and completion * feat: updated test cases
This commit is contained in:
parent
57c8381f68
commit
274fd8b51f
@ -12,6 +12,8 @@ export const getFieldValues = async (
|
|||||||
signal?: 'traces' | 'logs' | 'metrics',
|
signal?: 'traces' | 'logs' | 'metrics',
|
||||||
name?: string,
|
name?: string,
|
||||||
value?: string,
|
value?: string,
|
||||||
|
startUnixMilli?: number,
|
||||||
|
endUnixMilli?: number,
|
||||||
): Promise<SuccessResponse<FieldValueResponse> | ErrorResponse> => {
|
): Promise<SuccessResponse<FieldValueResponse> | ErrorResponse> => {
|
||||||
const params: Record<string, string> = {};
|
const params: Record<string, string> = {};
|
||||||
|
|
||||||
@ -27,8 +29,29 @@ export const getFieldValues = async (
|
|||||||
params.value = value;
|
params.value = value;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (startUnixMilli) {
|
||||||
|
params.startUnixMilli = Math.floor(startUnixMilli / 1000000).toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (endUnixMilli) {
|
||||||
|
params.endUnixMilli = Math.floor(endUnixMilli / 1000000).toString();
|
||||||
|
}
|
||||||
|
|
||||||
const response = await ApiBaseInstance.get('/fields/values', { params });
|
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.values(response.data.data.values).forEach((valueArray: any) => {
|
||||||
|
if (Array.isArray(valueArray)) {
|
||||||
|
allValues.push(...valueArray.map(String));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add a normalized values array to the response
|
||||||
|
response.data.data.normalizedValues = allValues;
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
statusCode: 200,
|
statusCode: 200,
|
||||||
error: null,
|
error: null,
|
||||||
|
|||||||
@ -28,6 +28,7 @@ import { popupContainer } from 'utils/selectPopupContainer';
|
|||||||
import { CustomMultiSelectProps, CustomTagProps, OptionData } from './types';
|
import { CustomMultiSelectProps, CustomTagProps, OptionData } from './types';
|
||||||
import {
|
import {
|
||||||
filterOptionsBySearch,
|
filterOptionsBySearch,
|
||||||
|
handleScrollToBottom,
|
||||||
prioritizeOrAddOptionForMultiSelect,
|
prioritizeOrAddOptionForMultiSelect,
|
||||||
SPACEKEY,
|
SPACEKEY,
|
||||||
} from './utils';
|
} from './utils';
|
||||||
@ -37,7 +38,7 @@ enum ToggleTagValue {
|
|||||||
All = 'All',
|
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> = ({
|
const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
|
||||||
placeholder = 'Search...',
|
placeholder = 'Search...',
|
||||||
@ -62,6 +63,8 @@ const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
|
|||||||
allowClear = false,
|
allowClear = false,
|
||||||
onRetry,
|
onRetry,
|
||||||
maxTagTextLength,
|
maxTagTextLength,
|
||||||
|
onDropdownVisibleChange,
|
||||||
|
showIncompleteDataMessage = false,
|
||||||
...rest
|
...rest
|
||||||
}) => {
|
}) => {
|
||||||
// ===== State & Refs =====
|
// ===== State & Refs =====
|
||||||
@ -78,6 +81,8 @@ const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
|
|||||||
const optionRefs = useRef<Record<number, HTMLDivElement | null>>({});
|
const optionRefs = useRef<Record<number, HTMLDivElement | null>>({});
|
||||||
const [visibleOptions, setVisibleOptions] = useState<OptionData[]>([]);
|
const [visibleOptions, setVisibleOptions] = useState<OptionData[]>([]);
|
||||||
const isClickInsideDropdownRef = useRef(false);
|
const isClickInsideDropdownRef = useRef(false);
|
||||||
|
const justOpenedRef = useRef<boolean>(false);
|
||||||
|
const [isScrolledToBottom, setIsScrolledToBottom] = useState(false);
|
||||||
|
|
||||||
// Convert single string value to array for consistency
|
// Convert single string value to array for consistency
|
||||||
const selectedValues = useMemo(
|
const selectedValues = useMemo(
|
||||||
@ -124,6 +129,12 @@ const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
|
|||||||
return allAvailableValues.every((val) => selectedValues.includes(val));
|
return allAvailableValues.every((val) => selectedValues.includes(val));
|
||||||
}, [selectedValues, allAvailableValues, enableAllSelection]);
|
}, [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
|
// Value passed to the underlying Ant Select component
|
||||||
const displayValue = useMemo(
|
const displayValue = useMemo(
|
||||||
() => (isAllSelected ? [ALL_SELECTED_VALUE] : selectedValues),
|
() => (isAllSelected ? [ALL_SELECTED_VALUE] : selectedValues),
|
||||||
@ -132,10 +143,18 @@ const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
|
|||||||
|
|
||||||
// ===== Internal onChange Handler =====
|
// ===== Internal onChange Handler =====
|
||||||
const handleInternalChange = useCallback(
|
const handleInternalChange = useCallback(
|
||||||
(newValue: string | string[]): void => {
|
(newValue: string | string[], directCaller?: boolean): void => {
|
||||||
// Ensure newValue is an array
|
// Ensure newValue is an array
|
||||||
const currentNewValue = Array.isArray(newValue) ? newValue : [];
|
const currentNewValue = Array.isArray(newValue) ? newValue : [];
|
||||||
|
|
||||||
|
if (
|
||||||
|
(allOptionShown || isAllSelected) &&
|
||||||
|
!directCaller &&
|
||||||
|
currentNewValue.length === 0
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (!onChange) return;
|
if (!onChange) return;
|
||||||
|
|
||||||
// Case 1: Cleared (empty array or undefined)
|
// Case 1: Cleared (empty array or undefined)
|
||||||
@ -144,7 +163,7 @@ const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
|
|||||||
return;
|
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)) {
|
if (currentNewValue.includes(ALL_SELECTED_VALUE)) {
|
||||||
const allActualOptions = allAvailableValues.map(
|
const allActualOptions = allAvailableValues.map(
|
||||||
(v) => options.flat().find((o) => o.value === v) || { label: v, value: v },
|
(v) => options.flat().find((o) => o.value === v) || { label: v, value: v },
|
||||||
@ -175,7 +194,14 @@ const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[onChange, allAvailableValues, options, enableAllSelection],
|
[
|
||||||
|
allOptionShown,
|
||||||
|
isAllSelected,
|
||||||
|
onChange,
|
||||||
|
allAvailableValues,
|
||||||
|
options,
|
||||||
|
enableAllSelection,
|
||||||
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
// ===== Existing Callbacks (potentially needing adjustment later) =====
|
// ===== Existing Callbacks (potentially needing adjustment later) =====
|
||||||
@ -510,11 +536,19 @@ const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Normal single value handling
|
// Normal single value handling
|
||||||
setSearchText(value.trim());
|
const trimmedValue = value.trim();
|
||||||
|
setSearchText(trimmedValue);
|
||||||
if (!isOpen) {
|
if (!isOpen) {
|
||||||
setIsOpen(true);
|
setIsOpen(true);
|
||||||
|
justOpenedRef.current = true;
|
||||||
}
|
}
|
||||||
if (onSearch) onSearch(value.trim());
|
|
||||||
|
// Reset active index when search changes if dropdown is open
|
||||||
|
if (isOpen && trimmedValue) {
|
||||||
|
setActiveIndex(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (onSearch) onSearch(trimmedValue);
|
||||||
},
|
},
|
||||||
[onSearch, isOpen, selectedValues, onChange],
|
[onSearch, isOpen, selectedValues, onChange],
|
||||||
);
|
);
|
||||||
@ -528,28 +562,34 @@ const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
|
|||||||
(text: string, searchQuery: string): React.ReactNode => {
|
(text: string, searchQuery: string): React.ReactNode => {
|
||||||
if (!searchQuery || !highlightSearch) return text;
|
if (!searchQuery || !highlightSearch) return text;
|
||||||
|
|
||||||
const parts = text.split(
|
try {
|
||||||
new RegExp(
|
const parts = text.split(
|
||||||
`(${searchQuery.replace(/[.*+?^${}()|[\\]\\]/g, '\\$&')})`,
|
new RegExp(
|
||||||
'gi',
|
`(${searchQuery.replace(/[-[\]{}()*+?.,\\^$|#\s]/g, '\\$&')})`,
|
||||||
),
|
'gi',
|
||||||
);
|
),
|
||||||
return (
|
);
|
||||||
<>
|
return (
|
||||||
{parts.map((part, i) => {
|
<>
|
||||||
// Create a unique key that doesn't rely on array index
|
{parts.map((part, i) => {
|
||||||
const uniqueKey = `${text.substring(0, 3)}-${part.substring(0, 3)}-${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() ? (
|
return part.toLowerCase() === searchQuery.toLowerCase() ? (
|
||||||
<span key={uniqueKey} className="highlight-text">
|
<span key={uniqueKey} className="highlight-text">
|
||||||
{part}
|
{part}
|
||||||
</span>
|
</span>
|
||||||
) : (
|
) : (
|
||||||
part
|
part
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
} catch (error) {
|
||||||
|
// If regex fails, return the original text without highlighting
|
||||||
|
console.error('Error in text highlighting:', error);
|
||||||
|
return text;
|
||||||
|
}
|
||||||
},
|
},
|
||||||
[highlightSearch],
|
[highlightSearch],
|
||||||
);
|
);
|
||||||
@ -560,10 +600,10 @@ const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
|
|||||||
|
|
||||||
if (isAllSelected) {
|
if (isAllSelected) {
|
||||||
// If all are selected, deselect all
|
// If all are selected, deselect all
|
||||||
handleInternalChange([]);
|
handleInternalChange([], true);
|
||||||
} else {
|
} else {
|
||||||
// Otherwise, select all
|
// Otherwise, select all
|
||||||
handleInternalChange([ALL_SELECTED_VALUE]);
|
handleInternalChange([ALL_SELECTED_VALUE], true);
|
||||||
}
|
}
|
||||||
}, [options, isAllSelected, handleInternalChange]);
|
}, [options, isAllSelected, handleInternalChange]);
|
||||||
|
|
||||||
@ -738,6 +778,26 @@ const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
|
|||||||
// Enhanced keyboard navigation with support for maxTagCount
|
// Enhanced keyboard navigation with support for maxTagCount
|
||||||
const handleKeyDown = useCallback(
|
const handleKeyDown = useCallback(
|
||||||
(e: React.KeyboardEvent<HTMLElement>): void => {
|
(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
|
// Get flattened list of all selectable options
|
||||||
const getFlatOptions = (): OptionData[] => {
|
const getFlatOptions = (): OptionData[] => {
|
||||||
if (!visibleOptions) return [];
|
if (!visibleOptions) return [];
|
||||||
@ -752,7 +812,7 @@ const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
|
|||||||
if (hasAll) {
|
if (hasAll) {
|
||||||
flatList.push({
|
flatList.push({
|
||||||
label: 'ALL',
|
label: 'ALL',
|
||||||
value: '__all__', // Special value for the ALL option
|
value: ALL_SELECTED_VALUE, // Special value for the ALL option
|
||||||
type: 'defined',
|
type: 'defined',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -784,6 +844,17 @@ const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
|
|||||||
|
|
||||||
const flatOptions = getFlatOptions();
|
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
|
// Get the active input element to check cursor position
|
||||||
const activeElement = document.activeElement as HTMLInputElement;
|
const activeElement = document.activeElement as HTMLInputElement;
|
||||||
const isInputActive = activeElement?.tagName === 'INPUT';
|
const isInputActive = activeElement?.tagName === 'INPUT';
|
||||||
@ -1129,7 +1200,7 @@ const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
|
|||||||
// If there's an active option in the dropdown, prioritize selecting it
|
// If there's an active option in the dropdown, prioritize selecting it
|
||||||
if (activeIndex >= 0 && activeIndex < flatOptions.length) {
|
if (activeIndex >= 0 && activeIndex < flatOptions.length) {
|
||||||
const selectedOption = flatOptions[activeIndex];
|
const selectedOption = flatOptions[activeIndex];
|
||||||
if (selectedOption.value === '__all__') {
|
if (selectedOption.value === ALL_SELECTED_VALUE) {
|
||||||
handleSelectAll();
|
handleSelectAll();
|
||||||
} else if (selectedOption.value && onChange) {
|
} else if (selectedOption.value && onChange) {
|
||||||
const newValues = selectedValues.includes(selectedOption.value)
|
const newValues = selectedValues.includes(selectedOption.value)
|
||||||
@ -1159,6 +1230,10 @@ const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setIsOpen(false);
|
setIsOpen(false);
|
||||||
setActiveIndex(-1);
|
setActiveIndex(-1);
|
||||||
|
// Call onDropdownVisibleChange when Escape is pressed to close dropdown
|
||||||
|
if (onDropdownVisibleChange) {
|
||||||
|
onDropdownVisibleChange(false);
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case SPACEKEY:
|
case SPACEKEY:
|
||||||
@ -1168,7 +1243,7 @@ const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
|
|||||||
const selectedOption = flatOptions[activeIndex];
|
const selectedOption = flatOptions[activeIndex];
|
||||||
|
|
||||||
// Check if it's the ALL option
|
// Check if it's the ALL option
|
||||||
if (selectedOption.value === '__all__') {
|
if (selectedOption.value === ALL_SELECTED_VALUE) {
|
||||||
handleSelectAll();
|
handleSelectAll();
|
||||||
} else if (selectedOption.value && onChange) {
|
} else if (selectedOption.value && onChange) {
|
||||||
const newValues = selectedValues.includes(selectedOption.value)
|
const newValues = selectedValues.includes(selectedOption.value)
|
||||||
@ -1214,7 +1289,7 @@ const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
|
|||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setIsOpen(true);
|
setIsOpen(true);
|
||||||
setActiveIndex(0);
|
justOpenedRef.current = true; // Set flag to initialize active option on next render
|
||||||
setActiveChipIndex(-1);
|
setActiveChipIndex(-1);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
@ -1260,9 +1335,14 @@ const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
[
|
[
|
||||||
|
allOptionShown,
|
||||||
|
isAllSelected,
|
||||||
|
isOpen,
|
||||||
|
activeIndex,
|
||||||
|
getVisibleChipIndices,
|
||||||
|
getLastVisibleChipIndex,
|
||||||
selectedChips,
|
selectedChips,
|
||||||
isSelectionMode,
|
isSelectionMode,
|
||||||
isOpen,
|
|
||||||
activeChipIndex,
|
activeChipIndex,
|
||||||
selectedValues,
|
selectedValues,
|
||||||
visibleOptions,
|
visibleOptions,
|
||||||
@ -1278,10 +1358,8 @@ const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
|
|||||||
startSelection,
|
startSelection,
|
||||||
selectionEnd,
|
selectionEnd,
|
||||||
extendSelection,
|
extendSelection,
|
||||||
activeIndex,
|
onDropdownVisibleChange,
|
||||||
handleSelectAll,
|
handleSelectAll,
|
||||||
getVisibleChipIndices,
|
|
||||||
getLastVisibleChipIndex,
|
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -1306,6 +1384,14 @@ const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
|
|||||||
setIsOpen(false);
|
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
|
// Custom dropdown render with sections support
|
||||||
const customDropdownRender = useCallback((): React.ReactElement => {
|
const customDropdownRender = useCallback((): React.ReactElement => {
|
||||||
// Process options based on current search
|
// Process options based on current search
|
||||||
@ -1382,6 +1468,7 @@ const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
|
|||||||
onMouseDown={handleDropdownMouseDown}
|
onMouseDown={handleDropdownMouseDown}
|
||||||
onClick={handleDropdownClick}
|
onClick={handleDropdownClick}
|
||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
|
onScroll={handleDropdownScroll}
|
||||||
onBlur={handleBlur}
|
onBlur={handleBlur}
|
||||||
role="listbox"
|
role="listbox"
|
||||||
aria-multiselectable="true"
|
aria-multiselectable="true"
|
||||||
@ -1460,15 +1547,18 @@ const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
|
|||||||
|
|
||||||
{/* Navigation help footer */}
|
{/* Navigation help footer */}
|
||||||
<div className="navigation-footer" role="note">
|
<div className="navigation-footer" role="note">
|
||||||
{!loading && !errorMessage && !noDataMessage && (
|
{!loading &&
|
||||||
<section className="navigate">
|
!errorMessage &&
|
||||||
<ArrowDown size={8} className="icons" />
|
!noDataMessage &&
|
||||||
<ArrowUp size={8} className="icons" />
|
!(showIncompleteDataMessage && isScrolledToBottom) && (
|
||||||
<ArrowLeft size={8} className="icons" />
|
<section className="navigate">
|
||||||
<ArrowRight size={8} className="icons" />
|
<ArrowDown size={8} className="icons" />
|
||||||
<span className="keyboard-text">to navigate</span>
|
<ArrowUp size={8} className="icons" />
|
||||||
</section>
|
<ArrowLeft size={8} className="icons" />
|
||||||
)}
|
<ArrowRight size={8} className="icons" />
|
||||||
|
<span className="keyboard-text">to navigate</span>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
{loading && (
|
{loading && (
|
||||||
<div className="navigation-loading">
|
<div className="navigation-loading">
|
||||||
<div className="navigation-icons">
|
<div className="navigation-icons">
|
||||||
@ -1494,9 +1584,19 @@ const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{noDataMessage && !loading && (
|
{showIncompleteDataMessage &&
|
||||||
<div className="navigation-text">{noDataMessage}</div>
|
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>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@ -1513,6 +1613,7 @@ const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
|
|||||||
handleDropdownMouseDown,
|
handleDropdownMouseDown,
|
||||||
handleDropdownClick,
|
handleDropdownClick,
|
||||||
handleKeyDown,
|
handleKeyDown,
|
||||||
|
handleDropdownScroll,
|
||||||
handleBlur,
|
handleBlur,
|
||||||
activeIndex,
|
activeIndex,
|
||||||
loading,
|
loading,
|
||||||
@ -1522,8 +1623,31 @@ const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
|
|||||||
renderOptionWithIndex,
|
renderOptionWithIndex,
|
||||||
handleSelectAll,
|
handleSelectAll,
|
||||||
onRetry,
|
onRetry,
|
||||||
|
showIncompleteDataMessage,
|
||||||
|
isScrolledToBottom,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
// 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 =====
|
// ===== Side Effects =====
|
||||||
|
|
||||||
// Clear search when dropdown closes
|
// Clear search when dropdown closes
|
||||||
@ -1588,52 +1712,9 @@ const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
|
|||||||
const { label, value, closable, onClose } = props;
|
const { label, value, closable, onClose } = props;
|
||||||
|
|
||||||
// If the display value is the special ALL value, render the ALL tag
|
// If the display value is the special ALL value, render the ALL tag
|
||||||
if (value === ALL_SELECTED_VALUE && isAllSelected) {
|
if (allOptionShown) {
|
||||||
const handleAllTagClose = (
|
// Don't render a visible tag - will be shown as placeholder
|
||||||
e: React.MouseEvent | React.KeyboardEvent,
|
return <div style={{ display: 'none' }} />;
|
||||||
): 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 not isAllSelected, render individual tags using previous logic
|
// If not isAllSelected, render individual tags using previous logic
|
||||||
@ -1713,52 +1794,69 @@ const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
|
|||||||
// Fallback for safety, should not be reached
|
// Fallback for safety, should not be reached
|
||||||
return <div />;
|
return <div />;
|
||||||
},
|
},
|
||||||
[
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
isAllSelected,
|
[isAllSelected, activeChipIndex, selectedChips, selectedValues, maxTagCount],
|
||||||
handleInternalChange,
|
|
||||||
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 =====
|
// ===== Component Rendering =====
|
||||||
return (
|
return (
|
||||||
<Select
|
<div
|
||||||
ref={selectRef}
|
className={cx('custom-multiselect-wrapper', {
|
||||||
className={cx('custom-multiselect', className, {
|
'all-selected': allOptionShown || isAllSelected,
|
||||||
'has-selection': selectedChips.length > 0 && !isAllSelected,
|
|
||||||
'is-all-selected': isAllSelected,
|
|
||||||
})}
|
})}
|
||||||
placeholder={placeholder}
|
>
|
||||||
mode="multiple"
|
{(allOptionShown || isAllSelected) && !searchText && (
|
||||||
showSearch
|
<div className="all-text">ALL</div>
|
||||||
filterOption={false}
|
)}
|
||||||
onSearch={handleSearch}
|
<Select
|
||||||
value={displayValue}
|
ref={selectRef}
|
||||||
onChange={handleInternalChange}
|
className={cx('custom-multiselect', className, {
|
||||||
onClear={(): void => handleInternalChange([])}
|
'has-selection': selectedChips.length > 0 && !isAllSelected,
|
||||||
onDropdownVisibleChange={setIsOpen}
|
'is-all-selected': isAllSelected,
|
||||||
open={isOpen}
|
})}
|
||||||
defaultActiveFirstOption={defaultActiveFirstOption}
|
placeholder={placeholder}
|
||||||
popupMatchSelectWidth={dropdownMatchSelectWidth}
|
mode="multiple"
|
||||||
allowClear={allowClear}
|
showSearch
|
||||||
getPopupContainer={getPopupContainer ?? popupContainer}
|
filterOption={false}
|
||||||
suffixIcon={<DownOutlined style={{ cursor: 'default' }} />}
|
onSearch={handleSearch}
|
||||||
dropdownRender={customDropdownRender}
|
value={displayValue}
|
||||||
menuItemSelectedIcon={null}
|
onChange={(newValue): void => {
|
||||||
popupClassName={cx('custom-multiselect-dropdown-container', popupClassName)}
|
handleInternalChange(newValue, false);
|
||||||
notFoundContent={<div className="empty-message">{noDataMessage}</div>}
|
}}
|
||||||
onKeyDown={handleKeyDown}
|
onClear={onClearHandler}
|
||||||
tagRender={tagRender as any}
|
onDropdownVisibleChange={handleDropdownVisibleChange}
|
||||||
placement={placement}
|
open={isOpen}
|
||||||
listHeight={300}
|
defaultActiveFirstOption={defaultActiveFirstOption}
|
||||||
searchValue={searchText}
|
popupMatchSelectWidth={dropdownMatchSelectWidth}
|
||||||
maxTagTextLength={maxTagTextLength}
|
allowClear={allowClear}
|
||||||
maxTagCount={isAllSelected ? 1 : maxTagCount}
|
getPopupContainer={getPopupContainer ?? popupContainer}
|
||||||
{...rest}
|
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 { CustomSelectProps, OptionData } from './types';
|
||||||
import {
|
import {
|
||||||
filterOptionsBySearch,
|
filterOptionsBySearch,
|
||||||
|
handleScrollToBottom,
|
||||||
prioritizeOrAddOptionForSingleSelect,
|
prioritizeOrAddOptionForSingleSelect,
|
||||||
SPACEKEY,
|
SPACEKEY,
|
||||||
} from './utils';
|
} from './utils';
|
||||||
@ -57,17 +58,29 @@ const CustomSelect: React.FC<CustomSelectProps> = ({
|
|||||||
errorMessage,
|
errorMessage,
|
||||||
allowClear = false,
|
allowClear = false,
|
||||||
onRetry,
|
onRetry,
|
||||||
|
showIncompleteDataMessage = false,
|
||||||
...rest
|
...rest
|
||||||
}) => {
|
}) => {
|
||||||
// ===== State & Refs =====
|
// ===== State & Refs =====
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
const [searchText, setSearchText] = useState('');
|
const [searchText, setSearchText] = useState('');
|
||||||
const [activeOptionIndex, setActiveOptionIndex] = useState<number>(-1);
|
const [activeOptionIndex, setActiveOptionIndex] = useState<number>(-1);
|
||||||
|
const [isScrolledToBottom, setIsScrolledToBottom] = useState(false);
|
||||||
|
|
||||||
// Refs for element access and scroll behavior
|
// Refs for element access and scroll behavior
|
||||||
const selectRef = useRef<BaseSelectRef>(null);
|
const selectRef = useRef<BaseSelectRef>(null);
|
||||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||||
const optionRefs = useRef<Record<number, 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 =====
|
// ===== Option Filtering & Processing Utilities =====
|
||||||
|
|
||||||
@ -130,23 +143,33 @@ const CustomSelect: React.FC<CustomSelectProps> = ({
|
|||||||
(text: string, searchQuery: string): React.ReactNode => {
|
(text: string, searchQuery: string): React.ReactNode => {
|
||||||
if (!searchQuery || !highlightSearch) return text;
|
if (!searchQuery || !highlightSearch) return text;
|
||||||
|
|
||||||
const parts = text.split(new RegExp(`(${searchQuery})`, 'gi'));
|
try {
|
||||||
return (
|
const parts = text.split(
|
||||||
<>
|
new RegExp(
|
||||||
{parts.map((part, i) => {
|
`(${searchQuery.replace(/[-[\]{}()*+?.,\\^$|#\s]/g, '\\$&')})`,
|
||||||
// Create a deterministic but unique key
|
'gi',
|
||||||
const uniqueKey = `${text.substring(0, 3)}-${part.substring(0, 3)}-${i}`;
|
),
|
||||||
|
);
|
||||||
|
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() ? (
|
return part.toLowerCase() === searchQuery.toLowerCase() ? (
|
||||||
<span key={uniqueKey} className="highlight-text">
|
<span key={uniqueKey} className="highlight-text">
|
||||||
{part}
|
{part}
|
||||||
</span>
|
</span>
|
||||||
) : (
|
) : (
|
||||||
part
|
part
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error in text highlighting:', error);
|
||||||
|
return text;
|
||||||
|
}
|
||||||
},
|
},
|
||||||
[highlightSearch],
|
[highlightSearch],
|
||||||
);
|
);
|
||||||
@ -246,9 +269,14 @@ const CustomSelect: React.FC<CustomSelectProps> = ({
|
|||||||
const trimmedValue = value.trim();
|
const trimmedValue = value.trim();
|
||||||
setSearchText(trimmedValue);
|
setSearchText(trimmedValue);
|
||||||
|
|
||||||
|
// Reset active option index when search changes
|
||||||
|
if (isOpen) {
|
||||||
|
setActiveOptionIndex(0);
|
||||||
|
}
|
||||||
|
|
||||||
if (onSearch) onSearch(trimmedValue);
|
if (onSearch) onSearch(trimmedValue);
|
||||||
},
|
},
|
||||||
[onSearch],
|
[onSearch, isOpen],
|
||||||
);
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -272,14 +300,23 @@ const CustomSelect: React.FC<CustomSelectProps> = ({
|
|||||||
const flatList: OptionData[] = [];
|
const flatList: OptionData[] = [];
|
||||||
|
|
||||||
// Process options
|
// Process options
|
||||||
|
let processedOptions = isEmpty(value)
|
||||||
|
? filteredOptions
|
||||||
|
: prioritizeOrAddOptionForSingleSelect(filteredOptions, value);
|
||||||
|
|
||||||
|
if (!isEmpty(searchText)) {
|
||||||
|
processedOptions = filterOptionsBySearch(processedOptions, searchText);
|
||||||
|
}
|
||||||
|
|
||||||
const { sectionOptions, nonSectionOptions } = splitOptions(
|
const { sectionOptions, nonSectionOptions } = splitOptions(
|
||||||
isEmpty(value)
|
processedOptions,
|
||||||
? filteredOptions
|
|
||||||
: prioritizeOrAddOptionForSingleSelect(filteredOptions, value),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
// Add custom option if needed
|
// Add custom option if needed
|
||||||
if (!isEmpty(searchText) && !isLabelPresent(filteredOptions, searchText)) {
|
if (
|
||||||
|
!isEmpty(searchText) &&
|
||||||
|
!isLabelPresent(processedOptions, searchText)
|
||||||
|
) {
|
||||||
flatList.push({
|
flatList.push({
|
||||||
label: searchText,
|
label: searchText,
|
||||||
value: searchText,
|
value: searchText,
|
||||||
@ -300,33 +337,52 @@ const CustomSelect: React.FC<CustomSelectProps> = ({
|
|||||||
|
|
||||||
const options = getFlatOptions();
|
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) {
|
switch (e.key) {
|
||||||
case 'ArrowDown':
|
case 'ArrowDown':
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setActiveOptionIndex((prev) =>
|
if (options.length > 0) {
|
||||||
prev < options.length - 1 ? prev + 1 : 0,
|
setActiveOptionIndex((prev) =>
|
||||||
);
|
prev < options.length - 1 ? prev + 1 : 0,
|
||||||
|
);
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case 'ArrowUp':
|
case 'ArrowUp':
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setActiveOptionIndex((prev) =>
|
if (options.length > 0) {
|
||||||
prev > 0 ? prev - 1 : options.length - 1,
|
setActiveOptionIndex((prev) =>
|
||||||
);
|
prev > 0 ? prev - 1 : options.length - 1,
|
||||||
|
);
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case 'Tab':
|
case 'Tab':
|
||||||
// Tab navigation with Shift key support
|
// Tab navigation with Shift key support
|
||||||
if (e.shiftKey) {
|
if (e.shiftKey) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setActiveOptionIndex((prev) =>
|
if (options.length > 0) {
|
||||||
prev > 0 ? prev - 1 : options.length - 1,
|
setActiveOptionIndex((prev) =>
|
||||||
);
|
prev > 0 ? prev - 1 : options.length - 1,
|
||||||
|
);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setActiveOptionIndex((prev) =>
|
if (options.length > 0) {
|
||||||
prev < options.length - 1 ? prev + 1 : 0,
|
setActiveOptionIndex((prev) =>
|
||||||
);
|
prev < options.length - 1 ? prev + 1 : 0,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
|
||||||
@ -339,6 +395,7 @@ const CustomSelect: React.FC<CustomSelectProps> = ({
|
|||||||
onChange(selectedOption.value, selectedOption);
|
onChange(selectedOption.value, selectedOption);
|
||||||
setIsOpen(false);
|
setIsOpen(false);
|
||||||
setActiveOptionIndex(-1);
|
setActiveOptionIndex(-1);
|
||||||
|
setSearchText('');
|
||||||
}
|
}
|
||||||
} else if (!isEmpty(searchText)) {
|
} else if (!isEmpty(searchText)) {
|
||||||
// Add custom value when no option is focused
|
// Add custom value when no option is focused
|
||||||
@ -351,6 +408,7 @@ const CustomSelect: React.FC<CustomSelectProps> = ({
|
|||||||
onChange(customOption.value, customOption);
|
onChange(customOption.value, customOption);
|
||||||
setIsOpen(false);
|
setIsOpen(false);
|
||||||
setActiveOptionIndex(-1);
|
setActiveOptionIndex(-1);
|
||||||
|
setSearchText('');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
@ -359,6 +417,7 @@ const CustomSelect: React.FC<CustomSelectProps> = ({
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setIsOpen(false);
|
setIsOpen(false);
|
||||||
setActiveOptionIndex(-1);
|
setActiveOptionIndex(-1);
|
||||||
|
setSearchText('');
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case ' ': // Space key
|
case ' ': // Space key
|
||||||
@ -369,6 +428,7 @@ const CustomSelect: React.FC<CustomSelectProps> = ({
|
|||||||
onChange(selectedOption.value, selectedOption);
|
onChange(selectedOption.value, selectedOption);
|
||||||
setIsOpen(false);
|
setIsOpen(false);
|
||||||
setActiveOptionIndex(-1);
|
setActiveOptionIndex(-1);
|
||||||
|
setSearchText('');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
@ -379,7 +439,7 @@ const CustomSelect: React.FC<CustomSelectProps> = ({
|
|||||||
// Open dropdown when Down or Tab is pressed while closed
|
// Open dropdown when Down or Tab is pressed while closed
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setIsOpen(true);
|
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"
|
className="custom-select-dropdown"
|
||||||
onClick={handleDropdownClick}
|
onClick={handleDropdownClick}
|
||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
|
onScroll={handleDropdownScroll}
|
||||||
role="listbox"
|
role="listbox"
|
||||||
tabIndex={-1}
|
tabIndex={-1}
|
||||||
aria-activedescendant={
|
aria-activedescendant={
|
||||||
@ -454,7 +515,6 @@ const CustomSelect: React.FC<CustomSelectProps> = ({
|
|||||||
<div className="no-section-options">
|
<div className="no-section-options">
|
||||||
{nonSectionOptions.length > 0 && mapOptions(nonSectionOptions)}
|
{nonSectionOptions.length > 0 && mapOptions(nonSectionOptions)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Section options */}
|
{/* Section options */}
|
||||||
{sectionOptions.length > 0 &&
|
{sectionOptions.length > 0 &&
|
||||||
sectionOptions.map((section) =>
|
sectionOptions.map((section) =>
|
||||||
@ -472,13 +532,16 @@ const CustomSelect: React.FC<CustomSelectProps> = ({
|
|||||||
|
|
||||||
{/* Navigation help footer */}
|
{/* Navigation help footer */}
|
||||||
<div className="navigation-footer" role="note">
|
<div className="navigation-footer" role="note">
|
||||||
{!loading && !errorMessage && !noDataMessage && (
|
{!loading &&
|
||||||
<section className="navigate">
|
!errorMessage &&
|
||||||
<ArrowDown size={8} className="icons" />
|
!noDataMessage &&
|
||||||
<ArrowUp size={8} className="icons" />
|
!(showIncompleteDataMessage && isScrolledToBottom) && (
|
||||||
<span className="keyboard-text">to navigate</span>
|
<section className="navigate">
|
||||||
</section>
|
<ArrowDown size={8} className="icons" />
|
||||||
)}
|
<ArrowUp size={8} className="icons" />
|
||||||
|
<span className="keyboard-text">to navigate</span>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
{loading && (
|
{loading && (
|
||||||
<div className="navigation-loading">
|
<div className="navigation-loading">
|
||||||
<div className="navigation-icons">
|
<div className="navigation-icons">
|
||||||
@ -504,9 +567,19 @@ const CustomSelect: React.FC<CustomSelectProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{noDataMessage && !loading && (
|
{showIncompleteDataMessage &&
|
||||||
<div className="navigation-text">{noDataMessage}</div>
|
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>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@ -520,6 +593,7 @@ const CustomSelect: React.FC<CustomSelectProps> = ({
|
|||||||
isLabelPresent,
|
isLabelPresent,
|
||||||
handleDropdownClick,
|
handleDropdownClick,
|
||||||
handleKeyDown,
|
handleKeyDown,
|
||||||
|
handleDropdownScroll,
|
||||||
activeOptionIndex,
|
activeOptionIndex,
|
||||||
loading,
|
loading,
|
||||||
errorMessage,
|
errorMessage,
|
||||||
@ -527,8 +601,22 @@ const CustomSelect: React.FC<CustomSelectProps> = ({
|
|||||||
dropdownRender,
|
dropdownRender,
|
||||||
renderOptionWithIndex,
|
renderOptionWithIndex,
|
||||||
onRetry,
|
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 =====
|
// ===== Side Effects =====
|
||||||
|
|
||||||
// Clear search text when dropdown closes
|
// Clear search text when dropdown closes
|
||||||
@ -582,7 +670,7 @@ const CustomSelect: React.FC<CustomSelectProps> = ({
|
|||||||
onSearch={handleSearch}
|
onSearch={handleSearch}
|
||||||
value={value}
|
value={value}
|
||||||
onChange={onChange}
|
onChange={onChange}
|
||||||
onDropdownVisibleChange={setIsOpen}
|
onDropdownVisibleChange={handleDropdownVisibleChange}
|
||||||
open={isOpen}
|
open={isOpen}
|
||||||
options={optionsWithHighlight}
|
options={optionsWithHighlight}
|
||||||
defaultActiveFirstOption={defaultActiveFirstOption}
|
defaultActiveFirstOption={defaultActiveFirstOption}
|
||||||
|
|||||||
@ -35,6 +35,43 @@ $custom-border-color: #2c3044;
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
position: relative;
|
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 {
|
.ant-select-selector {
|
||||||
max-height: 200px;
|
max-height: 200px;
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
@ -158,7 +195,7 @@ $custom-border-color: #2c3044;
|
|||||||
// Custom dropdown styles for single select
|
// Custom dropdown styles for single select
|
||||||
.custom-select-dropdown {
|
.custom-select-dropdown {
|
||||||
padding: 8px 0 0 0;
|
padding: 8px 0 0 0;
|
||||||
max-height: 500px;
|
max-height: 300px;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
overflow-x: hidden;
|
overflow-x: hidden;
|
||||||
scrollbar-width: thin;
|
scrollbar-width: thin;
|
||||||
@ -276,6 +313,10 @@ $custom-border-color: #2c3044;
|
|||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.navigation-text-incomplete {
|
||||||
|
color: var(--bg-amber-600) !important;
|
||||||
|
}
|
||||||
|
|
||||||
.navigation-error {
|
.navigation-error {
|
||||||
.navigation-text,
|
.navigation-text,
|
||||||
.navigation-icons {
|
.navigation-icons {
|
||||||
@ -322,7 +363,7 @@ $custom-border-color: #2c3044;
|
|||||||
// Custom dropdown styles for multi-select
|
// Custom dropdown styles for multi-select
|
||||||
.custom-multiselect-dropdown {
|
.custom-multiselect-dropdown {
|
||||||
padding: 8px 0 0 0;
|
padding: 8px 0 0 0;
|
||||||
max-height: 500px;
|
max-height: 350px;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
overflow-x: hidden;
|
overflow-x: hidden;
|
||||||
scrollbar-width: thin;
|
scrollbar-width: thin;
|
||||||
@ -656,6 +697,10 @@ $custom-border-color: #2c3044;
|
|||||||
border: 1px solid #e8e8e8;
|
border: 1px solid #e8e8e8;
|
||||||
color: rgba(0, 0, 0, 0.85);
|
color: rgba(0, 0, 0, 0.85);
|
||||||
|
|
||||||
|
font-size: 12px !important;
|
||||||
|
height: 20px;
|
||||||
|
line-height: 18px;
|
||||||
|
|
||||||
.ant-select-selection-item-content {
|
.ant-select-selection-item-content {
|
||||||
color: rgba(0, 0, 0, 0.85);
|
color: rgba(0, 0, 0, 0.85);
|
||||||
}
|
}
|
||||||
@ -836,3 +881,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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -27,6 +27,7 @@ export interface CustomSelectProps extends Omit<SelectProps, 'options'> {
|
|||||||
errorMessage?: string | null;
|
errorMessage?: string | null;
|
||||||
allowClear?: SelectProps['allowClear'];
|
allowClear?: SelectProps['allowClear'];
|
||||||
onRetry?: () => void;
|
onRetry?: () => void;
|
||||||
|
showIncompleteDataMessage?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CustomTagProps {
|
export interface CustomTagProps {
|
||||||
@ -51,10 +52,12 @@ export interface CustomMultiSelectProps
|
|||||||
getPopupContainer?: (triggerNode: HTMLElement) => HTMLElement;
|
getPopupContainer?: (triggerNode: HTMLElement) => HTMLElement;
|
||||||
dropdownRender?: (menu: React.ReactElement) => React.ReactElement;
|
dropdownRender?: (menu: React.ReactElement) => React.ReactElement;
|
||||||
highlightSearch?: boolean;
|
highlightSearch?: boolean;
|
||||||
errorMessage?: string;
|
errorMessage?: string | null;
|
||||||
popupClassName?: string;
|
popupClassName?: string;
|
||||||
placement?: 'topLeft' | 'topRight' | 'bottomLeft' | 'bottomRight';
|
placement?: 'topLeft' | 'topRight' | 'bottomLeft' | 'bottomRight';
|
||||||
maxTagCount?: number;
|
maxTagCount?: number;
|
||||||
allowClear?: SelectProps['allowClear'];
|
allowClear?: SelectProps['allowClear'];
|
||||||
onRetry?: () => void;
|
onRetry?: () => void;
|
||||||
|
maxTagTextLength?: number;
|
||||||
|
showIncompleteDataMessage?: boolean;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -133,3 +133,15 @@ export const filterOptionsBySearch = (
|
|||||||
})
|
})
|
||||||
.filter(Boolean) as OptionData[];
|
.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;
|
||||||
|
};
|
||||||
|
|||||||
@ -13,7 +13,6 @@ import { isEqual } from 'lodash-es';
|
|||||||
import isEmpty from 'lodash-es/isEmpty';
|
import isEmpty from 'lodash-es/isEmpty';
|
||||||
import { useDashboard } from 'providers/Dashboard/Dashboard';
|
import { useDashboard } from 'providers/Dashboard/Dashboard';
|
||||||
import { memo, useEffect, useMemo, useRef, useState } from 'react';
|
import { memo, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { useQueryClient } from 'react-query';
|
|
||||||
import { useDispatch, useSelector } from 'react-redux';
|
import { useDispatch, useSelector } from 'react-redux';
|
||||||
import { UpdateTimeInterval } from 'store/actions';
|
import { UpdateTimeInterval } from 'store/actions';
|
||||||
import { AppState } from 'store/reducers';
|
import { AppState } from 'store/reducers';
|
||||||
@ -62,14 +61,12 @@ function GridCardGraph({
|
|||||||
const {
|
const {
|
||||||
toScrollWidgetId,
|
toScrollWidgetId,
|
||||||
setToScrollWidgetId,
|
setToScrollWidgetId,
|
||||||
variablesToGetUpdated,
|
|
||||||
setDashboardQueryRangeCalled,
|
setDashboardQueryRangeCalled,
|
||||||
} = useDashboard();
|
} = useDashboard();
|
||||||
const { minTime, maxTime, selectedTime: globalSelectedInterval } = useSelector<
|
const { minTime, maxTime, selectedTime: globalSelectedInterval } = useSelector<
|
||||||
AppState,
|
AppState,
|
||||||
GlobalReducer
|
GlobalReducer
|
||||||
>((state) => state.globalTime);
|
>((state) => state.globalTime);
|
||||||
const queryClient = useQueryClient();
|
|
||||||
|
|
||||||
const handleBackNavigation = (): void => {
|
const handleBackNavigation = (): void => {
|
||||||
const searchParams = new URLSearchParams(window.location.search);
|
const searchParams = new URLSearchParams(window.location.search);
|
||||||
@ -120,11 +117,7 @@ function GridCardGraph({
|
|||||||
const isEmptyWidget =
|
const isEmptyWidget =
|
||||||
widget?.id === PANEL_TYPES.EMPTY_WIDGET || isEmpty(widget);
|
widget?.id === PANEL_TYPES.EMPTY_WIDGET || isEmpty(widget);
|
||||||
|
|
||||||
const queryEnabledCondition =
|
const queryEnabledCondition = isVisible && !isEmptyWidget && isQueryEnabled;
|
||||||
isVisible &&
|
|
||||||
!isEmptyWidget &&
|
|
||||||
isQueryEnabled &&
|
|
||||||
isEmpty(variablesToGetUpdated);
|
|
||||||
|
|
||||||
const [requestData, setRequestData] = useState<GetQueryResultsProps>(() => {
|
const [requestData, setRequestData] = useState<GetQueryResultsProps>(() => {
|
||||||
if (widget.panelTypes !== PANEL_TYPES.LIST) {
|
if (widget.panelTypes !== PANEL_TYPES.LIST) {
|
||||||
@ -163,22 +156,24 @@ function GridCardGraph({
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
// TODO [vikrantgupta25] remove this useEffect with refactor as this is prone to race condition
|
||||||
if (variablesToGetUpdated.length > 0) {
|
// this is added to tackle the case of async communication between VariableItem.tsx and GridCard.tsx
|
||||||
queryClient.cancelQueries([
|
// useEffect(() => {
|
||||||
maxTime,
|
// if (variablesToGetUpdated.length > 0) {
|
||||||
minTime,
|
// queryClient.cancelQueries([
|
||||||
globalSelectedInterval,
|
// maxTime,
|
||||||
variables,
|
// minTime,
|
||||||
widget?.query,
|
// globalSelectedInterval,
|
||||||
widget?.panelTypes,
|
// variables,
|
||||||
widget.timePreferance,
|
// widget?.query,
|
||||||
widget.fillSpans,
|
// widget?.panelTypes,
|
||||||
requestData,
|
// widget.timePreferance,
|
||||||
]);
|
// widget.fillSpans,
|
||||||
}
|
// requestData,
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// ]);
|
||||||
}, [variablesToGetUpdated]);
|
// }
|
||||||
|
// // eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
// }, [variablesToGetUpdated]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isEqual(updatedQuery, requestData.query)) {
|
if (!isEqual(updatedQuery, requestData.query)) {
|
||||||
@ -224,6 +219,15 @@ function GridCardGraph({
|
|||||||
widget.timePreferance,
|
widget.timePreferance,
|
||||||
widget.fillSpans,
|
widget.fillSpans,
|
||||||
requestData,
|
requestData,
|
||||||
|
variables
|
||||||
|
? Object.entries(variables).reduce(
|
||||||
|
(acc, [id, variable]) => ({
|
||||||
|
...acc,
|
||||||
|
[id]: variable.selectedValue,
|
||||||
|
}),
|
||||||
|
{},
|
||||||
|
)
|
||||||
|
: {},
|
||||||
...(customTimeRange && customTimeRange.startTime && customTimeRange.endTime
|
...(customTimeRange && customTimeRange.startTime && customTimeRange.endTime
|
||||||
? [customTimeRange.startTime, customTimeRange.endTime]
|
? [customTimeRange.startTime, customTimeRange.endTime]
|
||||||
: []),
|
: []),
|
||||||
|
|||||||
@ -142,6 +142,7 @@ function DynamicVariable({
|
|||||||
dynamicVariablesSelectedValue?.value,
|
dynamicVariablesSelectedValue?.value,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
const errorMessage = (error as any)?.message;
|
||||||
return (
|
return (
|
||||||
<div className="dynamic-variable-container">
|
<div className="dynamic-variable-container">
|
||||||
<CustomSelect
|
<CustomSelect
|
||||||
@ -151,14 +152,17 @@ function DynamicVariable({
|
|||||||
value: key,
|
value: key,
|
||||||
}))}
|
}))}
|
||||||
loading={isLoading}
|
loading={isLoading}
|
||||||
status={error ? 'error' : undefined}
|
status={errorMessage ? 'error' : undefined}
|
||||||
onChange={(value): void => {
|
onChange={(value): void => {
|
||||||
setSelectedAttribute(value);
|
setSelectedAttribute(value);
|
||||||
}}
|
}}
|
||||||
showSearch
|
showSearch
|
||||||
errorMessage={error as any}
|
errorMessage={errorMessage as any}
|
||||||
value={selectedAttribute || dynamicVariablesSelectedValue?.name}
|
value={selectedAttribute || dynamicVariablesSelectedValue?.name}
|
||||||
onSearch={handleSearch}
|
onSearch={handleSearch}
|
||||||
|
onRetry={(): void => {
|
||||||
|
refetch();
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
<Typography className="dynamic-variable-from-text">from</Typography>
|
<Typography className="dynamic-variable-from-text">from</Typography>
|
||||||
<Select
|
<Select
|
||||||
|
|||||||
@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -201,6 +201,16 @@
|
|||||||
.default-value-section {
|
.default-value-section {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: max-content 1fr;
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.variable-textbox-section {
|
.variable-textbox-section {
|
||||||
|
|||||||
@ -23,12 +23,15 @@ import {
|
|||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { useCallback, useEffect, useState } from 'react';
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
import { useQuery } from 'react-query';
|
import { useQuery } from 'react-query';
|
||||||
|
import { useSelector } from 'react-redux';
|
||||||
|
import { AppState } from 'store/reducers';
|
||||||
import {
|
import {
|
||||||
IDashboardVariable,
|
IDashboardVariable,
|
||||||
TSortVariableValuesType,
|
TSortVariableValuesType,
|
||||||
TVariableQueryType,
|
TVariableQueryType,
|
||||||
VariableSortTypeArr,
|
VariableSortTypeArr,
|
||||||
} from 'types/api/dashboard/getAll';
|
} from 'types/api/dashboard/getAll';
|
||||||
|
import { GlobalReducer } from 'types/reducer/globalTime';
|
||||||
import { v4 as generateUUID } from 'uuid';
|
import { v4 as generateUUID } from 'uuid';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@ -65,7 +68,7 @@ function VariableItem({
|
|||||||
variableData.description || '',
|
variableData.description || '',
|
||||||
);
|
);
|
||||||
const [queryType, setQueryType] = useState<TVariableQueryType>(
|
const [queryType, setQueryType] = useState<TVariableQueryType>(
|
||||||
variableData.type || 'QUERY',
|
variableData.type || 'DYNAMIC',
|
||||||
);
|
);
|
||||||
const [variableQueryValue, setVariableQueryValue] = useState<string>(
|
const [variableQueryValue, setVariableQueryValue] = useState<string>(
|
||||||
variableData.queryValue || '',
|
variableData.queryValue || '',
|
||||||
@ -116,15 +119,24 @@ function VariableItem({
|
|||||||
const [errorName, setErrorName] = useState<boolean>(false);
|
const [errorName, setErrorName] = useState<boolean>(false);
|
||||||
const [errorPreview, setErrorPreview] = useState<string | null>(null);
|
const [errorPreview, setErrorPreview] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const { maxTime, minTime } = useSelector<AppState, GlobalReducer>(
|
||||||
|
(state) => state.globalTime,
|
||||||
|
);
|
||||||
|
|
||||||
const { data: fieldValues } = useGetFieldValues({
|
const { data: fieldValues } = useGetFieldValues({
|
||||||
signal:
|
signal:
|
||||||
dynamicVariablesSelectedValue?.value === 'All Sources'
|
dynamicVariablesSelectedValue?.value === 'All Sources'
|
||||||
? undefined
|
? undefined
|
||||||
: (dynamicVariablesSelectedValue?.value as 'traces' | 'logs' | 'metrics'),
|
: (dynamicVariablesSelectedValue?.value?.toLowerCase() as
|
||||||
|
| 'traces'
|
||||||
|
| 'logs'
|
||||||
|
| 'metrics'),
|
||||||
name: dynamicVariablesSelectedValue?.name || '',
|
name: dynamicVariablesSelectedValue?.name || '',
|
||||||
enabled:
|
enabled:
|
||||||
!!dynamicVariablesSelectedValue?.name &&
|
!!dynamicVariablesSelectedValue?.name &&
|
||||||
!!dynamicVariablesSelectedValue?.value,
|
!!dynamicVariablesSelectedValue?.value,
|
||||||
|
startUnixMilli: minTime,
|
||||||
|
endUnixMilli: maxTime,
|
||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -156,7 +168,7 @@ function VariableItem({
|
|||||||
) {
|
) {
|
||||||
setPreviewValues(
|
setPreviewValues(
|
||||||
sortValues(
|
sortValues(
|
||||||
fieldValues.payload?.values?.stringValues || [],
|
fieldValues.payload?.normalizedValues || [],
|
||||||
variableSortType,
|
variableSortType,
|
||||||
) as never,
|
) as never,
|
||||||
);
|
);
|
||||||
@ -552,6 +564,11 @@ function VariableItem({
|
|||||||
<VariableItemRow className="default-value-section">
|
<VariableItemRow className="default-value-section">
|
||||||
<LabelContainer>
|
<LabelContainer>
|
||||||
<Typography className="typography-variables">Default Value</Typography>
|
<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>
|
</LabelContainer>
|
||||||
<CustomSelect
|
<CustomSelect
|
||||||
placeholder="Select a default value"
|
placeholder="Select a default value"
|
||||||
|
|||||||
@ -9,6 +9,7 @@ import { AppState } from 'store/reducers';
|
|||||||
import { IDashboardVariable } from 'types/api/dashboard/getAll';
|
import { IDashboardVariable } from 'types/api/dashboard/getAll';
|
||||||
import { GlobalReducer } from 'types/reducer/globalTime';
|
import { GlobalReducer } from 'types/reducer/globalTime';
|
||||||
|
|
||||||
|
import DynamicVariableSelection from './DynamicVariableSelection';
|
||||||
import {
|
import {
|
||||||
buildDependencies,
|
buildDependencies,
|
||||||
buildDependencyGraph,
|
buildDependencyGraph,
|
||||||
@ -104,7 +105,7 @@ function DashboardVariableSelection(): JSX.Element | null {
|
|||||||
id: string,
|
id: string,
|
||||||
value: IDashboardVariable['selectedValue'],
|
value: IDashboardVariable['selectedValue'],
|
||||||
allSelected: boolean,
|
allSelected: boolean,
|
||||||
// isMountedCall?: boolean,
|
haveCustomValuesSelected?: boolean,
|
||||||
// eslint-disable-next-line sonarjs/cognitive-complexity
|
// eslint-disable-next-line sonarjs/cognitive-complexity
|
||||||
): void => {
|
): void => {
|
||||||
if (id) {
|
if (id) {
|
||||||
@ -121,6 +122,7 @@ function DashboardVariableSelection(): JSX.Element | null {
|
|||||||
...oldVariables[id],
|
...oldVariables[id],
|
||||||
selectedValue: value,
|
selectedValue: value,
|
||||||
allSelected,
|
allSelected,
|
||||||
|
haveCustomValuesSelected,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
if (oldVariables?.[name]) {
|
if (oldVariables?.[name]) {
|
||||||
@ -128,6 +130,7 @@ function DashboardVariableSelection(): JSX.Element | null {
|
|||||||
...oldVariables[name],
|
...oldVariables[name],
|
||||||
selectedValue: value,
|
selectedValue: value,
|
||||||
allSelected,
|
allSelected,
|
||||||
|
haveCustomValuesSelected,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
@ -170,22 +173,22 @@ function DashboardVariableSelection(): JSX.Element | null {
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<Row style={{ display: 'flex', gap: '12px' }}>
|
||||||
{dependencyData?.hasCycle && (
|
{orderBasedSortedVariables &&
|
||||||
<Alert
|
Array.isArray(orderBasedSortedVariables) &&
|
||||||
message={`Circular dependency detected: ${dependencyData?.cycleNodes?.join(
|
orderBasedSortedVariables.length > 0 &&
|
||||||
' → ',
|
orderBasedSortedVariables.map((variable) =>
|
||||||
)}`}
|
variable.type === 'DYNAMIC' ? (
|
||||||
type="error"
|
<DynamicVariableSelection
|
||||||
showIcon
|
key={`${variable.name}${variable.id}}${variable.order}`}
|
||||||
className="cycle-error-alert"
|
existingVariables={variables}
|
||||||
/>
|
variableData={{
|
||||||
)}
|
name: variable.name,
|
||||||
<Row style={{ display: 'flex', gap: '12px' }}>
|
...variable,
|
||||||
{orderBasedSortedVariables &&
|
}}
|
||||||
Array.isArray(orderBasedSortedVariables) &&
|
onValueUpdate={onValueUpdate}
|
||||||
orderBasedSortedVariables.length > 0 &&
|
/>
|
||||||
orderBasedSortedVariables.map((variable) => (
|
) : (
|
||||||
<VariableItem
|
<VariableItem
|
||||||
key={`${variable.name}${variable.id}}${variable.order}`}
|
key={`${variable.name}${variable.id}}${variable.order}`}
|
||||||
existingVariables={variables}
|
existingVariables={variables}
|
||||||
@ -198,9 +201,9 @@ function DashboardVariableSelection(): JSX.Element | null {
|
|||||||
setVariablesToGetUpdated={setVariablesToGetUpdated}
|
setVariablesToGetUpdated={setVariablesToGetUpdated}
|
||||||
dependencyData={dependencyData}
|
dependencyData={dependencyData}
|
||||||
/>
|
/>
|
||||||
))}
|
),
|
||||||
</Row>
|
)}
|
||||||
</>
|
</Row>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -0,0 +1,385 @@
|
|||||||
|
/* eslint-disable sonarjs/cognitive-complexity */
|
||||||
|
/* eslint-disable no-nested-ternary */
|
||||||
|
import './DashboardVariableSelection.styles.scss';
|
||||||
|
|
||||||
|
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 } 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 [tempSelection, setTempSelection] = useState<
|
||||||
|
string | string[] | undefined
|
||||||
|
>(undefined);
|
||||||
|
|
||||||
|
// 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,
|
||||||
|
);
|
||||||
|
|
||||||
|
const { isLoading, refetch } = useQuery(
|
||||||
|
[
|
||||||
|
REACT_QUERY_KEY.DASHBOARD_BY_ID,
|
||||||
|
variableData.name || `variable_${variableData.id}`,
|
||||||
|
dynamicVariablesKey,
|
||||||
|
minTime,
|
||||||
|
maxTime,
|
||||||
|
],
|
||||||
|
{
|
||||||
|
enabled: variableData.type === 'DYNAMIC',
|
||||||
|
queryFn: () =>
|
||||||
|
getFieldValues(
|
||||||
|
variableData.dynamicVariablesSource?.toLowerCase() === 'all sources'
|
||||||
|
? undefined
|
||||||
|
: (variableData.dynamicVariablesSource?.toLowerCase() as
|
||||||
|
| 'traces'
|
||||||
|
| 'logs'
|
||||||
|
| 'metrics'),
|
||||||
|
variableData.dynamicVariablesAttribute,
|
||||||
|
debouncedApiSearchText,
|
||||||
|
minTime,
|
||||||
|
maxTime,
|
||||||
|
),
|
||||||
|
onSuccess: (data) => {
|
||||||
|
setOptionsData(data.payload?.normalizedValues || []);
|
||||||
|
setIsComplete(data.payload?.complete || false);
|
||||||
|
setFilteredOptionsData(data.payload?.normalizedValues || []);
|
||||||
|
},
|
||||||
|
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))
|
||||||
|
) {
|
||||||
|
onValueUpdate(variableData.name, variableData.id, optionsData, true);
|
||||||
|
} else {
|
||||||
|
onValueUpdate(
|
||||||
|
variableData.name,
|
||||||
|
variableData.id,
|
||||||
|
value,
|
||||||
|
optionsData.every((v) => value.includes(v.toString())),
|
||||||
|
Array.isArray(value) &&
|
||||||
|
!value.every((v) => optionsData.includes(v.toString())),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[variableData, onValueUpdate, optionsData],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (
|
||||||
|
variableData.dynamicVariablesSource &&
|
||||||
|
variableData.dynamicVariablesAttribute
|
||||||
|
) {
|
||||||
|
refetch();
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
refetch,
|
||||||
|
variableData.dynamicVariablesSource,
|
||||||
|
variableData.dynamicVariablesAttribute,
|
||||||
|
debouncedApiSearchText,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const handleSearch = useCallback(
|
||||||
|
(text: string) => {
|
||||||
|
if (isComplete) {
|
||||||
|
if (!text) {
|
||||||
|
setFilteredOptionsData(optionsData);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const localFilteredOptionsData: (string | number | boolean)[] = [];
|
||||||
|
optionsData.forEach((option) => {
|
||||||
|
if (option.toString().toLowerCase().includes(text.toLowerCase())) {
|
||||||
|
localFilteredOptionsData.push(option);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
setFilteredOptionsData(localFilteredOptionsData);
|
||||||
|
} else {
|
||||||
|
setApiSearchText(text);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[isComplete, optionsData],
|
||||||
|
);
|
||||||
|
|
||||||
|
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 = (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) {
|
||||||
|
if (isUndefined(tempSelection) && selectValue === ALL_SELECT_VALUE) {
|
||||||
|
// set all options from the optionsData and the selectedValue, make sure to remove duplicates
|
||||||
|
const allOptions = [
|
||||||
|
...new Set([
|
||||||
|
...optionsData.map((option) => option.toString()),
|
||||||
|
...(variableData.selectedValue
|
||||||
|
? Array.isArray(variableData.selectedValue)
|
||||||
|
? variableData.selectedValue.map((v) => v.toString())
|
||||||
|
: [variableData.selectedValue.toString()]
|
||||||
|
: []),
|
||||||
|
]),
|
||||||
|
];
|
||||||
|
setTempSelection(allOptions);
|
||||||
|
} else {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 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,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="variable-item">
|
||||||
|
<Typography.Text className="variable-name" ellipsis>
|
||||||
|
${variableData.name}
|
||||||
|
</Typography.Text>
|
||||||
|
<div className="variable-value">
|
||||||
|
{variableData.multiSelect ? (
|
||||||
|
<CustomMultiSelect
|
||||||
|
key={
|
||||||
|
selectValue && Array.isArray(selectValue)
|
||||||
|
? selectValue.join(' ')
|
||||||
|
: selectValue || variableData.id
|
||||||
|
}
|
||||||
|
options={filteredOptionsData.map((option) => ({
|
||||||
|
label: option.toString(),
|
||||||
|
value: option.toString(),
|
||||||
|
}))}
|
||||||
|
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 => (
|
||||||
|
<Tooltip title={omittedValues.map(({ value }) => value).join(', ')}>
|
||||||
|
<span>+ {omittedValues.length} </span>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
onClear={(): void => {
|
||||||
|
handleChange([]);
|
||||||
|
}}
|
||||||
|
enableAllSelection={enableSelectAll}
|
||||||
|
maxTagTextLength={30}
|
||||||
|
onSearch={handleSearch}
|
||||||
|
onRetry={(): void => {
|
||||||
|
refetch();
|
||||||
|
}}
|
||||||
|
showIncompleteDataMessage={!isComplete && filteredOptionsData.length > 0}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<CustomSelect
|
||||||
|
key={
|
||||||
|
selectValue && Array.isArray(selectValue)
|
||||||
|
? selectValue.join(' ')
|
||||||
|
: selectValue || 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={filteredOptionsData.map((option) => ({
|
||||||
|
label: option.toString(),
|
||||||
|
value: option.toString(),
|
||||||
|
}))}
|
||||||
|
value={selectValue}
|
||||||
|
defaultValue={variableData.defaultValue}
|
||||||
|
errorMessage={errorMessage}
|
||||||
|
onSearch={handleSearch}
|
||||||
|
onRetry={(): void => {
|
||||||
|
refetch();
|
||||||
|
}}
|
||||||
|
showIncompleteDataMessage={!isComplete && filteredOptionsData.length > 0}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default DynamicVariableSelection;
|
||||||
@ -8,23 +8,14 @@ import './DashboardVariableSelection.styles.scss';
|
|||||||
|
|
||||||
import { orange } from '@ant-design/colors';
|
import { orange } from '@ant-design/colors';
|
||||||
import { InfoCircleOutlined, WarningOutlined } from '@ant-design/icons';
|
import { InfoCircleOutlined, WarningOutlined } from '@ant-design/icons';
|
||||||
import {
|
import { Input, Popover, Tooltip, Typography } from 'antd';
|
||||||
Checkbox,
|
|
||||||
Input,
|
|
||||||
Popover,
|
|
||||||
Select,
|
|
||||||
Tag,
|
|
||||||
Tooltip,
|
|
||||||
Typography,
|
|
||||||
} from 'antd';
|
|
||||||
import { CheckboxChangeEvent } from 'antd/es/checkbox';
|
|
||||||
import dashboardVariablesQuery from 'api/dashboard/variables/dashboardVariablesQuery';
|
import dashboardVariablesQuery from 'api/dashboard/variables/dashboardVariablesQuery';
|
||||||
|
import { CustomMultiSelect, CustomSelect } from 'components/NewSelect';
|
||||||
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
|
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
|
||||||
import { commaValuesParser } from 'lib/dashbaordVariables/customCommaValuesParser';
|
import { commaValuesParser } from 'lib/dashbaordVariables/customCommaValuesParser';
|
||||||
import sortValues from 'lib/dashbaordVariables/sortVariableValues';
|
import sortValues from 'lib/dashbaordVariables/sortVariableValues';
|
||||||
import { debounce, isArray, isString } from 'lodash-es';
|
import { debounce, isArray, isEmpty, isString } from 'lodash-es';
|
||||||
import map from 'lodash-es/map';
|
import { memo, useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
import { ChangeEvent, memo, useEffect, useMemo, useState } from 'react';
|
|
||||||
import { useQuery } from 'react-query';
|
import { useQuery } from 'react-query';
|
||||||
import { useSelector } from 'react-redux';
|
import { useSelector } from 'react-redux';
|
||||||
import { AppState } from 'store/reducers';
|
import { AppState } from 'store/reducers';
|
||||||
@ -33,17 +24,10 @@ import { VariableResponseProps } from 'types/api/dashboard/variables/query';
|
|||||||
import { GlobalReducer } from 'types/reducer/globalTime';
|
import { GlobalReducer } from 'types/reducer/globalTime';
|
||||||
import { popupContainer } from 'utils/selectPopupContainer';
|
import { popupContainer } from 'utils/selectPopupContainer';
|
||||||
|
|
||||||
import { variablePropsToPayloadVariables } from '../utils';
|
import { ALL_SELECT_VALUE, variablePropsToPayloadVariables } from '../utils';
|
||||||
import { SelectItemStyle } from './styles';
|
import { SelectItemStyle } from './styles';
|
||||||
import { areArraysEqual, checkAPIInvocation, IDependencyData } from './util';
|
import { areArraysEqual, checkAPIInvocation, IDependencyData } from './util';
|
||||||
|
|
||||||
const ALL_SELECT_VALUE = '__ALL__';
|
|
||||||
|
|
||||||
enum ToggleTagValue {
|
|
||||||
Only = 'Only',
|
|
||||||
All = 'All',
|
|
||||||
}
|
|
||||||
|
|
||||||
interface VariableItemProps {
|
interface VariableItemProps {
|
||||||
variableData: IDashboardVariable;
|
variableData: IDashboardVariable;
|
||||||
existingVariables: Record<string, IDashboardVariable>;
|
existingVariables: Record<string, IDashboardVariable>;
|
||||||
@ -58,7 +42,7 @@ interface VariableItemProps {
|
|||||||
dependencyData: IDependencyData | null;
|
dependencyData: IDependencyData | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const getSelectValue = (
|
export const getSelectValue = (
|
||||||
selectedValue: IDashboardVariable['selectedValue'],
|
selectedValue: IDashboardVariable['selectedValue'],
|
||||||
variableData: IDashboardVariable,
|
variableData: IDashboardVariable,
|
||||||
): string | string[] | undefined => {
|
): string | string[] | undefined => {
|
||||||
@ -83,6 +67,9 @@ function VariableItem({
|
|||||||
const [optionsData, setOptionsData] = useState<(string | number | boolean)[]>(
|
const [optionsData, setOptionsData] = useState<(string | number | boolean)[]>(
|
||||||
[],
|
[],
|
||||||
);
|
);
|
||||||
|
const [tempSelection, setTempSelection] = useState<
|
||||||
|
string | string[] | undefined
|
||||||
|
>(undefined);
|
||||||
|
|
||||||
const { maxTime, minTime } = useSelector<AppState, GlobalReducer>(
|
const { maxTime, minTime } = useSelector<AppState, GlobalReducer>(
|
||||||
(state) => state.globalTime,
|
(state) => state.globalTime,
|
||||||
@ -146,18 +133,21 @@ function VariableItem({
|
|||||||
variableData.name &&
|
variableData.name &&
|
||||||
(validVariableUpdate() || valueNotInList || variableData.allSelected)
|
(validVariableUpdate() || valueNotInList || variableData.allSelected)
|
||||||
) {
|
) {
|
||||||
let value = variableData.selectedValue;
|
const value = variableData.selectedValue;
|
||||||
let allSelected = false;
|
let allSelected = false;
|
||||||
// The default value for multi-select is ALL and first value for
|
// The default value for multi-select is ALL and first value for
|
||||||
// single select
|
// single select
|
||||||
if (valueNotInList) {
|
// console.log(valueNotInList);
|
||||||
if (variableData.multiSelect) {
|
// if (valueNotInList) {
|
||||||
value = newOptionsData;
|
// if (variableData.multiSelect) {
|
||||||
allSelected = true;
|
// value = newOptionsData;
|
||||||
} else {
|
// allSelected = true;
|
||||||
[value] = newOptionsData;
|
// } else {
|
||||||
}
|
// [value] = newOptionsData;
|
||||||
} else if (variableData.multiSelect) {
|
// }
|
||||||
|
// } else
|
||||||
|
|
||||||
|
if (variableData.multiSelect) {
|
||||||
const { selectedValue } = variableData;
|
const { selectedValue } = variableData;
|
||||||
allSelected =
|
allSelected =
|
||||||
newOptionsData.length > 0 &&
|
newOptionsData.length > 0 &&
|
||||||
@ -242,26 +232,57 @@ function VariableItem({
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleChange = (inputValue: string | string[]): void => {
|
const handleChange = useCallback(
|
||||||
const value = variableData.multiSelect && !inputValue ? [] : inputValue;
|
(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 (
|
if (
|
||||||
value === ALL_SELECT_VALUE ||
|
value === variableData.selectedValue ||
|
||||||
(Array.isArray(value) && value.includes(ALL_SELECT_VALUE))
|
(Array.isArray(value) &&
|
||||||
|
Array.isArray(variableData.selectedValue) &&
|
||||||
|
areArraysEqual(value, variableData.selectedValue))
|
||||||
) {
|
) {
|
||||||
onValueUpdate(variableData.name, variableData.id, optionsData, true);
|
return;
|
||||||
} else {
|
|
||||||
onValueUpdate(variableData.name, variableData.id, value, false);
|
|
||||||
}
|
}
|
||||||
|
if (variableData.name) {
|
||||||
|
if (
|
||||||
|
value === ALL_SELECT_VALUE ||
|
||||||
|
(Array.isArray(value) && value.includes(ALL_SELECT_VALUE))
|
||||||
|
) {
|
||||||
|
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,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
// 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 +302,58 @@ function VariableItem({
|
|||||||
? 'ALL'
|
? 'ALL'
|
||||||
: selectedValueStringified;
|
: selectedValueStringified;
|
||||||
|
|
||||||
const mode: 'multiple' | undefined =
|
// Apply default value on first render if no selection exists
|
||||||
variableData.multiSelect && !variableData.allSelected
|
// eslint-disable-next-line sonarjs/cognitive-complexity
|
||||||
? 'multiple'
|
const finalSelectedValues = useMemo(() => {
|
||||||
: undefined;
|
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(() => {
|
useEffect(() => {
|
||||||
// Fetch options for CUSTOM Type
|
// Fetch options for CUSTOM Type
|
||||||
@ -294,113 +363,6 @@ function VariableItem({
|
|||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [variableData.type, variableData.customValue]);
|
}, [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 (
|
return (
|
||||||
<div className="variable-item">
|
<div className="variable-item">
|
||||||
<Typography.Text className="variable-name" ellipsis>
|
<Typography.Text className="variable-name" ellipsis>
|
||||||
@ -428,105 +390,73 @@ function VariableItem({
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
!errorMessage &&
|
optionsData &&
|
||||||
optionsData && (
|
(variableData.multiSelect ? (
|
||||||
<Select
|
<CustomMultiSelect
|
||||||
key={
|
key={
|
||||||
selectValue && Array.isArray(selectValue)
|
selectValue && Array.isArray(selectValue)
|
||||||
? selectValue.join(' ')
|
? selectValue.join(' ')
|
||||||
: selectValue || variableData.id
|
: selectValue || variableData.id
|
||||||
}
|
}
|
||||||
defaultValue={selectValue}
|
options={optionsData.map((option) => ({
|
||||||
onChange={handleChange}
|
label: option.toString(),
|
||||||
|
value: option.toString(),
|
||||||
|
}))}
|
||||||
|
defaultValue={variableData.defaultValue || selectValue}
|
||||||
|
onChange={handleTempChange}
|
||||||
bordered={false}
|
bordered={false}
|
||||||
placeholder="Select value"
|
placeholder="Select value"
|
||||||
placement="bottomLeft"
|
placement="bottomLeft"
|
||||||
mode={mode}
|
|
||||||
style={SelectItemStyle}
|
style={SelectItemStyle}
|
||||||
loading={isLoading}
|
loading={isLoading}
|
||||||
showSearch
|
showSearch
|
||||||
data-testid="variable-select"
|
data-testid="variable-select"
|
||||||
className="variable-select"
|
className="variable-select"
|
||||||
popupClassName="dropdown-styles"
|
popupClassName="dropdown-styles"
|
||||||
maxTagCount={4}
|
maxTagCount={2}
|
||||||
getPopupContainer={popupContainer}
|
getPopupContainer={popupContainer}
|
||||||
// eslint-disable-next-line react/no-unstable-nested-components
|
value={tempSelection || selectValue}
|
||||||
tagRender={(props): JSX.Element => (
|
onDropdownVisibleChange={handleDropdownVisibleChange}
|
||||||
<Tag closable onClose={props.onClose}>
|
errorMessage={errorMessage}
|
||||||
{props.value}
|
|
||||||
</Tag>
|
|
||||||
)}
|
|
||||||
// eslint-disable-next-line react/no-unstable-nested-components
|
// eslint-disable-next-line react/no-unstable-nested-components
|
||||||
maxTagPlaceholder={(omittedValues): JSX.Element => (
|
maxTagPlaceholder={(omittedValues): JSX.Element => (
|
||||||
<Tooltip title={omittedValues.map(({ value }) => value).join(', ')}>
|
<Tooltip title={omittedValues.map(({ value }) => value).join(', ')}>
|
||||||
<span>+ {omittedValues.length} </span>
|
<span>+ {omittedValues.length} </span>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
)}
|
)}
|
||||||
|
onClear={(): void => {
|
||||||
|
handleChange([]);
|
||||||
|
}}
|
||||||
|
enableAllSelection={enableSelectAll}
|
||||||
|
maxTagTextLength={30}
|
||||||
allowClear={selectValue !== ALL_SELECT_VALUE && selectValue !== 'ALL'}
|
allowClear={selectValue !== ALL_SELECT_VALUE && selectValue !== 'ALL'}
|
||||||
>
|
/>
|
||||||
{enableSelectAll && (
|
) : (
|
||||||
<Select.Option data-testid="option-ALL" value={ALL_SELECT_VALUE}>
|
<CustomSelect
|
||||||
<div className="all-label" onClick={(e): void => checkAll(e as any)}>
|
key={
|
||||||
<Checkbox checked={variableData.allSelected} />
|
selectValue && Array.isArray(selectValue)
|
||||||
ALL
|
? selectValue.join(' ')
|
||||||
</div>
|
: selectValue || variableData.id
|
||||||
</Select.Option>
|
}
|
||||||
)}
|
defaultValue={variableData.defaultValue || selectValue}
|
||||||
{map(optionsData, (option) => (
|
onChange={handleChange}
|
||||||
<Select.Option
|
bordered={false}
|
||||||
data-testid={`option-${option}`}
|
placeholder="Select value"
|
||||||
key={option.toString()}
|
style={SelectItemStyle}
|
||||||
value={option}
|
loading={isLoading}
|
||||||
>
|
showSearch
|
||||||
<div
|
data-testid="variable-select"
|
||||||
className={variableData.multiSelect ? 'dropdown-checkbox-label' : ''}
|
className="variable-select"
|
||||||
>
|
popupClassName="dropdown-styles"
|
||||||
{variableData.multiSelect && (
|
getPopupContainer={popupContainer}
|
||||||
<Checkbox
|
options={optionsData.map((option) => ({
|
||||||
onChange={(e): void => {
|
label: option.toString(),
|
||||||
e.stopPropagation();
|
value: option.toString(),
|
||||||
e.preventDefault();
|
}))}
|
||||||
handleOptionSelect(e, option);
|
value={selectValue}
|
||||||
}}
|
errorMessage={errorMessage}
|
||||||
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>
|
|
||||||
|
|
||||||
{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>
|
|
||||||
)
|
|
||||||
)}
|
)}
|
||||||
{variableData.type !== 'TEXTBOX' && errorMessage && (
|
{variableData.type !== 'TEXTBOX' && errorMessage && (
|
||||||
<span style={{ margin: '0 0.5rem' }}>
|
<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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -14,3 +14,5 @@ export function variablePropsToPayloadVariables(
|
|||||||
|
|
||||||
return payloadVariables;
|
return payloadVariables;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const ALL_SELECT_VALUE = '__ALL__';
|
||||||
|
|||||||
@ -12,6 +12,10 @@ interface UseGetFieldValuesProps {
|
|||||||
value?: string;
|
value?: string;
|
||||||
/** Whether the query should be enabled */
|
/** Whether the query should be enabled */
|
||||||
enabled?: boolean;
|
enabled?: boolean;
|
||||||
|
/** Start Unix Milli */
|
||||||
|
startUnixMilli?: number;
|
||||||
|
/** End Unix Milli */
|
||||||
|
endUnixMilli?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -27,12 +31,15 @@ export const useGetFieldValues = ({
|
|||||||
signal,
|
signal,
|
||||||
name,
|
name,
|
||||||
value,
|
value,
|
||||||
|
startUnixMilli,
|
||||||
|
endUnixMilli,
|
||||||
enabled = true,
|
enabled = true,
|
||||||
}: UseGetFieldValuesProps): UseQueryResult<
|
}: UseGetFieldValuesProps): UseQueryResult<
|
||||||
SuccessResponse<FieldValueResponse> | ErrorResponse
|
SuccessResponse<FieldValueResponse> | ErrorResponse
|
||||||
> =>
|
> =>
|
||||||
useQuery<SuccessResponse<FieldValueResponse> | ErrorResponse>({
|
useQuery<SuccessResponse<FieldValueResponse> | ErrorResponse>({
|
||||||
queryKey: ['fieldValues', signal, name, value],
|
queryKey: ['fieldValues', signal, name, value, startUnixMilli, endUnixMilli],
|
||||||
queryFn: () => getFieldValues(signal, name, value),
|
queryFn: () =>
|
||||||
|
getFieldValues(signal, name, value, startUnixMilli, endUnixMilli),
|
||||||
enabled,
|
enabled,
|
||||||
});
|
});
|
||||||
|
|||||||
@ -23,7 +23,12 @@ export const getDashboardVariables = (
|
|||||||
|
|
||||||
Object.entries(variables).forEach(([, value]) => {
|
Object.entries(variables).forEach(([, value]) => {
|
||||||
if (value?.name) {
|
if (value?.name) {
|
||||||
variablesTuple[value.name] = value?.selectedValue;
|
variablesTuple[value.name] =
|
||||||
|
value?.type === 'DYNAMIC' &&
|
||||||
|
value?.allSelected &&
|
||||||
|
!value?.haveCustomValuesSelected
|
||||||
|
? '__all__'
|
||||||
|
: value?.selectedValue;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -54,6 +54,7 @@ export interface IDashboardVariable {
|
|||||||
defaultValue?: string;
|
defaultValue?: string;
|
||||||
dynamicVariablesAttribute?: string;
|
dynamicVariablesAttribute?: string;
|
||||||
dynamicVariablesSource?: string;
|
dynamicVariablesSource?: string;
|
||||||
|
haveCustomValuesSelected?: boolean;
|
||||||
}
|
}
|
||||||
export interface Dashboard {
|
export interface Dashboard {
|
||||||
id: string;
|
id: string;
|
||||||
|
|||||||
@ -2,8 +2,10 @@
|
|||||||
* Response from the field values API
|
* Response from the field values API
|
||||||
*/
|
*/
|
||||||
export interface FieldValueResponse {
|
export interface FieldValueResponse {
|
||||||
/** List of field values returned */
|
/** List of field values returned by type */
|
||||||
values: { stringValues: string[] };
|
values: Record<string, (string | boolean | number)[]>;
|
||||||
|
/** Normalized values combined from all types */
|
||||||
|
normalizedValues?: string[];
|
||||||
/** Indicates if the returned list is complete */
|
/** Indicates if the returned list is complete */
|
||||||
complete: boolean;
|
complete: boolean;
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user