[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
@@ -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);
}
},
}
);
}