Compare commits

...

2 Commits

Author SHA1 Message Date
Abhi Kumar
34342f2360 feat(dashboard): suggest variables in panel title input
Assisted-by: Claude Opus 5.5
2026-09-25 16:18:56 +05:30
Abhi Kumar
dc0de1bf2c feat(dashboard): resolve variables in panel title
Assisted-by: Claude Opus 5.5
2026-09-25 16:18:50 +05:30
18 changed files with 328 additions and 31 deletions

View File

@@ -13,10 +13,10 @@ import type { EQueryType } from 'types/common/dashboard';
import type { LegendSeries } from 'pages/DashboardPage/DashboardContainer/Panels/utils/legendSeries';
import type { TableColumnOption } from '../hooks/useTableColumns';
import ConfigActions from './ConfigActions/ConfigActions';
import PanelTitleInput from './PanelTitleInput/PanelTitleInput';
import SectionSlot from './SectionSlot/SectionSlot';
import styles from './ConfigPane.module.scss';
import { DASHBOARD_NAME_MAX_LENGTH } from '../../constants';
import { PanelKind } from '../../Panels/types/panelKind';
interface ConfigPaneProps {
@@ -94,12 +94,9 @@ function ConfigPane({
<div className={styles.group}>
<div className={styles.field}>
<Typography.Text>Title</Typography.Text>
<Input
data-testid="panel-editor-v2-title"
<PanelTitleInput
value={spec.display.name}
placeholder="Panel title"
maxLength={DASHBOARD_NAME_MAX_LENGTH}
onChange={(e): void => setDisplayField('name', e.target.value)}
onChange={(value): void => setDisplayField('name', value)}
/>
</div>

View File

@@ -0,0 +1,107 @@
import { useLayoutEffect, useMemo, useRef, useState } from 'react';
import type { KeyboardEvent } from 'react';
import { AutoComplete, Input } from 'antd';
import type { InputRef } from 'antd';
import { useDashboardVariableNames } from 'pages/DashboardPage/DashboardContainer/hooks/useDashboardVariableNames';
import { DASHBOARD_NAME_MAX_LENGTH } from '../../../constants';
import { findVariableToken, insertVariable } from './variableToken';
import styles from './PanelTitleInput.module.scss';
interface PanelTitleInputProps {
value: string;
onChange: (value: string) => void;
}
interface VariableOption {
/** Whole title after insertion, so antd's change event carries it. */
value: string;
label: string;
cursor: number;
}
function PanelTitleInput({
value,
onChange,
}: PanelTitleInputProps): JSX.Element {
const variableNames = useDashboardVariableNames();
const inputRef = useRef<InputRef>(null);
const pendingCursor = useRef<number | null>(null);
const [cursor, setCursor] = useState(0);
const [focused, setFocused] = useState(false);
const [dismissed, setDismissed] = useState(false);
const options = useMemo<VariableOption[]>(() => {
const token = findVariableToken(value, cursor);
if (!token) {
return [];
}
const query = token.query.toLowerCase();
return variableNames
.filter((name) => name.toLowerCase().startsWith(query))
.map((name) => {
const next = insertVariable(value, token, name);
return { value: next.text, label: name, cursor: next.cursor };
});
}, [value, cursor, variableNames]);
useLayoutEffect(() => {
if (pendingCursor.current === null) {
return;
}
inputRef.current?.input?.setSelectionRange(
pendingCursor.current,
pendingCursor.current,
);
setCursor(pendingCursor.current);
pendingCursor.current = null;
}, [value]);
const syncCursor = (): void => {
setCursor(inputRef.current?.input?.selectionStart ?? 0);
};
const handleChange = (next: string): void => {
const picked = options.find((option) => option.value === next);
if (picked) {
pendingCursor.current = picked.cursor;
setDismissed(true);
} else {
setDismissed(false);
syncCursor();
}
onChange(next);
};
const handleKeyDown = (event: KeyboardEvent<HTMLInputElement>): void => {
if (event.key === 'Escape') {
setDismissed(true);
}
};
return (
<AutoComplete
className={styles.autoComplete}
value={value}
maxLength={DASHBOARD_NAME_MAX_LENGTH}
options={options}
open={focused && !dismissed && options.length > 0}
filterOption={false}
onChange={handleChange}
>
<Input
ref={inputRef}
data-testid="panel-editor-v2-title"
placeholder="Panel title"
onKeyDown={handleKeyDown}
onKeyUp={syncCursor}
onClick={syncCursor}
onFocus={(): void => setFocused(true)}
onBlur={(): void => setFocused(false)}
/>
</AutoComplete>
);
}
export default PanelTitleInput;

View File

@@ -0,0 +1,51 @@
import { useState } from 'react';
import { render, screen, userEvent } from 'tests/test-utils';
import PanelTitleInput from '../PanelTitleInput';
jest.mock(
'pages/DashboardPage/DashboardContainer/hooks/useDashboardVariableNames',
() => ({
useDashboardVariableNames: (): string[] => ['service.name', 'env'],
}),
);
function Harness({ initial = '' }: { initial?: string }): JSX.Element {
const [value, setValue] = useState(initial);
return <PanelTitleInput value={value} onChange={setValue} />;
}
describe('PanelTitleInput', () => {
it('suggests no variables until a `$` is typed', async () => {
const user = userEvent.setup();
render(<Harness />);
await user.type(screen.getByTestId('panel-editor-v2-title'), 'Latency');
expect(screen.queryByText('env')).not.toBeInTheDocument();
});
it('suggests variables matching the text after `$`', async () => {
const user = userEvent.setup();
render(<Harness />);
await user.type(
screen.getByTestId('panel-editor-v2-title'),
'Latency of $se',
);
await expect(screen.findByText('service.name')).resolves.toBeInTheDocument();
expect(screen.queryByText('env')).not.toBeInTheDocument();
});
it('inserts the picked variable into the title', async () => {
const user = userEvent.setup();
render(<Harness />);
const input = screen.getByTestId('panel-editor-v2-title');
await user.type(input, 'Latency of $se');
await user.click(await screen.findByText('service.name'));
expect(input).toHaveValue('Latency of $service.name');
});
});

View File

@@ -0,0 +1,30 @@
import { findVariableToken, insertVariable } from '../variableToken';
describe('findVariableToken', () => {
it('finds the token being typed before the cursor', () => {
expect(findVariableToken('Latency of $ser', 15)).toStrictEqual({
start: 11,
query: 'ser',
});
});
it('matches a bare `$`', () => {
expect(findVariableToken('by $', 4)).toStrictEqual({ start: 3, query: '' });
});
it('ignores a token the cursor has moved past', () => {
expect(findVariableToken('$env in prod', 12)).toBeNull();
});
});
describe('insertVariable', () => {
it('replaces the whole token, including text after the cursor', () => {
const token = { start: 4, query: 'ser' };
expect(
insertVariable('p99 $service_x for', token, 'service.name'),
).toStrictEqual({
text: 'p99 $service.name for',
cursor: 17,
});
});
});

View File

@@ -0,0 +1,30 @@
const TOKEN_BEFORE_CURSOR = /\$([\w.]*)$/;
const IDENTIFIER_PREFIX = /^[\w.]*/;
export interface VariableToken {
start: number;
query: string;
}
export function findVariableToken(
text: string,
cursor: number,
): VariableToken | null {
const match = TOKEN_BEFORE_CURSOR.exec(text.slice(0, cursor));
if (!match) {
return null;
}
return { start: match.index, query: match[1] };
}
/** Also replaces identifier chars after the cursor: `$ser|vice` leaves no `vice`. */
export function insertVariable(
text: string,
token: VariableToken,
name: string,
): { text: string; cursor: number } {
const before = text.slice(0, token.start);
const after = text.slice(token.start + 1).replace(IDENTIFIER_PREFIX, '');
const inserted = `${before}$${name}`;
return { text: `${inserted}${after}`, cursor: inserted.length };
}

View File

@@ -23,6 +23,11 @@ jest.mock(
}),
);
jest.mock(
'pages/DashboardPage/DashboardContainer/hooks/useDashboardVariableNames',
() => ({ useDashboardVariableNames: (): string[] => [] }),
);
function textSpec(): DashboardtypesPanelSpecDTO {
return {
display: { name: 'Runbook', description: 'steps' },

View File

@@ -1,7 +1,6 @@
import { useMemo } from 'react';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { dtoToFormModel } from 'pages/DashboardPage/DashboardContainer/DashboardSettings/Variables/variableAdapters';
import { useDashboardFetchRequired } from 'pages/DashboardPage/DashboardContainer/hooks/useDashboardFetchRequired';
import { useDashboardVariableNames } from 'pages/DashboardPage/DashboardContainer/hooks/useDashboardVariableNames';
import type { VariableItem } from './types';
@@ -22,15 +21,7 @@ const GLOBAL_TIMESTAMP_VARIABLES: VariableItem[] = [
export function useContextLinkVariables(): VariableItem[] {
const { currentQuery } = useQueryBuilder();
const { variables: variableDtos } = useDashboardFetchRequired();
const dashboardVariableNames = useMemo(
() =>
variableDtos
.map((dto) => dtoToFormModel(dto).name)
.filter((name): name is string => !!name),
[variableDtos],
);
const dashboardVariableNames = useDashboardVariableNames();
// `_`-prefixed to match V1 and avoid colliding with dashboard-variable names.
const fieldVariableNames = useMemo(() => {

View File

@@ -12,6 +12,7 @@ import type { PanelQueryData } from 'pages/DashboardPage/DashboardContainer/quer
import type { PanelActionsConfig } from '../Panel';
import PanelActionsMenu from '../PanelActionsMenu/PanelActionsMenu';
import { EMPTY_PANEL_QUERY_DATA } from '../utils/emptyPanelQueryData';
import { usePanelTitle } from '../hooks/usePanelTitle';
import PanelHeaderSearch from './PanelHeaderSearch';
import PanelStatusPopover from '../PanelStatus/PanelStatusPopover';
import {
@@ -67,7 +68,7 @@ function PanelHeader(props: PanelHeaderProps): JSX.Element {
const { panelId, panel, panelActions, hideActions } = props;
const query = props.mode === 'query' ? props : null;
const name = panel.spec.display.name;
const name = usePanelTitle(panel);
const description = panel.spec.display.description;
const errorDetail = useMemo(
() => panelStatusFromError(query?.error),

View File

@@ -5,13 +5,13 @@ import {
DialogHeader,
DialogTitle,
} from '@signozhq/ui/dialog';
import { TooltipSimple } from '@signozhq/ui/tooltip';
import { Typography } from '@signozhq/ui/typography';
import { ConfigProvider } from 'antd';
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
import { useRef } from 'react';
import ViewPanelModalContent from './ViewPanelModalContent';
import ViewPanelModalTitle from './ViewPanelModalTitle';
import styles from './ViewPanelModal.module.scss';
interface ViewPanelModalProps {
@@ -31,8 +31,6 @@ function ViewPanelModal({
open,
onClose,
}: ViewPanelModalProps): JSX.Element {
const name = panel?.spec.display.name ?? '';
// Render antd popups into the dialog (not document.body) so they stay inside the
// modal's interactive, focus-trapped layer instead of being blocked by Radix.
const contentRef = useRef<HTMLDivElement>(null);
@@ -54,11 +52,11 @@ function ViewPanelModal({
>
<DialogHeader>
<DialogTitle>
<TooltipSimple title={name} arrow>
<Typography.Text className={styles.title}>
{name ? `${name} - (View mode)` : 'View mode'}
</Typography.Text>
</TooltipSimple>
{panel ? (
<ViewPanelModalTitle panel={panel} />
) : (
<Typography.Text className={styles.title}>View mode</Typography.Text>
)}
</DialogTitle>
</DialogHeader>
<DialogCloseButton />

View File

@@ -0,0 +1,24 @@
import { TooltipSimple } from '@signozhq/ui/tooltip';
import { Typography } from '@signozhq/ui/typography';
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
import { usePanelTitle } from '../hooks/usePanelTitle';
import styles from './ViewPanelModal.module.scss';
interface ViewPanelModalTitleProps {
panel: DashboardtypesPanelDTO;
}
function ViewPanelModalTitle({ panel }: ViewPanelModalTitleProps): JSX.Element {
const name = usePanelTitle(panel);
return (
<TooltipSimple title={name} arrow>
<Typography.Text className={styles.title}>
{name ? `${name} - (View mode)` : 'View mode'}
</Typography.Text>
</TooltipSimple>
);
}
export default ViewPanelModalTitle;

View File

@@ -1,8 +1,12 @@
import { TooltipProvider } from '@signozhq/ui/tooltip';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
import {
type DashboardtypesPanelDTO,
Querybuildertypesv5VariableTypeDTO,
} from 'api/generated/services/sigNoz.schemas';
import type { PanelQueryData } from 'pages/DashboardPage/DashboardContainer/queryV5/types';
import { useDashboardStore } from 'pages/DashboardPage/DashboardContainer/store/useDashboardStore';
import type { ReactElement } from 'react';
import type { Warning } from 'types/api';
@@ -100,6 +104,30 @@ describe('PanelHeader title and description', () => {
expect(screen.getByText('My panel')).toBeInTheDocument();
});
it('substitutes dashboard variables into the panel name', () => {
useDashboardStore.setState({
dashboardId: 'dash-1',
resolvedVariables: {
'dash-1': {
service: {
type: Querybuildertypesv5VariableTypeDTO.query,
value: ['cart', 'api'],
},
},
},
});
renderWithProvider(
<PanelHeader
{...baseProps}
panel={makePanel({ name: 'Latency of $service ({{missing}})' })}
/>,
);
expect(
screen.getByText('Latency of cart, api ({{missing}})'),
).toBeInTheDocument();
useDashboardStore.setState({ dashboardId: '', resolvedVariables: {} });
});
it('shows the description info icon when a description is provided', () => {
renderWithProvider(
<PanelHeader

View File

@@ -89,7 +89,7 @@ jest.mock(
'pages/DashboardPage/DashboardContainer/store/useDashboardStore',
() => ({
useDashboardStore: (selector: (s: unknown) => unknown): unknown =>
selector({ dashboardId: 'dash-1' }),
selector({ dashboardId: 'dash-1', resolvedVariables: {} }),
}),
);

View File

@@ -38,7 +38,7 @@ jest.mock(
'pages/DashboardPage/DashboardContainer/store/useDashboardStore',
() => ({
useDashboardStore: (selector: (s: unknown) => unknown): unknown =>
selector({ dashboardId: 'dash-1' }),
selector({ dashboardId: 'dash-1', resolvedVariables: {} }),
}),
);

View File

@@ -8,6 +8,8 @@ import type { PanelOfKind } from 'pages/DashboardPage/DashboardContainer/Panels/
import { downloadCsv } from 'pages/DashboardPage/DashboardContainer/Panels/utils/downloadCsv';
import type { PanelQueryData } from 'pages/DashboardPage/DashboardContainer/queryV5/types';
import { usePanelTitle } from './usePanelTitle';
interface UseDownloadPanelCsvArgs {
panel: DashboardtypesPanelDTO;
data: PanelQueryData;
@@ -29,7 +31,7 @@ export function useDownloadPanelCsv({
data,
canDownloadCsv,
}: UseDownloadPanelCsvArgs): () => void {
const fileName = panel.spec.display.name;
const fileName = usePanelTitle(panel);
return useCallback((): void => {
if (!canDownloadCsv) {

View File

@@ -10,6 +10,7 @@ import type { PanelQueryData } from 'pages/DashboardPage/DashboardContainer/quer
import { buildDownloadMenuItem } from '../utils/buildDownloadMenuItem';
import { useDownloadPanelCsv } from './useDownloadPanelCsv';
import { useDownloadPanelImage } from './useDownloadPanelImage';
import { usePanelTitle } from './usePanelTitle';
interface UseDownloadPanelMenuItemArgs {
panelId: string;
@@ -28,7 +29,7 @@ export function useDownloadPanelMenuItem({
data,
actions,
}: UseDownloadPanelMenuItemArgs): MenuItem | null {
const panelName = panel.spec.display.name;
const panelName = usePanelTitle(panel);
const downloadPanelCsv = useDownloadPanelCsv({
panel,
data,

View File

@@ -0,0 +1,13 @@
import { useMemo } from 'react';
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
import { interpolateVariables } from 'pages/DashboardPage/DashboardContainer/Panels/utils/interpolateVariables';
import { selectResolvedVariables } from 'pages/DashboardPage/DashboardContainer/store/slices/variableSelectionSlice';
import { useDashboardStore } from 'pages/DashboardPage/DashboardContainer/store/useDashboardStore';
export function usePanelTitle(panel: DashboardtypesPanelDTO): string {
const dashboardId = useDashboardStore((s) => s.dashboardId);
const variables = useDashboardStore(selectResolvedVariables(dashboardId));
const name = panel.spec.display.name;
return useMemo(() => interpolateVariables(name, variables), [name, variables]);
}

View File

@@ -0,0 +1,16 @@
import { useMemo } from 'react';
import { dtoToFormModel } from 'pages/DashboardPage/DashboardContainer/DashboardSettings/Variables/variableAdapters';
import { useDashboardFetchRequired } from './useDashboardFetchRequired';
export function useDashboardVariableNames(): string[] {
const { variables } = useDashboardFetchRequired();
return useMemo(
() =>
variables
.map((dto) => dtoToFormModel(dto).name)
.filter((name): name is string => !!name),
[variables],
);
}