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