feat(site): add custom notification settings (#19938)

## Description

Add Custom Notification settings to `/deployment/notifications` page and
`/settings/notifications` user's page.

<img width="2936" height="3008" alt="Screenshot 2025-09-24 at 12 53 52"
src="https://github.com/user-attachments/assets/c11ccaba-7bdd-4b0d-98b3-faa48a9796ba"
/>

<img width="2920" height="2976" alt="Screenshot 2025-09-24 at 12 54 06"
src="https://github.com/user-attachments/assets/21aa3057-a14e-4ba6-8138-98a4dc3952dc"
/>

Follow-up from: https://github.com/coder/coder/pull/19751
This commit is contained in:
Susana Ferreira
2025-09-25 09:48:13 +01:00
committed by GitHub
parent b712e975d5
commit da467bad2a
9 changed files with 273 additions and 160 deletions
+7
View File
@@ -2519,6 +2519,13 @@ class ApiMethods {
return res.data;
};
getCustomNotificationTemplates = async () => {
const res = await this.axios.get<TypesGen.NotificationTemplate[]>(
"/api/v2/notifications/templates/custom",
);
return res.data;
};
getNotificationDispatchMethods = async () => {
const res = await this.axios.get<TypesGen.NotificationMethodsResponse>(
"/api/v2/notifications/dispatch-methods",
+29 -15
View File
@@ -62,6 +62,19 @@ export const systemNotificationTemplates = () => {
};
};
export const customNotificationTemplatesKey = [
"notifications",
"templates",
"custom",
];
export const customNotificationTemplates = () => {
return {
queryKey: customNotificationTemplatesKey,
queryFn: () => API.getCustomNotificationTemplates(),
};
};
export function selectTemplatesByGroup(
data: NotificationTemplate[],
): Record<string, NotificationTemplate[]> {
@@ -106,23 +119,24 @@ export const updateNotificationTemplateMethod = (
mutationFn: (req: UpdateNotificationTemplateMethod) =>
API.updateNotificationTemplateMethod(templateId, req),
onMutate: (data) => {
const prevData = queryClient.getQueryData<NotificationTemplate[]>(
const keys = [
systemNotificationTemplatesKey,
);
if (!prevData) {
return;
customNotificationTemplatesKey,
];
for (const key of keys) {
const prev = queryClient.getQueryData<NotificationTemplate[]>(key);
if (!prev) {
continue;
}
queryClient.setQueryData(
key,
prev.map((tpl) =>
tpl.id === templateId ? { ...tpl, method: data.method } : tpl,
),
);
}
queryClient.setQueryData(
systemNotificationTemplatesKey,
prevData.map((tpl) =>
tpl.id === templateId
? {
...tpl,
method: data.method,
}
: tpl,
),
);
},
} satisfies UseMutationOptions<
void,
@@ -1,4 +1,4 @@
import { MockNotificationTemplates } from "testHelpers/entities";
import { MockSystemNotificationTemplates } from "testHelpers/entities";
import type { Meta, StoryObj } from "@storybook/react-vite";
import { API } from "api/api";
import { selectTemplatesByGroup } from "api/queries/notifications";
@@ -13,7 +13,7 @@ const meta: Meta<typeof NotificationEvents> = {
args: {
defaultMethod: "smtp",
availableMethods: ["smtp", "webhook"],
templatesByGroup: selectTemplatesByGroup(MockNotificationTemplates),
templatesByGroup: selectTemplatesByGroup(MockSystemNotificationTemplates),
deploymentConfig: baseMeta.parameters.deploymentValues,
},
...baseMeta,
@@ -60,7 +60,7 @@ export const Toggle: Story = {
spyOn(API, "updateNotificationTemplateMethod").mockResolvedValue();
const user = userEvent.setup();
const canvas = within(canvasElement);
const tmpl = MockNotificationTemplates[4];
const tmpl = MockSystemNotificationTemplates[4];
const option = await canvas.findByText(tmpl.name);
const li = option.closest("li");
if (!li) {
@@ -79,7 +79,7 @@ export const ToggleError: Story = {
spyOn(API, "updateNotificationTemplateMethod").mockRejectedValue({});
const user = userEvent.setup();
const canvas = within(canvasElement);
const tmpl = MockNotificationTemplates[4];
const tmpl = MockSystemNotificationTemplates[4];
const option = await canvas.findByText(tmpl.name);
const li = option.closest("li");
if (!li) {
@@ -1,9 +1,11 @@
import {
MockCustomNotificationTemplates,
MockNotificationMethodsResponse,
MockNotificationTemplates,
MockSystemNotificationTemplates,
} from "testHelpers/entities";
import type { Meta, StoryObj } from "@storybook/react-vite";
import {
customNotificationTemplatesKey,
notificationDispatchMethodsKey,
systemNotificationTemplatesKey,
} from "api/queries/notifications";
@@ -28,6 +30,10 @@ export const LoadingTemplates: Story = {
key: systemNotificationTemplatesKey,
data: undefined,
},
{
key: customNotificationTemplatesKey,
data: undefined,
},
{
key: notificationDispatchMethodsKey,
data: MockNotificationMethodsResponse,
@@ -39,7 +45,14 @@ export const LoadingTemplates: Story = {
export const LoadingDispatchMethods: Story = {
parameters: {
queries: [
{ key: systemNotificationTemplatesKey, data: MockNotificationTemplates },
{
key: systemNotificationTemplatesKey,
data: MockSystemNotificationTemplates,
},
{
key: customNotificationTemplatesKey,
data: MockCustomNotificationTemplates,
},
{
key: notificationDispatchMethodsKey,
data: undefined,
@@ -48,7 +61,20 @@ export const LoadingDispatchMethods: Story = {
},
};
export const Events: Story = {};
export const Events: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
// System notification templates
await canvas.findByText("Template Events");
await canvas.findByText("User Events");
await canvas.findByText("Workspace Events");
// Custom notification template
await canvas.findByText("Custom Events");
await canvas.findByText("Custom Notification");
},
};
export const Settings: Story = {
play: async ({ canvasElement }) => {
@@ -1,5 +1,6 @@
import type { Interpolation, Theme } from "@emotion/react";
import {
customNotificationTemplates,
notificationDispatchMethods,
selectTemplatesByGroup,
systemNotificationTemplates,
@@ -27,21 +28,35 @@ import { Troubleshooting } from "./Troubleshooting";
const NotificationsPage: FC = () => {
const { deploymentConfig } = useDeploymentConfig();
const [templatesByGroup, dispatchMethods] = useQueries({
queries: [
{
...systemNotificationTemplates(),
select: selectTemplatesByGroup,
},
notificationDispatchMethods(),
],
});
const [systemTemplatesByGroup, customTemplatesByGroup, dispatchMethods] =
useQueries({
queries: [
{
...systemNotificationTemplates(),
select: selectTemplatesByGroup,
},
{
...customNotificationTemplates(),
select: selectTemplatesByGroup,
},
notificationDispatchMethods(),
],
});
const tabState = useSearchParamsKey({
key: "tab",
defaultValue: "events",
});
const ready = !!(templatesByGroup.data && dispatchMethods.data);
const ready = !!(
systemTemplatesByGroup.data &&
customTemplatesByGroup.data &&
dispatchMethods.data
);
// Combine system and custom notification templates
const allTemplatesByGroup = {
...systemTemplatesByGroup.data,
...customTemplatesByGroup.data,
};
return (
<>
<Helmet>
@@ -79,7 +94,7 @@ const NotificationsPage: FC = () => {
{ready ? (
tabState.value === "events" ? (
<NotificationEvents
templatesByGroup={templatesByGroup.data}
templatesByGroup={allTemplatesByGroup}
deploymentConfig={deploymentConfig.config}
defaultMethod={castNotificationMethod(
dispatchMethods.data.default,
@@ -1,6 +1,7 @@
import {
MockCustomNotificationTemplates,
MockNotificationMethodsResponse,
MockNotificationTemplates,
MockSystemNotificationTemplates,
MockUserOwner,
} from "testHelpers/entities";
import {
@@ -11,6 +12,7 @@ import {
} from "testHelpers/storybook";
import type { Meta } from "@storybook/react-vite";
import {
customNotificationTemplatesKey,
notificationDispatchMethodsKey,
systemNotificationTemplatesKey,
} from "api/queries/notifications";
@@ -187,7 +189,14 @@ export const baseMeta = {
parameters: {
experiments: ["notifications"],
queries: [
{ key: systemNotificationTemplatesKey, data: MockNotificationTemplates },
{
key: systemNotificationTemplatesKey,
data: MockSystemNotificationTemplates,
},
{
key: customNotificationTemplatesKey,
data: MockCustomNotificationTemplates,
},
{
key: notificationDispatchMethodsKey,
data: MockNotificationMethodsResponse,
@@ -1,7 +1,8 @@
import {
MockCustomNotificationTemplates,
MockNotificationMethodsResponse,
MockNotificationPreferences,
MockNotificationTemplates,
MockSystemNotificationTemplates,
MockUserOwner,
} from "testHelpers/entities";
import {
@@ -12,6 +13,7 @@ import {
import type { Meta, StoryObj } from "@storybook/react-vite";
import { API } from "api/api";
import {
customNotificationTemplatesKey,
notificationDispatchMethodsKey,
systemNotificationTemplatesKey,
userNotificationPreferencesKey,
@@ -32,7 +34,11 @@ const meta = {
},
{
key: systemNotificationTemplatesKey,
data: MockNotificationTemplates,
data: MockSystemNotificationTemplates,
},
{
key: customNotificationTemplatesKey,
data: MockCustomNotificationTemplates,
},
{
key: notificationDispatchMethodsKey,
@@ -100,7 +106,7 @@ if (!enabledPreference) {
"No enabled notification preference available to test the disabling action.",
);
}
const templateToDisable = MockNotificationTemplates.find(
const templateToDisable = MockSystemNotificationTemplates.find(
(tpl) => tpl.id === enabledPreference.id,
);
if (!templateToDisable) {
@@ -8,6 +8,7 @@ import ListItemText, { listItemTextClasses } from "@mui/material/ListItemText";
import Switch from "@mui/material/Switch";
import Tooltip from "@mui/material/Tooltip";
import {
customNotificationTemplates,
disableNotification,
notificationDispatchMethods,
selectTemplatesByGroup,
@@ -38,7 +39,12 @@ import { Section } from "../Section";
const NotificationsPage: FC = () => {
const { user, permissions } = useAuthenticated();
const [disabledPreferences, templatesByGroup, dispatchMethods] = useQueries({
const [
disabledPreferences,
systemTemplatesByGroup,
customTemplatesByGroup,
dispatchMethods,
] = useQueries({
queries: [
{
...userNotificationPreferences(user.id),
@@ -48,6 +54,10 @@ const NotificationsPage: FC = () => {
...systemNotificationTemplates(),
select: (data: NotificationTemplate[]) => selectTemplatesByGroup(data),
},
{
...customNotificationTemplates(),
select: (data: NotificationTemplate[]) => selectTemplatesByGroup(data),
},
notificationDispatchMethods(),
],
});
@@ -80,7 +90,15 @@ const NotificationsPage: FC = () => {
}, [searchParams.delete, disabledId, disableMutation]);
const ready =
disabledPreferences.data && templatesByGroup.data && dispatchMethods.data;
disabledPreferences.data &&
systemTemplatesByGroup.data &&
customTemplatesByGroup.data &&
dispatchMethods.data;
// Combine system and custom notification templates
const allTemplatesByGroup = {
...systemTemplatesByGroup.data,
...customTemplatesByGroup.data,
};
return (
<>
@@ -94,7 +112,7 @@ const NotificationsPage: FC = () => {
>
{ready ? (
<Stack spacing={4}>
{Object.entries(templatesByGroup.data).map(([group, templates]) => {
{Object.entries(allTemplatesByGroup).map(([group, templates]) => {
if (!canSeeNotificationGroup(group, permissions)) {
return null;
}
@@ -218,6 +236,8 @@ function canSeeNotificationGroup(
return permissions.createTemplates;
case "User Events":
return permissions.createUser;
case "Custom Events":
return true;
default:
return false;
}
+135 -119
View File
@@ -4537,125 +4537,141 @@ export const MockNotificationPreferences: TypesGen.NotificationPreference[] = [
},
];
export const MockNotificationTemplates: TypesGen.NotificationTemplate[] = [
{
id: "381df2a9-c0c0-4749-420f-80a9280c66f9",
name: "Workspace Autobuild Failed",
title_template: 'Workspace "{{.Labels.name}}" autobuild failed',
body_template:
'Hi {{.UserName}}\nAutomatic build of your workspace **{{.Labels.name}}** failed.\nThe specified reason was "**{{.Labels.reason}}**".',
actions:
'[{"url": "{{ base_url }}/@{{.UserUsername}}/{{.Labels.name}}", "label": "View workspace"}]',
group: "Workspace Events",
method: "webhook",
kind: "system",
enabled_by_default: true,
},
{
id: "f517da0b-cdc9-410f-ab89-a86107c420ed",
name: "Workspace Deleted",
title_template: 'Workspace "{{.Labels.name}}" deleted',
body_template:
'Hi {{.UserName}}\n\nYour workspace **{{.Labels.name}}** was deleted.\nThe specified reason was "**{{.Labels.reason}}{{ if .Labels.initiator }} ({{ .Labels.initiator }}){{end}}**".',
actions:
'[{"url": "{{ base_url }}/workspaces", "label": "View workspaces"}, {"url": "{{ base_url }}/templates", "label": "View templates"}]',
group: "Workspace Events",
method: "smtp",
kind: "system",
enabled_by_default: true,
},
{
id: "f44d9314-ad03-4bc8-95d0-5cad491da6b6",
name: "User account deleted",
title_template: 'User account "{{.Labels.deleted_account_name}}" deleted',
body_template:
"Hi {{.UserName}},\n\nUser account **{{.Labels.deleted_account_name}}** has been deleted.",
actions:
'[{"url": "{{ base_url }}/deployment/users?filter=status%3Aactive", "label": "View accounts"}]',
group: "User Events",
method: "",
kind: "system",
enabled_by_default: true,
},
{
id: "4e19c0ac-94e1-4532-9515-d1801aa283b2",
name: "User account created",
title_template: 'User account "{{.Labels.created_account_name}}" created',
body_template:
"Hi {{.UserName}},\n\nNew user account **{{.Labels.created_account_name}}** has been created.",
actions:
'[{"url": "{{ base_url }}/deployment/users?filter=status%3Aactive", "label": "View accounts"}]',
group: "User Events",
method: "",
kind: "system",
enabled_by_default: true,
},
{
id: "0ea69165-ec14-4314-91f1-69566ac3c5a0",
name: "Workspace Marked as Dormant",
title_template: 'Workspace "{{.Labels.name}}" marked as dormant',
body_template:
"Hi {{.UserName}}\n\nYour workspace **{{.Labels.name}}** has been marked as [**dormant**](https://coder.com/docs/templates/schedule#dormancy-threshold-enterprise) because of {{.Labels.reason}}.\nDormant workspaces are [automatically deleted](https://coder.com/docs/templates/schedule#dormancy-auto-deletion-enterprise) after {{.Labels.timeTilDormant}} of inactivity.\nTo prevent deletion, use your workspace with the link below.",
actions:
'[{"url": "{{ base_url }}/@{{.UserUsername}}/{{.Labels.name}}", "label": "View workspace"}]',
group: "Workspace Events",
method: "smtp",
kind: "system",
enabled_by_default: true,
},
{
id: "c34a0c09-0704-4cac-bd1c-0c0146811c2b",
name: "Workspace updated automatically",
title_template: 'Workspace "{{.Labels.name}}" updated automatically',
body_template:
"Hi {{.UserName}}\nYour workspace **{{.Labels.name}}** has been updated automatically to the latest template version ({{.Labels.template_version_name}}).",
actions:
'[{"url": "{{ base_url }}/@{{.UserUsername}}/{{.Labels.name}}", "label": "View workspace"}]',
group: "Workspace Events",
method: "smtp",
kind: "system",
enabled_by_default: true,
},
{
id: "51ce2fdf-c9ca-4be1-8d70-628674f9bc42",
name: "Workspace Marked for Deletion",
title_template: 'Workspace "{{.Labels.name}}" marked for deletion',
body_template:
"Hi {{.UserName}}\n\nYour workspace **{{.Labels.name}}** has been marked for **deletion** after {{.Labels.timeTilDormant}} of [dormancy](https://coder.com/docs/templates/schedule#dormancy-auto-deletion-enterprise) because of {{.Labels.reason}}.\nTo prevent deletion, use your workspace with the link below.",
actions:
'[{"url": "{{ base_url }}/@{{.UserUsername}}/{{.Labels.name}}", "label": "View workspace"}]',
group: "Workspace Events",
method: "webhook",
kind: "system",
enabled_by_default: true,
},
{
id: "template-event-1",
name: "Template Version Created",
title_template: 'Template version "{{.Labels.version_name}}" created',
body_template:
'Hi {{.UserName}}\nA new version of template "{{.Labels.template_name}}" has been created.',
actions:
'[{"url": "{{ base_url }}/templates/{{.Labels.template_name}}", "label": "View template"}]',
group: "Template Events",
method: "smtp",
kind: "system",
enabled_by_default: true,
},
{
id: "template-event-2",
name: "Template Updated",
title_template: 'Template "{{.Labels.template_name}}" updated',
body_template:
'Hi {{.UserName}}\nTemplate "{{.Labels.template_name}}" has been updated.',
actions:
'[{"url": "{{ base_url }}/templates/{{.Labels.template_name}}", "label": "View template"}]',
group: "Template Events",
method: "webhook",
kind: "system",
enabled_by_default: true,
},
];
export const MockSystemNotificationTemplates: TypesGen.NotificationTemplate[] =
[
{
id: "381df2a9-c0c0-4749-420f-80a9280c66f9",
name: "Workspace Autobuild Failed",
title_template: 'Workspace "{{.Labels.name}}" autobuild failed',
body_template:
'Hi {{.UserName}}\nAutomatic build of your workspace **{{.Labels.name}}** failed.\nThe specified reason was "**{{.Labels.reason}}**".',
actions:
'[{"url": "{{ base_url }}/@{{.UserUsername}}/{{.Labels.name}}", "label": "View workspace"}]',
group: "Workspace Events",
method: "webhook",
kind: "system",
enabled_by_default: true,
},
{
id: "f517da0b-cdc9-410f-ab89-a86107c420ed",
name: "Workspace Deleted",
title_template: 'Workspace "{{.Labels.name}}" deleted',
body_template:
'Hi {{.UserName}}\n\nYour workspace **{{.Labels.name}}** was deleted.\nThe specified reason was "**{{.Labels.reason}}{{ if .Labels.initiator }} ({{ .Labels.initiator }}){{end}}**".',
actions:
'[{"url": "{{ base_url }}/workspaces", "label": "View workspaces"}, {"url": "{{ base_url }}/templates", "label": "View templates"}]',
group: "Workspace Events",
method: "smtp",
kind: "system",
enabled_by_default: true,
},
{
id: "f44d9314-ad03-4bc8-95d0-5cad491da6b6",
name: "User account deleted",
title_template: 'User account "{{.Labels.deleted_account_name}}" deleted',
body_template:
"Hi {{.UserName}},\n\nUser account **{{.Labels.deleted_account_name}}** has been deleted.",
actions:
'[{"url": "{{ base_url }}/deployment/users?filter=status%3Aactive", "label": "View accounts"}]',
group: "User Events",
method: "",
kind: "system",
enabled_by_default: true,
},
{
id: "4e19c0ac-94e1-4532-9515-d1801aa283b2",
name: "User account created",
title_template: 'User account "{{.Labels.created_account_name}}" created',
body_template:
"Hi {{.UserName}},\n\nNew user account **{{.Labels.created_account_name}}** has been created.",
actions:
'[{"url": "{{ base_url }}/deployment/users?filter=status%3Aactive", "label": "View accounts"}]',
group: "User Events",
method: "",
kind: "system",
enabled_by_default: true,
},
{
id: "0ea69165-ec14-4314-91f1-69566ac3c5a0",
name: "Workspace Marked as Dormant",
title_template: 'Workspace "{{.Labels.name}}" marked as dormant',
body_template:
"Hi {{.UserName}}\n\nYour workspace **{{.Labels.name}}** has been marked as [**dormant**](https://coder.com/docs/templates/schedule#dormancy-threshold-enterprise) because of {{.Labels.reason}}.\nDormant workspaces are [automatically deleted](https://coder.com/docs/templates/schedule#dormancy-auto-deletion-enterprise) after {{.Labels.timeTilDormant}} of inactivity.\nTo prevent deletion, use your workspace with the link below.",
actions:
'[{"url": "{{ base_url }}/@{{.UserUsername}}/{{.Labels.name}}", "label": "View workspace"}]',
group: "Workspace Events",
method: "smtp",
kind: "system",
enabled_by_default: true,
},
{
id: "c34a0c09-0704-4cac-bd1c-0c0146811c2b",
name: "Workspace updated automatically",
title_template: 'Workspace "{{.Labels.name}}" updated automatically',
body_template:
"Hi {{.UserName}}\nYour workspace **{{.Labels.name}}** has been updated automatically to the latest template version ({{.Labels.template_version_name}}).",
actions:
'[{"url": "{{ base_url }}/@{{.UserUsername}}/{{.Labels.name}}", "label": "View workspace"}]',
group: "Workspace Events",
method: "smtp",
kind: "system",
enabled_by_default: true,
},
{
id: "51ce2fdf-c9ca-4be1-8d70-628674f9bc42",
name: "Workspace Marked for Deletion",
title_template: 'Workspace "{{.Labels.name}}" marked for deletion',
body_template:
"Hi {{.UserName}}\n\nYour workspace **{{.Labels.name}}** has been marked for **deletion** after {{.Labels.timeTilDormant}} of [dormancy](https://coder.com/docs/templates/schedule#dormancy-auto-deletion-enterprise) because of {{.Labels.reason}}.\nTo prevent deletion, use your workspace with the link below.",
actions:
'[{"url": "{{ base_url }}/@{{.UserUsername}}/{{.Labels.name}}", "label": "View workspace"}]',
group: "Workspace Events",
method: "webhook",
kind: "system",
enabled_by_default: true,
},
{
id: "template-event-1",
name: "Template Version Created",
title_template: 'Template version "{{.Labels.version_name}}" created',
body_template:
'Hi {{.UserName}}\nA new version of template "{{.Labels.template_name}}" has been created.',
actions:
'[{"url": "{{ base_url }}/templates/{{.Labels.template_name}}", "label": "View template"}]',
group: "Template Events",
method: "smtp",
kind: "system",
enabled_by_default: true,
},
{
id: "template-event-2",
name: "Template Updated",
title_template: 'Template "{{.Labels.template_name}}" updated',
body_template:
'Hi {{.UserName}}\nTemplate "{{.Labels.template_name}}" has been updated.',
actions:
'[{"url": "{{ base_url }}/templates/{{.Labels.template_name}}", "label": "View template"}]',
group: "Template Events",
method: "webhook",
kind: "system",
enabled_by_default: true,
},
];
export const MockCustomNotificationTemplates: TypesGen.NotificationTemplate[] =
[
{
id: "39b1e189-c857-4b0c-877a-511144c18516",
name: "Custom Notification",
title_template: "{{.Labels.custom_title}}",
body_template: "{{.Labels.custom_message}}",
actions: "[]",
group: "Custom Events",
method: "",
kind: "custom",
enabled_by_default: true,
},
];
export const MockNotificationMethodsResponse: TypesGen.NotificationMethodsResponse =
{ available: ["smtp", "webhook"], default: "smtp" };