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:
SagarRajput-7 2025-05-15 00:31:13 +05:30 committed by SagarRajput-7
parent 57c8381f68
commit 274fd8b51f
20 changed files with 1798 additions and 474 deletions

View File

@ -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,

View File

@ -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>
); );
}; };

View File

@ -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}

View File

@ -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;
}
}
}

View File

@ -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;
} }

View File

@ -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;
};

View File

@ -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]
: []), : []),

View File

@ -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

View File

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

View File

@ -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 {

View File

@ -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"

View File

@ -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>
); );
} }

View File

@ -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;

View File

@ -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' }}>

View File

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

View File

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

View File

@ -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,
}); });

View File

@ -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;
} }
}); });

View File

@ -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;

View File

@ -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;
} }