mirror of
https://github.com/wassname/talk.git
synced 2026-08-13 12:40:11 +08:00
[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:
@@ -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;
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user