From da467bad2af6922488faef40470f2e3150472694 Mon Sep 17 00:00:00 2001 From: Susana Ferreira Date: Thu, 25 Sep 2025 09:48:13 +0100 Subject: [PATCH] feat(site): add custom notification settings (#19938) ## Description Add Custom Notification settings to `/deployment/notifications` page and `/settings/notifications` user's page. Screenshot 2025-09-24 at 12 53 52 Screenshot 2025-09-24 at 12 54 06 Follow-up from: https://github.com/coder/coder/pull/19751 --- site/src/api/api.ts | 7 + site/src/api/queries/notifications.ts | 44 +-- .../NotificationEvents.stories.tsx | 8 +- .../NotificationsPage.stories.tsx | 32 ++- .../NotificationsPage/NotificationsPage.tsx | 37 ++- .../NotificationsPage/storybookUtils.ts | 13 +- .../NotificationsPage.stories.tsx | 12 +- .../NotificationsPage/NotificationsPage.tsx | 26 +- site/src/testHelpers/entities.ts | 254 ++++++++++-------- 9 files changed, 273 insertions(+), 160 deletions(-) diff --git a/site/src/api/api.ts b/site/src/api/api.ts index e20dab2471..a6c9aeb479 100644 --- a/site/src/api/api.ts +++ b/site/src/api/api.ts @@ -2519,6 +2519,13 @@ class ApiMethods { return res.data; }; + getCustomNotificationTemplates = async () => { + const res = await this.axios.get( + "/api/v2/notifications/templates/custom", + ); + return res.data; + }; + getNotificationDispatchMethods = async () => { const res = await this.axios.get( "/api/v2/notifications/dispatch-methods", diff --git a/site/src/api/queries/notifications.ts b/site/src/api/queries/notifications.ts index 3c54ffc949..86d8ead105 100644 --- a/site/src/api/queries/notifications.ts +++ b/site/src/api/queries/notifications.ts @@ -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 { @@ -106,23 +119,24 @@ export const updateNotificationTemplateMethod = ( mutationFn: (req: UpdateNotificationTemplateMethod) => API.updateNotificationTemplateMethod(templateId, req), onMutate: (data) => { - const prevData = queryClient.getQueryData( + const keys = [ systemNotificationTemplatesKey, - ); - if (!prevData) { - return; + customNotificationTemplatesKey, + ]; + + for (const key of keys) { + const prev = queryClient.getQueryData(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, diff --git a/site/src/pages/DeploymentSettingsPage/NotificationsPage/NotificationEvents.stories.tsx b/site/src/pages/DeploymentSettingsPage/NotificationsPage/NotificationEvents.stories.tsx index 1b1a93605c..e9f955b1f9 100644 --- a/site/src/pages/DeploymentSettingsPage/NotificationsPage/NotificationEvents.stories.tsx +++ b/site/src/pages/DeploymentSettingsPage/NotificationsPage/NotificationEvents.stories.tsx @@ -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 = { 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) { diff --git a/site/src/pages/DeploymentSettingsPage/NotificationsPage/NotificationsPage.stories.tsx b/site/src/pages/DeploymentSettingsPage/NotificationsPage/NotificationsPage.stories.tsx index e35348e027..7e107f91fd 100644 --- a/site/src/pages/DeploymentSettingsPage/NotificationsPage/NotificationsPage.stories.tsx +++ b/site/src/pages/DeploymentSettingsPage/NotificationsPage/NotificationsPage.stories.tsx @@ -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 }) => { diff --git a/site/src/pages/DeploymentSettingsPage/NotificationsPage/NotificationsPage.tsx b/site/src/pages/DeploymentSettingsPage/NotificationsPage/NotificationsPage.tsx index 893bd28313..180e31a28e 100644 --- a/site/src/pages/DeploymentSettingsPage/NotificationsPage/NotificationsPage.tsx +++ b/site/src/pages/DeploymentSettingsPage/NotificationsPage/NotificationsPage.tsx @@ -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 ( <> @@ -79,7 +94,7 @@ const NotificationsPage: FC = () => { {ready ? ( tabState.value === "events" ? ( tpl.id === enabledPreference.id, ); if (!templateToDisable) { diff --git a/site/src/pages/UserSettingsPage/NotificationsPage/NotificationsPage.tsx b/site/src/pages/UserSettingsPage/NotificationsPage/NotificationsPage.tsx index d9e8e99c07..46f3e8c98c 100644 --- a/site/src/pages/UserSettingsPage/NotificationsPage/NotificationsPage.tsx +++ b/site/src/pages/UserSettingsPage/NotificationsPage/NotificationsPage.tsx @@ -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 ? ( - {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; } diff --git a/site/src/testHelpers/entities.ts b/site/src/testHelpers/entities.ts index 7dac53a0df..571bcb5e3d 100644 --- a/site/src/testHelpers/entities.ts +++ b/site/src/testHelpers/entities.ts @@ -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" };