Skip to content

Commit 322c8e9

Browse files
committed
feat: add notification warning alert to Tasks page
1 parent e340560 commit 322c8e9

File tree

6 files changed

+354
-29
lines changed

6 files changed

+354
-29
lines changed

site/src/modules/notifications/utils.tsx

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,17 @@
11
import { MailIcon, WebhookIcon } from "lucide-react";
2+
import type {
3+
NotificationPreference,
4+
NotificationTemplate,
5+
} from "../../api/typesGenerated";
26

37
// TODO: This should be provided by the auto generated types from codersdk
48
const notificationMethods = ["smtp", "webhook"] as const;
59

10+
// localStorage key for tracking whether the user has dismissed the
11+
// task notifications warning alert on the Tasks page
12+
export const TasksNotificationAlertDismissedKey =
13+
"tasksNotificationAlertDismissed";
14+
615
export type NotificationMethod = (typeof notificationMethods)[number];
716

817
export const methodIcons: Record<NotificationMethod, typeof MailIcon> = {
@@ -26,3 +35,35 @@ export const castNotificationMethod = (value: string) => {
2635
)}`,
2736
);
2837
};
38+
39+
export function isTaskNotification(tmpl: NotificationTemplate): boolean {
40+
return tmpl.group === "Task Events";
41+
}
42+
43+
// Determines if a notification is disabled based on user preferences and system defaults
44+
// A notification is considered disabled if:
45+
// 1. It's NOT enabled by default AND the user hasn't set any preference (undefined), OR
46+
// 2. The user has explicitly disabled it in their preferences
47+
// Returns true if disabled, false if enabled
48+
export function notificationIsDisabled(
49+
disabledPreferences: Record<string, boolean>,
50+
tmpl: NotificationTemplate,
51+
): boolean {
52+
return (
53+
(!tmpl.enabled_by_default && disabledPreferences[tmpl.id] === undefined) ||
54+
!!disabledPreferences[tmpl.id]
55+
);
56+
}
57+
58+
// Transforms an array of NotificationPreference objects into a map
59+
// where the key is the template ID and the value is whether it's disabled
60+
// Example: [{ id: "abc", disabled: true }, { id: "def", disabled: false }]
61+
export function selectDisabledPreferences(data: NotificationPreference[]) {
62+
return data.reduce(
63+
(acc, pref) => {
64+
acc[pref.id] = pref.disabled;
65+
return acc;
66+
},
67+
{} as Record<string, boolean>,
68+
);
69+
}

site/src/pages/TasksPage/TasksPage.stories.tsx

Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import {
22
MockDisplayNameTasks,
33
MockInitializingTasks,
4+
MockSystemNotificationTemplates,
45
MockTasks,
56
MockTemplate,
67
MockUserOwner,
@@ -215,3 +216,142 @@ export const InitializingTasks: Story = {
215216
],
216217
},
217218
};
219+
220+
export const AllTaskNotificationsDisabledAlertVisible: Story = {
221+
parameters: {
222+
queries: [
223+
{
224+
key: ["tasks", { owner: MockUserOwner.username }],
225+
data: MockTasks,
226+
},
227+
{
228+
key: getTemplatesQueryKey({ q: "has-ai-task:true" }),
229+
data: [MockTemplate],
230+
},
231+
{
232+
// User notification preferences: empty because user hasn't changed defaults
233+
// Task notifications are disabled by default (enabled_by_default: false)
234+
key: ["users", MockUserOwner.id, "notifications", "preferences"],
235+
data: [],
236+
},
237+
{
238+
// System notification templates: includes task notifications with enabled_by_default: false
239+
key: ["notifications", "templates", "system"],
240+
data: MockSystemNotificationTemplates,
241+
},
242+
],
243+
},
244+
beforeEach: () => {
245+
// Mock localStorage to not contain warning dismissal
246+
spyOn(Storage.prototype, "getItem").mockReturnValue(null);
247+
// Prevent actual localStorage writes during the story
248+
spyOn(Storage.prototype, "setItem").mockImplementation(() => {});
249+
},
250+
};
251+
252+
export const AllTaskNotificationsDisabledAlertDismissed: Story = {
253+
parameters: {
254+
queries: [
255+
{
256+
key: ["tasks", { owner: MockUserOwner.username }],
257+
data: MockTasks,
258+
},
259+
{
260+
key: getTemplatesQueryKey({ q: "has-ai-task:true" }),
261+
data: [MockTemplate],
262+
},
263+
{
264+
// User notification preferences: empty because user hasn't changed defaults
265+
// Task notifications are disabled by default (enabled_by_default: false)
266+
key: ["users", MockUserOwner.id, "notifications", "preferences"],
267+
data: [],
268+
},
269+
{
270+
// System notification templates: includes task notifications with enabled_by_default: false
271+
key: ["notifications", "templates", "system"],
272+
data: MockSystemNotificationTemplates,
273+
},
274+
],
275+
},
276+
beforeEach: () => {
277+
// Mock localStorage to contain warning dismissal
278+
spyOn(Storage.prototype, "getItem").mockReturnValue("true");
279+
// Prevent actual localStorage writes during the story
280+
spyOn(Storage.prototype, "setItem").mockImplementation(() => {});
281+
},
282+
};
283+
284+
export const OneTaskNotificationEnabledAlertHidden: Story = {
285+
parameters: {
286+
queries: [
287+
{
288+
key: ["tasks", { owner: MockUserOwner.username }],
289+
data: MockTasks,
290+
},
291+
{
292+
key: getTemplatesQueryKey({ q: "has-ai-task:true" }),
293+
data: [MockTemplate],
294+
},
295+
{
296+
// User has explicitly enabled one task notification (Task Working)
297+
// Since at least one task notification is enabled, the warning alert should not appear
298+
key: ["users", MockUserOwner.id, "notifications", "preferences"],
299+
data: [
300+
{
301+
id: "bd4b7168-d05e-4e19-ad0f-3593b77aa90f", // Task Working
302+
disabled: false,
303+
updated_at: new Date().toISOString(),
304+
},
305+
],
306+
},
307+
{
308+
// System notification templates: includes task notifications with enabled_by_default: false
309+
key: ["notifications", "templates", "system"],
310+
data: MockSystemNotificationTemplates,
311+
},
312+
],
313+
},
314+
beforeEach: () => {
315+
// Mock localStorage to not contain warning dismissal
316+
spyOn(Storage.prototype, "getItem").mockReturnValue(null);
317+
// Prevent actual localStorage writes during the story
318+
spyOn(Storage.prototype, "setItem").mockImplementation(() => {});
319+
},
320+
};
321+
322+
export const AllTaskNotificationsExplicitlyDisabledAlertVisible: Story = {
323+
parameters: {
324+
queries: [
325+
{
326+
key: ["tasks", { owner: MockUserOwner.username }],
327+
data: MockTasks,
328+
},
329+
{
330+
key: getTemplatesQueryKey({ q: "has-ai-task:true" }),
331+
data: [MockTemplate],
332+
},
333+
{
334+
// User has explicitly disabled a task notification
335+
key: ["users", MockUserOwner.id, "notifications", "preferences"],
336+
data: [
337+
{
338+
id: "d4a6271c-cced-4ed0-84ad-afd02a9c7799", // Task Idle
339+
disabled: true,
340+
updated_at: "2024-08-06T11:58:37.755053Z",
341+
},
342+
],
343+
},
344+
{
345+
// System notification templates: includes task notifications with enabled_by_default: false
346+
key: ["notifications", "templates", "system"],
347+
data: MockSystemNotificationTemplates,
348+
},
349+
],
350+
},
351+
beforeEach: () => {
352+
// Mock localStorage to not contain warning dismissal
353+
spyOn(Storage.prototype, "getItem").mockReturnValue(null);
354+
// Prevent actual localStorage writes during the story
355+
spyOn(Storage.prototype, "setItem").mockImplementation(() => {});
356+
},
357+
};

site/src/pages/TasksPage/TasksPage.tsx

Lines changed: 58 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
import { API } from "api/api";
22
import { templates } from "api/queries/templates";
33
import type { TasksFilter } from "api/typesGenerated";
4+
import { Alert } from "components/Alert/Alert";
45
import { Badge } from "components/Badge/Badge";
56
import { Button, type ButtonProps } from "components/Button/Button";
7+
import { Link } from "components/Link/Link";
68
import { Margins } from "components/Margins/Margins";
79
import {
810
PageHeader,
@@ -12,10 +14,20 @@ import {
1214
import { useAuthenticated } from "hooks";
1315
import { useSearchParamsKey } from "hooks/useSearchParamsKey";
1416
import { TaskPrompt } from "modules/tasks/TaskPrompt/TaskPrompt";
15-
import type { FC } from "react";
16-
import { useQuery } from "react-query";
17+
import { type FC, useState } from "react";
18+
import { useQueries, useQuery } from "react-query";
1719
import { cn } from "utils/cn";
1820
import { pageTitle } from "utils/page";
21+
import {
22+
systemNotificationTemplates,
23+
userNotificationPreferences,
24+
} from "../../api/queries/notifications";
25+
import {
26+
isTaskNotification,
27+
notificationIsDisabled,
28+
selectDisabledPreferences,
29+
TasksNotificationAlertDismissedKey,
30+
} from "../../modules/notifications/utils";
1931
import { TasksTable } from "./TasksTable";
2032
import { UsersCombobox } from "./UsersCombobox";
2133

@@ -49,10 +61,54 @@ const TasksPage: FC = () => {
4961
const displayedTasks =
5062
tab.value === "waiting-for-input" ? idleTasks : tasksQuery.data;
5163

64+
// Fetch notification preferences and templates
65+
const [disabledPreferencesQuery, systemTemplatesQuery] = useQueries({
66+
queries: [
67+
{
68+
...userNotificationPreferences(user.id),
69+
select: selectDisabledPreferences,
70+
},
71+
systemNotificationTemplates(),
72+
],
73+
});
74+
75+
const disabledPreferences = disabledPreferencesQuery.data ?? {};
76+
77+
// Check if ALL task notifications are disabled
78+
// Returns true only when all task notification templates are disabled.
79+
// If even one is enabled, returns false and the warning won't show.
80+
const taskNotificationsDisabled = systemTemplatesQuery.data
81+
?.filter(isTaskNotification)
82+
.every((template) => notificationIsDisabled(disabledPreferences, template));
83+
84+
// Check localStorage for task notifications warning dismissal
85+
const [alertDismissed, setAlertDismissed] = useState(
86+
localStorage.getItem(TasksNotificationAlertDismissedKey) === "true",
87+
);
88+
5289
return (
5390
<>
5491
<title>{pageTitle("AI Tasks")}</title>
5592
<Margins>
93+
{taskNotificationsDisabled && !alertDismissed && (
94+
<div className="mt-6">
95+
<Alert
96+
severity="warning"
97+
dismissible
98+
onDismiss={() => {
99+
setAlertDismissed(true);
100+
localStorage.setItem(
101+
TasksNotificationAlertDismissedKey,
102+
"true",
103+
);
104+
}}
105+
>
106+
Your notifications for tasks status changes are disabled. Go to{" "}
107+
<Link href="/settings/notifications">Account Settings</Link> to
108+
change it.
109+
</Alert>
110+
</div>
111+
)}
56112
<PageHeader>
57113
<PageHeaderTitle>Tasks</PageHeaderTitle>
58114
<PageHeaderSubtitle>Automate tasks with AI</PageHeaderSubtitle>

site/src/pages/UserSettingsPage/NotificationsPage/NotificationsPage.stories.tsx

Lines changed: 72 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,8 +18,9 @@ import {
1818
systemNotificationTemplatesKey,
1919
userNotificationPreferencesKey,
2020
} from "api/queries/notifications";
21-
import { expect, spyOn, userEvent, within } from "storybook/test";
21+
import { expect, spyOn, userEvent, waitFor, within } from "storybook/test";
2222
import { reactRouterParameters } from "storybook-addon-remix-react-router";
23+
import { TasksNotificationAlertDismissedKey } from "../../../modules/notifications/utils";
2324
import NotificationsPage from "./NotificationsPage";
2425

2526
const meta = {
@@ -185,3 +186,73 @@ export const DisableInvalidTemplate: Story = {
185186
await within(document.body).findByText("Error disabling notification");
186187
},
187188
};
189+
190+
export const EnablingTaskNotificationClearsAlertDismissal: Story = {
191+
parameters: {
192+
queries: [
193+
{
194+
// User notification preferences: empty because user hasn't changed defaults
195+
// Task notifications are disabled by default (enabled_by_default: false)
196+
key: ["users", MockUserOwner.id, "notifications", "preferences"],
197+
data: [],
198+
},
199+
{
200+
// System notification templates: includes task notifications with enabled_by_default: false
201+
key: ["notifications", "templates", "system"],
202+
data: MockSystemNotificationTemplates,
203+
},
204+
{
205+
key: customNotificationTemplatesKey,
206+
data: MockCustomNotificationTemplates,
207+
},
208+
{
209+
key: notificationDispatchMethodsKey,
210+
data: MockNotificationMethodsResponse,
211+
},
212+
],
213+
},
214+
beforeEach: () => {
215+
// Mock the API call to update notification preferences
216+
spyOn(API, "putUserNotificationPreferences").mockResolvedValue([
217+
{
218+
id: "d4a6271c-cced-4ed0-84ad-afd02a9c7799",
219+
disabled: false,
220+
updated_at: new Date().toISOString(),
221+
},
222+
]);
223+
224+
// Mock localStorage as if the alert was previously dismissed
225+
const mockLocalStorage: Record<string, string> = {
226+
TasksNotificationAlertDismissedKey: "true",
227+
};
228+
229+
spyOn(Storage.prototype, "getItem").mockImplementation((key: string) => {
230+
return mockLocalStorage[key] || null;
231+
});
232+
233+
spyOn(Storage.prototype, "setItem").mockImplementation(
234+
(key: string, value: string) => {
235+
mockLocalStorage[key] = value;
236+
},
237+
);
238+
},
239+
play: async ({ canvasElement, step }) => {
240+
const canvas = within(canvasElement);
241+
242+
await step("Enable Task Idle notification", async () => {
243+
// Find the Task Idle checkbox by its label text
244+
const taskIdleToggle = canvas.getByLabelText("Task Idle");
245+
246+
// Click to enable it
247+
await userEvent.click(taskIdleToggle);
248+
249+
// Verify localStorage was updated to "false" to show the warning alert again
250+
// on the tasks page
251+
await waitFor(() => {
252+
expect(
253+
localStorage.getItem(TasksNotificationAlertDismissedKey),
254+
).toEqual("false");
255+
});
256+
});
257+
},
258+
};

0 commit comments

Comments
 (0)