chore: update watch uses to useWatch (#1516)

Fixes: HDX-3101

Co-authored-by: Brandon Pereira <7552738+brandon-pereira@users.noreply.github.com>
This commit is contained in:
Tom Alexander 2025-12-23 11:34:48 -05:00 committed by GitHub
parent 6537884825
commit 468eb92481
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
29 changed files with 687 additions and 428 deletions

View file

@ -0,0 +1,5 @@
---
"@hyperdx/app": patch
---
Update some forms to work better with React 19

View file

@ -8,6 +8,8 @@ import prettierConfig from 'eslint-config-prettier';
import simpleImportSort from 'eslint-plugin-simple-import-sort';
import prettierPlugin from 'eslint-plugin-prettier/recommended';
import playwrightPlugin from 'eslint-plugin-playwright';
import reactHookFormPlugin from 'eslint-plugin-react-hook-form';
import { fixupPluginRules } from '@eslint/compat';
export default [
js.configs.recommended,
@ -39,6 +41,7 @@ export default [
react: reactPlugin,
'react-hooks': reactHooksPlugin,
'simple-import-sort': simpleImportSort,
'react-hook-form': fixupPluginRules(reactHookFormPlugin), // not compatible with eslint 9 yet
},
rules: {
...nextPlugin.configs.recommended.rules,
@ -81,6 +84,7 @@ export default [
],
'react-hooks/exhaustive-deps': 'error',
'no-console': ['error', { allow: ['warn', 'error'] }],
'react-hook-form/no-use-watch': 'error',
},
languageOptions: {
parser: tseslint.parser,

View file

@ -104,6 +104,7 @@
},
"devDependencies": {
"@chromatic-com/storybook": "^4.1.3",
"@eslint/compat": "^2.0.0",
"@hookform/devtools": "^4.3.1",
"@jedmao/location": "^3.0.0",
"@playwright/test": "^1.57.0",
@ -135,6 +136,7 @@
"@types/sqlstring": "^2.3.2",
"eslint-config-next": "^16.0.10",
"eslint-plugin-playwright": "^2.4.0",
"eslint-plugin-react-hook-form": "^0.3.1",
"eslint-plugin-storybook": "10.1.4",
"identity-obj-proxy": "^3.0.0",
"jest": "^30.2.0",

View file

@ -5,7 +5,7 @@ import Link from 'next/link';
import { useRouter } from 'next/router';
import { NextSeo } from 'next-seo';
import cx from 'classnames';
import { SubmitHandler, useForm } from 'react-hook-form';
import { SubmitHandler, useForm, useWatch } from 'react-hook-form';
import {
Button,
Notification,
@ -45,7 +45,7 @@ export default function AuthPage({ action }: { action: 'register' | 'login' }) {
handleSubmit,
formState: { errors, isSubmitting },
setError,
watch,
control,
} = useForm<FormData>({
reValidateMode: 'onSubmit',
});
@ -67,8 +67,16 @@ export default function AuthPage({ action }: { action: 'register' | 'login' }) {
}
}, [installation, isRegister, router]);
const currentPassword = watch('password', '');
const confirmPassword = watch('confirmPassword', '');
const currentPassword = useWatch({
control,
name: 'password',
defaultValue: '',
});
const confirmPassword = useWatch({
control,
name: 'confirmPassword',
defaultValue: '',
});
const confirmPass = () => {
return currentPassword === confirmPassword;

View file

@ -1,4 +1,4 @@
import { useMemo, useState } from 'react';
import { useEffect, useMemo, useState } from 'react';
import dynamic from 'next/dynamic';
import {
parseAsFloat,
@ -6,7 +6,7 @@ import {
useQueryState,
useQueryStates,
} from 'nuqs';
import { useForm } from 'react-hook-form';
import { useForm, useWatch } from 'react-hook-form';
import { sql } from '@codemirror/lang-sql';
import { format as formatSql } from '@hyperdx/common-utils/dist/sqlFormatter';
import { DisplayType } from '@hyperdx/common-utils/dist/types';
@ -436,17 +436,19 @@ function ClickhousePage() {
const connection = _connection ?? connections?.[0]?.id ?? '';
const { control, watch } = useForm({
const { control } = useForm({
values: {
connection,
},
});
watch((data, { name, type }) => {
if (name === 'connection' && type === 'change') {
setConnection(data.connection ?? null);
const watchedConnection = useWatch({ control, name: 'connection' });
useEffect(() => {
if (watchedConnection !== connection) {
setConnection(watchedConnection ?? null);
}
});
}, [watchedConnection, connection, setConnection]);
const DEFAULT_INTERVAL = 'Past 1h';
const [displayedTimeInputValue, setDisplayedTimeInputValue] =
useState(DEFAULT_INTERVAL);

View file

@ -2,7 +2,7 @@ import { useCallback, useRef, useState } from 'react';
import dynamic from 'next/dynamic';
import Head from 'next/head';
import { parseAsJson, parseAsStringEnum, useQueryState } from 'nuqs';
import { useForm } from 'react-hook-form';
import { useForm, useWatch } from 'react-hook-form';
import { useHotkeys } from 'react-hotkeys-hook';
import { SavedChartConfig, SourceKind } from '@hyperdx/common-utils/dist/types';
import {
@ -57,7 +57,7 @@ function AIAssistant({
'ai-assistant-alert-dismissed',
false,
);
const { control, watch, setValue, handleSubmit } = useForm<{
const { control, setValue, handleSubmit } = useForm<{
text: string;
source: string;
}>({

View file

@ -3,7 +3,7 @@ import dynamic from 'next/dynamic';
import Head from 'next/head';
import { useRouter } from 'next/router';
import { filter } from 'lodash';
import { Controller, useForm } from 'react-hook-form';
import { Controller, useForm, useWatch } from 'react-hook-form';
import { StringParam, useQueryParam } from 'use-query-params';
import { z } from 'zod';
import { zodResolver } from '@hookform/resolvers/zod';
@ -191,7 +191,7 @@ function Mapping({ input }: { input: Input }) {
const { data: sources } = useSources();
const [dashboardId] = useQueryParam('dashboardId', StringParam);
const { handleSubmit, getFieldState, control, setValue, watch } =
const { handleSubmit, getFieldState, control, setValue } =
useForm<SourceResolutionFormValues>({
resolver: zodResolver(SourceResolutionForm),
defaultValues: {
@ -226,16 +226,24 @@ function Mapping({ input }: { input: Input }) {
}, [setValue, sources, input]);
const isUpdatingRef = useRef(false);
watch((a, { name }) => {
if (isUpdatingRef.current) return;
if (!a.sourceMappings || !input.tiles) return;
const [, inputIdx] = name?.split('.') || [];
if (!inputIdx) return;
const sourceMappings = useWatch({ control, name: 'sourceMappings' });
const prevSourceMappingsRef = useRef(sourceMappings);
const idx = Number(inputIdx);
const inputTile = input.tiles[idx];
useEffect(() => {
if (isUpdatingRef.current) return;
if (!sourceMappings || !input.tiles) return;
// Find which mapping changed
const changedIdx = sourceMappings.findIndex(
(mapping, idx) => mapping !== prevSourceMappingsRef.current?.[idx],
);
if (changedIdx === -1) return;
prevSourceMappingsRef.current = sourceMappings;
const inputTile = input.tiles[changedIdx];
if (!inputTile) return;
const sourceId = a.sourceMappings[idx] ?? '';
const sourceId = sourceMappings[changedIdx] ?? '';
const keysForTilesWithMatchingSource = input.tiles
.map((tile, index) => ({ ...tile, index }))
.filter(tile => tile.config.source === inputTile.config.source)
@ -263,7 +271,7 @@ function Mapping({ input }: { input: Input }) {
}
isUpdatingRef.current = false;
});
}, [sourceMappings, input.tiles, input.filters, getFieldState, setValue]);
const createDashboard = useCreateDashboard();
const updateDashboard = useUpdateDashboard();

View file

@ -14,7 +14,7 @@ import produce from 'immer';
import { parseAsString, useQueryState } from 'nuqs';
import { ErrorBoundary } from 'react-error-boundary';
import RGL, { WidthProvider } from 'react-grid-layout';
import { Controller, useForm } from 'react-hook-form';
import { Controller, useForm, useWatch } from 'react-hook-form';
import { TableConnection } from '@hyperdx/common-utils/dist/core/metadata';
import { convertToDashboardTemplate } from '@hyperdx/common-utils/dist/core/utils';
import {
@ -644,7 +644,7 @@ function DBDashboardPage({ presetConfig }: { presetConfig?: Dashboard }) {
const [isLive, setIsLive] = useState(false);
const { control, watch, setValue, handleSubmit } = useForm<{
const { control, setValue, handleSubmit } = useForm<{
granularity: SQLInterval | 'auto';
where: SearchCondition;
whereLanguage: SearchConditionLanguage;
@ -660,11 +660,14 @@ function DBDashboardPage({ presetConfig }: { presetConfig?: Dashboard }) {
whereLanguage: (whereLanguage as SearchConditionLanguage) ?? 'lucene',
},
});
watch((data, { name, type }) => {
if (name === 'granularity' && type === 'change') {
setGranularity(data.granularity as SQLInterval);
const watchedGranularity = useWatch({ control, name: 'granularity' });
useEffect(() => {
if (watchedGranularity && watchedGranularity !== granularity) {
setGranularity(watchedGranularity as SQLInterval);
}
});
}, [watchedGranularity, granularity, setGranularity]);
const [displayedTimeInputValue, setDisplayedTimeInputValue] =
useState('Past 1h');

View file

@ -843,7 +843,6 @@ function DBSearchPage() {
const {
control,
watch,
setValue,
reset,
handleSubmit,
@ -1004,31 +1003,31 @@ function DBSearchPage() {
onFilterChange: handleSetFilters,
});
useEffect(() => {
const { unsubscribe } = watch((data, { name, type }) => {
// If the user changes the source dropdown, reset the select and orderby fields
// to match the new source selected
if (name === 'source' && type === 'change') {
const newInputSourceObj = inputSourceObjs?.find(
s => s.id === data.source,
);
if (newInputSourceObj != null) {
// Save the selected source ID to localStorage
setLastSelectedSourceId(newInputSourceObj.id);
const watchedSource = useWatch({ control, name: 'source' });
const prevSourceRef = useRef(watchedSource);
setValue(
'select',
newInputSourceObj?.defaultTableSelectExpression ?? '',
);
// Clear all search filters
searchFilters.clearAllFilters();
}
useEffect(() => {
// If the user changes the source dropdown, reset the select and orderby fields
// to match the new source selected
if (watchedSource !== prevSourceRef.current) {
prevSourceRef.current = watchedSource;
const newInputSourceObj = inputSourceObjs?.find(
s => s.id === watchedSource,
);
if (newInputSourceObj != null) {
// Save the selected source ID to localStorage
setLastSelectedSourceId(newInputSourceObj.id);
setValue(
'select',
newInputSourceObj?.defaultTableSelectExpression ?? '',
);
// Clear all search filters
searchFilters.clearAllFilters();
}
});
return () => unsubscribe();
}
}, [
watch,
inputSourceObj,
watchedSource,
setValue,
inputSourceObjs,
searchFilters,

View file

@ -1,6 +1,6 @@
import React from 'react';
import router from 'next/router';
import { useForm } from 'react-hook-form';
import { useForm, useWatch } from 'react-hook-form';
import { NativeSelect, NumberInput } from 'react-hook-form-mantine';
import { z } from 'zod';
import { zodResolver } from '@hookform/resolvers/zod';
@ -92,7 +92,7 @@ const AlertForm = ({
}) => {
const { data: source } = useSource({ id: sourceId });
const { control, handleSubmit, watch } = useForm<Alert>({
const { control, handleSubmit } = useForm<Alert>({
defaultValues: defaultValues || {
interval: '5m',
threshold: 1,
@ -106,8 +106,13 @@ const AlertForm = ({
resolver: zodResolver(SavedSearchAlertFormSchema),
});
const groupBy = watch('groupBy');
const thresholdType = watch('thresholdType');
const groupBy = useWatch({ control, name: 'groupBy' });
const thresholdType = useWatch({ control, name: 'thresholdType' });
const channelType = useWatch({ control, name: 'channel.type' });
const interval = useWatch({ control, name: 'interval' });
const groupByValue = useWatch({ control, name: 'groupBy' });
const threshold = useWatch({ control, name: 'threshold' });
const thresholdTypeValue = useWatch({ control, name: 'thresholdType' });
return (
<form onSubmit={handleSubmit(onSubmit)}>
@ -168,7 +173,7 @@ const AlertForm = ({
<Text size="xxs" opacity={0.5} mb={4}>
Send to
</Text>
<AlertChannelForm control={control} type={watch('channel.type')} />
<AlertChannelForm control={control} type={channelType} />
</Paper>
{groupBy && thresholdType === AlertThresholdType.BELOW && (
<MantineAlert
@ -197,10 +202,10 @@ const AlertForm = ({
where={where}
whereLanguage={whereLanguage}
select={select}
interval={watch('interval')}
groupBy={watch('groupBy')}
threshold={watch('threshold')}
thresholdType={watch('thresholdType')}
interval={interval}
groupBy={groupByValue}
threshold={threshold}
thresholdType={thresholdTypeValue}
/>
)}
</Accordion.Panel>

View file

@ -1,7 +1,7 @@
import { useState } from 'react';
import { useEffect, useState } from 'react';
import dynamic from 'next/dynamic';
import { parseAsInteger, useQueryState } from 'nuqs';
import { useForm } from 'react-hook-form';
import { useForm, useWatch } from 'react-hook-form';
import { SourceKind } from '@hyperdx/common-utils/dist/types';
import { Box, Group, Slider, Text } from '@mantine/core';
@ -67,17 +67,19 @@ function DBServiceMapPage() {
) ?? defaultSource)
: defaultSource;
const { control, watch } = useForm({
const { control } = useForm({
values: {
source: source?.id,
},
});
watch((data, { name, type }) => {
if (name === 'source' && type === 'change') {
setSourceId(data.source ?? null);
const watchedSource = useWatch({ control, name: 'source' });
useEffect(() => {
if (watchedSource !== sourceId) {
setSourceId(watchedSource ?? null);
}
});
}, [watchedSource, sourceId, setSourceId]);
const [samplingFactor, setSamplingFactor] = useQueryState(
'samplingFactor',

View file

@ -1,5 +1,5 @@
import { useEffect, useState } from 'react';
import { Controller, FieldError, useForm } from 'react-hook-form';
import { Controller, FieldError, useForm, useWatch } from 'react-hook-form';
import { TableConnection } from '@hyperdx/common-utils/dist/core/metadata';
import {
DashboardFilter,
@ -94,7 +94,7 @@ const DashboardFilterEditForm = ({
onClose,
onCancel,
}: DashboardFilterEditFormProps) => {
const { handleSubmit, register, formState, control, watch, reset } =
const { handleSubmit, register, formState, control, reset } =
useForm<DashboardFilter>({
defaultValues: filter,
});
@ -103,10 +103,10 @@ const DashboardFilterEditForm = ({
reset(filter);
}, [filter, reset]);
const sourceId = watch('source');
const sourceId = useWatch({ control, name: 'source' });
const { data: source } = useSource({ id: sourceId });
const metricType = watch('sourceMetricType');
const metricType = useWatch({ control, name: 'sourceMetricType' });
const tableName = source && getMetricTableName(source, metricType);
const tableConnection: TableConnection | undefined = tableName
? {

View file

@ -16,6 +16,7 @@ export default function GranularityPicker({
return (
<Select
disabled={disabled}
data-testid="granularity-picker"
data={[
{
value: 'auto' as const,

View file

@ -5,7 +5,7 @@ import Link from 'next/link';
import cx from 'classnames';
import sub from 'date-fns/sub';
import { useQueryState } from 'nuqs';
import { useForm } from 'react-hook-form';
import { useForm, useWatch } from 'react-hook-form';
import { SourceKind, TSource } from '@hyperdx/common-utils/dist/types';
import {
Alert,
@ -1036,13 +1036,20 @@ function KubernetesDashboardPage() {
const logSource = sources?.find(s => s.id === logSourceId);
const metricSource = sources?.find(s => s.id === metricSourceId);
const { control, watch } = useForm({
const { control } = useForm({
values: {
logSourceId,
metricSourceId,
},
});
const watchedLogSourceId = useWatch({ control, name: 'logSourceId' });
const watchedMetricSourceId = useWatch({ control, name: 'metricSourceId' });
// Track previous values to detect user-initiated changes
const prevLogSourceIdRef = useRef(logSourceId);
const prevMetricSourceIdRef = useRef(metricSourceId);
useEffect(() => {
if (logSourceId && logSourceId !== _logSourceId) {
setLogSourceId(logSourceId);
@ -1055,54 +1062,89 @@ function KubernetesDashboardPage() {
}
}, [metricSourceId, _metricSourceId, setMetricSourceId]);
watch((data, { name, type }) => {
if (name === 'logSourceId' && type === 'change') {
setLogSourceId(data.logSourceId ?? null);
// Handle log source changes
useEffect(() => {
if (watchedLogSourceId === prevLogSourceIdRef.current) {
return;
}
prevLogSourceIdRef.current = watchedLogSourceId;
// Default to the log source's correlated metric source
if (data.logSourceId && sources) {
const logSource = findSource(sources, { id: data.logSourceId });
const correlatedMetricSource = logSource?.metricSourceId
? findSource(sources, { id: logSource.metricSourceId })
: undefined;
if (
correlatedMetricSource &&
correlatedMetricSource.id !== data.metricSourceId
) {
setMetricSourceId(correlatedMetricSource.id);
notifications.show({
id: `${correlatedMetricSource.id}-auto-correlated-metric-source`,
title: 'Updated Metrics Source',
message: `Using correlated metrics source: ${correlatedMetricSource.name}`,
});
} else if (logSource && !correlatedMetricSource) {
notifications.show({
id: `${logSource.id}-not-correlated`,
title: 'Warning',
message: `The selected logs source is not correlated with a metrics source. Source correlations can be configured in Team Settings.`,
color: 'yellow',
});
}
}
} else if (name === 'metricSourceId' && type === 'change') {
setMetricSourceId(data.metricSourceId ?? null);
const metricSource = data.metricSourceId
? findSource(sources, { id: data.metricSourceId })
setLogSourceId(watchedLogSourceId ?? null);
// Default to the log source's correlated metric source
if (watchedLogSourceId && sources) {
const logSource = findSource(sources, { id: watchedLogSourceId });
const correlatedMetricSource = logSource?.metricSourceId
? findSource(sources, { id: logSource.metricSourceId })
: undefined;
if (
metricSource &&
data.logSourceId &&
metricSource.logSourceId !== data.logSourceId
correlatedMetricSource &&
correlatedMetricSource.id !== watchedMetricSourceId
) {
setMetricSourceId(correlatedMetricSource.id);
notifications.show({
id: `${metricSource.id}-not-correlated`,
id: `${correlatedMetricSource.id}-auto-correlated-metric-source`,
title: 'Updated Metrics Source',
message: `Using correlated metrics source: ${correlatedMetricSource.name}`,
});
} else if (logSource && !correlatedMetricSource) {
notifications.show({
id: `${logSource.id}-not-correlated`,
title: 'Warning',
message: `The selected metrics source is not correlated with the selected logs source. Source correlations can be configured in Team Settings.`,
message: `The selected logs source is not correlated with a metrics source. Source correlations can be configured in Team Settings.`,
color: 'yellow',
});
}
}
});
}, [
watchedLogSourceId,
watchedMetricSourceId,
sources,
setLogSourceId,
setMetricSourceId,
]);
// Handle metric source changes
useEffect(() => {
if (watchedMetricSourceId === prevMetricSourceIdRef.current) {
return;
}
prevMetricSourceIdRef.current = watchedMetricSourceId;
setMetricSourceId(watchedMetricSourceId ?? null);
// Default to the metric source's correlated log source
if (watchedMetricSourceId && sources) {
const metricSource = findSource(sources, { id: watchedMetricSourceId });
const correlatedLogSource = metricSource?.logSourceId
? findSource(sources, { id: metricSource.logSourceId })
: undefined;
if (
correlatedLogSource &&
correlatedLogSource.id !== watchedLogSourceId
) {
setLogSourceId(correlatedLogSource.id);
notifications.show({
id: `${correlatedLogSource.id}-auto-correlated-log-source`,
title: 'Updated Logs Source',
message: `Using correlated logs source: ${correlatedLogSource.name}`,
});
} else if (metricSource && !correlatedLogSource) {
notifications.show({
id: `${metricSource.id}-not-correlated`,
title: 'Warning',
message: `The selected metrics source is not correlated with a logs source. Source correlations can be configured in Team Settings.`,
color: 'yellow',
});
}
}
}, [
watchedMetricSourceId,
watchedLogSourceId,
sources,
setMetricSourceId,
setLogSourceId,
]);
const [activeTab, setActiveTab] = useQueryState('tab', {
defaultValue: 'pods',

View file

@ -7,7 +7,7 @@ import {
useQueryState,
useQueryStates,
} from 'nuqs';
import { UseControllerProps, useForm } from 'react-hook-form';
import { UseControllerProps, useForm, useWatch } from 'react-hook-form';
import { tcFromSource } from '@hyperdx/common-utils/dist/core/metadata';
import { DEFAULT_AUTO_GRANULARITY_MAX_BUCKETS } from '@hyperdx/common-utils/dist/core/renderChartConfig';
import {
@ -1421,7 +1421,7 @@ function ServicesDashboardPage() {
};
}, [appliedConfigParams, sources]);
const { control, watch, setValue, handleSubmit } = useForm({
const { control, setValue, handleSubmit } = useForm({
defaultValues: {
where: '',
whereLanguage: 'sql' as 'sql' | 'lucene',
@ -1430,10 +1430,10 @@ function ServicesDashboardPage() {
},
});
const service = watch('service');
const sourceId = watch('source');
const service = useWatch({ control, name: 'service' });
const sourceId = useWatch({ control, name: 'source' });
const { data: source } = useSource({
id: watch('source'),
id: sourceId,
});
const [showFiltersModal, setShowFiltersModal] = useState(false);
@ -1501,32 +1501,17 @@ function ServicesDashboardPage() {
// Auto-submit when source changes
useEffect(() => {
const { unsubscribe } = watch((data, { name, type }) => {
if (
name === 'source' &&
type === 'change' &&
data.source &&
data.source !== appliedConfig.source
) {
onSubmit();
}
});
return () => unsubscribe();
}, [appliedConfig.source, onSubmit, watch]);
if (sourceId && sourceId !== appliedConfig.source) {
onSubmit();
}
}, [sourceId, appliedConfig.source, onSubmit]);
// Auto-submit when service changes
useEffect(() => {
const { unsubscribe } = watch((data, { name, type }) => {
if (
name === 'service' &&
type === 'change' &&
data.service !== appliedConfig.service
) {
onSubmit();
}
});
return () => unsubscribe();
}, [appliedConfig.service, onSubmit, watch]);
if (service !== appliedConfig.service) {
onSubmit();
}
}, [service, appliedConfig.service, onSubmit]);
return (
<Box p="sm">

View file

@ -7,7 +7,7 @@ import {
useQueryState,
useQueryStates,
} from 'nuqs';
import { useForm } from 'react-hook-form';
import { useForm, useWatch } from 'react-hook-form';
import { NumberParam } from 'serialize-query-params';
import {
StringParam,
@ -245,7 +245,7 @@ const appliedConfigMap = {
export default function SessionsPage() {
const [appliedConfig, setAppliedConfig] = useQueryStates(appliedConfigMap);
const { control, watch, setValue, handleSubmit } = useForm({
const { control, setValue, handleSubmit } = useForm({
values: {
where: appliedConfig.where,
whereLanguage: appliedConfig.whereLanguage,
@ -253,11 +253,11 @@ export default function SessionsPage() {
},
});
const where = watch('where');
const whereLanguage = watch('whereLanguage');
const sourceId = watch('source');
const where = useWatch({ control, name: 'where' });
const whereLanguage = useWatch({ control, name: 'whereLanguage' });
const sourceId = useWatch({ control, name: 'source' });
const { data: sessionSource, isPending: isSessionSourceLoading } = useSource({
id: watch('source'),
id: sourceId,
});
const { data: traceTrace } = useSource({

View file

@ -2,7 +2,7 @@ import { Fragment, useCallback, useMemo, useState } from 'react';
import Head from 'next/head';
import { HTTPError } from 'ky';
import { CopyToClipboard } from 'react-copy-to-clipboard';
import { SubmitHandler, useForm } from 'react-hook-form';
import { SubmitHandler, useForm, useWatch } from 'react-hook-form';
import { DEFAULT_METADATA_MAX_ROWS_TO_READ } from '@hyperdx/common-utils/dist/core/metadata';
import {
SourceKind,
@ -604,7 +604,7 @@ function ClickhouseSettingForm({
<SelectControlled
control={form.control}
name="value"
value={form.watch('value')}
value={useWatch({ control: form.control, name: 'value' })}
data={[displayValue(true), displayValue(false)]}
size="xs"
placeholder="Please select"

View file

@ -2,7 +2,7 @@ import { useCallback, useContext, useMemo, useState } from 'react';
import { sq } from 'date-fns/locale';
import ms from 'ms';
import { parseAsString, useQueryState } from 'nuqs';
import { useForm } from 'react-hook-form';
import { useForm, useWatch } from 'react-hook-form';
import { tcFromSource } from '@hyperdx/common-utils/dist/core/metadata';
import {
ChartConfigWithDateRange,
@ -82,14 +82,14 @@ export default function ContextSubpanel({
dbSqlRowTableConfig ?? {};
const [range, setRange] = useState<number>(ms('30s'));
const [contextBy, setContextBy] = useState<ContextBy>(ContextBy.All);
const { control, watch } = useForm({
const { control } = useForm({
defaultValues: {
where: '',
whereLanguage: originalLanguage ?? ('lucene' as 'lucene' | 'sql'),
},
});
const formWhere = watch('where');
const formWhere = useWatch({ control, name: 'where' });
const [debouncedWhere] = useDebouncedValue(formWhere, 1000);
// State management for nested panels

View file

@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { omit } from 'lodash';
import {
Control,
@ -459,7 +459,7 @@ export default function EditTimeChartForm({
[chartConfig],
);
const { control, watch, setValue, handleSubmit, register } =
const { control, setValue, handleSubmit, register } =
useForm<SavedChartConfigWithSeries>({
defaultValues: configWithSeries,
values: configWithSeries,
@ -478,13 +478,21 @@ export default function EditTimeChartForm({
const [isSampleEventsOpen, setIsSampleEventsOpen] = useState(false);
const select = watch('select');
const sourceId = watch('source');
const whereLanguage = watch('whereLanguage');
const alert = watch('alert');
const seriesReturnType = watch('seriesReturnType');
const compareToPreviousPeriod = watch('compareToPreviousPeriod');
const groupBy = watch('groupBy');
const select = useWatch({ control, name: 'select' });
const sourceId = useWatch({ control, name: 'source' });
const whereLanguage = useWatch({ control, name: 'whereLanguage' });
const alert = useWatch({ control, name: 'alert' });
const seriesReturnType = useWatch({ control, name: 'seriesReturnType' });
const compareToPreviousPeriod = useWatch({
control,
name: 'compareToPreviousPeriod',
});
const groupBy = useWatch({ control, name: 'groupBy' });
const displayType =
useWatch({ control, name: 'displayType' }) ?? DisplayType.Line;
const markdown = useWatch({ control, name: 'markdown' });
const alertChannelType = useWatch({ control, name: 'alert.channel.type' });
const granularity = useWatch({ control, name: 'granularity' });
const { data: tableSource } = useSource({ id: sourceId });
const databaseName = tableSource?.from.databaseName;
@ -493,8 +501,6 @@ export default function EditTimeChartForm({
// const tableSource = tableSourceWatch();
// const databaseName = tableSourceWatch('from.databaseName');
// const tableName = tableSourceWatch('from.tableName');
const displayType = watch('displayType') ?? DisplayType.Line;
const activeTab = useMemo(() => {
switch (displayType) {
case DisplayType.Search:
@ -636,17 +642,27 @@ export default function EditTimeChartForm({
[onSave, displayType],
);
watch((_, { name, type }) => {
// Track previous values for detecting changes
const prevGranularityRef = useRef(granularity);
const prevDisplayTypeRef = useRef(displayType);
useEffect(() => {
// Emulate the granularity picker auto-searching similar to dashboards
if (name === 'granularity' && type === 'change') {
if (granularity !== prevGranularityRef.current) {
prevGranularityRef.current = granularity;
onSubmit();
}
if (name === 'displayType' && type === 'change') {
if (_.displayType === DisplayType.Search && typeof select !== 'string') {
}, [granularity, onSubmit]);
useEffect(() => {
if (displayType !== prevDisplayTypeRef.current) {
prevDisplayTypeRef.current = displayType;
if (displayType === DisplayType.Search && typeof select !== 'string') {
setValue('select', '');
setValue('series', []);
}
if (_.displayType !== DisplayType.Search && typeof select === 'string') {
if (displayType !== DisplayType.Search && typeof select === 'string') {
const defaultSeries: SavedChartConfigWithSelectArray['select'] = [
{
aggFn: 'count',
@ -661,7 +677,7 @@ export default function EditTimeChartForm({
}
onSubmit();
}
});
}, [displayType, select, setValue, onSubmit]);
// Emulate the date range picker auto-searching similar to dashboards
useEffect(() => {
@ -821,7 +837,7 @@ export default function EditTimeChartForm({
<Box p="md" mb="md">
<HDXMarkdownChart
config={{
markdown: watch('markdown') || 'Preview',
markdown: markdown || 'Preview',
}}
/>
</Box>
@ -1059,7 +1075,7 @@ export default function EditTimeChartForm({
</Text>
<AlertChannelForm
control={control}
type={watch('alert.channel.type')}
type={alertChannelType}
namePrefix="alert."
/>
</Paper>

View file

@ -1,6 +1,6 @@
import { useEffect, useState } from 'react';
import { parseAsJson, useQueryState } from 'nuqs';
import { useForm } from 'react-hook-form';
import { useForm, useWatch } from 'react-hook-form';
import { tcFromSource } from '@hyperdx/common-utils/dist/core/metadata';
import { SourceKind } from '@hyperdx/common-utils/dist/types';
import {
@ -53,15 +53,17 @@ export default function DBTracePanel({
};
'data-testid'?: string;
}) {
const { control, watch } = useForm({
const { control } = useForm({
defaultValues: {
source: childSourceId,
},
});
const sourceId = useWatch({ control, name: 'source' });
const { data: childSourceData, isLoading: isChildSourceDataLoading } =
useSource({
id: watch('source'),
id: sourceId,
});
const { data: parentSourceData, isLoading: isParentSourceDataLoading } =

View file

@ -1,5 +1,5 @@
import React, { useEffect, useMemo, useState } from 'react';
import { useForm } from 'react-hook-form';
import React, { useEffect, useMemo, useRef, useState } from 'react';
import { useForm, useWatch } from 'react-hook-form';
import { tcFromSource } from '@hyperdx/common-utils/dist/core/metadata';
import {
ChartConfigWithDateRange,
@ -84,25 +84,29 @@ export const KubernetesFilters: React.FC<KubernetesFiltersProps> = ({
const [namespaceName, setNamespaceName] = useState<string | null>(null);
const [clusterName, setClusterName] = useState<string | null>(null);
const { control, watch, setValue } = useForm({
const { control, setValue } = useForm({
defaultValues: {
searchQuery: searchQuery,
},
});
// Watch for changes in the search query field
useEffect(() => {
const subscription = watch((value, { name }) => {
if (name === 'searchQuery') {
setSearchQuery(value.searchQuery ?? '');
}
});
return () => subscription.unsubscribe();
}, [watch, setSearchQuery]);
const watchedSearchQuery = useWatch({ control, name: 'searchQuery' });
const prevSearchQueryRef = useRef(searchQuery);
// Update search form value when search query changes
// Sync form changes to parent state
useEffect(() => {
setValue('searchQuery', searchQuery);
if (watchedSearchQuery !== prevSearchQueryRef.current) {
prevSearchQueryRef.current = watchedSearchQuery ?? '';
setSearchQuery(watchedSearchQuery ?? '');
}
}, [watchedSearchQuery, setSearchQuery]);
// Update search form value when search query changes from parent
useEffect(() => {
if (searchQuery !== prevSearchQueryRef.current) {
prevSearchQueryRef.current = searchQuery;
setValue('searchQuery', searchQuery);
}
}, [searchQuery, setValue]);
// Helper function to extract value from search query

View file

@ -1,5 +1,5 @@
import * as React from 'react';
import { useForm } from 'react-hook-form';
import { useForm, useWatch } from 'react-hook-form';
import {
Button,
Checkbox as MCheckbox,
@ -44,7 +44,7 @@ export const NumberFormatForm: React.FC<{
onApply: (value: NumberFormat) => void;
onClose: () => void;
}> = ({ value, onApply, onClose }) => {
const { register, handleSubmit, watch, setValue } = useForm<NumberFormat>({
const { register, handleSubmit, control, setValue } = useForm<NumberFormat>({
values: value,
defaultValues: {
factor: 1,
@ -56,7 +56,15 @@ export const NumberFormatForm: React.FC<{
},
});
const values = watch();
const values = useWatch({ control });
const valuesWithDefaults = values ?? {
factor: 1,
output: 'number' as const,
mantissa: 2,
thousandSeparated: true,
average: false,
decimalBytes: false,
};
const testNumber = 1234;
@ -91,7 +99,10 @@ export const NumberFormatForm: React.FC<{
>
<NativeSelect
label="Output format"
leftSection={values.output && FORMAT_ICONS[values.output]}
leftSection={
valuesWithDefaults.output &&
FORMAT_ICONS[valuesWithDefaults.output]
}
style={{ flex: 1 }}
data={[
{ value: 'number', label: 'Number' },
@ -102,7 +113,7 @@ export const NumberFormatForm: React.FC<{
]}
{...register('output')}
/>
{values.output === 'currency' && (
{valuesWithDefaults.output === 'currency' && (
<TextInput
w={80}
label="Symbol"
@ -127,11 +138,11 @@ export const NumberFormatForm: React.FC<{
>
Example
</div>
{formatNumber(testNumber || 0, values)}
{formatNumber(testNumber || 0, valuesWithDefaults as NumberFormat)}
</Paper>
</div>
{values.output !== 'time' && (
{valuesWithDefaults.output !== 'time' && (
<div>
<div className="fs-8 mt-2 fw-bold mb-1">Decimals</div>
<Slider

View file

@ -1,5 +1,5 @@
import { useEffect, useState } from 'react';
import { Controller, useForm } from 'react-hook-form';
import { Controller, useForm, useWatch } from 'react-hook-form';
import { SavedChartConfig } from '@hyperdx/common-utils/dist/types';
import {
Box,
@ -46,7 +46,7 @@ export default function SaveToDashboardModal({
const createDashboard = useCreateDashboard();
const updateDashboard = useUpdateDashboard();
const { control, handleSubmit, reset, watch, formState } = useForm<{
const { control, handleSubmit, reset, formState } = useForm<{
dashboardId: string;
newDashboardName: string;
}>({
@ -56,7 +56,7 @@ export default function SaveToDashboardModal({
},
});
const dashboardId = watch('dashboardId');
const dashboardId = useWatch({ control, name: 'dashboardId' });
const isCreatingNew = dashboardId === CREATE_NEW_DASHBOARD_VALUE;
// Reset form when modal is closed

View file

@ -1,11 +1,10 @@
import React, { useCallback, useEffect, useState } from 'react';
import React, { useCallback, useEffect, useRef, useState } from 'react';
import {
Control,
Controller,
useFieldArray,
useForm,
UseFormSetValue,
UseFormWatch,
useWatch,
} from 'react-hook-form';
import { z } from 'zod';
@ -178,20 +177,23 @@ function FormRow({
function HighlightedAttributeExpressionsFormRow({
control,
watch,
name,
label,
helpText,
}: TableModelProps & {
}: Omit<TableModelProps, 'setValue'> & {
name:
| 'highlightedTraceAttributeExpressions'
| 'highlightedRowAttributeExpressions';
label: string;
helpText?: string;
}) {
const databaseName = watch(`from.databaseName`, DEFAULT_DATABASE);
const tableName = watch(`from.tableName`);
const connectionId = watch(`connection`);
const databaseName = useWatch({
control,
name: 'from.databaseName',
defaultValue: DEFAULT_DATABASE,
});
const tableName = useWatch({ control, name: 'from.tableName' });
const connectionId = useWatch({ control, name: 'connection' });
const {
fields: highlightedAttributes,
@ -286,11 +288,7 @@ function HighlightedAttributeExpressionsFormRow({
}
/** Component for configuring one or more materialized views */
function MaterializedViewsFormSection({
control,
watch,
setValue,
}: TableModelProps) {
function MaterializedViewsFormSection({ control, setValue }: TableModelProps) {
const databaseName =
useWatch({ control, name: `from.databaseName` }) || DEFAULT_DATABASE;
@ -320,7 +318,6 @@ function MaterializedViewsFormSection({
{materializedViews.map((field, index) => (
<MaterializedViewFormSection
key={field.id}
watch={watch}
control={control}
mvIndex={index}
setValue={setValue}
@ -354,7 +351,6 @@ function MaterializedViewsFormSection({
/** Component for configuring a single materialized view */
function MaterializedViewFormSection({
watch,
control,
mvIndex,
onRemove,
@ -471,7 +467,6 @@ function MaterializedViewFormSection({
<AggregatedColumnsFormSection
control={control}
mvIndex={mvIndex}
watch={watch}
setValue={setValue}
/>
<Divider />
@ -482,7 +477,6 @@ function MaterializedViewFormSection({
/** Component for configuring the Aggregated Columns list for a single materialized view */
function AggregatedColumnsFormSection({
control,
watch,
setValue,
mvIndex,
}: TableModelProps & { mvIndex: number }) {
@ -500,58 +494,78 @@ function AggregatedColumnsFormSection({
appendAggregate({ sourceColumn: '', aggFn: 'avg', mvColumn: '' });
}, [appendAggregate]);
const kind = useWatch({ control, name: 'kind' });
const connection = useWatch({ control, name: 'connection' });
const mvTableName = useWatch({
control,
name: `materializedViews.${mvIndex}.tableName`,
});
const mvDatabaseName = useWatch({
control,
name: `materializedViews.${mvIndex}.databaseName`,
});
const fromDatabaseName = useWatch({ control, name: 'from.databaseName' });
const fromTableName = useWatch({ control, name: 'from.tableName' });
const prevMvTableNameRef = useRef(mvTableName);
useEffect(() => {
const { unsubscribe } = watch(async (value, { name, type }) => {
(async () => {
try {
if (
(value.kind === SourceKind.Log || value.kind === SourceKind.Trace) &&
value.connection &&
value.materializedViews?.[mvIndex] &&
value.materializedViews[mvIndex].databaseName &&
value.materializedViews[mvIndex].tableName &&
value.from?.databaseName &&
value.from?.tableName &&
name === `materializedViews.${mvIndex}.tableName` &&
type === 'change'
) {
const mvDatabaseName = value.materializedViews[mvIndex].databaseName;
const mvTableName = value.materializedViews[mvIndex].tableName;
if (mvTableName !== prevMvTableNameRef.current) {
prevMvTableNameRef.current = mvTableName;
const config = await inferMaterializedViewConfig(
{
databaseName: mvDatabaseName,
tableName: mvTableName,
connectionId: value.connection,
},
{
databaseName: value.from.databaseName,
tableName: value.from.tableName,
connectionId: value.connection,
},
);
if (
(kind === SourceKind.Log || kind === SourceKind.Trace) &&
connection &&
mvDatabaseName &&
mvTableName &&
fromDatabaseName &&
fromTableName
) {
const config = await inferMaterializedViewConfig(
{
databaseName: mvDatabaseName,
tableName: mvTableName,
connectionId: connection,
},
{
databaseName: fromDatabaseName,
tableName: fromTableName,
connectionId: connection,
},
);
if (config) {
setValue(`materializedViews.${mvIndex}`, config);
replaceAggregates(config.aggregatedColumns ?? []);
notifications.show({
color: 'green',
message:
'Partially inferred materialized view configuration from view schema.',
});
} else {
notifications.show({
color: 'yellow',
message: 'Unable to infer materialized view configuration.',
});
if (config) {
setValue(`materializedViews.${mvIndex}`, config);
replaceAggregates(config.aggregatedColumns ?? []);
notifications.show({
color: 'green',
message:
'Partially inferred materialized view configuration from view schema.',
});
} else {
notifications.show({
color: 'yellow',
message: 'Unable to infer materialized view configuration.',
});
}
}
}
} catch (e) {
console.error(e);
}
});
return () => unsubscribe();
}, [watch, mvIndex, replaceAggregates, setValue]);
})();
}, [
mvTableName,
kind,
connection,
mvDatabaseName,
fromDatabaseName,
fromTableName,
mvIndex,
replaceAggregates,
setValue,
]);
return (
<Box>
@ -571,7 +585,6 @@ function AggregatedColumnsFormSection({
{aggregates.map((field, colIndex) => (
<AggregatedColumnRow
key={field.id}
watch={watch}
setValue={setValue}
control={control}
mvIndex={mvIndex}
@ -676,10 +689,14 @@ function AggregatedColumnRow({
// custom always points towards the url param
export function LogTableModelForm(props: TableModelProps) {
const { control, watch } = props;
const databaseName = watch(`from.databaseName`, DEFAULT_DATABASE);
const tableName = watch(`from.tableName`);
const connectionId = watch(`connection`);
const { control } = props;
const databaseName = useWatch({
control,
name: 'from.databaseName',
defaultValue: DEFAULT_DATABASE,
});
const tableName = useWatch({ control, name: 'from.tableName' });
const connectionId = useWatch({ control, name: 'connection' });
const [showOptionalFields, setShowOptionalFields] = useState(false);
@ -925,10 +942,14 @@ export function LogTableModelForm(props: TableModelProps) {
}
export function TraceTableModelForm(props: TableModelProps) {
const { control, watch } = props;
const databaseName = watch(`from.databaseName`, DEFAULT_DATABASE);
const tableName = watch(`from.tableName`);
const connectionId = watch(`connection`);
const { control } = props;
const databaseName = useWatch({
control,
name: 'from.databaseName',
defaultValue: DEFAULT_DATABASE,
});
const tableName = useWatch({ control, name: 'from.tableName' });
const connectionId = useWatch({ control, name: 'connection' });
return (
<Stack gap="sm">
@ -1205,19 +1226,21 @@ export function TraceTableModelForm(props: TableModelProps) {
);
}
export function SessionTableModelForm({
control,
watch,
setValue,
}: TableModelProps) {
const databaseName = watch(`from.databaseName`, DEFAULT_DATABASE);
const connectionId = watch(`connection`);
export function SessionTableModelForm({ control, setValue }: TableModelProps) {
const databaseName = useWatch({
control,
name: 'from.databaseName',
defaultValue: DEFAULT_DATABASE,
});
const connectionId = useWatch({ control, name: 'connection' });
const tableName = useWatch({ control, name: 'from.tableName' });
const prevTableNameRef = useRef(tableName);
useEffect(() => {
const { unsubscribe } = watch(async (value, { name, type }) => {
(async () => {
try {
const tableName = value.from?.tableName;
if (tableName && name === 'from.tableName' && type === 'change') {
if (tableName && tableName !== prevTableNameRef.current) {
prevTableNameRef.current = tableName;
const isValid = await isValidSessionsTable({
databaseName,
tableName,
@ -1238,10 +1261,8 @@ export function SessionTableModelForm({
message: e.message,
});
}
});
return () => unsubscribe();
}, [setValue, watch, databaseName, connectionId]);
})();
}, [tableName, databaseName, connectionId]);
return (
<>
@ -1259,48 +1280,55 @@ export function SessionTableModelForm({
interface TableModelProps {
control: Control<TSourceUnion>;
watch: UseFormWatch<TSourceUnion>;
setValue: UseFormSetValue<TSourceUnion>;
}
export function MetricTableModelForm({
control,
watch,
setValue,
}: TableModelProps) {
const databaseName = watch(`from.databaseName`, DEFAULT_DATABASE);
const connectionId = watch(`connection`);
export function MetricTableModelForm({ control, setValue }: TableModelProps) {
const databaseName = useWatch({
control,
name: 'from.databaseName',
defaultValue: DEFAULT_DATABASE,
});
const connectionId = useWatch({ control, name: 'connection' });
const metricTables = useWatch({ control, name: 'metricTables' });
const prevMetricTablesRef = useRef(metricTables);
useEffect(() => {
for (const [_key, _value] of Object.entries(OTEL_CLICKHOUSE_EXPRESSIONS)) {
setValue(_key as any, _value);
}
const { unsubscribe } = watch(async (value, { name, type }) => {
}, [setValue]);
useEffect(() => {
(async () => {
try {
if (name && type === 'change') {
const [prefix, suffix] = name.split('.');
if (prefix === 'metricTables') {
const tableName =
value.kind === SourceKind.Metric
? value?.metricTables?.[
suffix as keyof typeof value.metricTables
]
: '';
const metricType = suffix as MetricsDataType;
const isValid = await isValidMetricTable({
databaseName,
tableName,
connectionId,
metricType,
});
if (!isValid) {
notifications.show({
color: 'red',
message: `${tableName} is not a valid OTEL ${metricType} schema.`,
if (metricTables && prevMetricTablesRef.current) {
// Check which metric table changed
for (const metricType of Object.values(MetricsDataType)) {
const newValue =
metricTables[metricType as keyof typeof metricTables];
const prevValue =
prevMetricTablesRef.current[
metricType as keyof typeof prevMetricTablesRef.current
];
if (newValue !== prevValue) {
const isValid = await isValidMetricTable({
databaseName,
tableName: newValue as string,
connectionId,
metricType: metricType as MetricsDataType,
});
if (!isValid) {
notifications.show({
color: 'red',
message: `${newValue} is not a valid OTEL ${metricType} schema.`,
});
}
}
}
}
prevMetricTablesRef.current = metricTables;
} catch (e) {
console.error(e);
notifications.show({
@ -1308,10 +1336,8 @@ export function MetricTableModelForm({
message: e.message,
});
}
});
return () => unsubscribe();
}, [setValue, watch, databaseName, connectionId]);
})();
}, [metricTables, databaseName, connectionId]);
return (
<>
@ -1348,48 +1374,22 @@ export function MetricTableModelForm({
function TableModelForm({
control,
watch,
setValue,
kind,
}: {
control: Control<TSourceUnion>;
watch: UseFormWatch<TSourceUnion>;
setValue: UseFormSetValue<TSourceUnion>;
kind: SourceKind;
}) {
switch (kind) {
case SourceKind.Log:
return (
<LogTableModelForm
control={control}
watch={watch}
setValue={setValue}
/>
);
return <LogTableModelForm control={control} setValue={setValue} />;
case SourceKind.Trace:
return (
<TraceTableModelForm
control={control}
watch={watch}
setValue={setValue}
/>
);
return <TraceTableModelForm control={control} setValue={setValue} />;
case SourceKind.Session:
return (
<SessionTableModelForm
control={control}
watch={watch}
setValue={setValue}
/>
);
return <SessionTableModelForm control={control} setValue={setValue} />;
case SourceKind.Metric:
return (
<MetricTableModelForm
control={control}
watch={watch}
setValue={setValue}
/>
);
return <MetricTableModelForm control={control} setValue={setValue} />;
}
}
@ -1412,7 +1412,6 @@ export function TableSourceForm({
const { data: connections } = useConnections();
const {
watch,
control,
setValue,
formState,
@ -1438,45 +1437,55 @@ export function TableSourceForm({
},
});
const watchedConnection = useWatch({ control, name: 'connection' });
const watchedDatabaseName = useWatch({ control, name: 'from.databaseName' });
const watchedTableName = useWatch({ control, name: 'from.tableName' });
const watchedKind = useWatch({ control, name: 'kind' });
const prevTableNameRef = useRef(watchedTableName);
useEffect(() => {
const { unsubscribe } = watch(async (_value, { name, type }) => {
(async () => {
try {
// TODO: HDX-1768 get rid of this type assertion
const value = _value as TSourceUnion;
if (
value.connection != null &&
value.from?.databaseName != null &&
(value.kind === SourceKind.Metric || value.from.tableName != null) &&
name === 'from.tableName' &&
type === 'change'
) {
const config = await inferTableSourceConfig({
databaseName: value.from.databaseName,
tableName:
value.kind !== SourceKind.Metric ? value.from.tableName : '',
connectionId: value.connection,
});
if (Object.keys(config).length > 0) {
notifications.show({
color: 'green',
message:
'Automatically inferred source configuration from table schema.',
if (watchedTableName !== prevTableNameRef.current) {
prevTableNameRef.current = watchedTableName;
if (
watchedConnection != null &&
watchedDatabaseName != null &&
(watchedKind === SourceKind.Metric || watchedTableName != null)
) {
const config = await inferTableSourceConfig({
databaseName: watchedDatabaseName,
tableName:
watchedKind !== SourceKind.Metric ? watchedTableName : '',
connectionId: watchedConnection,
});
if (Object.keys(config).length > 0) {
notifications.show({
color: 'green',
message:
'Automatically inferred source configuration from table schema.',
});
}
Object.entries(config).forEach(([key, value]) => {
resetField(key as any, {
keepDirty: true,
defaultValue: value,
});
});
}
Object.entries(config).forEach(([key, value]) => {
resetField(key as any, {
keepDirty: true,
defaultValue: value,
});
});
}
} catch (e) {
console.error(e);
}
});
return () => unsubscribe();
}, [watch, resetField]);
})();
}, [
watchedTableName,
watchedConnection,
watchedDatabaseName,
watchedKind,
resetField,
]);
// Sets the default connection field to the first connection after the
// connections have been loaded
@ -1484,7 +1493,7 @@ export function TableSourceForm({
resetField('connection', { defaultValue: connections?.[0]?.id });
}, [connections, resetField]);
const kind: SourceKind = watch('kind');
const kind: SourceKind = useWatch({ control, name: 'kind' });
const createSource = useCreateSource();
const updateSource = useUpdateSource();
@ -1492,59 +1501,119 @@ export function TableSourceForm({
// Bidirectional source linking
const { data: sources } = useSources();
const currentSourceId = watch('id');
const currentSourceId = useWatch({ control, name: 'id' });
// Watch all potential correlation fields
const logSourceId = useWatch({ control, name: 'logSourceId' });
const traceSourceId = useWatch({ control, name: 'traceSourceId' });
const metricSourceId = useWatch({ control, name: 'metricSourceId' });
const sessionTraceSourceId = useWatch({ control, name: 'traceSourceId' }); // For sessions
const prevLogSourceIdRef = useRef(logSourceId);
const prevTraceSourceIdRef = useRef(traceSourceId);
const prevMetricSourceIdRef = useRef(metricSourceId);
const prevSessionTraceSourceIdRef = useRef(sessionTraceSourceId);
useEffect(() => {
const { unsubscribe } = watch(async (_value, { name, type }) => {
const value = _value as TSourceUnion;
if (!currentSourceId || !sources || type !== 'change') return;
(async () => {
if (!currentSourceId || !sources || !kind) return;
const correlationFields = CORRELATION_FIELD_MAP[kind];
if (!correlationFields || !name || !(name in correlationFields)) return;
if (!correlationFields) return;
const fieldName = name as keyof TSourceUnion;
const newTargetSourceId = value[fieldName] as string | undefined;
const targetConfigs = correlationFields[fieldName];
// Check each field for changes
const changedFields: Array<{
name: keyof TSourceUnion;
value: string | undefined;
}> = [];
for (const { targetKind, targetField } of targetConfigs) {
// Find the previously linked source if any
const previouslyLinkedSource = sources.find(
s => s.kind === targetKind && s[targetField] === currentSourceId,
);
if (logSourceId !== prevLogSourceIdRef.current) {
prevLogSourceIdRef.current = logSourceId;
changedFields.push({
name: 'logSourceId' as keyof TSourceUnion,
value: logSourceId ?? undefined,
});
}
if (traceSourceId !== prevTraceSourceIdRef.current) {
prevTraceSourceIdRef.current = traceSourceId;
changedFields.push({
name: 'traceSourceId' as keyof TSourceUnion,
value: traceSourceId ?? undefined,
});
}
if (metricSourceId !== prevMetricSourceIdRef.current) {
prevMetricSourceIdRef.current = metricSourceId;
changedFields.push({
name: 'metricSourceId' as keyof TSourceUnion,
value: metricSourceId ?? undefined,
});
}
if (
sessionTraceSourceId !== prevSessionTraceSourceIdRef.current &&
kind === SourceKind.Session
) {
prevSessionTraceSourceIdRef.current = sessionTraceSourceId;
changedFields.push({
name: 'traceSourceId' as keyof TSourceUnion,
value: sessionTraceSourceId ?? undefined,
});
}
// If there was a previously linked source and it's different from the new one, unlink it
if (
previouslyLinkedSource &&
previouslyLinkedSource.id !== newTargetSourceId
) {
await updateSource.mutateAsync({
source: {
...previouslyLinkedSource,
[targetField]: undefined,
} as TSource,
});
}
for (const {
name: fieldName,
value: newTargetSourceId,
} of changedFields) {
if (!(fieldName in correlationFields)) continue;
// If a new source is selected, link it back
if (newTargetSourceId) {
const targetSource = sources.find(s => s.id === newTargetSourceId);
if (targetSource && targetSource.kind === targetKind) {
// Only update if the target field is empty to avoid overwriting existing correlations
if (!targetSource[targetField]) {
await updateSource.mutateAsync({
source: {
...targetSource,
[targetField]: currentSourceId,
} as TSource,
});
const targetConfigs = correlationFields[fieldName];
for (const { targetKind, targetField } of targetConfigs) {
// Find the previously linked source if any
const previouslyLinkedSource = sources.find(
s => s.kind === targetKind && s[targetField] === currentSourceId,
);
// If there was a previously linked source and it's different from the new one, unlink it
if (
previouslyLinkedSource &&
previouslyLinkedSource.id !== newTargetSourceId
) {
await updateSource.mutateAsync({
source: {
...previouslyLinkedSource,
[targetField]: undefined,
} as TSource,
});
}
// If a new source is selected, link it back
if (newTargetSourceId) {
const targetSource = sources.find(s => s.id === newTargetSourceId);
if (targetSource && targetSource.kind === targetKind) {
// Only update if the target field is empty to avoid overwriting existing correlations
if (!targetSource[targetField]) {
await updateSource.mutateAsync({
source: {
...targetSource,
[targetField]: currentSourceId,
} as TSource,
});
}
}
}
}
}
});
return () => unsubscribe();
}, [watch, kind, currentSourceId, sources, updateSource]);
})();
}, [
logSourceId,
traceSourceId,
metricSourceId,
sessionTraceSourceId,
kind,
currentSourceId,
sources,
updateSource,
]);
const sourceFormSchema = sourceSchemaWithout({ id: true });
const handleError = useCallback(
@ -1679,8 +1748,12 @@ export function TableSourceForm({
sourceFormSchema,
]);
const databaseName = watch(`from.databaseName`, DEFAULT_DATABASE);
const connectionId = watch(`connection`);
const databaseName = useWatch({
control,
name: 'from.databaseName',
defaultValue: DEFAULT_DATABASE,
});
const connectionId = useWatch({ control, name: 'connection' });
return (
<div
@ -1780,12 +1853,7 @@ export function TableSourceForm({
</FormRow>
)}
</Stack>
<TableModelForm
control={control}
watch={watch}
setValue={setValue}
kind={kind}
/>
<TableModelForm control={control} setValue={setValue} kind={kind} />
</div>
);
}

View file

@ -1,6 +1,6 @@
import { useEffect } from 'react';
import { HTTPError } from 'ky';
import { Controller, SubmitHandler, useForm } from 'react-hook-form';
import { Controller, SubmitHandler, useForm, useWatch } from 'react-hook-form';
import { ZodIssue } from 'zod';
import { json, jsonParseLinter } from '@codemirror/lang-json';
import { linter } from '@codemirror/lint';
@ -270,7 +270,7 @@ export function WebhookForm({
}
};
const service = form.watch('service');
const service = useWatch({ control: form.control, name: 'service' });
return (
<form onSubmit={form.handleSubmit(onSubmit)}>

View file

@ -142,7 +142,7 @@ export default function useRowWhere({
return useCallback(
(row: Record<string, any>) => {
// Filter out synthetic columns that aren't in the database schema
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { __hyperdx_id, ...dbRow } = row;
return processRowToWhereClause(dbRow, columnMap);
},

View file

@ -192,4 +192,44 @@ test.describe('Dashboard', { tag: ['@dashboard'] }, () => {
await expect(dashboardTiles).toHaveCount(tileCountBefore - 1);
});
});
test(
'should update charts when granularity is changed',
{ tag: '@dashboard' },
async () => {
await test.step('Create dashboard with a time series chart', async () => {
await dashboardPage.createNewDashboard();
// Add a time series tile
await dashboardPage.addTile();
await dashboardPage.chartEditor.createBasicChart(
'Time Series Test Chart',
);
// Wait for chart to render
const chartContainers = dashboardPage.getChartContainers();
await expect(chartContainers).toHaveCount(1, { timeout: 10000 });
});
await test.step('Change granularity and verify UI updates', async () => {
// Find granularity dropdown (typically labeled "Granularity" or shows current value like "Auto")
const granularityDropdown = dashboardPage.granularityPicker;
await expect(granularityDropdown).toBeVisible();
// Get current value
const currentValue = await granularityDropdown.inputValue();
// Change to a different granularity (e.g., "1m")
await dashboardPage.changeGranularity('1 Minute Granularity');
// Verify the value changed
const newValue = granularityDropdown;
await expect(newValue).not.toHaveValue(currentValue);
// Verify chart is still visible (validates that the change worked)
const chartContainers = dashboardPage.getChartContainers();
await expect(chartContainers).toHaveCount(1);
});
},
);
});

View file

@ -11,6 +11,7 @@ export class DashboardPage {
readonly page: Page;
readonly timePicker: TimePickerComponent;
readonly chartEditor: ChartEditorComponent;
readonly granularityPicker: Locator;
private readonly createDashboardButton: Locator;
private readonly addTileButton: Locator;
@ -34,6 +35,7 @@ export class DashboardPage {
);
this.liveButton = page.locator('button:has-text("Live")');
this.dashboardNameHeading = page.getByRole('heading', { level: 3 });
this.granularityPicker = page.getByTestId('granularity-picker');
}
/**
@ -58,6 +60,15 @@ export class DashboardPage {
await this.page.waitForURL('**/dashboards**');
}
async changeGranularity(granularity: string) {
await this.granularityPicker.click();
// Wait for dropdown options to appear and click the desired option
await this.page
.locator('[role="option"]', { hasText: granularity })
.click();
}
/**
* Edit dashboard name
*/

View file

@ -3975,6 +3975,20 @@ __metadata:
languageName: node
linkType: hard
"@eslint/compat@npm:^2.0.0":
version: 2.0.0
resolution: "@eslint/compat@npm:2.0.0"
dependencies:
"@eslint/core": "npm:^1.0.0"
peerDependencies:
eslint: ^8.40 || 9
peerDependenciesMeta:
eslint:
optional: true
checksum: 10c0/ba848e8ea02fbfba9985d6a6e5aeedf5c44c4f57cb01fb6b9629d2ebfa37f9eafa4506ad9690fb805e0c395269d84e09eae46bfe41884bfff6171ebd68e17373
languageName: node
linkType: hard
"@eslint/config-array@npm:^0.21.1":
version: 0.21.1
resolution: "@eslint/config-array@npm:0.21.1"
@ -4004,6 +4018,15 @@ __metadata:
languageName: node
linkType: hard
"@eslint/core@npm:^1.0.0":
version: 1.0.0
resolution: "@eslint/core@npm:1.0.0"
dependencies:
"@types/json-schema": "npm:^7.0.15"
checksum: 10c0/ce94edc4e7f78afac5815ba9afcae8c99d286a7d6f80efd596f6ba08dd1984277c4f3b2d6585f7a31d8235f9124ca311780ace664a7340a0cb19d9a71b413285
languageName: node
linkType: hard
"@eslint/eslintrc@npm:^3.3.1":
version: 3.3.3
resolution: "@eslint/eslintrc@npm:3.3.3"
@ -4294,6 +4317,7 @@ __metadata:
"@codemirror/lang-json": "npm:^6.0.1"
"@codemirror/lang-sql": "npm:^6.7.0"
"@dagrejs/dagre": "npm:^1.1.5"
"@eslint/compat": "npm:^2.0.0"
"@hookform/devtools": "npm:^4.3.1"
"@hookform/resolvers": "npm:^3.9.0"
"@hyperdx/browser": "npm:^0.21.1"
@ -4354,6 +4378,7 @@ __metadata:
dayjs: "npm:^1.11.19"
eslint-config-next: "npm:^16.0.10"
eslint-plugin-playwright: "npm:^2.4.0"
eslint-plugin-react-hook-form: "npm:^0.3.1"
eslint-plugin-storybook: "npm:10.1.4"
flat: "npm:^5.0.2"
fuse.js: "npm:^6.6.2"
@ -14599,6 +14624,15 @@ __metadata:
languageName: node
linkType: hard
"eslint-plugin-react-hook-form@npm:^0.3.1":
version: 0.3.1
resolution: "eslint-plugin-react-hook-form@npm:0.3.1"
dependencies:
requireindex: "npm:~1.1.0"
checksum: 10c0/bb2abc506706b6d8c1419f7a2510d927f2193ecee59d851f73d3bb8b2aa9aabc8aa1f8481aae2af44dc1d43ed14845e2a18f03e9b8a37bd2250144257b81e47a
languageName: node
linkType: hard
"eslint-plugin-react-hooks@npm:^7.0.0, eslint-plugin-react-hooks@npm:^7.0.1":
version: 7.0.1
resolution: "eslint-plugin-react-hooks@npm:7.0.1"
@ -24584,6 +24618,13 @@ __metadata:
languageName: node
linkType: hard
"requireindex@npm:~1.1.0":
version: 1.1.0
resolution: "requireindex@npm:1.1.0"
checksum: 10c0/4d57e7c6d160c4d043a233a6a95a22c792ada72ff378c5ebd415be505b328f3c719f9188dd100f8fcaa8af513a85f39eee0a5047838d715e22e79544a3b7d002
languageName: node
linkType: hard
"requires-port@npm:^1.0.0":
version: 1.0.0
resolution: "requires-port@npm:1.0.0"