Skip to content

Commit 21942af

Browse files
feat(site): implement notification ui (#14175)
1 parent aaa5174 commit 21942af

File tree

21 files changed

+1324
-6
lines changed

21 files changed

+1324
-6
lines changed

coderd/database/queries.sql.go

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

coderd/database/queries/notifications.sql

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -170,4 +170,5 @@ WHERE id = @id::uuid;
170170
-- name: GetNotificationTemplatesByKind :many
171171
SELECT *
172172
FROM notification_templates
173-
WHERE kind = @kind::notification_template_kind;
173+
WHERE kind = @kind::notification_template_kind
174+
ORDER BY name ASC;

site/src/@types/storybook.d.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,13 @@
11
import * as _storybook_types from "@storybook/react";
22
import type { QueryKey } from "react-query";
3-
import type { Experiments, FeatureName } from "api/typesGenerated";
3+
import type {
4+
Experiments,
5+
FeatureName,
6+
SerpentOption,
7+
User,
8+
DeploymentValues,
9+
} from "api/typesGenerated";
10+
import type { Permissions } from "contexts/auth/permissions";
411

512
declare module "@storybook/react" {
613
type WebSocketEvent =
@@ -11,5 +18,9 @@ declare module "@storybook/react" {
1118
experiments?: Experiments;
1219
queries?: { key: QueryKey; data: unknown }[];
1320
webSocket?: WebSocketEvent[];
21+
user?: User;
22+
permissions?: Partial<Permissions>;
23+
deploymentValues?: DeploymentValues;
24+
deploymentOptions?: SerpentOption[];
1425
}
1526
}

site/src/api/api.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2036,6 +2036,49 @@ class ApiMethods {
20362036

20372037
return response.data;
20382038
};
2039+
2040+
getUserNotificationPreferences = async (userId: string) => {
2041+
const res = await this.axios.get<TypesGen.NotificationPreference[] | null>(
2042+
`/api/v2/users/${userId}/notifications/preferences`,
2043+
);
2044+
return res.data ?? [];
2045+
};
2046+
2047+
putUserNotificationPreferences = async (
2048+
userId: string,
2049+
req: TypesGen.UpdateUserNotificationPreferences,
2050+
) => {
2051+
const res = await this.axios.put<TypesGen.NotificationPreference[]>(
2052+
`/api/v2/users/${userId}/notifications/preferences`,
2053+
req,
2054+
);
2055+
return res.data;
2056+
};
2057+
2058+
getSystemNotificationTemplates = async () => {
2059+
const res = await this.axios.get<TypesGen.NotificationTemplate[]>(
2060+
`/api/v2/notifications/templates/system`,
2061+
);
2062+
return res.data;
2063+
};
2064+
2065+
getNotificationDispatchMethods = async () => {
2066+
const res = await this.axios.get<TypesGen.NotificationMethodsResponse>(
2067+
`/api/v2/notifications/dispatch-methods`,
2068+
);
2069+
return res.data;
2070+
};
2071+
2072+
updateNotificationTemplateMethod = async (
2073+
templateId: string,
2074+
req: TypesGen.UpdateNotificationTemplateMethod,
2075+
) => {
2076+
const res = await this.axios.put<void>(
2077+
`/api/v2/notifications/templates/${templateId}/method`,
2078+
req,
2079+
);
2080+
return res.data;
2081+
};
20392082
}
20402083

20412084
// This is a hard coded CSRF token/cookie pair for local development. In prod,

