Skip to content

Commit ec0db7a

Browse files
fix(app): ask for one name when alerting a new saved search (#3563)
## Summary Creating an alert from the search page asked for two names and never said which was which. On an **unsaved** search the modal showed a bordered "Saved search name" input in its header *and* the alert's own "Name" field, whose placeholder read "Defaults to the saved search or dashboard tile name" — a name field explaining itself in terms of a name field four pixels above it. On a **saved** search the header already read "Alerts for **Checkout errors**" and the alert's Name field was pre-filled with "Checkout errors" again, which reads as a duplicate rather than an override. This is a prototype of the simplification: **one name per screen.** - **Unsaved search:** the search is named once, inline in the header, in the same `InlineNameInput` ("Untitled search") the saved search page uses for the same object. The alert's own name and tags row is gone — the create payload omits `displayName`/`tags`, which the server already derives from the saved search it is attached to, so the alert comes out named after the search either way. The tags button next to the name now tags the *search* (previously it tagged only the alert and the new search was silently saved untagged). - **Saved search:** the alert's field is labelled "Alert name" and left empty with the inherited name as its placeholder, so it reads as an override rather than a copy. Tags show "Inherited". - The unnamed-search error moved from a red toast to an inline message under the name, and now fires on the *first* submit alongside the other field errors rather than only after the webhook is valid. - The single "New Alert" tab is hidden when there is no saved search (nothing to switch between), and the modal's buttons are sentence case. No API change. Removing the client-side prefill relies on `deriveAlertDisplayFields`, which already clamps the name and tags to the alert caps — the same clamping the client was doing by hand with `clampAlertDisplayName`/`clampAlertTags`. ### Screenshots or video Both captured on `yarn dev`, same viewport and theme. **Alerting an unsaved search** — two name fields before, one after: | Before | After | | :----- | :---- | | ![Before: Alerts for \[Saved search name\] input, plus a Name field placeholdered ](https://cursor.com/artifacts/c/art-5123c16b-2794-4aba-8292-b8d6db9efb3b) | ![After: Alerts for \[Untitled search\] inline name and a tags button, with the helper line ](https://cursor.com/artifacts/c/art-4a662cc7-157c-4310-ada3-489ccd29e4da) | **Alerting a saved search** — pre-filled duplicate before, inherited placeholder after: | Before | After | | :----- | :---- | | ![Before: header ](https://cursor.com/artifacts/c/art-d80020c0-e8c1-4807-b92e-e542789fccb5) | ![After: header ](https://cursor.com/artifacts/c/art-175750c1-7b8f-42c9-85a0-1651f0975c4d) | Unnamed search on the first submit: ![The inline name input outlined in red with ](https://cursor.com/artifacts/c/art-2927fc48-79b5-42b4-b0f8-c84d4b3d2ed8) The alert created this way lands with the search's name and tags: ![Alerts page listing an alert named ](https://cursor.com/artifacts/c/art-78572c62-ae50-4616-a927-d652b6ef4270) ### How to reproduce **Starting point:** `yarn dev` (the Alerts button is hidden in local mode, so the demo server and the Vercel preview can't reach this modal). Register, then open **Search** and pick any logs source. **On `main`:** 1. Without saving the search, click **Alerts**. 2. Two name inputs: "Saved search name" beside "Alerts for" in the header, and "Name" directly below it. Nothing says which one names what, or that leaving the second empty inherits the first. 3. Click **Save**, name the search `Checkout errors`, then click **Alerts** again: the header says "Alerts for Checkout errors" and the Name field repeats `Checkout errors`. **On this branch:** 1. Without saving the search, click **Alerts**. One name, inline after "Alerts for", placeholdered "Untitled search", with "Saving creates this search; the alert takes its name and tags." underneath. 2. Click **Save search and alert** with the name empty: "Name the search before saving it." appears inline, in the same pass as the webhook error. 3. Type `Payment failures`, add the tag `payments` with the button beside the name, pick a webhook, and save. You land on the new saved search, and **Alerts** lists an alert named `Payment failures` tagged `payments`. 4. On that saved search, click **Alerts** again: the field is now "Alert name", empty, placeholdered `Payment failures`, with tags reading "Inherited". ### How to test on Vercel preview N/A — the Alerts button is not rendered in local mode (`IS_LOCAL_MODE`), so the preview build cannot reach this modal. ### References - Linear Issue: HDX-5917 <div><a href="https://cursor.com/agents/bc-6733facc-b522-4d71-b388-4a42c5f6e389?cursor_ref=pr_footer&cursor_cta=open_in_web"><picture><source media="(prefers-color-scheme: dark)" srcset="https://cursor.com/assets/images/open-in-web-dark.png"><source media="(prefers-color-scheme: light)" srcset="https://cursor.com/assets/images/open-in-web-light.png"><img alt="Open in Web" width="114" height="28" src="https://cursor.com/assets/images/open-in-web-dark.png"></picture></a>&nbsp;<a href="https://cursor.com/background-agent?bcId=bc-6733facc-b522-4d71-b388-4a42c5f6e389&cursor_ref=pr_footer&cursor_cta=open_in_cursor"><picture><source media="(prefers-color-scheme: dark)" srcset="https://cursor.com/assets/images/open-in-cursor-dark.png"><source media="(prefers-color-scheme: light)" srcset="https://cursor.com/assets/images/open-in-cursor-light.png"><img alt="Open in Cursor" width="131" height="28" src="https://cursor.com/assets/images/open-in-cursor-dark.png"></picture></a>&nbsp;</div> Fixes HDX-5917 Co-authored-by: Cursor Agent <199161495+cursoragent@users.noreply.github.com>
1 parent 5abf27e commit ec0db7a

5 files changed

Lines changed: 285 additions & 118 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@hyperdx/app': patch
3+
---
4+
5+
fix: Creating an alert from the search page asks for one name, not two. An unsaved search is named once in the alert modal's header and the alert inherits that name and its tags; on a saved search the alert's own name field is labelled "Alert name" and shows the inherited name as its placeholder instead of pre-filling a duplicate.

‎packages/app/src/DBSearchPageAlertModal.tsx‎

Lines changed: 162 additions & 90 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,14 @@
11
import React from 'react';
22
import router from 'next/router';
3-
import { Controller, useForm, useWatch } from 'react-hook-form';
3+
import {
4+
Control,
5+
Controller,
6+
useForm,
7+
useFormState,
8+
useWatch,
9+
} from 'react-hook-form';
410
import { z } from 'zod';
511
import { zodResolver } from '@hookform/resolvers/zod';
6-
import {
7-
clampAlertDisplayName,
8-
clampAlertTags,
9-
} from '@hyperdx/common-utils/dist/alerts';
1012
import { tcFromSource } from '@hyperdx/common-utils/dist/core/metadata';
1113
import {
1214
type Alert,
@@ -38,17 +40,19 @@ import {
3840
Stack,
3941
Tabs,
4042
Text,
41-
TextInput,
4243
} from '@mantine/core';
4344
import { notifications } from '@mantine/notifications';
4445
import {
4546
IconChartLine,
4647
IconInfoCircleFilled,
4748
IconPlus,
49+
IconTags,
4850
} from '@tabler/icons-react';
4951
import { useQueryClient } from '@tanstack/react-query';
5052

53+
import { InlineNameInputControlled } from '@/components/InlineNameInput/InlineNameInput';
5154
import { SQLInlineEditorControlled } from '@/components/SQLEditor/SQLInlineEditor';
55+
import { Tags } from '@/components/Tags';
5256
import { useCreateSavedSearch } from '@/savedSearch';
5357
import { useSavedSearch } from '@/savedSearch';
5458
import { useSource } from '@/source';
@@ -95,20 +99,81 @@ const SavedSearchAlertFormSchema = z
9599
.superRefine(validateAlertScheduleOffsetMinutes)
96100
.superRefine(validateAlertThresholdMax);
97101

102+
type NewSavedSearchFormValues = { name: string };
103+
104+
/**
105+
* Name and tags for the search an alert is being created alongside. The alert
106+
* inherits both, so this is the only naming the flow asks for.
107+
*/
108+
const NewSavedSearchFields = ({
109+
control,
110+
tags,
111+
onTagsChange,
112+
}: {
113+
control: Control<NewSavedSearchFormValues>;
114+
tags: string[];
115+
onTagsChange: (tags: string[]) => void;
116+
}) => {
117+
const { errors } = useFormState({ control, name: 'name' });
118+
const nameError = errors.name?.message;
119+
120+
return (
121+
<>
122+
<Group gap="xs" wrap="nowrap">
123+
<Text size="sm" style={{ flexShrink: 0 }}>
124+
Alerts for
125+
</Text>
126+
<InlineNameInputControlled
127+
control={control}
128+
name="name"
129+
rules={{
130+
validate: value =>
131+
value.trim().length > 0 || 'Name the search before saving it.',
132+
}}
133+
size="xs"
134+
placeholder="Untitled search"
135+
aria-label="Saved search name"
136+
data-testid="saved-search-name-input"
137+
/>
138+
<Tags allowCreate values={tags} onChange={onTagsChange}>
139+
<Button
140+
data-testid="saved-search-tags-button"
141+
variant="secondary"
142+
size="xs"
143+
style={{ flexShrink: 0 }}
144+
>
145+
<IconTags size={14} className="me-1" />
146+
{tags.length}
147+
</Button>
148+
</Tags>
149+
</Group>
150+
{nameError ? (
151+
<Text size="xs" c="red" data-testid="saved-search-name-error">
152+
{nameError}
153+
</Text>
154+
) : (
155+
<Text size="xxs" opacity={0.5}>
156+
Saving creates this search; the alert takes its name and tags.
157+
</Text>
158+
)}
159+
</>
160+
);
161+
};
162+
98163
const AlertForm = ({
99164
sourceId,
100165
where,
101166
whereLanguage,
102167
filters,
103168
select,
104169
defaultValues,
105-
prefill,
106170
loading,
107171
deleteLoading,
108172
hasSavedSearch,
109173
savedSearchName,
110174
onDelete,
111175
onSubmit,
176+
onSubmitAttempt,
112177
onClose,
113178
}: {
114179
sourceId?: string | null;
@@ -117,14 +182,18 @@ const AlertForm = ({
117182
filters?: Filter[] | null;
118183
select?: string | null;
119184
defaultValues?: null | AlertWithCreatedBy;
120-
/** Seed values for a brand-new alert */
121-
prefill?: { displayName?: string; tags?: string[] };
122185
loading?: boolean;
123186
deleteLoading?: boolean;
124187
hasSavedSearch?: boolean;
125188
savedSearchName?: string;
126189
onDelete: (id: string) => void;
127190
onSubmit: (data: Alert) => void;
191+
/**
192+
* Runs on every submit, before this form validates, so fields the parent
193+
* owns can report their own errors in the same pass rather than only once
194+
* the alert itself is valid.
195+
*/
196+
onSubmitAttempt?: () => void;
128197
onClose: () => void;
129198
}) => {
130199
const { data: source } = useSource({ id: sourceId });
@@ -154,8 +223,6 @@ const AlertForm = ({
154223
source: AlertSource.SAVED_SEARCH,
155224
channels: [{ type: 'webhook', webhookId: '' }],
156225
note: null,
157-
displayName: prefill?.displayName,
158-
tags: prefill?.tags,
159226
},
160227
resolver: zodResolver(SavedSearchAlertFormSchema),
161228
});
@@ -183,39 +250,48 @@ const AlertForm = ({
183250
const { data: alertData } = api.useAlert(defaultValues?.id);
184251
const alert = alertData?.data;
185252

253+
const submitAlert = handleSubmit(data =>
254+
onSubmit(
255+
normalizeNoOpAlertScheduleFields(
256+
// `id` is not a registered form field, so carry it from the alert
257+
// this form was opened with. This binds the submission to the
258+
// originally-edited alert rather than whatever the parent's alerts
259+
// list happens to index at submit time.
260+
// `channel` is cleared (JSON drops undefined): the API derives it
261+
// from channels[0], and echoing a stale value back would conflict
262+
// with an edited list.
263+
{ ...data, channel: undefined, id: defaultValues?.id },
264+
defaultValues,
265+
{
266+
preserveExplicitScheduleOffsetMinutes:
267+
dirtyFields.scheduleOffsetMinutes === true,
268+
preserveExplicitScheduleStartAt: dirtyFields.scheduleStartAt === true,
269+
},
270+
),
271+
),
272+
);
273+
186274
return (
187275
<form
188-
onSubmit={handleSubmit(data =>
189-
onSubmit(
190-
normalizeNoOpAlertScheduleFields(
191-
// `id` is not a registered form field, so carry it from the alert
192-
// this form was opened with. This binds the submission to the
193-
// originally-edited alert rather than whatever the parent's alerts
194-
// list happens to index at submit time.
195-
// `channel` is cleared (JSON drops undefined): the API derives it
196-
// from channels[0], and echoing a stale value back would conflict
197-
// with an edited list.
198-
{ ...data, channel: undefined, id: defaultValues?.id },
199-
defaultValues,
200-
{
201-
preserveExplicitScheduleOffsetMinutes:
202-
dirtyFields.scheduleOffsetMinutes === true,
203-
preserveExplicitScheduleStartAt:
204-
dirtyFields.scheduleStartAt === true,
205-
},
206-
),
207-
),
208-
)}
276+
onSubmit={e => {
277+
onSubmitAttempt?.();
278+
return submitAlert(e);
279+
}}
209280
>
210281
<Paper px="sm" py="xs" radius="xs">
211282
<Stack gap="xs">
212-
<AlertDisplayFields
213-
control={control}
214-
displayNameName="displayName"
215-
tagsName="tags"
216-
derivedDisplayName={savedSearchName}
217-
labelMarginTop="0"
218-
/>
283+
{/* The alert inherits the name and tags of the search it watches, so
284+
there is nothing to override until that search exists. */}
285+
{hasSavedSearch && (
286+
<AlertDisplayFields
287+
control={control}
288+
displayNameName="displayName"
289+
tagsName="tags"
290+
derivedDisplayName={savedSearchName}
291+
displayNameLabel="Alert name"
292+
labelMarginTop="0"
293+
/>
294+
)}
219295
<Text size="xxs" opacity={0.5}>
220296
Trigger
221297
</Text>
@@ -422,7 +498,7 @@ const AlertForm = ({
422498
onClick={() => onDelete(defaultValues.id!)}
423499
loading={deleteLoading}
424500
>
425-
Delete Alert
501+
Delete alert
426502
</Button>
427503
)}
428504
</div>
@@ -432,10 +508,10 @@ const AlertForm = ({
432508
</Button>
433509
<Button variant="primary" type="submit" loading={loading}>
434510
{defaultValues
435-
? 'Save Alert'
511+
? 'Save alert'
436512
: hasSavedSearch
437-
? 'Create Alert'
438-
: 'Save Search with Alert'}
513+
? 'Create alert'
514+
: 'Save search and alert'}
439515
</Button>
440516
</Group>
441517
</Group>
@@ -480,30 +556,30 @@ export const DBSearchPageAlertModal = ({
480556
}
481557
};
482558

483-
const [name, setName] = React.useState<string>('');
559+
const newSavedSearchForm = useForm<NewSavedSearchFormValues>({
560+
defaultValues: { name: '' },
561+
});
562+
const [newSavedSearchTags, setNewSavedSearchTags] = React.useState<string[]>(
563+
[],
564+
);
484565

485566
const onSubmit = async (data: Alert) => {
486567
try {
487568
// Create new search along with alert
488569
if (!id && searchedConfig) {
489-
if (!name) {
490-
notifications.show({
491-
color: 'red',
492-
message: 'Please provide a name for the saved search.',
493-
autoClose: 5000,
494-
});
570+
if (!(await newSavedSearchForm.trigger())) {
495571
return;
496572
}
497573
const result = await createSavedSearch.mutateAsync({
498-
name,
574+
name: newSavedSearchForm.getValues('name').trim(),
499575
select: searchedConfig.select ?? '',
500576
where: searchedConfig.where ?? '',
501577
whereLanguage:
502578
searchedConfig.whereLanguage ?? getStoredLanguage() ?? 'lucene',
503579
source: searchedConfig.source ?? '',
504580
orderBy: searchedConfig.orderBy ?? '',
505581
filters: searchedConfig.filters ?? [],
506-
tags: [],
582+
tags: newSavedSearchTags,
507583
});
508584
await createAlert.mutateAsync({
509585
...data,
@@ -590,56 +666,49 @@ export const DBSearchPageAlertModal = ({
590666
}}
591667
loaderProps={{ type: 'dots' }}
592668
/>
593-
<Stack gap={0} mb="md">
594-
<Group>
669+
<Stack gap={4} mb="md">
670+
{id ? (
595671
<Text size="sm">
596672
Alerts for <strong>{savedSearch?.name}</strong>
597673
</Text>
598-
{!id && (
599-
<TextInput
600-
size="xs"
601-
placeholder="Saved search name"
602-
value={name}
603-
onChange={e => setName(e.currentTarget.value)}
604-
required
605-
/>
606-
)}
607-
</Group>
608-
<Text size="xxs">{savedSearch?.where}</Text>
674+
) : (
675+
<NewSavedSearchFields
676+
control={newSavedSearchForm.control}
677+
tags={newSavedSearchTags}
678+
onTagsChange={setNewSavedSearchTags}
679+
/>
680+
)}
681+
<Text size="xxs">{savedSearch?.where ?? searchedConfig?.where}</Text>
609682
</Stack>
610683

611-
<Tabs value={activeIndex} onChange={setTab} mb="xs">
612-
<Tabs.List>
613-
{(savedSearch?.alerts || []).map((alert, index) => (
614-
<Tabs.Tab key={alert.id} value={`${index}`}>
615-
<Group gap="xs">
616-
{getWebhookChannelIcon(alert.channel?.type)}
617-
Alert {index + 1}
618-
<AlertStatusIcon alerts={[alert]} />
684+
{/* An unsaved search has no alerts to switch between, and the one tab
685+
left reads as a control that does something. */}
686+
{id && (
687+
<Tabs value={activeIndex} onChange={setTab} mb="xs">
688+
<Tabs.List>
689+
{(savedSearch?.alerts || []).map((alert, index) => (
690+
<Tabs.Tab key={alert.id} value={`${index}`}>
691+
<Group gap="xs">
692+
{getWebhookChannelIcon(alert.channel?.type)}
693+
Alert {index + 1}
694+
<AlertStatusIcon alerts={[alert]} />
695+
</Group>
696+
</Tabs.Tab>
697+
))}
698+
<Tabs.Tab value="stage">
699+
<Group gap={4}>
700+
<IconPlus size={18} style={{ marginLeft: -8 }} />
701+
New alert
619702
</Group>
620703
</Tabs.Tab>
621-
))}
622-
<Tabs.Tab value="stage">
623-
<Group gap={4}>
624-
<IconPlus size={18} style={{ marginLeft: -8 }} />
625-
New Alert
626-
</Group>
627-
</Tabs.Tab>
628-
</Tabs.List>
629-
</Tabs>
704+
</Tabs.List>
705+
</Tabs>
706+
)}
630707

631708
<AlertForm
632709
key={activeIndex}
633710
hasSavedSearch={!!savedSearch}
634711
savedSearchName={savedSearch?.name}
635-
prefill={
636-
savedSearch
637-
? {
638-
displayName: clampAlertDisplayName(savedSearch.name),
639-
tags: clampAlertTags(savedSearch.tags),
640-
}
641-
: undefined
642-
}
643712
sourceId={searchedConfig?.source}
644713
where={searchedConfig?.where}
645714
whereLanguage={searchedConfig?.whereLanguage}
@@ -651,6 +720,9 @@ export const DBSearchPageAlertModal = ({
651720
: savedSearch?.alerts?.[parseInt(activeIndex)]
652721
}
653722
onSubmit={onSubmit}
723+
onSubmitAttempt={
724+
id ? undefined : () => void newSavedSearchForm.trigger()
725+
}
654726
onDelete={onDelete}
655727
deleteLoading={deleteAlert.isPending}
656728
onClose={onClose}

0 commit comments

Comments
 (0)