mirror of
https://github.com/wassname/talk.git
synced 2026-08-11 11:27:10 +08:00
[CORL-139, CORL-140] Community (#2239)
* feat: Add table ui component * feat: community user table * feat: filters and role change * fix: add some comments * fix: user viewer * fix: snapshots * test: add tests * fix: better popover experience * fix: test * chore: use enum * feat: prevent server side setting your own role * fix: cleanup
This commit is contained in:
@@ -6,9 +6,6 @@ import { AppBarNavigation } from "talk-ui/components";
|
||||
import NavigationLink from "./NavigationLink";
|
||||
|
||||
/* TODO:
|
||||
<Localized id="navigation-community">
|
||||
<NavigationLink to="/admin/community">Community</NavigationLink>
|
||||
</Localized>
|
||||
<Localized id="navigation-stories">
|
||||
<NavigationLink to="/admin/stories">Stories</NavigationLink>
|
||||
</Localized>
|
||||
@@ -19,6 +16,9 @@ const Navigation: StatelessComponent = () => (
|
||||
<Localized id="navigation-moderate">
|
||||
<NavigationLink to="/admin/moderate">Moderate</NavigationLink>
|
||||
</Localized>
|
||||
<Localized id="navigation-community">
|
||||
<NavigationLink to="/admin/community">Community</NavigationLink>
|
||||
</Localized>
|
||||
<Localized id="navigation-configure">
|
||||
<NavigationLink to="/admin/configure">Configure</NavigationLink>
|
||||
</Localized>
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import { Localized } from "fluent-react/compat";
|
||||
import React from "react";
|
||||
|
||||
import { GQLUSER_ROLE, GQLUSER_ROLE_RL } from "talk-framework/schema";
|
||||
|
||||
interface Props {
|
||||
container?: React.ReactElement<any> | React.ComponentType<any> | string;
|
||||
children: GQLUSER_ROLE_RL;
|
||||
}
|
||||
|
||||
function createElement(
|
||||
Container: React.ReactElement<any> | React.ComponentType<any> | string,
|
||||
children: React.ReactNode
|
||||
) {
|
||||
if (React.isValidElement<any>(Container)) {
|
||||
return React.cloneElement(Container, { children });
|
||||
} else {
|
||||
return <Container>{children}</Container>;
|
||||
}
|
||||
}
|
||||
|
||||
const TranslatedRole: React.StatelessComponent<Props> = props => {
|
||||
switch (props.children) {
|
||||
case GQLUSER_ROLE.COMMENTER:
|
||||
return (
|
||||
<Localized id="role-commenter">
|
||||
{createElement(props.container!, "Commenter")}
|
||||
</Localized>
|
||||
);
|
||||
case GQLUSER_ROLE.ADMIN:
|
||||
return (
|
||||
<Localized id="role-admin">
|
||||
{createElement(props.container!, "Admin")}
|
||||
</Localized>
|
||||
);
|
||||
case GQLUSER_ROLE.MODERATOR:
|
||||
return (
|
||||
<Localized id="role-moderator">
|
||||
{createElement(props.container!, "Moderator")}
|
||||
</Localized>
|
||||
);
|
||||
case GQLUSER_ROLE.STAFF:
|
||||
return (
|
||||
<Localized id="role-staff">
|
||||
{createElement(props.container!, "Staff")}
|
||||
</Localized>
|
||||
);
|
||||
default:
|
||||
// Unknown role, just use untranslated string.
|
||||
return createElement(props.container!, props.children);
|
||||
}
|
||||
};
|
||||
|
||||
TranslatedRole.defaultProps = {
|
||||
container: "span",
|
||||
};
|
||||
|
||||
export default TranslatedRole;
|
||||
@@ -11,6 +11,15 @@ exports[`renders correctly 1`] = `
|
||||
Moderate
|
||||
</NavigationLink>
|
||||
</Localized>
|
||||
<Localized
|
||||
id="navigation-community"
|
||||
>
|
||||
<NavigationLink
|
||||
to="/admin/community"
|
||||
>
|
||||
Community
|
||||
</NavigationLink>
|
||||
</Localized>
|
||||
<Localized
|
||||
id="navigation-configure"
|
||||
>
|
||||
|
||||
+1
-1
@@ -6,7 +6,7 @@ import { BaseButton, Spinner } from "talk-ui/components";
|
||||
interface Props {
|
||||
inView: boolean | undefined;
|
||||
intersectionRef: React.Ref<any>;
|
||||
disableLoadMore: boolean;
|
||||
disableLoadMore?: boolean;
|
||||
onLoadMore: () => void;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import { graphql } from "react-relay";
|
||||
import { Environment } from "relay-runtime";
|
||||
|
||||
import {
|
||||
commitMutationPromiseNormalized,
|
||||
createMutationContainer,
|
||||
MutationInput,
|
||||
MutationResponsePromise,
|
||||
} from "talk-framework/lib/relay";
|
||||
|
||||
import { UpdateUserRoleMutation as MutationTypes } from "talk-admin/__generated__/UpdateUserRoleMutation.graphql";
|
||||
|
||||
export type UpdateUserRoleInput = MutationInput<MutationTypes>;
|
||||
|
||||
const mutation = graphql`
|
||||
mutation UpdateUserRoleMutation($input: UpdateUserRoleInput!) {
|
||||
updateUserRole(input: $input) {
|
||||
user {
|
||||
role
|
||||
}
|
||||
clientMutationId
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
let clientMutationId = 0;
|
||||
|
||||
function commit(environment: Environment, input: UpdateUserRoleInput) {
|
||||
return commitMutationPromiseNormalized<MutationTypes>(environment, {
|
||||
mutation,
|
||||
optimisticResponse: {
|
||||
updateUserRole: {
|
||||
user: {
|
||||
id: input.userID,
|
||||
role: input.role,
|
||||
},
|
||||
clientMutationId: (clientMutationId++).toString(),
|
||||
},
|
||||
} as any, // TODO: (cvle) generated types should contain one for the optimistic response.
|
||||
variables: {
|
||||
input: {
|
||||
...input,
|
||||
clientMutationId: (clientMutationId++).toString(),
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export const withUpdateUserRoleMutation = createMutationContainer(
|
||||
"updateUserRole",
|
||||
commit
|
||||
);
|
||||
|
||||
export type UpdateUserRoleMutation = (
|
||||
input: UpdateUserRoleInput
|
||||
) => MutationResponsePromise<MutationTypes, "updateUserRole">;
|
||||
@@ -41,3 +41,7 @@ export {
|
||||
withSetPasswordMutation,
|
||||
SetPasswordMutation,
|
||||
} from "./SetPasswordMutation";
|
||||
export {
|
||||
withUpdateUserRoleMutation,
|
||||
UpdateUserRoleMutation,
|
||||
} from "./UpdateUserRoleMutation";
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import { mapValues } from "lodash";
|
||||
import { GQLUSER_ROLE, GQLUSER_ROLE_RL } from "talk-framework/schema";
|
||||
|
||||
/**
|
||||
* permissionMap describes what abilities certain roles have.
|
||||
*
|
||||
* This list is currently manually managed. We want to
|
||||
* get to a point where this is generated from the schema.
|
||||
*
|
||||
* We currently specify in the comments which endpoints of
|
||||
* the graph is important for the ability, which we can later
|
||||
* used to auto generate the map making the schema become
|
||||
* the single point of truth.
|
||||
*/
|
||||
const permissionMap = {
|
||||
// Mutation.updateUserRole
|
||||
CHANGE_ROLE: [GQLUSER_ROLE.ADMIN],
|
||||
};
|
||||
|
||||
export type AbilityType = keyof typeof permissionMap;
|
||||
export const Ability = mapValues(permissionMap, (_, key) => key) as {
|
||||
[P in AbilityType]: P
|
||||
};
|
||||
|
||||
/**
|
||||
* can is used to check if the `viewer` has permission for `ability`.
|
||||
*
|
||||
* Example: `can(props.me, Ability.CHANGE_ROLE)`.
|
||||
*/
|
||||
export function can(viewer: { role: GQLUSER_ROLE_RL }, ability: AbilityType) {
|
||||
return permissionMap[ability].includes(viewer.role as GQLUSER_ROLE);
|
||||
}
|
||||
@@ -3,7 +3,7 @@ import React from "react";
|
||||
|
||||
import AppContainer from "./containers/AppContainer";
|
||||
import AuthCheckContainer from "./containers/AuthCheckContainer";
|
||||
import Community from "./routes/community/components/Community";
|
||||
import CommunityContainer from "./routes/community/containers/CommunityContainer";
|
||||
import ConfigureContainer from "./routes/configure/containers/ConfigureContainer";
|
||||
import ConfigureAdvancedRouteContainer from "./routes/configure/sections/advanced/containers/AdvancedConfigRouteContainer";
|
||||
import ConfigureAuthRouteContainer from "./routes/configure/sections/auth/containers/AuthConfigRouteContainer";
|
||||
@@ -41,7 +41,7 @@ export default makeRouteConfig(
|
||||
/>
|
||||
<Route path="rejected" {...RejectedQueueContainer.routeConfig} />
|
||||
</Route>
|
||||
<Route path="community" Component={Community} />
|
||||
<Route path="community" {...CommunityContainer.routeConfig} />
|
||||
<Route path="stories" Component={Stories} />
|
||||
<Route path="configure" Component={ConfigureContainer}>
|
||||
<Redirect from="/" to="/admin/configure/general" />
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
.root {
|
||||
max-width: 950px;
|
||||
margin-top: calc(3 * var(--spacing-unit));
|
||||
margin-bottom: calc(3 * var(--spacing-unit));
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
import React from "react";
|
||||
import { createRenderer } from "react-test-renderer/shallow";
|
||||
|
||||
import Community from "./Community";
|
||||
|
||||
it("renders correctly", () => {
|
||||
const renderer = createRenderer();
|
||||
renderer.render(<Community />);
|
||||
expect(renderer.getRenderOutput()).toMatchSnapshot();
|
||||
});
|
||||
@@ -1,11 +1,19 @@
|
||||
import React, { StatelessComponent } from "react";
|
||||
|
||||
import MainLayout from "talk-admin/components/MainLayout";
|
||||
import { Typography } from "talk-ui/components";
|
||||
import { PropTypesOf } from "talk-framework/types";
|
||||
|
||||
const Community: StatelessComponent = () => (
|
||||
<MainLayout>
|
||||
<Typography variant="heading3">Community</Typography>
|
||||
import UserTableContainer from "../containers/UserTableContainer";
|
||||
|
||||
import styles from "./Community.css";
|
||||
|
||||
interface Props {
|
||||
query: PropTypesOf<typeof UserTableContainer>["query"];
|
||||
}
|
||||
|
||||
const Community: StatelessComponent<Props> = props => (
|
||||
<MainLayout className={styles.root} data-testid="community-container">
|
||||
<UserTableContainer query={props.query} />
|
||||
</MainLayout>
|
||||
);
|
||||
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
.root {
|
||||
font-size: calc(18rem / var(--rem-base));
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { Localized } from "fluent-react/compat";
|
||||
import React, { StatelessComponent } from "react";
|
||||
|
||||
import { Typography } from "talk-ui/components";
|
||||
|
||||
import styles from "./EmptyMessage.css";
|
||||
|
||||
const EmptyMessage: StatelessComponent = props => (
|
||||
<Localized id="community-emptyMessage">
|
||||
<Typography className={styles.root} variant="bodyCopyBold" align="center">
|
||||
We could not find anyone in your community matching your criteria.
|
||||
</Typography>
|
||||
</Localized>
|
||||
);
|
||||
|
||||
export default EmptyMessage;
|
||||
@@ -0,0 +1,10 @@
|
||||
import { Localized } from "fluent-react/compat";
|
||||
import React, { StatelessComponent } from "react";
|
||||
|
||||
const NotAvailable: StatelessComponent = props => (
|
||||
<Localized id="community-notAvailable">
|
||||
<span>Not available</span>
|
||||
</Localized>
|
||||
);
|
||||
|
||||
export default NotAvailable;
|
||||
@@ -0,0 +1,5 @@
|
||||
.button {
|
||||
width: 100%;
|
||||
justify-content: space-between;
|
||||
padding: 0;
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import { Localized } from "fluent-react/compat";
|
||||
import React, { StatelessComponent } from "react";
|
||||
|
||||
import TranslatedRole from "talk-admin/components/TranslatedRole";
|
||||
import { GQLUSER_ROLE, GQLUSER_ROLE_RL } from "talk-framework/schema";
|
||||
import {
|
||||
Button,
|
||||
ButtonIcon,
|
||||
ClickOutside,
|
||||
Dropdown,
|
||||
DropdownButton,
|
||||
Popover,
|
||||
} from "talk-ui/components";
|
||||
|
||||
import styles from "./RoleChange.css";
|
||||
import RoleText from "./RoleText";
|
||||
|
||||
interface Props {
|
||||
onChangeRole: (role: GQLUSER_ROLE_RL) => void;
|
||||
role: GQLUSER_ROLE_RL;
|
||||
}
|
||||
|
||||
const RoleChange: StatelessComponent<Props> = props => (
|
||||
<Localized id="community-role-popover" attrs={{ description: true }}>
|
||||
<Popover
|
||||
id="userMenu"
|
||||
placement="bottom-start"
|
||||
description="A dropdown to change the user role"
|
||||
body={({ toggleVisibility }) => (
|
||||
<ClickOutside onClickOutside={toggleVisibility}>
|
||||
<Dropdown>
|
||||
{Object.keys(GQLUSER_ROLE).map((r: GQLUSER_ROLE_RL) => (
|
||||
<TranslatedRole
|
||||
key={r}
|
||||
container={
|
||||
<DropdownButton
|
||||
onClick={() => {
|
||||
props.onChangeRole(r);
|
||||
toggleVisibility();
|
||||
}}
|
||||
>
|
||||
dummy
|
||||
</DropdownButton>
|
||||
}
|
||||
>
|
||||
{r}
|
||||
</TranslatedRole>
|
||||
))}
|
||||
</Dropdown>
|
||||
</ClickOutside>
|
||||
)}
|
||||
>
|
||||
{({ toggleVisibility, ref, visible }) => (
|
||||
<Localized
|
||||
id="community-changeRoleButton"
|
||||
attrs={{ "aria-label": true }}
|
||||
>
|
||||
<Button
|
||||
aria-label="Change role"
|
||||
className={styles.button}
|
||||
onClick={toggleVisibility}
|
||||
ref={ref}
|
||||
variant="regular"
|
||||
size="small"
|
||||
>
|
||||
<RoleText>{props.role}</RoleText>
|
||||
{
|
||||
<ButtonIcon size="lg">
|
||||
{visible ? "arrow_drop_up" : "arrow_drop_down"}
|
||||
</ButtonIcon>
|
||||
}
|
||||
</Button>
|
||||
</Localized>
|
||||
)}
|
||||
</Popover>
|
||||
</Localized>
|
||||
);
|
||||
|
||||
export default RoleChange;
|
||||
@@ -0,0 +1,9 @@
|
||||
.root {
|
||||
font-weight: var(--font-weight-regular);
|
||||
color: var(--palette-grey-darkest);
|
||||
}
|
||||
|
||||
.commenter {
|
||||
font-weight: var(--font-weight-regular);
|
||||
color: var(--palette-grey-light);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import cn from "classnames";
|
||||
import React, { StatelessComponent } from "react";
|
||||
|
||||
import TranslatedRole from "talk-admin/components/TranslatedRole";
|
||||
import { GQLUSER_ROLE } from "talk-framework/schema";
|
||||
import { PropTypesOf } from "talk-ui/types";
|
||||
|
||||
import styles from "./RoleText.css";
|
||||
|
||||
interface Props {
|
||||
children: PropTypesOf<typeof TranslatedRole>["children"];
|
||||
}
|
||||
|
||||
const RoleText: StatelessComponent<Props> = props => (
|
||||
<TranslatedRole
|
||||
container={
|
||||
<span
|
||||
className={cn(styles.root, {
|
||||
[styles.commenter]: props.children === GQLUSER_ROLE.COMMENTER,
|
||||
})}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{props.children}
|
||||
</TranslatedRole>
|
||||
);
|
||||
|
||||
export default RoleText;
|
||||
@@ -0,0 +1,38 @@
|
||||
import React, { StatelessComponent } from "react";
|
||||
|
||||
import { PropTypesOf } from "talk-framework/types";
|
||||
import { TableCell, TableRow, TextLink } from "talk-ui/components";
|
||||
|
||||
import RoleChangeContainer from "../containers/RoleChangeContainer";
|
||||
import NotAvailable from "./NotAvailable";
|
||||
import RoleText from "./RoleText";
|
||||
|
||||
interface Props {
|
||||
canChangeRole: boolean;
|
||||
userID: string;
|
||||
username: string | null;
|
||||
email: string | null;
|
||||
memberSince: string;
|
||||
role: PropTypesOf<typeof RoleChangeContainer>["role"];
|
||||
}
|
||||
|
||||
const UserRow: StatelessComponent<Props> = props => (
|
||||
<TableRow>
|
||||
<TableCell>{props.username || <NotAvailable />}</TableCell>
|
||||
<TableCell>
|
||||
{<TextLink href={`mailto:${props.email}`}>{props.email}</TextLink> || (
|
||||
<NotAvailable />
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>{props.memberSince}</TableCell>
|
||||
<TableCell>
|
||||
{props.canChangeRole ? (
|
||||
<RoleChangeContainer userID={props.userID} role={props.role} />
|
||||
) : (
|
||||
<RoleText>{props.role}</RoleText>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
|
||||
export default UserRow;
|
||||
@@ -0,0 +1,12 @@
|
||||
.usernameColumn {
|
||||
width: 25%;
|
||||
}
|
||||
.emailColumn {
|
||||
width: 40%;
|
||||
}
|
||||
.memberSinceColumn {
|
||||
width: 20%;
|
||||
}
|
||||
.roleColumn {
|
||||
width: 15%;
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import { Localized } from "fluent-react/compat";
|
||||
import React, { StatelessComponent } from "react";
|
||||
|
||||
import { PropTypesOf } from "talk-framework/types";
|
||||
|
||||
import AutoLoadMoreContainer from "talk-admin/containers/AutoLoadMoreContainer";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableRow,
|
||||
} from "talk-ui/components/Table";
|
||||
|
||||
import UserRowContainer from "../containers/UserRowContainer";
|
||||
|
||||
import { Flex, HorizontalGutter, Spinner } from "talk-ui/components";
|
||||
import EmptyMessage from "./EmptyMessage";
|
||||
|
||||
import styles from "./UserTable.css";
|
||||
|
||||
interface Props {
|
||||
viewer: PropTypesOf<typeof UserRowContainer>["viewer"] | null;
|
||||
users: Array<{ id: string } & PropTypesOf<typeof UserRowContainer>["user"]>;
|
||||
onLoadMore: () => void;
|
||||
hasMore: boolean;
|
||||
disableLoadMore: boolean;
|
||||
loading: boolean;
|
||||
}
|
||||
|
||||
const UserTable: StatelessComponent<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>
|
||||
</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">
|
||||
<AutoLoadMoreContainer
|
||||
disableLoadMore={props.disableLoadMore}
|
||||
onLoadMore={props.onLoadMore}
|
||||
/>
|
||||
</Flex>
|
||||
)}
|
||||
</HorizontalGutter>
|
||||
</>
|
||||
);
|
||||
|
||||
export default UserTable;
|
||||
@@ -0,0 +1,8 @@
|
||||
.legend {
|
||||
margin-bottom: 2px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.textField {
|
||||
height: 31px;
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import { Localized } from "fluent-react/compat";
|
||||
import React, { StatelessComponent } from "react";
|
||||
|
||||
import { GQLUSER_ROLE, GQLUSER_ROLE_RL } from "talk-framework/schema";
|
||||
import {
|
||||
FieldSet,
|
||||
Flex,
|
||||
OptGroup,
|
||||
Option,
|
||||
SelectField,
|
||||
TextField,
|
||||
Typography,
|
||||
} from "talk-ui/components";
|
||||
|
||||
import styles from "./UserTableFilter.css";
|
||||
|
||||
interface Props {
|
||||
roleFilter: GQLUSER_ROLE_RL | null;
|
||||
onSetRoleFilter: (role: GQLUSER_ROLE_RL) => void;
|
||||
}
|
||||
|
||||
const UserTableFilter: StatelessComponent<Props> = props => (
|
||||
<Flex itemGutter="double">
|
||||
<FieldSet>
|
||||
<Localized id="community-filter-search">
|
||||
<Typography
|
||||
container="legend"
|
||||
className={styles.legend}
|
||||
variant="bodyCopyBold"
|
||||
>
|
||||
Search
|
||||
</Typography>
|
||||
</Localized>
|
||||
<Localized
|
||||
id="community-filter-searchField"
|
||||
attrs={{ placeholder: true, "aria-label": true }}
|
||||
>
|
||||
<TextField
|
||||
classes={{ input: styles.textField }}
|
||||
placeholder="Search by username or email address..."
|
||||
aria-label="Search by username or email address"
|
||||
/>
|
||||
</Localized>
|
||||
</FieldSet>
|
||||
<FieldSet>
|
||||
<Localized id="community-filter-showMe">
|
||||
<Typography
|
||||
className={styles.legend}
|
||||
container="legend"
|
||||
variant="bodyCopyBold"
|
||||
>
|
||||
Show Me
|
||||
</Typography>
|
||||
</Localized>
|
||||
<Localized
|
||||
id="community-filter-roleSelectField"
|
||||
attrs={{ "aria-label": true }}
|
||||
>
|
||||
<SelectField
|
||||
aria-label="Search by role"
|
||||
value={props.roleFilter || ""}
|
||||
onChange={e => props.onSetRoleFilter(e.target.value as any)}
|
||||
>
|
||||
<Localized id="community-filter-everyone">
|
||||
<Option value="">Everyone</Option>
|
||||
</Localized>
|
||||
<Localized
|
||||
id="community-filter-optGroupAudience"
|
||||
attrs={{ label: true }}
|
||||
>
|
||||
<OptGroup label="Audience">
|
||||
<Localized id="role-plural-commenter">
|
||||
<Option value={GQLUSER_ROLE.COMMENTER}>Commenters</Option>
|
||||
</Localized>
|
||||
</OptGroup>
|
||||
</Localized>
|
||||
<Localized
|
||||
id="community-filter-optGroupOrganization"
|
||||
attrs={{ label: true }}
|
||||
>
|
||||
<OptGroup label="Organization">
|
||||
<Localized id="role-plural-admin">
|
||||
<Option value={GQLUSER_ROLE.ADMIN}>Admins</Option>
|
||||
</Localized>
|
||||
<Localized id="role-plural-moderator">
|
||||
<Option value={GQLUSER_ROLE.MODERATOR}>Moderators</Option>
|
||||
</Localized>
|
||||
<Localized id="role-plural-staff">
|
||||
<Option value={GQLUSER_ROLE.STAFF}>Staff</Option>
|
||||
</Localized>
|
||||
</OptGroup>
|
||||
</Localized>
|
||||
</SelectField>
|
||||
</Localized>
|
||||
</FieldSet>
|
||||
</Flex>
|
||||
);
|
||||
|
||||
export default UserTableFilter;
|
||||
-11
@@ -1,11 +0,0 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`renders correctly 1`] = `
|
||||
<MainLayout>
|
||||
<ForwardRef(forwardRef)
|
||||
variant="heading3"
|
||||
>
|
||||
Community
|
||||
</ForwardRef(forwardRef)>
|
||||
</MainLayout>
|
||||
`;
|
||||
@@ -0,0 +1,28 @@
|
||||
import { FormApi } from "final-form";
|
||||
import React, { StatelessComponent } from "react";
|
||||
import { graphql } from "react-relay";
|
||||
|
||||
import { CommunityContainerQueryResponse } from "talk-admin/__generated__/CommunityContainerQuery.graphql";
|
||||
import { withRouteConfig } from "talk-framework/lib/router";
|
||||
|
||||
import Community from "../components/Community";
|
||||
|
||||
interface Props {
|
||||
data: CommunityContainerQueryResponse | null;
|
||||
form: FormApi;
|
||||
}
|
||||
|
||||
const CommunityContainer: StatelessComponent<Props> = props => {
|
||||
return <Community query={props.data} />;
|
||||
};
|
||||
|
||||
const enhanced = withRouteConfig({
|
||||
query: graphql`
|
||||
query CommunityContainerQuery {
|
||||
...UserTableContainer_query
|
||||
}
|
||||
`,
|
||||
cacheConfig: { force: true },
|
||||
})(CommunityContainer);
|
||||
|
||||
export default enhanced;
|
||||
@@ -0,0 +1,32 @@
|
||||
import React, { StatelessComponent, useCallback } from "react";
|
||||
|
||||
import {
|
||||
UpdateUserRoleMutation,
|
||||
withUpdateUserRoleMutation,
|
||||
} from "talk-admin/mutations/UpdateUserRoleMutation";
|
||||
import { GQLUSER_ROLE_RL } from "talk-framework/schema";
|
||||
|
||||
import RoleChange from "../components/RoleChange";
|
||||
|
||||
interface Props {
|
||||
userID: string;
|
||||
role: GQLUSER_ROLE_RL;
|
||||
updateUserRole: UpdateUserRoleMutation;
|
||||
}
|
||||
|
||||
const RoleChangeContainer: StatelessComponent<Props> = props => {
|
||||
const handleOnChangeRole = useCallback(
|
||||
(role: GQLUSER_ROLE_RL) => {
|
||||
if (role === props.role) {
|
||||
return;
|
||||
}
|
||||
props.updateUserRole({ userID: props.userID, role });
|
||||
},
|
||||
[props.userID, props.role, props.updateUserRole]
|
||||
);
|
||||
return <RoleChange onChangeRole={handleOnChangeRole} role={props.role} />;
|
||||
};
|
||||
|
||||
const enhanced = withUpdateUserRoleMutation(RoleChangeContainer);
|
||||
|
||||
export default enhanced;
|
||||
@@ -0,0 +1,56 @@
|
||||
import React, { StatelessComponent } from "react";
|
||||
import { graphql } from "react-relay";
|
||||
|
||||
import { UserRowContainer_user as UserData } from "talk-admin/__generated__/UserRowContainer_user.graphql";
|
||||
import { UserRowContainer_viewer as ViewerData } from "talk-admin/__generated__/UserRowContainer_viewer.graphql";
|
||||
import { useTalkContext } from "talk-framework/lib/bootstrap";
|
||||
import { withFragmentContainer } from "talk-framework/lib/relay";
|
||||
|
||||
import { Ability, can } from "talk-admin/permissions";
|
||||
import UserRow from "../components/UserRow";
|
||||
|
||||
interface Props {
|
||||
user: UserData;
|
||||
viewer: ViewerData;
|
||||
}
|
||||
|
||||
const UserRowContainer: StatelessComponent<Props> = props => {
|
||||
const { locales } = useTalkContext();
|
||||
return (
|
||||
<UserRow
|
||||
userID={props.user.id}
|
||||
username={props.user.username!}
|
||||
email={props.user.email}
|
||||
memberSince={new Intl.DateTimeFormat(locales, {
|
||||
day: "2-digit",
|
||||
month: "2-digit",
|
||||
year: "2-digit",
|
||||
}).format(new Date(props.user.createdAt))}
|
||||
role={props.user.role}
|
||||
canChangeRole={
|
||||
props.viewer.id !== props.user.id &&
|
||||
can(props.viewer, Ability.CHANGE_ROLE)
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const enhanced = withFragmentContainer<Props>({
|
||||
viewer: graphql`
|
||||
fragment UserRowContainer_viewer on User {
|
||||
id
|
||||
role
|
||||
}
|
||||
`,
|
||||
user: graphql`
|
||||
fragment UserRowContainer_user on User {
|
||||
id
|
||||
username
|
||||
email
|
||||
createdAt
|
||||
role
|
||||
}
|
||||
`,
|
||||
})(UserRowContainer);
|
||||
|
||||
export default enhanced;
|
||||
@@ -0,0 +1,157 @@
|
||||
import React, { StatelessComponent, useCallback, useState } from "react";
|
||||
import { graphql, RelayPaginationProp } from "react-relay";
|
||||
|
||||
import { UserTableContainer_query as QueryData } from "talk-admin/__generated__/UserTableContainer_query.graphql";
|
||||
import { UserTableContainerPaginationQueryVariables } from "talk-admin/__generated__/UserTableContainerPaginationQuery.graphql";
|
||||
import { IntersectionProvider } from "talk-framework/lib/intersection";
|
||||
import { withPaginationContainer } from "talk-framework/lib/relay";
|
||||
import { GQLUSER_ROLE_RL } from "talk-framework/schema";
|
||||
|
||||
import { HorizontalGutter } from "talk-ui/components";
|
||||
import UserTable from "../components/UserTable";
|
||||
import UserTableFilter from "../components/UserTableFilter";
|
||||
|
||||
interface Props {
|
||||
query: QueryData | null;
|
||||
relay: RelayPaginationProp;
|
||||
}
|
||||
|
||||
const UserTableContainer: StatelessComponent<Props> = props => {
|
||||
const users = props.query
|
||||
? props.query.users.edges.map(edge => edge.node)
|
||||
: [];
|
||||
const [disableLoadMore, setDisableLoadMore] = useState(false);
|
||||
const [refetching, setRefetching] = useState(false);
|
||||
const [roleFilter, setRoleFilter] = useState<GQLUSER_ROLE_RL | null>(null);
|
||||
|
||||
const setRoleFilterAndRefetch = useCallback(
|
||||
(role: GQLUSER_ROLE_RL | null) => {
|
||||
setRoleFilter(role);
|
||||
setRefetching(true);
|
||||
props.relay.refetchConnection(
|
||||
10,
|
||||
error => {
|
||||
setRefetching(false);
|
||||
if (error) {
|
||||
// tslint:disable-next-line:no-console
|
||||
console.error(error);
|
||||
}
|
||||
},
|
||||
{
|
||||
roleFilter: role,
|
||||
}
|
||||
);
|
||||
},
|
||||
[roleFilter, props.relay]
|
||||
);
|
||||
|
||||
const loadMore = useCallback(
|
||||
() => {
|
||||
if (!props.relay.hasMore() || props.relay.isLoading()) {
|
||||
return;
|
||||
}
|
||||
setDisableLoadMore(true);
|
||||
props.relay.loadMore(
|
||||
10, // Fetch the next 10 feed items
|
||||
error => {
|
||||
setDisableLoadMore(false);
|
||||
if (error) {
|
||||
// tslint:disable-next-line:no-console
|
||||
console.error(error);
|
||||
}
|
||||
}
|
||||
);
|
||||
},
|
||||
[props.relay]
|
||||
);
|
||||
|
||||
return (
|
||||
<IntersectionProvider>
|
||||
<HorizontalGutter size="double">
|
||||
<UserTableFilter
|
||||
onSetRoleFilter={role => setRoleFilterAndRefetch(role || null)}
|
||||
roleFilter={roleFilter}
|
||||
/>
|
||||
<UserTable
|
||||
viewer={props.query && props.query.viewer}
|
||||
loading={!props.query || refetching}
|
||||
users={users}
|
||||
onLoadMore={loadMore}
|
||||
hasMore={!refetching && props.relay.hasMore()}
|
||||
disableLoadMore={disableLoadMore}
|
||||
/>
|
||||
</HorizontalGutter>
|
||||
</IntersectionProvider>
|
||||
);
|
||||
};
|
||||
|
||||
// TODO: (cvle) This should be autogenerated.
|
||||
interface FragmentVariables {
|
||||
count: number;
|
||||
cursor?: string;
|
||||
roleFilter: GQLUSER_ROLE_RL | null;
|
||||
}
|
||||
|
||||
const enhanced = withPaginationContainer<
|
||||
Props,
|
||||
UserTableContainerPaginationQueryVariables,
|
||||
FragmentVariables
|
||||
>(
|
||||
{
|
||||
query: graphql`
|
||||
fragment UserTableContainer_query on Query
|
||||
@argumentDefinitions(
|
||||
count: { type: "Int!", defaultValue: 10 }
|
||||
cursor: { type: "Cursor" }
|
||||
roleFilter: { type: "USER_ROLE" }
|
||||
) {
|
||||
viewer {
|
||||
...UserRowContainer_viewer
|
||||
}
|
||||
users(first: $count, after: $cursor, role: $roleFilter)
|
||||
@connection(key: "UserTable_users") {
|
||||
edges {
|
||||
node {
|
||||
id
|
||||
...UserRowContainer_user
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
},
|
||||
{
|
||||
direction: "forward",
|
||||
getConnectionFromProps(props) {
|
||||
return props.query && props.query.users;
|
||||
},
|
||||
// This is also the default implementation of `getFragmentVariables` if it isn't provided.
|
||||
getFragmentVariables(prevVars, totalCount) {
|
||||
return {
|
||||
...prevVars,
|
||||
count: totalCount,
|
||||
};
|
||||
},
|
||||
getVariables(props, { count, cursor }, fragmentVariables) {
|
||||
return {
|
||||
count,
|
||||
cursor,
|
||||
roleFilter: fragmentVariables.roleFilter,
|
||||
};
|
||||
},
|
||||
query: graphql`
|
||||
# Pagination query to be fetched upon calling 'loadMore'.
|
||||
# Notice that we re-use our fragment, and the shape of this query matches our fragment spec.
|
||||
query UserTableContainerPaginationQuery(
|
||||
$count: Int!
|
||||
$cursor: Cursor
|
||||
$roleFilter: USER_ROLE
|
||||
) {
|
||||
...UserTableContainer_query
|
||||
@arguments(count: $count, cursor: $cursor, roleFilter: $roleFilter)
|
||||
}
|
||||
`,
|
||||
}
|
||||
)(UserTableContainer);
|
||||
|
||||
export default enhanced;
|
||||
@@ -1,10 +1,10 @@
|
||||
import React, { StatelessComponent } from "react";
|
||||
import { CSSTransition, TransitionGroup } from "react-transition-group";
|
||||
|
||||
import AutoLoadMoreContainer from "talk-admin/containers/AutoLoadMoreContainer";
|
||||
import { Flex, HorizontalGutter } from "talk-ui/components";
|
||||
import { PropTypesOf } from "talk-ui/types";
|
||||
|
||||
import AutoLoadMoreContainer from "../containers/AutoLoadMoreContainer";
|
||||
import ModerateCardContainer from "../containers/ModerateCardContainer";
|
||||
|
||||
import styles from "./Queue.css";
|
||||
|
||||
@@ -49,10 +49,12 @@ it("redirect to redirectPath when already logged in", async () => {
|
||||
initLocalState: localRecord => {
|
||||
localRecord.setValue(true, "loggedIn");
|
||||
localRecord.setValue(createAccessToken(), "accessToken");
|
||||
localRecord.setValue("/admin/community", "redirectPath");
|
||||
localRecord.setValue("/admin/moderate/pending", "redirectPath");
|
||||
},
|
||||
});
|
||||
await wait(() =>
|
||||
expect(window.location.toString()).toBe("http://localhost/admin/community")
|
||||
expect(window.location.toString()).toBe(
|
||||
"http://localhost/admin/moderate/pending"
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,388 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`renders community 1`] = `
|
||||
<div
|
||||
className="MainLayout-root Community-root"
|
||||
data-testid="community-container"
|
||||
>
|
||||
<div
|
||||
className="HorizontalGutter-root HorizontalGutter-double"
|
||||
>
|
||||
<div
|
||||
className="Flex-root Flex-flex Flex-doubleItemGutter"
|
||||
>
|
||||
<fieldset
|
||||
className="FieldSet-root"
|
||||
>
|
||||
<legend
|
||||
className="Typography-root Typography-bodyCopyBold Typography-colorTextPrimary UserTableFilter-legend"
|
||||
>
|
||||
Search
|
||||
</legend>
|
||||
<div
|
||||
className="TextField-root"
|
||||
>
|
||||
<input
|
||||
aria-label="Search by username or email address"
|
||||
className="TextField-input UserTableFilter-textField TextField-colorRegular"
|
||||
placeholder="Search by username or email address..."
|
||||
type="text"
|
||||
/>
|
||||
</div>
|
||||
</fieldset>
|
||||
<fieldset
|
||||
className="FieldSet-root"
|
||||
>
|
||||
<legend
|
||||
className="Typography-root Typography-bodyCopyBold Typography-colorTextPrimary UserTableFilter-legend"
|
||||
>
|
||||
Show Me
|
||||
</legend>
|
||||
<span
|
||||
className="SelectField-root"
|
||||
>
|
||||
<select
|
||||
aria-label="Search by role"
|
||||
className="SelectField-select"
|
||||
onBlur={[Function]}
|
||||
onChange={[Function]}
|
||||
onFocus={[Function]}
|
||||
value=""
|
||||
>
|
||||
<option
|
||||
value=""
|
||||
>
|
||||
Everyone
|
||||
</option>
|
||||
<optgroup
|
||||
label="Audience"
|
||||
>
|
||||
<option
|
||||
value="COMMENTER"
|
||||
>
|
||||
Commenters
|
||||
</option>
|
||||
</optgroup>
|
||||
<optgroup
|
||||
label="Organization"
|
||||
>
|
||||
<option
|
||||
value="ADMIN"
|
||||
>
|
||||
Admins
|
||||
</option>
|
||||
<option
|
||||
value="MODERATOR"
|
||||
>
|
||||
Moderators
|
||||
</option>
|
||||
<option
|
||||
value="STAFF"
|
||||
>
|
||||
Staff
|
||||
</option>
|
||||
</optgroup>
|
||||
</select>
|
||||
<span
|
||||
aria-hidden={true}
|
||||
className="SelectField-afterWrapper"
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="Icon-root Icon-sm"
|
||||
>
|
||||
expand_more
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
</fieldset>
|
||||
</div>
|
||||
<div
|
||||
className="HorizontalGutter-root HorizontalGutter-double"
|
||||
>
|
||||
<table
|
||||
className="Table-root Table-fullWidth"
|
||||
>
|
||||
<thead
|
||||
className="TableHead-root"
|
||||
>
|
||||
<tr
|
||||
className="TableRow-root"
|
||||
>
|
||||
<th
|
||||
className="TableCell-root UserTable-usernameColumn TableCell-header"
|
||||
>
|
||||
Username
|
||||
</th>
|
||||
<th
|
||||
className="TableCell-root UserTable-emailColumn TableCell-header"
|
||||
>
|
||||
Email
|
||||
</th>
|
||||
<th
|
||||
className="TableCell-root UserTable-memberSinceColumn TableCell-header"
|
||||
>
|
||||
Member Since
|
||||
</th>
|
||||
<th
|
||||
className="TableCell-root UserTable-roleColumn TableCell-header"
|
||||
>
|
||||
Role
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody
|
||||
className="TableBody-root"
|
||||
>
|
||||
<tr
|
||||
className="TableRow-root TableRow-body"
|
||||
>
|
||||
<td
|
||||
className="TableCell-root TableCell-body"
|
||||
>
|
||||
Markus
|
||||
</td>
|
||||
<td
|
||||
className="TableCell-root TableCell-body"
|
||||
>
|
||||
<a
|
||||
className="TextLink-root"
|
||||
href="mailto:markus@test.com"
|
||||
>
|
||||
markus@test.com
|
||||
</a>
|
||||
</td>
|
||||
<td
|
||||
className="TableCell-root TableCell-body"
|
||||
>
|
||||
07/06/18
|
||||
</td>
|
||||
<td
|
||||
className="TableCell-root TableCell-body"
|
||||
>
|
||||
<span
|
||||
className="RoleText-root"
|
||||
>
|
||||
Admin
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr
|
||||
className="TableRow-root TableRow-body"
|
||||
>
|
||||
<td
|
||||
className="TableCell-root TableCell-body"
|
||||
>
|
||||
Lukas
|
||||
</td>
|
||||
<td
|
||||
className="TableCell-root TableCell-body"
|
||||
>
|
||||
<a
|
||||
className="TextLink-root"
|
||||
href="mailto:lukas@test.com"
|
||||
>
|
||||
lukas@test.com
|
||||
</a>
|
||||
</td>
|
||||
<td
|
||||
className="TableCell-root TableCell-body"
|
||||
>
|
||||
07/06/18
|
||||
</td>
|
||||
<td
|
||||
className="TableCell-root TableCell-body"
|
||||
>
|
||||
<div
|
||||
className="Popover-root"
|
||||
>
|
||||
<button
|
||||
aria-label="Change role"
|
||||
className="BaseButton-root Button-root RoleChange-button Button-sizeSmall Button-colorRegular Button-variantRegular"
|
||||
onBlur={[Function]}
|
||||
onClick={[Function]}
|
||||
onFocus={[Function]}
|
||||
onMouseOut={[Function]}
|
||||
onMouseOver={[Function]}
|
||||
onTouchEnd={[Function]}
|
||||
type="button"
|
||||
>
|
||||
<span
|
||||
className="RoleText-root"
|
||||
>
|
||||
Moderator
|
||||
</span>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="Icon-root Icon-lg ButtonIcon-root"
|
||||
>
|
||||
arrow_drop_down
|
||||
</span>
|
||||
</button>
|
||||
<div
|
||||
aria-hidden={true}
|
||||
aria-labelledby="userMenu-ariainfo"
|
||||
id="userMenu"
|
||||
role="popup"
|
||||
>
|
||||
<div
|
||||
className="AriaInfo-root"
|
||||
id="userMenu-ariainfo"
|
||||
>
|
||||
A dropdown to change the user role
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`renders empty community 1`] = `
|
||||
<div
|
||||
className="MainLayout-root Community-root"
|
||||
data-testid="community-container"
|
||||
>
|
||||
<div
|
||||
className="HorizontalGutter-root HorizontalGutter-double"
|
||||
>
|
||||
<div
|
||||
className="Flex-root Flex-flex Flex-doubleItemGutter"
|
||||
>
|
||||
<fieldset
|
||||
className="FieldSet-root"
|
||||
>
|
||||
<legend
|
||||
className="Typography-root Typography-bodyCopyBold Typography-colorTextPrimary UserTableFilter-legend"
|
||||
>
|
||||
Search
|
||||
</legend>
|
||||
<div
|
||||
className="TextField-root"
|
||||
>
|
||||
<input
|
||||
aria-label="Search by username or email address"
|
||||
className="TextField-input UserTableFilter-textField TextField-colorRegular"
|
||||
placeholder="Search by username or email address..."
|
||||
type="text"
|
||||
/>
|
||||
</div>
|
||||
</fieldset>
|
||||
<fieldset
|
||||
className="FieldSet-root"
|
||||
>
|
||||
<legend
|
||||
className="Typography-root Typography-bodyCopyBold Typography-colorTextPrimary UserTableFilter-legend"
|
||||
>
|
||||
Show Me
|
||||
</legend>
|
||||
<span
|
||||
className="SelectField-root"
|
||||
>
|
||||
<select
|
||||
aria-label="Search by role"
|
||||
className="SelectField-select"
|
||||
onBlur={[Function]}
|
||||
onChange={[Function]}
|
||||
onFocus={[Function]}
|
||||
value=""
|
||||
>
|
||||
<option
|
||||
value=""
|
||||
>
|
||||
Everyone
|
||||
</option>
|
||||
<optgroup
|
||||
label="Audience"
|
||||
>
|
||||
<option
|
||||
value="COMMENTER"
|
||||
>
|
||||
Commenters
|
||||
</option>
|
||||
</optgroup>
|
||||
<optgroup
|
||||
label="Organization"
|
||||
>
|
||||
<option
|
||||
value="ADMIN"
|
||||
>
|
||||
Admins
|
||||
</option>
|
||||
<option
|
||||
value="MODERATOR"
|
||||
>
|
||||
Moderators
|
||||
</option>
|
||||
<option
|
||||
value="STAFF"
|
||||
>
|
||||
Staff
|
||||
</option>
|
||||
</optgroup>
|
||||
</select>
|
||||
<span
|
||||
aria-hidden={true}
|
||||
className="SelectField-afterWrapper"
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="Icon-root Icon-sm"
|
||||
>
|
||||
expand_more
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
</fieldset>
|
||||
</div>
|
||||
<div
|
||||
className="HorizontalGutter-root HorizontalGutter-double"
|
||||
>
|
||||
<table
|
||||
className="Table-root Table-fullWidth"
|
||||
>
|
||||
<thead
|
||||
className="TableHead-root"
|
||||
>
|
||||
<tr
|
||||
className="TableRow-root"
|
||||
>
|
||||
<th
|
||||
className="TableCell-root UserTable-usernameColumn TableCell-header"
|
||||
>
|
||||
Username
|
||||
</th>
|
||||
<th
|
||||
className="TableCell-root UserTable-emailColumn TableCell-header"
|
||||
>
|
||||
Email
|
||||
</th>
|
||||
<th
|
||||
className="TableCell-root UserTable-memberSinceColumn TableCell-header"
|
||||
>
|
||||
Member Since
|
||||
</th>
|
||||
<th
|
||||
className="TableCell-root UserTable-roleColumn TableCell-header"
|
||||
>
|
||||
Role
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody
|
||||
className="TableBody-root"
|
||||
/>
|
||||
</table>
|
||||
<p
|
||||
className="Typography-root Typography-bodyCopyBold Typography-colorTextPrimary Typography-alignCenter EmptyMessage-root"
|
||||
>
|
||||
We could not find anyone in your community matching your criteria.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
@@ -0,0 +1,190 @@
|
||||
import { get, merge } from "lodash";
|
||||
import TestRenderer from "react-test-renderer";
|
||||
import sinon from "sinon";
|
||||
|
||||
import {
|
||||
createSinonStub,
|
||||
replaceHistoryLocation,
|
||||
waitForElement,
|
||||
waitUntilThrow,
|
||||
within,
|
||||
} from "talk-framework/testHelpers";
|
||||
|
||||
import create from "../create";
|
||||
import {
|
||||
communityUsers,
|
||||
emptyCommunityUsers,
|
||||
settings,
|
||||
users,
|
||||
} from "../fixtures";
|
||||
|
||||
beforeEach(async () => {
|
||||
replaceHistoryLocation("http://localhost/admin/community");
|
||||
});
|
||||
|
||||
const createTestRenderer = async (resolver: any = {}) => {
|
||||
const resolvers = {
|
||||
...resolver,
|
||||
Query: {
|
||||
settings: sinon
|
||||
.stub()
|
||||
.returns(merge({}, settings, get(resolver, "Query.settings"))),
|
||||
users: sinon.stub().callsFake((_, data) => {
|
||||
expectAndFail(data.role).toBeFalsy();
|
||||
return communityUsers;
|
||||
}),
|
||||
viewer: sinon.stub().returns(users[0]),
|
||||
...resolver.Query,
|
||||
},
|
||||
};
|
||||
const { testRenderer } = create({
|
||||
// Set this to true, to see graphql responses.
|
||||
logNetwork: false,
|
||||
resolvers,
|
||||
initLocalState: localRecord => {
|
||||
localRecord.setValue(true, "loggedIn");
|
||||
},
|
||||
});
|
||||
const container = await waitForElement(() =>
|
||||
within(testRenderer.root).getByTestID("community-container")
|
||||
);
|
||||
return { testRenderer, container };
|
||||
};
|
||||
|
||||
it("renders community", async () => {
|
||||
const { container } = await createTestRenderer();
|
||||
expect(within(container).toJSON()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("renders empty community", async () => {
|
||||
const { container } = await createTestRenderer({
|
||||
Query: {
|
||||
users: sinon.stub().returns(emptyCommunityUsers),
|
||||
},
|
||||
});
|
||||
expect(within(container).toJSON()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("filter by role", async () => {
|
||||
const { container } = await createTestRenderer({
|
||||
Query: {
|
||||
users: createSinonStub(
|
||||
s => s.onFirstCall().returns(communityUsers),
|
||||
s =>
|
||||
s.onSecondCall().callsFake((_, data) => {
|
||||
expectAndFail(data.role).toBe("COMMENTER");
|
||||
return emptyCommunityUsers;
|
||||
})
|
||||
),
|
||||
},
|
||||
});
|
||||
|
||||
const selectField = within(container).getByLabelText("Search by role");
|
||||
const commentersOption = within(selectField).getByText("Commenters");
|
||||
|
||||
TestRenderer.act(() => {
|
||||
selectField.props.onChange({
|
||||
target: { value: commentersOption.props.value.toString() },
|
||||
});
|
||||
// TODO: Fix act warnings until await Promise.resolve();
|
||||
// or whatever comes out at https://github.com/facebook/react/issues/14769
|
||||
});
|
||||
|
||||
await waitForElement(() =>
|
||||
within(container).getByText("We could not find anyone", { exact: false })
|
||||
);
|
||||
});
|
||||
|
||||
it("can't change viewer role", async () => {
|
||||
const viewer = users[0];
|
||||
const { container } = await createTestRenderer();
|
||||
|
||||
const viewerRow = within(container).getByText(viewer.username, {
|
||||
selector: "tr",
|
||||
});
|
||||
expect(() => within(viewerRow).getByLabelText("Change role")).toThrow();
|
||||
});
|
||||
|
||||
it("change user role", async () => {
|
||||
const user = users[1];
|
||||
const updateUserRole = sinon.stub().callsFake((_: any, data: any) => {
|
||||
expectAndFail(data.input).toMatchObject({
|
||||
userID: user.id,
|
||||
role: "STAFF",
|
||||
});
|
||||
const userRecord = merge({}, user, { role: data.input.role });
|
||||
return {
|
||||
user: userRecord,
|
||||
clientMutationId: data.input.clientMutationId,
|
||||
};
|
||||
});
|
||||
|
||||
const { container } = await createTestRenderer({
|
||||
Mutation: { updateUserRole },
|
||||
});
|
||||
|
||||
const userRow = within(container).getByText(user.username, {
|
||||
selector: "tr",
|
||||
});
|
||||
|
||||
TestRenderer.act(() => {
|
||||
within(userRow)
|
||||
.getByLabelText("Change role")
|
||||
.props.onClick();
|
||||
});
|
||||
|
||||
const popup = within(userRow).getByLabelText(
|
||||
"A dropdown to change the user role"
|
||||
);
|
||||
|
||||
TestRenderer.act(() => {
|
||||
within(popup)
|
||||
.getByText("Staff")
|
||||
.props.onClick();
|
||||
});
|
||||
|
||||
within(userRow).getByText("Staff");
|
||||
expect(updateUserRole.called).toBe(true);
|
||||
});
|
||||
|
||||
it("can't change role as a moderator", async () => {
|
||||
const viewer = users[1];
|
||||
const { container } = await createTestRenderer({
|
||||
Query: {
|
||||
viewer: sinon.stub().returns(viewer),
|
||||
},
|
||||
});
|
||||
expect(() => within(container).getByLabelText("Change role")).toThrow();
|
||||
});
|
||||
|
||||
it("load more", async () => {
|
||||
const { container } = await createTestRenderer({
|
||||
Query: {
|
||||
users: createSinonStub(
|
||||
s =>
|
||||
s.onFirstCall().returns({
|
||||
edges: [
|
||||
{ node: users[0], cursor: users[0].createdAt },
|
||||
{ node: users[1], cursor: users[1].createdAt },
|
||||
],
|
||||
pageInfo: { endCursor: users[1].createdAt, hasNextPage: true },
|
||||
}),
|
||||
s =>
|
||||
s.onSecondCall().returns({
|
||||
edges: [{ node: users[2], cursor: users[2].createdAt }],
|
||||
pageInfo: { endCursor: users[2].createdAt, hasNextPage: false },
|
||||
})
|
||||
),
|
||||
},
|
||||
});
|
||||
const loadMore = within(container).getByText("Load More");
|
||||
TestRenderer.act(() => {
|
||||
loadMore.props.onClick();
|
||||
});
|
||||
|
||||
// Wait for load more to disappear.
|
||||
await waitUntilThrow(() => within(container).getByText("Load More"));
|
||||
|
||||
// Make sure third user was added.
|
||||
within(container).getByText(users[2].username);
|
||||
});
|
||||
@@ -1,7 +1,7 @@
|
||||
import { EventEmitter2 } from "eventemitter2";
|
||||
import { IResolvers } from "graphql-tools";
|
||||
import React from "react";
|
||||
import TestRenderer from "react-test-renderer";
|
||||
import TestRenderer, { ReactTestRenderer } from "react-test-renderer";
|
||||
import { Environment, RecordProxy, RecordSourceProxy } from "relay-runtime";
|
||||
|
||||
import EntryContainer from "talk-admin/containers/EntryContainer";
|
||||
@@ -41,6 +41,7 @@ export default function create(params: CreateParams) {
|
||||
|
||||
const context: TalkContext = {
|
||||
relayEnvironment: environment,
|
||||
locales: ["en-US"],
|
||||
localeBundles: [createFluentBundle()],
|
||||
localStorage: createPromisifiedStorage(),
|
||||
sessionStorage: createPromisifiedStorage(),
|
||||
@@ -52,12 +53,15 @@ export default function create(params: CreateParams) {
|
||||
clearSession: () => Promise.resolve(),
|
||||
};
|
||||
|
||||
const testRenderer = TestRenderer.create(
|
||||
<TalkContextProvider value={context}>
|
||||
<EntryContainer />
|
||||
</TalkContextProvider>,
|
||||
{ createNodeMock }
|
||||
);
|
||||
let testRenderer: ReactTestRenderer;
|
||||
TestRenderer.act(() => {
|
||||
testRenderer = TestRenderer.create(
|
||||
<TalkContextProvider value={context}>
|
||||
<EntryContainer />
|
||||
</TalkContextProvider>,
|
||||
{ createNodeMock }
|
||||
);
|
||||
});
|
||||
|
||||
return { context, testRenderer };
|
||||
return { context, testRenderer: testRenderer! };
|
||||
}
|
||||
|
||||
@@ -246,27 +246,32 @@ export const moderationActions = [
|
||||
},
|
||||
];
|
||||
|
||||
export const baseUser = {
|
||||
profiles: [{ __typename: "LocalProfile" }],
|
||||
createdAt: "2018-07-06T18:24:00.000Z",
|
||||
};
|
||||
|
||||
export const users = [
|
||||
{
|
||||
...baseUser,
|
||||
id: "user-0",
|
||||
username: "Markus",
|
||||
email: "markus@test.com",
|
||||
role: "ADMIN",
|
||||
profiles: [{ __typename: "LocalProfile" }],
|
||||
},
|
||||
{
|
||||
...baseUser,
|
||||
id: "user-1",
|
||||
username: "Lukas",
|
||||
email: "lukas@test.com",
|
||||
role: "MODERATOR",
|
||||
profiles: [{ __typename: "LocalProfile" }],
|
||||
},
|
||||
{
|
||||
...baseUser,
|
||||
id: "user-2",
|
||||
username: "Isabelle",
|
||||
email: "isabelle@test.com",
|
||||
role: "COMMENTER",
|
||||
profiles: [{ __typename: "LocalProfile" }],
|
||||
},
|
||||
];
|
||||
|
||||
@@ -372,3 +377,16 @@ export const emptyRejectedComments = {
|
||||
edges: [],
|
||||
pageInfo: { endCursor: null, hasNextPage: false },
|
||||
};
|
||||
|
||||
export const communityUsers = {
|
||||
edges: [
|
||||
{ node: users[0], cursor: users[0].createdAt },
|
||||
{ node: users[1], cursor: users[1].createdAt },
|
||||
],
|
||||
pageInfo: { endCursor: null, hasNextPage: false },
|
||||
};
|
||||
|
||||
export const emptyCommunityUsers = {
|
||||
edges: [],
|
||||
pageInfo: { endCursor: null, hasNextPage: false },
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user