Skip to content

Commit 7b84fde

Browse files
kathiekiwiTrigger.dev RepoOps
authored andcommitted
feat(alerts): alert creation UI — wizard, dashboard-agent chat flow, channel dialogs
Alert creation UI: guided wizard, Dashboard Agent chat flow, and notification channel setup. Mono-RevId: 1ad855bb4b4cdcc53da0b008c817b13c569d00d0
1 parent c93b290 commit 7b84fde

129 files changed

Lines changed: 20239 additions & 218 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
export function CheckMarkIcon({
2+
className,
3+
"aria-label": ariaLabel,
4+
}: {
5+
className?: string;
6+
"aria-label"?: string;
7+
}) {
8+
return (
9+
<svg
10+
className={className}
11+
aria-label={ariaLabel}
12+
width="24"
13+
height="24"
14+
viewBox="0 0 24 24"
15+
fill="none"
16+
xmlns="http://www.w3.org/2000/svg"
17+
>
18+
<path
19+
d="M5 12.75L10 19L19 5"
20+
stroke="currentColor"
21+
strokeWidth="2"
22+
strokeLinecap="round"
23+
strokeLinejoin="round"
24+
/>
25+
</svg>
26+
);
27+
}
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
export function TrashIcon({ className }: { className?: string }) {
2+
return (
3+
<svg
4+
className={className}
5+
width="24"
6+
height="24"
7+
viewBox="0 0 24 24"
8+
fill="none"
9+
xmlns="http://www.w3.org/2000/svg"
10+
>
11+
<path
12+
d="M17.9355 7L17.1777 19.125C17.1117 20.179 16.2377 21 15.1816 21H8.81836C7.76231 21 6.88826 20.179 6.82227 19.125L6.06445 7H17.9355Z"
13+
stroke="currentColor"
14+
strokeWidth="2"
15+
/>
16+
<rect x="4" y="6" width="16" height="2" rx="1" fill="currentColor" />
17+
<rect
18+
x="11"
19+
y="10"
20+
width="7"
21+
height="2"
22+
rx="1"
23+
transform="rotate(90 11 10)"
24+
fill="currentColor"
25+
/>
26+
<rect
27+
x="15"
28+
y="10"
29+
width="7"
30+
height="2"
31+
rx="1"
32+
transform="rotate(90 15 10)"
33+
fill="currentColor"
34+
/>
35+
<path
36+
d="M15 7C15 5.34315 13.6569 4 12 4C10.3431 4 9 5.34315 9 7"
37+
stroke="currentColor"
38+
strokeWidth="2"
39+
/>
40+
</svg>
41+
);
42+
}

