type simplification

This commit is contained in:
Drew Davis 2026-04-20 16:11:30 -04:00
parent 34defe5a06
commit 404e2cd813
6 changed files with 254 additions and 230 deletions

View file

@ -50,13 +50,15 @@ export function registerSaveDashboard(
' `filters` they expect to receive (e.g. a `ServiceName` filter), since ' +
" the parent's onClick will drive those filter expressions.\n" +
' 2. Create the PARENT (overview) dashboard with table tiles whose ' +
' `onClick` points at the leaf dashboards. Two ways to reference the ' +
' target:\n' +
' - mode="id" + the ObjectId returned from step 1 (most precise).\n' +
' - mode="name-template" + a Handlebars template that resolves to the ' +
' EXACT name of the target dashboard. Dashboard names must be unique ' +
' on the team for this to resolve. Use this when the target is ' +
' dynamic (e.g. "{{ServiceName}} Details").\n' +
' `onClick` points at the leaf dashboards. The target shape is the ' +
' same for both dashboard and search onClicks:\n' +
' - `{ mode: "id", id: "<ObjectId>" }` — precise, use when you have ' +
' the returned id from step 1 (dashboard) or from ' +
' hyperdx_list_sources (source).\n' +
' - `{ mode: "template", template: "<Handlebars>" }` — rendered ' +
' per row; for dashboard onClicks the rendered value must match ' +
' the EXACT name of one dashboard on the team, and for search ' +
' onClicks it resolves to a source id or case-insensitive name.\n' +
' 3. Populate `filterValueTemplates` with one entry per filter value to ' +
' forward. Each entry is `{ filter: "<column/expression>", ' +
' template: "{{ColumnName}}" }`. Values are SQL-escaped automatically.\n' +

View file

@ -26,6 +26,60 @@ const mcpOnClickFilterEntrySchema = z.object({
),
});
// Both dashboard and search onClicks share the same target shape. `mode:'id'`
// means "send me to this specific dashboard/source by ObjectId", and
// `mode:'template'` means "render this Handlebars template per row and
// resolve it to a target at click time" — a dashboard name for dashboard
// onClicks, or a source id / name for search onClicks.
const mcpOnClickTargetSchema = z.discriminatedUnion('mode', [
z.object({
mode: z.literal('id'),
id: z
.string()
.describe(
'Destination ObjectId. For type="dashboard" this is the target ' +
'dashboard id (usually the id returned from a prior ' +
'hyperdx_save_dashboard call in the same session). For ' +
'type="search" this is a source id from hyperdx_list_sources.',
),
}),
z.object({
mode: z.literal('template'),
template: z
.string()
.describe(
'Handlebars template rendered per row. For type="dashboard" it ' +
'must resolve to the EXACT name of one dashboard on the team ' +
'(case-insensitive) — non-unique names abort with a toast. For ' +
'type="search" it resolves to either a source id or a case-' +
'insensitive source name. Example: "{{ServiceName}} Details".',
),
}),
]);
const mcpOnClickSharedFields = {
target: mcpOnClickTargetSchema,
whereTemplate: z
.string()
.optional()
.describe(
"Optional Handlebars template rendered into the destination's " +
'global WHERE input. Example: "ServiceName = \'{{ServiceName}}\'". ' +
'Row values are NOT auto-escaped here — use filterValueTemplates ' +
'for values coming from the row unless you need raw SQL.',
),
whereLanguage: SearchConditionLanguageSchema.optional().describe(
'Language of whereTemplate: "sql" or "lucene". Default "sql".',
),
filterValueTemplates: z
.array(mcpOnClickFilterEntrySchema)
.optional()
.describe(
'Adds per-column filters to the destination URL as ' +
'`expression IN (value)`. Values are SQL-escaped automatically.',
),
} as const;
const mcpOnClickSchema = z
.discriminatedUnion('type', [
z
@ -33,84 +87,20 @@ const mcpOnClickSchema = z
.describe('Default: row click opens search pre-filtered by group-by.'),
z.object({
type: z.literal('dashboard'),
target: z.discriminatedUnion('mode', [
z.object({
mode: z.literal('id'),
dashboardId: z
.string()
.describe(
'Target dashboard ObjectId. Use this when the target is a ' +
'specific, known dashboard (you usually have its id from a ' +
'prior hyperdx_save_dashboard call in the same session).',
),
}),
z.object({
mode: z.literal('name-template'),
nameTemplate: z
.string()
.describe(
'Handlebars template that must resolve to the exact name of ' +
'a dashboard on the same team (case-insensitive). The rendered ' +
'name must match EXACTLY ONE dashboard or the click surfaces ' +
'a toast error. Example: "{{ServiceName}} Details".',
),
}),
]),
whereTemplate: z
.string()
.optional()
.describe(
'Optional Handlebars template rendered into the destination ' +
"dashboard's global WHERE input. Example: \"ServiceName = '{{ServiceName}}'\". " +
'Row values are NOT auto-escaped here — use filterValueTemplates ' +
'for values coming from the row unless you need raw SQL.',
),
whereLanguage: SearchConditionLanguageSchema.optional().describe(
'Language of whereTemplate: "sql" or "lucene". Default "sql".',
),
filterValueTemplates: z
.array(mcpOnClickFilterEntrySchema)
.optional()
.describe(
'Adds per-column filters to the destination URL as ' +
'`expression IN (value)`. Values are SQL-escaped automatically.',
),
...mcpOnClickSharedFields,
}),
z.object({
type: z.literal('search'),
source: z.discriminatedUnion('mode', [
z.object({
mode: z.literal('id'),
sourceId: z
.string()
.describe('Target source id from hyperdx_list_sources.'),
}),
z.object({
mode: z.literal('template'),
sourceTemplate: z
.string()
.describe(
'Handlebars template rendered to a source id or case-insensitive ' +
'source name. Example: "{{SourceName}}".',
),
}),
]),
whereTemplate: z.string().optional(),
whereLanguage: SearchConditionLanguageSchema.optional(),
filterValueTemplates: z
.array(mcpOnClickFilterEntrySchema)
.optional()
.describe(
'Adds per-column filters on the destination search page as ' +
'`expression IN (value)`.',
),
...mcpOnClickSharedFields,
}),
])
.describe(
'Row-click drill-down action. Only applies to table tiles. On click, the ' +
"row's column values are threaded through Handlebars templates so the " +
"destination reflects the clicked row. The current dashboard's time " +
'range is always propagated.\n\n' +
'range is always propagated. Dashboard and Search onClicks share the ' +
'same shape — only the `type` (and where the target resolves to) ' +
'differs.\n\n' +
'Available Handlebars helpers:\n' +
' • {{int v}} round a number / numeric string to an integer\n' +
' • {{default v "fb"}} fallback when v is null/empty\n' +
@ -123,11 +113,11 @@ const mcpOnClickSchema = z
'toast error.\n\n' +
'Typical patterns:\n' +
'1. Drill from a services table into a per-service dashboard:\n' +
' { "type": "dashboard", "target": { "mode": "name-template", ' +
'"nameTemplate": "{{ServiceName}} Details" }, ' +
' { "type": "dashboard", "target": { "mode": "template", ' +
'"template": "{{ServiceName}} Details" }, ' +
'"filterValueTemplates": [{ "filter": "ServiceName", "template": "{{ServiceName}}" }] }\n' +
'2. Drill into search for a specific error:\n' +
' { "type": "search", "source": { "mode": "id", "sourceId": "<log source>" }, ' +
' { "type": "search", "target": { "mode": "id", "id": "<log source id>" }, ' +
'"filterValueTemplates": [{ "filter": "TraceId", "template": "{{TraceId}}" }] }',
);
@ -460,13 +450,13 @@ export const mcpTilesParam = z
'5. Linked table (drill into a per-service dashboard by name): ' +
'{ "name": "Services", "config": { "displayType": "table", "sourceId": "<from list_sources>", ' +
'"groupBy": "ResourceAttributes[\'service.name\']", "select": [{ "aggFn": "count" }], ' +
'"onClick": { "type": "dashboard", "target": { "mode": "name-template", ' +
'"nameTemplate": "{{`ResourceAttributes[\'service.name\']`}} Details" }, ' +
'"onClick": { "type": "dashboard", "target": { "mode": "template", ' +
'"template": "{{`ResourceAttributes[\'service.name\']`}} Details" }, ' +
'"filterValueTemplates": [{ "filter": "ResourceAttributes[\'service.name\']", ' +
'"template": "{{`ResourceAttributes[\'service.name\']`}}" }] } } }\n' +
'6. Linked table (drill into search by trace id): ' +
'{ "name": "Recent Errors", "config": { "displayType": "table", "sourceId": "<log source>", ' +
'"groupBy": "TraceId", "select": [{ "aggFn": "count", "where": "SeverityText:ERROR" }], ' +
'"onClick": { "type": "search", "source": { "mode": "id", "sourceId": "<log source>" }, ' +
'"onClick": { "type": "search", "target": { "mode": "id", "id": "<log source>" }, ' +
'"filterValueTemplates": [{ "filter": "TraceId", "template": "{{TraceId}}" }] } } }',
);

