feat(site): implement notification ui (#14175)

This commit is contained in:
Bruno Quaresma
2024-08-09 13:43:09 -03:00
committed by GitHub
parent aaa5174bef
commit 21942afef3
21 changed files with 1324 additions and 6 deletions

View File

@ -3659,6 +3659,7 @@ const getNotificationTemplatesByKind = `-- name: GetNotificationTemplatesByKind
SELECT id, name, title_template, body_template, actions, "group", method, kind SELECT id, name, title_template, body_template, actions, "group", method, kind
FROM notification_templates FROM notification_templates
WHERE kind = $1::notification_template_kind WHERE kind = $1::notification_template_kind
ORDER BY name ASC
` `
func (q *sqlQuerier) GetNotificationTemplatesByKind(ctx context.Context, kind NotificationTemplateKind) ([]NotificationTemplate, error) { func (q *sqlQuerier) GetNotificationTemplatesByKind(ctx context.Context, kind NotificationTemplateKind) ([]NotificationTemplate, error) {

View File

@ -170,4 +170,5 @@ WHERE id = @id::uuid;
-- name: GetNotificationTemplatesByKind :many -- name: GetNotificationTemplatesByKind :many
SELECT * SELECT *
FROM notification_templates FROM notification_templates
WHERE kind = @kind::notification_template_kind; WHERE kind = @kind::notification_template_kind
ORDER BY name ASC;

View File

@ -1,6 +1,13 @@
import * as _storybook_types from "@storybook/react"; import * as _storybook_types from "@storybook/react";
import type { QueryKey } from "react-query"; import type { QueryKey } from "react-query";
import type { Experiments, FeatureName } from "api/typesGenerated"; import type {
Experiments,
FeatureName,
SerpentOption,
User,
DeploymentValues,
} from "api/typesGenerated";
import type { Permissions } from "contexts/auth/permissions";
declare module "@storybook/react" { declare module "@storybook/react" {
type WebSocketEvent = type WebSocketEvent =
@ -11,5 +18,9 @@ declare module "@storybook/react" {
experiments?: Experiments; experiments?: Experiments;
queries?: { key: QueryKey; data: unknown }[]; queries?: { key: QueryKey; data: unknown }[];
webSocket?: WebSocketEvent[]; webSocket?: WebSocketEvent[];
user?: User;
permissions?: Partial<Permissions>;
deploymentValues?: DeploymentValues;
deploymentOptions?: SerpentOption[];
} }
} }

View File

@ -2036,6 +2036,49 @@ class ApiMethods {
return response.data; return response.data;
}; };
getUserNotificationPreferences = async (userId: string) => {
const res = await this.axios.get<TypesGen.NotificationPreference[] | null>(
`/api/v2/users/${userId}/notifications/preferences`,
);
return res.data ?? [];
};
putUserNotificationPreferences = async (
userId: string,
req: TypesGen.UpdateUserNotificationPreferences,
) => {
const res = await this.axios.put<TypesGen.NotificationPreference[]>(
`/api/v2/users/${userId}/notifications/preferences`,
req,
);
return res.data;
};
getSystemNotificationTemplates = async () => {
const res = await this.axios.get<TypesGen.NotificationTemplate[]>(
`/api/v2/notifications/templates/system`,
);
return res.data;
};
getNotificationDispatchMethods = async () => {
const res = await this.axios.get<TypesGen.NotificationMethodsResponse>(
`/api/v2/notifications/dispatch-methods`,
);
return res.data;
};
updateNotificationTemplateMethod = async (
templateId: string,
req: TypesGen.UpdateNotificationTemplateMethod,
) => {
const res = await this.axios.put<void>(
`/api/v2/notifications/templates/${templateId}/method`,
req,
);
return res.data;
};
} }
// This is a hard coded CSRF token/cookie pair for local development. In prod, // This is a hard coded CSRF token/cookie pair for local development. In prod,

View File

@ -0,0 +1,138 @@
import type { QueryClient, UseMutationOptions } from "react-query";
import { API } from "api/api";
import type {
NotificationPreference,
NotificationTemplate,
UpdateNotificationTemplateMethod,
UpdateUserNotificationPreferences,
} from "api/typesGenerated";
export const userNotificationPreferencesKey = (userId: string) => [
"users",
userId,
"notifications",
"preferences",
];
export const userNotificationPreferences = (userId: string) => {
return {
queryKey: userNotificationPreferencesKey(userId),
queryFn: () => API.getUserNotificationPreferences(userId),
};
};
export const updateUserNotificationPreferences = (
userId: string,
queryClient: QueryClient,
) => {
return {
mutationFn: (req) => {
return API.putUserNotificationPreferences(userId, req);
},
onMutate: (data) => {
queryClient.setQueryData(
userNotificationPreferencesKey(userId),
Object.entries(data.template_disabled_map).map(
([id, disabled]) =>
({
id,
disabled,
updated_at: new Date().toISOString(),
}) satisfies NotificationPreference,
),
);
},
} satisfies UseMutationOptions<
NotificationPreference[],
unknown,
UpdateUserNotificationPreferences
>;
};
export const systemNotificationTemplatesKey = [
"notifications",
"templates",
"system",
];
export const systemNotificationTemplates = () => {
return {
queryKey: systemNotificationTemplatesKey,
queryFn: () => API.getSystemNotificationTemplates(),
};
};
export function selectTemplatesByGroup(
data: NotificationTemplate[],
): Record<string, NotificationTemplate[]> {
const grouped = data.reduce(
(acc, tpl) => {
if (!acc[tpl.group]) {
acc[tpl.group] = [];
}
acc[tpl.group].push(tpl);
return acc;
},
{} as Record<string, NotificationTemplate[]>,
);
// Sort templates within each group
for (const group in grouped) {
grouped[group].sort((a, b) => a.name.localeCompare(b.name));
}
// Sort groups by name
const sortedGroups = Object.keys(grouped).sort((a, b) => a.localeCompare(b));
const sortedGrouped: Record<string, NotificationTemplate[]> = {};
for (const group of sortedGroups) {
sortedGrouped[group] = grouped[group];
}
return sortedGrouped;
}
export const notificationDispatchMethodsKey = [
"notifications",
"dispatchMethods",
];
export const notificationDispatchMethods = () => {
return {
staleTime: Infinity,
queryKey: notificationDispatchMethodsKey,
queryFn: () => API.getNotificationDispatchMethods(),
};
};
export const updateNotificationTemplateMethod = (
templateId: string,
queryClient: QueryClient,
) => {
return {
mutationFn: (req: UpdateNotificationTemplateMethod) =>
API.updateNotificationTemplateMethod(templateId, req),
onMutate: (data) => {
const prevData = queryClient.getQueryData<NotificationTemplate[]>(
systemNotificationTemplatesKey,
);
if (!prevData) {
return;
}
queryClient.setQueryData(
systemNotificationTemplatesKey,
prevData.map((tpl) =>
tpl.id === templateId
? {
...tpl,
method: data.method,
}
: tpl,
),
);
},
} satisfies UseMutationOptions<
void,
unknown,
UpdateNotificationTemplateMethod
>;
};

View File

@ -141,10 +141,12 @@ export function apiKey(): UseQueryOptions<GenerateAPIKeyResponse> {
}; };
} }
export const hasFirstUserKey = ["hasFirstUser"];
export const hasFirstUser = (userMetadata: MetadataState<User>) => { export const hasFirstUser = (userMetadata: MetadataState<User>) => {
return cachedQuery({ return cachedQuery({
metadata: userMetadata, metadata: userMetadata,
queryKey: ["hasFirstUser"], queryKey: hasFirstUserKey,
queryFn: API.hasFirstUser, queryFn: API.hasFirstUser,
}); });
}; };

View File

@ -0,0 +1,29 @@
import EmailIcon from "@mui/icons-material/EmailOutlined";
import WebhookIcon from "@mui/icons-material/WebhookOutlined";
// TODO: This should be provided by the auto generated types from codersdk
const notificationMethods = ["smtp", "webhook"] as const;
export type NotificationMethod = (typeof notificationMethods)[number];
export const methodIcons: Record<NotificationMethod, typeof EmailIcon> = {
smtp: EmailIcon,
webhook: WebhookIcon,
};
export const methodLabels: Record<NotificationMethod, string> = {
smtp: "SMTP",
webhook: "Webhook",
};
export const castNotificationMethod = (value: string) => {
if (notificationMethods.includes(value as NotificationMethod)) {
return value as NotificationMethod;
}
throw new Error(
`Invalid notification method: ${value}. Accepted values: ${notificationMethods.join(
", ",
)}`,
);
};

View File

@ -24,7 +24,7 @@ export const useDeploySettings = (): DeploySettingsContextValue => {
const context = useContext(DeploySettingsContext); const context = useContext(DeploySettingsContext);
if (!context) { if (!context) {
throw new Error( throw new Error(
"useDeploySettings should be used inside of DeploySettingsLayout", "useDeploySettings should be used inside of DeploySettingsContext or DeploySettingsLayout",
); );
} }
return context; return context;

View File

@ -0,0 +1,279 @@
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 { NotificationsPage } from "./NotificationsPage";
const meta: Meta<typeof NotificationsPage> = {
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,
],
};
export default meta;
type Story = StoryObj<typeof NotificationsPage>;
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 toggleButton = within(option.closest("li")!).getByRole("button", {
name: "Webhook",
});
await user.click(toggleButton);
},
};
export const Settings: Story = {
play: async ({ canvasElement }) => {
const user = userEvent.setup();
const canvas = within(canvasElement);
const settingsTab = await canvas.findByText("Settings");
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",
},
];
}

View File

@ -0,0 +1,297 @@
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 { Fragment, type FC } from "react";
import { Helmet } from "react-helmet-async";
import { useMutation, useQueries, useQueryClient } from "react-query";
import { useSearchParams } from "react-router-dom";
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 {
castNotificationMethod,
methodIcons,
methodLabels,
type NotificationMethod,
} from "modules/notifications/utils";
import { Section } from "pages/UserSettingsPage/Section";
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<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) => {
await updateMethodMutation.mutateAsync({
method,
});
displaySuccess("Notification method updated");
}}
>
{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>
);
};
export const NotificationsPage: FC = () => {
const [searchParams] = useSearchParams();
const { deploymentValues } = useDeploySettings();
const [templatesByGroup, dispatchMethods] = useQueries({
queries: [
{
...systemNotificationTemplates(),
select: selectTemplatesByGroup,
},
notificationDispatchMethods(),
],
});
const ready =
templatesByGroup.data && dispatchMethods.data && deploymentValues;
const tab = searchParams.get("tab") || "events";
return (
<>
<Helmet>
<title>{pageTitle("Notifications Settings")}</title>
</Helmet>
<Section
title="Notifications"
description="Control delivery methods for notifications on this deployment."
layout="fluid"
>
<Tabs active={tab}>
<TabsList>
<TabLink to="?tab=events" value="events">
Events
</TabLink>
<TabLink to="?tab=settings" value="settings">
Settings
</TabLink>
</TabsList>
</Tabs>
<div css={styles.content}>
{ready ? (
tab === "events" ? (
<EventsView
templatesByGroup={templatesByGroup.data}
deploymentValues={deploymentValues.config}
defaultMethod={castNotificationMethod(
dispatchMethods.data.default,
)}
availableMethods={dispatchMethods.data.available.map(
castNotificationMethod,
)}
/>
) : (
<OptionsTable
options={deploymentValues?.options.filter((o) =>
deploymentGroupHasParent(o.group, "Notifications"),
)}
/>
)
) : (
<Loader />
)}
</div>
</Section>
</>
);
};
type EventsViewProps = {
defaultMethod: NotificationMethod;
availableMethods: NotificationMethod[];
templatesByGroup: ReturnType<typeof selectTemplatesByGroup>;
deploymentValues: DeploymentValues;
};
const EventsView: FC<EventsViewProps> = ({
defaultMethod,
availableMethods,
templatesByGroup,
deploymentValues,
}) => {
return (
<Stack spacing={4}>
{availableMethods.includes("smtp") &&
deploymentValues.notifications?.webhook.endpoint === "" && (
<Alert
severity="warning"
actions={
<Button
variant="text"
size="small"
component="a"
target="_blank"
rel="noreferrer"
href={docs("/cli/server#--notifications-webhook-endpoint")}
>
Read the docs
</Button>
}
>
Webhook notifications are enabled, but no endpoint has been
configured.
</Alert>
)}
{availableMethods.includes("smtp") &&
deploymentValues.notifications?.email.smarthost === "" && (
<Alert
severity="warning"
actions={
<Button
variant="text"
size="small"
component="a"
target="_blank"
rel="noreferrer"
href={docs("/cli/server#--notifications-email-smarthost")}
>
Read the docs
</Button>
}
>
SMTP notifications are enabled, but no smarthost has been
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>
);
};
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<string, Interpolation<Theme>>;

View File

@ -3,6 +3,7 @@ import HubOutlinedIcon from "@mui/icons-material/HubOutlined";
import InsertChartIcon from "@mui/icons-material/InsertChart"; import InsertChartIcon from "@mui/icons-material/InsertChart";
import LaunchOutlined from "@mui/icons-material/LaunchOutlined"; import LaunchOutlined from "@mui/icons-material/LaunchOutlined";
import LockRounded from "@mui/icons-material/LockOutlined"; import LockRounded from "@mui/icons-material/LockOutlined";
import NotificationsIcon from "@mui/icons-material/NotificationsNoneOutlined";
import Globe from "@mui/icons-material/PublicOutlined"; import Globe from "@mui/icons-material/PublicOutlined";
import ApprovalIcon from "@mui/icons-material/VerifiedUserOutlined"; import ApprovalIcon from "@mui/icons-material/VerifiedUserOutlined";
import VpnKeyOutlined from "@mui/icons-material/VpnKeyOutlined"; import VpnKeyOutlined from "@mui/icons-material/VpnKeyOutlined";
@ -12,8 +13,11 @@ import {
Sidebar as BaseSidebar, Sidebar as BaseSidebar,
SidebarNavItem, SidebarNavItem,
} from "components/Sidebar/Sidebar"; } from "components/Sidebar/Sidebar";
import { useDashboard } from "modules/dashboard/useDashboard";
export const Sidebar: FC = () => { export const Sidebar: FC = () => {
const { experiments } = useDashboard();
return ( return (
<BaseSidebar> <BaseSidebar>
<SidebarNavItem href="general" icon={LaunchOutlined}> <SidebarNavItem href="general" icon={LaunchOutlined}>
@ -47,6 +51,11 @@ export const Sidebar: FC = () => {
<SidebarNavItem href="observability" icon={InsertChartIcon}> <SidebarNavItem href="observability" icon={InsertChartIcon}>
Observability Observability
</SidebarNavItem> </SidebarNavItem>
{experiments.includes("notifications") && (
<SidebarNavItem href="notifications" icon={NotificationsIcon}>
Notifications
</SidebarNavItem>
)}
</BaseSidebar> </BaseSidebar>
); );
}; };

View File

@ -3,6 +3,7 @@ import { useQuery } from "react-query";
import { useLocation, useParams } from "react-router-dom"; import { useLocation, useParams } from "react-router-dom";
import { organizationsPermissions } from "api/queries/organizations"; import { organizationsPermissions } from "api/queries/organizations";
import { useAuthenticated } from "contexts/auth/RequireAuth"; import { useAuthenticated } from "contexts/auth/RequireAuth";
import { useDashboard } from "modules/dashboard/useDashboard";
import { import {
canEditOrganization, canEditOrganization,
useOrganizationSettings, useOrganizationSettings,
@ -19,6 +20,7 @@ import { type OrganizationWithPermissions, SidebarView } from "./SidebarView";
export const Sidebar: FC = () => { export const Sidebar: FC = () => {
const location = useLocation(); const location = useLocation();
const { permissions } = useAuthenticated(); const { permissions } = useAuthenticated();
const { experiments } = useDashboard();
const { organizations } = useOrganizationSettings(); const { organizations } = useOrganizationSettings();
const { organization: organizationName } = useParams() as { const { organization: organizationName } = useParams() as {
organization?: string; organization?: string;
@ -54,6 +56,7 @@ export const Sidebar: FC = () => {
activeOrganizationName={organizationName} activeOrganizationName={organizationName}
organizations={editableOrgs} organizations={editableOrgs}
permissions={permissions} permissions={permissions}
experiments={experiments}
/> />
); );
}; };

View File

@ -35,6 +35,7 @@ const meta: Meta<typeof SidebarView> = {
}, },
], ],
permissions: MockPermissions, permissions: MockPermissions,
experiments: ["notifications"],
}, },
}; };

View File

@ -4,7 +4,11 @@ import AddIcon from "@mui/icons-material/Add";
import SettingsIcon from "@mui/icons-material/Settings"; import SettingsIcon from "@mui/icons-material/Settings";
import type { FC, ReactNode } from "react"; import type { FC, ReactNode } from "react";
import { Link, NavLink } from "react-router-dom"; import { Link, NavLink } from "react-router-dom";
import type { AuthorizationResponse, Organization } from "api/typesGenerated"; import type {
AuthorizationResponse,
Experiments,
Organization,
} from "api/typesGenerated";
import { Loader } from "components/Loader/Loader"; import { Loader } from "components/Loader/Loader";
import { Sidebar as BaseSidebar } from "components/Sidebar/Sidebar"; import { Sidebar as BaseSidebar } from "components/Sidebar/Sidebar";
import { Stack } from "components/Stack/Stack"; import { Stack } from "components/Stack/Stack";
@ -26,6 +30,8 @@ interface SidebarProps {
organizations: OrganizationWithPermissions[] | undefined; organizations: OrganizationWithPermissions[] | undefined;
/** Site-wide permissions. */ /** Site-wide permissions. */
permissions: AuthorizationResponse; permissions: AuthorizationResponse;
/** Active experiments */
experiments: Experiments;
} }
/** /**
@ -36,6 +42,7 @@ export const SidebarView: FC<SidebarProps> = ({
activeOrganizationName, activeOrganizationName,
organizations, organizations,
permissions, permissions,
experiments,
}) => { }) => {
// TODO: Do something nice to scroll to the active org. // TODO: Do something nice to scroll to the active org.
return ( return (
@ -43,6 +50,7 @@ export const SidebarView: FC<SidebarProps> = ({
<header css={styles.sidebarHeader}>Deployment</header> <header css={styles.sidebarHeader}>Deployment</header>
<DeploymentSettingsNavigation <DeploymentSettingsNavigation
active={!activeOrganizationName && activeSettings} active={!activeOrganizationName && activeSettings}
experiments={experiments}
permissions={permissions} permissions={permissions}
/> />
<OrganizationsSettingsNavigation <OrganizationsSettingsNavigation
@ -59,6 +67,8 @@ interface DeploymentSettingsNavigationProps {
active: boolean; active: boolean;
/** Site-wide permissions. */ /** Site-wide permissions. */
permissions: AuthorizationResponse; permissions: AuthorizationResponse;
/** Active experiments */
experiments: Experiments;
} }
/** /**
@ -71,6 +81,7 @@ interface DeploymentSettingsNavigationProps {
const DeploymentSettingsNavigation: FC<DeploymentSettingsNavigationProps> = ({ const DeploymentSettingsNavigation: FC<DeploymentSettingsNavigationProps> = ({
active, active,
permissions, permissions,
experiments,
}) => { }) => {
return ( return (
<div css={{ paddingBottom: 12 }}> <div css={{ paddingBottom: 12 }}>
@ -133,6 +144,11 @@ const DeploymentSettingsNavigation: FC<DeploymentSettingsNavigationProps> = ({
Users Users
</SidebarNavSubItem> </SidebarNavSubItem>
)} )}
{experiments.includes("notifications") && (
<SidebarNavSubItem href="notifications">
Notifications
</SidebarNavSubItem>
)}
</Stack> </Stack>
)} )}
</div> </div>

View File

@ -0,0 +1,78 @@
import type { Meta, StoryObj } from "@storybook/react";
import { spyOn, userEvent, within } from "@storybook/test";
import { API } from "api/api";
import {
notificationDispatchMethodsKey,
systemNotificationTemplatesKey,
userNotificationPreferencesKey,
} from "api/queries/notifications";
import {
MockNotificationMethodsResponse,
MockNotificationPreferences,
MockNotificationTemplates,
MockUser,
} from "testHelpers/entities";
import {
withAuthProvider,
withDashboardProvider,
withGlobalSnackbar,
} from "testHelpers/storybook";
import { NotificationsPage } from "./NotificationsPage";
const meta: Meta<typeof NotificationsPage> = {
title: "pages/UserSettingsPage/NotificationsPage",
component: NotificationsPage,
parameters: {
experiments: ["notifications"],
queries: [
{
key: userNotificationPreferencesKey(MockUser.id),
data: MockNotificationPreferences,
},
{
key: systemNotificationTemplatesKey,
data: MockNotificationTemplates,
},
{
key: notificationDispatchMethodsKey,
data: MockNotificationMethodsResponse,
},
],
user: MockUser,
permissions: { viewDeploymentValues: true },
},
decorators: [withGlobalSnackbar, withAuthProvider, withDashboardProvider],
};
export default meta;
type Story = StoryObj<typeof NotificationsPage>;
export const Default: Story = {};
export const ToggleGroup: Story = {
play: async ({ canvasElement }) => {
spyOn(API, "putUserNotificationPreferences").mockResolvedValue([]);
const user = userEvent.setup();
const canvas = within(canvasElement);
const groupLabel = await canvas.findByLabelText("Workspace Events");
await user.click(groupLabel);
},
};
export const ToggleNotification: Story = {
play: async ({ canvasElement }) => {
spyOn(API, "putUserNotificationPreferences").mockResolvedValue([]);
const user = userEvent.setup();
const canvas = within(canvasElement);
const notificationLabel = await canvas.findByLabelText(
"Workspace Marked as Dormant",
);
await user.click(notificationLabel);
},
};
export const NonAdmin: Story = {
parameters: {
permissions: { viewDeploymentValues: false },
},
};

View File

@ -0,0 +1,214 @@
import type { Interpolation, Theme } from "@emotion/react";
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 ListItemIcon from "@mui/material/ListItemIcon";
import ListItemText, { listItemTextClasses } from "@mui/material/ListItemText";
import Switch from "@mui/material/Switch";
import Tooltip from "@mui/material/Tooltip";
import { Fragment, type FC } from "react";
import { Helmet } from "react-helmet-async";
import { useMutation, useQueries, useQueryClient } from "react-query";
import {
notificationDispatchMethods,
selectTemplatesByGroup,
systemNotificationTemplates,
updateUserNotificationPreferences,
userNotificationPreferences,
} from "api/queries/notifications";
import type {
NotificationPreference,
NotificationTemplate,
} from "api/typesGenerated";
import { displaySuccess } from "components/GlobalSnackbar/utils";
import { Loader } from "components/Loader/Loader";
import { Stack } from "components/Stack/Stack";
import { useAuthenticated } from "contexts/auth/RequireAuth";
import {
castNotificationMethod,
methodIcons,
methodLabels,
} from "modules/notifications/utils";
import { pageTitle } from "utils/page";
import { Section } from "../Section";
export const NotificationsPage: FC = () => {
const { user, permissions } = useAuthenticated();
const [disabledPreferences, templatesByGroup, dispatchMethods] = useQueries({
queries: [
{
...userNotificationPreferences(user.id),
select: selectDisabledPreferences,
},
{
...systemNotificationTemplates(),
select: (data: NotificationTemplate[]) => {
const groups = selectTemplatesByGroup(data);
return permissions.viewDeploymentValues
? groups
: {
// Members only have access to the "Workspace Notifications" group
["Workspace Events"]: groups["Workspace Events"],
};
},
},
notificationDispatchMethods(),
],
});
const queryClient = useQueryClient();
const updatePreferences = useMutation(
updateUserNotificationPreferences(user.id, queryClient),
);
const ready =
disabledPreferences.data && templatesByGroup.data && dispatchMethods.data;
return (
<>
<Helmet>
<title>{pageTitle("Notifications Settings")}</title>
</Helmet>
<Section
title="Notifications"
description="Configure your notification preferences. Icons on the right of each notification indicate delivery method, either SMTP or Webhook."
layout="fluid"
>
{ready ? (
<Stack spacing={4}>
{Object.entries(templatesByGroup.data).map(([group, templates]) => {
const allDisabled = templates.some((tpl) => {
return disabledPreferences.data[tpl.id] === true;
});
return (
<Card
variant="outlined"
css={{ background: "transparent" }}
key={group}
>
<List>
<ListItem css={styles.listHeader}>
<ListItemIcon>
<Switch
id={group}
checked={!allDisabled}
onChange={async (_, checked) => {
const updated = { ...disabledPreferences.data };
for (const tpl of templates) {
updated[tpl.id] = !checked;
}
await updatePreferences.mutateAsync({
template_disabled_map: updated,
});
displaySuccess("Notification preferences updated");
}}
/>
</ListItemIcon>
<ListItemText
css={styles.listItemText}
primary={group}
primaryTypographyProps={{
component: "label",
htmlFor: group,
}}
/>
</ListItem>
{templates.map((tmpl, i) => {
const method = castNotificationMethod(
tmpl.method || dispatchMethods.data.default,
);
const Icon = methodIcons[method];
const label = methodLabels[method];
const isLastItem = i === templates.length - 1;
return (
<Fragment key={tmpl.id}>
<ListItem>
<ListItemIcon>
<Switch
id={tmpl.id}
checked={!disabledPreferences.data[tmpl.id]}
onChange={async (_, checked) => {
await updatePreferences.mutateAsync({
template_disabled_map: {
...disabledPreferences.data,
[tmpl.id]: !checked,
},
});
displaySuccess(
"Notification preferences updated",
);
}}
/>
</ListItemIcon>
<ListItemText
primaryTypographyProps={{
component: "label",
htmlFor: tmpl.id,
}}
css={styles.listItemText}
primary={tmpl.name}
/>
<ListItemIcon
css={styles.listItemEndIcon}
aria-label="Delivery method"
>
<Tooltip title={label}>
<Icon aria-label={label} />
</Tooltip>
</ListItemIcon>
</ListItem>
{!isLastItem && <Divider />}
</Fragment>
);
})}
</List>
</Card>
);
})}
</Stack>
) : (
<Loader />
)}
</Section>
</>
);
};
export default NotificationsPage;
function selectDisabledPreferences(data: NotificationPreference[]) {
return data.reduce(
(acc, pref) => {
acc[pref.id] = pref.disabled;
return acc;
},
{} as Record<string, boolean>,
);
}
const styles = {
listHeader: (theme) => ({
background: theme.palette.background.paper,
borderBottom: `1px solid ${theme.palette.divider}`,
}),
listItemText: {
[`& .${listItemTextClasses.primary}`]: {
fontSize: 14,
fontWeight: 500,
textTransform: "capitalize",
},
[`& .${listItemTextClasses.secondary}`]: {
fontSize: 14,
},
},
listItemEndIcon: (theme) => ({
minWidth: 0,
fontSize: 20,
color: theme.palette.text.secondary,
"& svg": {
fontSize: "inherit",
},
}),
} as Record<string, Interpolation<Theme>>;

View File

@ -70,6 +70,7 @@ const styles = {
description: (theme) => ({ description: (theme) => ({
color: theme.palette.text.secondary, color: theme.palette.text.secondary,
fontSize: 16, fontSize: 16,
margin: 0,
marginTop: 4, marginTop: 4,
lineHeight: "140%", lineHeight: "140%",
}), }),

View File

@ -2,6 +2,7 @@ import AppearanceIcon from "@mui/icons-material/Brush";
import ScheduleIcon from "@mui/icons-material/EditCalendarOutlined"; import ScheduleIcon from "@mui/icons-material/EditCalendarOutlined";
import FingerprintOutlinedIcon from "@mui/icons-material/FingerprintOutlined"; import FingerprintOutlinedIcon from "@mui/icons-material/FingerprintOutlined";
import SecurityIcon from "@mui/icons-material/LockOutlined"; import SecurityIcon from "@mui/icons-material/LockOutlined";
import NotificationsIcon from "@mui/icons-material/NotificationsNoneOutlined";
import AccountIcon from "@mui/icons-material/Person"; import AccountIcon from "@mui/icons-material/Person";
import VpnKeyOutlined from "@mui/icons-material/VpnKeyOutlined"; import VpnKeyOutlined from "@mui/icons-material/VpnKeyOutlined";
import type { FC } from "react"; import type { FC } from "react";
@ -20,7 +21,7 @@ interface SidebarProps {
} }
export const Sidebar: FC<SidebarProps> = ({ user }) => { export const Sidebar: FC<SidebarProps> = ({ user }) => {
const { entitlements } = useDashboard(); const { entitlements, experiments } = useDashboard();
const showSchedulePage = const showSchedulePage =
entitlements.features.advanced_template_scheduling.enabled; entitlements.features.advanced_template_scheduling.enabled;
@ -56,6 +57,11 @@ export const Sidebar: FC<SidebarProps> = ({ user }) => {
<SidebarNavItem href="tokens" icon={VpnKeyOutlined}> <SidebarNavItem href="tokens" icon={VpnKeyOutlined}>
Tokens Tokens
</SidebarNavItem> </SidebarNavItem>
{experiments.includes("notifications") && (
<SidebarNavItem href="notifications" icon={NotificationsIcon}>
Notifications
</SidebarNavItem>
)}
</BaseSidebar> </BaseSidebar>
); );
}; };

View File

@ -270,6 +270,13 @@ const WorkspaceProxyHealthPage = lazy(
const ProvisionerDaemonsHealthPage = lazy( const ProvisionerDaemonsHealthPage = lazy(
() => import("./pages/HealthPage/ProvisionerDaemonsPage"), () => import("./pages/HealthPage/ProvisionerDaemonsPage"),
); );
const UserNotificationsPage = lazy(
() => import("./pages/UserSettingsPage/NotificationsPage/NotificationsPage"),
);
const DeploymentNotificationsPage = lazy(
() =>
import("./pages/DeploySettingsPage/NotificationsPage/NotificationsPage"),
);
const RoutesWithSuspense = () => { const RoutesWithSuspense = () => {
return ( return (
@ -422,6 +429,10 @@ export const router = createBrowserRouter(
<Route path="users" element={<UsersPage />} /> <Route path="users" element={<UsersPage />} />
<Route path="users/create" element={<CreateUserPage />} /> <Route path="users/create" element={<CreateUserPage />} />
{groupsRouter()} {groupsRouter()}
<Route
path="notifications"
element={<DeploymentNotificationsPage />}
/>
</Route> </Route>
<Route path="/settings" element={<UserSettingsLayout />}> <Route path="/settings" element={<UserSettingsLayout />}>
@ -442,6 +453,7 @@ export const router = createBrowserRouter(
<Route index element={<TokensPage />} /> <Route index element={<TokensPage />} />
<Route path="new" element={<CreateTokenPage />} /> <Route path="new" element={<CreateTokenPage />} />
</Route> </Route>
<Route path="notifications" element={<UserNotificationsPage />} />
</Route> </Route>
{/* In order for the 404 page to work properly the routes that start with {/* In order for the 404 page to work properly the routes that start with

View File

@ -3720,3 +3720,131 @@ export const MockOAuth2ProviderAppSecrets: TypesGen.OAuth2ProviderAppSecret[] =
client_secret_truncated: "foo", client_secret_truncated: "foo",
}, },
]; ];
export const MockNotificationPreferences: TypesGen.NotificationPreference[] = [
{
id: "f44d9314-ad03-4bc8-95d0-5cad491da6b6",
disabled: false,
updated_at: "2024-08-06T11:58:37.755053Z",
},
{
id: "381df2a9-c0c0-4749-420f-80a9280c66f9",
disabled: true,
updated_at: "2024-08-06T11:58:37.755053Z",
},
{
id: "f517da0b-cdc9-410f-ab89-a86107c420ed",
disabled: false,
updated_at: "2024-08-06T11:58:37.755053Z",
},
{
id: "c34a0c09-0704-4cac-bd1c-0c0146811c2b",
disabled: false,
updated_at: "2024-08-06T11:58:37.755053Z",
},
{
id: "0ea69165-ec14-4314-91f1-69566ac3c5a0",
disabled: false,
updated_at: "2024-08-06T11:58:37.755053Z",
},
{
id: "51ce2fdf-c9ca-4be1-8d70-628674f9bc42",
disabled: false,
updated_at: "2024-08-06T11:58:37.755053Z",
},
{
id: "4e19c0ac-94e1-4532-9515-d1801aa283b2",
disabled: true,
updated_at: "2024-08-06T11:58:37.755053Z",
},
];
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",
},
{
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",
},
{
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",
},
{
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",
},
{
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",
},
{
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",
},
{
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",
},
];
export const MockNotificationMethodsResponse: TypesGen.NotificationMethodsResponse =
{ available: ["smtp", "webhook"], default: "smtp" };

View File

@ -1,8 +1,15 @@
import type { StoryContext } from "@storybook/react"; import type { StoryContext } from "@storybook/react";
import type { FC } from "react"; import type { FC } from "react";
import { useQueryClient } from "react-query";
import { withDefaultFeatures } from "api/api"; import { withDefaultFeatures } from "api/api";
import { getAuthorizationKey } from "api/queries/authCheck";
import { hasFirstUserKey, meKey } from "api/queries/users";
import type { Entitlements } from "api/typesGenerated"; import type { Entitlements } from "api/typesGenerated";
import { GlobalSnackbar } from "components/GlobalSnackbar/GlobalSnackbar";
import { AuthProvider } from "contexts/auth/AuthProvider";
import { permissionsToCheck } from "contexts/auth/permissions";
import { DashboardContext } from "modules/dashboard/DashboardProvider"; import { DashboardContext } from "modules/dashboard/DashboardProvider";
import { DeploySettingsContext } from "pages/DeploySettingsPage/DeploySettingsLayout";
import { import {
MockAppearanceConfig, MockAppearanceConfig,
MockDefaultOrganization, MockDefaultOrganization,
@ -87,3 +94,45 @@ export const withDesktopViewport = (Story: FC) => (
<Story /> <Story />
</div> </div>
); );
export const withAuthProvider = (Story: FC, { parameters }: StoryContext) => {
if (!parameters.user) {
throw new Error("You forgot to add `parameters.user` to your story");
}
// eslint-disable-next-line react-hooks/rules-of-hooks -- decorators are components
const queryClient = useQueryClient();
queryClient.setQueryData(meKey, parameters.user);
queryClient.setQueryData(hasFirstUserKey, true);
queryClient.setQueryData(
getAuthorizationKey({ checks: permissionsToCheck }),
parameters.permissions ?? {},
);
return (
<AuthProvider>
<Story />
</AuthProvider>
);
};
export const withGlobalSnackbar = (Story: FC) => (
<>
<Story />
<GlobalSnackbar />
</>
);
export const withDeploySettings = (Story: FC, { parameters }: StoryContext) => {
return (
<DeploySettingsContext.Provider
value={{
deploymentValues: {
config: parameters.deploymentValues ?? {},
options: parameters.deploymentOptions ?? [],
},
}}
>
<Story />
</DeploySettingsContext.Provider>
);
};