mirror of
https://github.com/wassname/talk.git
synced 2026-07-07 17:50:00 +08:00
[CORL-156] Manage user suspension status (#2419)
* wire up suspension modal * show user suspended callout on stream * prevent comment actions for suspended users * set default to 3 hour suspension * add message field to suspension * allow custom message for suspension * show suspend success modal * fix type errors * remove unused code * update styles * fix fixture for streams * add suspension ui tests * fix types * remove warnings? * remove snapshot * fix merge conflicts * allow custom email message when banning users * fix typo * correct message type * use final-form in suspend modal * refactor suspend modal to use final-form * refactor ban modal to use final-form * refactor userStatusChangeContainer to use useCallback * feat: improve translated form * fix: addressed issue caused by i18n refactor * update getMessage to accept arguments, remove format method * translate suspend info * change hour format * make message a mandatory input for suspend and ban user * fix types in user table * Revert "fix types in user table" This reverts commit d396e90b88bb1bd354c5cdbdd72b6d8f1ab72929. * fix types for user table * fix: small review tweaks
This commit is contained in:
committed by
Wyatt Johnson
parent
4e548e8fbf
commit
5df2de6afc
@@ -1,7 +1,7 @@
|
||||
import { Localized } from "fluent-react/compat";
|
||||
import React, { FunctionComponent } from "react";
|
||||
|
||||
import { DURATION_UNIT } from "coral-framework/components";
|
||||
import { reduceSeconds } from "coral-framework/lib/i18n";
|
||||
|
||||
interface From {
|
||||
start: Date;
|
||||
@@ -13,48 +13,23 @@ export interface SuspensionActionProps {
|
||||
from: From;
|
||||
}
|
||||
|
||||
const UNITS = [
|
||||
DURATION_UNIT.WEEKS,
|
||||
DURATION_UNIT.DAYS,
|
||||
DURATION_UNIT.HOURS,
|
||||
DURATION_UNIT.MINUTES,
|
||||
DURATION_UNIT.SECONDS,
|
||||
];
|
||||
|
||||
const UNIT_MAP = {
|
||||
[DURATION_UNIT.SECONDS]: "second",
|
||||
[DURATION_UNIT.MINUTES]: "minute",
|
||||
[DURATION_UNIT.HOURS]: "hour",
|
||||
[DURATION_UNIT.DAYS]: "day",
|
||||
[DURATION_UNIT.WEEKS]: "week",
|
||||
};
|
||||
|
||||
function reduceValue(value: number) {
|
||||
// Find the largest match for the smallest number.
|
||||
const unit: keyof typeof UNIT_MAP =
|
||||
UNITS.find(compare => value >= compare) || DURATION_UNIT.SECONDS;
|
||||
|
||||
return {
|
||||
value: Math.round((value / unit) * 100) / 100,
|
||||
unit: UNIT_MAP[unit],
|
||||
};
|
||||
}
|
||||
|
||||
const SuspensionAction: FunctionComponent<SuspensionActionProps> = ({
|
||||
action,
|
||||
from,
|
||||
}) => {
|
||||
if (action === "created") {
|
||||
const seconds = (from.finish.getTime() - from.start.getTime()) / 1000;
|
||||
const { value, unit } = reduceValue(seconds);
|
||||
const { scaled, unit } = reduceSeconds(seconds);
|
||||
|
||||
return (
|
||||
<Localized
|
||||
id="moderate-user-drawer-suspension"
|
||||
$unit={unit}
|
||||
$value={value}
|
||||
$value={scaled}
|
||||
>
|
||||
<span>Suspension, {seconds} seconds</span>
|
||||
<span>
|
||||
Suspension, {scaled} {unit}
|
||||
</span>
|
||||
</Localized>
|
||||
);
|
||||
} else if (action === "removed") {
|
||||
|
||||
@@ -2,11 +2,12 @@ import { Localized } from "fluent-react/compat";
|
||||
import React, { FunctionComponent } from "react";
|
||||
import { ReadyState } from "react-relay";
|
||||
|
||||
import { UserHistoryDrawerQuery as QueryTypes } from "coral-admin/__generated__/UserHistoryDrawerQuery.graphql";
|
||||
import { UserStatusChangeContainer } from "coral-admin/components/UserStatus";
|
||||
import { CopyButton } from "coral-framework/components";
|
||||
import { useCoralContext } from "coral-framework/lib/bootstrap";
|
||||
import { graphql, QueryRenderer } from "coral-framework/lib/relay";
|
||||
|
||||
import { UserHistoryDrawerQuery as QueryTypes } from "coral-admin/__generated__/UserHistoryDrawerQuery.graphql";
|
||||
import { UserStatusChangeContainer } from "coral-admin/components/UserStatus";
|
||||
import {
|
||||
Button,
|
||||
CallOut,
|
||||
@@ -49,6 +50,12 @@ const UserHistoryDrawerQuery: FunctionComponent<Props> = ({
|
||||
createdAt
|
||||
...UserStatusChangeContainer_user
|
||||
}
|
||||
settings {
|
||||
organization {
|
||||
name
|
||||
}
|
||||
...UserStatusChangeContainer_settings
|
||||
}
|
||||
}
|
||||
`}
|
||||
variables={{ userID }}
|
||||
@@ -74,7 +81,7 @@ const UserHistoryDrawerQuery: FunctionComponent<Props> = ({
|
||||
);
|
||||
}
|
||||
|
||||
const user = props.user;
|
||||
const { user, settings } = props;
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -85,10 +92,20 @@ const UserHistoryDrawerQuery: FunctionComponent<Props> = ({
|
||||
<span>{user.username}</span>
|
||||
</Flex>
|
||||
<div className={styles.userStatus}>
|
||||
<div className={styles.userStatusLabel}>Status:</div>
|
||||
<div className={styles.userStatusChange}>
|
||||
<UserStatusChangeContainer user={user} />
|
||||
</div>
|
||||
<Flex alignItems="center">
|
||||
<div className={styles.userStatusLabel}>
|
||||
<Typography variant="bodyCopyBold" container="div">
|
||||
<Flex alignItems="center" itemGutter="half">
|
||||
<Localized id="moderate-user-drawer-status-label">
|
||||
Status:
|
||||
</Localized>
|
||||
</Flex>
|
||||
</Typography>
|
||||
</div>
|
||||
<div className={styles.userStatusChange}>
|
||||
<UserStatusChangeContainer settings={settings} user={user} />
|
||||
</div>
|
||||
</Flex>
|
||||
</div>
|
||||
<div className={styles.userDetails}>
|
||||
<Flex alignItems="center" className={styles.userDetail}>
|
||||
|
||||
@@ -1,3 +1,10 @@
|
||||
.card {
|
||||
max-width: 500px;
|
||||
}
|
||||
|
||||
.textArea {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
height: calc(12 * var(--mini-unit));
|
||||
padding: calc(0.5 * var(--mini-unit));
|
||||
}
|
||||
|
||||
@@ -1,16 +1,19 @@
|
||||
import { Localized } from "fluent-react/compat";
|
||||
import React, { FunctionComponent } from "react";
|
||||
|
||||
import NotAvailable from "coral-admin/components/NotAvailable";
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
CardCloseButton,
|
||||
CheckBox,
|
||||
Flex,
|
||||
HorizontalGutter,
|
||||
Modal,
|
||||
Typography,
|
||||
} from "coral-ui/components";
|
||||
import { Localized } from "fluent-react/compat";
|
||||
import React, { FunctionComponent, useCallback, useMemo } from "react";
|
||||
import { Field, Form } from "react-final-form";
|
||||
|
||||
import NotAvailable from "coral-admin/components/NotAvailable";
|
||||
import { GetMessage, withGetMessage } from "coral-framework/lib/i18n";
|
||||
|
||||
import styles from "./BanModal.css";
|
||||
|
||||
@@ -18,7 +21,8 @@ interface Props {
|
||||
username: string | null;
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onConfirm: () => void;
|
||||
onConfirm: (message?: string) => void;
|
||||
getMessage: GetMessage;
|
||||
}
|
||||
|
||||
const BanModal: FunctionComponent<Props> = ({
|
||||
@@ -26,51 +30,107 @@ const BanModal: FunctionComponent<Props> = ({
|
||||
onClose,
|
||||
onConfirm,
|
||||
username,
|
||||
}) => (
|
||||
<Modal open={open} onClose={onClose} aria-labelledby="banModal-title">
|
||||
{({ firstFocusableRef, lastFocusableRef }) => (
|
||||
<Card className={styles.card}>
|
||||
<CardCloseButton onClick={onClose} ref={firstFocusableRef} />
|
||||
<HorizontalGutter size="double">
|
||||
<HorizontalGutter>
|
||||
<Localized
|
||||
id="community-banModal-areYouSure"
|
||||
strong={<strong />}
|
||||
$username={username || <NotAvailable />}
|
||||
>
|
||||
<Typography variant="header2" id="banModal-title">
|
||||
Are you sure you want to ban{" "}
|
||||
<strong>{username || <NotAvailable />}</strong>?
|
||||
</Typography>
|
||||
</Localized>
|
||||
<Localized id="community-banModal-consequence">
|
||||
<Typography>
|
||||
Once banned, this user will no longer be able to comment, use
|
||||
reactions, or report comments.
|
||||
</Typography>
|
||||
</Localized>
|
||||
</HorizontalGutter>
|
||||
<Flex justifyContent="flex-end" itemGutter="half">
|
||||
<Localized id="community-banModal-cancel">
|
||||
<Button variant="outlined" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
</Localized>
|
||||
<Localized id="community-banModal-banUser">
|
||||
<Button
|
||||
variant="filled"
|
||||
color="primary"
|
||||
onClick={onConfirm}
|
||||
ref={lastFocusableRef}
|
||||
>
|
||||
Ban User
|
||||
</Button>
|
||||
</Localized>
|
||||
</Flex>
|
||||
</HorizontalGutter>
|
||||
</Card>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
getMessage,
|
||||
}) => {
|
||||
const getDefaultMessage = useMemo((): string => {
|
||||
return getMessage(
|
||||
"community-banModal-emailTemplate",
|
||||
"Someone with access to your account has violated our community guidelines. As a result, your account has been banned. You will no longer be able to comment, react or report comments",
|
||||
{
|
||||
username,
|
||||
}
|
||||
);
|
||||
}, [getMessage, username]);
|
||||
|
||||
export default BanModal;
|
||||
const onFormSubmit = useCallback(
|
||||
({ emailMessage }) => {
|
||||
onConfirm(emailMessage);
|
||||
},
|
||||
[onConfirm]
|
||||
);
|
||||
|
||||
return (
|
||||
<Modal open={open} onClose={onClose} aria-labelledby="banModal-title">
|
||||
{({ firstFocusableRef, lastFocusableRef }) => (
|
||||
<Card className={styles.card}>
|
||||
<CardCloseButton onClick={onClose} ref={firstFocusableRef} />
|
||||
<HorizontalGutter size="double">
|
||||
<HorizontalGutter>
|
||||
<Localized
|
||||
id="community-banModal-areYouSure"
|
||||
strong={<strong />}
|
||||
$username={username || <NotAvailable />}
|
||||
>
|
||||
<Typography variant="header2" id="banModal-title">
|
||||
Are you sure you want to ban{" "}
|
||||
<strong>{username || <NotAvailable />}</strong>?
|
||||
</Typography>
|
||||
</Localized>
|
||||
<Localized id="community-banModal-consequence">
|
||||
<Typography>
|
||||
Once banned, this user will no longer be able to comment, use
|
||||
reactions, or report comments.
|
||||
</Typography>
|
||||
</Localized>
|
||||
</HorizontalGutter>
|
||||
<Form
|
||||
onSubmit={onFormSubmit}
|
||||
initialValues={{
|
||||
showMessage: false,
|
||||
emailMessage: getDefaultMessage,
|
||||
}}
|
||||
>
|
||||
{({ handleSubmit }) => (
|
||||
<form onSubmit={handleSubmit}>
|
||||
<Field name="showMessage">
|
||||
{({ input }) => (
|
||||
<Localized id="community-banModal-customize">
|
||||
<CheckBox {...input} id="banModal-showMessage">
|
||||
Customize ban email message
|
||||
</CheckBox>
|
||||
</Localized>
|
||||
)}
|
||||
</Field>
|
||||
<Field name="showMessage" subscription={{ value: true }}>
|
||||
{({ input: { value } }) =>
|
||||
value ? (
|
||||
<Field
|
||||
className={styles.textArea}
|
||||
id="banModal-message"
|
||||
component="textarea"
|
||||
name="emailMessage"
|
||||
/>
|
||||
) : null
|
||||
}
|
||||
</Field>
|
||||
|
||||
<Flex justifyContent="flex-end" itemGutter="half">
|
||||
<Localized id="community-banModal-cancel">
|
||||
<Button variant="outlined" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
</Localized>
|
||||
<Localized id="community-banModal-banUser">
|
||||
<Button
|
||||
variant="filled"
|
||||
color="primary"
|
||||
type="submit"
|
||||
ref={lastFocusableRef}
|
||||
>
|
||||
Ban User
|
||||
</Button>
|
||||
</Localized>
|
||||
</Flex>
|
||||
</form>
|
||||
)}
|
||||
</Form>
|
||||
</HorizontalGutter>
|
||||
</Card>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
const enhanced = withGetMessage(BanModal);
|
||||
|
||||
export default enhanced;
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import { graphql } from "react-relay";
|
||||
import { Environment } from "relay-runtime";
|
||||
|
||||
import { RemoveUserSuspensionMutation as MutationTypes } from "coral-admin/__generated__/RemoveUserSuspensionMutation.graphql";
|
||||
import {
|
||||
commitMutationPromiseNormalized,
|
||||
createMutation,
|
||||
lookup,
|
||||
MutationInput,
|
||||
} from "coral-framework/lib/relay";
|
||||
import { GQLUser, GQLUSER_STATUS } from "coral-framework/schema";
|
||||
|
||||
let clientMutationId = 0;
|
||||
|
||||
const RemoveUserSuspensionMutation = createMutation(
|
||||
"removeUserSuspension",
|
||||
(environment: Environment, input: MutationInput<MutationTypes>) => {
|
||||
return commitMutationPromiseNormalized<MutationTypes>(environment, {
|
||||
mutation: graphql`
|
||||
mutation RemoveUserSuspensionMutation(
|
||||
$input: RemoveUserSuspensionInput!
|
||||
) {
|
||||
removeUserSuspension(input: $input) {
|
||||
user {
|
||||
id
|
||||
status {
|
||||
current
|
||||
suspension {
|
||||
active
|
||||
}
|
||||
}
|
||||
}
|
||||
clientMutationId
|
||||
}
|
||||
}
|
||||
`,
|
||||
variables: {
|
||||
input: {
|
||||
...input,
|
||||
clientMutationId: clientMutationId.toString(),
|
||||
},
|
||||
},
|
||||
optimisticResponse: {
|
||||
removeUserSuspension: {
|
||||
user: {
|
||||
id: input.userID,
|
||||
status: {
|
||||
current: lookup<GQLUser>(
|
||||
environment,
|
||||
input.userID
|
||||
)!.status.current.concat(GQLUSER_STATUS.SUSPENDED),
|
||||
suspension: {
|
||||
active: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
clientMutationId: (clientMutationId++).toString(),
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
export default RemoveUserSuspensionMutation;
|
||||
@@ -0,0 +1,207 @@
|
||||
import { Mutator } from "final-form";
|
||||
import { Localized } from "fluent-react/compat";
|
||||
import React, { FunctionComponent, useCallback } from "react";
|
||||
import { Field, Form } from "react-final-form";
|
||||
|
||||
import {
|
||||
GetMessage,
|
||||
ScaledUnit,
|
||||
withGetMessage,
|
||||
} from "coral-framework/lib/i18n";
|
||||
|
||||
import {
|
||||
Button,
|
||||
CheckBox,
|
||||
Flex,
|
||||
HorizontalGutter,
|
||||
RadioButton,
|
||||
} from "coral-ui/components";
|
||||
|
||||
import styles from "./SuspendModal.css";
|
||||
|
||||
interface Props {
|
||||
username: string | null;
|
||||
onCancel: () => void;
|
||||
getMessage: GetMessage;
|
||||
organizationName: string;
|
||||
onSubmit: (duration: ScaledUnit, message: string) => void;
|
||||
}
|
||||
|
||||
const DURATIONS: ScaledUnit[] = [
|
||||
{ original: 3600, value: "3600", unit: "hour", scaled: 1 }, // 1 hour
|
||||
{ original: 10800, value: "10800", unit: "hour", scaled: 3 }, // 3 hours
|
||||
{ original: 86400, value: "86400", unit: "hour", scaled: 24 }, // 24 hours
|
||||
{ original: 604800, value: "604800", unit: "day", scaled: 7 }, // 7 days
|
||||
];
|
||||
|
||||
const DEFAULT_DURATION = DURATIONS[0]; // 1 hour
|
||||
|
||||
const SuspendForm: FunctionComponent<Props> = ({
|
||||
onCancel,
|
||||
username,
|
||||
getMessage,
|
||||
onSubmit,
|
||||
organizationName,
|
||||
}) => {
|
||||
const getMessageWithDuration = useCallback(
|
||||
({ scaled, unit }: Pick<ScaledUnit, "scaled" | "unit">): string => {
|
||||
return getMessage(
|
||||
"community-suspendModal-emailTemplate",
|
||||
`your account has been temporarily suspended. During the suspension, you will be unable to comment, flag or engage with fellow commenters. Please rejoin the conversation in ${scaled} ${unit}`,
|
||||
{
|
||||
username,
|
||||
organizationName,
|
||||
value: scaled,
|
||||
unit,
|
||||
}
|
||||
);
|
||||
},
|
||||
[username, organizationName]
|
||||
);
|
||||
|
||||
const onFormSubmit = useCallback(
|
||||
({ duration, emailMessage }) => {
|
||||
const unit = DURATIONS.find(d => d.value === duration);
|
||||
onSubmit(unit!, emailMessage);
|
||||
},
|
||||
[onSubmit]
|
||||
);
|
||||
|
||||
const setMessageValue: Mutator = useCallback(
|
||||
([name, newValue], state, { changeValue }) => {
|
||||
if (state.lastFormState) {
|
||||
const { duration, emailMessage } = state.lastFormState.values;
|
||||
const unit = DURATIONS.find(d => d.value === duration);
|
||||
const expectedEmailMessage = getMessageWithDuration(unit!);
|
||||
if (expectedEmailMessage === emailMessage) {
|
||||
changeValue(state, name, () => newValue);
|
||||
}
|
||||
}
|
||||
},
|
||||
[getMessageWithDuration]
|
||||
);
|
||||
|
||||
const resetMessageValue: Mutator = (
|
||||
[name, checked],
|
||||
state,
|
||||
{ changeValue }
|
||||
) => {
|
||||
if (state.lastFormState && !checked) {
|
||||
const { duration, emailMessage } = state.lastFormState.values;
|
||||
const unit = DURATIONS.find(d => d.value === duration);
|
||||
const expectedEmailMessage = getMessageWithDuration(unit!);
|
||||
if (expectedEmailMessage !== emailMessage) {
|
||||
changeValue(state, name, () => expectedEmailMessage);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Form
|
||||
onSubmit={onFormSubmit}
|
||||
mutators={{
|
||||
setMessageValue,
|
||||
resetMessageValue,
|
||||
}}
|
||||
initialValues={{
|
||||
duration: DEFAULT_DURATION.value,
|
||||
emailMessage: getMessageWithDuration(DEFAULT_DURATION),
|
||||
}}
|
||||
>
|
||||
{({ handleSubmit, form }) => (
|
||||
<form onSubmit={handleSubmit}>
|
||||
<HorizontalGutter spacing={2}>
|
||||
<div>
|
||||
{DURATIONS.map(({ original, value, scaled, unit }) => (
|
||||
<Field
|
||||
key={value}
|
||||
name="duration"
|
||||
type="radio"
|
||||
component="input"
|
||||
value={value}
|
||||
>
|
||||
{({ input }) => (
|
||||
<Localized
|
||||
id="framework-timeago-time"
|
||||
$value={scaled}
|
||||
$unit={unit}
|
||||
>
|
||||
<RadioButton
|
||||
id={`duration-${value}`}
|
||||
{...input}
|
||||
onChange={event => {
|
||||
form.mutators.setMessageValue(
|
||||
"emailMessage",
|
||||
getMessageWithDuration({ scaled, unit })
|
||||
);
|
||||
input.onChange(event);
|
||||
}}
|
||||
>
|
||||
<span>
|
||||
{scaled} {unit}
|
||||
</span>
|
||||
</RadioButton>
|
||||
</Localized>
|
||||
)}
|
||||
</Field>
|
||||
))}
|
||||
</div>
|
||||
<Field type="checkbox" name="editMessage">
|
||||
{({ input }) => (
|
||||
<Localized id="community-suspendModal-customize">
|
||||
<CheckBox
|
||||
id="suspendModal-editMessage"
|
||||
{...input}
|
||||
onChange={event => {
|
||||
form.mutators.resetMessageValue(
|
||||
"emailMessage",
|
||||
!input.checked
|
||||
);
|
||||
input.onChange(event);
|
||||
}}
|
||||
>
|
||||
Customize suspension email message
|
||||
</CheckBox>
|
||||
</Localized>
|
||||
)}
|
||||
</Field>
|
||||
<Field name="editMessage" subscription={{ value: true }}>
|
||||
{({ input: { value } }) =>
|
||||
value ? (
|
||||
<Field
|
||||
className={styles.textArea}
|
||||
id="suspendModal-message"
|
||||
component="textarea"
|
||||
name="emailMessage"
|
||||
/>
|
||||
) : null
|
||||
}
|
||||
</Field>
|
||||
</HorizontalGutter>
|
||||
<Flex
|
||||
className={styles.footer}
|
||||
justifyContent="flex-end"
|
||||
itemGutter="half"
|
||||
>
|
||||
<Localized id="community-suspendModal-cancel">
|
||||
<Button variant="outlined" onClick={onCancel}>
|
||||
Cancel
|
||||
</Button>
|
||||
</Localized>
|
||||
<Localized id="community-suspendModal-suspendUser">
|
||||
<Button variant="filled" color="primary" type="submit">
|
||||
Suspend User
|
||||
</Button>
|
||||
</Localized>
|
||||
</Flex>
|
||||
</form>
|
||||
)}
|
||||
</Form>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const enhanced = withGetMessage(SuspendForm);
|
||||
|
||||
export default enhanced;
|
||||
@@ -0,0 +1,22 @@
|
||||
.card {
|
||||
max-width: 500px;
|
||||
}
|
||||
|
||||
.radioButton {
|
||||
margin: 0 var(--spacing-1) 0 0 !important;
|
||||
}
|
||||
|
||||
.textArea {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
height: calc(12 * var(--mini-unit));
|
||||
padding: calc(0.5 * var(--mini-unit));
|
||||
}
|
||||
|
||||
.header {
|
||||
margin: 0 0 var(--spacing-3) 0 !important;
|
||||
}
|
||||
|
||||
.footer {
|
||||
margin-top: var(--spacing-3);
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
import { Localized } from "fluent-react/compat";
|
||||
import React, { FunctionComponent, useCallback, useState } from "react";
|
||||
|
||||
import {
|
||||
GetMessage,
|
||||
ScaledUnit,
|
||||
withGetMessage,
|
||||
} from "coral-framework/lib/i18n";
|
||||
|
||||
import NotAvailable from "coral-admin/components/NotAvailable";
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
CardCloseButton,
|
||||
Flex,
|
||||
HorizontalGutter,
|
||||
Modal,
|
||||
Typography,
|
||||
} from "coral-ui/components";
|
||||
|
||||
import SuspendForm from "./SuspendForm";
|
||||
|
||||
import styles from "./SuspendModal.css";
|
||||
|
||||
interface Props {
|
||||
username: string | null;
|
||||
getMessage: GetMessage;
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onConfirm: (timeout: number, message: string) => void;
|
||||
organizationName: string;
|
||||
success: boolean;
|
||||
}
|
||||
|
||||
const SuspendModal: FunctionComponent<Props> = ({
|
||||
open,
|
||||
onClose,
|
||||
onConfirm,
|
||||
getMessage,
|
||||
username,
|
||||
success,
|
||||
organizationName,
|
||||
}) => {
|
||||
const [successDuration, setSuccessDuration] = useState("");
|
||||
const onFormSubmit = useCallback(
|
||||
({ original, scaled, unit }: ScaledUnit, message: string) => {
|
||||
setSuccessDuration(
|
||||
getMessage("framework-timeago-time", `${scaled} ${unit}`, {
|
||||
value: scaled,
|
||||
unit,
|
||||
})
|
||||
);
|
||||
onConfirm(original, message);
|
||||
},
|
||||
[onConfirm]
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Modal open={open} onClose={onClose} aria-labelledby="suspendModal-title">
|
||||
{({ firstFocusableRef, lastFocusableRef }) => (
|
||||
<Card className={styles.card}>
|
||||
<CardCloseButton onClick={onClose} ref={firstFocusableRef} />
|
||||
{success && (
|
||||
<HorizontalGutter spacing={2}>
|
||||
<HorizontalGutter>
|
||||
<Localized
|
||||
id="community-suspendModal-success"
|
||||
$username={username}
|
||||
strong={<strong />}
|
||||
$duration={successDuration}
|
||||
>
|
||||
<Typography>
|
||||
<strong>{username}</strong> has been suspended for{" "}
|
||||
<strong>{successDuration}</strong>
|
||||
</Typography>
|
||||
</Localized>
|
||||
</HorizontalGutter>
|
||||
|
||||
<Flex justifyContent="flex-end" itemGutter="half">
|
||||
<Localized id="community-suspendModal-success-close">
|
||||
<Button variant="filled" color="primary" onClick={onClose}>
|
||||
Ok
|
||||
</Button>
|
||||
</Localized>
|
||||
</Flex>
|
||||
</HorizontalGutter>
|
||||
)}
|
||||
{!success && (
|
||||
<HorizontalGutter spacing={2}>
|
||||
<Localized
|
||||
id="community-suspendModal-areYouSure"
|
||||
strong={<strong />}
|
||||
$username={username || <NotAvailable />}
|
||||
>
|
||||
<Typography
|
||||
className={styles.header}
|
||||
variant="header2"
|
||||
id="suspendModal-title"
|
||||
>
|
||||
Suspend <strong>{username || <NotAvailable />}</strong>?
|
||||
</Typography>
|
||||
</Localized>
|
||||
<Localized id="community-suspendModal-consequence">
|
||||
<Typography>
|
||||
While suspended, this user will no longer be able to
|
||||
comment, use reactions, or report comments.
|
||||
</Typography>
|
||||
</Localized>
|
||||
|
||||
<Localized id="community-suspendModal-selectDuration">
|
||||
<Typography variant="header3">
|
||||
Select suspension length
|
||||
</Typography>
|
||||
</Localized>
|
||||
|
||||
<SuspendForm
|
||||
username={username}
|
||||
onCancel={onClose}
|
||||
organizationName={organizationName}
|
||||
onSubmit={onFormSubmit}
|
||||
/>
|
||||
</HorizontalGutter>
|
||||
)}
|
||||
</Card>
|
||||
)}
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const enhanced = withGetMessage(SuspendModal);
|
||||
|
||||
export default enhanced;
|
||||
@@ -0,0 +1,62 @@
|
||||
import { graphql } from "react-relay";
|
||||
import { Environment } from "relay-runtime";
|
||||
|
||||
import { SuspendUserMutation as MutationTypes } from "coral-admin/__generated__/SuspendUserMutation.graphql";
|
||||
import {
|
||||
commitMutationPromiseNormalized,
|
||||
createMutation,
|
||||
lookup,
|
||||
MutationInput,
|
||||
} from "coral-framework/lib/relay";
|
||||
import { GQLUser, GQLUSER_STATUS } from "coral-framework/schema";
|
||||
|
||||
let clientMutationId = 0;
|
||||
|
||||
const SuspendUserMutation = createMutation(
|
||||
"suspendUser",
|
||||
(environment: Environment, input: MutationInput<MutationTypes>) => {
|
||||
return commitMutationPromiseNormalized<MutationTypes>(environment, {
|
||||
mutation: graphql`
|
||||
mutation SuspendUserMutation($input: SuspendUserInput!) {
|
||||
suspendUser(input: $input) {
|
||||
user {
|
||||
id
|
||||
status {
|
||||
current
|
||||
suspension {
|
||||
active
|
||||
}
|
||||
}
|
||||
}
|
||||
clientMutationId
|
||||
}
|
||||
}
|
||||
`,
|
||||
variables: {
|
||||
input: {
|
||||
...input,
|
||||
clientMutationId: clientMutationId.toString(),
|
||||
},
|
||||
},
|
||||
optimisticResponse: {
|
||||
suspendUser: {
|
||||
user: {
|
||||
id: input.userID,
|
||||
status: {
|
||||
current: lookup<GQLUser>(
|
||||
environment,
|
||||
input.userID
|
||||
)!.status.current.concat(GQLUSER_STATUS.SUSPENDED),
|
||||
suspension: {
|
||||
active: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
clientMutationId: (clientMutationId++).toString(),
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
export default SuspendUserMutation;
|
||||
@@ -1,5 +1,6 @@
|
||||
import React, { FunctionComponent, useCallback, useState } from "react";
|
||||
|
||||
import { UserStatusChangeContainer_settings as SettingsData } from "coral-admin/__generated__/UserStatusChangeContainer_settings.graphql";
|
||||
import { UserStatusChangeContainer_user as UserData } from "coral-admin/__generated__/UserStatusChangeContainer_user.graphql";
|
||||
import {
|
||||
graphql,
|
||||
@@ -12,21 +13,27 @@ import ButtonPadding from "../ButtonPadding";
|
||||
import BanModal from "./BanModal";
|
||||
import BanUserMutation from "./BanUserMutation";
|
||||
import RemoveUserBanMutation from "./RemoveUserBanMutation";
|
||||
import RemoveUserSuspensionMutation from "./RemoveUserSuspensionMutation";
|
||||
import SuspendModal from "./SuspendModal";
|
||||
import SuspendUserMutation from "./SuspendUserMutation";
|
||||
import UserStatusChange from "./UserStatusChange";
|
||||
import UserStatusContainer from "./UserStatusContainer";
|
||||
|
||||
interface Props {
|
||||
user: UserData;
|
||||
fullWidth?: boolean;
|
||||
settings: SettingsData;
|
||||
}
|
||||
|
||||
const UserStatusChangeContainer: FunctionComponent<Props> = ({
|
||||
user,
|
||||
fullWidth = false,
|
||||
}) => {
|
||||
const UserStatusChangeContainer: FunctionComponent<Props> = props => {
|
||||
const { user, settings, fullWidth } = props;
|
||||
const banUser = useMutation(BanUserMutation);
|
||||
const suspendUser = useMutation(SuspendUserMutation);
|
||||
const removeUserBan = useMutation(RemoveUserBanMutation);
|
||||
const removeUserSuspension = useMutation(RemoveUserSuspensionMutation);
|
||||
const [showBanned, setShowBanned] = useState<boolean>(false);
|
||||
const [showSuspend, setShowSuspend] = useState<boolean>(false);
|
||||
const [showSuspendSuccess, setShowSuspendSuccess] = useState<boolean>(false);
|
||||
const handleBan = useCallback(() => {
|
||||
if (user.status.ban.active) {
|
||||
return;
|
||||
@@ -43,14 +50,43 @@ const UserStatusChangeContainer: FunctionComponent<Props> = ({
|
||||
if (user.status.suspension.active) {
|
||||
return;
|
||||
}
|
||||
// TODO: (cvle)
|
||||
}, [user]);
|
||||
setShowSuspend(true);
|
||||
}, [user, setShowSuspend]);
|
||||
const handleRemoveSuspension = useCallback(() => {
|
||||
if (!user.status.suspension.active) {
|
||||
return;
|
||||
}
|
||||
// TODO: (cvle)
|
||||
}, [user]);
|
||||
removeUserSuspension({ userID: user.id });
|
||||
}, [user, removeUserSuspension]);
|
||||
|
||||
const handleSuspendModalClose = useCallback(() => {
|
||||
setShowSuspend(false);
|
||||
setShowSuspendSuccess(false);
|
||||
}, [setShowBanned, setShowSuspendSuccess]);
|
||||
|
||||
const handleBanModalClose = useCallback(() => {
|
||||
setShowBanned(false);
|
||||
}, [setShowBanned]);
|
||||
|
||||
const handleSuspendConfirm = useCallback(
|
||||
(timeout, message) => {
|
||||
suspendUser({
|
||||
userID: user.id,
|
||||
timeout,
|
||||
message,
|
||||
});
|
||||
setShowSuspendSuccess(true);
|
||||
},
|
||||
[user, suspendUser, setShowSuspendSuccess]
|
||||
);
|
||||
|
||||
const handleBanConfirm = useCallback(
|
||||
message => {
|
||||
banUser({ userID: user.id, message });
|
||||
setShowBanned(false);
|
||||
},
|
||||
[user, setShowBanned]
|
||||
);
|
||||
|
||||
if (user.role !== GQLUSER_ROLE.COMMENTER) {
|
||||
return (
|
||||
@@ -73,14 +109,19 @@ const UserStatusChangeContainer: FunctionComponent<Props> = ({
|
||||
>
|
||||
<UserStatusContainer user={user} />
|
||||
</UserStatusChange>
|
||||
<SuspendModal
|
||||
username={user.username}
|
||||
open={showSuspend || showSuspendSuccess}
|
||||
success={showSuspendSuccess}
|
||||
onClose={handleSuspendModalClose}
|
||||
organizationName={settings.organization.name}
|
||||
onConfirm={handleSuspendConfirm}
|
||||
/>
|
||||
<BanModal
|
||||
username={user.username}
|
||||
open={showBanned}
|
||||
onClose={() => setShowBanned(false)}
|
||||
onConfirm={() => {
|
||||
banUser({ userID: user.id });
|
||||
setShowBanned(false);
|
||||
}}
|
||||
onClose={handleBanModalClose}
|
||||
onConfirm={handleBanConfirm}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
@@ -89,7 +130,6 @@ const UserStatusChangeContainer: FunctionComponent<Props> = ({
|
||||
const enhanced = withFragmentContainer<Props>({
|
||||
user: graphql`
|
||||
fragment UserStatusChangeContainer_user on User {
|
||||
...UserStatusContainer_user
|
||||
id
|
||||
role
|
||||
username
|
||||
@@ -101,6 +141,14 @@ const enhanced = withFragmentContainer<Props>({
|
||||
active
|
||||
}
|
||||
}
|
||||
...UserStatusContainer_user
|
||||
}
|
||||
`,
|
||||
settings: graphql`
|
||||
fragment UserStatusChangeContainer_settings on Settings {
|
||||
organization {
|
||||
name
|
||||
}
|
||||
}
|
||||
`,
|
||||
})(UserStatusChangeContainer);
|
||||
|
||||
@@ -16,6 +16,7 @@ interface Props {
|
||||
user: PropTypesOf<typeof UserRole>["user"] &
|
||||
PropTypesOf<typeof UserStatus>["user"];
|
||||
viewer: PropTypesOf<typeof UserRole>["viewer"];
|
||||
settings: PropTypesOf<typeof UserStatus>["settings"];
|
||||
onUsernameClicked?: (userID: string) => void;
|
||||
}
|
||||
|
||||
@@ -27,6 +28,7 @@ const UserRow: FunctionComponent<Props> = ({
|
||||
user,
|
||||
viewer,
|
||||
onUsernameClicked,
|
||||
settings,
|
||||
}) => {
|
||||
const usernameClicked = useCallback(() => {
|
||||
if (!onUsernameClicked) {
|
||||
@@ -53,7 +55,7 @@ const UserRow: FunctionComponent<Props> = ({
|
||||
<UserRole user={user} viewer={viewer} />
|
||||
</TableCell>
|
||||
<TableCell className={styles.statusColumn}>
|
||||
<UserStatus user={user} fullWidth />
|
||||
<UserStatus user={user} settings={settings} fullWidth />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React, { FunctionComponent } from "react";
|
||||
import { graphql } from "react-relay";
|
||||
|
||||
import { UserRowContainer_settings as SettingsData } from "coral-admin/__generated__/UserRowContainer_settings.graphql";
|
||||
import { UserRowContainer_user as UserData } from "coral-admin/__generated__/UserRowContainer_user.graphql";
|
||||
import { UserRowContainer_viewer as ViewerData } from "coral-admin/__generated__/UserRowContainer_viewer.graphql";
|
||||
import { useCoralContext } from "coral-framework/lib/bootstrap";
|
||||
@@ -11,6 +12,7 @@ import UserRow from "./UserRow";
|
||||
interface Props {
|
||||
user: UserData;
|
||||
viewer: ViewerData;
|
||||
settings: SettingsData;
|
||||
onUsernameClicked?: (userID: string) => void;
|
||||
}
|
||||
|
||||
@@ -19,6 +21,7 @@ const UserRowContainer: FunctionComponent<Props> = props => {
|
||||
return (
|
||||
<UserRow
|
||||
user={props.user}
|
||||
settings={props.settings}
|
||||
viewer={props.viewer}
|
||||
userID={props.user.id}
|
||||
username={props.user.username!}
|
||||
@@ -39,6 +42,11 @@ const enhanced = withFragmentContainer<Props>({
|
||||
...UserRoleChangeContainer_viewer
|
||||
}
|
||||
`,
|
||||
settings: graphql`
|
||||
fragment UserRowContainer_settings on Settings {
|
||||
...UserStatusChangeContainer_settings
|
||||
}
|
||||
`,
|
||||
user: graphql`
|
||||
fragment UserRowContainer_user on User {
|
||||
...UserStatusChangeContainer_user
|
||||
|
||||
@@ -22,6 +22,7 @@ import styles from "./UserTable.css";
|
||||
|
||||
interface Props {
|
||||
viewer: PropTypesOf<typeof UserRowContainer>["viewer"] | null;
|
||||
settings: PropTypesOf<typeof UserRowContainer>["settings"] | null;
|
||||
users: Array<{ id: string } & PropTypesOf<typeof UserRowContainer>["user"]>;
|
||||
onLoadMore: () => void;
|
||||
hasMore: boolean;
|
||||
@@ -29,7 +30,11 @@ interface Props {
|
||||
loading: boolean;
|
||||
}
|
||||
|
||||
const UserTable: FunctionComponent<Props> = props => {
|
||||
const UserTable: FunctionComponent<Props> = ({
|
||||
viewer,
|
||||
settings,
|
||||
...props
|
||||
}) => {
|
||||
const [userDrawerUserID, setUserDrawerUserID] = useState("");
|
||||
const [userDrawerVisible, setUserDrawerVisible] = useState(false);
|
||||
|
||||
@@ -45,7 +50,6 @@ const UserTable: FunctionComponent<Props> = props => {
|
||||
setUserDrawerVisible(false);
|
||||
setUserDrawerUserID("");
|
||||
}, [setUserDrawerUserID, setUserDrawerVisible]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<HorizontalGutter size="double">
|
||||
@@ -77,11 +81,14 @@ const UserTable: FunctionComponent<Props> = props => {
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{!props.loading &&
|
||||
settings &&
|
||||
viewer &&
|
||||
props.users.map(u => (
|
||||
<UserRowContainer
|
||||
key={u.id}
|
||||
user={u}
|
||||
viewer={props.viewer!}
|
||||
settings={settings}
|
||||
viewer={viewer}
|
||||
onUsernameClicked={onShowUserDrawer}
|
||||
/>
|
||||
))}
|
||||
|
||||
@@ -52,6 +52,7 @@ const UserTableContainer: FunctionComponent<Props> = props => {
|
||||
/>
|
||||
<UserTable
|
||||
viewer={props.query && props.query.viewer}
|
||||
settings={props.query && props.query.settings}
|
||||
loading={!props.query || isRefetching}
|
||||
users={users}
|
||||
onLoadMore={loadMore}
|
||||
@@ -87,6 +88,7 @@ const enhanced = withPaginationContainer<
|
||||
}
|
||||
settings {
|
||||
...InviteUsersContainer_settings
|
||||
...UserRowContainer_settings
|
||||
}
|
||||
users(
|
||||
first: $count
|
||||
|
||||
@@ -1,120 +1,5 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`ban user 1`] = `
|
||||
<div
|
||||
aria-labelledby="banModal-title"
|
||||
className="Modal-root"
|
||||
onKeyDown={[Function]}
|
||||
role="modal"
|
||||
>
|
||||
<div
|
||||
className="Backdrop-root Backdrop-active"
|
||||
data-testid="backdrop"
|
||||
onClick={[Function]}
|
||||
/>
|
||||
<div
|
||||
className="Modal-scroll"
|
||||
>
|
||||
<div
|
||||
className="Modal-alignContainer1"
|
||||
>
|
||||
<div
|
||||
className="Modal-alignContainer2"
|
||||
>
|
||||
<div
|
||||
className="Modal-wrapper"
|
||||
>
|
||||
<div
|
||||
onFocus={[Function]}
|
||||
tabIndex={0}
|
||||
/>
|
||||
<div
|
||||
tabIndex={-1}
|
||||
/>
|
||||
<div
|
||||
className="Card-root BanModal-card"
|
||||
>
|
||||
<button
|
||||
className="BaseButton-root CloseButton-root"
|
||||
onBlur={[Function]}
|
||||
onClick={[Function]}
|
||||
onFocus={[Function]}
|
||||
onMouseOut={[Function]}
|
||||
onMouseOver={[Function]}
|
||||
onTouchEnd={[Function]}
|
||||
type="button"
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="Icon-root Icon-md CloseButton-icon"
|
||||
>
|
||||
close
|
||||
</span>
|
||||
</button>
|
||||
<div
|
||||
className="Box-root HorizontalGutter-root HorizontalGutter-double"
|
||||
>
|
||||
<div
|
||||
className="Box-root HorizontalGutter-root HorizontalGutter-full"
|
||||
>
|
||||
<h1
|
||||
className="Box-root Typography-root Typography-header2 Typography-colorTextPrimary"
|
||||
id="banModal-title"
|
||||
>
|
||||
Are you sure you want to ban
|
||||
<strong>
|
||||
Isabelle
|
||||
</strong>
|
||||
?
|
||||
</h1>
|
||||
<p
|
||||
className="Box-root Typography-root Typography-bodyCopy Typography-colorTextPrimary"
|
||||
>
|
||||
Once banned, this user will no longer be able to comment, use
|
||||
reactions, or report comments.
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
className="Box-root Flex-root Flex-flex Flex-halfItemGutter Flex-justifyFlexEnd gutter"
|
||||
>
|
||||
<button
|
||||
className="BaseButton-root Button-root Button-sizeRegular Button-colorRegular Button-variantOutlined"
|
||||
onBlur={[Function]}
|
||||
onClick={[Function]}
|
||||
onFocus={[Function]}
|
||||
onMouseOut={[Function]}
|
||||
onMouseOver={[Function]}
|
||||
onTouchEnd={[Function]}
|
||||
type="button"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
className="BaseButton-root Button-root Button-sizeRegular Button-colorPrimary Button-variantFilled"
|
||||
onBlur={[Function]}
|
||||
onClick={[Function]}
|
||||
onFocus={[Function]}
|
||||
onMouseOut={[Function]}
|
||||
onMouseOver={[Function]}
|
||||
onTouchEnd={[Function]}
|
||||
type="button"
|
||||
>
|
||||
Ban User
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
onFocus={[Function]}
|
||||
tabIndex={0}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`renders community 1`] = `
|
||||
<div
|
||||
className="MainLayout-root Community-root"
|
||||
|
||||
@@ -404,6 +404,264 @@ it("can't change staff, moderator and admin status", async () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("suspend user", async () => {
|
||||
const user = users.commenters[0];
|
||||
|
||||
const resolvers = createResolversStub<GQLResolver>({
|
||||
Mutation: {
|
||||
suspendUser: ({ variables }) => {
|
||||
expectAndFail(variables).toMatchObject({
|
||||
userID: user.id,
|
||||
});
|
||||
const userRecord = pureMerge<typeof user>(user, {
|
||||
status: {
|
||||
current: user.status.current.concat(GQLUSER_STATUS.SUSPENDED),
|
||||
suspension: { active: true },
|
||||
},
|
||||
});
|
||||
return {
|
||||
user: userRecord,
|
||||
};
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const { container, testRenderer } = await createTestRenderer({
|
||||
resolvers,
|
||||
});
|
||||
|
||||
const userRow = within(container).getByText(user.username!, {
|
||||
selector: "tr",
|
||||
});
|
||||
|
||||
TestRenderer.act(() => {
|
||||
within(userRow)
|
||||
.getByLabelText("Change user status")
|
||||
.props.onClick();
|
||||
});
|
||||
|
||||
const popup = within(userRow).getByLabelText(
|
||||
"A dropdown to change the user status"
|
||||
);
|
||||
|
||||
TestRenderer.act(() => {
|
||||
within(popup)
|
||||
.getByText("Suspend User", { selector: "button" })
|
||||
.props.onClick();
|
||||
});
|
||||
|
||||
const modal = within(testRenderer.root).getByLabelText("Suspend", {
|
||||
exact: false,
|
||||
});
|
||||
|
||||
TestRenderer.act(() => {
|
||||
within(modal)
|
||||
.getByType("form")
|
||||
.props.onSubmit();
|
||||
});
|
||||
within(userRow).getByText("Suspended");
|
||||
expect(resolvers.Mutation!.suspendUser!.called).toBe(true);
|
||||
});
|
||||
|
||||
it("remove user suspension", async () => {
|
||||
const user = users.suspendedCommenter;
|
||||
const resolvers = createResolversStub<GQLResolver>({
|
||||
Mutation: {
|
||||
removeUserSuspension: ({ variables }) => {
|
||||
expectAndFail(variables).toMatchObject({
|
||||
userID: user.id,
|
||||
});
|
||||
const userRecord = pureMerge<typeof user>(user, {
|
||||
status: {
|
||||
current: [GQLUSER_STATUS.ACTIVE],
|
||||
suspension: { active: false },
|
||||
},
|
||||
});
|
||||
return {
|
||||
user: userRecord,
|
||||
};
|
||||
},
|
||||
},
|
||||
Query: {
|
||||
users: () => ({
|
||||
edges: [
|
||||
{
|
||||
node: user,
|
||||
cursor: user.createdAt,
|
||||
},
|
||||
],
|
||||
pageInfo: { endCursor: null, hasNextPage: false },
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
const { container } = await createTestRenderer({
|
||||
resolvers,
|
||||
});
|
||||
|
||||
const userRow = within(container).getByText(user.username!, {
|
||||
selector: "tr",
|
||||
});
|
||||
|
||||
TestRenderer.act(() => {
|
||||
within(userRow)
|
||||
.getByLabelText("Change user status")
|
||||
.props.onClick();
|
||||
});
|
||||
|
||||
const popup = within(userRow).getByLabelText(
|
||||
"A dropdown to change the user status"
|
||||
);
|
||||
|
||||
TestRenderer.act(() => {
|
||||
within(popup)
|
||||
.getByText("Remove Suspension", { selector: "button" })
|
||||
.props.onClick();
|
||||
});
|
||||
expect(resolvers.Mutation!.removeUserSuspension!.called).toBe(true);
|
||||
|
||||
await waitForElement(() => within(userRow).getByText("Active"));
|
||||
});
|
||||
|
||||
it("suspend user with custom timeout", async () => {
|
||||
const user = users.commenters[0];
|
||||
|
||||
const resolvers = createResolversStub<GQLResolver>({
|
||||
Mutation: {
|
||||
suspendUser: ({ variables }) => {
|
||||
expectAndFail(variables).toMatchObject({
|
||||
userID: user.id,
|
||||
timeout: 604800,
|
||||
});
|
||||
const userRecord = pureMerge<typeof user>(user, {
|
||||
status: {
|
||||
current: user.status.current.concat(GQLUSER_STATUS.SUSPENDED),
|
||||
suspension: { active: true },
|
||||
},
|
||||
});
|
||||
return {
|
||||
user: userRecord,
|
||||
};
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const { container, testRenderer } = await createTestRenderer({
|
||||
resolvers,
|
||||
});
|
||||
|
||||
const userRow = within(container).getByText(user.username!, {
|
||||
selector: "tr",
|
||||
});
|
||||
|
||||
TestRenderer.act(() => {
|
||||
within(userRow)
|
||||
.getByLabelText("Change user status")
|
||||
.props.onClick();
|
||||
});
|
||||
|
||||
const popup = within(userRow).getByLabelText(
|
||||
"A dropdown to change the user status"
|
||||
);
|
||||
|
||||
TestRenderer.act(() => {
|
||||
within(popup)
|
||||
.getByText("Suspend User", { selector: "button" })
|
||||
.props.onClick();
|
||||
});
|
||||
|
||||
const modal = within(testRenderer.root).getByLabelText("Suspend", {
|
||||
exact: false,
|
||||
});
|
||||
|
||||
const changedDuration = within(modal).getByID("duration-604800");
|
||||
|
||||
TestRenderer.act(() => {
|
||||
changedDuration.props.onChange(changedDuration.props.value.toString());
|
||||
});
|
||||
|
||||
TestRenderer.act(() => {
|
||||
within(modal)
|
||||
.getByType("form")
|
||||
.props.onSubmit();
|
||||
});
|
||||
within(userRow).getByText("Suspended");
|
||||
expect(resolvers.Mutation!.suspendUser!.called).toBe(true);
|
||||
});
|
||||
|
||||
it("suspend user with custom message", async () => {
|
||||
const user = users.commenters[0];
|
||||
|
||||
const resolvers = createResolversStub<GQLResolver>({
|
||||
Mutation: {
|
||||
suspendUser: ({ variables }) => {
|
||||
expectAndFail(variables).toMatchObject({
|
||||
userID: user.id,
|
||||
message: "YOU WERE SUSPENDED FOR BEHAVING BADLY",
|
||||
});
|
||||
const userRecord = pureMerge<typeof user>(user, {
|
||||
status: {
|
||||
current: user.status.current.concat(GQLUSER_STATUS.SUSPENDED),
|
||||
suspension: { active: true },
|
||||
},
|
||||
});
|
||||
return {
|
||||
user: userRecord,
|
||||
};
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const { container, testRenderer } = await createTestRenderer({
|
||||
resolvers,
|
||||
});
|
||||
|
||||
const userRow = within(container).getByText(user.username!, {
|
||||
selector: "tr",
|
||||
});
|
||||
|
||||
TestRenderer.act(() => {
|
||||
within(userRow)
|
||||
.getByLabelText("Change user status")
|
||||
.props.onClick();
|
||||
});
|
||||
|
||||
const popup = within(userRow).getByLabelText(
|
||||
"A dropdown to change the user status"
|
||||
);
|
||||
|
||||
TestRenderer.act(() => {
|
||||
within(popup)
|
||||
.getByText("Suspend User", { selector: "button" })
|
||||
.props.onClick();
|
||||
});
|
||||
|
||||
const modal = within(testRenderer.root).getByLabelText("Suspend", {
|
||||
exact: false,
|
||||
});
|
||||
|
||||
const toggleEdit = within(modal).getByID("suspendModal-editMessage");
|
||||
|
||||
TestRenderer.act(() => {
|
||||
toggleEdit.props.onChange(true);
|
||||
});
|
||||
|
||||
TestRenderer.act(() => {
|
||||
within(modal)
|
||||
.getByID("suspendModal-message")
|
||||
.props.onChange("YOU WERE SUSPENDED FOR BEHAVING BADLY");
|
||||
});
|
||||
|
||||
TestRenderer.act(() => {
|
||||
within(modal)
|
||||
.getByType("form")
|
||||
.props.onSubmit();
|
||||
});
|
||||
|
||||
within(userRow).getByText("Suspended");
|
||||
expect(resolvers.Mutation!.suspendUser!.called).toBe(true);
|
||||
});
|
||||
|
||||
it("ban user", async () => {
|
||||
const user = users.commenters[0];
|
||||
|
||||
@@ -457,12 +715,85 @@ it("ban user", async () => {
|
||||
}
|
||||
);
|
||||
|
||||
expect(within(modal).toJSON()).toMatchSnapshot();
|
||||
TestRenderer.act(() => {
|
||||
within(modal)
|
||||
.getByType("form")
|
||||
.props.onSubmit();
|
||||
});
|
||||
within(userRow).getByText("Banned");
|
||||
expect(resolvers.Mutation!.banUser!.called).toBe(true);
|
||||
});
|
||||
|
||||
it("ban user with custom message", async () => {
|
||||
const user = users.commenters[0];
|
||||
|
||||
const resolvers = createResolversStub<GQLResolver>({
|
||||
Mutation: {
|
||||
banUser: ({ variables }) => {
|
||||
expectAndFail(variables).toMatchObject({
|
||||
userID: user.id,
|
||||
message: "YOU WERE BANNED FOR BREAKING THE RULES",
|
||||
});
|
||||
const userRecord = pureMerge<typeof user>(user, {
|
||||
status: {
|
||||
current: user.status.current.concat(GQLUSER_STATUS.BANNED),
|
||||
ban: { active: true },
|
||||
},
|
||||
});
|
||||
return {
|
||||
user: userRecord,
|
||||
};
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const { container, testRenderer } = await createTestRenderer({
|
||||
resolvers,
|
||||
});
|
||||
|
||||
const userRow = within(container).getByText(user.username!, {
|
||||
selector: "tr",
|
||||
});
|
||||
|
||||
TestRenderer.act(() => {
|
||||
within(userRow)
|
||||
.getByLabelText("Change user status")
|
||||
.props.onClick();
|
||||
});
|
||||
|
||||
const popup = within(userRow).getByLabelText(
|
||||
"A dropdown to change the user status"
|
||||
);
|
||||
|
||||
TestRenderer.act(() => {
|
||||
within(popup)
|
||||
.getByText("Ban User", { selector: "button" })
|
||||
.props.onClick();
|
||||
});
|
||||
|
||||
const modal = within(testRenderer.root).getByLabelText(
|
||||
"Are you sure you want to ban",
|
||||
{
|
||||
exact: false,
|
||||
}
|
||||
);
|
||||
|
||||
const toggleMessage = within(modal).getByID("banModal-showMessage");
|
||||
|
||||
TestRenderer.act(() => {
|
||||
toggleMessage.props.onChange(true);
|
||||
});
|
||||
|
||||
TestRenderer.act(() => {
|
||||
within(modal)
|
||||
.getByText("Ban User")
|
||||
.props.onClick();
|
||||
.getByID("banModal-message")
|
||||
.props.onChange("YOU WERE BANNED FOR BREAKING THE RULES");
|
||||
});
|
||||
|
||||
TestRenderer.act(() => {
|
||||
within(modal)
|
||||
.getByType("form")
|
||||
.props.onSubmit();
|
||||
});
|
||||
within(userRow).getByText("Banned");
|
||||
expect(resolvers.Mutation!.banUser!.called).toBe(true);
|
||||
|
||||
@@ -355,6 +355,24 @@ export const users = {
|
||||
],
|
||||
baseUser
|
||||
),
|
||||
suspendedCommenter: createFixture<GQLUser>(
|
||||
{
|
||||
id: "user-suspended-0",
|
||||
username: "lol1111",
|
||||
email: "lol1111@test.com",
|
||||
role: GQLUSER_ROLE.COMMENTER,
|
||||
ignoreable: true,
|
||||
status: {
|
||||
current: [GQLUSER_STATUS.SUSPENDED],
|
||||
ban: { active: false },
|
||||
suspension: {
|
||||
active: true,
|
||||
until: new Date(Date.now() + 600000).toISOString(),
|
||||
},
|
||||
},
|
||||
},
|
||||
baseUser
|
||||
),
|
||||
bannedCommenter: createFixture<GQLUser>(
|
||||
{
|
||||
id: "user-banned-0",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Localized } from "fluent-react/compat";
|
||||
import React, { ChangeEvent, Component } from "react";
|
||||
|
||||
import { UNIT } from "coral-framework/lib/i18n";
|
||||
import { Flex, Option, SelectField, TextField } from "coral-ui/components";
|
||||
|
||||
import styles from "./DurationField.css";
|
||||
@@ -9,22 +10,16 @@ import styles from "./DurationField.css";
|
||||
* DURATION_UNIT are units that can be used in the
|
||||
* DurationField components.
|
||||
*/
|
||||
export enum DURATION_UNIT {
|
||||
SECONDS = 1,
|
||||
MINUTES = 60,
|
||||
HOURS = 3600,
|
||||
DAYS = 86400,
|
||||
WEEKS = 604800,
|
||||
}
|
||||
export const DURATION_UNIT = UNIT;
|
||||
|
||||
type UnitElementCallback = (
|
||||
currentValue: DURATION_UNIT,
|
||||
currentValue: UNIT,
|
||||
unitValue: string
|
||||
) => React.ReactElement<any>;
|
||||
|
||||
// This is used to render the Option elements to inlcude in the select field.
|
||||
const unitElementMap: Record<DURATION_UNIT, UnitElementCallback> = {
|
||||
[DURATION_UNIT.SECONDS]: (currentValue, unitValue) => (
|
||||
const unitElementMap: Record<UNIT, UnitElementCallback> = {
|
||||
[UNIT.SECONDS]: (currentValue, unitValue) => (
|
||||
<Localized
|
||||
id="framework-durationField-seconds"
|
||||
$value={currentValue}
|
||||
@@ -33,7 +28,7 @@ const unitElementMap: Record<DURATION_UNIT, UnitElementCallback> = {
|
||||
<Option value={unitValue}>Seconds</Option>
|
||||
</Localized>
|
||||
),
|
||||
[DURATION_UNIT.MINUTES]: (currentValue, unitValue) => (
|
||||
[UNIT.MINUTES]: (currentValue, unitValue) => (
|
||||
<Localized
|
||||
id="framework-durationField-minutes"
|
||||
$value={currentValue}
|
||||
@@ -42,7 +37,7 @@ const unitElementMap: Record<DURATION_UNIT, UnitElementCallback> = {
|
||||
<Option value={unitValue}>Minutes</Option>
|
||||
</Localized>
|
||||
),
|
||||
[DURATION_UNIT.HOURS]: (currentValue, unitValue) => (
|
||||
[UNIT.HOURS]: (currentValue, unitValue) => (
|
||||
<Localized
|
||||
id="framework-durationField-hours"
|
||||
$value={currentValue}
|
||||
@@ -51,7 +46,7 @@ const unitElementMap: Record<DURATION_UNIT, UnitElementCallback> = {
|
||||
<Option value={unitValue}>Hours</Option>
|
||||
</Localized>
|
||||
),
|
||||
[DURATION_UNIT.DAYS]: (currentValue, unitValue) => (
|
||||
[UNIT.DAYS]: (currentValue, unitValue) => (
|
||||
<Localized
|
||||
id="framework-durationField-days"
|
||||
$value={currentValue}
|
||||
@@ -60,7 +55,7 @@ const unitElementMap: Record<DURATION_UNIT, UnitElementCallback> = {
|
||||
<Option value={unitValue}>Days</Option>
|
||||
</Localized>
|
||||
),
|
||||
[DURATION_UNIT.WEEKS]: (currentValue, unitValue) => (
|
||||
[UNIT.WEEKS]: (currentValue, unitValue) => (
|
||||
<Localized
|
||||
id="framework-durationField-weeks"
|
||||
$value={currentValue}
|
||||
@@ -77,16 +72,16 @@ interface Props {
|
||||
disabled: boolean;
|
||||
onChange: (v: string) => void;
|
||||
/** Specifiy units to include */
|
||||
units?: ReadonlyArray<DURATION_UNIT>;
|
||||
units?: ReadonlyArray<UNIT>;
|
||||
}
|
||||
|
||||
interface State {
|
||||
/** Current value */
|
||||
value: string;
|
||||
/** Current unit */
|
||||
unit?: DURATION_UNIT;
|
||||
unit?: UNIT;
|
||||
/** All available units */
|
||||
units: ReadonlyArray<DURATION_UNIT>;
|
||||
units: ReadonlyArray<UNIT>;
|
||||
/**
|
||||
* Element callbacks to generate the rendered
|
||||
* Option element for the select field
|
||||
@@ -100,11 +95,7 @@ interface State {
|
||||
* @param units The units that we use.
|
||||
* @param unit The current value if any otherwise the best matching unit will be used.
|
||||
*/
|
||||
function valueToState(
|
||||
value: string,
|
||||
units: ReadonlyArray<DURATION_UNIT>,
|
||||
unit?: DURATION_UNIT
|
||||
) {
|
||||
function valueToState(value: string, units: ReadonlyArray<UNIT>, unit?: UNIT) {
|
||||
const parsed = parseInt(value, 10);
|
||||
|
||||
// If value was a valid number..
|
||||
@@ -147,7 +138,7 @@ function stateToValue(state: State) {
|
||||
*/
|
||||
class DurationField extends Component<Props, State> {
|
||||
public static defaultProps: Partial<Props> = {
|
||||
units: [DURATION_UNIT.HOURS, DURATION_UNIT.DAYS, DURATION_UNIT.WEEKS],
|
||||
units: [UNIT.HOURS, UNIT.DAYS, UNIT.WEEKS],
|
||||
};
|
||||
|
||||
public state: State = valueToState(this.props.value, this.props.units!);
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import { FluentBundle } from "fluent/compat";
|
||||
|
||||
export default function format(
|
||||
bundles: FluentBundle[],
|
||||
key: string,
|
||||
args?: object
|
||||
): string {
|
||||
const res = bundles.reduce((val, bundle) => {
|
||||
const message = bundle.getMessage(key);
|
||||
const got = bundle.format(message, args);
|
||||
return val || got;
|
||||
}, "");
|
||||
if (res && Array.isArray(res)) {
|
||||
return res.join("");
|
||||
}
|
||||
|
||||
return res || "";
|
||||
}
|
||||
@@ -1,17 +1,21 @@
|
||||
import { FluentBundle } from "fluent/compat";
|
||||
|
||||
export default function getMessage(
|
||||
export default function getMessage<T extends {}>(
|
||||
bundles: FluentBundle[],
|
||||
key: string,
|
||||
defaultTo = ""
|
||||
defaultTo: string,
|
||||
args?: T
|
||||
): string {
|
||||
const res = bundles.reduce((val, bundle) => {
|
||||
const got = bundle.getMessage(key);
|
||||
if (!got && process.env.NODE_ENV !== "production") {
|
||||
const message = bundle.getMessage(key);
|
||||
if (!message && process.env.NODE_ENV !== "production") {
|
||||
// tslint:disable-next-line:no-console
|
||||
console.warn(`Translation ${key} was not found for ${bundle.locales}`);
|
||||
}
|
||||
return val || got;
|
||||
if (!args) {
|
||||
return val || message;
|
||||
}
|
||||
return val || bundle.format(message, args);
|
||||
}, "");
|
||||
if (res && Array.isArray(res)) {
|
||||
return res.join("");
|
||||
|
||||
@@ -3,3 +3,4 @@ export { default as negotiateLanguages } from "./negotiateLanguages";
|
||||
export { BundledLocales, LoadableLocales, LocalesData } from "./locales";
|
||||
export { default as getMessage } from "./getMessage";
|
||||
export { default as withGetMessage, GetMessage } from "./withGetMessage";
|
||||
export { default as reduceSeconds, UNIT, ScaledUnit } from "./reduceSeconds";
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* UNIT are units that can be used in the
|
||||
* DurationField components.
|
||||
*/
|
||||
export enum UNIT {
|
||||
SECONDS = 1,
|
||||
MINUTES = 60,
|
||||
HOURS = 3600,
|
||||
DAYS = 86400,
|
||||
WEEKS = 604800,
|
||||
}
|
||||
|
||||
export const UNIT_MAP = {
|
||||
[UNIT.SECONDS]: "second",
|
||||
[UNIT.MINUTES]: "minute",
|
||||
[UNIT.HOURS]: "hour",
|
||||
[UNIT.DAYS]: "day",
|
||||
[UNIT.WEEKS]: "week",
|
||||
};
|
||||
|
||||
export const DEFAULT_UNITS = [
|
||||
UNIT.WEEKS,
|
||||
UNIT.DAYS,
|
||||
UNIT.HOURS,
|
||||
UNIT.MINUTES,
|
||||
UNIT.SECONDS,
|
||||
];
|
||||
|
||||
type ValueOf<T> = T[keyof T];
|
||||
|
||||
export interface ScaledUnit {
|
||||
original: number;
|
||||
value: string;
|
||||
unit: ValueOf<typeof UNIT_MAP>;
|
||||
scaled: number;
|
||||
}
|
||||
|
||||
export default function reduceSeconds(
|
||||
value: number,
|
||||
units: UNIT[] = DEFAULT_UNITS
|
||||
): ScaledUnit {
|
||||
// Find the largest match for the smallest number.
|
||||
const unit: keyof typeof UNIT_MAP =
|
||||
units.find(compare => value >= compare) || UNIT.SECONDS;
|
||||
|
||||
// Scale the value to the unit.
|
||||
const scaled = Math.round((value / unit) * 100) / 100;
|
||||
|
||||
return {
|
||||
original: value,
|
||||
value: value.toString(),
|
||||
scaled,
|
||||
unit: UNIT_MAP[unit],
|
||||
};
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import { DefaultingInferableComponentEnhancer, hoistStatics } from "recompose";
|
||||
import { CoralContext, withContext } from "../bootstrap";
|
||||
import getMessage from "./getMessage";
|
||||
|
||||
export type GetMessage = (id: string, defaultTo?: string) => string;
|
||||
export type GetMessage = <T>(id: string, defaultTo: string, args?: T) => string;
|
||||
|
||||
interface InjectedProps {
|
||||
getMessage: GetMessage;
|
||||
@@ -35,8 +35,12 @@ const withGetMessage: DefaultingInferableComponentEnhancer<
|
||||
const Workaround = BaseComponent as React.ComponentType<InjectedProps>;
|
||||
|
||||
class WithGetMessage extends React.Component<Props> {
|
||||
private getMessage = (id: string, defaultTo?: string) => {
|
||||
return getMessage(this.props.localeBundles, id, defaultTo);
|
||||
private getMessage = <U extends {}>(
|
||||
id: string,
|
||||
defaultTo: string,
|
||||
args?: U
|
||||
): string => {
|
||||
return getMessage(this.props.localeBundles, id, defaultTo, args);
|
||||
};
|
||||
public render() {
|
||||
const { localeBundles: _, ...rest } = this.props;
|
||||
|
||||
@@ -92,8 +92,13 @@ export class CommentContainer extends Component<Props, State> {
|
||||
this.props.viewer &&
|
||||
this.props.viewer.status.current.includes(GQLUSER_STATUS.BANNED)
|
||||
);
|
||||
const suspended = Boolean(
|
||||
this.props.viewer &&
|
||||
this.props.viewer.status.current.includes(GQLUSER_STATUS.SUSPENDED)
|
||||
);
|
||||
return (
|
||||
!banned &&
|
||||
!suspended &&
|
||||
isMyComment &&
|
||||
isBeforeDate(this.props.comment.editing.editableUntil)
|
||||
);
|
||||
@@ -181,6 +186,10 @@ export class CommentContainer extends Component<Props, State> {
|
||||
this.props.viewer &&
|
||||
this.props.viewer.status.current.includes(GQLUSER_STATUS.BANNED)
|
||||
);
|
||||
const suspended = Boolean(
|
||||
this.props.viewer &&
|
||||
this.props.viewer.status.current.includes(GQLUSER_STATUS.SUSPENDED)
|
||||
);
|
||||
const showCaret =
|
||||
this.props.viewer &&
|
||||
roleIsAtLeast(this.props.viewer.role, GQLUSER_ROLE.MODERATOR);
|
||||
@@ -263,9 +272,9 @@ export class CommentContainer extends Component<Props, State> {
|
||||
comment={comment}
|
||||
settings={settings}
|
||||
viewer={viewer}
|
||||
readOnly={banned}
|
||||
readOnly={banned || suspended}
|
||||
/>
|
||||
{!disableReplies && !banned && (
|
||||
{!disableReplies && !banned && !suspended && (
|
||||
<ReplyButton
|
||||
id={`comments-commentContainer-replyButton-${
|
||||
comment.id
|
||||
@@ -283,7 +292,7 @@ export class CommentContainer extends Component<Props, State> {
|
||||
/>
|
||||
</ButtonsBar>
|
||||
<ButtonsBar>
|
||||
{!banned && (
|
||||
{!banned && !suspended && (
|
||||
<ReportButtonContainer
|
||||
comment={comment}
|
||||
viewer={viewer}
|
||||
|
||||
@@ -32,6 +32,7 @@ import { PostCommentFormContainer } from "./PostCommentForm";
|
||||
import SortMenu from "./SortMenu";
|
||||
import StoryClosedTimeoutContainer from "./StoryClosedTimeout";
|
||||
import styles from "./StreamContainer.css";
|
||||
import { SuspendedInfoContainer } from "./SuspendedInfo/index";
|
||||
|
||||
interface Props {
|
||||
story: StoryData;
|
||||
@@ -75,6 +76,10 @@ export const StreamContainer: FunctionComponent<Props> = props => {
|
||||
const banned = Boolean(
|
||||
props.viewer && props.viewer.status.current.includes(GQLUSER_STATUS.BANNED)
|
||||
);
|
||||
const suspended = Boolean(
|
||||
props.viewer &&
|
||||
props.viewer.status.current.includes(GQLUSER_STATUS.SUSPENDED)
|
||||
);
|
||||
|
||||
const allCommentsCount = props.story.commentCounts.totalVisible;
|
||||
const featuredCommentsCount = props.story.commentCounts.tags.FEATURED;
|
||||
@@ -100,7 +105,7 @@ export const StreamContainer: FunctionComponent<Props> = props => {
|
||||
<HorizontalGutter className={styles.root} size="double">
|
||||
<UserBoxContainer viewer={props.viewer} settings={props.settings} />
|
||||
<CommunityGuidelinesContainer settings={props.settings} />
|
||||
{!banned && (
|
||||
{!banned && !suspended && (
|
||||
<PostCommentFormContainer
|
||||
settings={props.settings}
|
||||
story={props.story}
|
||||
@@ -110,6 +115,12 @@ export const StreamContainer: FunctionComponent<Props> = props => {
|
||||
/>
|
||||
)}
|
||||
{banned && <BannedInfo />}
|
||||
{suspended && (
|
||||
<SuspendedInfoContainer
|
||||
viewer={props.viewer}
|
||||
settings={props.settings}
|
||||
/>
|
||||
)}
|
||||
<HorizontalGutter spacing={4} className={styles.tabBarContainer}>
|
||||
<SortMenu
|
||||
className={styles.sortMenu}
|
||||
@@ -193,6 +204,7 @@ const enhanced = withFragmentContainer<Props>({
|
||||
...CreateCommentReplyMutation_viewer
|
||||
...CreateCommentMutation_viewer
|
||||
...PostCommentFormContainer_viewer
|
||||
...SuspendedInfoContainer_viewer
|
||||
status {
|
||||
current
|
||||
}
|
||||
@@ -206,6 +218,7 @@ const enhanced = withFragmentContainer<Props>({
|
||||
...PostCommentFormContainer_settings
|
||||
...UserBoxContainer_settings
|
||||
...CommunityGuidelinesContainer_settings
|
||||
...SuspendedInfoContainer_settings
|
||||
}
|
||||
`,
|
||||
})(StreamContainer);
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import { Localized } from "fluent-react/compat";
|
||||
import React, { FunctionComponent, useMemo } from "react";
|
||||
|
||||
import { useCoralContext } from "coral-framework/lib/bootstrap";
|
||||
|
||||
import { CallOut, HorizontalGutter, Typography } from "coral-ui/components";
|
||||
|
||||
interface Props {
|
||||
organization: string;
|
||||
until: string;
|
||||
}
|
||||
|
||||
const SuspendedInfo: FunctionComponent<Props> = ({ until, organization }) => {
|
||||
const { locales } = useCoralContext();
|
||||
const untilDate = useMemo(() => {
|
||||
const formatter = new Intl.DateTimeFormat(locales, {
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
hour: "numeric",
|
||||
minute: "2-digit",
|
||||
});
|
||||
return formatter.format(new Date(until));
|
||||
}, [locales, until]);
|
||||
return (
|
||||
<CallOut fullWidth>
|
||||
<HorizontalGutter>
|
||||
<Localized id="suspendInfo-heading">
|
||||
<Typography variant="heading3">
|
||||
Your account has been temporarily suspended from commenting.
|
||||
</Typography>
|
||||
</Localized>
|
||||
<Localized
|
||||
$organization={organization}
|
||||
$until={untilDate}
|
||||
id="suspendInfo-info"
|
||||
>
|
||||
<Typography variant="bodyCopy">
|
||||
In accordance with {organization}'s community guidelines your
|
||||
account has been temporarily suspended. While suspended you will not
|
||||
be able to comment, respect or report comments. Please rejoin the
|
||||
conversation on {untilDate}.
|
||||
</Typography>
|
||||
</Localized>
|
||||
</HorizontalGutter>
|
||||
</CallOut>
|
||||
);
|
||||
};
|
||||
|
||||
export default SuspendedInfo;
|
||||
@@ -0,0 +1,48 @@
|
||||
import { withFragmentContainer } from "coral-framework/lib/relay";
|
||||
import { SuspendedInfoContainer_settings as SettingsData } from "coral-stream/__generated__/SuspendedInfoContainer_settings.graphql";
|
||||
import { SuspendedInfoContainer_viewer as ViewerData } from "coral-stream/__generated__/SuspendedInfoContainer_viewer.graphql";
|
||||
import React, { FunctionComponent } from "react";
|
||||
import { graphql } from "react-relay";
|
||||
|
||||
import SuspendedInfo from "./SuspendedInfo";
|
||||
|
||||
interface Props {
|
||||
settings: SettingsData;
|
||||
viewer: ViewerData | null;
|
||||
}
|
||||
|
||||
export const SuspendedInfoContainer: FunctionComponent<Props> = ({
|
||||
settings,
|
||||
viewer,
|
||||
}) => {
|
||||
if (!viewer) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<SuspendedInfo
|
||||
until={viewer.status.suspension.until}
|
||||
organization={settings.organization.name}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const enhanced = withFragmentContainer<Props>({
|
||||
viewer: graphql`
|
||||
fragment SuspendedInfoContainer_viewer on User {
|
||||
status {
|
||||
suspension {
|
||||
until
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
settings: graphql`
|
||||
fragment SuspendedInfoContainer_settings on Settings {
|
||||
organization {
|
||||
name
|
||||
}
|
||||
}
|
||||
`,
|
||||
})(SuspendedInfoContainer);
|
||||
|
||||
export default enhanced;
|
||||
@@ -0,0 +1,4 @@
|
||||
export {
|
||||
default,
|
||||
default as SuspendedInfoContainer,
|
||||
} from "./SuspendedInfoContainer";
|
||||
@@ -0,0 +1,105 @@
|
||||
import timekeeper from "timekeeper";
|
||||
|
||||
import { pureMerge } from "coral-common/utils";
|
||||
import { GQLResolver, GQLUSER_STATUS } from "coral-framework/schema";
|
||||
import {
|
||||
createResolversStub,
|
||||
CreateTestRendererParams,
|
||||
waitForElement,
|
||||
within,
|
||||
} from "coral-framework/testHelpers";
|
||||
|
||||
import { comments, settings, stories } from "../../fixtures";
|
||||
import create from "./create";
|
||||
|
||||
const story = stories[0];
|
||||
const firstComment = story.comments.edges[0].node;
|
||||
const viewer = firstComment.author!;
|
||||
|
||||
async function createTestRenderer(
|
||||
params: CreateTestRendererParams<GQLResolver> = {}
|
||||
) {
|
||||
const { testRenderer, context } = create({
|
||||
...params,
|
||||
resolvers: pureMerge(
|
||||
createResolversStub<GQLResolver>({
|
||||
Query: {
|
||||
settings: () => settings,
|
||||
viewer: () =>
|
||||
pureMerge<typeof viewer>(viewer, {
|
||||
status: {
|
||||
current: [GQLUSER_STATUS.SUSPENDED],
|
||||
suspension: {
|
||||
until: new Date(Date.now() + 600000).toISOString(),
|
||||
},
|
||||
},
|
||||
}),
|
||||
story: () =>
|
||||
pureMerge<typeof story>(story, {
|
||||
comments: {
|
||||
edges: [
|
||||
...story.comments.edges,
|
||||
{
|
||||
node: pureMerge<typeof comments[2]>(comments[2], {
|
||||
actionCounts: { reaction: { total: 1 } },
|
||||
}),
|
||||
cursor: comments[2].createdAt,
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
},
|
||||
}),
|
||||
params.resolvers
|
||||
),
|
||||
initLocalState: (localRecord, source, environment) => {
|
||||
localRecord.setValue(story.id, "storyID");
|
||||
if (params.initLocalState) {
|
||||
params.initLocalState(localRecord, source, environment);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const tabPane = await waitForElement(() =>
|
||||
within(testRenderer.root).getByTestID("current-tab-pane")
|
||||
);
|
||||
|
||||
return {
|
||||
testRenderer,
|
||||
context,
|
||||
tabPane,
|
||||
};
|
||||
}
|
||||
|
||||
afterAll(() => {
|
||||
timekeeper.reset();
|
||||
});
|
||||
|
||||
it("disables comment stream", async () => {
|
||||
timekeeper.freeze(firstComment.createdAt);
|
||||
const { testRenderer, tabPane } = await createTestRenderer();
|
||||
await waitForElement(() =>
|
||||
within(testRenderer.root).getByTestID("comments-allComments-log")
|
||||
);
|
||||
within(tabPane).getAllByText(
|
||||
"Your account has been temporarily suspended from commenting.",
|
||||
{
|
||||
exact: false,
|
||||
}
|
||||
);
|
||||
within(tabPane).getAllByText("In accordance with Acme Co", {
|
||||
exact: false,
|
||||
});
|
||||
expect(
|
||||
within(tabPane).queryByText("Reply", { selector: "button" })
|
||||
).toBeNull();
|
||||
expect(
|
||||
within(tabPane).queryByText("Report", { selector: "button" })
|
||||
).toBeNull();
|
||||
expect(
|
||||
within(tabPane).queryByText("Edit", { selector: "button" })
|
||||
).toBeNull();
|
||||
expect(
|
||||
within(tabPane).getByText("Respect", { selector: "button" }).props.disabled
|
||||
).toBe(true);
|
||||
});
|
||||
@@ -40,6 +40,11 @@ export const settings = createFixture<GQLSettings>({
|
||||
message: "Story is closed",
|
||||
timeout: undefined,
|
||||
},
|
||||
organization: {
|
||||
name: "Acme Co",
|
||||
contactEmail: "acme@acme.co",
|
||||
url: "https://acme.co",
|
||||
},
|
||||
auth: {
|
||||
integrations: {
|
||||
facebook: {
|
||||
@@ -110,6 +115,9 @@ export const baseUser = createFixture<GQLUser>({
|
||||
createdAt: "2018-02-06T18:24:00.000Z",
|
||||
status: {
|
||||
current: [GQLUSER_STATUS.ACTIVE],
|
||||
suspension: {
|
||||
active: false,
|
||||
},
|
||||
},
|
||||
ignoredUsers: [],
|
||||
comments: {
|
||||
|
||||
@@ -133,6 +133,7 @@ export const Users = (ctx: TenantContext) => ({
|
||||
ctx.tenant,
|
||||
ctx.user!,
|
||||
input.userID,
|
||||
input.message,
|
||||
ctx.now
|
||||
),
|
||||
suspend: async (input: GQLSuspendUserInput) =>
|
||||
@@ -143,6 +144,7 @@ export const Users = (ctx: TenantContext) => ({
|
||||
ctx.user!,
|
||||
input.userID,
|
||||
input.timeout,
|
||||
input.message,
|
||||
ctx.now
|
||||
),
|
||||
removeBan: async (input: GQLRemoveUserBanInput) =>
|
||||
|
||||
@@ -13,4 +13,5 @@ export const BanStatusHistory: Required<
|
||||
return null;
|
||||
},
|
||||
createdAt: ({ createdAt }) => createdAt,
|
||||
message: ({ message }) => message,
|
||||
};
|
||||
|
||||
@@ -23,4 +23,5 @@ export const SuspensionStatusHistory: Required<
|
||||
return null;
|
||||
},
|
||||
modifiedAt: ({ modifiedAt }) => modifiedAt,
|
||||
message: ({ message }) => message,
|
||||
};
|
||||
|
||||
@@ -1301,6 +1301,11 @@ type BanStatusHistory {
|
||||
createdAt is the time that the given User was banned.
|
||||
"""
|
||||
createdAt: Time!
|
||||
|
||||
"""
|
||||
message is sent to banned user via email.
|
||||
"""
|
||||
message: String!
|
||||
}
|
||||
|
||||
"""
|
||||
@@ -1375,6 +1380,11 @@ type SuspensionStatusHistory {
|
||||
then the suspension has not been cancelled/edited.
|
||||
"""
|
||||
modifiedAt: Time
|
||||
|
||||
"""
|
||||
message is sent to suspended user via email.
|
||||
"""
|
||||
message: String!
|
||||
}
|
||||
|
||||
"""
|
||||
@@ -4225,6 +4235,11 @@ input BanUserInput {
|
||||
clientMutationId is required for Relay support.
|
||||
"""
|
||||
clientMutationId: String!
|
||||
|
||||
"""
|
||||
message is sent to banned user via email.
|
||||
"""
|
||||
message: String!
|
||||
}
|
||||
|
||||
type BanUserPayload {
|
||||
@@ -4259,6 +4274,11 @@ input SuspendUserInput {
|
||||
clientMutationId is required for Relay support.
|
||||
"""
|
||||
clientMutationId: String!
|
||||
|
||||
"""
|
||||
message is sent to suspended user via email.
|
||||
"""
|
||||
message: String!
|
||||
}
|
||||
|
||||
type SuspendUserPayload {
|
||||
|
||||
@@ -10,11 +10,9 @@ email-notification-template-forgotPassword =
|
||||
email-subject-forgotPassword = Password Reset Request
|
||||
|
||||
email-notification-template-ban =
|
||||
Hello { $username },<br/><br/>
|
||||
Someone with access to your account has violated our community guidelines.
|
||||
As a result, your account has been banned. You will no longer be able to
|
||||
comment, react or report comments. if you think this has been done in error,
|
||||
please contact our community team at <a data-l10n-name="organizationContactEmail" >{ $organizationContactEmail }</a>.
|
||||
{ $customMessage }<br /><br />
|
||||
if you think this has been done in error, please contact our community team
|
||||
at <a data-l10n-name="organizationContactEmail" >{ $organizationContactEmail }</a>.
|
||||
|
||||
email-subject-ban = Your account has been banned
|
||||
|
||||
@@ -27,11 +25,7 @@ email-notification-template-passwordChange =
|
||||
email-subject-passwordChange = Your password has been changed
|
||||
|
||||
email-notification-template-suspend =
|
||||
Hello { $username },<br/><br/>
|
||||
In accordance with { $organizationName }'s community guidelines, your
|
||||
account has been temporarily suspended. During the suspension, you will be
|
||||
unable to comment, flag or engage with fellow commenters. Please rejoin the
|
||||
conversation { $until }.<br/><br/>
|
||||
{ $customMessage }<br/><br/>
|
||||
If you think this has been done in error, please contact our community team
|
||||
at <a data-l10n-name="organizationContactEmail" >{ $organizationContactEmail }</a>.
|
||||
|
||||
|
||||
@@ -142,6 +142,11 @@ export interface SuspensionStatusHistory {
|
||||
* was edited at.
|
||||
*/
|
||||
modifiedAt?: Date;
|
||||
|
||||
/**
|
||||
* message is the email message content sent to the user.
|
||||
*/
|
||||
message: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -180,6 +185,8 @@ export interface BanStatusHistory {
|
||||
* createdAt is the time that the given ban was added.
|
||||
*/
|
||||
createdAt: Date;
|
||||
|
||||
message?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1128,6 +1135,7 @@ async function retrieveConnection(
|
||||
* @param tenantID the Tenant's ID where the User exists
|
||||
* @param id the ID of the user being banned
|
||||
* @param createdBy the ID of the user banning the above mentioned user
|
||||
* @param message message to banned user
|
||||
* @param now the current date
|
||||
*/
|
||||
export async function banUser(
|
||||
@@ -1135,6 +1143,7 @@ export async function banUser(
|
||||
tenantID: string,
|
||||
id: string,
|
||||
createdBy: string,
|
||||
message?: string,
|
||||
now = new Date()
|
||||
) {
|
||||
// Create the new ban.
|
||||
@@ -1143,6 +1152,7 @@ export async function banUser(
|
||||
active: true,
|
||||
createdBy,
|
||||
createdAt: now,
|
||||
message,
|
||||
};
|
||||
|
||||
// Try to update the user if the user isn't already banned.
|
||||
@@ -1267,6 +1277,7 @@ export async function removeUserBan(
|
||||
* @param id the ID of the user being suspended
|
||||
* @param createdBy the ID of the user banning the above mentioned user
|
||||
* @param from the range of time that the user is being banned for
|
||||
* @param message the message sent to suspended user in email
|
||||
* @param now the current date
|
||||
*/
|
||||
export async function suspendUser(
|
||||
@@ -1275,6 +1286,7 @@ export async function suspendUser(
|
||||
id: string,
|
||||
createdBy: string,
|
||||
finish: Date,
|
||||
message: string,
|
||||
now = new Date()
|
||||
) {
|
||||
// Create the new suspension.
|
||||
@@ -1286,6 +1298,7 @@ export async function suspendUser(
|
||||
},
|
||||
createdBy,
|
||||
createdAt: now,
|
||||
message,
|
||||
};
|
||||
|
||||
// Try to update the user if the user isn't already suspended.
|
||||
|
||||
@@ -24,6 +24,7 @@ export type BanTemplate = UserNotificationContext<
|
||||
{
|
||||
username: string;
|
||||
organizationContactEmail: string;
|
||||
customMessage?: string;
|
||||
}
|
||||
>;
|
||||
|
||||
@@ -33,6 +34,7 @@ export type SuspendTemplate = UserNotificationContext<
|
||||
username: string;
|
||||
until: string;
|
||||
organizationContactEmail: string;
|
||||
customMessage?: string;
|
||||
}
|
||||
>;
|
||||
|
||||
|
||||
@@ -2,10 +2,12 @@
|
||||
|
||||
{% block content %}
|
||||
Hello {{ context.username }},<br/><br/>
|
||||
|
||||
In accordance with {{ context.organizationName }}'s community guidelines, your
|
||||
account has been temporarily suspended. During the suspension, you will be
|
||||
unable to comment, flag or engage with fellow commenters. Please rejoin the
|
||||
conversation {{ context.until }}.<br/><br/>
|
||||
If you think this has been done in error, please contact our community team
|
||||
at <a data-l10n-name="organizationContactEmail" href="mailto:{{ context.organizationContactEmail }}">{{ context.organizationContactEmail }}</a>.
|
||||
|
||||
If you think this has been done in error, please contact our community team at
|
||||
<a data-l10n-name="organizationContactEmail" href="mailto:{{ context.organizationContactEmail }}">{{ context.organizationContactEmail }}</a>.
|
||||
{% endblock %}
|
||||
|
||||
@@ -447,6 +447,7 @@ export async function updateAvatar(
|
||||
* @param tenant Tenant where the User will be banned on
|
||||
* @param user the User that is banning the User
|
||||
* @param userID the ID of the User being banned
|
||||
* @param message message to banned user
|
||||
* @param now the current time that the ban took effect
|
||||
*/
|
||||
export async function ban(
|
||||
@@ -455,6 +456,7 @@ export async function ban(
|
||||
tenant: Tenant,
|
||||
banner: User,
|
||||
userID: string,
|
||||
message: string,
|
||||
now = new Date()
|
||||
) {
|
||||
// Get the user being banned to check to see if the user already has an
|
||||
@@ -471,7 +473,7 @@ export async function ban(
|
||||
}
|
||||
|
||||
// Ban the user.
|
||||
const user = await banUser(mongo, tenant.id, userID, banner.id, now);
|
||||
const user = await banUser(mongo, tenant.id, userID, banner.id, message, now);
|
||||
|
||||
// If the user has an email address associated with their account, send them
|
||||
// a ban notification email.
|
||||
@@ -490,6 +492,7 @@ export async function ban(
|
||||
organizationName: tenant.organization.name,
|
||||
organizationURL: tenant.organization.url,
|
||||
organizationContactEmail: tenant.organization.contactEmail,
|
||||
customMessage: (message || "").replace(/\n/g, "<br />"),
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -506,6 +509,7 @@ export async function ban(
|
||||
* @param user the User that is suspending the User
|
||||
* @param userID the ID of the user being suspended
|
||||
* @param timeout the duration in seconds that the user will suspended for
|
||||
* @param message message to suspended user
|
||||
* @param now the current time that the suspension will take effect
|
||||
*/
|
||||
export async function suspend(
|
||||
@@ -515,6 +519,7 @@ export async function suspend(
|
||||
user: User,
|
||||
userID: string,
|
||||
timeout: number,
|
||||
message: string,
|
||||
now = new Date()
|
||||
) {
|
||||
// Convert the timeout to the until time.
|
||||
@@ -542,6 +547,7 @@ export async function suspend(
|
||||
userID,
|
||||
user.id,
|
||||
finishDateTime.toJSDate(),
|
||||
message,
|
||||
now
|
||||
);
|
||||
|
||||
@@ -563,6 +569,7 @@ export async function suspend(
|
||||
organizationName: tenant.organization.name,
|
||||
organizationURL: tenant.organization.url,
|
||||
organizationContactEmail: tenant.organization.contactEmail,
|
||||
customMessage: (message || "").replace(/\n/g, "<br />"),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -577,6 +577,34 @@ community-banModal-consequence =
|
||||
reactions, or report comments.
|
||||
community-banModal-cancel = Cancel
|
||||
community-banModal-banUser = Ban User
|
||||
community-banModal-customize = Customize ban email message
|
||||
community-banModal-emailTemplate =
|
||||
Hello { $username },
|
||||
|
||||
Someone with access to your account has violated our community guidelines. As a result, your account has been banned. You will no longer be able to comment, react or report comments
|
||||
|
||||
community-suspendModal-areYouSure = Suspend <strong>{ $username }</strong>?
|
||||
community-suspendModal-consequence =
|
||||
Once suspended, this user will no longer be able to comment, use
|
||||
reactions, or report comments.
|
||||
community-suspendModal-duration-3600 = 1 hour
|
||||
community-suspendModal-duration-10800 = 3 hours
|
||||
community-suspendModal-duration-86400 = 24 hours
|
||||
community-suspendModal-duration-604800 = 7 days
|
||||
community-suspendModal-cancel = Cancel
|
||||
community-suspendModal-suspendUser = Suspend User
|
||||
community-suspendModal-emailTemplate =
|
||||
Hello { $username },
|
||||
|
||||
In accordance with { $organizationName }'s community guidelines, your account has been temporarily suspended. During the suspension, you will be unable to comment, flag or engage with fellow commenters. Please rejoin the conversation in { framework-timeago-time }.
|
||||
|
||||
community-suspendModal-customize = Customize suspension email message
|
||||
|
||||
community-suspendModal-success =
|
||||
<strong>{ $username }</strong> has been suspended for <strong>{ $duration }</strong>
|
||||
|
||||
community-suspendModal-success-close = Close
|
||||
community-suspendModal-selectDuration = Select suspension length
|
||||
|
||||
community-invite-inviteMember = Invite members to your organization
|
||||
community-invite-emailAddressLabel = Email address:
|
||||
|
||||
@@ -36,8 +36,7 @@ framework-validation-notAWholeNumberGreaterThanOrEqual = Please enter a whole nu
|
||||
framework-timeago-just-now = Just now
|
||||
|
||||
framework-timeago-time =
|
||||
{ $value }
|
||||
{ $unit ->
|
||||
{ $value } { $unit ->
|
||||
[second] { $value ->
|
||||
[1] second
|
||||
*[other] seconds
|
||||
|
||||
@@ -245,3 +245,10 @@ configure-openStream-description =
|
||||
configure-openStream-openStream = Open Stream
|
||||
|
||||
comments-tombstone-ignore = This comment is hidden because you ignored {$username}
|
||||
|
||||
suspendInfo-heading = Your account has been temporarily suspended from commenting.
|
||||
suspendInfo-info =
|
||||
In accordance with { $organization }'s community guidelines your
|
||||
account has been temporarily suspended. While suspended you will not
|
||||
be able to comment, respect or report comments. Please rejoin the
|
||||
conversation on { $until }
|
||||
Reference in New Issue
Block a user