Thanks to visit codestin.com
Credit goes to github.com

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

+1
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

coderd/database/queries/notifications.sql

+2-1
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

+12-1
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

+43
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

+138
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

+3-1
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
};
+29
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

+1-1
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)