[CORL-443] Add user drawer to the community section of the moderation area (#2401)

* Move the user history drawer into shared location that all admin routes access

CORL-443

* Move the moderate card to the shared components for admin

CORL-443

* Add a user drawer to the community area of the admin section

CORL-443

* Touch up missing tabs in UserRow.css

CORL-443

* Create unit tests around the user drawer

CORL-443

* Move toxicity label to new shared component location to fix rebase

CORL-443

* Update comment fixture generation to include reason metadata, action counts

CORL-443

* Rename userDrawerID to userDrawerUserID

CORL-443

* Clean up imports on user drawer unit tests

CORL-443

* Add coral-test to the jest config paths

CORL-443

* Add todo around creating predictable date times for test fixtures

CORL-443

* Move testRenderer construction outside of act() operations

CORL-443
This commit is contained in:
Nick Funk
2019-07-16 14:53:55 -06:00
committed by GitHub
parent bdb57aef7f
commit d73bdc7eec
72 changed files with 674 additions and 87 deletions
+1
View File
@@ -33,6 +33,7 @@ module.exports = {
"^coral-stream/(.*)$": "<rootDir>/src/core/client/stream/$1",
"^coral-framework/(.*)$": "<rootDir>/src/core/client/framework/$1",
"^coral-common/(.*)$": "<rootDir>/src/core/common/$1",
"^coral-test/(.*)$": "<rootDir>/src/core/client/test/$1",
},
moduleFileExtensions: ["ts", "tsx", "js", "jsx", "json", "node", "ftl"],
snapshotSerializers: ["enzyme-to-json/serializer"],
@@ -0,0 +1,3 @@
.category {
color: var(--palette-error-darkest);
}
@@ -6,15 +6,14 @@ import { Localized } from "fluent-react/compat";
import React, { FunctionComponent, useCallback } from "react";
import { graphql, RelayPaginationProp } from "react-relay";
import { Button, CallOut, Typography } from "coral-ui/components/";
import { ModerateCardContainer } from "coral-admin/components/ModerateCard";
import { Button, CallOut, Typography } from "coral-ui/components";
import { UserHistoryAllComments_settings } from "coral-admin/__generated__/UserHistoryAllComments_settings.graphql";
import { UserHistoryAllComments_user } from "coral-admin/__generated__/UserHistoryAllComments_user.graphql";
import { UserHistoryAllComments_viewer } from "coral-admin/__generated__/UserHistoryAllComments_viewer.graphql";
import { UserHistoryAllCommentsPaginationQueryVariables } from "coral-admin/__generated__/UserHistoryAllCommentsPaginationQuery.graphql";
import { ModerateCardContainer } from "../ModerateCard";
import styles from "./UserHistoryAllComments.css";
interface Props {
@@ -6,6 +6,7 @@ import { Localized } from "fluent-react/compat";
import React, { FunctionComponent, useCallback } from "react";
import { graphql, RelayPaginationProp } from "react-relay";
import { ModerateCardContainer } from "coral-admin/components/ModerateCard";
import { Button, CallOut, Typography } from "coral-ui/components";
import { UserHistoryRejectedComments_settings } from "coral-admin/__generated__/UserHistoryRejectedComments_settings.graphql";
@@ -13,8 +14,6 @@ import { UserHistoryRejectedComments_user } from "coral-admin/__generated__/User
import { UserHistoryRejectedComments_viewer } from "coral-admin/__generated__/UserHistoryRejectedComments_viewer.graphql";
import { UserHistoryRejectedCommentsPaginationQueryVariables } from "coral-admin/__generated__/UserHistoryRejectedCommentsPaginationQuery.graphql";
import { ModerateCardContainer } from "../ModerateCard";
import styles from "./UserHistoryRejectedComments.css";
interface Props {
@@ -21,3 +21,11 @@
.statusColumn {
vertical-align: top;
}
.usernameButton {
padding: 0px;
font-family: var(--font-family-sans-serif);
font-weight: var(--font-weight-regular);
font-size: calc(14rem / var(--rem-base));
line-height: calc(14em / 14);
}
@@ -1,8 +1,8 @@
import React, { FunctionComponent } from "react";
import React, { FunctionComponent, useCallback } from "react";
import NotAvailable from "coral-admin/components/NotAvailable";
import { PropTypesOf } from "coral-framework/types";
import { TableCell, TableRow, TextLink } from "coral-ui/components";
import { Button, TableCell, TableRow, TextLink } from "coral-ui/components";
import UserRole from "./UserRole";
import UserStatus from "./UserStatus";
@@ -17,28 +17,47 @@ interface Props {
user: PropTypesOf<typeof UserRole>["user"] &
PropTypesOf<typeof UserStatus>["user"];
viewer: PropTypesOf<typeof UserRole>["viewer"];
onUsernameClicked?: (userID: string) => void;
}
const UserRow: FunctionComponent<Props> = props => (
<TableRow>
<TableCell className={styles.usernameColumn}>
{props.username || <NotAvailable />}
</TableCell>
<TableCell className={styles.emailColumn}>
{<TextLink href={`mailto:${props.email}`}>{props.email}</TextLink> || (
<NotAvailable />
)}
</TableCell>
<TableCell className={styles.memberSinceColumn}>
{props.memberSince}
</TableCell>
<TableCell className={styles.roleColumn}>
<UserRole user={props.user} viewer={props.viewer} />
</TableCell>
<TableCell className={styles.statusColumn}>
<UserStatus user={props.user} />
</TableCell>
</TableRow>
);
const UserRow: FunctionComponent<Props> = ({
userID,
username,
email,
memberSince,
user,
viewer,
onUsernameClicked,
}) => {
const usernameClicked = useCallback(() => {
if (!onUsernameClicked) {
return;
}
onUsernameClicked(userID);
}, [userID, onUsernameClicked]);
return (
<TableRow>
<TableCell className={styles.usernameColumn}>
<Button onClick={usernameClicked} className={styles.usernameButton}>
{username || <NotAvailable />}
</Button>
</TableCell>
<TableCell className={styles.emailColumn}>
{<TextLink href={`mailto:${email}`}>{email}</TextLink> || (
<NotAvailable />
)}
</TableCell>
<TableCell className={styles.memberSinceColumn}>{memberSince}</TableCell>
<TableCell className={styles.roleColumn}>
<UserRole user={user} viewer={viewer} />
</TableCell>
<TableCell className={styles.statusColumn}>
<UserStatus user={user} />
</TableCell>
</TableRow>
);
};
export default UserRow;
@@ -11,6 +11,7 @@ import UserRow from "./UserRow";
interface Props {
user: UserData;
viewer: ViewerData;
onUsernameClicked?: (userID: string) => void;
}
const UserRowContainer: FunctionComponent<Props> = props => {
@@ -27,6 +28,7 @@ const UserRowContainer: FunctionComponent<Props> = props => {
month: "2-digit",
year: "numeric",
}).format(new Date(props.user.createdAt))}
onUsernameClicked={props.onUsernameClicked}
/>
);
};
@@ -1,9 +1,10 @@
import { Localized } from "fluent-react/compat";
import React, { FunctionComponent } from "react";
import React, { FunctionComponent, useCallback, useState } from "react";
import { PropTypesOf } from "coral-framework/types";
import AutoLoadMore from "coral-admin/components/AutoLoadMore";
import UserHistoryDrawerContainer from "coral-admin/components/UserHistoryDrawer/UserHistoryDrawerContainer";
import {
Table,
TableBody,
@@ -28,56 +29,86 @@ interface Props {
loading: boolean;
}
const UserTable: FunctionComponent<Props> = props => (
<>
<HorizontalGutter size="double">
<Table fullWidth>
<TableHead>
<TableRow>
<Localized id="community-column-username">
<TableCell className={styles.usernameColumn}>Username</TableCell>
</Localized>
<Localized id="community-column-email">
<TableCell className={styles.emailColumn}>
Email Address
</TableCell>
</Localized>
<Localized id="community-column-memberSince">
<TableCell className={styles.memberSinceColumn}>
Member Since
</TableCell>
</Localized>
<Localized id="community-column-role">
<TableCell className={styles.roleColumn}>Role</TableCell>
</Localized>
<Localized id="community-column-status">
<TableCell className={styles.statusColumn}>Status</TableCell>
</Localized>
</TableRow>
</TableHead>
<TableBody>
{!props.loading &&
props.users.map(u => (
<UserRowContainer key={u.id} user={u} viewer={props.viewer!} />
))}
</TableBody>
</Table>
{!props.loading && props.users.length === 0 && <EmptyMessage />}
{props.loading && (
<Flex justifyContent="center">
<Spinner />
</Flex>
)}
{props.hasMore && (
<Flex justifyContent="center">
<AutoLoadMore
disableLoadMore={props.disableLoadMore}
onLoadMore={props.onLoadMore}
/>
</Flex>
)}
</HorizontalGutter>
</>
);
const UserTable: FunctionComponent<Props> = props => {
const [userDrawerUserID, setUserDrawerUserID] = useState("");
const [userDrawerVisible, setUserDrawerVisible] = useState(false);
const onShowUserDrawer = useCallback(
(userID: string) => {
setUserDrawerUserID(userID);
setUserDrawerVisible(true);
},
[setUserDrawerUserID, setUserDrawerVisible]
);
const onHideUserDrawer = useCallback(() => {
setUserDrawerVisible(false);
setUserDrawerUserID("");
}, [setUserDrawerUserID, setUserDrawerVisible]);
return (
<>
<HorizontalGutter size="double">
<Table fullWidth>
<TableHead>
<TableRow>
<Localized id="community-column-username">
<TableCell className={styles.usernameColumn}>
Username
</TableCell>
</Localized>
<Localized id="community-column-email">
<TableCell className={styles.emailColumn}>
Email Address
</TableCell>
</Localized>
<Localized id="community-column-memberSince">
<TableCell className={styles.memberSinceColumn}>
Member Since
</TableCell>
</Localized>
<Localized id="community-column-role">
<TableCell className={styles.roleColumn}>Role</TableCell>
</Localized>
<Localized id="community-column-status">
<TableCell className={styles.statusColumn}>Status</TableCell>
</Localized>
</TableRow>
</TableHead>
<TableBody>
{!props.loading &&
props.users.map(u => (
<UserRowContainer
key={u.id}
user={u}
viewer={props.viewer!}
onUsernameClicked={onShowUserDrawer}
/>
))}
</TableBody>
</Table>
{!props.loading && props.users.length === 0 && <EmptyMessage />}
{props.loading && (
<Flex justifyContent="center">
<Spinner />
</Flex>
)}
{props.hasMore && (
<Flex justifyContent="center">
<AutoLoadMore
disableLoadMore={props.disableLoadMore}
onLoadMore={props.onLoadMore}
/>
</Flex>
)}
<UserHistoryDrawerContainer
userID={userDrawerUserID}
open={userDrawerVisible}
onClose={onHideUserDrawer}
/>
</HorizontalGutter>
</>
);
};
export default UserTable;
@@ -3,12 +3,11 @@ import React, { FunctionComponent, useCallback, useState } from "react";
import { CSSTransition, TransitionGroup } from "react-transition-group";
import AutoLoadMore from "coral-admin/components/AutoLoadMore";
import ModerateCardContainer from "coral-admin/components/ModerateCard";
import UserHistoryDrawerContainer from "coral-admin/components/UserHistoryDrawer/UserHistoryDrawerContainer";
import { Button, Flex, HorizontalGutter } from "coral-ui/components";
import { PropTypesOf } from "coral-ui/types";
import ModerateCardContainer from "../ModerateCard";
import UserHistoryDrawerContainer from "../UserHistoryDrawer/UserHistoryDrawerContainer";
import styles from "./Queue.css";
interface Props {
@@ -359,7 +359,18 @@ exports[`renders community 1`] = `
<td
className="TableCell-root UserRow-usernameColumn TableCell-body"
>
Markus
<button
className="BaseButton-root Button-root UserRow-usernameButton Button-sizeRegular Button-colorRegular Button-variantRegular"
onBlur={[Function]}
onClick={[Function]}
onFocus={[Function]}
onMouseOut={[Function]}
onMouseOver={[Function]}
onTouchEnd={[Function]}
type="button"
>
Markus
</button>
</td>
<td
className="TableCell-root UserRow-emailColumn TableCell-body"
@@ -421,7 +432,18 @@ exports[`renders community 1`] = `
<td
className="TableCell-root UserRow-usernameColumn TableCell-body"
>
Lukas
<button
className="BaseButton-root Button-root UserRow-usernameButton Button-sizeRegular Button-colorRegular Button-variantRegular"
onBlur={[Function]}
onClick={[Function]}
onFocus={[Function]}
onMouseOut={[Function]}
onMouseOver={[Function]}
onTouchEnd={[Function]}
type="button"
>
Lukas
</button>
</td>
<td
className="TableCell-root UserRow-emailColumn TableCell-body"
@@ -514,7 +536,18 @@ exports[`renders community 1`] = `
<td
className="TableCell-root UserRow-usernameColumn TableCell-body"
>
Huy
<button
className="BaseButton-root Button-root UserRow-usernameButton Button-sizeRegular Button-colorRegular Button-variantRegular"
onBlur={[Function]}
onClick={[Function]}
onFocus={[Function]}
onMouseOut={[Function]}
onMouseOver={[Function]}
onTouchEnd={[Function]}
type="button"
>
Huy
</button>
</td>
<td
className="TableCell-root UserRow-emailColumn TableCell-body"
@@ -607,7 +640,18 @@ exports[`renders community 1`] = `
<td
className="TableCell-root UserRow-usernameColumn TableCell-body"
>
Isabelle
<button
className="BaseButton-root Button-root UserRow-usernameButton Button-sizeRegular Button-colorRegular Button-variantRegular"
onBlur={[Function]}
onClick={[Function]}
onFocus={[Function]}
onMouseOut={[Function]}
onMouseOver={[Function]}
onTouchEnd={[Function]}
type="button"
>
Isabelle
</button>
</td>
<td
className="TableCell-root UserRow-emailColumn TableCell-body"
@@ -0,0 +1,109 @@
import {
createSettings,
createStory,
createUser,
} from "coral-test/helpers/fixture";
import { pureMerge } from "coral-common/utils";
import { GQLResolver, GQLUser, GQLUSER_ROLE } from "coral-framework/schema";
import {
act,
createResolversStub,
CreateTestRendererParams,
waitForElement,
within,
} from "coral-framework/testHelpers";
import create from "./create";
const viewer = createUser();
viewer.role = GQLUSER_ROLE.ADMIN;
const settings = createSettings();
async function createTestRenderer(
user: GQLUser,
params: CreateTestRendererParams<GQLResolver> = {}
) {
const { testRenderer, context, subscriptionHandler } = create(
{
...params,
resolvers: pureMerge(
createResolversStub<GQLResolver>({
Query: {
settings: () => settings,
viewer: () => viewer,
user: () => user,
},
}),
params.resolvers
),
initLocalState: (localRecord, source, environment) => {
localRecord.setValue(true, "loggedIn");
if (params.initLocalState) {
params.initLocalState(localRecord, source, environment);
}
},
},
user
);
return { testRenderer, context, subscriptionHandler };
}
it("User drawer is open for user, user name is visible", async () => {
const story = createStory();
const user = story.comments.edges[0].node.author!;
const { testRenderer } = await createTestRenderer(user);
await act(async () => {
const { getByText } = within(testRenderer.root);
await waitForElement(() => getByText(user.id, { exact: false }));
});
});
it("User drawer is open for user, user name is visible", async () => {
const story = createStory();
const user = story.comments.edges[0].node.author!;
const { testRenderer } = await createTestRenderer(user);
await act(async () => {
const { getByText } = within(testRenderer.root);
await waitForElement(() => getByText(user.username!, { exact: false }));
});
});
it("All comments selected, comment is visible in all comments", async () => {
const story = createStory();
const user = story.comments.edges[0].node.author!;
const comment = user.allComments.edges[0].node;
const { testRenderer } = await createTestRenderer(user);
await act(async () => {
const { getByText } = within(testRenderer.root);
await waitForElement(() => getByText(comment.body!, { exact: false }));
});
});
it("Select rejected comments, rejected comment is visible.", async () => {
const story = createStory();
const user = story.comments.edges[0].node.author!;
const rejectedComment = user.rejectedComments.edges[0].node;
const { testRenderer } = await createTestRenderer(user);
await act(async () => {
const { getByText } = within(testRenderer.root);
const rejectedTab = await waitForElement(() =>
getByText("rejected", {
selector: "button",
exact: false,
})
);
rejectedTab.props.onClick();
await waitForElement(() =>
getByText(rejectedComment.body!, { exact: false })
);
});
});
@@ -0,0 +1,32 @@
import React from "react";
import UserHistoryDrawerContainer from "coral-admin/components/UserHistoryDrawer/UserHistoryDrawerContainer";
import { GQLUser } from "coral-framework/schema";
import {
createTestRenderer as createTestRendererGeneric,
CreateTestRendererParams,
} from "coral-framework/testHelpers";
export default function create(
params: CreateTestRendererParams,
user: GQLUser
) {
return createTestRendererGeneric(
"userDrawer",
<UserHistoryDrawerContainer
userID={user.id}
open
onClose={() => {
return;
}}
/>,
{
...params,
initLocalState: (localRecord, source, environment) => {
if (params.initLocalState) {
params.initLocalState(localRecord, source, environment);
}
},
}
);
}
+341
View File
@@ -0,0 +1,341 @@
import {
GQLComment,
GQLCOMMENT_STATUS,
GQLMODERATION_MODE,
GQLSettings,
GQLStory,
GQLUser,
GQLUSER_ROLE,
GQLUSER_STATUS,
} from "coral-framework/schema";
import {
createFixture,
denormalizeComment,
denormalizeStory,
} from "coral-framework/testHelpers";
import uuid from "uuid/v4";
// TODO: Look into a date/time provider that can create
// predictable date/time (i.e. constantly increasing, or seeded)
export function createDateInRange(start: Date, end: Date) {
return new Date(
start.getTime() + Math.random() * (end.getTime() - start.getTime())
);
}
export function randomDate() {
return createDateInRange(new Date(2000, 0, 1), new Date());
}
export function createUserStatus(banned: boolean = false) {
return {
current: [banned ? GQLUSER_STATUS.BANNED : GQLUSER_STATUS.ACTIVE],
ban: {
active: banned,
history: [],
},
suspension: {
active: false,
until: null,
history: [],
},
};
}
export function createUser() {
return createFixture<GQLUser>({
id: uuid(),
username: uuid(),
role: GQLUSER_ROLE.COMMENTER,
createdAt: randomDate().toISOString(),
status: createUserStatus(),
ignoredUsers: [],
ignoreable: true,
});
}
export function createComment(author?: GQLUser) {
const revision = uuid();
const createdAt = randomDate();
const editableUntil = new Date(createdAt.getTime() + 30 * 60000);
if (author === undefined) {
author = createUser();
author!.createdAt = new Date(
createdAt.getTime() - 60 * 60000
).toISOString();
}
const comment = denormalizeComment(
createFixture<GQLComment>({
id: uuid(),
author,
body: uuid(),
status: GQLCOMMENT_STATUS.NONE,
statusHistory: {
edges: [],
pageInfo: { endCursor: null, hasNextPage: false },
},
createdAt: createdAt.toISOString(),
replies: { edges: [], pageInfo: { endCursor: null, hasNextPage: false } },
replyCount: 0,
editing: {
edited: false,
editableUntil: editableUntil.toISOString(),
},
actionCounts: {
reaction: {
total: 0,
},
flag: {
reasons: {
COMMENT_DETECTED_TOXIC: 0,
COMMENT_DETECTED_SPAM: 0,
COMMENT_DETECTED_TRUST: 0,
COMMENT_DETECTED_LINKS: 0,
COMMENT_DETECTED_BANNED_WORD: 0,
COMMENT_DETECTED_SUSPECT_WORD: 0,
COMMENT_REPORTED_OFFENSIVE: 0,
COMMENT_REPORTED_SPAM: 0,
},
},
},
tags: [],
permalink: "",
flags: {
edges: [],
nodes: [],
pageInfo: {
hasNextPage: false,
hasPreviousPage: false,
},
},
viewerActionPresence: { reaction: false, dontAgree: false, flag: false },
parent: undefined,
})
);
comment.revision = {
id: revision,
comment,
metadata: {
perspective: {
score: 0,
},
},
createdAt,
actionCounts: {
reaction: {
total: 0,
},
dontAgree: {
total: 0,
},
flag: {
total: 0,
reasons: {
COMMENT_REPORTED_OFFENSIVE: 0,
COMMENT_REPORTED_SPAM: 0,
COMMENT_REPORTED_OTHER: 0,
COMMENT_DETECTED_TOXIC: 0,
COMMENT_DETECTED_SPAM: 0,
COMMENT_DETECTED_TRUST: 0,
COMMENT_DETECTED_LINKS: 0,
COMMENT_DETECTED_BANNED_WORD: 0,
COMMENT_DETECTED_SUSPECT_WORD: 0,
},
},
},
};
return comment;
}
export function createComments(count: number = 3) {
const comments = [];
for (let i = 0; i < count; i++) {
comments.push(createComment());
}
return comments;
}
export function createStory() {
const id = uuid();
const comments = createComments();
comments.forEach(c => {
const edges = [{ node: c, cursor: c.createdAt }];
c.author!.comments = {
edges,
nodes: [c],
pageInfo: {
hasPreviousPage: false,
hasNextPage: false,
},
};
c.author!.allComments = {
edges,
nodes: [c],
pageInfo: {
hasPreviousPage: false,
hasNextPage: false,
},
};
c.author!.rejectedComments = {
edges,
nodes: [c],
pageInfo: {
hasPreviousPage: false,
hasNextPage: false,
},
};
});
const story = denormalizeStory(
createFixture<GQLStory>({
id,
url: `http://localhost/stories/story-${id}`,
comments: {
edges: [
{ node: comments[0], cursor: comments[0].createdAt },
{ node: comments[1], cursor: comments[1].createdAt },
{ node: comments[2], cursor: comments[2].createdAt },
],
pageInfo: {
hasNextPage: false,
},
},
metadata: {
title: uuid(),
},
isClosed: false,
commentCounts: {
totalVisible: 0,
tags: {
FEATURED: 0,
},
},
settings: {
moderation: GQLMODERATION_MODE.POST,
premodLinksEnable: false,
messageBox: {
enabled: false,
},
},
})
);
comments.forEach(c => (c.story = story));
return story;
}
export function createSettings() {
return createFixture<GQLSettings>({
id: "settings",
moderation: GQLMODERATION_MODE.POST,
premodLinksEnable: false,
live: {
enabled: true,
configurable: true,
},
wordList: {
suspect: ["suspect"],
banned: ["banned"],
},
charCount: {
enabled: false,
max: 1000,
min: 3,
},
disableCommenting: {
enabled: false,
message: "Comments are closed on this story.",
},
closeCommenting: {
auto: false,
timeout: 604800,
message: "Comments are closed on this story.",
},
email: {
enabled: true,
},
customCSSURL: "",
allowedDomains: ["localhost:8080"],
editCommentWindowLength: 30000,
communityGuidelines: {
enabled: false,
content: "",
},
organization: {
name: "Coral",
url: "https://test.com/",
contactEmail: "coral@test.com",
},
integrations: {
akismet: {
enabled: false,
},
perspective: {
enabled: false,
},
},
auth: {
integrations: {
local: {
enabled: true,
allowRegistration: true,
targetFilter: {
admin: true,
stream: true,
},
},
sso: {
enabled: false,
allowRegistration: true,
targetFilter: {
admin: true,
stream: true,
},
key: "",
keyGeneratedAt: null,
},
google: {
enabled: false,
allowRegistration: true,
targetFilter: {
admin: true,
stream: true,
},
clientID: "",
clientSecret: "",
callbackURL: "http://localhost/google/callback",
redirectURL: "http://localhost/google",
},
facebook: {
enabled: false,
allowRegistration: true,
targetFilter: {
admin: true,
stream: true,
},
clientID: "",
clientSecret: "",
callbackURL: "http://localhost/facebook/callback",
redirectURL: "http://localhost/facebook",
},
oidc: {
enabled: false,
allowRegistration: false,
targetFilter: {
admin: true,
stream: true,
},
name: "OIDC",
callbackURL: "http://localhost/oidc/callback",
redirectURL: "http://localhost/oidc",
},
},
},
});
}