Skip to content

Commit c2a9f96

Browse files
authored
feat: Add more dashboard table onClick options (hyperdxio#2141)
## Summary This is the **second in a series of PRs** adding customizable on-click / linking behaviors to dashboard tables. This PR extends the custom dashboard table onClick behavior from hyperdxio#2140. This feature is behind the `NEXT_PUBLIC_IS_DASHBOARD_LINKING_ENABLED`, which has been enabled in the preview environment and for local development. ### Scope In this PR: 1. Table clicks can now link to other dashboards by name 2. Table clicks can now link to dashboards by ID 3. Table clicks can now link to search with a source set by ID ### Not included yet Future PRs will add: - Update dashboard import to support link source and dashboard ID mappings - Passing templated filter values to the destination dashboard or search - Updates to the external API to support the new onClick fields - Updates to the MCP prompts to support generating dashboards with custom links - Updates to the import flow to support importing bundles of linked dashboards at once - Support for linking on other visualization types ## Screenshots or video https://github.com/user-attachments/assets/18fc4033-3e28-4ef9-872d-2a27261758ae ## How to test locally or on Vercel This can be tested in the preview environment ## References - Linear Issue: Closes HDX-4064, Closes HDX-4066 - Related PRs:
1 parent 4e9caec commit c2a9f96

15 files changed

Lines changed: 1050 additions & 108 deletions

File tree

.changeset/rich-houses-divide.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
"@hyperdx/common-utils": patch
3+
"@hyperdx/app": patch
4+
---
5+
6+
feat: Add more dashboard onClick linking options

packages/app/src/DBDashboardPage.tsx

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1193,14 +1193,16 @@ function DBDashboardPage({ presetConfig }: { presetConfig?: Dashboard }) {
11931193
if (dashboard.savedQuery) {
11941194
setValue('where', dashboard.savedQuery);
11951195
setWhere(dashboard.savedQuery);
1196-
const savedLanguage = dashboard.savedQueryLanguage ?? 'lucene';
1196+
const savedLanguage =
1197+
dashboard.savedQueryLanguage ?? getStoredLanguage() ?? 'lucene';
11971198
setValue('whereLanguage', savedLanguage);
11981199
setWhereLanguage(savedLanguage);
11991200
} else if (isSwitchingDashboards) {
12001201
setValue('where', '');
12011202
setWhere('');
1202-
setValue('whereLanguage', 'lucene');
1203-
setWhereLanguage('lucene');
1203+
const storedLanguage = getStoredLanguage() ?? 'lucene';
1204+
setValue('whereLanguage', storedLanguage);
1205+
setWhereLanguage(storedLanguage);
12041206
}
12051207
}
12061208

@@ -1230,6 +1232,17 @@ function DBDashboardPage({ presetConfig }: { presetConfig?: Dashboard }) {
12301232
setFilterQueries,
12311233
]);
12321234

1235+
// Sync changes to the URL params into the form
1236+
useEffect(() => {
1237+
setValue('where', where);
1238+
setValue(
1239+
'whereLanguage',
1240+
whereLanguage === 'sql' || whereLanguage === 'lucene'
1241+
? whereLanguage
1242+
: (getStoredLanguage() ?? 'lucene'),
1243+
);
1244+
}, [setValue, where, whereLanguage]);
1245+
12331246
const handleSaveQuery = useCallback(() => {
12341247
if (!dashboard || isLocalDashboard) return;
12351248

packages/app/src/components/DBEditTimeChartForm/OnClickForm/OnClickDrawer.tsx

Lines changed: 93 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -1,75 +1,102 @@
11
import { useCallback, useEffect, useMemo } from 'react';
2-
import { Control, Controller, useForm, useWatch } from 'react-hook-form';
3-
import { z } from 'zod';
2+
import { Controller, useForm, useWatch } from 'react-hook-form';
43
import { zodResolver } from '@hookform/resolvers/zod';
5-
import { validateOnClickSearch } from '@hyperdx/common-utils/dist/core/linkUrlBuilder';
6-
import { OnClick, OnClickSchema } from '@hyperdx/common-utils/dist/types';
4+
import { validateOnClickTemplate } from '@hyperdx/common-utils/dist/core/linkUrlBuilder';
5+
import { isSearchableSource, OnClick } from '@hyperdx/common-utils/dist/types';
76
import {
87
Box,
98
Button,
109
Divider,
1110
Drawer,
1211
Group,
13-
InputLabel,
1412
SegmentedControl,
1513
Stack,
1614
Text,
17-
Tooltip,
1815
} from '@mantine/core';
1916
import { notifications } from '@mantine/notifications';
20-
import { IconHelpCircle } from '@tabler/icons-react';
2117

22-
import { TextInputControlled } from '@/components/InputControlled';
18+
import { InputLabelWithTooltip } from '@/components/InputLabelWithTooltip';
2319
import SearchWhereInput from '@/components/SearchInput/SearchWhereInput';
20+
import { useDashboards } from '@/dashboard';
21+
import { useSources } from '@/source';
2422

25-
import { emptySearchOnClick } from './utils';
23+
import { OnClickTargetInputControlled } from './OnClickTargetInputControlled';
24+
import {
25+
DrawerControl,
26+
DrawerFormValues,
27+
DrawerSchema,
28+
emptyDashboardOnClick,
29+
emptySearchOnClick,
30+
} from './utils';
2631

27-
const DrawerSchema = z.object({ onClick: OnClickSchema.nullish() });
32+
const TEMPLATE_HELP_TEXT = `Templates can reference column values from the clicked row using {{columnName}}.`;
2833

29-
type DrawerFormValues = z.infer<typeof DrawerSchema>;
30-
type DrawerControl = Control<DrawerFormValues>;
34+
function SearchOnClickFields({ control }: { control: DrawerControl }) {
35+
const { data: sources } = useSources();
3136

32-
const TEMPLATE_HELP_TEXT = `Templates can reference column values from the clicked row using {{columnName}}.`;
37+
const sourceOptions = useMemo(() => {
38+
return sources?.filter(isSearchableSource).map(source => ({
39+
label: source.name,
40+
value: source.id,
41+
}));
42+
}, [sources]);
3343

34-
function InputLabelWithTooltip({
35-
text,
36-
tooltip,
37-
}: {
38-
text: string;
39-
tooltip: string;
40-
}) {
4144
return (
42-
<Group gap="xs" align="center" mb={4}>
43-
<InputLabel mb={0}>{text}</InputLabel>
44-
<Tooltip label={tooltip}>
45-
<IconHelpCircle size={16} className="cursor-pointer" />
46-
</Tooltip>
47-
</Group>
45+
<Stack gap="xs">
46+
<Text size="xs" c="dimmed">
47+
{TEMPLATE_HELP_TEXT}
48+
</Text>
49+
50+
<OnClickTargetInputControlled
51+
control={control}
52+
options={sourceOptions}
53+
objectType="source"
54+
/>
55+
56+
<Box>
57+
<InputLabelWithTooltip
58+
label="WHERE"
59+
tooltip="Handlebars template that determines the WHERE condition passed to the search page"
60+
/>
61+
<SearchWhereInput
62+
control={control}
63+
name="onClick.whereTemplate"
64+
languageName="onClick.whereLanguage"
65+
allowMultiline
66+
showLabel={false}
67+
sqlPlaceholder="ServiceName = '{{ServiceName}}'"
68+
lucenePlaceholder="ServiceName:{{ServiceName}}"
69+
/>
70+
</Box>
71+
</Stack>
4872
);
4973
}
5074

51-
function SearchOnClickFields({ control }: { control: DrawerControl }) {
75+
function DashboardOnClickFields({ control }: { control: DrawerControl }) {
76+
const { data: dashboards } = useDashboards();
77+
const dashboardOptions = useMemo(() => {
78+
return dashboards?.map(dashboard => ({
79+
label: dashboard.name,
80+
value: dashboard.id,
81+
}));
82+
}, [dashboards]);
83+
5284
return (
5385
<Stack gap="xs">
5486
<Text size="xs" c="dimmed">
5587
{TEMPLATE_HELP_TEXT}
5688
</Text>
57-
<TextInputControlled
58-
name="onClick.target.template"
89+
90+
<OnClickTargetInputControlled
5991
control={control}
60-
label={
61-
<InputLabelWithTooltip
62-
text="Source"
63-
tooltip="Handlebars template that is matched by name against available Log and Trace sources"
64-
/>
65-
}
66-
placeholder="e.g. Logs or Logs-{{Environment}}"
67-
data-testid="onclick-source-template-input"
92+
options={dashboardOptions}
93+
objectType="dashboard"
6894
/>
95+
6996
<Box>
7097
<InputLabelWithTooltip
71-
text="WHERE"
72-
tooltip="Handlebars template that determines the WHERE condition passed to the search page"
98+
label="WHERE"
99+
tooltip="Handlebars template that determines the global WHERE condition passed to the dashboard"
73100
/>
74101
<SearchWhereInput
75102
control={control}
@@ -90,6 +117,8 @@ function ModeFields({ control }: { control: DrawerControl }) {
90117

91118
if (onClick?.type === 'search') {
92119
return <SearchOnClickFields control={control} />;
120+
} else if (onClick?.type === 'dashboard') {
121+
return <DashboardOnClickFields control={control} />;
93122
}
94123

95124
return (
@@ -130,11 +159,27 @@ export default function OnClickDrawer({
130159
if (opened) reset(appliedDefaults);
131160
}, [opened, appliedDefaults, reset]);
132161

162+
const { data: dashboards } = useDashboards();
163+
const { data: sources } = useSources();
164+
const watchedOnClick = useWatch({ control, name: 'onClick' });
165+
166+
const isTargetMissing = useMemo(() => {
167+
if (!watchedOnClick || watchedOnClick.target.mode !== 'id') return false;
168+
169+
const validTargetIds =
170+
watchedOnClick.type === 'dashboard'
171+
? dashboards?.map(d => d.id)
172+
: sources?.filter(isSearchableSource).map(s => s.id);
173+
174+
if (!validTargetIds) return false;
175+
return !validTargetIds.includes(watchedOnClick.target.id);
176+
}, [watchedOnClick, dashboards, sources]);
177+
133178
const applyChanges = useCallback(() => {
134179
handleSubmit(values => {
135180
try {
136-
if (values.onClick?.type === 'search') {
137-
validateOnClickSearch(values.onClick);
181+
if (values.onClick) {
182+
validateOnClickTemplate(values.onClick);
138183
}
139184
} catch (err) {
140185
notifications.show({
@@ -177,11 +222,16 @@ export default function OnClickDrawer({
177222
data={[
178223
{ label: 'Default', value: 'default' },
179224
{ label: 'Search', value: 'search' },
225+
{ label: 'Dashboard', value: 'dashboard' },
180226
]}
181227
value={onClickValue?.type ?? 'default'}
182228
onChange={value => {
183229
const formValue =
184-
value === 'search' ? emptySearchOnClick() : null;
230+
value === 'search'
231+
? emptySearchOnClick()
232+
: value === 'dashboard'
233+
? emptyDashboardOnClick()
234+
: null;
185235
setValue('onClick', formValue);
186236
}}
187237
fullWidth
@@ -199,6 +249,7 @@ export default function OnClickDrawer({
199249
<Button
200250
variant="primary"
201251
onClick={applyChanges}
252+
disabled={isTargetMissing}
202253
data-testid="onclick-apply-button"
203254
>
204255
Apply

packages/app/src/components/DBEditTimeChartForm/OnClickForm/OnClickFormButton.tsx

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,11 @@ export function OnClickFormButton({
2525

2626
const onClickValue = useWatch({ control, name: 'onClick' });
2727
const onClickTypeLabel =
28-
onClickValue?.type === 'search' ? 'Search' : 'Default';
28+
onClickValue?.type === 'search'
29+
? 'Search'
30+
: onClickValue?.type === 'dashboard'
31+
? 'Dashboard'
32+
: 'Default';
2933

3034
// TODO: Remove once feature flag is permanently enabled
3135
if (!IS_DASHBOARD_LINKING_ENABLED) {
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
import { useMemo } from 'react';
2+
import { Controller } from 'react-hook-form';
3+
import { Select } from '@mantine/core';
4+
5+
import { TextInputControlled } from '@/components/InputControlled';
6+
import { InputLabelWithTooltip } from '@/components/InputLabelWithTooltip';
7+
8+
import { DrawerControl } from './utils';
9+
10+
const TEMPLATE_SELECT_VALUE = 'template';
11+
12+
export function OnClickTargetInputControlled({
13+
control,
14+
options,
15+
objectType,
16+
}: {
17+
control: DrawerControl;
18+
options: { label: string; value: string }[] | undefined;
19+
objectType: 'source' | 'dashboard';
20+
}) {
21+
const optionsWithTemplate = useMemo(() => {
22+
return [
23+
{
24+
group: 'Template',
25+
items: [
26+
{
27+
label: 'Template',
28+
value: TEMPLATE_SELECT_VALUE,
29+
},
30+
],
31+
},
32+
{
33+
group: objectType === 'dashboard' ? 'Dashboard' : 'Source',
34+
items: options ?? [],
35+
},
36+
];
37+
}, [options, objectType]);
38+
39+
const label = objectType === 'dashboard' ? 'Dashboard' : 'Source';
40+
const labelTooltip =
41+
objectType === 'dashboard'
42+
? 'A dashboard, or a Handlebars template that is matched by name to an available dashboard'
43+
: 'A source, or a Handlebars template that is matched by name to an available Log or Trace source';
44+
const placeholder =
45+
objectType === 'dashboard'
46+
? 'e.g. Error Dashboard or Errors-{{ServiceName}}'
47+
: 'e.g. Logs or Logs-{{Environment}}';
48+
49+
return (
50+
<Controller
51+
control={control}
52+
name="onClick.target"
53+
render={({ field, fieldState }) => {
54+
const selectedId =
55+
field.value?.mode === 'id' ? field.value.id : undefined;
56+
const targetMissing =
57+
options != null &&
58+
selectedId != null &&
59+
selectedId !== '' &&
60+
!options.some(option => option.value === selectedId);
61+
62+
return (
63+
<>
64+
<InputLabelWithTooltip label={label} tooltip={labelTooltip} />
65+
<Select
66+
data={optionsWithTemplate}
67+
data-testid="onclick-target-select"
68+
value={
69+
field.value?.mode === 'template'
70+
? TEMPLATE_SELECT_VALUE
71+
: field.value?.id
72+
}
73+
error={
74+
targetMissing
75+
? `The previously selected ${objectType} no longer exists. Choose another ${objectType}.`
76+
: undefined
77+
}
78+
onChange={value => {
79+
if (value === TEMPLATE_SELECT_VALUE) {
80+
field.onChange({ mode: 'template', template: '' });
81+
} else {
82+
field.onChange({ mode: 'id', id: value ?? '' });
83+
}
84+
}}
85+
/>
86+
{field.value?.mode === 'template' && (
87+
<TextInputControlled
88+
control={control}
89+
name="onClick.target.template"
90+
placeholder={placeholder}
91+
data-testid="onclick-template-input"
92+
error={fieldState.error?.message}
93+
/>
94+
)}
95+
</>
96+
);
97+
}}
98+
/>
99+
);
100+
}
Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,10 @@
1-
import { OnClick } from '@hyperdx/common-utils/dist/types';
1+
import { Control } from 'react-hook-form';
2+
import z from 'zod';
3+
import { OnClick, OnClickSchema } from '@hyperdx/common-utils/dist/types';
4+
5+
export const DrawerSchema = z.object({ onClick: OnClickSchema.nullish() });
6+
export type DrawerFormValues = z.infer<typeof DrawerSchema>;
7+
export type DrawerControl = Control<DrawerFormValues>;
28

39
export function emptySearchOnClick(): OnClick {
410
return {
@@ -7,3 +13,11 @@ export function emptySearchOnClick(): OnClick {
713
whereLanguage: 'sql',
814
};
915
}
16+
17+
export function emptyDashboardOnClick(): OnClick {
18+
return {
19+
type: 'dashboard',
20+
target: { mode: 'template', template: '' },
21+
whereLanguage: 'sql',
22+
};
23+
}

0 commit comments

Comments
 (0)