Skip to content

Commit d9bf563

Browse files
kathiekiwiTrigger.dev RepoOps
authored andcommitted
fix(webapp): Alerts UI fixes & polish
fix(webapp): improve alert creation, editing, and Slack connection flow Mono-RevId: a038b8e5f71e7150954feece93ecc862d7d0ef60
1 parent a21e3be commit d9bf563

26 files changed

Lines changed: 770 additions & 168 deletions
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
area: webapp
3+
type: fix
4+
---
5+
6+
Small fixes to creating and editing alerts.

‎apps/webapp/app/components/alerts/AddChannelFromNotifiers.dom.test.ts‎

Lines changed: 200 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -23,10 +23,17 @@ vi.mock("~/components/primitives/Dialog", () => ({
2323
DialogContent: ({ children }: any) => createElement("div", null, children),
2424
DialogHeader: ({ children }: any) => createElement("h2", null, children),
2525
}));
26-
vi.mock("~/hooks/useOrganizations", () => ({ useOrganization: () => ({ slug: "org" }) }));
27-
vi.mock("~/hooks/useProject", () => ({ useProject: () => ({ slug: "proj", id: "p1" }) }));
26+
vi.mock("~/hooks/useOrganizations", () => ({
27+
useOrganization: () => ({ slug: "org" }),
28+
useOptionalOrganization: () => ({ slug: "org" }),
29+
}));
30+
vi.mock("~/hooks/useProject", () => ({
31+
useProject: () => ({ slug: "proj", id: "p1" }),
32+
useOptionalProject: () => ({ slug: "proj", id: "p1" }),
33+
}));
2834
vi.mock("~/hooks/useEnvironment", () => ({
2935
useEnvironment: () => ({ slug: "prod", type: "PRODUCTION" }),
36+
useOptionalEnvironment: () => ({ slug: "prod", type: "PRODUCTION" }),
3037
}));
3138
vi.mock("~/hooks/useShowSelfServe", () => ({ useShowSelfServe: () => true }));
3239
vi.mock("~/components/Feedback", () => ({ Feedback: () => null }));
@@ -41,17 +48,34 @@ if (typeof (globalThis as any).ResizeObserver === "undefined") {
4148

4249
const NEW_CHANNEL = { id: "ch_new", name: "Email to ops@example.com", type: "EMAIL" };
4350
const posted: FormData[] = [];
44-
const channelsLoader = vi.fn(async () => ({
45-
alertChannels: [],
46-
limits: { used: 0, limit: 10 },
47-
canWrite: true,
48-
form: { slack: { status: "NOT_CONFIGURED" }, emailAlertsEnabled: true },
49-
}));
51+
let slack: Record<string, unknown> = { status: "NOT_CONFIGURED" };
52+
let existing: unknown[] = [];
53+
const EXISTING_CHANNEL = {
54+
id: "ch_1",
55+
name: "Ops webhook",
56+
type: "WEBHOOK",
57+
enabled: true,
58+
alertTypes: ["TASK_RUN"],
59+
environmentTypes: ["PRODUCTION"],
60+
properties: { type: "WEBHOOK", url: "https://example.com/hook" },
61+
};
62+
let hold: Promise<void> | undefined;
63+
let startManual = false;
64+
const channelsLoader = vi.fn(async () => {
65+
await hold;
66+
return {
67+
alertChannels: existing,
68+
limits: { used: existing.length, limit: 10 },
69+
canWrite: true,
70+
form: { slack, emailAlertsEnabled: true },
71+
};
72+
});
5073
const onCreated = vi.fn();
5174

5275
let container: HTMLElement;
5376
let root: Root;
5477
let latest: CreationFlowState | undefined;
78+
let router: ReturnType<typeof createMemoryRouter>;
5579

5680
function Harness({ access }: { access: AddChannelAccess }) {
5781
const flow = useCreationFlowState(undefined, {
@@ -63,40 +87,54 @@ function Harness({ access }: { access: AddChannelAccess }) {
6387
// eslint-disable-next-line -- test harness captures the hook return
6488
latest = flow;
6589
const [channels] = useState([{ id: "ch_1", name: "Ops webhook", type: "WEBHOOK" }]);
66-
return createElement(CreationTranscript, { flow, channels, addChannel: access });
90+
const [resumed, setResumed] = useState(access.resumed ?? false);
91+
const [manual, setManual] = useState(startManual);
92+
return createElement(CreationTranscript, {
93+
flow,
94+
channels,
95+
manual,
96+
onManualChange: setManual,
97+
onFreeText: () => {},
98+
addChannel: { ...access, resumed, onResumeConsumed: () => setResumed(false) },
99+
});
67100
}
68101

69-
function mount(access: Partial<AddChannelAccess> = {}) {
102+
function mount(access: Partial<AddChannelAccess> = {}, entry = "/") {
70103
const full: AddChannelAccess = {
71104
projectId: "p1",
105+
environmentId: "e1",
72106
canWrite: true,
73107
atLimit: false,
74108
onCreated,
75109
...access,
76110
};
77-
const router = createMemoryRouter([
78-
{
79-
path: "/",
80-
element: createElement(
81-
OperatingSystemContextProvider,
82-
{ platform: "mac" } as ComponentProps<typeof OperatingSystemContextProvider>,
83-
createElement(ShortcutsProvider, null, createElement(Harness, { access: full }))
84-
),
85-
},
86-
{
87-
path: "/orgs/org/projects/proj/env/prod/alerts/channels",
88-
loader: () => channelsLoader(),
89-
},
90-
{
91-
path: "/resources/alerts/channels",
92-
action: async ({ request }) => {
93-
posted.push(await request.formData());
94-
return { channel: NEW_CHANNEL };
111+
router = createMemoryRouter(
112+
[
113+
{
114+
path: "/",
115+
element: createElement(
116+
OperatingSystemContextProvider,
117+
{ platform: "mac" } as ComponentProps<typeof OperatingSystemContextProvider>,
118+
createElement(ShortcutsProvider, null, createElement(Harness, { access: full }))
119+
),
95120
},
96-
},
97-
]);
121+
{
122+
path: "/orgs/org/projects/proj/env/prod/alerts/channels",
123+
loader: () => channelsLoader(),
124+
},
125+
{
126+
path: "/resources/alerts/channels",
127+
action: async ({ request }) => {
128+
posted.push(await request.formData());
129+
return { channel: NEW_CHANNEL };
130+
},
131+
},
132+
{ path: "/orgs/org/projects/proj/env/prod/alerts/new/connect-to-slack", element: null },
133+
],
134+
{ initialEntries: [entry] }
135+
);
98136
act(() => root.render(createElement(RouterProvider, { router })));
99-
pickCard("No expiry");
137+
if (!access.resumed) pickCard("No expiry");
100138
}
101139

102140
function pickCard(label: string) {
@@ -124,6 +162,11 @@ describe("add a channel from the notifiers step", () => {
124162
posted.length = 0;
125163
channelsLoader.mockClear();
126164
onCreated.mockClear();
165+
sessionStorage.clear();
166+
slack = { status: "NOT_CONFIGURED" };
167+
hold = undefined;
168+
startManual = false;
169+
existing = [];
127170
});
128171

129172
afterEach(() => {
@@ -192,4 +235,130 @@ describe("add a channel from the notifiers step", () => {
192235
expect(latest!.answers.notifiers).toContain("ch_new");
193236
expect(latest!.currentStep).toBe("notifiers");
194237
});
238+
239+
it("saves the answers and returns to the creation card when connecting Slack", async () => {
240+
const returnTo = "/orgs/org/projects/proj/env/prod/alerts?create=1&add=slack";
241+
mount({ connectReturnTo: returnTo });
242+
act(() => latest!.toggleNotifier("ch_1"));
243+
act(() => addRow()!.click());
244+
await flush();
245+
const slackTab = Array.from(document.querySelectorAll<HTMLElement>('[role="radio"]')).find(
246+
(b) => b.textContent === "Slack"
247+
)!;
248+
act(() => slackTab.click());
249+
250+
const connect = Array.from(document.querySelectorAll("a")).find((a) =>
251+
a.textContent?.includes("Connect to Slack")
252+
)!;
253+
expect(new URL(connect.href).searchParams.get("returnTo")).toBe(returnTo);
254+
act(() => connect.click());
255+
expect(JSON.parse(sessionStorage.getItem("alerts.slackConnectResume.p1.e1")!)).toMatchObject({
256+
what: "queue.depth",
257+
condition: "100",
258+
expiry: "none",
259+
notifiers: ["ch_1"],
260+
});
261+
});
262+
263+
it("reopens the channels modal on Slack and selects the created channel", async () => {
264+
slack = {
265+
status: "READY",
266+
integrationId: "int_1",
267+
channels: [{ id: "C123", name: "alerts", is_private: false }],
268+
};
269+
existing = [EXISTING_CHANNEL];
270+
mount({ resumed: true }, "/?add=slack");
271+
await flush();
272+
expect(latest!.currentStep).toBe("notifiers");
273+
expect(document.querySelector('[data-testid="dialog"]')).not.toBeNull();
274+
expect(document.querySelector('input[name="integrationId"]')).not.toBeNull();
275+
276+
const form = document.querySelector<HTMLFormElement>("#create-alert")!;
277+
const value = document.createElement("input");
278+
value.type = "hidden";
279+
value.name = "channelValue";
280+
value.value = "C123/alerts";
281+
form.appendChild(value);
282+
act(() => form.requestSubmit());
283+
await flush();
284+
await flush();
285+
286+
expect(posted[0]!.get("type")).toBe("SLACK");
287+
const close = Array.from(document.querySelectorAll("button")).find(
288+
(b) => b.textContent === "Close dialog"
289+
)!;
290+
act(() => close.click());
291+
expect(latest!.answers.notifiers).toContain("ch_new");
292+
});
293+
294+
it("reopens on the Slack form even when the URL marker is cleared before channels load", async () => {
295+
slack = {
296+
status: "READY",
297+
integrationId: "int_1",
298+
channels: [{ id: "C123", name: "alerts", is_private: false }],
299+
};
300+
existing = [EXISTING_CHANNEL];
301+
let release!: () => void;
302+
hold = new Promise((resolve) => (release = resolve));
303+
mount({ resumed: true }, "/?add=slack");
304+
await act(async () => router.navigate("/", { replace: true }));
305+
await act(async () => release());
306+
await flush();
307+
await flush();
308+
309+
expect(document.querySelector("#create-alert")).not.toBeNull();
310+
expect(document.querySelector('input[name="integrationId"]')).not.toBeNull();
311+
});
312+
313+
it("opens a later Add channel on the list, not the Slack form", async () => {
314+
slack = {
315+
status: "READY",
316+
integrationId: "int_1",
317+
channels: [{ id: "C123", name: "alerts", is_private: false }],
318+
};
319+
existing = [EXISTING_CHANNEL];
320+
mount({ resumed: true }, "/?add=slack");
321+
await flush();
322+
expect(document.querySelector('input[name="integrationId"]')).not.toBeNull();
323+
324+
const close = Array.from(document.querySelectorAll("button")).find(
325+
(b) => b.textContent === "Close dialog"
326+
)!;
327+
act(() => close.click());
328+
pickCard("No expiry");
329+
act(() => addRow()!.click());
330+
await flush();
331+
332+
expect(document.querySelector('[data-testid="dialog"]')).not.toBeNull();
333+
expect(document.querySelector("#create-alert")).toBeNull();
334+
});
335+
336+
describe.each([
337+
["Manual", true],
338+
["Agent", false],
339+
])("resumed in %s", (_label, manualFirst) => {
340+
it("does not reopen the Slack dialog in the other mode after it is closed", async () => {
341+
slack = {
342+
status: "READY",
343+
integrationId: "int_1",
344+
channels: [{ id: "C123", name: "alerts", is_private: false }],
345+
};
346+
existing = [EXISTING_CHANNEL];
347+
startManual = manualFirst;
348+
mount({ resumed: true }, "/?add=slack");
349+
await flush();
350+
expect(document.querySelector('input[name="integrationId"]')).not.toBeNull();
351+
352+
const close = Array.from(document.querySelectorAll("button")).find(
353+
(b) => b.textContent === "Close dialog"
354+
)!;
355+
act(() => close.click());
356+
const other = document.querySelector<HTMLElement>('[role="radio"][aria-checked="false"]')!;
357+
act(() => other.click());
358+
await flush();
359+
360+
expect(document.querySelector('[data-testid="dialog"]')).toBeNull();
361+
expect(document.querySelector("#create-alert")).toBeNull();
362+
});
363+
});
195364
});

‎apps/webapp/app/components/alerts/AddChannelSection.tsx‎

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,10 +11,14 @@ export function AddChannelSection({
1111
form,
1212
onCreated,
1313
defaultType = "EMAIL",
14+
connectReturnTo,
15+
onConnect,
1416
}: {
1517
form: Pick<React.ComponentProps<typeof AlertChannelForm>, "slack" | "emailAlertsEnabled">;
1618
onCreated: (channel: { id: string }) => void;
1719
defaultType?: "EMAIL" | "SLACK";
20+
connectReturnTo?: string;
21+
onConnect?: () => void;
1822
}) {
1923
const organization = useOrganization();
2024
const project = useProject();
@@ -34,7 +38,11 @@ export function AddChannelSection({
3438
slack={form.slack}
3539
emailAlertsEnabled={form.emailAlertsEnabled}
3640
defaultType={defaultType}
37-
connectReturnTo={`${v3ProjectAlertChannelsPath(organization, project, environment)}?add=slack`}
41+
connectReturnTo={
42+
connectReturnTo ??
43+
`${v3ProjectAlertChannelsPath(organization, project, environment)}?add=slack`
44+
}
45+
onConnect={onConnect}
3846
fetcher={fetcher}
3947
action={alertChannelsResourcePath(project.id)}
4048
/>

‎apps/webapp/app/components/alerts/AlertChannelForm.tsx‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,7 @@ export function AlertChannelForm({
144144
extraFields,
145145
submitDisabled = false,
146146
connectReturnTo,
147+
onConnect,
147148
}: {
148149
mode: "create" | "edit";
149150
slack: SlackState;
@@ -158,6 +159,7 @@ export function AlertChannelForm({
158159
extraFields?: ReactNode;
159160
submitDisabled?: boolean;
160161
connectReturnTo?: string;
162+
onConnect?: () => void;
161163
}) {
162164
const actionData = useActionData();
163165
const fetcherData = fetcher?.data as { status?: string } | undefined;
@@ -294,6 +296,7 @@ export function AlertChannelForm({
294296
<LinkButton
295297
variant="tertiary/large"
296298
to={returnToParam ? `${connectToSlackPath}?${returnToParam}` : connectToSlackPath}
299+
onClick={onConnect}
297300
fullWidth
298301
>
299302
<span className="flex items-center gap-2 text-text-bright">
@@ -312,6 +315,7 @@ export function AlertChannelForm({
312315
pathname: connectToSlackPath,
313316
search: `?reinstall=true${returnToParam ? `&${returnToParam}` : ""}`,
314317
}}
318+
onClick={onConnect}
315319
fullWidth
316320
>
317321
<span className="flex items-center gap-2 text-text-bright">

‎apps/webapp/app/components/alerts/AlertChannelsBody.tsx‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,9 @@ export type ChannelsModal = {
6969
fetcher: FetcherWithComponents<unknown>;
7070
reload: () => void;
7171
onCreated?: (channel: { id: string }) => void;
72+
connectReturnTo?: string;
73+
onConnect?: () => void;
74+
addSlack?: boolean;
7275
};
7376

7477
export type ChannelFormState = Pick<
@@ -107,7 +110,7 @@ export function AlertChannelsBody({
107110
const showSelfServe = useShowSelfServe();
108111
const [editingId, setEditingId] = useState<string | undefined>();
109112
const [searchParams] = useSearchParams();
110-
const addSlack = searchParams.get("add") === "slack";
113+
const addSlack = modal?.addSlack ?? searchParams.get("add") === "slack";
111114
const [adding, setAdding] = useState(addSlack);
112115
const editing = alertChannels.find((channel) => channel.id === editingId);
113116

@@ -229,6 +232,8 @@ export function AlertChannelsBody({
229232
<AddChannelSection
230233
form={form}
231234
defaultType={addSlack ? "SLACK" : "EMAIL"}
235+
connectReturnTo={modal.connectReturnTo}
236+
onConnect={modal.onConnect}
232237
onCreated={(channel) => {
233238
setAdding(false);
234239
modal.reload();

0 commit comments

Comments
 (0)