[CORL-498, CORL-495, CORL-539, CORL-496, CORL-494] Email Notifications Support & Framework (#2498)

* chore: renamed old templates

* feat: initial notifications support

* feat: email enhancements

* fix: linting

* feat: initial digesting beheviour

* feat: added notification configuration

* feat: added unsubscribe routes

* fix: fixed failing snapshots/tests bc random ids

* feat: adjusted the save beheviour, added tests

* feat: added tests

* feat: added staff replies

* feat: renamed E-Mail to Email

* feat: enhanced cron processing

* fix: linting + updating tests

* feat: enhanced cron context

* fix: added staff replies back in
This commit is contained in:
Wyatt Johnson
2019-09-05 07:02:26 +00:00
committed by GitHub
parent 0fad1070a6
commit efea0e8e1c
111 changed files with 3439 additions and 382 deletions
+4
View File
@@ -3,6 +3,7 @@ import React from "react";
import DownloadRoute from "./routes/download/Download";
import ConfirmRoute from "./routes/email/Confirm";
import UnsubscribeRoute from "./routes/notifications/Unsubscribe";
import ResetRoute from "./routes/password/Reset";
export default makeRouteConfig(
@@ -14,5 +15,8 @@ export default makeRouteConfig(
<Route path="confirm" {...ConfirmRoute.routeConfig} />
</Route>
<Route path="download" {...DownloadRoute.routeConfig} />
<Route path="notifications">
<Route path="unsubscribe" {...UnsubscribeRoute.routeConfig} />
</Route>
</Route>
);
@@ -0,0 +1,32 @@
import { Localized } from "fluent-react/compat";
import React from "react";
import { CallOut, HorizontalGutter, Typography } from "coral-ui/components";
interface Props {
reason: React.ReactNode;
}
const Sorry: React.FunctionComponent<Props> = ({ reason }) => {
return (
<HorizontalGutter size="double">
<Localized id="unsubscribe-oopsSorry">
<Typography variant="heading1">Oops Sorry!</Typography>
</Localized>
<CallOut color="error" fullWidth>
{reason ? (
reason
) : (
<Localized id="account-tokenNotFound">
<span data-testid="invalid-link">
The specified link is invalid, check to see if it was copied
correctly.
</span>
</Localized>
)}
</CallOut>
</HorizontalGutter>
);
};
export default Sorry;
@@ -0,0 +1,18 @@
import { Localized } from "fluent-react/compat";
import React from "react";
import { HorizontalGutter, Typography } from "coral-ui/components";
const Success: React.FunctionComponent = () => {
return (
<HorizontalGutter data-testid="success" size="double">
<Localized id="unsubscribe-successfullyUnsubscribed">
<Typography variant="heading1">
You are now unsubscribed from all notifications
</Typography>
</Localized>
</HorizontalGutter>
);
};
export default Success;
@@ -0,0 +1,76 @@
import { FORM_ERROR } from "final-form";
import { Localized } from "fluent-react/compat";
import React, { useCallback } from "react";
import { Form } from "react-final-form";
import { InvalidRequestError } from "coral-framework/lib/errors";
import { useMutation } from "coral-framework/lib/relay";
import {
Button,
CallOut,
HorizontalGutter,
Typography,
} from "coral-ui/components";
import UnsubscribeNotificationsMutation from "./UnsubscribeNotificationsMutation";
interface Props {
token: string;
disabled?: boolean;
onSuccess: () => void;
}
const UnsubscribeForm: React.FunctionComponent<Props> = ({
onSuccess,
token,
}) => {
const unsubscribe = useMutation(UnsubscribeNotificationsMutation);
const onSubmit = useCallback(async () => {
try {
await unsubscribe({ token });
onSuccess();
} catch (error) {
if (error instanceof InvalidRequestError) {
return error.invalidArgs;
}
return { [FORM_ERROR]: error.message };
}
return;
}, [token]);
return (
<div data-testid="unsubscribe-form">
<Form onSubmit={onSubmit}>
{({ handleSubmit, submitting, submitError }) => (
<form onSubmit={handleSubmit}>
<HorizontalGutter>
<Localized id="unsubscribe-clickToConfirm">
<Typography variant="heading1">
Click below to confirm that you want to unsubscribe from all
notifications.
</Typography>
</Localized>
{submitError && (
<CallOut color="error" fullWidth>
{submitError}
</CallOut>
)}
<Localized id="unsubscribe-confirm">
<Button
type="submit"
variant="filled"
color="primary"
disabled={submitting}
fullWidth
>
Confirm
</Button>
</Localized>
</HorizontalGutter>
</form>
)}
</Form>
</div>
);
};
export default UnsubscribeForm;
@@ -0,0 +1,14 @@
import { Environment } from "relay-runtime";
import { createMutation } from "coral-framework/lib/relay";
const UnsubscribeNotificationsMutation = createMutation(
"unsubscribeNotifications",
async (environment: Environment, variables: { token: string }, { rest }) =>
await rest.fetch<void>("/account/notifications/unsubscribe", {
method: "DELETE",
token: variables.token,
})
);
export default UnsubscribeNotificationsMutation;
@@ -0,0 +1,16 @@
.container {
width: 100%;
text-align: center;
}
.root {
display: inline-block;
max-width: calc(70 * var(--mini-unit));
@media (min-width: $breakpoints-xs) {
max-width: calc(42 * var(--mini-unit));
}
text-align: left;
}
@@ -0,0 +1,77 @@
import React, { useCallback, useState } from "react";
import { Environment } from "relay-runtime";
import Loading from "coral-account/components/Loading";
import { useToken } from "coral-framework/hooks";
import { createFetch } from "coral-framework/lib/relay";
import { withRouteConfig } from "coral-framework/lib/router";
import { parseHashQuery } from "coral-framework/utils";
import Sorry from "./Sorry";
import Success from "./Success";
import UnsubscribeForm from "./UnsubscribeForm";
import styles from "./UnsubscribeRoute.css";
const fetcher = createFetch(
"unsubscribeToken",
async (environment: Environment, variables: { token: string }, { rest }) =>
await rest.fetch<void>("/account/notifications/unsubscribe", {
method: "GET",
token: variables.token,
})
);
interface Props {
token: string | undefined;
}
const UnsubscribeRoute: React.FunctionComponent<Props> = ({ token }) => {
const [finished, setFinished] = useState(false);
const onSuccess = useCallback(() => {
setFinished(true);
}, []);
const [state, error] = useToken(fetcher, token);
if (state === "UNCHECKED") {
return (
<div className={styles.container}>
<div className={styles.root}>
<Loading />
</div>
</div>
);
}
if (state !== "VALID" || error) {
return (
<div className={styles.container}>
<div className={styles.root}>
<Sorry reason={error} />
</div>
</div>
);
}
return !finished ? (
<div className={styles.container}>
<div className={styles.root}>
<UnsubscribeForm token={token!} onSuccess={onSuccess} />
</div>
</div>
) : (
<div className={styles.container}>
<div className={styles.root}>
<Success />
</div>
</div>
);
};
const enhanced = withRouteConfig<Props>({
render: ({ match, Component }) => (
<Component token={parseHashQuery(match.location.hash).unsubscribeToken} />
),
})(UnsubscribeRoute);
export default enhanced;
@@ -0,0 +1 @@
export { default, default as UnsubscribeRoute } from "./UnsubscribeRoute";
@@ -0,0 +1,96 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`renders form 1`] = `
<div
data-testid="main-layout"
>
<div
className="MainLayout-bar"
/>
<div
className="MainLayout-centered"
>
<div
className="UnsubscribeRoute-container"
>
<div
className="UnsubscribeRoute-root"
>
<div
data-testid="unsubscribe-form"
>
<form
onSubmit={[Function]}
>
<div
className="Box-root HorizontalGutter-root HorizontalGutter-full"
>
<h1
className="Box-root Typography-root Typography-heading1 Typography-colorTextPrimary"
>
Click below to confirm that you want to unsubscribe from all notifications.
</h1>
<button
className="BaseButton-root Button-root Button-sizeRegular Button-colorPrimary Button-variantFilled Button-fullWidth"
data-color="primary"
data-variant="filled"
disabled={false}
onBlur={[Function]}
onFocus={[Function]}
onMouseOut={[Function]}
onMouseOver={[Function]}
onTouchEnd={[Function]}
type="submit"
>
Confirm
</button>
</div>
</form>
</div>
</div>
</div>
</div>
</div>
`;
exports[`renders missing confirm token 1`] = `
<div
data-testid="main-layout"
>
<div
className="MainLayout-bar"
/>
<div
className="MainLayout-centered"
>
<div
className="UnsubscribeRoute-container"
>
<div
className="UnsubscribeRoute-root"
>
<div
className="Box-root HorizontalGutter-root HorizontalGutter-double"
>
<h1
className="Box-root Typography-root Typography-heading1 Typography-colorTextPrimary"
>
Oops Sorry!
</h1>
<div
className="CallOut-root CallOut-colorError CallOut-fullWidth"
>
<div>
<span
data-testid="invalid-link"
>
The specified link is invalid, check to see if it was copied correctly.
</span>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
`;
@@ -0,0 +1,134 @@
import sinon from "sinon";
import { ERROR_CODES } from "coral-common/errors";
import { InvalidRequestError } from "coral-framework/lib/errors";
import { GQLResolver } from "coral-framework/schema";
import {
act,
createAccessToken,
CreateTestRendererParams,
replaceHistoryLocation,
waitForElement,
within,
} from "coral-framework/testHelpers";
import create from "./create";
const token = createAccessToken();
async function createTestRenderer(
params: CreateTestRendererParams<GQLResolver> = {}
) {
const { testRenderer, context } = create();
return {
context,
testRenderer,
root: testRenderer.root,
};
}
it("renders missing confirm token", async () => {
replaceHistoryLocation("http://localhost/account/notifications/unsubscribe");
const { root } = await createTestRenderer();
await waitForElement(() => within(root).getByTestID("invalid-link"));
expect(within(root).toJSON()).toMatchSnapshot();
});
it("renders form", async () => {
replaceHistoryLocation(
`http://localhost/account/notifications/unsubscribe#unsubscribeToken=${token}`
);
const { root, context } = await createTestRenderer();
const restMock = sinon.mock(context.rest);
restMock
.expects("fetch")
.withArgs("/account/notifications/unsubscribe", {
method: "GET",
token,
})
.once();
await act(async () => {
await waitForElement(() => within(root).getByTestID("unsubscribe-form"));
});
expect(within(root).toJSON()).toMatchSnapshot();
restMock.verify();
});
it("renders error from server", async () => {
replaceHistoryLocation(
`http://localhost/account/notifications/unsubscribe#unsubscribeToken=${token}`
);
const codes = [
ERROR_CODES.RATE_LIMIT_EXCEEDED,
ERROR_CODES.INTEGRATION_DISABLED,
ERROR_CODES.USER_NOT_FOUND,
ERROR_CODES.TOKEN_INVALID,
];
for (const code of codes) {
const { root, context } = await createTestRenderer();
const restMock = sinon.mock(context.rest);
restMock
.expects("fetch")
.withArgs("/account/notifications/unsubscribe", {
method: "GET",
token,
})
.once()
.throwsException(
new InvalidRequestError({
code,
})
);
await act(async () => {
await waitForElement(() =>
within(root).getByText(code, {
exact: false,
})
);
});
restMock.verify();
}
});
it("submits form", async () => {
replaceHistoryLocation(
`http://localhost/account/notifications/unsubscribe#unsubscribeToken=${token}`
);
const { root, context } = await createTestRenderer();
const restMock = sinon.mock(context.rest);
restMock
.expects("fetch")
.withArgs("/account/notifications/unsubscribe", {
method: "GET",
token,
})
.once();
restMock
.expects("fetch")
.withArgs("/account/notifications/unsubscribe", {
method: "DELETE",
token,
})
.once();
await act(async () => {
await waitForElement(() => within(root).getByTestID("unsubscribe-form"));
});
const form = within(root).getByType("form");
// Submit valid form.
await act(async () => {
form.props.onSubmit();
await waitForElement(() => within(root).getByTestID("success"));
});
restMock.verify();
});
@@ -37,7 +37,7 @@ const OrganizationNameConfig: FunctionComponent<Props> = ({ disabled }) => (
id="configure-organization-emailExplanation"
strong={<strong />}
>
<Typography variant="detail">This E-Mail will be used</Typography>
<Typography variant="detail">This Email will be used</Typography>
</Localized>
<Field
name="organization.contactEmail"
@@ -0,0 +1,210 @@
import { FORM_ERROR } from "final-form";
import { Localized } from "fluent-react/compat";
import React, { FunctionComponent, useCallback } from "react";
import { Field, Form, FormSpy } from "react-final-form";
import { graphql } from "react-relay";
import { InvalidRequestError } from "coral-framework/lib/errors";
import { useMutation, withFragmentContainer } from "coral-framework/lib/relay";
import { GQLDIGEST_FREQUENCY } from "coral-framework/schema";
import { NotificationSettingsContainer_viewer } from "coral-stream/__generated__/NotificationSettingsContainer_viewer.graphql";
import {
Button,
CallOut,
CheckBox,
FieldSet,
Flex,
FormField,
HorizontalGutter,
Option,
SelectField,
Typography,
} from "coral-ui/components";
import UpdateNotificationSettingsMutation from "./UpdateNotificationSettingsMutation";
interface Props {
viewer: NotificationSettingsContainer_viewer;
}
type FormProps = NotificationSettingsContainer_viewer["notifications"];
const NotificationSettingsContainer: FunctionComponent<Props> = ({
viewer: { notifications },
}) => {
const mutation = useMutation(UpdateNotificationSettingsMutation);
const onSubmit = useCallback(
async (values: FormProps) => {
try {
await mutation(values);
} catch (err) {
if (err instanceof InvalidRequestError) {
return err.invalidArgs;
}
return {
[FORM_ERROR]: err.message,
};
}
return;
},
[mutation]
);
return (
<HorizontalGutter data-testid="profile-settings-notifications">
<Localized id="profile-settings-notifications-emailNotifications">
<Typography variant="heading3">Email Notifications</Typography>
</Localized>
<Localized id="profile-settings-notifications-receiveWhen">
<Typography variant="heading4">Receive notifications when:</Typography>
</Localized>
<HorizontalGutter>
<Form initialValues={{ ...notifications }} onSubmit={onSubmit}>
{({
handleSubmit,
submitting,
submitError,
pristine,
submitSucceeded,
}) => (
<form onSubmit={handleSubmit}>
<HorizontalGutter>
<FieldSet>
<FormField>
<Field name="onReply" type="checkbox">
{({ input }) => (
<Localized id="profile-settings-notifications-onReply">
<CheckBox id={input.name} {...input}>
My comment receives a reply
</CheckBox>
</Localized>
)}
</Field>
</FormField>
<FormField>
<Field name="onFeatured" type="checkbox">
{({ input }) => (
<Localized id="profile-settings-notifications-onFeatured">
<CheckBox id={input.name} {...input}>
My comment is featured
</CheckBox>
</Localized>
)}
</Field>
</FormField>
<FormField>
<Field name="onStaffReplies" type="checkbox">
{({ input }) => (
<Localized id="profile-settings-notifications-onStaffReplies">
<CheckBox id={input.name} {...input}>
A staff member replies to my comment
</CheckBox>
</Localized>
)}
</Field>
</FormField>
<FormField>
<Field name="onModeration" type="checkbox">
{({ input }) => (
<Localized id="profile-settings-notifications-onModeration">
<CheckBox id={input.name} {...input}>
My pending comment has been reviewed
</CheckBox>
</Localized>
)}
</Field>
</FormField>
<FormField>
<Flex alignItems="center" itemGutter>
<Localized id="profile-settings-notifications-sendNotifications">
<Typography variant="bodyCopyBold">
Send Notifications:
</Typography>
</Localized>
<FormSpy subscription={{ values: true }}>
{({ values }) => (
<Field name="digestFrequency">
{({ input }) => (
<SelectField
id={input.name}
{...input}
disabled={
!values.onReply &&
!values.onStaffReplies &&
!values.onFeatured &&
!values.onModeration
}
>
<Localized id="profile-settings-notifications-sendNotifications-immediately">
<Option value={GQLDIGEST_FREQUENCY.NONE}>
Immediately
</Option>
</Localized>
<Localized id="profile-settings-notifications-sendNotifications-daily">
<Option value={GQLDIGEST_FREQUENCY.DAILY}>
Daily
</Option>
</Localized>
<Localized id="profile-settings-notifications-sendNotifications-hourly">
<Option value={GQLDIGEST_FREQUENCY.HOURLY}>
Hourly
</Option>
</Localized>
</SelectField>
)}
</Field>
)}
</FormSpy>
</Flex>
</FormField>
</FieldSet>
{submitError && (
<CallOut color="error" fullWidth>
{submitError}
</CallOut>
)}
{submitSucceeded && (
<Localized id="profile-settings-notifications-updated">
<CallOut color="success" fullWidth>
Your notification settings have been updated
</CallOut>
</Localized>
)}
<Flex justifyContent="flex-end">
<Localized id="profile-settings-notifications-button">
<Button
color="primary"
variant="filled"
type="submit"
disabled={submitting || pristine}
>
Update Notification Settings
</Button>
</Localized>
</Flex>
</HorizontalGutter>
</form>
)}
</Form>
</HorizontalGutter>
</HorizontalGutter>
);
};
const enhanced = withFragmentContainer<Props>({
viewer: graphql`
fragment NotificationSettingsContainer_viewer on User {
notifications {
onReply
onFeatured
onStaffReplies
onModeration
digestFrequency
}
}
`,
})(NotificationSettingsContainer);
export default enhanced;
@@ -10,6 +10,7 @@ import ChangePasswordContainer from "./ChangePasswordContainer";
import DeleteAccountContainer from "./DeleteAccount/DeleteAccountContainer";
import DownloadCommentsContainer from "./DownloadCommentsContainer";
import IgnoreUserSettingsContainer from "./IgnoreUserSettingsContainer";
import NotificationSettingsContainer from "./NotificationSettingsContainer";
import styles from "./SettingsContainer.css";
@@ -28,6 +29,7 @@ const SettingsContainer: FunctionComponent<Props> = ({ viewer, settings }) => (
{settings.accountFeatures.deleteAccount && (
<DeleteAccountContainer viewer={viewer} settings={settings} />
)}
<NotificationSettingsContainer viewer={viewer} />
</HorizontalGutter>
);
@@ -37,6 +39,7 @@ const enhanced = withFragmentContainer<Props>({
...IgnoreUserSettingsContainer_viewer
...DownloadCommentsContainer_viewer
...DeleteAccountContainer_viewer
...NotificationSettingsContainer_viewer
}
`,
settings: graphql`
@@ -0,0 +1,45 @@
import { graphql } from "react-relay";
import { Environment } from "relay-runtime";
import {
commitMutationPromiseNormalized,
createMutation,
MutationInput,
} from "coral-framework/lib/relay";
import { UpdateNotificationSettingsMutation as MutationTypes } from "coral-stream/__generated__/UpdateNotificationSettingsMutation.graphql";
let clientMutationId = 0;
const UpdateNotificationSettingsMutation = createMutation(
"updateNotificationSettings",
(environment: Environment, input: MutationInput<MutationTypes>) =>
commitMutationPromiseNormalized<MutationTypes>(environment, {
mutation: graphql`
mutation UpdateNotificationSettingsMutation(
$input: UpdateNotificationSettingsInput!
) {
updateNotificationSettings(input: $input) {
user {
id
notifications {
onReply
onFeatured
onStaffReplies
onModeration
digestFrequency
}
}
clientMutationId
}
}
`,
variables: {
input: {
...input,
clientMutationId: (clientMutationId++).toString(),
},
},
})
);
export default UpdateNotificationSettingsMutation;
+9 -1
View File
@@ -1,11 +1,12 @@
import {
GQLComment,
GQLCOMMENT_STATUS,
GQLDIGEST_FREQUENCY,
GQLMODERATION_MODE,
GQLSettings,
GQLStory,
GQLTag,
GQLTAG,
GQLTag,
GQLUser,
GQLUSER_ROLE,
GQLUSER_STATUS,
@@ -143,6 +144,13 @@ export const baseUser = createFixture<GQLUser>({
hasNextPage: false,
},
},
notifications: {
onReply: false,
onModeration: false,
onStaffReplies: false,
onFeatured: false,
digestFrequency: GQLDIGEST_FREQUENCY.NONE,
},
ignoreable: true,
profiles: [
{
@@ -495,6 +495,225 @@ all your comments from this site.
</span>
</button>
</div>
<div
className="Box-root HorizontalGutter-root HorizontalGutter-full"
data-testid="profile-settings-notifications"
>
<h1
className="Box-root Typography-root Typography-heading3 Typography-colorTextPrimary"
>
Email Notifications
</h1>
<h1
className="Box-root Typography-root Typography-heading4 Typography-colorTextPrimary"
>
Receive notifications when:
</h1>
<div
className="Box-root HorizontalGutter-root HorizontalGutter-full"
>
<form
onSubmit={[Function]}
>
<div
className="Box-root HorizontalGutter-root HorizontalGutter-full"
>
<fieldset
className="FieldSet-root"
>
<div
className="Box-root HorizontalGutter-root FormField-root HorizontalGutter-half"
>
<div
className="CheckBox-root"
>
<input
checked={false}
className="CheckBox-input"
id="onReply"
name="onReply"
onBlur={[Function]}
onChange={[Function]}
onFocus={[Function]}
type="checkbox"
value={false}
/>
<label
className="CheckBox-label"
htmlFor="onReply"
>
<span
className="CheckBox-labelSpan"
>
My comment receives a reply
</span>
</label>
</div>
</div>
<div
className="Box-root HorizontalGutter-root FormField-root HorizontalGutter-half"
>
<div
className="CheckBox-root"
>
<input
checked={false}
className="CheckBox-input"
id="onFeatured"
name="onFeatured"
onBlur={[Function]}
onChange={[Function]}
onFocus={[Function]}
type="checkbox"
value={false}
/>
<label
className="CheckBox-label"
htmlFor="onFeatured"
>
<span
className="CheckBox-labelSpan"
>
My comment is featured
</span>
</label>
</div>
</div>
<div
className="Box-root HorizontalGutter-root FormField-root HorizontalGutter-half"
>
<div
className="CheckBox-root"
>
<input
checked={false}
className="CheckBox-input"
id="onStaffReplies"
name="onStaffReplies"
onBlur={[Function]}
onChange={[Function]}
onFocus={[Function]}
type="checkbox"
value={false}
/>
<label
className="CheckBox-label"
htmlFor="onStaffReplies"
>
<span
className="CheckBox-labelSpan"
>
A staff member replies to my comment
</span>
</label>
</div>
</div>
<div
className="Box-root HorizontalGutter-root FormField-root HorizontalGutter-half"
>
<div
className="CheckBox-root"
>
<input
checked={false}
className="CheckBox-input"
id="onModeration"
name="onModeration"
onBlur={[Function]}
onChange={[Function]}
onFocus={[Function]}
type="checkbox"
value={false}
/>
<label
className="CheckBox-label"
htmlFor="onModeration"
>
<span
className="CheckBox-labelSpan"
>
My pending comment has been reviewed
</span>
</label>
</div>
</div>
<div
className="Box-root HorizontalGutter-root FormField-root HorizontalGutter-half"
>
<div
className="Box-root Flex-root Flex-flex Flex-itemGutter Flex-alignCenter gutter"
>
<p
className="Box-root Typography-root Typography-bodyCopyBold Typography-colorTextPrimary"
>
Send Notifications:
</p>
<span
className="SelectField-root"
>
<select
className="SelectField-select"
disabled={true}
id="digestFrequency"
name="digestFrequency"
onBlur={[Function]}
onChange={[Function]}
onFocus={[Function]}
value="NONE"
>
<option
value="NONE"
>
Immediately
</option>
<option
value="DAILY"
>
Daily
</option>
<option
value="HOURLY"
>
Hourly
</option>
</select>
<span
aria-hidden={true}
className="SelectField-afterWrapper SelectField-afterWrapperDisabled"
>
<i
aria-hidden="true"
className="Icon-root Icon-sm"
>
expand_more
</i>
</span>
</span>
</div>
</div>
</fieldset>
<div
className="Box-root Flex-root Flex-flex Flex-justifyFlexEnd"
>
<button
className="BaseButton-root Button-root Button-sizeRegular Button-colorPrimary Button-variantFilled Button-disabled"
data-color="primary"
data-variant="filled"
disabled={true}
onBlur={[Function]}
onFocus={[Function]}
onMouseOut={[Function]}
onMouseOver={[Function]}
onTouchEnd={[Function]}
type="submit"
>
Update Notification Settings
</button>
</div>
</div>
</form>
</div>
</div>
</div>
</section>
</div>
@@ -184,3 +184,100 @@ it("render ignored users list", async () => {
);
within(ignoredCommenters).getByText(commenters[1].username!);
});
it("render notifications form", async () => {
const updateNotificationSettings = sinon
.stub()
.callsFake((_: any, { input: { clientMutationId, ...notifications } }) => {
expectAndFail(notifications).toMatchObject({
onReply: true,
onFeatured: true,
onStaffReplies: true,
onModeration: true,
digestFrequency: "HOURLY",
});
return {
user: pureMerge<typeof viewer>(viewer, {
notifications,
}),
clientMutationId,
};
});
const { testRenderer } = await createTestRenderer({
resolvers: createResolversStub<GQLResolver>({
Mutation: {
updateNotificationSettings,
},
}),
});
const container = await waitForElement(() =>
within(testRenderer.root).getByTestID("profile-settings-notifications")
);
const form = within(container).getByType("form");
// Get the form fields.
const onReply = await waitForElement(() =>
within(form).getByID("onReply", { exact: false })
);
const onStaffReplies = await waitForElement(() =>
within(form).getByID("onStaffReplies", { exact: false })
);
const onModeration = await waitForElement(() =>
within(form).getByID("onModeration", { exact: false })
);
const onFeatured = await waitForElement(() =>
within(form).getByID("onFeatured", { exact: false })
);
const digestFrequency = await waitForElement(() =>
within(form).getByID("digestFrequency", { exact: false })
);
const save = await waitForElement(() => within(form).getByType("button"));
// The save button should be disabled for unchanged fields.
expect(save.props.disabled).toEqual(true);
// The digest frequency select should be disabled with no options enabled.
expect(digestFrequency.props.disabled).toEqual(true);
// Enable the options.
act(() => {
onReply.props.onChange(true);
onStaffReplies.props.onChange(true);
onModeration.props.onChange(true);
onFeatured.props.onChange(true);
});
// The digest frequency select should now be enabled.
expect(digestFrequency.props.disabled).toEqual(false);
// Change the digest frequency.
act(() => {
digestFrequency.props.onChange("HOURLY");
});
// Submit the form.
await act(async () => {
await form.props.onSubmit();
});
// Ensure that the mutation was called and that the save button is now
// disabled.
expect(updateNotificationSettings.calledOnce).toEqual(true);
expect(save.props.disabled).toEqual(true);
// Change a notification option.
act(() => {
onReply.props.onChange(false);
});
// The save button should now be enabled.
expect(save.props.disabled).toEqual(false);
// Change a notification back (making it pristine).
act(() => {
onReply.props.onChange(true);
});
// The save button should now be disabled.
expect(save.props.disabled).toEqual(true);
});
@@ -16,7 +16,7 @@ import { Table, TableBody, TableHead, TableRow, TableCell } from "./";
<TableHead>
<TableRow>
<TableCell>Username</TableCell>
<TableCell>E-Mail Address</TableCell>
<TableCell>Email Address</TableCell>
<TableCell>Member Since</TableCell>
</TableRow>
</TableHead>
@@ -10,7 +10,7 @@ it("renders correctly", () => {
<TableHead>
<TableRow>
<TableCell>Username</TableCell>
<TableCell>E-Mail Address</TableCell>
<TableCell>Email Address</TableCell>
<TableCell>Member Since</TableCell>
</TableRow>
</TableHead>
@@ -16,7 +16,7 @@ exports[`renders correctly 1`] = `
Username
</withPropsOnChange(TableCell)>
<withPropsOnChange(TableCell)>
E-Mail Address
Email Address
</withPropsOnChange(TableCell)>
<withPropsOnChange(TableCell)>
Member Since