From 274fd8b51fb2b3eb50f295ab329c4ba5a25da3f7 Mon Sep 17 00:00:00 2001 From: SagarRajput-7 <162284829+SagarRajput-7@users.noreply.github.com> Date: Thu, 15 May 2025 00:31:13 +0530 Subject: [PATCH] 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 --- .../api/dynamicVariables/getFieldValues.ts | 23 + .../NewSelect/CustomMultiSelect.tsx | 370 ++++++++++------ .../src/components/NewSelect/CustomSelect.tsx | 180 ++++++-- frontend/src/components/NewSelect/styles.scss | 84 +++- frontend/src/components/NewSelect/types.ts | 5 +- frontend/src/components/NewSelect/utils.ts | 12 + .../GridCardLayout/GridCard/index.tsx | 52 +-- .../DynamicVariable/DynamicVariable.tsx | 8 +- .../__test__/DynamicVariable.test.tsx | 376 ++++++++++++++++ .../VariableItem/VariableItem.styles.scss | 10 + .../Variables/VariableItem/VariableItem.tsx | 23 +- .../DashboardVariableSelection.tsx | 43 +- .../DynamicVariableSelection.tsx | 385 +++++++++++++++++ .../VariableItem.tsx | 400 ++++++++---------- .../DynamicVariableSelection.test.tsx | 274 ++++++++++++ frontend/src/container/NewDashboard/utils.ts | 2 + .../dynamicVariables/useGetFieldValues.ts | 11 +- .../getDashboardVariables.ts | 7 +- frontend/src/types/api/dashboard/getAll.ts | 1 + .../api/dynamicVariables/getFieldValues.ts | 6 +- 20 files changed, 1798 insertions(+), 474 deletions(-) create mode 100644 frontend/src/container/NewDashboard/DashboardSettings/Variables/VariableItem/DynamicVariable/__test__/DynamicVariable.test.tsx create mode 100644 frontend/src/container/NewDashboard/DashboardVariablesSelection/DynamicVariableSelection.tsx create mode 100644 frontend/src/container/NewDashboard/DashboardVariablesSelection/__test__/DynamicVariableSelection.test.tsx diff --git a/frontend/src/api/dynamicVariables/getFieldValues.ts b/frontend/src/api/dynamicVariables/getFieldValues.ts index a79bd9029165..b8e30a098526 100644 --- a/frontend/src/api/dynamicVariables/getFieldValues.ts +++ b/frontend/src/api/dynamicVariables/getFieldValues.ts @@ -12,6 +12,8 @@ export const getFieldValues = async ( signal?: 'traces' | 'logs' | 'metrics', name?: string, value?: string, + startUnixMilli?: number, + endUnixMilli?: number, ): Promise | ErrorResponse> => { const params: Record = {}; @@ -27,8 +29,29 @@ export const getFieldValues = async ( 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 }); + // 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 { statusCode: 200, error: null, diff --git a/frontend/src/components/NewSelect/CustomMultiSelect.tsx b/frontend/src/components/NewSelect/CustomMultiSelect.tsx index 23333e032273..ca2c5ef37862 100644 --- a/frontend/src/components/NewSelect/CustomMultiSelect.tsx +++ b/frontend/src/components/NewSelect/CustomMultiSelect.tsx @@ -28,6 +28,7 @@ import { popupContainer } from 'utils/selectPopupContainer'; import { CustomMultiSelectProps, CustomTagProps, OptionData } from './types'; import { filterOptionsBySearch, + handleScrollToBottom, prioritizeOrAddOptionForMultiSelect, SPACEKEY, } from './utils'; @@ -37,7 +38,7 @@ enum ToggleTagValue { All = 'All', } -const ALL_SELECTED_VALUE = '__all__'; // Constant for the special value +const ALL_SELECTED_VALUE = '__ALL__'; // Constant for the special value const CustomMultiSelect: React.FC = ({ placeholder = 'Search...', @@ -62,6 +63,8 @@ const CustomMultiSelect: React.FC = ({ allowClear = false, onRetry, maxTagTextLength, + onDropdownVisibleChange, + showIncompleteDataMessage = false, ...rest }) => { // ===== State & Refs ===== @@ -78,6 +81,8 @@ const CustomMultiSelect: React.FC = ({ const optionRefs = useRef>({}); const [visibleOptions, setVisibleOptions] = useState([]); const isClickInsideDropdownRef = useRef(false); + const justOpenedRef = useRef(false); + const [isScrolledToBottom, setIsScrolledToBottom] = useState(false); // Convert single string value to array for consistency const selectedValues = useMemo( @@ -124,6 +129,12 @@ const CustomMultiSelect: React.FC = ({ return allAvailableValues.every((val) => selectedValues.includes(val)); }, [selectedValues, allAvailableValues, enableAllSelection]); + // Define allOptionShown earlier in the code + const allOptionShown = useMemo( + () => value === ALL_SELECTED_VALUE || value === 'ALL', + [value], + ); + // Value passed to the underlying Ant Select component const displayValue = useMemo( () => (isAllSelected ? [ALL_SELECTED_VALUE] : selectedValues), @@ -132,10 +143,18 @@ const CustomMultiSelect: React.FC = ({ // ===== Internal onChange Handler ===== const handleInternalChange = useCallback( - (newValue: string | string[]): void => { + (newValue: string | string[], directCaller?: boolean): void => { // Ensure newValue is an array const currentNewValue = Array.isArray(newValue) ? newValue : []; + if ( + (allOptionShown || isAllSelected) && + !directCaller && + currentNewValue.length === 0 + ) { + return; + } + if (!onChange) return; // Case 1: Cleared (empty array or undefined) @@ -144,7 +163,7 @@ const CustomMultiSelect: React.FC = ({ return; } - // Case 2: "__all__" is selected (means select all actual values) + // Case 2: "__ALL__" is selected (means select all actual values) if (currentNewValue.includes(ALL_SELECTED_VALUE)) { const allActualOptions = allAvailableValues.map( (v) => options.flat().find((o) => o.value === v) || { label: v, value: v }, @@ -175,7 +194,14 @@ const CustomMultiSelect: React.FC = ({ } } }, - [onChange, allAvailableValues, options, enableAllSelection], + [ + allOptionShown, + isAllSelected, + onChange, + allAvailableValues, + options, + enableAllSelection, + ], ); // ===== Existing Callbacks (potentially needing adjustment later) ===== @@ -510,11 +536,19 @@ const CustomMultiSelect: React.FC = ({ } // Normal single value handling - setSearchText(value.trim()); + const trimmedValue = value.trim(); + setSearchText(trimmedValue); if (!isOpen) { setIsOpen(true); + justOpenedRef.current = true; } - if (onSearch) onSearch(value.trim()); + + // Reset active index when search changes if dropdown is open + if (isOpen && trimmedValue) { + setActiveIndex(0); + } + + if (onSearch) onSearch(trimmedValue); }, [onSearch, isOpen, selectedValues, onChange], ); @@ -528,28 +562,34 @@ const CustomMultiSelect: React.FC = ({ (text: string, searchQuery: string): React.ReactNode => { if (!searchQuery || !highlightSearch) return text; - const parts = text.split( - new RegExp( - `(${searchQuery.replace(/[.*+?^${}()|[\\]\\]/g, '\\$&')})`, - 'gi', - ), - ); - return ( - <> - {parts.map((part, i) => { - // Create a unique key that doesn't rely on array index - const uniqueKey = `${text.substring(0, 3)}-${part.substring(0, 3)}-${i}`; + try { + const parts = text.split( + new RegExp( + `(${searchQuery.replace(/[-[\]{}()*+?.,\\^$|#\s]/g, '\\$&')})`, + 'gi', + ), + ); + return ( + <> + {parts.map((part, i) => { + // Create a unique key that doesn't rely on array index + const uniqueKey = `${text.substring(0, 3)}-${part.substring(0, 3)}-${i}`; - return part.toLowerCase() === searchQuery.toLowerCase() ? ( - - {part} - - ) : ( - part - ); - })} - - ); + return part.toLowerCase() === searchQuery.toLowerCase() ? ( + + {part} + + ) : ( + part + ); + })} + + ); + } catch (error) { + // If regex fails, return the original text without highlighting + console.error('Error in text highlighting:', error); + return text; + } }, [highlightSearch], ); @@ -560,10 +600,10 @@ const CustomMultiSelect: React.FC = ({ if (isAllSelected) { // If all are selected, deselect all - handleInternalChange([]); + handleInternalChange([], true); } else { // Otherwise, select all - handleInternalChange([ALL_SELECTED_VALUE]); + handleInternalChange([ALL_SELECTED_VALUE], true); } }, [options, isAllSelected, handleInternalChange]); @@ -738,6 +778,26 @@ const CustomMultiSelect: React.FC = ({ // Enhanced keyboard navigation with support for maxTagCount const handleKeyDown = useCallback( (e: React.KeyboardEvent): void => { + // Simple early return if ALL is selected - block all possible keyboard interactions + // that could remove the ALL tag, but still allow dropdown navigation and search + if ( + (allOptionShown || isAllSelected) && + (e.key === 'Backspace' || e.key === 'Delete') + ) { + // Only prevent default if the input is empty or cursor is at start position + const activeElement = document.activeElement as HTMLInputElement; + const isInputActive = activeElement?.tagName === 'INPUT'; + const isInputEmpty = isInputActive && !activeElement?.value; + const isCursorAtStart = + isInputActive && activeElement?.selectionStart === 0; + + if (isInputEmpty || isCursorAtStart) { + e.preventDefault(); + e.stopPropagation(); + return; + } + } + // Get flattened list of all selectable options const getFlatOptions = (): OptionData[] => { if (!visibleOptions) return []; @@ -752,7 +812,7 @@ const CustomMultiSelect: React.FC = ({ if (hasAll) { flatList.push({ label: 'ALL', - value: '__all__', // Special value for the ALL option + value: ALL_SELECTED_VALUE, // Special value for the ALL option type: 'defined', }); } @@ -784,6 +844,17 @@ const CustomMultiSelect: React.FC = ({ const flatOptions = getFlatOptions(); + // If we just opened the dropdown and have options, set first option as active + if (justOpenedRef.current && flatOptions.length > 0) { + setActiveIndex(0); + justOpenedRef.current = false; + } + + // If no option is active but we have options and dropdown is open, activate the first one + if (isOpen && activeIndex === -1 && flatOptions.length > 0) { + setActiveIndex(0); + } + // Get the active input element to check cursor position const activeElement = document.activeElement as HTMLInputElement; const isInputActive = activeElement?.tagName === 'INPUT'; @@ -1129,7 +1200,7 @@ const CustomMultiSelect: React.FC = ({ // If there's an active option in the dropdown, prioritize selecting it if (activeIndex >= 0 && activeIndex < flatOptions.length) { const selectedOption = flatOptions[activeIndex]; - if (selectedOption.value === '__all__') { + if (selectedOption.value === ALL_SELECTED_VALUE) { handleSelectAll(); } else if (selectedOption.value && onChange) { const newValues = selectedValues.includes(selectedOption.value) @@ -1159,6 +1230,10 @@ const CustomMultiSelect: React.FC = ({ e.preventDefault(); setIsOpen(false); setActiveIndex(-1); + // Call onDropdownVisibleChange when Escape is pressed to close dropdown + if (onDropdownVisibleChange) { + onDropdownVisibleChange(false); + } break; case SPACEKEY: @@ -1168,7 +1243,7 @@ const CustomMultiSelect: React.FC = ({ const selectedOption = flatOptions[activeIndex]; // Check if it's the ALL option - if (selectedOption.value === '__all__') { + if (selectedOption.value === ALL_SELECTED_VALUE) { handleSelectAll(); } else if (selectedOption.value && onChange) { const newValues = selectedValues.includes(selectedOption.value) @@ -1214,7 +1289,7 @@ const CustomMultiSelect: React.FC = ({ e.stopPropagation(); e.preventDefault(); setIsOpen(true); - setActiveIndex(0); + justOpenedRef.current = true; // Set flag to initialize active option on next render setActiveChipIndex(-1); break; @@ -1260,9 +1335,14 @@ const CustomMultiSelect: React.FC = ({ } }, [ + allOptionShown, + isAllSelected, + isOpen, + activeIndex, + getVisibleChipIndices, + getLastVisibleChipIndex, selectedChips, isSelectionMode, - isOpen, activeChipIndex, selectedValues, visibleOptions, @@ -1278,10 +1358,8 @@ const CustomMultiSelect: React.FC = ({ startSelection, selectionEnd, extendSelection, - activeIndex, + onDropdownVisibleChange, handleSelectAll, - getVisibleChipIndices, - getLastVisibleChipIndex, ], ); @@ -1306,6 +1384,14 @@ const CustomMultiSelect: React.FC = ({ setIsOpen(false); }, []); + // Add a scroll handler for the dropdown + const handleDropdownScroll = useCallback( + (e: React.UIEvent): void => { + setIsScrolledToBottom(handleScrollToBottom(e)); + }, + [], + ); + // Custom dropdown render with sections support const customDropdownRender = useCallback((): React.ReactElement => { // Process options based on current search @@ -1382,6 +1468,7 @@ const CustomMultiSelect: React.FC = ({ onMouseDown={handleDropdownMouseDown} onClick={handleDropdownClick} onKeyDown={handleKeyDown} + onScroll={handleDropdownScroll} onBlur={handleBlur} role="listbox" aria-multiselectable="true" @@ -1460,15 +1547,18 @@ const CustomMultiSelect: React.FC = ({ {/* Navigation help footer */}
- {!loading && !errorMessage && !noDataMessage && ( -
- - - - - to navigate -
- )} + {!loading && + !errorMessage && + !noDataMessage && + !(showIncompleteDataMessage && isScrolledToBottom) && ( +
+ + + + + to navigate +
+ )} {loading && (
@@ -1494,9 +1584,19 @@ const CustomMultiSelect: React.FC = ({
)} - {noDataMessage && !loading && ( -
{noDataMessage}
- )} + {showIncompleteDataMessage && + isScrolledToBottom && + !loading && + !errorMessage && ( +
+ Use search for more options +
+ )} + + {noDataMessage && + !loading && + !(showIncompleteDataMessage && isScrolledToBottom) && + !errorMessage &&
{noDataMessage}
}
); @@ -1513,6 +1613,7 @@ const CustomMultiSelect: React.FC = ({ handleDropdownMouseDown, handleDropdownClick, handleKeyDown, + handleDropdownScroll, handleBlur, activeIndex, loading, @@ -1522,8 +1623,31 @@ const CustomMultiSelect: React.FC = ({ renderOptionWithIndex, handleSelectAll, 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 ===== // Clear search when dropdown closes @@ -1588,52 +1712,9 @@ const CustomMultiSelect: React.FC = ({ const { label, value, closable, onClose } = props; // If the display value is the special ALL value, render the ALL tag - if (value === ALL_SELECTED_VALUE && isAllSelected) { - const handleAllTagClose = ( - e: React.MouseEvent | React.KeyboardEvent, - ): void => { - e.stopPropagation(); - e.preventDefault(); - handleInternalChange([]); // Clear selection when ALL tag is closed - }; - - const handleAllTagKeyDown = (e: React.KeyboardEvent): void => { - if (e.key === 'Enter' || e.key === SPACEKEY) { - handleAllTagClose(e); - } - // Prevent Backspace/Delete propagation if needed, handle in main keydown handler - }; - - return ( -
- ALL - {closable && ( - - × - - )} -
- ); + if (allOptionShown) { + // Don't render a visible tag - will be shown as placeholder + return
; } // If not isAllSelected, render individual tags using previous logic @@ -1713,52 +1794,69 @@ const CustomMultiSelect: React.FC = ({ // Fallback for safety, should not be reached return
; }, - [ - isAllSelected, - handleInternalChange, - activeChipIndex, - selectedChips, - selectedValues, - maxTagCount, - ], + // eslint-disable-next-line react-hooks/exhaustive-deps + [isAllSelected, activeChipIndex, selectedChips, selectedValues, maxTagCount], ); + // Simple onClear handler to prevent clearing ALL + const onClearHandler = useCallback((): void => { + // Skip clearing if ALL is selected + if (allOptionShown || isAllSelected) { + return; + } + + // Normal clear behavior + handleInternalChange([], true); + if (onClear) onClear(); + }, [onClear, handleInternalChange, allOptionShown, isAllSelected]); + // ===== Component Rendering ===== return ( - 0 && !isAllSelected, + 'is-all-selected': isAllSelected, + })} + placeholder={placeholder} + mode="multiple" + showSearch + filterOption={false} + onSearch={handleSearch} + value={displayValue} + onChange={(newValue): void => { + handleInternalChange(newValue, false); + }} + onClear={onClearHandler} + onDropdownVisibleChange={handleDropdownVisibleChange} + open={isOpen} + defaultActiveFirstOption={defaultActiveFirstOption} + popupMatchSelectWidth={dropdownMatchSelectWidth} + allowClear={allowClear} + getPopupContainer={getPopupContainer ?? popupContainer} + suffixIcon={} + dropdownRender={customDropdownRender} + menuItemSelectedIcon={null} + popupClassName={cx('custom-multiselect-dropdown-container', popupClassName)} + notFoundContent={
{noDataMessage}
} + onKeyDown={handleKeyDown} + tagRender={tagRender as any} + placement={placement} + listHeight={300} + searchValue={searchText} + maxTagTextLength={maxTagTextLength} + maxTagCount={isAllSelected ? undefined : maxTagCount} + {...rest} + /> +
); }; diff --git a/frontend/src/components/NewSelect/CustomSelect.tsx b/frontend/src/components/NewSelect/CustomSelect.tsx index ec9e55c31e33..696920861786 100644 --- a/frontend/src/components/NewSelect/CustomSelect.tsx +++ b/frontend/src/components/NewSelect/CustomSelect.tsx @@ -29,6 +29,7 @@ import { popupContainer } from 'utils/selectPopupContainer'; import { CustomSelectProps, OptionData } from './types'; import { filterOptionsBySearch, + handleScrollToBottom, prioritizeOrAddOptionForSingleSelect, SPACEKEY, } from './utils'; @@ -57,17 +58,29 @@ const CustomSelect: React.FC = ({ errorMessage, allowClear = false, onRetry, + showIncompleteDataMessage = false, ...rest }) => { // ===== State & Refs ===== const [isOpen, setIsOpen] = useState(false); const [searchText, setSearchText] = useState(''); const [activeOptionIndex, setActiveOptionIndex] = useState(-1); + const [isScrolledToBottom, setIsScrolledToBottom] = useState(false); // Refs for element access and scroll behavior const selectRef = useRef(null); const dropdownRef = useRef(null); const optionRefs = useRef>({}); + // Flag to track if dropdown just opened + const justOpenedRef = useRef(false); + + // Add a scroll handler for the dropdown + const handleDropdownScroll = useCallback( + (e: React.UIEvent): void => { + setIsScrolledToBottom(handleScrollToBottom(e)); + }, + [], + ); // ===== Option Filtering & Processing Utilities ===== @@ -130,23 +143,33 @@ const CustomSelect: React.FC = ({ (text: string, searchQuery: string): React.ReactNode => { if (!searchQuery || !highlightSearch) return text; - const parts = text.split(new RegExp(`(${searchQuery})`, 'gi')); - return ( - <> - {parts.map((part, i) => { - // Create a deterministic but unique key - const uniqueKey = `${text.substring(0, 3)}-${part.substring(0, 3)}-${i}`; + try { + const parts = text.split( + new RegExp( + `(${searchQuery.replace(/[-[\]{}()*+?.,\\^$|#\s]/g, '\\$&')})`, + 'gi', + ), + ); + return ( + <> + {parts.map((part, i) => { + // Create a deterministic but unique key + const uniqueKey = `${text.substring(0, 3)}-${part.substring(0, 3)}-${i}`; - return part.toLowerCase() === searchQuery.toLowerCase() ? ( - - {part} - - ) : ( - part - ); - })} - - ); + return part.toLowerCase() === searchQuery.toLowerCase() ? ( + + {part} + + ) : ( + part + ); + })} + + ); + } catch (error) { + console.error('Error in text highlighting:', error); + return text; + } }, [highlightSearch], ); @@ -246,9 +269,14 @@ const CustomSelect: React.FC = ({ const trimmedValue = value.trim(); setSearchText(trimmedValue); + // Reset active option index when search changes + if (isOpen) { + setActiveOptionIndex(0); + } + if (onSearch) onSearch(trimmedValue); }, - [onSearch], + [onSearch, isOpen], ); /** @@ -272,14 +300,23 @@ const CustomSelect: React.FC = ({ const flatList: OptionData[] = []; // Process options + let processedOptions = isEmpty(value) + ? filteredOptions + : prioritizeOrAddOptionForSingleSelect(filteredOptions, value); + + if (!isEmpty(searchText)) { + processedOptions = filterOptionsBySearch(processedOptions, searchText); + } + const { sectionOptions, nonSectionOptions } = splitOptions( - isEmpty(value) - ? filteredOptions - : prioritizeOrAddOptionForSingleSelect(filteredOptions, value), + processedOptions, ); // Add custom option if needed - if (!isEmpty(searchText) && !isLabelPresent(filteredOptions, searchText)) { + if ( + !isEmpty(searchText) && + !isLabelPresent(processedOptions, searchText) + ) { flatList.push({ label: searchText, value: searchText, @@ -300,33 +337,52 @@ const CustomSelect: React.FC = ({ const options = getFlatOptions(); + // If we just opened the dropdown and have options, set first option as active + if (justOpenedRef.current && options.length > 0) { + setActiveOptionIndex(0); + justOpenedRef.current = false; + } + + // If no option is active but we have options, activate the first one + if (activeOptionIndex === -1 && options.length > 0) { + setActiveOptionIndex(0); + } + switch (e.key) { case 'ArrowDown': e.preventDefault(); - setActiveOptionIndex((prev) => - prev < options.length - 1 ? prev + 1 : 0, - ); + if (options.length > 0) { + setActiveOptionIndex((prev) => + prev < options.length - 1 ? prev + 1 : 0, + ); + } break; case 'ArrowUp': e.preventDefault(); - setActiveOptionIndex((prev) => - prev > 0 ? prev - 1 : options.length - 1, - ); + if (options.length > 0) { + setActiveOptionIndex((prev) => + prev > 0 ? prev - 1 : options.length - 1, + ); + } break; case 'Tab': // Tab navigation with Shift key support if (e.shiftKey) { e.preventDefault(); - setActiveOptionIndex((prev) => - prev > 0 ? prev - 1 : options.length - 1, - ); + if (options.length > 0) { + setActiveOptionIndex((prev) => + prev > 0 ? prev - 1 : options.length - 1, + ); + } } else { e.preventDefault(); - setActiveOptionIndex((prev) => - prev < options.length - 1 ? prev + 1 : 0, - ); + if (options.length > 0) { + setActiveOptionIndex((prev) => + prev < options.length - 1 ? prev + 1 : 0, + ); + } } break; @@ -339,6 +395,7 @@ const CustomSelect: React.FC = ({ onChange(selectedOption.value, selectedOption); setIsOpen(false); setActiveOptionIndex(-1); + setSearchText(''); } } else if (!isEmpty(searchText)) { // Add custom value when no option is focused @@ -351,6 +408,7 @@ const CustomSelect: React.FC = ({ onChange(customOption.value, customOption); setIsOpen(false); setActiveOptionIndex(-1); + setSearchText(''); } } break; @@ -359,6 +417,7 @@ const CustomSelect: React.FC = ({ e.preventDefault(); setIsOpen(false); setActiveOptionIndex(-1); + setSearchText(''); break; case ' ': // Space key @@ -369,6 +428,7 @@ const CustomSelect: React.FC = ({ onChange(selectedOption.value, selectedOption); setIsOpen(false); setActiveOptionIndex(-1); + setSearchText(''); } } break; @@ -379,7 +439,7 @@ const CustomSelect: React.FC = ({ // Open dropdown when Down or Tab is pressed while closed e.preventDefault(); setIsOpen(true); - setActiveOptionIndex(0); + justOpenedRef.current = true; // Set flag to initialize active option on next render } }, [ @@ -444,6 +504,7 @@ const CustomSelect: React.FC = ({ className="custom-select-dropdown" onClick={handleDropdownClick} onKeyDown={handleKeyDown} + onScroll={handleDropdownScroll} role="listbox" tabIndex={-1} aria-activedescendant={ @@ -454,7 +515,6 @@ const CustomSelect: React.FC = ({
{nonSectionOptions.length > 0 && mapOptions(nonSectionOptions)}
- {/* Section options */} {sectionOptions.length > 0 && sectionOptions.map((section) => @@ -472,13 +532,16 @@ const CustomSelect: React.FC = ({ {/* Navigation help footer */}
- {!loading && !errorMessage && !noDataMessage && ( -
- - - to navigate -
- )} + {!loading && + !errorMessage && + !noDataMessage && + !(showIncompleteDataMessage && isScrolledToBottom) && ( +
+ + + to navigate +
+ )} {loading && (
@@ -504,9 +567,19 @@ const CustomSelect: React.FC = ({
)} - {noDataMessage && !loading && ( -
{noDataMessage}
- )} + {showIncompleteDataMessage && + isScrolledToBottom && + !loading && + !errorMessage && ( +
+ Use search for more options +
+ )} + + {noDataMessage && + !loading && + !(showIncompleteDataMessage && isScrolledToBottom) && + !errorMessage &&
{noDataMessage}
}
); @@ -520,6 +593,7 @@ const CustomSelect: React.FC = ({ isLabelPresent, handleDropdownClick, handleKeyDown, + handleDropdownScroll, activeOptionIndex, loading, errorMessage, @@ -527,8 +601,22 @@ const CustomSelect: React.FC = ({ dropdownRender, renderOptionWithIndex, onRetry, + showIncompleteDataMessage, + isScrolledToBottom, ]); + // Handle dropdown visibility changes + const handleDropdownVisibleChange = useCallback((visible: boolean): void => { + setIsOpen(visible); + if (visible) { + justOpenedRef.current = true; + setActiveOptionIndex(0); + } else { + setSearchText(''); + setActiveOptionIndex(-1); + } + }, []); + // ===== Side Effects ===== // Clear search text when dropdown closes @@ -582,7 +670,7 @@ const CustomSelect: React.FC = ({ onSearch={handleSearch} value={value} onChange={onChange} - onDropdownVisibleChange={setIsOpen} + onDropdownVisibleChange={handleDropdownVisibleChange} open={isOpen} options={optionsWithHighlight} defaultActiveFirstOption={defaultActiveFirstOption} diff --git a/frontend/src/components/NewSelect/styles.scss b/frontend/src/components/NewSelect/styles.scss index 7eb2d9541484..f4b15d97d3bd 100644 --- a/frontend/src/components/NewSelect/styles.scss +++ b/frontend/src/components/NewSelect/styles.scss @@ -35,6 +35,43 @@ $custom-border-color: #2c3044; width: 100%; position: relative; + &.is-all-selected { + .ant-select-selection-search-input { + caret-color: transparent; + } + + .ant-select-selection-placeholder { + opacity: 1 !important; + color: var(--bg-vanilla-400) !important; + font-weight: 500; + visibility: visible !important; + pointer-events: none; + z-index: 2; + + .lightMode & { + color: rgba(0, 0, 0, 0.85) !important; + } + } + + &.ant-select-focused .ant-select-selection-placeholder { + opacity: 0.45 !important; + } + } + + .all-selected-text { + position: absolute; + left: 12px; + top: 50%; + transform: translateY(-50%); + color: var(--bg-vanilla-400); + z-index: 1; + pointer-events: none; + + .lightMode & { + color: rgba(0, 0, 0, 0.85); + } + } + .ant-select-selector { max-height: 200px; overflow: auto; @@ -158,7 +195,7 @@ $custom-border-color: #2c3044; // Custom dropdown styles for single select .custom-select-dropdown { padding: 8px 0 0 0; - max-height: 500px; + max-height: 300px; overflow-y: auto; overflow-x: hidden; scrollbar-width: thin; @@ -276,6 +313,10 @@ $custom-border-color: #2c3044; font-size: 12px; } + .navigation-text-incomplete { + color: var(--bg-amber-600) !important; + } + .navigation-error { .navigation-text, .navigation-icons { @@ -322,7 +363,7 @@ $custom-border-color: #2c3044; // Custom dropdown styles for multi-select .custom-multiselect-dropdown { padding: 8px 0 0 0; - max-height: 500px; + max-height: 350px; overflow-y: auto; overflow-x: hidden; scrollbar-width: thin; @@ -656,6 +697,10 @@ $custom-border-color: #2c3044; border: 1px solid #e8e8e8; color: rgba(0, 0, 0, 0.85); + font-size: 12px !important; + height: 20px; + line-height: 18px; + .ant-select-selection-item-content { color: rgba(0, 0, 0, 0.85); } @@ -836,3 +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; + } + } +} diff --git a/frontend/src/components/NewSelect/types.ts b/frontend/src/components/NewSelect/types.ts index 27ebc6d3300d..884197bc1952 100644 --- a/frontend/src/components/NewSelect/types.ts +++ b/frontend/src/components/NewSelect/types.ts @@ -27,6 +27,7 @@ export interface CustomSelectProps extends Omit { errorMessage?: string | null; allowClear?: SelectProps['allowClear']; onRetry?: () => void; + showIncompleteDataMessage?: boolean; } export interface CustomTagProps { @@ -51,10 +52,12 @@ export interface CustomMultiSelectProps getPopupContainer?: (triggerNode: HTMLElement) => HTMLElement; dropdownRender?: (menu: React.ReactElement) => React.ReactElement; highlightSearch?: boolean; - errorMessage?: string; + errorMessage?: string | null; popupClassName?: string; placement?: 'topLeft' | 'topRight' | 'bottomLeft' | 'bottomRight'; maxTagCount?: number; allowClear?: SelectProps['allowClear']; onRetry?: () => void; + maxTagTextLength?: number; + showIncompleteDataMessage?: boolean; } diff --git a/frontend/src/components/NewSelect/utils.ts b/frontend/src/components/NewSelect/utils.ts index 30579cd53fe2..650e8e2f7a8b 100644 --- a/frontend/src/components/NewSelect/utils.ts +++ b/frontend/src/components/NewSelect/utils.ts @@ -133,3 +133,15 @@ export const filterOptionsBySearch = ( }) .filter(Boolean) as OptionData[]; }; + +/** + * Utility function to handle dropdown scroll and detect when scrolled to bottom + * Returns true when scrolled to within 20px of the bottom + */ +export const handleScrollToBottom = ( + e: React.UIEvent, +): 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; +}; diff --git a/frontend/src/container/GridCardLayout/GridCard/index.tsx b/frontend/src/container/GridCardLayout/GridCard/index.tsx index 56585d13bc41..3e9498f5da12 100644 --- a/frontend/src/container/GridCardLayout/GridCard/index.tsx +++ b/frontend/src/container/GridCardLayout/GridCard/index.tsx @@ -13,7 +13,6 @@ import { isEqual } from 'lodash-es'; import isEmpty from 'lodash-es/isEmpty'; import { useDashboard } from 'providers/Dashboard/Dashboard'; import { memo, useEffect, useMemo, useRef, useState } from 'react'; -import { useQueryClient } from 'react-query'; import { useDispatch, useSelector } from 'react-redux'; import { UpdateTimeInterval } from 'store/actions'; import { AppState } from 'store/reducers'; @@ -62,14 +61,12 @@ function GridCardGraph({ const { toScrollWidgetId, setToScrollWidgetId, - variablesToGetUpdated, setDashboardQueryRangeCalled, } = useDashboard(); const { minTime, maxTime, selectedTime: globalSelectedInterval } = useSelector< AppState, GlobalReducer >((state) => state.globalTime); - const queryClient = useQueryClient(); const handleBackNavigation = (): void => { const searchParams = new URLSearchParams(window.location.search); @@ -120,11 +117,7 @@ function GridCardGraph({ const isEmptyWidget = widget?.id === PANEL_TYPES.EMPTY_WIDGET || isEmpty(widget); - const queryEnabledCondition = - isVisible && - !isEmptyWidget && - isQueryEnabled && - isEmpty(variablesToGetUpdated); + const queryEnabledCondition = isVisible && !isEmptyWidget && isQueryEnabled; const [requestData, setRequestData] = useState(() => { if (widget.panelTypes !== PANEL_TYPES.LIST) { @@ -163,22 +156,24 @@ function GridCardGraph({ }; }); - useEffect(() => { - if (variablesToGetUpdated.length > 0) { - queryClient.cancelQueries([ - maxTime, - minTime, - globalSelectedInterval, - variables, - widget?.query, - widget?.panelTypes, - widget.timePreferance, - widget.fillSpans, - requestData, - ]); - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [variablesToGetUpdated]); + // TODO [vikrantgupta25] remove this useEffect with refactor as this is prone to race condition + // this is added to tackle the case of async communication between VariableItem.tsx and GridCard.tsx + // useEffect(() => { + // if (variablesToGetUpdated.length > 0) { + // queryClient.cancelQueries([ + // maxTime, + // minTime, + // globalSelectedInterval, + // variables, + // widget?.query, + // widget?.panelTypes, + // widget.timePreferance, + // widget.fillSpans, + // requestData, + // ]); + // } + // // eslint-disable-next-line react-hooks/exhaustive-deps + // }, [variablesToGetUpdated]); useEffect(() => { if (!isEqual(updatedQuery, requestData.query)) { @@ -224,6 +219,15 @@ function GridCardGraph({ widget.timePreferance, widget.fillSpans, requestData, + variables + ? Object.entries(variables).reduce( + (acc, [id, variable]) => ({ + ...acc, + [id]: variable.selectedValue, + }), + {}, + ) + : {}, ...(customTimeRange && customTimeRange.startTime && customTimeRange.endTime ? [customTimeRange.startTime, customTimeRange.endTime] : []), diff --git a/frontend/src/container/NewDashboard/DashboardSettings/Variables/VariableItem/DynamicVariable/DynamicVariable.tsx b/frontend/src/container/NewDashboard/DashboardSettings/Variables/VariableItem/DynamicVariable/DynamicVariable.tsx index c0786c58d4af..33f25641254a 100644 --- a/frontend/src/container/NewDashboard/DashboardSettings/Variables/VariableItem/DynamicVariable/DynamicVariable.tsx +++ b/frontend/src/container/NewDashboard/DashboardSettings/Variables/VariableItem/DynamicVariable/DynamicVariable.tsx @@ -142,6 +142,7 @@ function DynamicVariable({ dynamicVariablesSelectedValue?.value, ]); + const errorMessage = (error as any)?.message; return (
{ setSelectedAttribute(value); }} showSearch - errorMessage={error as any} + errorMessage={errorMessage as any} value={selectedAttribute || dynamicVariablesSelectedValue?.name} onSearch={handleSearch} + onRetry={(): void => { + refetch(); + }} /> from ({ + label: option.toString(), + value: option.toString(), + }))} + defaultValue={variableData.defaultValue || selectValue} + onChange={handleTempChange} bordered={false} placeholder="Select value" placement="bottomLeft" - mode={mode} style={SelectItemStyle} loading={isLoading} showSearch data-testid="variable-select" className="variable-select" popupClassName="dropdown-styles" - maxTagCount={4} + maxTagCount={2} getPopupContainer={popupContainer} - // eslint-disable-next-line react/no-unstable-nested-components - tagRender={(props): JSX.Element => ( - - {props.value} - - )} + value={tempSelection || selectValue} + onDropdownVisibleChange={handleDropdownVisibleChange} + errorMessage={errorMessage} // eslint-disable-next-line react/no-unstable-nested-components maxTagPlaceholder={(omittedValues): JSX.Element => ( value).join(', ')}> + {omittedValues.length} )} + onClear={(): void => { + handleChange([]); + }} + enableAllSelection={enableSelectAll} + maxTagTextLength={30} allowClear={selectValue !== ALL_SELECT_VALUE && selectValue !== 'ALL'} - > - {enableSelectAll && ( - -
checkAll(e as any)}> - - ALL -
-
- )} - {map(optionsData, (option) => ( - -
- {variableData.multiSelect && ( - { - e.stopPropagation(); - e.preventDefault(); - handleOptionSelect(e, option); - }} - checked={ - variableData.allSelected || - option.toString() === selectValue || - (Array.isArray(selectValue) && - selectValue?.includes(option.toString())) - } - /> - )} -
handleToggle(e as any, option as string)} - > - - {option.toString()} - - - {variableData.multiSelect && - optionState.tag === option.toString() && - optionState.visible && - ensureValidOption(option as string) && ( - - {currentToggleTagValue({ option: option as string })} - - )} -
-
-
- ))} - - ) + /> + ) : ( + ({ + label: option.toString(), + value: option.toString(), + }))} + value={selectValue} + errorMessage={errorMessage} + /> + )) )} {variableData.type !== 'TEXTBOX' && errorMessage && ( diff --git a/frontend/src/container/NewDashboard/DashboardVariablesSelection/__test__/DynamicVariableSelection.test.tsx b/frontend/src/container/NewDashboard/DashboardVariablesSelection/__test__/DynamicVariableSelection.test.tsx new file mode 100644 index 000000000000..add550e306fe --- /dev/null +++ b/frontend/src/container/NewDashboard/DashboardVariablesSelection/__test__/DynamicVariableSelection.test.tsx @@ -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 = { + 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( + , + ); + + // 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( + , + ); + + // 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( + , + ); + + // 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( + , + ); + + // 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( + , + ); + + // 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( + , + ); + + // 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'); + }); +}); diff --git a/frontend/src/container/NewDashboard/utils.ts b/frontend/src/container/NewDashboard/utils.ts index a023425d7286..15761994efe6 100644 --- a/frontend/src/container/NewDashboard/utils.ts +++ b/frontend/src/container/NewDashboard/utils.ts @@ -14,3 +14,5 @@ export function variablePropsToPayloadVariables( return payloadVariables; } + +export const ALL_SELECT_VALUE = '__ALL__'; diff --git a/frontend/src/hooks/dynamicVariables/useGetFieldValues.ts b/frontend/src/hooks/dynamicVariables/useGetFieldValues.ts index 7cec5902c3c6..d58370a7f3d9 100644 --- a/frontend/src/hooks/dynamicVariables/useGetFieldValues.ts +++ b/frontend/src/hooks/dynamicVariables/useGetFieldValues.ts @@ -12,6 +12,10 @@ interface UseGetFieldValuesProps { value?: string; /** Whether the query should be enabled */ enabled?: boolean; + /** Start Unix Milli */ + startUnixMilli?: number; + /** End Unix Milli */ + endUnixMilli?: number; } /** @@ -27,12 +31,15 @@ export const useGetFieldValues = ({ signal, name, value, + startUnixMilli, + endUnixMilli, enabled = true, }: UseGetFieldValuesProps): UseQueryResult< SuccessResponse | ErrorResponse > => useQuery | ErrorResponse>({ - queryKey: ['fieldValues', signal, name, value], - queryFn: () => getFieldValues(signal, name, value), + queryKey: ['fieldValues', signal, name, value, startUnixMilli, endUnixMilli], + queryFn: () => + getFieldValues(signal, name, value, startUnixMilli, endUnixMilli), enabled, }); diff --git a/frontend/src/lib/dashbaordVariables/getDashboardVariables.ts b/frontend/src/lib/dashbaordVariables/getDashboardVariables.ts index eba52f029f8f..cba2e597e101 100644 --- a/frontend/src/lib/dashbaordVariables/getDashboardVariables.ts +++ b/frontend/src/lib/dashbaordVariables/getDashboardVariables.ts @@ -23,7 +23,12 @@ export const getDashboardVariables = ( Object.entries(variables).forEach(([, value]) => { if (value?.name) { - variablesTuple[value.name] = value?.selectedValue; + variablesTuple[value.name] = + value?.type === 'DYNAMIC' && + value?.allSelected && + !value?.haveCustomValuesSelected + ? '__all__' + : value?.selectedValue; } }); diff --git a/frontend/src/types/api/dashboard/getAll.ts b/frontend/src/types/api/dashboard/getAll.ts index 2802696b2932..0318ecf60f9c 100644 --- a/frontend/src/types/api/dashboard/getAll.ts +++ b/frontend/src/types/api/dashboard/getAll.ts @@ -54,6 +54,7 @@ export interface IDashboardVariable { defaultValue?: string; dynamicVariablesAttribute?: string; dynamicVariablesSource?: string; + haveCustomValuesSelected?: boolean; } export interface Dashboard { id: string; diff --git a/frontend/src/types/api/dynamicVariables/getFieldValues.ts b/frontend/src/types/api/dynamicVariables/getFieldValues.ts index 54aec3890f75..65156809acd9 100644 --- a/frontend/src/types/api/dynamicVariables/getFieldValues.ts +++ b/frontend/src/types/api/dynamicVariables/getFieldValues.ts @@ -2,8 +2,10 @@ * Response from the field values API */ export interface FieldValueResponse { - /** List of field values returned */ - values: { stringValues: string[] }; + /** List of field values returned by type */ + values: Record; + /** Normalized values combined from all types */ + normalizedValues?: string[]; /** Indicates if the returned list is complete */ complete: boolean; }