View file

@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo } from 'react';
import { useCallback, useEffect, useMemo, useRef } from 'react';
import { Control, Controller, useForm, useWatch } from 'react-hook-form';
import { z } from 'zod';
import { zodResolver } from '@hookform/resolvers/zod';
@ -18,11 +18,11 @@ import {
Select,
Stack,
Text,
Textarea,
TextInput,
} from '@mantine/core';
import { IconPlus, IconTrash } from '@tabler/icons-react';
import SearchWhereInput from '@/components/SearchInput/SearchWhereInput';
import { useDashboards } from '@/dashboard';
import { useSources } from '@/source';
@ -40,7 +40,7 @@ const DrawerSchema = z.object({ onClick: TableOnClickSchema });
function emptyDashboardOnClick(): TableOnClick {
return {
type: 'dashboard',
target: { mode: 'id', dashboardId: '' },
target: { mode: 'id', id: '' },
whereLanguage: 'sql',
};
}
@ -48,7 +48,7 @@ function emptyDashboardOnClick(): TableOnClick {
function emptySearchOnClick(): TableOnClick {
return {
type: 'search',
source: { mode: 'id', sourceId: '' },
target: { mode: 'id', id: '' },
whereLanguage: 'sql',
};
}
@ -57,6 +57,29 @@ function noneOnClick(): TableOnClick {
return { type: 'none' };
}
/**
* Fields shared by the `dashboard` and `search` onClick variants. Preserved
* across mode toggles so users don't lose a half-written WHERE template or
* their filter rows when experimenting with destinations.
*/
type SharedTemplateFields = Pick<
Extract<TableOnClick, { type: 'dashboard' }>,
'whereTemplate' | 'whereLanguage' | 'filterValueTemplates'
>;
function carryAcrossModes(
from: TableOnClick | undefined,
): SharedTemplateFields {
if (!from || from.type === 'none') return {};
const out: SharedTemplateFields = {};
if (from.whereTemplate !== undefined) out.whereTemplate = from.whereTemplate;
if (from.whereLanguage !== undefined) out.whereLanguage = from.whereLanguage;
if (from.filterValueTemplates !== undefined) {
out.filterValueTemplates = from.filterValueTemplates;
}
return out;
}
type TableOnClickDrawerProps = {
opened: boolean;
value: TableOnClick | undefined;
@ -128,11 +151,18 @@ export default function TableOnClickDrawer({
]}
value={onClickValue?.type ?? 'none'}
onChange={next => {
if (next === 'none') setValue('onClick', noneOnClick());
else if (next === 'dashboard')
setValue('onClick', emptyDashboardOnClick());
else if (next === 'search')
setValue('onClick', emptySearchOnClick());
if (next === 'none') {
setValue('onClick', noneOnClick());
return;
}
const base =
next === 'dashboard'
? emptyDashboardOnClick()
: emptySearchOnClick();
setValue('onClick', {
...base,
...carryAcrossModes(onClickValue),
});
}}
fullWidth
/>
@ -171,11 +201,23 @@ function ModeFields({
if (!onClick) return null;
if (onClick.type === 'dashboard') {
return <DashboardOnClickFields onClick={onClick} setValue={setValue} />;
return (
<DashboardOnClickFields
onClick={onClick}
setValue={setValue}
control={control}
/>
);
}
if (onClick.type === 'search') {
return <SearchOnClickFields onClick={onClick} setValue={setValue} />;
return (
<SearchOnClickFields
onClick={onClick}
setValue={setValue}
control={control}
/>
);
}
// Default (type: 'none')
@ -190,9 +232,11 @@ function ModeFields({
function DashboardOnClickFields({
onClick,
setValue,
control,
}: {
onClick: Extract<TableOnClick, { type: 'dashboard' }>;
setValue: (name: 'onClick', value: TableOnClick) => void;
control: Control<DrawerFormValues>;
}) {
const { data: dashboards } = useDashboards();
@ -203,24 +247,53 @@ function DashboardOnClickFields({
const mode = onClick.target.mode;
// Seed the filter list with the selected dashboard's declared filter
// expressions (value templates left blank) the first time a user picks
// a specific dashboard while the current list is "blank" — i.e. empty, or
// every entry has no template value filled in yet. The ref tracks the
// last dashboard we've handled so clearing rows or switching away and
// back doesn't silently re-populate on top of user intent.
const seededForDashboardRef = useRef<string | undefined>(undefined);
useEffect(() => {
const dashboardId =
onClick.target.mode === 'id' ? onClick.target.id : undefined;
if (!dashboardId) return;
if (seededForDashboardRef.current === dashboardId) return;
const target = (dashboards ?? []).find(d => d.id === dashboardId);
if (!target) return;
seededForDashboardRef.current = dashboardId;
const targetFilters = target.filters ?? [];
if (targetFilters.length === 0) return;
const currentEntries = onClick.filterValueTemplates ?? [];
const allTemplatesBlank = currentEntries.every(e => !e.template);
if (!allTemplatesBlank) return; // preserve anything the user typed
setValue('onClick', {
...onClick,
filterValueTemplates: targetFilters.map(f => ({
filter: f.expression,
template: '',
})),
});
}, [onClick, dashboards, setValue]);
return (
<Stack gap="xs">
<SegmentedControl
data={[
{ label: 'By Dashboard', value: 'id' },
{ label: 'By Name (templated)', value: 'name-template' },
{ label: 'By Name (templated)', value: 'template' },
]}
value={mode}
onChange={next => {
if (next === 'id') {
setValue('onClick', {
...onClick,
target: { mode: 'id', dashboardId: '' },
target: { mode: 'id', id: '' },
});
} else {
setValue('onClick', {
...onClick,
target: { mode: 'name-template', nameTemplate: '' },
target: { mode: 'template', template: '' },
});
}
}}
@ -232,65 +305,50 @@ function DashboardOnClickFields({
placeholder="Select a dashboard"
searchable
data={dashboardOptions}
value={onClick.target.mode === 'id' ? onClick.target.dashboardId : ''}
value={onClick.target.mode === 'id' ? onClick.target.id : ''}
onChange={next => {
setValue('onClick', {
...onClick,
target: { mode: 'id', dashboardId: next ?? '' },
target: { mode: 'id', id: next ?? '' },
});
}}
/>
)}
{mode === 'name-template' && (
{mode === 'template' && (
<TextInput
label="Target dashboard name (Handlebars)"
description="Rendered per row. Target name must match exactly one dashboard on your team."
placeholder="{{ServiceName}} Errors"
value={
onClick.target.mode === 'name-template'
? onClick.target.nameTemplate
: ''
onClick.target.mode === 'template' ? onClick.target.template : ''
}
onChange={e => {
setValue('onClick', {
...onClick,
target: {
mode: 'name-template',
nameTemplate: e.currentTarget.value,
mode: 'template',
template: e.currentTarget.value,
},
});
}}
/>
)}
<Textarea
label="Global WHERE template (optional)"
placeholder="ServiceName = '{{ServiceName}}'"
autosize
minRows={2}
value={onClick.whereTemplate ?? ''}
onChange={e => {
setValue('onClick', {
...onClick,
whereTemplate: e.currentTarget.value || undefined,
});
}}
/>
<SegmentedControl
data={[
{ label: 'SQL', value: 'sql' },
{ label: 'Lucene', value: 'lucene' },
]}
value={onClick.whereLanguage ?? 'sql'}
onChange={next => {
setValue('onClick', {
...onClick,
whereLanguage: next as 'sql' | 'lucene',
});
}}
/>
<Box>
<Text size="sm" fw={500} mb={4}>
Global WHERE template (optional)
</Text>
<SearchWhereInput
control={control}
name="onClick.whereTemplate"
languageName="onClick.whereLanguage"
showLabel={false}
allowMultiline
sqlPlaceholder="ServiceName = '{{ServiceName}}'"
lucenePlaceholder="ServiceName:{{ServiceName}}"
/>
</Box>
<FilterExpressionList
entries={onClick.filterValueTemplates ?? []}
@ -394,9 +452,11 @@ function FilterExpressionList({
function SearchOnClickFields({
onClick,
setValue,
control,
}: {
onClick: Extract<TableOnClick, { type: 'search' }>;
setValue: (name: 'onClick', value: TableOnClick) => void;
control: Control<DrawerFormValues>;
}) {
const { data: sources } = useSources();
const sourceOptions = useMemo(
@ -404,7 +464,7 @@ function SearchOnClickFields({
[sources],
);
const mode = onClick.source.mode;
const mode = onClick.target.mode;
return (
<Stack gap="xs">
@ -418,12 +478,12 @@ function SearchOnClickFields({
if (next === 'id') {
setValue('onClick', {
...onClick,
source: { mode: 'id', sourceId: '' },
target: { mode: 'id', id: '' },
});
} else {
setValue('onClick', {
...onClick,
source: { mode: 'template', sourceTemplate: '' },
target: { mode: 'template', template: '' },
});
}
}}
@ -435,11 +495,11 @@ function SearchOnClickFields({
placeholder="Select a source"
searchable
data={sourceOptions}
value={onClick.source.mode === 'id' ? onClick.source.sourceId : ''}
value={onClick.target.mode === 'id' ? onClick.target.id : ''}
onChange={next => {
setValue('onClick', {
...onClick,
source: { mode: 'id', sourceId: next ?? '' },
target: { mode: 'id', id: next ?? '' },
});
}}
/>
@ -448,52 +508,37 @@ function SearchOnClickFields({
{mode === 'template' && (
<TextInput
label="Source template"
description="Resolves to a source name (case-insensitive)"
placeholder="Logs-{{SourceName}}"
description="Resolves to a source id or case-insensitive source name."
placeholder="{{SourceName}}"
value={
onClick.source.mode === 'template'
? onClick.source.sourceTemplate
: ''
onClick.target.mode === 'template' ? onClick.target.template : ''
}
onChange={e => {
setValue('onClick', {
...onClick,
source: {
target: {
mode: 'template',
sourceTemplate: e.currentTarget.value,
template: e.currentTarget.value,
},
});
}}
/>
)}
<Textarea
label="WHERE template"
placeholder="ServiceName = '{{ServiceName}}'"
autosize
minRows={2}
value={onClick.whereTemplate ?? ''}
onChange={e => {
setValue('onClick', {
...onClick,
whereTemplate: e.currentTarget.value || undefined,
});
}}
/>
<SegmentedControl
data={[
{ label: 'SQL', value: 'sql' },
{ label: 'Lucene', value: 'lucene' },
]}
value={onClick.whereLanguage ?? 'sql'}
onChange={next => {
setValue('onClick', {
...onClick,
whereLanguage: next as 'sql' | 'lucene',
});
}}
/>
<Box>
<Text size="sm" fw={500} mb={4}>
WHERE template
</Text>
<SearchWhereInput
control={control}
name="onClick.whereTemplate"
languageName="onClick.whereLanguage"
showLabel={false}
allowMultiline
sqlPlaceholder="ServiceName = '{{ServiceName}}'"
lucenePlaceholder="ServiceName:{{ServiceName}}"
/>
</Box>
<FilterExpressionList
entries={onClick.filterValueTemplates ?? []}

View file

@ -25,7 +25,7 @@ describe('buildDashboardLinkUrl', () => {
it('uses a concrete id target', () => {
const onClick: TableOnClickDashboard = {
type: 'dashboard',
target: { mode: 'id', dashboardId: 'dash_1' },
target: { mode: 'id', id: 'dash_1' },
whereLanguage: 'sql',
};
const result = buildDashboardLinkUrl({
@ -46,8 +46,8 @@ describe('buildDashboardLinkUrl', () => {
const onClick: TableOnClickDashboard = {
type: 'dashboard',
target: {
mode: 'name-template',
nameTemplate: '{{ServiceName}} Errors',
mode: 'template',
template: '{{ServiceName}} Errors',
},
whereLanguage: 'sql',
};
@ -64,7 +64,7 @@ describe('buildDashboardLinkUrl', () => {
it('resolves case-insensitively', () => {
const onClick: TableOnClickDashboard = {
type: 'dashboard',
target: { mode: 'name-template', nameTemplate: 'API ERRORS' },
target: { mode: 'template', template: 'API ERRORS' },
whereLanguage: 'sql',
};
const result = buildDashboardLinkUrl({
@ -81,8 +81,8 @@ describe('buildDashboardLinkUrl', () => {
const onClick: TableOnClickDashboard = {
type: 'dashboard',
target: {
mode: 'name-template',
nameTemplate: '{{ServiceName}} Errors',
mode: 'template',
template: '{{ServiceName}} Errors',
},
whereLanguage: 'sql',
};
@ -101,7 +101,7 @@ describe('buildDashboardLinkUrl', () => {
it('errors when the rendered name matches more than one dashboard', () => {
const onClick: TableOnClickDashboard = {
type: 'dashboard',
target: { mode: 'name-template', nameTemplate: 'Errors' },
target: { mode: 'template', template: 'Errors' },
whereLanguage: 'sql',
};
const result = buildDashboardLinkUrl({
@ -126,7 +126,7 @@ describe('buildDashboardLinkUrl', () => {
// In strict mode, referencing a missing key throws — so a template that
// legitimately resolves to empty needs the key to exist with a falsy
// value and a helper default.
target: { mode: 'name-template', nameTemplate: '{{default v ""}}' },
target: { mode: 'template', template: '{{default v ""}}' },
whereLanguage: 'sql',
};
const result = buildDashboardLinkUrl({
@ -143,8 +143,8 @@ describe('buildDashboardLinkUrl', () => {
const onClick: TableOnClickDashboard = {
type: 'dashboard',
target: {
mode: 'name-template',
nameTemplate: '{{ServiceName}} Errors',
mode: 'template',
template: '{{ServiceName}} Errors',
},
whereLanguage: 'sql',
};
@ -163,7 +163,7 @@ describe('buildDashboardLinkUrl', () => {
it('renders whereTemplate into the url', () => {
const onClick: TableOnClickDashboard = {
type: 'dashboard',
target: { mode: 'id', dashboardId: 'd1' },
target: { mode: 'id', id: 'd1' },
whereTemplate: "ServiceName = '{{ServiceName}}'",
whereLanguage: 'sql',
};
@ -184,7 +184,7 @@ describe('buildDashboardLinkUrl', () => {
it('renders filterValueTemplates into SQL IN conditions using the raw expression', () => {
const onClick: TableOnClickDashboard = {
type: 'dashboard',
target: { mode: 'id', dashboardId: 'd1' },
target: { mode: 'id', id: 'd1' },
filterValueTemplates: [
{ filter: 'ServiceName', template: '{{ServiceName}}' },
{ filter: 'Env', template: 'prod' },
@ -211,7 +211,7 @@ describe('buildDashboardLinkUrl', () => {
it('skips filterValueTemplates rows with empty filter or template', () => {
const onClick: TableOnClickDashboard = {
type: 'dashboard',
target: { mode: 'id', dashboardId: 'd1' },
target: { mode: 'id', id: 'd1' },
filterValueTemplates: [
{ filter: '', template: '{{ServiceName}}' },
{ filter: 'Env', template: '' },
@ -236,7 +236,7 @@ describe('buildDashboardLinkUrl', () => {
it('merges repeated filter expressions into a single IN clause', () => {
const onClick: TableOnClickDashboard = {
type: 'dashboard',
target: { mode: 'id', dashboardId: 'd1' },
target: { mode: 'id', id: 'd1' },
filterValueTemplates: [
{ filter: 'ServiceName', template: 'api' },
{ filter: 'Env', template: 'prod' },
@ -274,7 +274,7 @@ describe('renderSearchLinkPieces', () => {
it('resolves source by id', () => {
const onClick: TableOnClickSearch = {
type: 'search',
source: { mode: 'id', sourceId: 'src_1' },
target: { mode: 'id', id: 'src_1' },
whereLanguage: 'sql',
};
const result = renderSearchLinkPieces({
@ -290,7 +290,7 @@ describe('renderSearchLinkPieces', () => {
it('resolves source by templated name', () => {
const onClick: TableOnClickSearch = {
type: 'search',
source: { mode: 'template', sourceTemplate: '{{Src}}' },
target: { mode: 'template', template: '{{Src}}' },
whereLanguage: 'sql',
};
const result = renderSearchLinkPieces({
@ -309,7 +309,7 @@ describe('renderSearchLinkPieces', () => {
it('errors when source template does not match any source', () => {
const onClick: TableOnClickSearch = {
type: 'search',
source: { mode: 'template', sourceTemplate: '{{Src}}' },
target: { mode: 'template', template: '{{Src}}' },
whereLanguage: 'sql',
};
const result = renderSearchLinkPieces({
@ -325,7 +325,7 @@ describe('renderSearchLinkPieces', () => {
it('renders filterValueTemplates into SQL IN conditions', () => {
const onClick: TableOnClickSearch = {
type: 'search',
source: { mode: 'id', sourceId: 'src_1' },
target: { mode: 'id', id: 'src_1' },
whereLanguage: 'sql',
filterValueTemplates: [
{ filter: 'ServiceName', template: '{{ServiceName}}' },
@ -350,7 +350,7 @@ describe('renderSearchLinkPieces', () => {
it('skips filterValueTemplates rows with empty filter or template', () => {
const onClick: TableOnClickSearch = {
type: 'search',
source: { mode: 'id', sourceId: 'src_1' },
target: { mode: 'id', id: 'src_1' },
whereLanguage: 'sql',
filterValueTemplates: [
{ filter: '', template: '{{ServiceName}}' },
@ -375,7 +375,7 @@ describe('renderSearchLinkPieces', () => {
it('merges repeated filter expressions into a single IN clause', () => {
const onClick: TableOnClickSearch = {
type: 'search',
source: { mode: 'id', sourceId: 'src_1' },
target: { mode: 'id', id: 'src_1' },
whereLanguage: 'sql',
filterValueTemplates: [
{ filter: 'ServiceName', template: 'api' },

View file

@ -87,11 +87,11 @@ function renderFilterTemplates(
/**
* Build a URL to navigate from a table row click to another dashboard.
* For `mode: 'name-template'`, resolves the rendered dashboard name against
* the supplied lookup (case-insensitive). Since dashboard names are not
* unique per team, ambiguous resolutions surface an error rather than
* silently picking one. Renders handlebars templates with the row context
* and appends the current dashboard's time range so it is preserved across
* For `mode: 'template'`, resolves the rendered dashboard name against the
* supplied lookup (case-insensitive). Since dashboard names are not unique
* per team, ambiguous resolutions surface an error rather than silently
* picking one. Renders handlebars templates with the row context and
* appends the current dashboard's time range so it is preserved across
* navigation.
*/
export function buildDashboardLinkUrl({
@ -107,16 +107,16 @@ export function buildDashboardLinkUrl({
}): LinkBuildResult {
let dashboardId: string;
if (onClick.target.mode === 'id') {
if (!onClick.target.dashboardId) {
if (!onClick.target.id) {
return {
ok: false,
error: 'Dashboard link: no target dashboard selected',
};
}
dashboardId = onClick.target.dashboardId;
dashboardId = onClick.target.id;
} else {
const nameResult = renderOrError(
onClick.target.nameTemplate,
onClick.target.template,
row,
'Dashboard link',
);
@ -203,8 +203,8 @@ export function renderSearchLinkPieces({
}): { ok: true; value: RenderedSearchLink } | { ok: false; error: string } {
let sourceId: string;
let sourceResolvedFrom: RenderedSearchLink['sourceResolvedFrom'];
if (onClick.source.mode === 'id') {
sourceId = onClick.source.sourceId;
if (onClick.target.mode === 'id') {
sourceId = onClick.target.id;
if (!sourceId) {
return { ok: false, error: 'Search link: no target source selected' };
}
@ -216,11 +216,7 @@ export function renderSearchLinkPieces({
}
sourceResolvedFrom = 'id';
} else {
const rendered = renderOrError(
onClick.source.sourceTemplate,
row,
'Search link',
);
const rendered = renderOrError(onClick.target.template, row, 'Search link');
if (!rendered.ok) return rendered;
const value = rendered.value.trim();
if (value === '') {

View file

@ -688,21 +688,23 @@ export type TableOnClickFilterTemplate = z.infer<
typeof TableOnClickFilterTemplateSchema
>;
/**
* Shared target shape for both dashboard and search onClicks:
* - `{ mode: 'id', id }` references a specific destination by its ObjectId.
* - `{ mode: 'template', template }` renders a Handlebars template to either
* a name (for dashboards) or a name / id (for sources) at click time.
* Empty strings are permitted so the drawer can be committed mid-edit; the
* URL builder surfaces empty values as explicit errors at click time.
*/
export const TableOnClickTargetSchema = z.discriminatedUnion('mode', [
z.object({ mode: z.literal('id'), id: z.string() }),
z.object({ mode: z.literal('template'), template: z.string() }),
]);
export type TableOnClickTarget = z.infer<typeof TableOnClickTargetSchema>;
export const TableOnClickDashboardSchema = z.object({
type: z.literal('dashboard'),
// Empty strings are permitted so the drawer can be committed mid-edit (e.g.,
// user switched to dashboard mode but hasn't picked a target yet). Empty
// values are surfaced as errors at click time by the URL builder.
target: z.discriminatedUnion('mode', [
z.object({
mode: z.literal('id'),
dashboardId: z.string(),
}),
z.object({
mode: z.literal('name-template'),
nameTemplate: z.string(),
}),
]),
target: TableOnClickTargetSchema,
whereTemplate: z.string().optional(),
whereLanguage: SearchConditionLanguageSchema,
filterValueTemplates: z.array(TableOnClickFilterTemplateSchema).optional(),
@ -711,18 +713,7 @@ export type TableOnClickDashboard = z.infer<typeof TableOnClickDashboardSchema>;
export const TableOnClickSearchSchema = z.object({
type: z.literal('search'),
// Same as dashboard above: allow empty strings so Apply can commit
// mid-edit; the URL builder validates at click time.
source: z.discriminatedUnion('mode', [
z.object({
mode: z.literal('id'),
sourceId: z.string(),
}),
z.object({
mode: z.literal('template'),
sourceTemplate: z.string(),
}),
]),
target: TableOnClickTargetSchema,
whereTemplate: z.string().optional(),
whereLanguage: SearchConditionLanguageSchema,
/**