From a602c72f1c7d55bea0c20341d77c262796bef2be Mon Sep 17 00:00:00 2001 From: BrunoQuaresma Date: Wed, 4 Sep 2024 13:05:07 +0000 Subject: [PATCH 1/7] Fix lint error --- .../NotificationsPage/NotificationsPage.stories.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/site/src/pages/DeploySettingsPage/NotificationsPage/NotificationsPage.stories.tsx b/site/src/pages/DeploySettingsPage/NotificationsPage/NotificationsPage.stories.tsx index b3d994c2faad7..73313a0a5f079 100644 --- a/site/src/pages/DeploySettingsPage/NotificationsPage/NotificationsPage.stories.tsx +++ b/site/src/pages/DeploySettingsPage/NotificationsPage/NotificationsPage.stories.tsx @@ -95,7 +95,11 @@ export const Toggle: Story = { const user = userEvent.setup(); const canvas = within(canvasElement); const option = await canvas.findByText("Workspace Marked as Dormant"); - const toggleButton = within(option.closest("li")!).getByRole("button", { + const li = option.closest("li"); + if(!li) { + throw new Error("Could not find li"); + } + const toggleButton = within(li).getByRole("button", { name: "Webhook", }); await user.click(toggleButton); From f58660ef0adc4749bf76278f2887d21311727276 Mon Sep 17 00:00:00 2001 From: BrunoQuaresma Date: Wed, 4 Sep 2024 16:42:00 +0000 Subject: [PATCH 2/7] Extract notification events to its own component --- .../NotificationEvents.stories.tsx | 64 +++++ .../NotificationsPage/NotificationEvents.tsx | 217 ++++++++++++++ .../NotificationsPage.stories.tsx | 266 +----------------- .../NotificationsPage/NotificationsPage.tsx | 211 +------------- .../NotificationsPage/storybookUtils.ts | 217 ++++++++++++++ 5 files changed, 506 insertions(+), 469 deletions(-) create mode 100644 site/src/pages/DeploySettingsPage/NotificationsPage/NotificationEvents.stories.tsx create mode 100644 site/src/pages/DeploySettingsPage/NotificationsPage/NotificationEvents.tsx create mode 100644 site/src/pages/DeploySettingsPage/NotificationsPage/storybookUtils.ts diff --git a/site/src/pages/DeploySettingsPage/NotificationsPage/NotificationEvents.stories.tsx b/site/src/pages/DeploySettingsPage/NotificationsPage/NotificationEvents.stories.tsx new file mode 100644 index 0000000000000..e25c0df6d190f --- /dev/null +++ b/site/src/pages/DeploySettingsPage/NotificationsPage/NotificationEvents.stories.tsx @@ -0,0 +1,64 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { spyOn, userEvent, within } from "@storybook/test"; +import { API } from "api/api"; +import type { DeploymentValues } from "api/typesGenerated"; +import { baseMeta } from "./storybookUtils"; +import { NotificationEvents } from "./NotificationEvents"; + +const meta: Meta = { + title: "pages/DeploymentSettings/NotificationsPage/NotificationEvents", + component: NotificationEvents, + ...baseMeta, +}; + +export default meta; + +type Story = StoryObj; + +export const NoEmailSmarthost: Story = { + parameters: { + deploymentValues: { + notifications: { + webhook: { + endpoint: "https://example.com", + }, + email: { + smarthost: "", + }, + }, + } as DeploymentValues, + }, +}; + +export const NoWebhookEndpoint: Story = { + parameters: { + deploymentValues: { + notifications: { + webhook: { + endpoint: "", + }, + email: { + smarthost: "smtp.example.com", + }, + }, + } as DeploymentValues, + }, +}; + +export const Toggle: Story = { + play: async ({ canvasElement }) => { + spyOn(API, "updateNotificationTemplateMethod").mockResolvedValue(); + const user = userEvent.setup(); + const canvas = within(canvasElement); + const option = await canvas.findByText("Workspace Marked as Dormant"); + const li = option.closest("li"); + if(!li) { + throw new Error("Could not find li"); + } + const toggleButton = within(li).getByRole("button", { + name: "Webhook", + }); + await user.click(toggleButton); + }, +}; + diff --git a/site/src/pages/DeploySettingsPage/NotificationsPage/NotificationEvents.tsx b/site/src/pages/DeploySettingsPage/NotificationsPage/NotificationEvents.tsx new file mode 100644 index 0000000000000..55ba886da48a6 --- /dev/null +++ b/site/src/pages/DeploySettingsPage/NotificationsPage/NotificationEvents.tsx @@ -0,0 +1,217 @@ +import type { Interpolation, Theme } from "@emotion/react"; +import Button from "@mui/material/Button"; +import Card from "@mui/material/Card"; +import Divider from "@mui/material/Divider"; +import List from "@mui/material/List"; +import ListItem from "@mui/material/ListItem"; +import ListItemText, { listItemTextClasses } from "@mui/material/ListItemText"; +import ToggleButton from "@mui/material/ToggleButton"; +import ToggleButtonGroup from "@mui/material/ToggleButtonGroup"; +import Tooltip from "@mui/material/Tooltip"; +import { + updateNotificationTemplateMethod, + type selectTemplatesByGroup, +} from "api/queries/notifications"; +import type { DeploymentValues } from "api/typesGenerated"; +import { Alert } from "components/Alert/Alert"; +import { displaySuccess } from "components/GlobalSnackbar/utils"; +import { Stack } from "components/Stack/Stack"; +import { + type NotificationMethod, + castNotificationMethod, + methodIcons, + methodLabels, +} from "modules/notifications/utils"; +import { type FC, Fragment } from "react"; +import { useMutation, useQueryClient } from "react-query"; +import { docs } from "utils/docs"; + +type NotificationEventsProps = { + defaultMethod: NotificationMethod; + availableMethods: NotificationMethod[]; + templatesByGroup: ReturnType; + deploymentValues: DeploymentValues; +}; + +export const NotificationEvents: FC = ({ + defaultMethod, + availableMethods, + templatesByGroup, + deploymentValues, +}) => { + return ( + + {availableMethods.includes("smtp") && + deploymentValues.notifications?.webhook.endpoint === "" && ( + + Read the docs + + } + > + Webhook notifications are enabled, but no endpoint has been + configured. + + )} + + {availableMethods.includes("smtp") && + deploymentValues.notifications?.email.smarthost === "" && ( + + Read the docs + + } + > + SMTP notifications are enabled, but no smarthost has been + configured. + + )} + + {Object.entries(templatesByGroup).map(([group, templates]) => ( + + + + + + + {templates.map((tpl, i) => { + const value = castNotificationMethod(tpl.method || defaultMethod); + const isLastItem = i === templates.length - 1; + + return ( + + + + + + {!isLastItem && } + + ); + })} + + + ))} + + ); +}; + +type MethodToggleGroupProps = { + templateId: string; + options: NotificationMethod[]; + value: NotificationMethod; +}; + +const MethodToggleGroup: FC = ({ + value, + options, + templateId, +}) => { + const queryClient = useQueryClient(); + const updateMethodMutation = useMutation( + updateNotificationTemplateMethod(templateId, queryClient), + ); + + return ( + { + await updateMethodMutation.mutateAsync({ + method, + }); + displaySuccess("Notification method updated"); + }} + > + {options.map((method) => { + const Icon = methodIcons[method]; + const label = methodLabels[method]; + return ( + + { + // Retain the value if the user clicks the same button, ensuring + // at least one value remains selected. + if (method === value) { + e.preventDefault(); + e.stopPropagation(); + return; + } + }} + > + + + + ); + })} + + ); +}; + +const styles = { + listHeader: (theme) => ({ + background: theme.palette.background.paper, + borderBottom: `1px solid ${theme.palette.divider}`, + }), + listItemText: { + [`& .${listItemTextClasses.primary}`]: { + fontSize: 14, + fontWeight: 500, + }, + [`& .${listItemTextClasses.secondary}`]: { + fontSize: 14, + }, + }, + toggleGroup: (theme) => ({ + border: `1px solid ${theme.palette.divider}`, + borderRadius: 4, + }), + toggleButton: (theme) => ({ + border: 0, + borderRadius: 4, + fontSize: 16, + padding: "4px 8px", + color: theme.palette.text.disabled, + + "&:hover": { + color: theme.palette.text.primary, + }, + + "& svg": { + fontSize: "inherit", + }, + }), +} as Record>; diff --git a/site/src/pages/DeploySettingsPage/NotificationsPage/NotificationsPage.stories.tsx b/site/src/pages/DeploySettingsPage/NotificationsPage/NotificationsPage.stories.tsx index 73313a0a5f079..99488b8196f01 100644 --- a/site/src/pages/DeploySettingsPage/NotificationsPage/NotificationsPage.stories.tsx +++ b/site/src/pages/DeploySettingsPage/NotificationsPage/NotificationsPage.stories.tsx @@ -1,110 +1,19 @@ import type { Meta, StoryObj } from "@storybook/react"; -import { spyOn, userEvent, within } from "@storybook/test"; -import { API } from "api/api"; -import { - notificationDispatchMethodsKey, - systemNotificationTemplatesKey, -} from "api/queries/notifications"; -import type { DeploymentValues, SerpentOption } from "api/typesGenerated"; -import { - MockNotificationMethodsResponse, - MockNotificationTemplates, - MockUser, -} from "testHelpers/entities"; -import { - withAuthProvider, - withDashboardProvider, - withDeploySettings, - withGlobalSnackbar, -} from "testHelpers/storybook"; +import { userEvent, within } from "@storybook/test"; import { NotificationsPage } from "./NotificationsPage"; +import { baseMeta } from "./storybookUtils"; const meta: Meta = { title: "pages/DeploymentSettings/NotificationsPage", component: NotificationsPage, - parameters: { - experiments: ["notifications"], - queries: [ - { key: systemNotificationTemplatesKey, data: MockNotificationTemplates }, - { - key: notificationDispatchMethodsKey, - data: MockNotificationMethodsResponse, - }, - ], - user: MockUser, - permissions: { viewDeploymentValues: true }, - deploymentOptions: mockNotificationOptions(), - deploymentValues: { - notifications: { - webhook: { - endpoint: "https://example.com", - }, - email: { - smarthost: "smtp.example.com", - }, - }, - } as DeploymentValues, - }, - decorators: [ - withGlobalSnackbar, - withAuthProvider, - withDashboardProvider, - withDeploySettings, - ], + ...baseMeta, }; export default meta; type Story = StoryObj; -export const Default: Story = {}; - -export const NoEmailSmarthost: Story = { - parameters: { - deploymentValues: { - notifications: { - webhook: { - endpoint: "https://example.com", - }, - email: { - smarthost: "", - }, - }, - } as DeploymentValues, - }, -}; - -export const NoWebhookEndpoint: Story = { - parameters: { - deploymentValues: { - notifications: { - webhook: { - endpoint: "", - }, - email: { - smarthost: "smtp.example.com", - }, - }, - } as DeploymentValues, - }, -}; - -export const Toggle: Story = { - play: async ({ canvasElement }) => { - spyOn(API, "updateNotificationTemplateMethod").mockResolvedValue(); - const user = userEvent.setup(); - const canvas = within(canvasElement); - const option = await canvas.findByText("Workspace Marked as Dormant"); - const li = option.closest("li"); - if(!li) { - throw new Error("Could not find li"); - } - const toggleButton = within(li).getByRole("button", { - name: "Webhook", - }); - await user.click(toggleButton); - }, -}; +export const Events: Story = {}; export const Settings: Story = { play: async ({ canvasElement }) => { @@ -114,170 +23,3 @@ export const Settings: Story = { await user.click(settingsTab); }, }; - -function mockNotificationOptions(): SerpentOption[] { - return [ - { - name: "Notifications: Dispatch Timeout", - description: - "How long to wait while a notification is being sent before giving up.", - flag: "notifications-dispatch-timeout", - env: "CODER_NOTIFICATIONS_DISPATCH_TIMEOUT", - yaml: "dispatchTimeout", - default: "1m0s", - value: 60000000000, - annotations: { - format_duration: "true", - }, - group: { - name: "Notifications", - yaml: "notifications", - description: "Configure how notifications are processed and delivered.", - }, - value_source: "default", - }, - { - name: "Notifications: Fetch Interval", - description: "How often to query the database for queued notifications.", - flag: "notifications-fetch-interval", - env: "CODER_NOTIFICATIONS_FETCH_INTERVAL", - yaml: "fetchInterval", - default: "15s", - value: 15000000000, - annotations: { - format_duration: "true", - }, - group: { - name: "Notifications", - yaml: "notifications", - description: "Configure how notifications are processed and delivered.", - }, - hidden: true, - value_source: "default", - }, - { - name: "Notifications: Lease Count", - description: - "How many notifications a notifier should lease per fetch interval.", - flag: "notifications-lease-count", - env: "CODER_NOTIFICATIONS_LEASE_COUNT", - yaml: "leaseCount", - default: "20", - value: 20, - group: { - name: "Notifications", - yaml: "notifications", - description: "Configure how notifications are processed and delivered.", - }, - hidden: true, - value_source: "default", - }, - { - name: "Notifications: Lease Period", - description: - "How long a notifier should lease a message. This is effectively how long a notification is 'owned' by a notifier, and once this period expires it will be available for lease by another notifier. Leasing is important in order for multiple running notifiers to not pick the same messages to deliver concurrently. This lease period will only expire if a notifier shuts down ungracefully; a dispatch of the notification releases the lease.", - flag: "notifications-lease-period", - env: "CODER_NOTIFICATIONS_LEASE_PERIOD", - yaml: "leasePeriod", - default: "2m0s", - value: 120000000000, - annotations: { - format_duration: "true", - }, - group: { - name: "Notifications", - yaml: "notifications", - description: "Configure how notifications are processed and delivered.", - }, - hidden: true, - value_source: "default", - }, - { - name: "Notifications: Max Send Attempts", - description: "The upper limit of attempts to send a notification.", - flag: "notifications-max-send-attempts", - env: "CODER_NOTIFICATIONS_MAX_SEND_ATTEMPTS", - yaml: "maxSendAttempts", - default: "5", - value: 5, - group: { - name: "Notifications", - yaml: "notifications", - description: "Configure how notifications are processed and delivered.", - }, - value_source: "default", - }, - { - name: "Notifications: Method", - description: - "Which delivery method to use (available options: 'smtp', 'webhook').", - flag: "notifications-method", - env: "CODER_NOTIFICATIONS_METHOD", - yaml: "method", - default: "smtp", - value: "smtp", - group: { - name: "Notifications", - yaml: "notifications", - description: "Configure how notifications are processed and delivered.", - }, - value_source: "env", - }, - { - name: "Notifications: Retry Interval", - description: "The minimum time between retries.", - flag: "notifications-retry-interval", - env: "CODER_NOTIFICATIONS_RETRY_INTERVAL", - yaml: "retryInterval", - default: "5m0s", - value: 300000000000, - annotations: { - format_duration: "true", - }, - group: { - name: "Notifications", - yaml: "notifications", - description: "Configure how notifications are processed and delivered.", - }, - hidden: true, - value_source: "default", - }, - { - name: "Notifications: Store Sync Buffer Size", - description: - "The notifications system buffers message updates in memory to ease pressure on the database. This option controls how many updates are kept in memory. The lower this value the lower the change of state inconsistency in a non-graceful shutdown - but it also increases load on the database. It is recommended to keep this option at its default value.", - flag: "notifications-store-sync-buffer-size", - env: "CODER_NOTIFICATIONS_STORE_SYNC_BUFFER_SIZE", - yaml: "storeSyncBufferSize", - default: "50", - value: 50, - group: { - name: "Notifications", - yaml: "notifications", - description: "Configure how notifications are processed and delivered.", - }, - hidden: true, - value_source: "default", - }, - { - name: "Notifications: Store Sync Interval", - description: - "The notifications system buffers message updates in memory to ease pressure on the database. This option controls how often it synchronizes its state with the database. The shorter this value the lower the change of state inconsistency in a non-graceful shutdown - but it also increases load on the database. It is recommended to keep this option at its default value.", - flag: "notifications-store-sync-interval", - env: "CODER_NOTIFICATIONS_STORE_SYNC_INTERVAL", - yaml: "storeSyncInterval", - default: "2s", - value: 2000000000, - annotations: { - format_duration: "true", - }, - group: { - name: "Notifications", - yaml: "notifications", - description: "Configure how notifications are processed and delivered.", - }, - hidden: true, - value_source: "default", - }, - ]; -} diff --git a/site/src/pages/DeploySettingsPage/NotificationsPage/NotificationsPage.tsx b/site/src/pages/DeploySettingsPage/NotificationsPage/NotificationsPage.tsx index 150c62280ff22..e1c606fadac0c 100644 --- a/site/src/pages/DeploySettingsPage/NotificationsPage/NotificationsPage.tsx +++ b/site/src/pages/DeploySettingsPage/NotificationsPage/NotificationsPage.tsx @@ -1,98 +1,24 @@ import type { Interpolation, Theme } from "@emotion/react"; -import Button from "@mui/material/Button"; -import Card from "@mui/material/Card"; -import Divider from "@mui/material/Divider"; -import List from "@mui/material/List"; -import ListItem from "@mui/material/ListItem"; -import ListItemText, { listItemTextClasses } from "@mui/material/ListItemText"; -import ToggleButton from "@mui/material/ToggleButton"; -import ToggleButtonGroup from "@mui/material/ToggleButtonGroup"; -import Tooltip from "@mui/material/Tooltip"; import { notificationDispatchMethods, selectTemplatesByGroup, systemNotificationTemplates, - updateNotificationTemplateMethod, } from "api/queries/notifications"; -import type { DeploymentValues } from "api/typesGenerated"; -import { Alert } from "components/Alert/Alert"; -import { displaySuccess } from "components/GlobalSnackbar/utils"; import { Loader } from "components/Loader/Loader"; -import { Stack } from "components/Stack/Stack"; import { TabLink, Tabs, TabsList } from "components/Tabs/Tabs"; import { - type NotificationMethod, castNotificationMethod, - methodIcons, - methodLabels, } from "modules/notifications/utils"; import { Section } from "pages/UserSettingsPage/Section"; -import { type FC, Fragment } from "react"; +import type { FC } from "react"; import { Helmet } from "react-helmet-async"; -import { useMutation, useQueries, useQueryClient } from "react-query"; +import { useQueries } from "react-query"; import { useSearchParams } from "react-router-dom"; import { deploymentGroupHasParent } from "utils/deployOptions"; -import { docs } from "utils/docs"; import { pageTitle } from "utils/page"; import { useDeploySettings } from "../DeploySettingsLayout"; import OptionsTable from "../OptionsTable"; - -type MethodToggleGroupProps = { - templateId: string; - options: NotificationMethod[]; - value: NotificationMethod; -}; - -const MethodToggleGroup: FC = ({ - value, - options, - templateId, -}) => { - const queryClient = useQueryClient(); - const updateMethodMutation = useMutation( - updateNotificationTemplateMethod(templateId, queryClient), - ); - - return ( - { - await updateMethodMutation.mutateAsync({ - method, - }); - displaySuccess("Notification method updated"); - }} - > - {options.map((method) => { - const Icon = methodIcons[method]; - const label = methodLabels[method]; - return ( - - { - // Retain the value if the user clicks the same button, ensuring - // at least one value remains selected. - if (method === value) { - e.preventDefault(); - e.stopPropagation(); - return; - } - }} - > - - - - ); - })} - - ); -}; +import { NotificationEvents } from "./NotificationEvents"; export const NotificationsPage: FC = () => { const [searchParams] = useSearchParams(); @@ -134,7 +60,7 @@ export const NotificationsPage: FC = () => {
{ready ? ( tab === "events" ? ( - { ); }; -type EventsViewProps = { - defaultMethod: NotificationMethod; - availableMethods: NotificationMethod[]; - templatesByGroup: ReturnType; - deploymentValues: DeploymentValues; -}; - -const EventsView: FC = ({ - defaultMethod, - availableMethods, - templatesByGroup, - deploymentValues, -}) => { - return ( - - {availableMethods.includes("smtp") && - deploymentValues.notifications?.webhook.endpoint === "" && ( - - Read the docs - - } - > - Webhook notifications are enabled, but no endpoint has been - configured. - - )} - - {availableMethods.includes("smtp") && - deploymentValues.notifications?.email.smarthost === "" && ( - - Read the docs - - } - > - SMTP notifications are enabled, but no smarthost has been - configured. - - )} - - {Object.entries(templatesByGroup).map(([group, templates]) => ( - - - - - - - {templates.map((tpl, i) => { - const value = castNotificationMethod(tpl.method || defaultMethod); - const isLastItem = i === templates.length - 1; - - return ( - - - - - - {!isLastItem && } - - ); - })} - - - ))} - - ); -}; export default NotificationsPage; const styles = { content: { paddingTop: 24 }, - listHeader: (theme) => ({ - background: theme.palette.background.paper, - borderBottom: `1px solid ${theme.palette.divider}`, - }), - listItemText: { - [`& .${listItemTextClasses.primary}`]: { - fontSize: 14, - fontWeight: 500, - }, - [`& .${listItemTextClasses.secondary}`]: { - fontSize: 14, - }, - }, - toggleGroup: (theme) => ({ - border: `1px solid ${theme.palette.divider}`, - borderRadius: 4, - }), - toggleButton: (theme) => ({ - border: 0, - borderRadius: 4, - fontSize: 16, - padding: "4px 8px", - color: theme.palette.text.disabled, - - "&:hover": { - color: theme.palette.text.primary, - }, - - "& svg": { - fontSize: "inherit", - }, - }), } as Record>; diff --git a/site/src/pages/DeploySettingsPage/NotificationsPage/storybookUtils.ts b/site/src/pages/DeploySettingsPage/NotificationsPage/storybookUtils.ts new file mode 100644 index 0000000000000..64a148807b5c8 --- /dev/null +++ b/site/src/pages/DeploySettingsPage/NotificationsPage/storybookUtils.ts @@ -0,0 +1,217 @@ +import type { Meta } from "@storybook/react"; +import { + notificationDispatchMethodsKey, + systemNotificationTemplatesKey, +} from "api/queries/notifications"; +import type { DeploymentValues, SerpentOption } from "api/typesGenerated"; +import { + MockNotificationMethodsResponse, + MockNotificationTemplates, + MockUser, +} from "testHelpers/entities"; +import { + withAuthProvider, + withDashboardProvider, + withDeploySettings, + withGlobalSnackbar, +} from "testHelpers/storybook"; +import type { NotificationsPage } from "./NotificationsPage"; + +// Extracted from a real API response +const mockedOptions: SerpentOption[] = [ + { + name: "Notifications: Dispatch Timeout", + description: + "How long to wait while a notification is being sent before giving up.", + flag: "notifications-dispatch-timeout", + env: "CODER_NOTIFICATIONS_DISPATCH_TIMEOUT", + yaml: "dispatchTimeout", + default: "1m0s", + value: 60000000000, + annotations: { + format_duration: "true", + }, + group: { + name: "Notifications", + yaml: "notifications", + description: "Configure how notifications are processed and delivered.", + }, + value_source: "default", + }, + { + name: "Notifications: Fetch Interval", + description: "How often to query the database for queued notifications.", + flag: "notifications-fetch-interval", + env: "CODER_NOTIFICATIONS_FETCH_INTERVAL", + yaml: "fetchInterval", + default: "15s", + value: 15000000000, + annotations: { + format_duration: "true", + }, + group: { + name: "Notifications", + yaml: "notifications", + description: "Configure how notifications are processed and delivered.", + }, + hidden: true, + value_source: "default", + }, + { + name: "Notifications: Lease Count", + description: + "How many notifications a notifier should lease per fetch interval.", + flag: "notifications-lease-count", + env: "CODER_NOTIFICATIONS_LEASE_COUNT", + yaml: "leaseCount", + default: "20", + value: 20, + group: { + name: "Notifications", + yaml: "notifications", + description: "Configure how notifications are processed and delivered.", + }, + hidden: true, + value_source: "default", + }, + { + name: "Notifications: Lease Period", + description: + "How long a notifier should lease a message. This is effectively how long a notification is 'owned' by a notifier, and once this period expires it will be available for lease by another notifier. Leasing is important in order for multiple running notifiers to not pick the same messages to deliver concurrently. This lease period will only expire if a notifier shuts down ungracefully; a dispatch of the notification releases the lease.", + flag: "notifications-lease-period", + env: "CODER_NOTIFICATIONS_LEASE_PERIOD", + yaml: "leasePeriod", + default: "2m0s", + value: 120000000000, + annotations: { + format_duration: "true", + }, + group: { + name: "Notifications", + yaml: "notifications", + description: "Configure how notifications are processed and delivered.", + }, + hidden: true, + value_source: "default", + }, + { + name: "Notifications: Max Send Attempts", + description: "The upper limit of attempts to send a notification.", + flag: "notifications-max-send-attempts", + env: "CODER_NOTIFICATIONS_MAX_SEND_ATTEMPTS", + yaml: "maxSendAttempts", + default: "5", + value: 5, + group: { + name: "Notifications", + yaml: "notifications", + description: "Configure how notifications are processed and delivered.", + }, + value_source: "default", + }, + { + name: "Notifications: Method", + description: + "Which delivery method to use (available options: 'smtp', 'webhook').", + flag: "notifications-method", + env: "CODER_NOTIFICATIONS_METHOD", + yaml: "method", + default: "smtp", + value: "smtp", + group: { + name: "Notifications", + yaml: "notifications", + description: "Configure how notifications are processed and delivered.", + }, + value_source: "env", + }, + { + name: "Notifications: Retry Interval", + description: "The minimum time between retries.", + flag: "notifications-retry-interval", + env: "CODER_NOTIFICATIONS_RETRY_INTERVAL", + yaml: "retryInterval", + default: "5m0s", + value: 300000000000, + annotations: { + format_duration: "true", + }, + group: { + name: "Notifications", + yaml: "notifications", + description: "Configure how notifications are processed and delivered.", + }, + hidden: true, + value_source: "default", + }, + { + name: "Notifications: Store Sync Buffer Size", + description: + "The notifications system buffers message updates in memory to ease pressure on the database. This option controls how many updates are kept in memory. The lower this value the lower the change of state inconsistency in a non-graceful shutdown - but it also increases load on the database. It is recommended to keep this option at its default value.", + flag: "notifications-store-sync-buffer-size", + env: "CODER_NOTIFICATIONS_STORE_SYNC_BUFFER_SIZE", + yaml: "storeSyncBufferSize", + default: "50", + value: 50, + group: { + name: "Notifications", + yaml: "notifications", + description: "Configure how notifications are processed and delivered.", + }, + hidden: true, + value_source: "default", + }, + { + name: "Notifications: Store Sync Interval", + description: + "The notifications system buffers message updates in memory to ease pressure on the database. This option controls how often it synchronizes its state with the database. The shorter this value the lower the change of state inconsistency in a non-graceful shutdown - but it also increases load on the database. It is recommended to keep this option at its default value.", + flag: "notifications-store-sync-interval", + env: "CODER_NOTIFICATIONS_STORE_SYNC_INTERVAL", + yaml: "storeSyncInterval", + default: "2s", + value: 2000000000, + annotations: { + format_duration: "true", + }, + group: { + name: "Notifications", + yaml: "notifications", + description: "Configure how notifications are processed and delivered.", + }, + hidden: true, + value_source: "default", + }, +]; + +export const baseMeta = { + parameters: { + experiments: ["notifications"], + queries: [ + { key: systemNotificationTemplatesKey, data: MockNotificationTemplates }, + { + key: notificationDispatchMethodsKey, + data: MockNotificationMethodsResponse, + }, + ], + user: MockUser, + permissions: { viewDeploymentValues: true }, + deploymentOptions: mockedOptions, + deploymentValues: { + notifications: { + webhook: { + endpoint: "https://example.com", + }, + email: { + smarthost: "smtp.example.com", + }, + }, + } as DeploymentValues, + }, + decorators: [ + withGlobalSnackbar, + withAuthProvider, + withDashboardProvider, + withDeploySettings, + ], +} satisfies Meta; + From 20ccc3f1b13e894d938da15a8b37d06120d0e9d9 Mon Sep 17 00:00:00 2001 From: BrunoQuaresma Date: Wed, 4 Sep 2024 18:42:31 +0000 Subject: [PATCH 3/7] Only show alert if templates has the method enabled --- .../NotificationEvents.stories.tsx | 12 ++++++-- .../NotificationsPage/NotificationEvents.tsx | 7 +++-- .../NotificationsPage.stories.tsx | 29 +++++++++++++++++++ .../NotificationsPage/storybookUtils.ts | 4 +-- 4 files changed, 46 insertions(+), 6 deletions(-) diff --git a/site/src/pages/DeploySettingsPage/NotificationsPage/NotificationEvents.stories.tsx b/site/src/pages/DeploySettingsPage/NotificationsPage/NotificationEvents.stories.tsx index e25c0df6d190f..c8f2737978413 100644 --- a/site/src/pages/DeploySettingsPage/NotificationsPage/NotificationEvents.stories.tsx +++ b/site/src/pages/DeploySettingsPage/NotificationsPage/NotificationEvents.stories.tsx @@ -4,10 +4,18 @@ import { API } from "api/api"; import type { DeploymentValues } from "api/typesGenerated"; import { baseMeta } from "./storybookUtils"; import { NotificationEvents } from "./NotificationEvents"; +import { selectTemplatesByGroup } from "api/queries/notifications"; +import { MockNotificationTemplates } from "testHelpers/entities"; const meta: Meta = { title: "pages/DeploymentSettings/NotificationsPage/NotificationEvents", component: NotificationEvents, + args: { + defaultMethod: "smtp", + availableMethods: ["smtp", "webhook"], + templatesByGroup: selectTemplatesByGroup(MockNotificationTemplates), + deploymentValues: baseMeta.parameters.deploymentValues + }, ...baseMeta, }; @@ -16,7 +24,7 @@ export default meta; type Story = StoryObj; export const NoEmailSmarthost: Story = { - parameters: { + args: { deploymentValues: { notifications: { webhook: { @@ -31,7 +39,7 @@ export const NoEmailSmarthost: Story = { }; export const NoWebhookEndpoint: Story = { - parameters: { + args: { deploymentValues: { notifications: { webhook: { diff --git a/site/src/pages/DeploySettingsPage/NotificationsPage/NotificationEvents.tsx b/site/src/pages/DeploySettingsPage/NotificationsPage/NotificationEvents.tsx index 55ba886da48a6..80b1f29e2571d 100644 --- a/site/src/pages/DeploySettingsPage/NotificationsPage/NotificationEvents.tsx +++ b/site/src/pages/DeploySettingsPage/NotificationsPage/NotificationEvents.tsx @@ -39,9 +39,12 @@ export const NotificationEvents: FC = ({ templatesByGroup, deploymentValues, }) => { + const hasWebhookNotifications = Object.values(templatesByGroup).flat().some(t => t.method === "webhook") + const hasEmailNotifications = Object.values(templatesByGroup).flat().some(t => t.method === "smtp") + return ( - {availableMethods.includes("smtp") && + {hasWebhookNotifications && deploymentValues.notifications?.webhook.endpoint === "" && ( = ({ )} - {availableMethods.includes("smtp") && + {hasEmailNotifications && deploymentValues.notifications?.email.smarthost === "" && ( = { title: "pages/DeploymentSettings/NotificationsPage", @@ -13,6 +15,33 @@ export default meta; type Story = StoryObj; +export const LoadingTemplates: Story = { + parameters: { + queries: [ + { + key: systemNotificationTemplatesKey, + data: undefined, + }, + { + key: notificationDispatchMethodsKey, + data: MockNotificationMethodsResponse, + }, + ] + } +}; + +export const LoadingDispatchMethods: Story = { + parameters: { + queries: [ + { key: systemNotificationTemplatesKey, data: MockNotificationTemplates }, + { + key: notificationDispatchMethodsKey, + data: undefined, + }, + ] + } +}; + export const Events: Story = {}; export const Settings: Story = { diff --git a/site/src/pages/DeploySettingsPage/NotificationsPage/storybookUtils.ts b/site/src/pages/DeploySettingsPage/NotificationsPage/storybookUtils.ts index 64a148807b5c8..2acf131887c75 100644 --- a/site/src/pages/DeploySettingsPage/NotificationsPage/storybookUtils.ts +++ b/site/src/pages/DeploySettingsPage/NotificationsPage/storybookUtils.ts @@ -18,7 +18,7 @@ import { import type { NotificationsPage } from "./NotificationsPage"; // Extracted from a real API response -const mockedOptions: SerpentOption[] = [ +export const mockNotificationsDeploymentOptions: SerpentOption[] = [ { name: "Notifications: Dispatch Timeout", description: @@ -195,7 +195,7 @@ export const baseMeta = { ], user: MockUser, permissions: { viewDeploymentValues: true }, - deploymentOptions: mockedOptions, + deploymentOptions: mockNotificationsDeploymentOptions, deploymentValues: { notifications: { webhook: { From c4d4afb024934f842c3dad49ac4bb9776ecd7b5a Mon Sep 17 00:00:00 2001 From: BrunoQuaresma Date: Wed, 4 Sep 2024 18:43:00 +0000 Subject: [PATCH 4/7] Fix fmt --- .../NotificationEvents.stories.tsx | 11 +++++------ .../NotificationsPage/NotificationEvents.tsx | 10 +++++++--- .../NotificationsPage.stories.tsx | 18 ++++++++++++------ .../NotificationsPage/NotificationsPage.tsx | 5 +---- .../NotificationsPage/storybookUtils.ts | 5 ++--- 5 files changed, 27 insertions(+), 22 deletions(-) diff --git a/site/src/pages/DeploySettingsPage/NotificationsPage/NotificationEvents.stories.tsx b/site/src/pages/DeploySettingsPage/NotificationsPage/NotificationEvents.stories.tsx index c8f2737978413..0769be96d1319 100644 --- a/site/src/pages/DeploySettingsPage/NotificationsPage/NotificationEvents.stories.tsx +++ b/site/src/pages/DeploySettingsPage/NotificationsPage/NotificationEvents.stories.tsx @@ -1,11 +1,11 @@ import type { Meta, StoryObj } from "@storybook/react"; import { spyOn, userEvent, within } from "@storybook/test"; import { API } from "api/api"; -import type { DeploymentValues } from "api/typesGenerated"; -import { baseMeta } from "./storybookUtils"; -import { NotificationEvents } from "./NotificationEvents"; import { selectTemplatesByGroup } from "api/queries/notifications"; +import type { DeploymentValues } from "api/typesGenerated"; import { MockNotificationTemplates } from "testHelpers/entities"; +import { NotificationEvents } from "./NotificationEvents"; +import { baseMeta } from "./storybookUtils"; const meta: Meta = { title: "pages/DeploymentSettings/NotificationsPage/NotificationEvents", @@ -14,7 +14,7 @@ const meta: Meta = { defaultMethod: "smtp", availableMethods: ["smtp", "webhook"], templatesByGroup: selectTemplatesByGroup(MockNotificationTemplates), - deploymentValues: baseMeta.parameters.deploymentValues + deploymentValues: baseMeta.parameters.deploymentValues, }, ...baseMeta, }; @@ -60,7 +60,7 @@ export const Toggle: Story = { const canvas = within(canvasElement); const option = await canvas.findByText("Workspace Marked as Dormant"); const li = option.closest("li"); - if(!li) { + if (!li) { throw new Error("Could not find li"); } const toggleButton = within(li).getByRole("button", { @@ -69,4 +69,3 @@ export const Toggle: Story = { await user.click(toggleButton); }, }; - diff --git a/site/src/pages/DeploySettingsPage/NotificationsPage/NotificationEvents.tsx b/site/src/pages/DeploySettingsPage/NotificationsPage/NotificationEvents.tsx index 80b1f29e2571d..dcaeb86c0437b 100644 --- a/site/src/pages/DeploySettingsPage/NotificationsPage/NotificationEvents.tsx +++ b/site/src/pages/DeploySettingsPage/NotificationsPage/NotificationEvents.tsx @@ -9,8 +9,8 @@ import ToggleButton from "@mui/material/ToggleButton"; import ToggleButtonGroup from "@mui/material/ToggleButtonGroup"; import Tooltip from "@mui/material/Tooltip"; import { - updateNotificationTemplateMethod, type selectTemplatesByGroup, + updateNotificationTemplateMethod, } from "api/queries/notifications"; import type { DeploymentValues } from "api/typesGenerated"; import { Alert } from "components/Alert/Alert"; @@ -39,8 +39,12 @@ export const NotificationEvents: FC = ({ templatesByGroup, deploymentValues, }) => { - const hasWebhookNotifications = Object.values(templatesByGroup).flat().some(t => t.method === "webhook") - const hasEmailNotifications = Object.values(templatesByGroup).flat().some(t => t.method === "smtp") + const hasWebhookNotifications = Object.values(templatesByGroup) + .flat() + .some((t) => t.method === "webhook"); + const hasEmailNotifications = Object.values(templatesByGroup) + .flat() + .some((t) => t.method === "smtp"); return ( diff --git a/site/src/pages/DeploySettingsPage/NotificationsPage/NotificationsPage.stories.tsx b/site/src/pages/DeploySettingsPage/NotificationsPage/NotificationsPage.stories.tsx index c5af877149706..79984c46dd46e 100644 --- a/site/src/pages/DeploySettingsPage/NotificationsPage/NotificationsPage.stories.tsx +++ b/site/src/pages/DeploySettingsPage/NotificationsPage/NotificationsPage.stories.tsx @@ -1,9 +1,15 @@ import type { Meta, StoryObj } from "@storybook/react"; import { userEvent, within } from "@storybook/test"; +import { + notificationDispatchMethodsKey, + systemNotificationTemplatesKey, +} from "api/queries/notifications"; +import { + MockNotificationMethodsResponse, + MockNotificationTemplates, +} from "testHelpers/entities"; import { NotificationsPage } from "./NotificationsPage"; import { baseMeta } from "./storybookUtils"; -import { notificationDispatchMethodsKey, systemNotificationTemplatesKey } from "api/queries/notifications"; -import { MockNotificationMethodsResponse, MockNotificationTemplates } from "testHelpers/entities"; const meta: Meta = { title: "pages/DeploymentSettings/NotificationsPage", @@ -26,8 +32,8 @@ export const LoadingTemplates: Story = { key: notificationDispatchMethodsKey, data: MockNotificationMethodsResponse, }, - ] - } + ], + }, }; export const LoadingDispatchMethods: Story = { @@ -38,8 +44,8 @@ export const LoadingDispatchMethods: Story = { key: notificationDispatchMethodsKey, data: undefined, }, - ] - } + ], + }, }; export const Events: Story = {}; diff --git a/site/src/pages/DeploySettingsPage/NotificationsPage/NotificationsPage.tsx b/site/src/pages/DeploySettingsPage/NotificationsPage/NotificationsPage.tsx index e1c606fadac0c..c073792248072 100644 --- a/site/src/pages/DeploySettingsPage/NotificationsPage/NotificationsPage.tsx +++ b/site/src/pages/DeploySettingsPage/NotificationsPage/NotificationsPage.tsx @@ -6,9 +6,7 @@ import { } from "api/queries/notifications"; import { Loader } from "components/Loader/Loader"; import { TabLink, Tabs, TabsList } from "components/Tabs/Tabs"; -import { - castNotificationMethod, -} from "modules/notifications/utils"; +import { castNotificationMethod } from "modules/notifications/utils"; import { Section } from "pages/UserSettingsPage/Section"; import type { FC } from "react"; import { Helmet } from "react-helmet-async"; @@ -86,7 +84,6 @@ export const NotificationsPage: FC = () => { ); }; - export default NotificationsPage; const styles = { diff --git a/site/src/pages/DeploySettingsPage/NotificationsPage/storybookUtils.ts b/site/src/pages/DeploySettingsPage/NotificationsPage/storybookUtils.ts index 2acf131887c75..d11714796d37a 100644 --- a/site/src/pages/DeploySettingsPage/NotificationsPage/storybookUtils.ts +++ b/site/src/pages/DeploySettingsPage/NotificationsPage/storybookUtils.ts @@ -18,7 +18,7 @@ import { import type { NotificationsPage } from "./NotificationsPage"; // Extracted from a real API response -export const mockNotificationsDeploymentOptions: SerpentOption[] = [ +export const mockNotificationsDeploymentOptions: SerpentOption[] = [ { name: "Notifications: Dispatch Timeout", description: @@ -213,5 +213,4 @@ export const baseMeta = { withDashboardProvider, withDeploySettings, ], -} satisfies Meta; - +} satisfies Meta; From f54c43591de4ee186e846564623f217f02476940 Mon Sep 17 00:00:00 2001 From: BrunoQuaresma Date: Fri, 6 Sep 2024 13:31:10 +0000 Subject: [PATCH 5/7] Apply fixes and improvements suggested by @dannykopping --- .../NotificationEvents.stories.tsx | 31 ++++- .../NotificationsPage/NotificationEvents.tsx | 119 +++++++++++------- .../NotificationsPage/storybookUtils.ts | 2 + 3 files changed, 101 insertions(+), 51 deletions(-) diff --git a/site/src/pages/DeploySettingsPage/NotificationsPage/NotificationEvents.stories.tsx b/site/src/pages/DeploySettingsPage/NotificationsPage/NotificationEvents.stories.tsx index 0769be96d1319..36f175f42c9c9 100644 --- a/site/src/pages/DeploySettingsPage/NotificationsPage/NotificationEvents.stories.tsx +++ b/site/src/pages/DeploySettingsPage/NotificationsPage/NotificationEvents.stories.tsx @@ -23,7 +23,7 @@ export default meta; type Story = StoryObj; -export const NoEmailSmarthost: Story = { +export const SMTPNotConfigured: Story = { args: { deploymentValues: { notifications: { @@ -38,7 +38,7 @@ export const NoEmailSmarthost: Story = { }, }; -export const NoWebhookEndpoint: Story = { +export const WebhookNotConfigured: Story = { args: { deploymentValues: { notifications: { @@ -47,6 +47,8 @@ export const NoWebhookEndpoint: Story = { }, email: { smarthost: "smtp.example.com", + from: "localhost", + hello: "localhost", }, }, } as DeploymentValues, @@ -58,7 +60,8 @@ export const Toggle: Story = { spyOn(API, "updateNotificationTemplateMethod").mockResolvedValue(); const user = userEvent.setup(); const canvas = within(canvasElement); - const option = await canvas.findByText("Workspace Marked as Dormant"); + const tmpl = MockNotificationTemplates[4]; + const option = await canvas.findByText(tmpl.name); const li = option.closest("li"); if (!li) { throw new Error("Could not find li"); @@ -67,5 +70,27 @@ export const Toggle: Story = { name: "Webhook", }); await user.click(toggleButton); + await within(document.body).findByText("Notification method updated"); + }, +}; + +export const ToggleError: Story = { + play: async ({ canvasElement }) => { + spyOn(API, "updateNotificationTemplateMethod").mockRejectedValue({}); + const user = userEvent.setup(); + const canvas = within(canvasElement); + const tmpl = MockNotificationTemplates[4]; + const option = await canvas.findByText(tmpl.name); + const li = option.closest("li"); + if (!li) { + throw new Error("Could not find li"); + } + const toggleButton = within(li).getByRole("button", { + name: "Webhook", + }); + await user.click(toggleButton); + await within(document.body).findByText( + "Failed to update notification method", + ); }, }; diff --git a/site/src/pages/DeploySettingsPage/NotificationsPage/NotificationEvents.tsx b/site/src/pages/DeploySettingsPage/NotificationsPage/NotificationEvents.tsx index dcaeb86c0437b..cabf7a24c3704 100644 --- a/site/src/pages/DeploySettingsPage/NotificationsPage/NotificationEvents.tsx +++ b/site/src/pages/DeploySettingsPage/NotificationsPage/NotificationEvents.tsx @@ -8,13 +8,14 @@ import ListItemText, { listItemTextClasses } from "@mui/material/ListItemText"; import ToggleButton from "@mui/material/ToggleButton"; import ToggleButtonGroup from "@mui/material/ToggleButtonGroup"; import Tooltip from "@mui/material/Tooltip"; +import { getErrorMessage } from "api/errors"; import { type selectTemplatesByGroup, updateNotificationTemplateMethod, } from "api/queries/notifications"; import type { DeploymentValues } from "api/typesGenerated"; import { Alert } from "components/Alert/Alert"; -import { displaySuccess } from "components/GlobalSnackbar/utils"; +import { displayError, displaySuccess } from "components/GlobalSnackbar/utils"; import { Stack } from "components/Stack/Stack"; import { type NotificationMethod, @@ -39,58 +40,67 @@ export const NotificationEvents: FC = ({ templatesByGroup, deploymentValues, }) => { + // Webhook const hasWebhookNotifications = Object.values(templatesByGroup) .flat() .some((t) => t.method === "webhook"); - const hasEmailNotifications = Object.values(templatesByGroup) + const webhookValues = deploymentValues.notifications?.webhook ?? {}; + const isWebhookConfigured = requiredFieldsArePresent(webhookValues, [ + "endpoint", + ]); + + // SMTP + const hasSMTPNotifications = Object.values(templatesByGroup) .flat() .some((t) => t.method === "smtp"); + const smtpValues = deploymentValues.notifications?.email ?? {}; + const isSMTPConfigured = requiredFieldsArePresent(smtpValues, [ + "smarthost", + "from", + "hello", + ]); return ( - {hasWebhookNotifications && - deploymentValues.notifications?.webhook.endpoint === "" && ( - - Read the docs - - } - > - Webhook notifications are enabled, but no endpoint has been - configured. - - )} + {hasWebhookNotifications && !isWebhookConfigured && ( + + Read the docs + + } + > + Webhook notifications are enabled, but not properly configured. + + )} - {hasEmailNotifications && - deploymentValues.notifications?.email.smarthost === "" && ( - - Read the docs - - } - > - SMTP notifications are enabled, but no smarthost has been - configured. - - )} + {hasSMTPNotifications && !isSMTPConfigured && ( + + Read the docs + + } + > + SMTP notifications are enabled but not properly configured. + + )} {Object.entries(templatesByGroup).map(([group, templates]) => ( = ({ ); }; +function requiredFieldsArePresent( + obj: Record, + fields: string[], +): boolean { + return fields.every((field) => Boolean(obj[field])); +} + type MethodToggleGroupProps = { templateId: string; options: NotificationMethod[]; @@ -155,10 +172,16 @@ const MethodToggleGroup: FC = ({ aria-label="Notification method" css={styles.toggleGroup} onChange={async (_, method) => { - await updateMethodMutation.mutateAsync({ - method, - }); - displaySuccess("Notification method updated"); + try { + await updateMethodMutation.mutateAsync({ + method, + }); + displaySuccess("Notification method updated"); + } catch (error) { + displayError( + getErrorMessage(error, "Failed to update notification method"), + ); + } }} > {options.map((method) => { diff --git a/site/src/pages/DeploySettingsPage/NotificationsPage/storybookUtils.ts b/site/src/pages/DeploySettingsPage/NotificationsPage/storybookUtils.ts index d11714796d37a..b443cf8af92da 100644 --- a/site/src/pages/DeploySettingsPage/NotificationsPage/storybookUtils.ts +++ b/site/src/pages/DeploySettingsPage/NotificationsPage/storybookUtils.ts @@ -203,6 +203,8 @@ export const baseMeta = { }, email: { smarthost: "smtp.example.com", + from: "localhost", + hello: "localhost", }, }, } as DeploymentValues, From 8cf84781afa0840e9facaafdbaf66bbfd9f920f7 Mon Sep 17 00:00:00 2001 From: Bruno Quaresma Date: Fri, 6 Sep 2024 11:04:29 -0300 Subject: [PATCH 6/7] Update site/src/pages/DeploySettingsPage/NotificationsPage/NotificationEvents.stories.tsx Co-authored-by: Danny Kopping --- .../NotificationsPage/NotificationEvents.stories.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/site/src/pages/DeploySettingsPage/NotificationsPage/NotificationEvents.stories.tsx b/site/src/pages/DeploySettingsPage/NotificationsPage/NotificationEvents.stories.tsx index 36f175f42c9c9..159a2d45b8e63 100644 --- a/site/src/pages/DeploySettingsPage/NotificationsPage/NotificationEvents.stories.tsx +++ b/site/src/pages/DeploySettingsPage/NotificationsPage/NotificationEvents.stories.tsx @@ -47,7 +47,7 @@ export const WebhookNotConfigured: Story = { }, email: { smarthost: "smtp.example.com", - from: "localhost", + from: "bob@localhost", hello: "localhost", }, }, From db9c136c8253176302942dd5c24e3c1727caccc7 Mon Sep 17 00:00:00 2001 From: Bruno Quaresma Date: Fri, 6 Sep 2024 11:05:25 -0300 Subject: [PATCH 7/7] Update storybookUtils.ts --- .../DeploySettingsPage/NotificationsPage/storybookUtils.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/site/src/pages/DeploySettingsPage/NotificationsPage/storybookUtils.ts b/site/src/pages/DeploySettingsPage/NotificationsPage/storybookUtils.ts index b443cf8af92da..c422adb56adb9 100644 --- a/site/src/pages/DeploySettingsPage/NotificationsPage/storybookUtils.ts +++ b/site/src/pages/DeploySettingsPage/NotificationsPage/storybookUtils.ts @@ -203,7 +203,7 @@ export const baseMeta = { }, email: { smarthost: "smtp.example.com", - from: "localhost", + from: "bob@localhost", hello: "localhost", }, },