‎apps/webapp/app/components/Feedback.tsx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import { InformationCircleIcon, ArrowUpCircleIcon } from "@heroicons/react/20/so
1010
import { EnvelopeIcon, ShieldCheckIcon } from "@heroicons/react/24/solid";
1111
import { Form, useActionData, useLocation, useNavigation, useSearchParams } from "@remix-run/react";
1212
import { type ReactNode, useEffect, useRef, useState } from "react";
13-
import { type FeedbackType, feedbackTypes, schema } from "~/routes/resources.feedback";
13+
import { type FeedbackType, feedbackTypes, schema } from "~/utils/feedback";
1414
import { Button } from "./primitives/Buttons";
1515
import { Dialog, DialogContent, DialogHeader, DialogTrigger } from "./primitives/Dialog";
1616
import { Fieldset } from "./primitives/Fieldset";
Lines changed: 195 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,195 @@
1+
// @vitest-environment jsdom
2+
import { createElement, useState, type ComponentProps } from "react";
3+
import { act } from "react-dom/test-utils";
4+
import { createRoot, type Root } from "react-dom/client";
5+
import { createMemoryRouter, RouterProvider } from "react-router-dom";
6+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
7+
import { OperatingSystemContextProvider } from "~/components/primitives/OperatingSystemProvider";
8+
import { ShortcutsProvider } from "~/components/primitives/ShortcutsProvider";
9+
import {
10+
useCreationFlowState,
11+
type CreationFlowState,
12+
} from "~/components/dashboard-agent/alert-creation/CreationFlow";
13+
import { CreationTranscript, type AddChannelAccess } from "./CreationTranscript";
14+
15+
vi.mock("~/components/primitives/Dialog", () => ({
16+
Dialog: ({ children, onOpenChange }: any) =>
17+
createElement(
18+
"div",
19+
{ "data-testid": "dialog" },
20+
createElement("button", { onClick: () => onOpenChange(false) }, "Close dialog"),
21+
children
22+
),
23+
DialogContent: ({ children }: any) => createElement("div", null, children),
24+
DialogHeader: ({ children }: any) => createElement("h2", null, children),
25+
}));
26+
vi.mock("~/hooks/useOrganizations", () => ({ useOrganization: () => ({ slug: "org" }) }));
27+
vi.mock("~/hooks/useProject", () => ({ useProject: () => ({ slug: "proj", id: "p1" }) }));
28+
vi.mock("~/hooks/useEnvironment", () => ({
29+
useEnvironment: () => ({ slug: "prod", type: "PRODUCTION" }),
30+
}));
31+
vi.mock("~/hooks/useShowSelfServe", () => ({ useShowSelfServe: () => true }));
32+
vi.mock("~/components/Feedback", () => ({ Feedback: () => null }));
33+
34+
if (typeof (globalThis as any).ResizeObserver === "undefined") {
35+
(globalThis as any).ResizeObserver = class {
36+
observe() {}
37+
unobserve() {}
38+
disconnect() {}
39+
};
40+
}
41+
42+
const NEW_CHANNEL = { id: "ch_new", name: "Email to ops@example.com", type: "EMAIL" };
43+
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+
}));
50+
const onCreated = vi.fn();
51+
52+
let container: HTMLElement;
53+
let root: Root;
54+
let latest: CreationFlowState | undefined;
55+
56+
function Harness({ access }: { access: AddChannelAccess }) {
57+
const flow = useCreationFlowState(undefined, {
58+
area: "queues",
59+
what: "queue.depth",
60+
scope: "all",
61+
condition: "100",
62+
});
63+
// eslint-disable-next-line -- test harness captures the hook return
64+
latest = flow;
65+
const [channels] = useState([{ id: "ch_1", name: "Ops webhook", type: "WEBHOOK" }]);
66+
return createElement(CreationTranscript, { flow, channels, addChannel: access });
67+
}
68+
69+
function mount(access: Partial<AddChannelAccess> = {}) {
70+
const full: AddChannelAccess = {
71+
projectId: "p1",
72+
canWrite: true,
73+
atLimit: false,
74+
onCreated,
75+
...access,
76+
};
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 };
95+
},
96+
},
97+
]);
98+
act(() => root.render(createElement(RouterProvider, { router })));
99+
pickCard("No expiry");
100+
}
101+
102+
function pickCard(label: string) {
103+
const btn = Array.from(card()!.querySelectorAll("button")).find((b) => b.textContent === label)!;
104+
act(() => btn.click());
105+
}
106+
107+
const card = () => document.querySelector<HTMLElement>("[role=dialog]");
108+
const addRow = () =>
109+
Array.from(card()!.querySelectorAll<HTMLButtonElement>("li button")).find(
110+
(b) => b.textContent === "Add channel…"
111+
);
112+
113+
async function flush() {
114+
await act(async () => {
115+
await new Promise((r) => setTimeout(r, 0));
116+
});
117+
}
118+
119+
describe("add a channel from the notifiers step", () => {
120+
beforeEach(() => {
121+
container = document.createElement("div");
122+
document.body.appendChild(container);
123+
root = createRoot(container);
124+
posted.length = 0;
125+
channelsLoader.mockClear();
126+
onCreated.mockClear();
127+
});
128+
129+
afterEach(() => {
130+
act(() => root.unmount());
131+
container.remove();
132+
});
133+
134+
it("shows Add channel enabled with write access", () => {
135+
mount();
136+
expect(latest!.currentStep).toBe("notifiers");
137+
expect(addRow()?.disabled).toBe(false);
138+
expect(addRow()?.querySelectorAll("svg")).toHaveLength(0);
139+
});
140+
141+
it("disables Add channel without write access or at the channel limit", () => {
142+
mount({ canWrite: false });
143+
expect(addRow()?.disabled).toBe(true);
144+
act(() => root.unmount());
145+
root = createRoot(container);
146+
mount({ atLimit: true });
147+
expect(addRow()?.disabled).toBe(true);
148+
});
149+
150+
it("opens the channels modal with an inline add-channel form", async () => {
151+
mount();
152+
act(() => addRow()!.click());
153+
await flush();
154+
const dialog = document.querySelector('[data-testid="dialog"]')!;
155+
expect(dialog.textContent).toContain("Channels");
156+
expect(dialog.textContent).not.toContain("Add channel");
157+
expect(dialog.querySelector("#create-alert")).not.toBeNull();
158+
expect(channelsLoader).toHaveBeenCalledTimes(1);
159+
expect(document.querySelector('input[type="email"]')).not.toBeNull();
160+
});
161+
162+
it("creates a channel, refetches the modal list and refetches notifiers on close", async () => {
163+
mount();
164+
act(() => addRow()!.click());
165+
await flush();
166+
167+
const email = document.querySelector<HTMLInputElement>('input[type="email"]')!;
168+
act(() => {
169+
const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")?.set;
170+
setter?.call(email, "ops@example.com");
171+
email.dispatchEvent(new Event("input", { bubbles: true }));
172+
});
173+
const save = Array.from(document.querySelectorAll("button")).find(
174+
(b) => b.textContent === "Save"
175+
)!;
176+
act(() => save.click());
177+
await flush();
178+
await flush();
179+
180+
expect(posted).toHaveLength(1);
181+
expect(posted[0]!.get("channelValue")).toBe("ops@example.com");
182+
expect(channelsLoader.mock.calls.length).toBeGreaterThan(1);
183+
expect(document.querySelector('[data-testid="dialog"]')).not.toBeNull();
184+
expect(onCreated).not.toHaveBeenCalled();
185+
186+
const close = Array.from(document.querySelectorAll("button")).find(
187+
(b) => b.textContent === "Close dialog"
188+
)!;
189+
act(() => close.click());
190+
expect(document.querySelector('[data-testid="dialog"]')).toBeNull();
191+
expect(onCreated).toHaveBeenCalledTimes(1);
192+
expect(latest!.answers.notifiers).toContain("ch_new");
193+
expect(latest!.currentStep).toBe("notifiers");
194+
});
195+
});
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import { useFetcher } from "@remix-run/react";
2+
import { useEffect } from "react";
3+
import { AlertChannelForm } from "~/components/alerts/AlertChannelForm";
4+
import { alertChannelsResourcePath } from "~/components/alerts/alertChannelsResourcePath";
5+
import { useEnvironment } from "~/hooks/useEnvironment";
6+
import { useOrganization } from "~/hooks/useOrganizations";
7+
import { useProject } from "~/hooks/useProject";
8+
import { v3ProjectAlertChannelsPath } from "~/utils/pathBuilder";
9+
10+
export function AddChannelSection({
11+
form,
12+
onCreated,
13+
defaultType = "EMAIL",
14+
}: {
15+
form: Pick<React.ComponentProps<typeof AlertChannelForm>, "slack" | "emailAlertsEnabled">;
16+
onCreated: (channel: { id: string }) => void;
17+
defaultType?: "EMAIL" | "SLACK";
18+
}) {
19+
const organization = useOrganization();
20+
const project = useProject();
21+
const environment = useEnvironment();
22+
const fetcher = useFetcher<{ channel?: { id: string } }>();
23+
const created = fetcher.data?.channel;
24+
25+
useEffect(() => {
26+
if (created) onCreated(created);
27+
}, [created]); // eslint-disable-line react-hooks/exhaustive-deps -- fire once per created channel
28+
29+
return (
30+
<div className="flex flex-col gap-3 p-4">
31+
<AlertChannelForm
32+
inline
33+
mode="create"
34+
slack={form.slack}
35+
emailAlertsEnabled={form.emailAlertsEnabled}
36+
defaultType={defaultType}
37+
connectReturnTo={`${v3ProjectAlertChannelsPath(organization, project, environment)}?add=slack`}
38+
fetcher={fetcher}
39+
action={alertChannelsResourcePath(project.id)}
40+
/>
41+
</div>
42+
);
43+
}

0 commit comments

Comments
 (0)