Skip to content

Commit f58660e

Browse files
committed
Extract notification events to its own component
1 parent a602c72 commit f58660e

File tree

5 files changed

+506
-469
lines changed

5 files changed

+506
-469
lines changed
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
import type { Meta, StoryObj } from "@storybook/react";
2+
import { spyOn, userEvent, within } from "@storybook/test";
3+
import { API } from "api/api";
4+
import type { DeploymentValues } from "api/typesGenerated";
5+
import { baseMeta } from "./storybookUtils";
6+
import { NotificationEvents } from "./NotificationEvents";
7+
8+
const meta: Meta<typeof NotificationEvents> = {
9+
title: "pages/DeploymentSettings/NotificationsPage/NotificationEvents",
10+
component: NotificationEvents,
11+
...baseMeta,
12+
};
13+
14+
export default meta;
15+
16+
type Story = StoryObj<typeof NotificationEvents>;
17+
18+
export const NoEmailSmarthost: Story = {
19+
parameters: {
20+
deploymentValues: {
21+
notifications: {
22+
webhook: {
23+
endpoint: "https://example.com",
24+
},
25+
email: {
26+
smarthost: "",
27+
},
28+
},
29+
} as DeploymentValues,
30+
},
31+
};
32+
33+
export const NoWebhookEndpoint: Story = {
34+
parameters: {
35+
deploymentValues: {
36+
notifications: {
37+
webhook: {
38+
endpoint: "",
39+
},
40+
email: {
41+
smarthost: "smtp.example.com",
42+
},
43+
},
44+
} as DeploymentValues,
45+
},
46+
};
47+
48+
export const Toggle: Story = {
49+
play: async ({ canvasElement }) => {
50+
spyOn(API, "updateNotificationTemplateMethod").mockResolvedValue();
51+
const user = userEvent.setup();
52+
const canvas = within(canvasElement);
53+
const option = await canvas.findByText("Workspace Marked as Dormant");
54+
const li = option.closest("li");
55+
if(!li) {
56+
throw new Error("Could not find li");
57+
}
58+
const toggleButton = within(li).getByRole("button", {
59+
name: "Webhook",
60+
});
61+
await user.click(toggleButton);
62+
},
63+
};
64+
Lines changed: 217 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,217 @@
1+
import type { Interpolation, Theme } from "@emotion/react";
2+
import Button from "@mui/material/Button";
3+
import Card from "@mui/material/Card";
4+
import Divider from "@mui/material/Divider";
5+
import List from "@mui/material/List";
6+
import ListItem from "@mui/material/ListItem";
7+
import ListItemText, { listItemTextClasses } from "@mui/material/ListItemText";
8+
import ToggleButton from "@mui/material/ToggleButton";
9+
import ToggleButtonGroup from "@mui/material/ToggleButtonGroup";
10+
import Tooltip from "@mui/material/Tooltip";
11+
import {
12+
updateNotificationTemplateMethod,
13+
type selectTemplatesByGroup,
14+
} from "api/queries/notifications";
15+
import type { DeploymentValues } from "api/typesGenerated";
16+
import { Alert } from "components/Alert/Alert";
17+
import { displaySuccess } from "components/GlobalSnackbar/utils";
18+
import { Stack } from "components/Stack/Stack";
19+
import {
20+
type NotificationMethod,
21+
castNotificationMethod,
22+
methodIcons,
23+
methodLabels,
24+
} from "modules/notifications/utils";
25+
import { type FC, Fragment } from "react";
26+
import { useMutation, useQueryClient } from "react-query";
27+
import { docs } from "utils/docs";
28+
29+
type NotificationEventsProps = {
30+
defaultMethod: NotificationMethod;
31+
availableMethods: NotificationMethod[];
32+
templatesByGroup: ReturnType<typeof selectTemplatesByGroup>;
33+
deploymentValues: DeploymentValues;
34+
};
35+
36+
export const NotificationEvents: FC<NotificationEventsProps> = ({
37+
defaultMethod,
38+
availableMethods,
39+
templatesByGroup,
40+
deploymentValues,
41+
}) => {
42+
return (
43+
<Stack spacing={4}>
44+
{availableMethods.includes("smtp") &&
45+
deploymentValues.notifications?.webhook.endpoint === "" && (
46+
<Alert
47+
severity="warning"
48+
actions={
49+
<Button
50+
variant="text"
51+
size="small"
52+
component="a"
53+
target="_blank"
54+
rel="noreferrer"
55+
href={docs("/cli/server#--notifications-webhook-endpoint")}
56+
>
57+
Read the docs
58+
</Button>
59+
}
60+
>
61+
Webhook notifications are enabled, but no endpoint has been
62+
configured.
63+
</Alert>
64+
)}
65+
66+
{availableMethods.includes("smtp") &&
67+
deploymentValues.notifications?.email.smarthost === "" && (
68+
<Alert
69+
severity="warning"
70+
actions={
71+
<Button
72+
variant="text"
73+
size="small"
74+
component="a"
75+
target="_blank"
76+
rel="noreferrer"
77+
href={docs("/cli/server#--notifications-email-smarthost")}
78+
>
79+
Read the docs
80+
</Button>
81+
}
82+
>
83+
SMTP notifications are enabled, but no smarthost has been
84+
configured.
85+
</Alert>
86+
)}
87+
88+
{Object.entries(templatesByGroup).map(([group, templates]) => (
89+
<Card
90+
key={group}
91+
variant="outlined"
92+
css={{ background: "transparent", width: "100%" }}
93+
>
94+
<List>
95+
<ListItem css={styles.listHeader}>
96+
<ListItemText css={styles.listItemText} primary={group} />
97+
</ListItem>
98+
99+
{templates.map((tpl, i) => {
100+
const value = castNotificationMethod(tpl.method || defaultMethod);
101+
const isLastItem = i === templates.length - 1;
102+
103+
return (
104+
<Fragment key={tpl.id}>
105+
<ListItem>
106+
<ListItemText
107+
css={styles.listItemText}
108+
primary={tpl.name}
109+
/>
110+
<MethodToggleGroup
111+
templateId={tpl.id}
112+
options={availableMethods}
113+
value={value}
114+
/>
115+
</ListItem>
116+
{!isLastItem && <Divider />}
117+
</Fragment>
118+
);
119+
})}
120+
</List>
121+
</Card>
122+
))}
123+
</Stack>
124+
);
125+
};
126+
127+
type MethodToggleGroupProps = {
128+
templateId: string;
129+
options: NotificationMethod[];
130+
value: NotificationMethod;
131+
};
132+
133+
const MethodToggleGroup: FC<MethodToggleGroupProps> = ({
134+
value,
135+
options,
136+
templateId,
137+
}) => {
138+
const queryClient = useQueryClient();
139+
const updateMethodMutation = useMutation(
140+
updateNotificationTemplateMethod(templateId, queryClient),
141+
);
142+
143+
return (
144+
<ToggleButtonGroup
145+
exclusive
146+
value={value}
147+
size="small"
148+
aria-label="Notification method"
149+
css={styles.toggleGroup}
150+
onChange={async (_, method) => {
151+
await updateMethodMutation.mutateAsync({
152+
method,
153+
});
154+
displaySuccess("Notification method updated");
155+
}}
156+
>
157+
{options.map((method) => {
158+
const Icon = methodIcons[method];
159+
const label = methodLabels[method];
160+
return (
161+
<Tooltip key={method} title={label}>
162+
<ToggleButton
163+
value={method}
164+
css={styles.toggleButton}
165+
onClick={(e) => {
166+
// Retain the value if the user clicks the same button, ensuring
167+
// at least one value remains selected.
168+
if (method === value) {
169+
e.preventDefault();
170+
e.stopPropagation();
171+
return;
172+
}
173+
}}
174+
>
175+
<Icon aria-label={label} />
176+
</ToggleButton>
177+
</Tooltip>
178+
);
179+
})}
180+
</ToggleButtonGroup>
181+
);
182+
};
183+
184+
const styles = {
185+
listHeader: (theme) => ({
186+
background: theme.palette.background.paper,
187+
borderBottom: `1px solid ${theme.palette.divider}`,
188+
}),
189+
listItemText: {
190+
[`& .${listItemTextClasses.primary}`]: {
191+
fontSize: 14,
192+
fontWeight: 500,
193+
},
194+
[`& .${listItemTextClasses.secondary}`]: {
195+
fontSize: 14,
196+
},
197+
},
198+
toggleGroup: (theme) => ({
199+
border: `1px solid ${theme.palette.divider}`,
200+
borderRadius: 4,
201+
}),
202+
toggleButton: (theme) => ({
203+
border: 0,
204+
borderRadius: 4,
205+
fontSize: 16,
206+
padding: "4px 8px",
207+
color: theme.palette.text.disabled,
208+
209+
"&:hover": {
210+
color: theme.palette.text.primary,
211+
},
212+
213+
"& svg": {
214+
fontSize: "inherit",
215+
},
216+
}),
217+
} as Record<string, Interpolation<Theme>>;

0 commit comments

Comments
 (0)