-
Notifications
You must be signed in to change notification settings - Fork 894
fix(site): only show method warning if some template is using it #14565
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
a602c72
f58660e
8ac097c
20ccc3f
c4d4afb
f54c435
8cf8478
db9c136
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,96 @@ | ||
import type { Meta, StoryObj } from "@storybook/react"; | ||
import { spyOn, userEvent, within } from "@storybook/test"; | ||
import { API } from "api/api"; | ||
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<typeof NotificationEvents> = { | ||
title: "pages/DeploymentSettings/NotificationsPage/NotificationEvents", | ||
component: NotificationEvents, | ||
args: { | ||
defaultMethod: "smtp", | ||
availableMethods: ["smtp", "webhook"], | ||
templatesByGroup: selectTemplatesByGroup(MockNotificationTemplates), | ||
deploymentValues: baseMeta.parameters.deploymentValues, | ||
}, | ||
...baseMeta, | ||
}; | ||
|
||
export default meta; | ||
|
||
type Story = StoryObj<typeof NotificationEvents>; | ||
|
||
export const SMTPNotConfigured: Story = { | ||
args: { | ||
deploymentValues: { | ||
notifications: { | ||
webhook: { | ||
endpoint: "https://example.com", | ||
}, | ||
email: { | ||
smarthost: "", | ||
}, | ||
}, | ||
} as DeploymentValues, | ||
}, | ||
}; | ||
|
||
export const WebhookNotConfigured: Story = { | ||
args: { | ||
deploymentValues: { | ||
notifications: { | ||
webhook: { | ||
endpoint: "", | ||
}, | ||
email: { | ||
smarthost: "smtp.example.com", | ||
from: "bob@localhost", | ||
hello: "localhost", | ||
}, | ||
}, | ||
} as DeploymentValues, | ||
}, | ||
}; | ||
|
||
export const Toggle: Story = { | ||
play: async ({ canvasElement }) => { | ||
spyOn(API, "updateNotificationTemplateMethod").mockResolvedValue(); | ||
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("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", | ||
); | ||
}, | ||
}; |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,247 @@ | ||
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 { 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 { displayError, 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<typeof selectTemplatesByGroup>; | ||
deploymentValues: DeploymentValues; | ||
}; | ||
|
||
export const NotificationEvents: FC<NotificationEventsProps> = ({ | ||
defaultMethod, | ||
availableMethods, | ||
templatesByGroup, | ||
deploymentValues, | ||
}) => { | ||
// Webhook | ||
const hasWebhookNotifications = Object.values(templatesByGroup) | ||
.flat() | ||
.some((t) => t.method === "webhook"); | ||
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 ( | ||
<Stack spacing={4}> | ||
{hasWebhookNotifications && !isWebhookConfigured && ( | ||
<Alert | ||
severity="warning" | ||
actions={ | ||
<Button | ||
variant="text" | ||
size="small" | ||
component="a" | ||
target="_blank" | ||
rel="noreferrer" | ||
href={docs("/admin/notifications#webhook")} | ||
> | ||
Read the docs | ||
</Button> | ||
} | ||
> | ||
Webhook notifications are enabled, but not properly configured. | ||
</Alert> | ||
)} | ||
|
||
{hasSMTPNotifications && !isSMTPConfigured && ( | ||
<Alert | ||
severity="warning" | ||
actions={ | ||
<Button | ||
variant="text" | ||
size="small" | ||
component="a" | ||
target="_blank" | ||
rel="noreferrer" | ||
href={docs("/admin/notifications#smtp-email")} | ||
> | ||
Read the docs | ||
</Button> | ||
} | ||
> | ||
SMTP notifications are enabled but not properly configured. | ||
</Alert> | ||
)} | ||
|
||
{Object.entries(templatesByGroup).map(([group, templates]) => ( | ||
<Card | ||
key={group} | ||
variant="outlined" | ||
css={{ background: "transparent", width: "100%" }} | ||
> | ||
<List> | ||
<ListItem css={styles.listHeader}> | ||
<ListItemText css={styles.listItemText} primary={group} /> | ||
</ListItem> | ||
|
||
{templates.map((tpl, i) => { | ||
const value = castNotificationMethod(tpl.method || defaultMethod); | ||
const isLastItem = i === templates.length - 1; | ||
|
||
return ( | ||
<Fragment key={tpl.id}> | ||
<ListItem> | ||
<ListItemText | ||
css={styles.listItemText} | ||
primary={tpl.name} | ||
/> | ||
<MethodToggleGroup | ||
templateId={tpl.id} | ||
options={availableMethods} | ||
value={value} | ||
/> | ||
</ListItem> | ||
{!isLastItem && <Divider />} | ||
</Fragment> | ||
); | ||
})} | ||
</List> | ||
</Card> | ||
))} | ||
</Stack> | ||
); | ||
}; | ||
|
||
function requiredFieldsArePresent( | ||
obj: Record<string, string | undefined>, | ||
fields: string[], | ||
): boolean { | ||
return fields.every((field) => Boolean(obj[field])); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Nit: Could use the There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. We also need to check if the field is not an empty string so I think There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Ah yeah fair enough 😎 |
||
} | ||
|
||
type MethodToggleGroupProps = { | ||
templateId: string; | ||
options: NotificationMethod[]; | ||
value: NotificationMethod; | ||
}; | ||
|
||
const MethodToggleGroup: FC<MethodToggleGroupProps> = ({ | ||
value, | ||
options, | ||
templateId, | ||
}) => { | ||
const queryClient = useQueryClient(); | ||
const updateMethodMutation = useMutation( | ||
updateNotificationTemplateMethod(templateId, queryClient), | ||
); | ||
|
||
return ( | ||
<ToggleButtonGroup | ||
exclusive | ||
value={value} | ||
size="small" | ||
aria-label="Notification method" | ||
css={styles.toggleGroup} | ||
onChange={async (_, method) => { | ||
try { | ||
await updateMethodMutation.mutateAsync({ | ||
method, | ||
}); | ||
displaySuccess("Notification method updated"); | ||
} catch (error) { | ||
displayError( | ||
getErrorMessage(error, "Failed to update notification method"), | ||
); | ||
} | ||
}} | ||
> | ||
{options.map((method) => { | ||
const Icon = methodIcons[method]; | ||
const label = methodLabels[method]; | ||
return ( | ||
<Tooltip key={method} title={label}> | ||
<ToggleButton | ||
value={method} | ||
css={styles.toggleButton} | ||
onClick={(e) => { | ||
// 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; | ||
} | ||
}} | ||
> | ||
<Icon aria-label={label} /> | ||
</ToggleButton> | ||
</Tooltip> | ||
); | ||
})} | ||
</ToggleButtonGroup> | ||
); | ||
}; | ||
|
||
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<string, Interpolation<Theme>>; |
Uh oh!
There was an error while loading. Please reload this page.