[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:
Kiwi
2019-03-22 20:13:11 +00:00
committed by Wyatt Johnson
parent e70f6f5c7f
commit 538e1fca9d
76 changed files with 2016 additions and 179 deletions
+29 -58
View File
@@ -1173,18 +1173,18 @@
}
},
"@babel/plugin-transform-regenerator": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-regenerator/-/plugin-transform-regenerator-7.0.0.tgz",
"integrity": "sha512-sj2qzsEx8KDVv1QuJc/dEfilkg3RRPvPYx/VnKLtItVQRWt1Wqf5eVCOLZm29CiGFfYYsA3VPjfizTCV0S0Dlw==",
"version": "7.4.0",
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-regenerator/-/plugin-transform-regenerator-7.4.0.tgz",
"integrity": "sha512-SZ+CgL4F0wm4npojPU6swo/cK4FcbLgxLd4cWpHaNXY/NJ2dpahODCqBbAwb2rDmVszVb3SSjnk9/vik3AYdBw==",
"dev": true,
"requires": {
"regenerator-transform": "^0.13.3"
"regenerator-transform": "^0.13.4"
},
"dependencies": {
"regenerator-transform": {
"version": "0.13.3",
"resolved": "https://registry.npmjs.org/regenerator-transform/-/regenerator-transform-0.13.3.tgz",
"integrity": "sha512-5ipTrZFSq5vU2YoGoww4uaRVAK4wyYC4TSICibbfEPOruUu8FFP7ErV0BjmbIOEpn3O/k9na9UEdYR/3m7N6uA==",
"version": "0.13.4",
"resolved": "https://registry.npmjs.org/regenerator-transform/-/regenerator-transform-0.13.4.tgz",
"integrity": "sha512-T0QMBjK3J0MtxjPmdIMXm72Wvj2Abb0Bd4HADdfijwMdoIsyQZ6fWC7kDFhk2YinBBEMZDL7Y7wh0J1sGx3S4A==",
"dev": true,
"requires": {
"private": "^0.1.6"
@@ -1192,45 +1192,6 @@
}
}
},
"@babel/plugin-transform-runtime": {
"version": "7.0.0-beta.42",
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-runtime/-/plugin-transform-runtime-7.0.0-beta.42.tgz",
"integrity": "sha512-4LcNdjMvKzCwK/eqfbUiXFAZht8OTx0Gv2Ok42o+zhb8DvNUaYUndgW9AU4Q6nbpxzw2vTWNUXSIRvdGsxpgQQ==",
"dev": true,
"requires": {
"@babel/helper-module-imports": "7.0.0-beta.42",
"@babel/helper-plugin-utils": "7.0.0-beta.42"
},
"dependencies": {
"@babel/helper-module-imports": {
"version": "7.0.0-beta.42",
"resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.0.0-beta.42.tgz",
"integrity": "sha512-0kTX0cjuVKUKDJmHjmAb504kNrwae0Ja32hGii7zSHDKm0tVZvvpT8Cc1yYHo6UsIkUmzEvfGwIrNYemx1jTtQ==",
"dev": true,
"requires": {
"@babel/types": "7.0.0-beta.42",
"lodash": "^4.2.0"
}
},
"@babel/helper-plugin-utils": {
"version": "7.0.0-beta.42",
"resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.0.0-beta.42.tgz",
"integrity": "sha512-hZLw8Iz9/YOxI9mgWyPOP1S84OcdQo1WFkZrS1sSf45g16sEb4dVslds2uvZgmx9BiG94PoWyABGf48Py6D6CA==",
"dev": true
},
"@babel/types": {
"version": "7.0.0-beta.42",
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.0.0-beta.42.tgz",
"integrity": "sha512-+pmpISmTHQqMMpHHtDLxcvtRhmn53bAxy8goJfHipS/uy/r3PLcuSdPizLW7DhtBWbtgIKZufLObfnIMoyMNsw==",
"dev": true,
"requires": {
"esutils": "^2.0.2",
"lodash": "^4.2.0",
"to-fast-properties": "^2.0.0"
}
}
}
},
"@babel/plugin-transform-shorthand-properties": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-shorthand-properties/-/plugin-transform-shorthand-properties-7.2.0.tgz",
@@ -2612,9 +2573,9 @@
}
},
"@types/react-test-renderer": {
"version": "16.0.3",
"resolved": "https://registry.npmjs.org/@types/react-test-renderer/-/react-test-renderer-16.0.3.tgz",
"integrity": "sha512-NWOAxVQeJxpXuNKgw83Hah0nquiw1nUexM9qY/Hk3a+XhZwgMtaa6GLA9E1TKMT75Odb3/KE/jiBO4enTuEJjQ==",
"version": "16.8.1",
"resolved": "https://registry.npmjs.org/@types/react-test-renderer/-/react-test-renderer-16.8.1.tgz",
"integrity": "sha512-8gU69ELfJGxzVWVYj4MTtuHxz9nO+d175XeQ1XrXXxesUBsB4KK6OCfzVhEX6leZWWBDVtMJXp/rUjhClzL7gw==",
"dev": true,
"requires": {
"@types/react": "*"
@@ -5356,6 +5317,16 @@
"regenerator-transform": "^0.12.3"
}
},
"@babel/plugin-transform-runtime": {
"version": "7.0.0-beta.42",
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-runtime/-/plugin-transform-runtime-7.0.0-beta.42.tgz",
"integrity": "sha512-4LcNdjMvKzCwK/eqfbUiXFAZht8OTx0Gv2Ok42o+zhb8DvNUaYUndgW9AU4Q6nbpxzw2vTWNUXSIRvdGsxpgQQ==",
"dev": true,
"requires": {
"@babel/helper-module-imports": "7.0.0-beta.42",
"@babel/helper-plugin-utils": "7.0.0-beta.42"
}
},
"@babel/plugin-transform-shorthand-properties": {
"version": "7.0.0-beta.42",
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-shorthand-properties/-/plugin-transform-shorthand-properties-7.0.0-beta.42.tgz",
@@ -11112,6 +11083,15 @@
"resolved": "https://registry.npmjs.org/core-js/-/core-js-1.2.7.tgz",
"integrity": "sha1-ZSKUwUZR2yj6k70tX/KYOk8IxjY=",
"dev": true
},
"promise": {
"version": "7.3.1",
"resolved": "https://registry.npmjs.org/promise/-/promise-7.3.1.tgz",
"integrity": "sha512-nolQXZ/4L+bP/UGlkfaIujX9BKxGwmQ9OT4mOt5yvy8iK1h3wqTEJCijzGANTCCl9nWjY41juyAn2K3Q1hLLTg==",
"dev": true,
"requires": {
"asap": "~2.0.3"
}
}
}
},
@@ -22643,15 +22623,6 @@
"resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.0.tgz",
"integrity": "sha512-MtEC1TqN0EU5nephaJ4rAtThHtC86dNN9qCuEhtshvpVBkAW5ZO7BASN9REnF9eoXGcRub+pFuKEpOHE+HbEMw=="
},
"promise": {
"version": "7.3.1",
"resolved": "https://registry.npmjs.org/promise/-/promise-7.3.1.tgz",
"integrity": "sha512-nolQXZ/4L+bP/UGlkfaIujX9BKxGwmQ9OT4mOt5yvy8iK1h3wqTEJCijzGANTCCl9nWjY41juyAn2K3Q1hLLTg==",
"dev": true,
"requires": {
"asap": "~2.0.3"
}
},
"promise-inflight": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/promise-inflight/-/promise-inflight-1.0.1.tgz",
+1 -1
View File
@@ -175,7 +175,7 @@
"@types/react-dom": "^16.0.11",
"@types/react-relay": "^1.3.9",
"@types/react-responsive": "^3.0.1",
"@types/react-test-renderer": "^16.0.3",
"@types/react-test-renderer": "^16.8.1",
"@types/react-transition-group": "^2.0.14",
"@types/recompose": "^0.26.5",
"@types/relay-runtime": "^1.3.6",
+12 -10
View File
@@ -14,15 +14,6 @@ function lintAndWrite(files) {
}
}
function getFileName(name) {
return path.join(
__dirname,
"../src/core/server/graph",
name,
"schema/__generated__/types.ts"
);
}
async function main() {
const config = getGraphQLConfig(__dirname);
const projects = config.getProjects();
@@ -30,7 +21,10 @@ async function main() {
const files = [
{
name: "tenant",
fileName: getFileName("tenant"),
fileName: path.join(
__dirname,
"../src/core/server/graph/tenant/schema/__generated__/types.ts"
),
config: {
contextType: "TenantContext",
importStatements: [
@@ -40,6 +34,14 @@ async function main() {
customScalarType: { Cursor: "Cursor", Time: "Date" },
},
},
{
name: "tenant",
fileName: path.join(
__dirname,
"../src/core/client/framework/schema/__generated__/types.ts"
),
config: {},
},
];
for (const file of files) {
@@ -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"
>
@@ -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">;
+4
View File
@@ -41,3 +41,7 @@ export {
withSetPasswordMutation,
SetPasswordMutation,
} from "./SetPasswordMutation";
export {
withUpdateUserRoleMutation,
UpdateUserRoleMutation,
} from "./UpdateUserRoleMutation";
+32
View File
@@ -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);
}
+2 -2
View File
@@ -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;
@@ -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);
});
+12 -8
View File
@@ -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! };
}
+21 -3
View File
@@ -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 },
};
+1
View File
@@ -40,6 +40,7 @@ export default function create(params: CreateParams) {
const context: TalkContext = {
relayEnvironment: environment,
locales: ["en-US"],
localeBundles: [createFluentBundle()],
localStorage: createPromisifiedStorage(),
sessionStorage: createPromisifiedStorage(),
@@ -2,7 +2,7 @@ import { Environment } from "relay-runtime";
import getViewerSourceID from "./getViewerSourceID";
export default function getMe(environment: Environment) {
export default function getViewer(environment: Environment) {
const source = environment.getStore().getSource();
const viewerID = getViewerSourceID(environment);
if (!viewerID) {
@@ -1,6 +1,8 @@
import { Environment, ROOT_ID } from "relay-runtime";
export default function getMeSourceID(environment: Environment): string | null {
export default function getViewerSourceID(
environment: Environment
): string | null {
const source = environment.getStore().getSource();
const root = source.get(ROOT_ID)!;
if (!root.viewer) {
+1 -1
View File
@@ -1,5 +1,5 @@
export { default as getViewer } from "./getViewer";
export { default as getMeSourceID } from "./getViewerSourceID";
export { default as getViewerSourceID } from "./getViewerSourceID";
export { default as getURLWithCommentID } from "./getURLWithCommentID";
export { default as urls } from "./urls";
export { default as createContextHOC } from "./createContextHOC";
@@ -18,6 +18,9 @@ export interface TalkContext {
/** relayEnvironment for our relay framework. */
relayEnvironment: Environment;
/** locales */
locales: string[];
/** localeBundles for our i18n framework. */
localeBundles: FluentBundle[];
@@ -61,12 +64,14 @@ export interface TalkContext {
clearSession: () => Promise<void>;
}
const { Provider, Consumer } = React.createContext<TalkContext>({} as any);
export const TalkReactContext = React.createContext<TalkContext>({} as any);
export const useTalkContext = () => React.useContext(TalkReactContext);
/**
* Allows consuming the provided context using the React Context API.
*/
export const TalkContextConsumer = Consumer;
export const TalkContextConsumer = TalkReactContext.Consumer;
/**
* In addition to just providing the context, TalkContextProvider also
@@ -75,7 +80,7 @@ export const TalkContextConsumer = Consumer;
export const TalkContextProvider: StatelessComponent<{
value: TalkContext;
}> = ({ value, children }) => (
<Provider value={value}>
<TalkReactContext.Provider value={value}>
<LocalizationProvider bundles={value.localeBundles}>
<UIContext.Provider
value={{
@@ -87,5 +92,5 @@ export const TalkContextProvider: StatelessComponent<{
{children}
</UIContext.Provider>
</LocalizationProvider>
</Provider>
</TalkReactContext.Provider>
);
@@ -238,6 +238,7 @@ export default async function createManaged({
// Assemble context.
const context: TalkContext = {
relayEnvironment: environment,
locales,
localeBundles,
timeagoFormatter,
pym,
@@ -2,6 +2,8 @@ export {
TalkContext,
TalkContextConsumer,
TalkContextProvider,
TalkReactContext,
useTalkContext,
} from "./TalkContext";
export { default as createManaged } from "./createManaged";
export { default as withContext } from "./withContext";
@@ -0,0 +1,37 @@
/**
* TODO: (cvle) This file is a workaround to have Relay compatible enum types.
* This should be generated by `graphql-schema-typescript`.
*/
import { RelayEnumLiteral } from "../types";
import {
GQLCOMMENT_FLAG_DETECTED_REASON,
GQLCOMMENT_FLAG_REASON,
GQLCOMMENT_FLAG_REPORTED_REASON,
GQLCOMMENT_SORT,
GQLCOMMENT_STATUS,
GQLLOCALES,
GQLMODERATION_MODE,
GQLSTORY_STATUS,
GQLUSER_AUTH_CONDITIONS,
GQLUSER_ROLE,
} from "./__generated__/types";
export type GQLUSER_ROLE_RL = RelayEnumLiteral<typeof GQLUSER_ROLE>;
export type GQLCOMMENT_FLAG_DETECTED_REASON_RL = RelayEnumLiteral<
typeof GQLCOMMENT_FLAG_DETECTED_REASON
>;
export type GQLCOMMENT_FLAG_REASON_RL = RelayEnumLiteral<
typeof GQLCOMMENT_FLAG_REASON
>;
export type GQLCOMMENT_FLAG_REPORTED_REASON_RL = RelayEnumLiteral<
typeof GQLCOMMENT_FLAG_REPORTED_REASON
>;
export type GQLCOMMENT_SORT_RL = RelayEnumLiteral<typeof GQLCOMMENT_SORT>;
export type GQLCOMMENT_STATUS_RL = RelayEnumLiteral<typeof GQLCOMMENT_STATUS>;
export type GQLLOCALES_RL = RelayEnumLiteral<typeof GQLLOCALES>;
export type GQLMODERATION_MODE_RL = RelayEnumLiteral<typeof GQLMODERATION_MODE>;
export type GQLSTORY_STATUS_RL = RelayEnumLiteral<typeof GQLSTORY_STATUS>;
export type GQLUSER_AUTH_CONDITIONS_RL = RelayEnumLiteral<
typeof GQLUSER_AUTH_CONDITIONS
>;
@@ -0,0 +1,2 @@
export * from "./__generated__/types";
export * from "./custom";
+2
View File
@@ -1,3 +1,5 @@
// TODO: (@cvle) Extract useful common types into its own package.
export { Omit, Overwrite, PropTypesOf } from "talk-ui/types";
export { DeepPartial } from "talk-common/types";
export type RelayEnumLiteral<T> = keyof T | "%future added value";
@@ -5,14 +5,14 @@ import {
RecordSourceSelectorProxy,
} from "relay-runtime";
import { getMeSourceID } from "talk-framework/helpers";
import { getViewerSourceID } from "talk-framework/helpers";
export default function prependCommentEdgeToProfile(
environment: Environment,
store: RecordSourceSelectorProxy,
commentEdge: RecordProxy
) {
const meProxy = store.get(getMeSourceID(environment)!);
const meProxy = store.get(getViewerSourceID(environment)!);
const con = ConnectionHandler.getConnection(
meProxy,
"CommentHistory_comments"
@@ -2,7 +2,7 @@ import sinon from "sinon";
import { waitForElement, within } from "talk-framework/testHelpers";
import { meAsModerator, settings, stories } from "../fixtures";
import { settings, stories, viewerAsModerator } from "../fixtures";
import create from "./create";
async function createTestRenderer(
@@ -14,7 +14,7 @@ async function createTestRenderer(
Query: {
settings: sinon.stub().returns(settings),
story: sinon.stub().returns(stories[0]),
viewer: sinon.stub().returns(meAsModerator),
viewer: sinon.stub().returns(viewerAsModerator),
...resolver.Query,
},
};
@@ -2,7 +2,7 @@ import sinon from "sinon";
import { waitForElement, within } from "talk-framework/testHelpers";
import { meAsModerator, settings, stories } from "../fixtures";
import { settings, stories, viewerAsModerator } from "../fixtures";
import create from "./create";
async function createTestRenderer(
@@ -16,7 +16,7 @@ async function createTestRenderer(
expectAndFail(variables).toEqual({ id: stories[0].id, url: null });
return stories[0];
}),
viewer: sinon.stub().returns(meAsModerator),
viewer: sinon.stub().returns(viewerAsModerator),
...resolver.Query,
},
...resolver,
@@ -8,7 +8,7 @@ import {
within,
} from "talk-framework/testHelpers";
import { meAsModerator, settings, stories } from "../fixtures";
import { settings, stories, viewerAsModerator } from "../fixtures";
import create from "./create";
async function createTestRenderer(
@@ -22,7 +22,7 @@ async function createTestRenderer(
expectAndFail(variables).toEqual({ id: stories[0].id, url: null });
return stories[0];
}),
viewer: sinon.stub().returns(meAsModerator),
viewer: sinon.stub().returns(viewerAsModerator),
...resolver.Query,
},
...resolver,
+12 -9
View File
@@ -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 { TalkContext, TalkContextProvider } from "talk-framework/lib/bootstrap";
@@ -42,6 +42,7 @@ export default function create(params: CreateParams) {
const context: TalkContext = {
relayEnvironment: environment,
locales: ["en-US"],
localeBundles: [createFluentBundle()],
localStorage: createPromisifiedStorage(),
sessionStorage: createPromisifiedStorage(),
@@ -53,12 +54,14 @@ export default function create(params: CreateParams) {
clearSession: () => Promise.resolve(),
};
const testRenderer = TestRenderer.create(
<TalkContextProvider value={context}>
<AppContainer />
</TalkContextProvider>,
{ createNodeMock }
);
return { context, testRenderer };
let testRenderer: ReactTestRenderer;
TestRenderer.act(() => {
testRenderer = TestRenderer.create(
<TalkContextProvider value={context}>
<AppContainer />
</TalkContextProvider>,
{ createNodeMock }
);
});
return { context, testRenderer: testRenderer! };
}
+2 -2
View File
@@ -396,13 +396,13 @@ export const storyWithDeepestReplies = denormalizeStory({
},
});
export const meAsModerator = {
export const viewerAsModerator = {
id: "me-as-moderator",
username: "Moderator",
role: "MODERATOR",
};
export const meWithComments = {
export const viewerWithComments = {
id: "me-with-comments",
username: "Markus",
role: "COMMENTER",
@@ -8,13 +8,13 @@ import {
within,
} from "talk-framework/testHelpers";
import { comments, meWithComments, settings, stories } from "../fixtures";
import { comments, settings, stories, viewerWithComments } from "../fixtures";
import create from "./create";
let testRenderer: ReactTestRenderer;
beforeEach(() => {
const meStub = {
...meWithComments,
...viewerWithComments,
comments: createSinonStub(
s => s.throws(),
s =>
@@ -7,7 +7,7 @@ import {
within,
} from "talk-framework/testHelpers";
import { meWithComments, settings, stories } from "../fixtures";
import { settings, stories, viewerWithComments } from "../fixtures";
import create from "./create";
let testRenderer: ReactTestRenderer;
@@ -22,7 +22,7 @@ beforeEach(() => {
.withArgs(undefined, { id: stories[0].id, url: null })
.returns(stories[0])
),
viewer: sinon.stub().returns(meWithComments),
viewer: sinon.stub().returns(viewerWithComments),
},
};
-24
View File
@@ -1,28 +1,4 @@
import React from "react";
// TODO: Remove when fixed.
// Mock React.createContext because of https://github.com/airbnb/enzyme/issues/1509.
function mockReact() {
const originalReact = require.requireActual("react");
return {
...originalReact,
createContext: jest.fn(defaultValue => {
let value = defaultValue;
const Provider = (props: any) => {
value = props.value;
return props.children;
};
const Consumer = (props: any) => props.children(value);
return {
Provider,
Consumer,
};
}),
};
}
jest.mock("fluent-intl-polyfill/compat", () => null);
jest.mock("react", () => mockReact());
jest.mock("react-transition-group", () => ({
CSSTransition: (props: { children: React.ReactNode }) => props.children,
Transition: (props: { children: React.ReactNode }) => props.children,
@@ -2,6 +2,7 @@ import cn from "classnames";
import React from "react";
import { Manager, Popper, Reference, RefHandler } from "react-popper";
import { oncePerFrame } from "talk-common/utils";
import { withStyles } from "talk-ui/hocs";
import AriaInfo from "../AriaInfo";
@@ -58,22 +59,33 @@ class Popover extends React.Component<PopoverProps> {
visible: false,
};
public toggleVisibility = (event?: React.SyntheticEvent | Event) => {
if (event && event.stopPropagation) {
event.stopPropagation();
private toggleVisibility = (() => {
let fn = (event?: React.SyntheticEvent | Event) => {
this.setState((state: State) => ({
visible: !state.visible,
}));
};
if (process.env.NODE_ENV !== "test") {
/**
* Only run this once per frame in the browser, otherwise
* we might get into a situation where this is called twice
* by different event handlers cancelling each other out.
*
* We don't wan this behavior when running in a simulated browser
* environment with simulated events.
*/
fn = oncePerFrame(fn);
}
this.setState((state: State) => ({
visible: !state.visible,
}));
};
return fn;
})();
public close = () => {
private close = () => {
this.setState((state: State) => ({
visible: false,
}));
};
public handleEsc = (e: KeyboardEvent) => {
private handleEsc = (e: KeyboardEvent) => {
if (e.key === "Escape") {
e.preventDefault();
this.close();
@@ -26,6 +26,6 @@ exports[`uses formatter from props 1`] = `
dateTime="2018-12-17T03:24:00.000Z"
title="2018-12-17T03:24:00.000Z"
>
My Context Formatter
My Props Formatter
</time>
`;
@@ -0,0 +1,8 @@
.root {
border-collapse: collapse;
box-sizing: border-box;
}
.fullWidth {
width: 100%;
}
@@ -0,0 +1,36 @@
---
name: Table
menu: UI Kit
---
import { Playground, PropsTable } from "docz";
import { Table, TableBody, TableHead, TableRow, TableCell } from "./";
# Table
## Basic usage
<Playground>
<Table fullWidth>
<TableHead>
<TableRow>
<TableCell>Username</TableCell>
<TableCell>E-Mail Address</TableCell>
<TableCell>Member Since</TableCell>
</TableRow>
</TableHead>
<TableBody>
<TableRow>
<TableCell>ButFirstCoffee</TableCell>
<TableCell>coffee@mail.com</TableCell>
<TableCell>01/27/2019</TableCell>
</TableRow>
<TableRow>
<TableCell>SneaksOnSneaks</TableCell>
<TableCell>hisairness@mail.com</TableCell>
<TableCell>04/27/2019</TableCell>
</TableRow>
</TableBody>
</Table>
</Playground>
@@ -0,0 +1,32 @@
import React from "react";
import { createRenderer } from "react-test-renderer/shallow";
import { Table, TableBody, TableCell, TableHead, TableRow } from ".";
it("renders correctly", () => {
const renderer = createRenderer();
renderer.render(
<Table fullWidth>
<TableHead>
<TableRow>
<TableCell>Username</TableCell>
<TableCell>E-Mail Address</TableCell>
<TableCell>Member Since</TableCell>
</TableRow>
</TableHead>
<TableBody>
<TableRow>
<TableCell>ButFirstCoffee</TableCell>
<TableCell>coffee@mail.com</TableCell>
<TableCell>01/27/2019</TableCell>
</TableRow>
<TableRow>
<TableCell>SneaksOnSneaks</TableCell>
<TableCell>hisairness@mail.com</TableCell>
<TableCell>04/27/2019</TableCell>
</TableRow>
</TableBody>
</Table>
);
expect(renderer.getRenderOutput()).toMatchSnapshot();
});
@@ -0,0 +1,36 @@
import cn from "classnames";
import React, { StatelessComponent } from "react";
import { withStyles } from "talk-ui/hocs";
import styles from "./Table.css";
interface Props extends React.HTMLAttributes<HTMLTableElement> {
className?: string;
/**
* Override or extend the styles applied to the component.
*/
classes: typeof styles;
fullWidth?: boolean;
}
const Table: StatelessComponent<Props> = ({
classes,
className,
fullWidth,
children,
...rest
}) => {
const rootClassName = cn(classes.root, className, {
[classes.fullWidth]: fullWidth,
});
return (
<table className={rootClassName} {...rest}>
{children}
</table>
);
};
const enhanced = withStyles(styles)(Table);
export default enhanced;
@@ -0,0 +1,2 @@
.root {
}
@@ -0,0 +1,31 @@
import cn from "classnames";
import React, { StatelessComponent } from "react";
import { withStyles } from "talk-ui/hocs";
import styles from "./TableBody.css";
interface Props extends React.HTMLAttributes<HTMLTableSectionElement> {
className?: string;
/**
* Override or extend the styles applied to the component.
*/
classes: typeof styles;
}
const TableBody: StatelessComponent<Props> = ({
classes,
className,
children,
...rest
}) => {
const rootClassName = cn(classes.root, className);
return (
<tbody className={rootClassName} {...rest}>
{children}
</tbody>
);
};
const enhanced = withStyles(styles)(TableBody);
export default enhanced;
@@ -0,0 +1,25 @@
.root {
/* acts like min-width in a cell */
height: calc(4.5 * var(--spacing-unit));
text-align: left;
padding: var(--spacing-unit) calc(1.5 * var(--spacing-unit));
box-sizing: border-box;
}
.header {
composes: bodyCopy from "talk-ui/shared/typography.css";
color: var(--palette-grey-dark);
}
.body {
composes: detail from "talk-ui/shared/typography.css";
color: var(--palette-grey-dark);
}
.alignCenter {
text-align: center;
}
.alignEnd {
text-align: right;
}
@@ -0,0 +1,45 @@
import cn from "classnames";
import React, { StatelessComponent, useContext } from "react";
import { withStyles } from "talk-ui/hocs";
import { Omit } from "talk-ui/types";
import { TableHeadContext } from "./TableHead";
import styles from "./TableCell.css";
interface Props
extends Omit<React.TdHTMLAttributes<HTMLTableCellElement>, "align"> {
className?: string;
/**
* Override or extend the styles applied to the component.
*/
classes: typeof styles;
align?: "begin" | "center" | "end";
}
const TableCell: StatelessComponent<Props> = ({
align,
classes,
className,
children,
...rest
}) => {
const inTableHead = useContext(TableHeadContext);
const rootClassName = cn(classes.root, className, {
[classes.header]: inTableHead,
[classes.body]: !inTableHead,
[classes.alignCenter]: align === "center",
[classes.alignEnd]: align === "end",
});
const Component = inTableHead ? "th" : "td";
return (
<Component className={rootClassName} {...rest}>
{children}
</Component>
);
};
const enhanced = withStyles(styles)(TableCell);
export default enhanced;
@@ -0,0 +1,5 @@
.root {
background: var(--palette-grey-lightest);
border: 1px solid var(--palette-grey-lighter);
box-sizing: border-box;
}
@@ -0,0 +1,35 @@
import cn from "classnames";
import React, { StatelessComponent } from "react";
import { withStyles } from "talk-ui/hocs";
import styles from "./TableHead.css";
export const TableHeadContext = React.createContext<boolean>(false);
interface Props extends React.HTMLAttributes<HTMLTableSectionElement> {
className?: string;
/**
* Override or extend the styles applied to the component.
*/
classes: typeof styles;
}
const TableHead: StatelessComponent<Props> = ({
classes,
className,
children,
...rest
}) => {
const rootClassName = cn(classes.root, className);
return (
<TableHeadContext.Provider value>
<thead className={rootClassName} {...rest}>
{children}
</thead>
</TableHeadContext.Provider>
);
};
const enhanced = withStyles(styles)(TableHead);
export default enhanced;
@@ -0,0 +1,15 @@
.root {
box-sizing: border-box;
}
.body:nth-child(even) {
background-color: var(--palette-grey-lightest);
}
.body:first-child:hover {
border-top: 1px solid var(--palette-primary-main);
}
.body:hover {
background-color: var(--palette-primary-lightest);
box-shadow: inset 0px 0px 0px 1px var(--palette-primary-main);
}
@@ -0,0 +1,36 @@
import cn from "classnames";
import React, { StatelessComponent, useContext } from "react";
import { withStyles } from "talk-ui/hocs";
import { TableHeadContext } from "./TableHead";
import styles from "./TableRow.css";
interface Props extends React.HTMLAttributes<HTMLTableRowElement> {
className?: string;
/**
* Override or extend the styles applied to the component.
*/
classes: typeof styles;
}
const TableRow: StatelessComponent<Props> = ({
classes,
className,
children,
...rest
}) => {
const inTableHead = useContext(TableHeadContext);
const rootClassName = cn(classes.root, className, {
[classes.body]: !inTableHead,
});
return (
<tr className={rootClassName} {...rest}>
{children}
</tr>
);
};
const enhanced = withStyles(styles)(TableRow);
export default enhanced;
@@ -0,0 +1,51 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`renders correctly 1`] = `
<Table
classes={
Object {
"fullWidth": "Table-fullWidth",
"root": "Table-root",
}
}
fullWidth={true}
>
<withPropsOnChange(TableHead)>
<withPropsOnChange(TableRow)>
<withPropsOnChange(TableCell)>
Username
</withPropsOnChange(TableCell)>
<withPropsOnChange(TableCell)>
E-Mail Address
</withPropsOnChange(TableCell)>
<withPropsOnChange(TableCell)>
Member Since
</withPropsOnChange(TableCell)>
</withPropsOnChange(TableRow)>
</withPropsOnChange(TableHead)>
<withPropsOnChange(TableBody)>
<withPropsOnChange(TableRow)>
<withPropsOnChange(TableCell)>
ButFirstCoffee
</withPropsOnChange(TableCell)>
<withPropsOnChange(TableCell)>
coffee@mail.com
</withPropsOnChange(TableCell)>
<withPropsOnChange(TableCell)>
01/27/2019
</withPropsOnChange(TableCell)>
</withPropsOnChange(TableRow)>
<withPropsOnChange(TableRow)>
<withPropsOnChange(TableCell)>
SneaksOnSneaks
</withPropsOnChange(TableCell)>
<withPropsOnChange(TableCell)>
hisairness@mail.com
</withPropsOnChange(TableCell)>
<withPropsOnChange(TableCell)>
04/27/2019
</withPropsOnChange(TableCell)>
</withPropsOnChange(TableRow)>
</withPropsOnChange(TableBody)>
</Table>
`;
@@ -0,0 +1,5 @@
export { default as Table } from "./Table";
export { default as TableBody } from "./TableBody";
export { default as TableHead } from "./TableHead";
export { default as TableRow } from "./TableRow";
export { default as TableCell } from "./TableCell";
+1
View File
@@ -53,3 +53,4 @@ export {
Divider as DropdownDivider,
Button as DropdownButton,
} from "./Dropdown";
export { Table, TableBody, TableHead, TableRow, TableCell } from "./Table";
@@ -80,5 +80,5 @@ export const Users = (ctx: TenantContext) => ({
updateUserAvatar: async (input: GQLUpdateUserAvatarInput) =>
updateAvatar(ctx.mongo, ctx.tenant, input.userID, input.avatar),
updateUserRole: async (input: GQLUpdateUserRoleInput) =>
updateRole(ctx.mongo, ctx.tenant, input.userID, input.role),
updateRole(ctx.mongo, ctx.tenant, ctx.user!, input.userID, input.role),
});
+5
View File
@@ -345,9 +345,14 @@ export async function updateUsername(
export async function updateRole(
mongo: Db,
tenant: Tenant,
user: Pick<User, "id">,
userID: string,
role: GQLUSER_ROLE
) {
if (user.id === userID) {
throw new Error("cannot update your own user role");
}
return updateUserRole(mongo, tenant.id, userID, role);
}
+40
View File
@@ -4,6 +4,17 @@
general-brandName = { -product-name }
## Roles
role-admin = Admin
role-moderator = Moderator
role-staff = Staff
role-commenter = Commenter
role-plural-admin = Admins
role-plural-moderator = Moderators
role-plural-staff = Staff
role-plural-commenter = Commenters
## Navigation
navigation-moderate = Moderate
navigation-community = Community
@@ -350,3 +361,32 @@ createPassword-passwordLabel = Password
createPassword-passwordDescription = Must be at least {$minLength} characters
createPassword-passwordTextField =
.placeholder = Password
## Community
community-emptyMessage = We could not find anyone in your community matching your criteria.
community-filter-searchField =
.placeholder = Search by username or email address...
.aria-label = Search by username or email address
community-filter-roleSelectField =
.aria-label = Search by role
community-changeRoleButton =
.aria-label = Change role
community-filter-optGroupAudience =
.label = Audience
community-filter-optGroupOrganization =
.label = Organization
community-filter-search = Search
community-filter-showMe = Show Me
community-filter-everyone = Everyone
community-column-username = Username
community-column-email = Email
community-column-memberSince = Member Since
community-column-role = Role
community-role-popover =
.description = A dropdown to change the user role