mirror of
https://github.com/wassname/talk.git
synced 2026-08-02 13:10:23 +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:
@@ -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;
|
||||
+14
@@ -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();
|
||||
});
|
||||
+1
-1
@@ -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;
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user