mirror of
https://github.com/hyperdxio/hyperdx
synced 2026-04-21 13:37:15 +00:00
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:
parent
a13b60d0ef
commit
2491c2a603
4 changed files with 214 additions and 41 deletions
5
.changeset/cool-otters-arrive.md
Normal file
5
.changeset/cool-otters-arrive.md
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
"@hyperdx/app": patch
|
||||
---
|
||||
|
||||
fix: Prevent metric name validation on markdown chart
|
||||
|
|
@ -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),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue