[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:
Tessa Thornton
2019-08-02 21:16:21 +00:00
committed by Wyatt Johnson
parent 4e548e8fbf
commit 5df2de6afc
42 changed files with 1511 additions and 276 deletions
@@ -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);
+18
View File
@@ -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",