[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
@@ -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);
});