fix: Prevent metric name validation on markdown chart (#1859)

## Summary



This PR fixes an issue that prevented markdown dashboard tiles from being saved when a metric source was selected on a separate tab (without a metricName being selected first).

The issue occurred because the metric name validation was running even for markdown tiles, which do not need to have a source, nor metric names.

### Screenshots or video

Before:

1. Select a metric source in the dashboard chart editor
2. Switch to the markdown tab
3. Attempt to save
4. **Nothing happens**

After:

1. Select a metric source in the dashboard chart editor
2. Switch to the markdown tab
3. Attempt to save
4. The tile saves

### How to test locally or on Vercel

This can be tested in the preview environment following the steps above to reproduce

### References



- Linear Issue: Closes HDX-3615
- Related PRs:
This commit is contained in:
Drew Davis 2026-03-06 10:18:29 -05:00 committed by GitHub
parent a13b60d0ef
commit 2491c2a603
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 214 additions and 41 deletions

View file

@ -0,0 +1,5 @@
---
"@hyperdx/app": patch
---
fix: Prevent metric name validation on markdown chart

View file

@ -4,15 +4,18 @@ import type {
RawSqlSavedChartConfig,
TSource,
} from '@hyperdx/common-utils/dist/types';
import { DisplayType, SourceKind } from '@hyperdx/common-utils/dist/types';
import { DEFAULT_CHART_CONFIG } from '@/ChartUtils';
import {
DisplayType,
MetricsDataType,
SourceKind,
} from '@hyperdx/common-utils/dist/types';
import type { ChartEditorFormState } from '../types';
import {
convertFormStateToChartConfig,
convertFormStateToSavedChartConfig,
convertSavedChartConfigToFormState,
validateMetricNames,
} from '../utils';
jest.mock('../../SearchInput', () => ({
@ -403,3 +406,151 @@ describe('convertSavedChartConfigToFormState', () => {
expect(result.where).toBe('status = 200');
});
});
describe('validateMetricNames', () => {
const metricSeriesItem = {
...seriesItem,
metricType: MetricsDataType.Gauge,
metricName: 'cpu.usage',
};
it('returns false when tableSource is undefined', () => {
const setError = jest.fn();
const result = validateMetricNames(
undefined,
[metricSeriesItem],
DisplayType.Line,
setError,
);
expect(result).toBe(false);
expect(setError).not.toHaveBeenCalled();
});
it('returns false when tableSource is not a metric source', () => {
const setError = jest.fn();
const result = validateMetricNames(
logSource,
[metricSeriesItem],
DisplayType.Line,
setError,
);
expect(result).toBe(false);
expect(setError).not.toHaveBeenCalled();
});
it('returns false when series is undefined', () => {
const setError = jest.fn();
const result = validateMetricNames(
metricSource,
undefined,
DisplayType.Line,
setError,
);
expect(result).toBe(false);
expect(setError).not.toHaveBeenCalled();
});
it('returns false when displayType is Markdown', () => {
const setError = jest.fn();
const seriesWithoutName = {
...seriesItem,
metricType: MetricsDataType.Gauge,
};
const result = validateMetricNames(
metricSource,
[seriesWithoutName],
DisplayType.Markdown,
setError,
);
expect(result).toBe(false);
expect(setError).not.toHaveBeenCalled();
});
it('returns false when all series items with metricType have a metricName', () => {
const setError = jest.fn();
const result = validateMetricNames(
metricSource,
[metricSeriesItem],
DisplayType.Line,
setError,
);
expect(result).toBe(false);
expect(setError).not.toHaveBeenCalled();
});
it('returns false when series items have no metricType', () => {
const setError = jest.fn();
const result = validateMetricNames(
metricSource,
[seriesItem],
DisplayType.Line,
setError,
);
expect(result).toBe(false);
expect(setError).not.toHaveBeenCalled();
});
it('returns true and calls setError when a series item has metricType but no metricName', () => {
const setError = jest.fn();
const seriesWithoutName = {
...seriesItem,
metricType: MetricsDataType.Gauge,
};
const result = validateMetricNames(
metricSource,
[seriesWithoutName],
DisplayType.Line,
setError,
);
expect(result).toBe(true);
expect(setError).toHaveBeenCalledTimes(1);
expect(setError).toHaveBeenCalledWith('series.0.metricName', {
type: 'manual',
message: 'Please select a metric name',
});
});
it('calls setError for each series item missing metricName', () => {
const setError = jest.fn();
const seriesWithoutName = {
...seriesItem,
metricType: MetricsDataType.Gauge,
};
const result = validateMetricNames(
metricSource,
[seriesWithoutName, seriesWithoutName],
DisplayType.Line,
setError,
);
expect(result).toBe(true);
expect(setError).toHaveBeenCalledTimes(2);
expect(setError).toHaveBeenCalledWith(
'series.0.metricName',
expect.any(Object),
);
expect(setError).toHaveBeenCalledWith(
'series.1.metricName',
expect.any(Object),
);
});
it('only errors on series items that are missing metricName', () => {
const setError = jest.fn();
const seriesWithoutName = {
...seriesItem,
metricType: MetricsDataType.Gauge,
};
const result = validateMetricNames(
metricSource,
[metricSeriesItem, seriesWithoutName],
DisplayType.Line,
setError,
);
expect(result).toBe(true);
expect(setError).toHaveBeenCalledTimes(1);
expect(setError).toHaveBeenCalledWith(
'series.1.metricName',
expect.any(Object),
);
});
});

View file

@ -1,4 +1,5 @@
import { omit, pick } from 'lodash';
import { FieldPath } from 'react-hook-form';
import {
isBuilderSavedChartConfig,
isRawSqlSavedChartConfig,
@ -16,7 +17,7 @@ import {
import { getStoredLanguage } from '../SearchInput';
import { ChartEditorFormState } from './types';
import { ChartEditorFormState, SavedChartConfigWithSelectArray } from './types';
function normalizeChartConfig<
C extends Pick<
@ -146,3 +147,42 @@ export function convertSavedChartConfigToFormState(
: [],
};
}
// Helper function to safely construct field paths for series
export const getSeriesFieldPath = (
namePrefix: string,
fieldName: string,
): FieldPath<ChartEditorFormState> => {
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
return `${namePrefix}${fieldName}` as FieldPath<ChartEditorFormState>;
};
// Helper function to validate metric names for metric sources
export const validateMetricNames = (
tableSource: TSource | undefined,
series: SavedChartConfigWithSelectArray['select'] | undefined,
displayType: DisplayType | undefined,
setError: (
name: FieldPath<ChartEditorFormState>,
error: { type: string; message: string },
) => void,
): boolean => {
if (
tableSource?.kind === SourceKind.Metric &&
Array.isArray(series) &&
displayType !== DisplayType.Markdown
) {
let hasValidationError = false;
series.forEach((s, index) => {
if (s.metricType && !s.metricName) {
setError(getSeriesFieldPath(`series.${index}.`, 'metricName'), {
type: 'manual',
message: 'Please select a metric name',
});
hasValidationError = true;
}
});
return hasValidationError;
}
return false;
};

View file

@ -3,7 +3,6 @@ import {
Control,
Controller,
FieldErrors,
FieldPath,
useFieldArray,
useForm,
UseFormClearErrors,
@ -125,6 +124,8 @@ import {
convertFormStateToChartConfig,
convertFormStateToSavedChartConfig,
convertSavedChartConfigToFormState,
getSeriesFieldPath,
validateMetricNames,
} from './ChartEditor/utils';
import { ErrorBoundary } from './Error/ErrorBoundary';
import MVOptimizationIndicator from './MaterializedViews/MVOptimizationIndicator';
@ -164,40 +165,6 @@ const isQueryReady = (queriedConfig: ChartConfigWithDateRange | undefined) => {
const MINIMUM_THRESHOLD_VALUE = 0.0000000001; // to make alert input > 0
// Helper function to safely construct field paths for series
const getSeriesFieldPath = (
namePrefix: string,
fieldName: string,
): FieldPath<ChartEditorFormState> => {
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
return `${namePrefix}${fieldName}` as FieldPath<ChartEditorFormState>;
};
// Helper function to validate metric names for metric sources
const validateMetricNames = (
tableSource: TSource | undefined,
series: SavedChartConfigWithSelectArray['select'] | undefined,
setError: (
name: FieldPath<ChartEditorFormState>,
error: { type: string; message: string },
) => void,
): boolean => {
if (tableSource?.kind === SourceKind.Metric && Array.isArray(series)) {
let hasValidationError = false;
series.forEach((s, index) => {
if (s.metricType && !s.metricName) {
setError(getSeriesFieldPath(`series.${index}.`, 'metricName'), {
type: 'manual',
message: 'Please select a metric name',
});
hasValidationError = true;
}
});
return hasValidationError;
}
return false;
};
type SeriesItem = NonNullable<
SavedChartConfigWithSelectArray['select']
>[number];
@ -754,7 +721,12 @@ export default function EditTimeChartForm({
if (
!isRawSqlChart &&
validateMetricNames(tableSource, form.series, setError)
validateMetricNames(
tableSource,
form.series,
form.displayType,
setError,
)
) {
return;
}
@ -834,7 +806,12 @@ export default function EditTimeChartForm({
// Validate metric sources have metric names selected
if (
!isRawSqlChart &&
validateMetricNames(tableSource, form.series, setError)
validateMetricNames(
tableSource,
form.series,
form.displayType,
setError,
)
) {
return;
}