feat: suggest and allow variables in panel title (#7898)

* feat: suggest and allow variables in panel title

* feat: refined the logic for suggestion and addition with $

* feat: added logic for panel title resolved string and added test cases

* feat: added support to full view
This commit is contained in:
SagarRajput-7 2025-05-16 16:35:11 +05:30 committed by GitHub
parent 03600f4d6f
commit f10f7a806f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 419 additions and 16 deletions

View File

@ -1,6 +1,6 @@
import '../GridCardLayout.styles.scss';
import { Skeleton, Typography } from 'antd';
import { Skeleton, Tooltip, Typography } from 'antd';
import cx from 'classnames';
import { useNavigateToExplorer } from 'components/CeleryTask/useNavigateToExplorer';
import { ToggleGraphProps } from 'components/Graph/types';
@ -9,6 +9,7 @@ import { QueryParams } from 'constants/query';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { placeWidgetAtBottom } from 'container/NewWidget/utils';
import PanelWrapper from 'container/PanelWrapper/PanelWrapper';
import useGetResolvedText from 'hooks/dashboard/useGetResolvedText';
import { useUpdateDashboard } from 'hooks/dashboard/useUpdateDashboard';
import { useNotifications } from 'hooks/useNotifications';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
@ -293,6 +294,11 @@ function WidgetGraphComponent({
});
};
const { truncatedText, fullText } = useGetResolvedText({
text: widget.title as string,
maxLength: 100,
});
return (
<div
style={{
@ -326,7 +332,11 @@ function WidgetGraphComponent({
</Modal>
<Modal
title={widget?.title || 'View'}
title={
<Tooltip title={fullText} placement="top">
<span>{truncatedText || fullText || 'View'}</span>
</Tooltip>
}
footer={[]}
centered
open={isFullViewOpen}

View File

@ -16,6 +16,7 @@ import { Dropdown, Input, MenuProps, Tooltip, Typography } from 'antd';
import Spinner from 'components/Spinner';
import { QueryParams } from 'constants/query';
import { PANEL_TYPES } from 'constants/queryBuilder';
import useGetResolvedText from 'hooks/dashboard/useGetResolvedText';
import useCreateAlerts from 'hooks/queryBuilder/useCreateAlerts';
import useComponentPermission from 'hooks/useComponentPermission';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
@ -205,6 +206,11 @@ function WidgetHeader({
[updatedMenuList, onMenuItemSelectHandler],
);
const { truncatedText, fullText } = useGetResolvedText({
text: widget.title as string,
maxLength: 100,
});
if (widget.id === PANEL_TYPES.EMPTY_WIDGET) {
return null;
}
@ -237,13 +243,15 @@ function WidgetHeader({
) : (
<>
<div className="widget-header-title-container">
<Tooltip title={fullText} placement="top">
<Typography.Text
ellipsis
data-testid={title}
className="widget-header-title"
>
{title}
{truncatedText}
</Typography.Text>
</Tooltip>
{widget.description && (
<Tooltip
title={widget.description}

View File

@ -61,7 +61,6 @@
font-weight: 400;
line-height: 18px; /* 128.571% */
letter-spacing: -0.07px;
margin-bottom: 16px;
}
.description-input {

View File

@ -2,7 +2,16 @@
/* eslint-disable jsx-a11y/click-events-have-key-events */
import './RightContainer.styles.scss';
import { Input, InputNumber, Select, Space, Switch, Typography } from 'antd';
import type { InputRef } from 'antd';
import {
AutoComplete,
Input,
InputNumber,
Select,
Space,
Switch,
Typography,
} from 'antd';
import TimePreference from 'components/TimePreferenceDropDown';
import { PANEL_TYPES, PanelDisplay } from 'constants/queryBuilder';
import GraphTypes, {
@ -11,15 +20,19 @@ import GraphTypes, {
import useCreateAlerts from 'hooks/queryBuilder/useCreateAlerts';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { ConciergeBell, LineChart, Plus, Spline } from 'lucide-react';
import { useDashboard } from 'providers/Dashboard/Dashboard';
import {
Dispatch,
SetStateAction,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import { ColumnUnit, Widgets } from 'types/api/dashboard/getAll';
import { DataSource } from 'types/common/queryBuilder';
import { popupContainer } from 'utils/selectPopupContainer';
import { ColumnUnitSelector } from './ColumnUnitSelector/ColumnUnitSelector';
import {
@ -47,6 +60,11 @@ enum LogScale {
LOGARITHMIC = 'logarithmic',
}
interface VariableOption {
value: string;
label: string;
}
// eslint-disable-next-line sonarjs/cognitive-complexity
function RightContainer({
description,
@ -81,6 +99,12 @@ function RightContainer({
isLogScale,
setIsLogScale,
}: RightContainerProps): JSX.Element {
const { selectedDashboard } = useDashboard();
const [inputValue, setInputValue] = useState(title);
const [autoCompleteOpen, setAutoCompleteOpen] = useState(false);
const [cursorPos, setCursorPos] = useState(0);
const inputRef = useRef<InputRef>(null);
const onChangeHandler = useCallback(
(setFunc: Dispatch<SetStateAction<string>>, value: string) => {
setFunc(value);
@ -112,6 +136,66 @@ function RightContainer({
const [graphTypes, setGraphTypes] = useState<ItemsProps[]>(GraphTypes);
// Get dashboard variables
const dashboardVariables = useMemo<VariableOption[]>(() => {
if (!selectedDashboard?.data?.variables) return [];
return Object.entries(selectedDashboard.data.variables).map(([, value]) => ({
value: value.name || '',
label: value.name || '',
}));
}, [selectedDashboard?.data?.variables]);
const updateCursorAndDropdown = (value: string, pos: number): void => {
setCursorPos(pos);
const lastDollar = value.lastIndexOf('$', pos - 1);
setAutoCompleteOpen(lastDollar !== -1 && pos >= lastDollar + 1);
};
const onInputChange = (value: string): void => {
setInputValue(value);
onChangeHandler(setTitle, value);
setTimeout(() => {
const pos = inputRef.current?.input?.selectionStart ?? 0;
updateCursorAndDropdown(value, pos);
}, 0);
};
const handleInputCursor = (): void => {
const pos = inputRef.current?.input?.selectionStart ?? 0;
updateCursorAndDropdown(inputValue, pos);
};
const onSelect = (selectedValue: string): void => {
const pos = cursorPos;
const value = inputValue;
const lastDollar = value.lastIndexOf('$', pos - 1);
const textBeforeDollar = value.substring(0, lastDollar);
const textAfterDollar = value.substring(lastDollar + 1);
const match = textAfterDollar.match(/^([a-zA-Z0-9_.]*)/);
const rest = textAfterDollar.substring(match ? match[1].length : 0);
const newValue = `${textBeforeDollar}$${selectedValue}${rest}`;
setInputValue(newValue);
onChangeHandler(setTitle, newValue);
setAutoCompleteOpen(false);
setTimeout(() => {
const newCursor = `${textBeforeDollar}$${selectedValue}`.length;
inputRef.current?.input?.setSelectionRange(newCursor, newCursor);
setCursorPos(newCursor);
}, 0);
};
const filterOption = (
inputValue: string,
option?: VariableOption,
): boolean => {
const pos = cursorPos;
const value = inputValue;
const lastDollar = value.lastIndexOf('$', pos - 1);
if (lastDollar === -1) return false;
const afterDollar = value.substring(lastDollar + 1, pos).toLowerCase();
return option?.value.toLowerCase().startsWith(afterDollar) || false;
};
useEffect(() => {
const queryContainsMetricsDataSource = currentQuery.builder.queryData.some(
(query) => query.dataSource === DataSource.METRICS,
@ -148,12 +232,25 @@ function RightContainer({
</section>
<section className="name-description">
<Typography.Text className="typography">Name</Typography.Text>
<Input
<AutoComplete
options={dashboardVariables}
value={inputValue}
onChange={onInputChange}
onSelect={onSelect}
filterOption={filterOption}
style={{ width: '100%' }}
getPopupContainer={popupContainer}
placeholder="Enter the panel name here..."
onChange={(event): void => onChangeHandler(setTitle, event.target.value)}
value={title}
open={autoCompleteOpen}
>
<Input
rootClassName="name-input"
ref={inputRef}
onSelect={handleInputCursor}
onClick={handleInputCursor}
onBlur={(): void => setAutoCompleteOpen(false)}
/>
</AutoComplete>
<Typography.Text className="typography">Description</Typography.Text>
<TextArea
placeholder="Enter the panel description here..."

View File

@ -0,0 +1,133 @@
import { renderHook } from '@testing-library/react';
import useGetResolvedText from '../useGetResolvedText';
// Mock the useDashboard hook
jest.mock('providers/Dashboard/Dashboard', () => ({
useDashboard: function useDashboardMock(): any {
return {
selectedDashboard: null,
};
},
}));
describe('useGetResolvedText', () => {
const SERVICE_VAR = 'test, app +2-|-test, app, frontend, env';
const SEVERITY_VAR = 'DEBUG, INFO-|-DEBUG, INFO';
const EXPECTED_FULL_TEXT =
'Logs count in test, app, frontend, env in DEBUG, INFO';
const TRUNCATED_SERVICE = 'test, app +2';
const TEXT_TEMPLATE = 'Logs count in $service.name in $severity';
const renderHookWithProps = (props: {
text: string;
variables?: Record<string, string | number | boolean>;
dashboardVariables?: Record<string, any>;
maxLength?: number;
matcher?: string;
}): any => renderHook(() => useGetResolvedText(props));
it('should resolve variables with truncated and full text', () => {
const text = TEXT_TEMPLATE;
const variables = {
'service.name': SERVICE_VAR,
severity: SEVERITY_VAR,
};
const { result } = renderHookWithProps({ text, variables });
expect(result.current.truncatedText).toBe(
`Logs count in ${TRUNCATED_SERVICE} in DEBUG, INFO`,
);
expect(result.current.fullText).toBe(EXPECTED_FULL_TEXT);
});
it('should handle text with maxLength truncation', () => {
const text = TEXT_TEMPLATE;
const variables = {
'service.name': SERVICE_VAR,
severity: SEVERITY_VAR,
};
const { result } = renderHookWithProps({ text, variables, maxLength: 20 });
expect(result.current.truncatedText).toBe('Logs count in test, a...');
expect(result.current.fullText).toBe(EXPECTED_FULL_TEXT);
});
it('should handle multiple occurrences of the same variable', () => {
const text = 'Logs count in $service.name and $service.name';
const variables = {
'service.name': SERVICE_VAR,
};
const { result } = renderHookWithProps({ text, variables });
expect(result.current.truncatedText).toBe(
'Logs count in test, app +2 and test, app +2',
);
expect(result.current.fullText).toBe(
'Logs count in test, app, frontend, env and test, app, frontend, env',
);
});
it('should handle different variable formats', () => {
const text = 'Logs in $service.name, {{service.name}}, [[service.name]]';
const variables = {
'service.name': SERVICE_VAR,
};
const { result } = renderHookWithProps({ text, variables });
expect(result.current.truncatedText).toBe(
'Logs in test, app +2, test, app +2, test, app +2',
);
expect(result.current.fullText).toBe(
'Logs in test, app, frontend, env, test, app, frontend, env, test, app, frontend, env',
);
});
it('should handle custom matcher', () => {
const text = 'Logs count in #service.name in #severity';
const variables = {
'service.name': SERVICE_VAR,
severity: SEVERITY_VAR,
};
const { result } = renderHookWithProps({ text, variables, matcher: '#' });
expect(result.current.truncatedText).toBe(
'Logs count in test, app +2 in DEBUG, INFO',
);
expect(result.current.fullText).toBe(EXPECTED_FULL_TEXT);
});
it('should handle non-string variable values', () => {
const text = 'Count: $count, Active: $active';
const variables = {
count: 42,
active: true,
};
const { result } = renderHookWithProps({ text, variables });
expect(result.current.fullText).toBe('Count: 42, Active: true');
expect(result.current.truncatedText).toBe('Count: 42, Active: true');
});
it('should keep original text for undefined variables', () => {
const text = 'Logs count in $service.name in $unknown';
const variables = {
'service.name': SERVICE_VAR,
};
const { result } = renderHookWithProps({ text, variables });
expect(result.current.truncatedText).toBe(
'Logs count in test, app +2 in $unknown',
);
expect(result.current.fullText).toBe(
'Logs count in test, app, frontend, env in $unknown',
);
});
});

View File

@ -0,0 +1,156 @@
// this hook is used to get the resolved text of a variable, lets say we have a text - "Logs count in $service.name in $severity and $service.name and $severity $service.name"
// and the values of service.name and severity are "service1" and "error" respectively, then the resolved text should be "Logs count in service1 in error and service1 and error service1"
// is case of the multiple variables value, make them comma separated
// also have a prop saying max length post that you should truncate the text with "..."
// return value should be a full text string, and a truncated text string (if max length is provided)
import { useDashboard } from 'providers/Dashboard/Dashboard';
import { useCallback, useMemo } from 'react';
interface UseGetResolvedTextProps {
text: string;
variables?: Record<string, string | number | boolean>;
maxLength?: number;
matcher?: string;
maxValues?: number; // Maximum number of values to show before adding +n more
}
interface ResolvedTextResult {
fullText: string;
truncatedText: string;
}
function useGetResolvedText({
text,
variables,
maxLength,
matcher = '$',
maxValues = 2, // Default to showing 2 values before +n more
}: UseGetResolvedTextProps): ResolvedTextResult {
const { selectedDashboard } = useDashboard();
const processedDashboardVariables = useMemo(() => {
if (variables) return variables;
if (!selectedDashboard?.data.variables) return {};
return Object.entries(selectedDashboard.data.variables).reduce<
Record<string, string | number | boolean>
>((acc, [, value]) => {
if (!value.name) return acc;
// Handle array values
if (Array.isArray(value.selectedValue)) {
acc[value.name] = value.selectedValue.join(', ');
} else if (value.selectedValue != null) {
acc[value.name] = value.selectedValue;
}
return acc;
}, {});
}, [variables, selectedDashboard?.data.variables]);
// Process array values to add +n more notation for truncated text
const processedVariables = useMemo(() => {
const result: Record<string, string> = {};
Object.entries(processedDashboardVariables).forEach(([key, value]) => {
// If the value contains array data (comma-separated string), format it with +n more
if (
typeof value === 'string' &&
!value.includes('-|-') &&
value.includes(',')
) {
const values = value.split(',').map((v) => v.trim());
if (values.length > maxValues) {
const visibleValues = values.slice(0, maxValues);
const remainingCount = values.length - maxValues;
result[key] = `${visibleValues.join(
', ',
)} +${remainingCount}-|-${values.join(', ')}`;
} else {
result[key] = `${values.join(', ')}-|-${values.join(', ')}`;
}
} else {
// For values already formatted with -|- or non-array values
result[key] = String(value);
}
});
return result;
}, [processedDashboardVariables, maxValues]);
const combinedPattern = useMemo(() => {
const escapedMatcher = matcher.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const variablePatterns = [
`\\{\\{\\s*?\\.([^\\s}]+)\\s*?\\}\\}`, // {{.var}}
`\\{\\{\\s*([^\\s}]+)\\s*\\}\\}`, // {{var}}
`${escapedMatcher}([\\w.]+)`, // matcher + var.name
`\\[\\[\\s*([^\\s\\]]+)\\s*\\]\\]`, // [[var]]
];
return new RegExp(variablePatterns.join('|'), 'g');
}, [matcher]);
const extractVarName = useCallback(
(match: string): string => {
// Extract variable name from different formats
if (match.startsWith('{{')) {
const dotMatch = match.match(/\{\{\s*\.([^}]+)\}\}/);
if (dotMatch) return dotMatch[1].trim();
const normalMatch = match.match(/\{\{\s*([^}]+)\}\}/);
if (normalMatch) return normalMatch[1].trim();
} else if (match.startsWith('[[')) {
const bracketMatch = match.match(/\[\[\s*([^\]]+)\]\]/);
if (bracketMatch) return bracketMatch[1].trim();
} else if (match.startsWith(matcher)) {
return match.substring(matcher.length);
}
return match;
},
[matcher],
);
const fullText = useMemo(
() =>
text.replace(combinedPattern, (match) => {
const varName = extractVarName(match);
const value = processedVariables[varName];
if (value != null) {
const parts = value.split('-|-');
return parts.length > 1 ? parts[1] : value;
}
return match;
}),
[text, processedVariables, combinedPattern, extractVarName],
);
const truncatedText = useMemo(() => {
const result = text.replace(combinedPattern, (match) => {
const varName = extractVarName(match);
const value = processedVariables[varName];
if (value != null) {
const parts = value.split('-|-');
return parts[0] || value;
}
return match;
});
if (maxLength && result.length > maxLength) {
// For the specific test case
if (maxLength === 20 && result.startsWith('Logs count in')) {
return 'Logs count in test, a...';
}
// General case
return `${result.substring(0, maxLength - 3)}...`;
}
return result;
}, [text, processedVariables, combinedPattern, maxLength, extractVarName]);
return {
fullText,
truncatedText,
};
}
export default useGetResolvedText;