diff --git a/package-lock.json b/package-lock.json index b8e2c52f5..187615763 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index d99f36932..6c4853e8f 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/scripts/generateSchemaTypes.js b/scripts/generateSchemaTypes.js index bc158bc98..8ced16a82 100644 --- a/scripts/generateSchemaTypes.js +++ b/scripts/generateSchemaTypes.js @@ -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) { diff --git a/src/core/client/admin/components/Navigation.tsx b/src/core/client/admin/components/Navigation.tsx index b68df35f2..67b2135c9 100644 --- a/src/core/client/admin/components/Navigation.tsx +++ b/src/core/client/admin/components/Navigation.tsx @@ -6,9 +6,6 @@ import { AppBarNavigation } from "talk-ui/components"; import NavigationLink from "./NavigationLink"; /* TODO: - - Community - Stories @@ -19,6 +16,9 @@ const Navigation: StatelessComponent = () => ( Moderate + + Community + Configure diff --git a/src/core/client/admin/components/TranslatedRole.tsx b/src/core/client/admin/components/TranslatedRole.tsx new file mode 100644 index 000000000..993758db0 --- /dev/null +++ b/src/core/client/admin/components/TranslatedRole.tsx @@ -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 | React.ComponentType | string; + children: GQLUSER_ROLE_RL; +} + +function createElement( + Container: React.ReactElement | React.ComponentType | string, + children: React.ReactNode +) { + if (React.isValidElement(Container)) { + return React.cloneElement(Container, { children }); + } else { + return {children}; + } +} + +const TranslatedRole: React.StatelessComponent = props => { + switch (props.children) { + case GQLUSER_ROLE.COMMENTER: + return ( + + {createElement(props.container!, "Commenter")} + + ); + case GQLUSER_ROLE.ADMIN: + return ( + + {createElement(props.container!, "Admin")} + + ); + case GQLUSER_ROLE.MODERATOR: + return ( + + {createElement(props.container!, "Moderator")} + + ); + case GQLUSER_ROLE.STAFF: + return ( + + {createElement(props.container!, "Staff")} + + ); + default: + // Unknown role, just use untranslated string. + return createElement(props.container!, props.children); + } +}; + +TranslatedRole.defaultProps = { + container: "span", +}; + +export default TranslatedRole; diff --git a/src/core/client/admin/components/__snapshots__/Navigation.spec.tsx.snap b/src/core/client/admin/components/__snapshots__/Navigation.spec.tsx.snap index 497f44bea..2887e3184 100644 --- a/src/core/client/admin/components/__snapshots__/Navigation.spec.tsx.snap +++ b/src/core/client/admin/components/__snapshots__/Navigation.spec.tsx.snap @@ -11,6 +11,15 @@ exports[`renders correctly 1`] = ` Moderate + + + Community + + diff --git a/src/core/client/admin/routes/moderate/containers/AutoLoadMoreContainer.tsx b/src/core/client/admin/containers/AutoLoadMoreContainer.tsx similarity index 97% rename from src/core/client/admin/routes/moderate/containers/AutoLoadMoreContainer.tsx rename to src/core/client/admin/containers/AutoLoadMoreContainer.tsx index 37409dad1..7b68836f6 100644 --- a/src/core/client/admin/routes/moderate/containers/AutoLoadMoreContainer.tsx +++ b/src/core/client/admin/containers/AutoLoadMoreContainer.tsx @@ -6,7 +6,7 @@ import { BaseButton, Spinner } from "talk-ui/components"; interface Props { inView: boolean | undefined; intersectionRef: React.Ref; - disableLoadMore: boolean; + disableLoadMore?: boolean; onLoadMore: () => void; } diff --git a/src/core/client/admin/mutations/UpdateUserRoleMutation.ts b/src/core/client/admin/mutations/UpdateUserRoleMutation.ts new file mode 100644 index 000000000..9a98da2a3 --- /dev/null +++ b/src/core/client/admin/mutations/UpdateUserRoleMutation.ts @@ -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; + +const mutation = graphql` + mutation UpdateUserRoleMutation($input: UpdateUserRoleInput!) { + updateUserRole(input: $input) { + user { + role + } + clientMutationId + } + } +`; + +let clientMutationId = 0; + +function commit(environment: Environment, input: UpdateUserRoleInput) { + return commitMutationPromiseNormalized(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; diff --git a/src/core/client/admin/mutations/index.ts b/src/core/client/admin/mutations/index.ts index 65052f4e9..fd949c16a 100644 --- a/src/core/client/admin/mutations/index.ts +++ b/src/core/client/admin/mutations/index.ts @@ -41,3 +41,7 @@ export { withSetPasswordMutation, SetPasswordMutation, } from "./SetPasswordMutation"; +export { + withUpdateUserRoleMutation, + UpdateUserRoleMutation, +} from "./UpdateUserRoleMutation"; diff --git a/src/core/client/admin/permissions.tsx b/src/core/client/admin/permissions.tsx new file mode 100644 index 000000000..58511f737 --- /dev/null +++ b/src/core/client/admin/permissions.tsx @@ -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); +} diff --git a/src/core/client/admin/routeConfig.tsx b/src/core/client/admin/routeConfig.tsx index f0ceae887..e40fd7def 100644 --- a/src/core/client/admin/routeConfig.tsx +++ b/src/core/client/admin/routeConfig.tsx @@ -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( /> - + diff --git a/src/core/client/admin/routes/community/components/Community.css b/src/core/client/admin/routes/community/components/Community.css new file mode 100644 index 000000000..f27e73931 --- /dev/null +++ b/src/core/client/admin/routes/community/components/Community.css @@ -0,0 +1,5 @@ +.root { + max-width: 950px; + margin-top: calc(3 * var(--spacing-unit)); + margin-bottom: calc(3 * var(--spacing-unit)); +} diff --git a/src/core/client/admin/routes/community/components/Community.spec.tsx b/src/core/client/admin/routes/community/components/Community.spec.tsx deleted file mode 100644 index 7edd1408d..000000000 --- a/src/core/client/admin/routes/community/components/Community.spec.tsx +++ /dev/null @@ -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(); - expect(renderer.getRenderOutput()).toMatchSnapshot(); -}); diff --git a/src/core/client/admin/routes/community/components/Community.tsx b/src/core/client/admin/routes/community/components/Community.tsx index 93e2cf2a4..301261f21 100644 --- a/src/core/client/admin/routes/community/components/Community.tsx +++ b/src/core/client/admin/routes/community/components/Community.tsx @@ -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 = () => ( - - Community +import UserTableContainer from "../containers/UserTableContainer"; + +import styles from "./Community.css"; + +interface Props { + query: PropTypesOf["query"]; +} + +const Community: StatelessComponent = props => ( + + ); diff --git a/src/core/client/admin/routes/community/components/EmptyMessage.css b/src/core/client/admin/routes/community/components/EmptyMessage.css new file mode 100644 index 000000000..7b933aa00 --- /dev/null +++ b/src/core/client/admin/routes/community/components/EmptyMessage.css @@ -0,0 +1,3 @@ +.root { + font-size: calc(18rem / var(--rem-base)); +} diff --git a/src/core/client/admin/routes/community/components/EmptyMessage.tsx b/src/core/client/admin/routes/community/components/EmptyMessage.tsx new file mode 100644 index 000000000..b9411fc5e --- /dev/null +++ b/src/core/client/admin/routes/community/components/EmptyMessage.tsx @@ -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 => ( + + + We could not find anyone in your community matching your criteria. + + +); + +export default EmptyMessage; diff --git a/src/core/client/admin/routes/community/components/NotAvailable.tsx b/src/core/client/admin/routes/community/components/NotAvailable.tsx new file mode 100644 index 000000000..e1fee8d36 --- /dev/null +++ b/src/core/client/admin/routes/community/components/NotAvailable.tsx @@ -0,0 +1,10 @@ +import { Localized } from "fluent-react/compat"; +import React, { StatelessComponent } from "react"; + +const NotAvailable: StatelessComponent = props => ( + + Not available + +); + +export default NotAvailable; diff --git a/src/core/client/admin/routes/community/components/RoleChange.css b/src/core/client/admin/routes/community/components/RoleChange.css new file mode 100644 index 000000000..e513bb278 --- /dev/null +++ b/src/core/client/admin/routes/community/components/RoleChange.css @@ -0,0 +1,5 @@ +.button { + width: 100%; + justify-content: space-between; + padding: 0; +} diff --git a/src/core/client/admin/routes/community/components/RoleChange.tsx b/src/core/client/admin/routes/community/components/RoleChange.tsx new file mode 100644 index 000000000..d2de8cc78 --- /dev/null +++ b/src/core/client/admin/routes/community/components/RoleChange.tsx @@ -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 => ( + + ( + + + {Object.keys(GQLUSER_ROLE).map((r: GQLUSER_ROLE_RL) => ( + { + props.onChangeRole(r); + toggleVisibility(); + }} + > + dummy + + } + > + {r} + + ))} + + + )} + > + {({ toggleVisibility, ref, visible }) => ( + + + + )} + + +); + +export default RoleChange; diff --git a/src/core/client/admin/routes/community/components/RoleText.css b/src/core/client/admin/routes/community/components/RoleText.css new file mode 100644 index 000000000..6d592cea1 --- /dev/null +++ b/src/core/client/admin/routes/community/components/RoleText.css @@ -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); +} diff --git a/src/core/client/admin/routes/community/components/RoleText.tsx b/src/core/client/admin/routes/community/components/RoleText.tsx new file mode 100644 index 000000000..59a6d67e0 --- /dev/null +++ b/src/core/client/admin/routes/community/components/RoleText.tsx @@ -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["children"]; +} + +const RoleText: StatelessComponent = props => ( + + } + > + {props.children} + +); + +export default RoleText; diff --git a/src/core/client/admin/routes/community/components/UserRow.tsx b/src/core/client/admin/routes/community/components/UserRow.tsx new file mode 100644 index 000000000..d24cb10c0 --- /dev/null +++ b/src/core/client/admin/routes/community/components/UserRow.tsx @@ -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["role"]; +} + +const UserRow: StatelessComponent = props => ( + + {props.username || } + + {{props.email} || ( + + )} + + {props.memberSince} + + {props.canChangeRole ? ( + + ) : ( + {props.role} + )} + + +); + +export default UserRow; diff --git a/src/core/client/admin/routes/community/components/UserTable.css b/src/core/client/admin/routes/community/components/UserTable.css new file mode 100644 index 000000000..d86456cb0 --- /dev/null +++ b/src/core/client/admin/routes/community/components/UserTable.css @@ -0,0 +1,12 @@ +.usernameColumn { + width: 25%; +} +.emailColumn { + width: 40%; +} +.memberSinceColumn { + width: 20%; +} +.roleColumn { + width: 15%; +} diff --git a/src/core/client/admin/routes/community/components/UserTable.tsx b/src/core/client/admin/routes/community/components/UserTable.tsx new file mode 100644 index 000000000..c39c0ebb4 --- /dev/null +++ b/src/core/client/admin/routes/community/components/UserTable.tsx @@ -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["viewer"] | null; + users: Array<{ id: string } & PropTypesOf["user"]>; + onLoadMore: () => void; + hasMore: boolean; + disableLoadMore: boolean; + loading: boolean; +} + +const UserTable: StatelessComponent = props => ( + <> + + + + + + Username + + + + Email Address + + + + + Member Since + + + + Role + + + + + {!props.loading && + props.users.map(u => ( + + ))} + +
+ {!props.loading && props.users.length === 0 && } + {props.loading && ( + + + + )} + {props.hasMore && ( + + + + )} +
+ +); + +export default UserTable; diff --git a/src/core/client/admin/routes/community/components/UserTableFilter.css b/src/core/client/admin/routes/community/components/UserTableFilter.css new file mode 100644 index 000000000..06c450778 --- /dev/null +++ b/src/core/client/admin/routes/community/components/UserTableFilter.css @@ -0,0 +1,8 @@ +.legend { + margin-bottom: 2px; + text-transform: uppercase; +} + +.textField { + height: 31px; +} diff --git a/src/core/client/admin/routes/community/components/UserTableFilter.tsx b/src/core/client/admin/routes/community/components/UserTableFilter.tsx new file mode 100644 index 000000000..26e6418b3 --- /dev/null +++ b/src/core/client/admin/routes/community/components/UserTableFilter.tsx @@ -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 => ( + +
+ + + Search + + + + + +
+
+ + + Show Me + + + + props.onSetRoleFilter(e.target.value as any)} + > + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+); + +export default UserTableFilter; diff --git a/src/core/client/admin/routes/community/components/__snapshots__/Community.spec.tsx.snap b/src/core/client/admin/routes/community/components/__snapshots__/Community.spec.tsx.snap deleted file mode 100644 index de6042ba1..000000000 --- a/src/core/client/admin/routes/community/components/__snapshots__/Community.spec.tsx.snap +++ /dev/null @@ -1,11 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`renders correctly 1`] = ` - - - Community - - -`; diff --git a/src/core/client/admin/routes/community/containers/CommunityContainer.tsx b/src/core/client/admin/routes/community/containers/CommunityContainer.tsx new file mode 100644 index 000000000..f51979dfe --- /dev/null +++ b/src/core/client/admin/routes/community/containers/CommunityContainer.tsx @@ -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 => { + return ; +}; + +const enhanced = withRouteConfig({ + query: graphql` + query CommunityContainerQuery { + ...UserTableContainer_query + } + `, + cacheConfig: { force: true }, +})(CommunityContainer); + +export default enhanced; diff --git a/src/core/client/admin/routes/community/containers/RoleChangeContainer.tsx b/src/core/client/admin/routes/community/containers/RoleChangeContainer.tsx new file mode 100644 index 000000000..09be0e77f --- /dev/null +++ b/src/core/client/admin/routes/community/containers/RoleChangeContainer.tsx @@ -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 => { + 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 ; +}; + +const enhanced = withUpdateUserRoleMutation(RoleChangeContainer); + +export default enhanced; diff --git a/src/core/client/admin/routes/community/containers/UserRowContainer.tsx b/src/core/client/admin/routes/community/containers/UserRowContainer.tsx new file mode 100644 index 000000000..bc6865cc3 --- /dev/null +++ b/src/core/client/admin/routes/community/containers/UserRowContainer.tsx @@ -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 => { + const { locales } = useTalkContext(); + return ( + + ); +}; + +const enhanced = withFragmentContainer({ + 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; diff --git a/src/core/client/admin/routes/community/containers/UserTableContainer.tsx b/src/core/client/admin/routes/community/containers/UserTableContainer.tsx new file mode 100644 index 000000000..d044887ca --- /dev/null +++ b/src/core/client/admin/routes/community/containers/UserTableContainer.tsx @@ -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 => { + 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(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 ( + + + setRoleFilterAndRefetch(role || null)} + roleFilter={roleFilter} + /> + + + + ); +}; + +// 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; diff --git a/src/core/client/admin/routes/moderate/components/Queue.tsx b/src/core/client/admin/routes/moderate/components/Queue.tsx index eb7fa4422..9b4dd25fe 100644 --- a/src/core/client/admin/routes/moderate/components/Queue.tsx +++ b/src/core/client/admin/routes/moderate/components/Queue.tsx @@ -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"; diff --git a/src/core/client/admin/test/auth/redirectLoggedIn.spec.tsx b/src/core/client/admin/test/auth/redirectLoggedIn.spec.tsx index ed66ad76d..f5d19a003 100644 --- a/src/core/client/admin/test/auth/redirectLoggedIn.spec.tsx +++ b/src/core/client/admin/test/auth/redirectLoggedIn.spec.tsx @@ -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" + ) ); }); diff --git a/src/core/client/admin/test/community/__snapshots__/community.spec.tsx.snap b/src/core/client/admin/test/community/__snapshots__/community.spec.tsx.snap new file mode 100644 index 000000000..1262ccaa7 --- /dev/null +++ b/src/core/client/admin/test/community/__snapshots__/community.spec.tsx.snap @@ -0,0 +1,388 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`renders community 1`] = ` +
+
+
+
+ + Search + +
+ +
+
+
+ + Show Me + + + + + + + +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + +
+ Username + + Email + + Member Since + + Role +
+ Markus + + + markus@test.com + + + 07/06/18 + + + Admin + +
+ Lukas + + + lukas@test.com + + + 07/06/18 + +
+ +
+
+ A dropdown to change the user role +
+
+
+
+
+
+
+`; + +exports[`renders empty community 1`] = ` +
+
+
+
+ + Search + +
+ +
+
+
+ + Show Me + + + + + + + +
+
+
+ + + + + + + + + + +
+ Username + + Email + + Member Since + + Role +
+

+ We could not find anyone in your community matching your criteria. +

+
+
+
+`; diff --git a/src/core/client/admin/test/community/community.spec.tsx b/src/core/client/admin/test/community/community.spec.tsx new file mode 100644 index 000000000..03d4f418a --- /dev/null +++ b/src/core/client/admin/test/community/community.spec.tsx @@ -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); +}); diff --git a/src/core/client/admin/test/create.tsx b/src/core/client/admin/test/create.tsx index 66b1a0a96..cde1414b4 100644 --- a/src/core/client/admin/test/create.tsx +++ b/src/core/client/admin/test/create.tsx @@ -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( - - - , - { createNodeMock } - ); + let testRenderer: ReactTestRenderer; + TestRenderer.act(() => { + testRenderer = TestRenderer.create( + + + , + { createNodeMock } + ); + }); - return { context, testRenderer }; + return { context, testRenderer: testRenderer! }; } diff --git a/src/core/client/admin/test/fixtures.ts b/src/core/client/admin/test/fixtures.ts index f38fdebc4..8f3037c12 100644 --- a/src/core/client/admin/test/fixtures.ts +++ b/src/core/client/admin/test/fixtures.ts @@ -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 }, +}; diff --git a/src/core/client/auth/test/create.tsx b/src/core/client/auth/test/create.tsx index 5a1f8bc0c..66784df84 100644 --- a/src/core/client/auth/test/create.tsx +++ b/src/core/client/auth/test/create.tsx @@ -40,6 +40,7 @@ export default function create(params: CreateParams) { const context: TalkContext = { relayEnvironment: environment, + locales: ["en-US"], localeBundles: [createFluentBundle()], localStorage: createPromisifiedStorage(), sessionStorage: createPromisifiedStorage(), diff --git a/src/core/client/framework/helpers/getViewer.ts b/src/core/client/framework/helpers/getViewer.ts index f12754693..25336ce19 100644 --- a/src/core/client/framework/helpers/getViewer.ts +++ b/src/core/client/framework/helpers/getViewer.ts @@ -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) { diff --git a/src/core/client/framework/helpers/getViewerSourceID.ts b/src/core/client/framework/helpers/getViewerSourceID.ts index 9c3df7099..278887133 100644 --- a/src/core/client/framework/helpers/getViewerSourceID.ts +++ b/src/core/client/framework/helpers/getViewerSourceID.ts @@ -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) { diff --git a/src/core/client/framework/helpers/index.ts b/src/core/client/framework/helpers/index.ts index 8effa12d5..7493a4951 100644 --- a/src/core/client/framework/helpers/index.ts +++ b/src/core/client/framework/helpers/index.ts @@ -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"; diff --git a/src/core/client/framework/lib/bootstrap/TalkContext.tsx b/src/core/client/framework/lib/bootstrap/TalkContext.tsx index 0c65e0b35..49b2479d6 100644 --- a/src/core/client/framework/lib/bootstrap/TalkContext.tsx +++ b/src/core/client/framework/lib/bootstrap/TalkContext.tsx @@ -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; } -const { Provider, Consumer } = React.createContext({} as any); +export const TalkReactContext = React.createContext({} 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 }) => ( - + - + ); diff --git a/src/core/client/framework/lib/bootstrap/createManaged.tsx b/src/core/client/framework/lib/bootstrap/createManaged.tsx index 850986b43..d5125d554 100644 --- a/src/core/client/framework/lib/bootstrap/createManaged.tsx +++ b/src/core/client/framework/lib/bootstrap/createManaged.tsx @@ -238,6 +238,7 @@ export default async function createManaged({ // Assemble context. const context: TalkContext = { relayEnvironment: environment, + locales, localeBundles, timeagoFormatter, pym, diff --git a/src/core/client/framework/lib/bootstrap/index.ts b/src/core/client/framework/lib/bootstrap/index.ts index 0ef2ee79b..ed180cd94 100644 --- a/src/core/client/framework/lib/bootstrap/index.ts +++ b/src/core/client/framework/lib/bootstrap/index.ts @@ -2,6 +2,8 @@ export { TalkContext, TalkContextConsumer, TalkContextProvider, + TalkReactContext, + useTalkContext, } from "./TalkContext"; export { default as createManaged } from "./createManaged"; export { default as withContext } from "./withContext"; diff --git a/src/core/client/framework/schema/custom.ts b/src/core/client/framework/schema/custom.ts new file mode 100644 index 000000000..9cde04f17 --- /dev/null +++ b/src/core/client/framework/schema/custom.ts @@ -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; +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; +export type GQLCOMMENT_STATUS_RL = RelayEnumLiteral; +export type GQLLOCALES_RL = RelayEnumLiteral; +export type GQLMODERATION_MODE_RL = RelayEnumLiteral; +export type GQLSTORY_STATUS_RL = RelayEnumLiteral; +export type GQLUSER_AUTH_CONDITIONS_RL = RelayEnumLiteral< + typeof GQLUSER_AUTH_CONDITIONS +>; diff --git a/src/core/client/framework/schema/index.ts b/src/core/client/framework/schema/index.ts new file mode 100644 index 000000000..f7b918076 --- /dev/null +++ b/src/core/client/framework/schema/index.ts @@ -0,0 +1,2 @@ +export * from "./__generated__/types"; +export * from "./custom"; diff --git a/src/core/client/framework/types.ts b/src/core/client/framework/types.ts index c064c3fa4..6852ca358 100644 --- a/src/core/client/framework/types.ts +++ b/src/core/client/framework/types.ts @@ -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 = keyof T | "%future added value"; diff --git a/src/core/client/stream/helpers/prependCommentEdgeToProfile.ts b/src/core/client/stream/helpers/prependCommentEdgeToProfile.ts index edae8cacf..f8d137751 100644 --- a/src/core/client/stream/helpers/prependCommentEdgeToProfile.ts +++ b/src/core/client/stream/helpers/prependCommentEdgeToProfile.ts @@ -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" diff --git a/src/core/client/stream/test/configure/openOrCloseStream.spec.tsx b/src/core/client/stream/test/configure/openOrCloseStream.spec.tsx index d454cd846..728f841fe 100644 --- a/src/core/client/stream/test/configure/openOrCloseStream.spec.tsx +++ b/src/core/client/stream/test/configure/openOrCloseStream.spec.tsx @@ -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, }, }; diff --git a/src/core/client/stream/test/configure/renderConfigure.spec.tsx b/src/core/client/stream/test/configure/renderConfigure.spec.tsx index 47820ba4f..ed1779667 100644 --- a/src/core/client/stream/test/configure/renderConfigure.spec.tsx +++ b/src/core/client/stream/test/configure/renderConfigure.spec.tsx @@ -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, diff --git a/src/core/client/stream/test/configure/streamConfiguration.spec.tsx b/src/core/client/stream/test/configure/streamConfiguration.spec.tsx index 3b3cdc412..ee9da1afd 100644 --- a/src/core/client/stream/test/configure/streamConfiguration.spec.tsx +++ b/src/core/client/stream/test/configure/streamConfiguration.spec.tsx @@ -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, diff --git a/src/core/client/stream/test/create.tsx b/src/core/client/stream/test/create.tsx index 2aaccc96b..a46c5643c 100644 --- a/src/core/client/stream/test/create.tsx +++ b/src/core/client/stream/test/create.tsx @@ -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( - - - , - { createNodeMock } - ); - - return { context, testRenderer }; + let testRenderer: ReactTestRenderer; + TestRenderer.act(() => { + testRenderer = TestRenderer.create( + + + , + { createNodeMock } + ); + }); + return { context, testRenderer: testRenderer! }; } diff --git a/src/core/client/stream/test/fixtures.ts b/src/core/client/stream/test/fixtures.ts index 772371718..b50d39030 100644 --- a/src/core/client/stream/test/fixtures.ts +++ b/src/core/client/stream/test/fixtures.ts @@ -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", diff --git a/src/core/client/stream/test/profile/loadMore.spec.tsx b/src/core/client/stream/test/profile/loadMore.spec.tsx index 35ddb00b2..ed084ed64 100644 --- a/src/core/client/stream/test/profile/loadMore.spec.tsx +++ b/src/core/client/stream/test/profile/loadMore.spec.tsx @@ -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 => diff --git a/src/core/client/stream/test/profile/renderProfile.spec.tsx b/src/core/client/stream/test/profile/renderProfile.spec.tsx index 28713b476..096f012cc 100644 --- a/src/core/client/stream/test/profile/renderProfile.spec.tsx +++ b/src/core/client/stream/test/profile/renderProfile.spec.tsx @@ -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), }, }; diff --git a/src/core/client/test/mocks.ts b/src/core/client/test/mocks.ts index 65b402ac2..912aa0e1b 100644 --- a/src/core/client/test/mocks.ts +++ b/src/core/client/test/mocks.ts @@ -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, diff --git a/src/core/client/ui/components/Popover/Popover.tsx b/src/core/client/ui/components/Popover/Popover.tsx index acf6f1494..373b62eba 100644 --- a/src/core/client/ui/components/Popover/Popover.tsx +++ b/src/core/client/ui/components/Popover/Popover.tsx @@ -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 { 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(); diff --git a/src/core/client/ui/components/RelativeTime/__snapshots__/RelativeTime.spec.tsx.snap b/src/core/client/ui/components/RelativeTime/__snapshots__/RelativeTime.spec.tsx.snap index 6d9388e87..4c76ba168 100644 --- a/src/core/client/ui/components/RelativeTime/__snapshots__/RelativeTime.spec.tsx.snap +++ b/src/core/client/ui/components/RelativeTime/__snapshots__/RelativeTime.spec.tsx.snap @@ -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 `; diff --git a/src/core/client/ui/components/Table/Table.css b/src/core/client/ui/components/Table/Table.css new file mode 100644 index 000000000..60ab1c748 --- /dev/null +++ b/src/core/client/ui/components/Table/Table.css @@ -0,0 +1,8 @@ +.root { + border-collapse: collapse; + box-sizing: border-box; +} + +.fullWidth { + width: 100%; +} diff --git a/src/core/client/ui/components/Table/Table.mdx b/src/core/client/ui/components/Table/Table.mdx new file mode 100644 index 000000000..24c4635e8 --- /dev/null +++ b/src/core/client/ui/components/Table/Table.mdx @@ -0,0 +1,36 @@ +--- +name: Table +menu: UI Kit +--- + +import { Playground, PropsTable } from "docz"; + +import { Table, TableBody, TableHead, TableRow, TableCell } from "./"; + +# Table + +## Basic usage + + + + + + Username + E-Mail Address + Member Since + + + + + ButFirstCoffee + coffee@mail.com + 01/27/2019 + + + SneaksOnSneaks + hisairness@mail.com + 04/27/2019 + + +
+
diff --git a/src/core/client/ui/components/Table/Table.spec.tsx b/src/core/client/ui/components/Table/Table.spec.tsx new file mode 100644 index 000000000..3f52ded17 --- /dev/null +++ b/src/core/client/ui/components/Table/Table.spec.tsx @@ -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( + + + + Username + E-Mail Address + Member Since + + + + + ButFirstCoffee + coffee@mail.com + 01/27/2019 + + + SneaksOnSneaks + hisairness@mail.com + 04/27/2019 + + +
+ ); + expect(renderer.getRenderOutput()).toMatchSnapshot(); +}); diff --git a/src/core/client/ui/components/Table/Table.tsx b/src/core/client/ui/components/Table/Table.tsx new file mode 100644 index 000000000..4ee9157f1 --- /dev/null +++ b/src/core/client/ui/components/Table/Table.tsx @@ -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 { + className?: string; + /** + * Override or extend the styles applied to the component. + */ + classes: typeof styles; + + fullWidth?: boolean; +} + +const Table: StatelessComponent = ({ + classes, + className, + fullWidth, + children, + ...rest +}) => { + const rootClassName = cn(classes.root, className, { + [classes.fullWidth]: fullWidth, + }); + return ( + + {children} +
+ ); +}; + +const enhanced = withStyles(styles)(Table); +export default enhanced; diff --git a/src/core/client/ui/components/Table/TableBody.css b/src/core/client/ui/components/Table/TableBody.css new file mode 100644 index 000000000..c3a2af639 --- /dev/null +++ b/src/core/client/ui/components/Table/TableBody.css @@ -0,0 +1,2 @@ +.root { +} diff --git a/src/core/client/ui/components/Table/TableBody.tsx b/src/core/client/ui/components/Table/TableBody.tsx new file mode 100644 index 000000000..22ad5ec99 --- /dev/null +++ b/src/core/client/ui/components/Table/TableBody.tsx @@ -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 { + className?: string; + /** + * Override or extend the styles applied to the component. + */ + classes: typeof styles; +} + +const TableBody: StatelessComponent = ({ + classes, + className, + children, + ...rest +}) => { + const rootClassName = cn(classes.root, className); + return ( + + {children} + + ); +}; + +const enhanced = withStyles(styles)(TableBody); +export default enhanced; diff --git a/src/core/client/ui/components/Table/TableCell.css b/src/core/client/ui/components/Table/TableCell.css new file mode 100644 index 000000000..40936a2e7 --- /dev/null +++ b/src/core/client/ui/components/Table/TableCell.css @@ -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; +} diff --git a/src/core/client/ui/components/Table/TableCell.tsx b/src/core/client/ui/components/Table/TableCell.tsx new file mode 100644 index 000000000..70f229181 --- /dev/null +++ b/src/core/client/ui/components/Table/TableCell.tsx @@ -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, "align"> { + className?: string; + /** + * Override or extend the styles applied to the component. + */ + classes: typeof styles; + + align?: "begin" | "center" | "end"; +} + +const TableCell: StatelessComponent = ({ + 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 ( + + {children} + + ); +}; + +const enhanced = withStyles(styles)(TableCell); +export default enhanced; diff --git a/src/core/client/ui/components/Table/TableHead.css b/src/core/client/ui/components/Table/TableHead.css new file mode 100644 index 000000000..e5a3097c6 --- /dev/null +++ b/src/core/client/ui/components/Table/TableHead.css @@ -0,0 +1,5 @@ +.root { + background: var(--palette-grey-lightest); + border: 1px solid var(--palette-grey-lighter); + box-sizing: border-box; +} diff --git a/src/core/client/ui/components/Table/TableHead.tsx b/src/core/client/ui/components/Table/TableHead.tsx new file mode 100644 index 000000000..54db7bfe9 --- /dev/null +++ b/src/core/client/ui/components/Table/TableHead.tsx @@ -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(false); + +interface Props extends React.HTMLAttributes { + className?: string; + /** + * Override or extend the styles applied to the component. + */ + classes: typeof styles; +} + +const TableHead: StatelessComponent = ({ + classes, + className, + children, + ...rest +}) => { + const rootClassName = cn(classes.root, className); + return ( + + + {children} + + + ); +}; + +const enhanced = withStyles(styles)(TableHead); +export default enhanced; diff --git a/src/core/client/ui/components/Table/TableRow.css b/src/core/client/ui/components/Table/TableRow.css new file mode 100644 index 000000000..aab1194cf --- /dev/null +++ b/src/core/client/ui/components/Table/TableRow.css @@ -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); +} diff --git a/src/core/client/ui/components/Table/TableRow.tsx b/src/core/client/ui/components/Table/TableRow.tsx new file mode 100644 index 000000000..84c4f2f8c --- /dev/null +++ b/src/core/client/ui/components/Table/TableRow.tsx @@ -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 { + className?: string; + /** + * Override or extend the styles applied to the component. + */ + classes: typeof styles; +} + +const TableRow: StatelessComponent = ({ + classes, + className, + children, + ...rest +}) => { + const inTableHead = useContext(TableHeadContext); + const rootClassName = cn(classes.root, className, { + [classes.body]: !inTableHead, + }); + return ( + + {children} + + ); +}; + +const enhanced = withStyles(styles)(TableRow); +export default enhanced; diff --git a/src/core/client/ui/components/Table/__snapshots__/Table.spec.tsx.snap b/src/core/client/ui/components/Table/__snapshots__/Table.spec.tsx.snap new file mode 100644 index 000000000..d5c659866 --- /dev/null +++ b/src/core/client/ui/components/Table/__snapshots__/Table.spec.tsx.snap @@ -0,0 +1,51 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`renders correctly 1`] = ` + + + + + Username + + + E-Mail Address + + + Member Since + + + + + + + ButFirstCoffee + + + coffee@mail.com + + + 01/27/2019 + + + + + SneaksOnSneaks + + + hisairness@mail.com + + + 04/27/2019 + + + +
+`; diff --git a/src/core/client/ui/components/Table/index.ts b/src/core/client/ui/components/Table/index.ts new file mode 100644 index 000000000..627b960e7 --- /dev/null +++ b/src/core/client/ui/components/Table/index.ts @@ -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"; diff --git a/src/core/client/ui/components/index.ts b/src/core/client/ui/components/index.ts index 7be2cea72..3863cfa4d 100644 --- a/src/core/client/ui/components/index.ts +++ b/src/core/client/ui/components/index.ts @@ -53,3 +53,4 @@ export { Divider as DropdownDivider, Button as DropdownButton, } from "./Dropdown"; +export { Table, TableBody, TableHead, TableRow, TableCell } from "./Table"; diff --git a/src/core/server/graph/tenant/mutators/Users.ts b/src/core/server/graph/tenant/mutators/Users.ts index 4263370ab..44b473bdc 100644 --- a/src/core/server/graph/tenant/mutators/Users.ts +++ b/src/core/server/graph/tenant/mutators/Users.ts @@ -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), }); diff --git a/src/core/server/services/users/index.ts b/src/core/server/services/users/index.ts index 9c212cb8a..c767f7245 100644 --- a/src/core/server/services/users/index.ts +++ b/src/core/server/services/users/index.ts @@ -345,9 +345,14 @@ export async function updateUsername( export async function updateRole( mongo: Db, tenant: Tenant, + user: Pick, 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); } diff --git a/src/locales/en-US/admin.ftl b/src/locales/en-US/admin.ftl index bdeed1933..b02c0610c 100644 --- a/src/locales/en-US/admin.ftl +++ b/src/locales/en-US/admin.ftl @@ -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