site/src/api/queries/notifications.ts

Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
import type { QueryClient, UseMutationOptions } from "react-query";
2+
import { API } from "api/api";
3+
import type {
4+
NotificationPreference,
5+
NotificationTemplate,
6+
UpdateNotificationTemplateMethod,
7+
UpdateUserNotificationPreferences,
8+
} from "api/typesGenerated";
9+
10+
export const userNotificationPreferencesKey = (userId: string) => [
11+
"users",
12+
userId,
13+
"notifications",
14+
"preferences",
15+
];
16+
17+
export const userNotificationPreferences = (userId: string) => {
18+
return {
19+
queryKey: userNotificationPreferencesKey(userId),
20+
queryFn: () => API.getUserNotificationPreferences(userId),
21+
};
22+
};
23+
24+
export const updateUserNotificationPreferences = (
25+
userId: string,
26+
queryClient: QueryClient,
27+
) => {
28+
return {
29+
mutationFn: (req) => {
30+
return API.putUserNotificationPreferences(userId, req);
31+
},
32+
onMutate: (data) => {
33+
queryClient.setQueryData(
34+
userNotificationPreferencesKey(userId),
35+
Object.entries(data.template_disabled_map).map(
36+
([id, disabled]) =>
37+
({
38+
id,
39+
disabled,
40+
updated_at: new Date().toISOString(),
41+
}) satisfies NotificationPreference,
42+
),
43+
);
44+
},
45+
} satisfies UseMutationOptions<
46+
NotificationPreference[],
47+
unknown,
48+
UpdateUserNotificationPreferences
49+
>;
50+
};
51+
52+
export const systemNotificationTemplatesKey = [
53+
"notifications",
54+
"templates",
55+
"system",
56+
];
57+
58+
export const systemNotificationTemplates = () => {
59+
return {
60+
queryKey: systemNotificationTemplatesKey,
61+
queryFn: () => API.getSystemNotificationTemplates(),
62+
};
63+
};
64+
65+
export function selectTemplatesByGroup(
66+
data: NotificationTemplate[],
67+
): Record<string, NotificationTemplate[]> {
68+
const grouped = data.reduce(
69+
(acc, tpl) => {
70+
if (!acc[tpl.group]) {
71+
acc[tpl.group] = [];
72+
}
73+
acc[tpl.group].push(tpl);
74+
return acc;
75+
},
76+
{} as Record<string, NotificationTemplate[]>,
77+
);
78+
79+
// Sort templates within each group
80+
for (const group in grouped) {
81+
grouped[group].sort((a, b) => a.name.localeCompare(b.name));
82+
}
83+
84+
// Sort groups by name
85+
const sortedGroups = Object.keys(grouped).sort((a, b) => a.localeCompare(b));
86+
const sortedGrouped: Record<string, NotificationTemplate[]> = {};
87+
for (const group of sortedGroups) {
88+
sortedGrouped[group] = grouped[group];
89+
}
90+
91+
return sortedGrouped;
92+
}
93+
94+
export const notificationDispatchMethodsKey = [
95+
"notifications",
96+
"dispatchMethods",
97+
];
98+
99+
export const notificationDispatchMethods = () => {
100+
return {
101+
staleTime: Infinity,
102+
queryKey: notificationDispatchMethodsKey,
103+
queryFn: () => API.getNotificationDispatchMethods(),
104+
};
105+
};
106+
107+
export const updateNotificationTemplateMethod = (
108+
templateId: string,
109+
queryClient: QueryClient,
110+
) => {
111+
return {
112+
mutationFn: (req: UpdateNotificationTemplateMethod) =>
113+
API.updateNotificationTemplateMethod(templateId, req),
114+
onMutate: (data) => {
115+
const prevData = queryClient.getQueryData<NotificationTemplate[]>(
116+
systemNotificationTemplatesKey,
117+
);
118+
if (!prevData) {
119+
return;
120+
}
121+
queryClient.setQueryData(
122+
systemNotificationTemplatesKey,
123+
prevData.map((tpl) =>
124+
tpl.id === templateId
125+
? {
126+
...tpl,
127+
method: data.method,
128+
}
129+
: tpl,
130+
),
131+
);
132+
},
133+
} satisfies UseMutationOptions<
134+
void,
135+
unknown,
136+
UpdateNotificationTemplateMethod
137+
>;
138+
};

site/src/api/queries/users.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -141,10 +141,12 @@ export function apiKey(): UseQueryOptions<GenerateAPIKeyResponse> {
141141
};
142142
}
143143

144+
export const hasFirstUserKey = ["hasFirstUser"];
145+
144146
export const hasFirstUser = (userMetadata: MetadataState<User>) => {
145147
return cachedQuery({
146148
metadata: userMetadata,
147-
queryKey: ["hasFirstUser"],
149+
queryKey: hasFirstUserKey,
148150
queryFn: API.hasFirstUser,
149151
});
150152
};
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import EmailIcon from "@mui/icons-material/EmailOutlined";
2+
import WebhookIcon from "@mui/icons-material/WebhookOutlined";
3+
4+
// TODO: This should be provided by the auto generated types from codersdk
5+
const notificationMethods = ["smtp", "webhook"] as const;
6+
7+
export type NotificationMethod = (typeof notificationMethods)[number];
8+
9+
export const methodIcons: Record<NotificationMethod, typeof EmailIcon> = {
10+
smtp: EmailIcon,
11+
webhook: WebhookIcon,
12+
};
13+
14+
export const methodLabels: Record<NotificationMethod, string> = {
15+
smtp: "SMTP",
16+
webhook: "Webhook",
17+
};
18+
19+
export const castNotificationMethod = (value: string) => {
20+
if (notificationMethods.includes(value as NotificationMethod)) {
21+
return value as NotificationMethod;
22+
}
23+
24+
throw new Error(
25+
`Invalid notification method: ${value}. Accepted values: ${notificationMethods.join(
26+
", ",
27+
)}`,
28+
);
29+
};

site/src/pages/DeploySettingsPage/DeploySettingsLayout.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ export const useDeploySettings = (): DeploySettingsContextValue => {
2424
const context = useContext(DeploySettingsContext);
2525
if (!context) {
2626
throw new Error(
27-
"useDeploySettings should be used inside of DeploySettingsLayout",
27+
"useDeploySettings should be used inside of DeploySettingsContext or DeploySettingsLayout",
2828
);
2929
}
3030
return context;

0 commit comments

Comments
 (0)
pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy