[CORL-155] User Suspending and Banning (#2247)

* feat: suspending, banning, now propogation

* feat: adapting to `now`

* feat: support auth for suspension/banned

* feat: added trace-id to requests

* feat: new mutation api with hooks support

* feat: added user status filtering, current field

* feat: Implement filter by status, adapt to new USER_STATUS type, add lookup helper <3

* fix: typo

* fix: tests

* chore: rename banned status to ban status

* test: feature test + lots of test helper improvements e.g. types

* fix: add translation to ban user modal

* fix: translation

* fix: test
This commit is contained in:
Wyatt Johnson
2019-04-22 22:57:32 +00:00
committed by GitHub
parent b63c00f26f
commit dbbc1af42e
147 changed files with 4609 additions and 1468 deletions
+9 -9
View File
@@ -2670,9 +2670,9 @@
"integrity": "sha512-1w52Nyx4Gq47uuu0EVcsHBxZFJgurQ+rTKS3qMHxR1GY2T8c2AJYd6vZoZ9q1rupaDjU0yT+Jc2XTyXkjeMA+Q=="
},
"@types/luxon": {
"version": "0.5.3",
"resolved": "https://registry.npmjs.org/@types/luxon/-/luxon-0.5.3.tgz",
"integrity": "sha512-YiVw0M9q9CeynfRKhZYaX2/aCXlCIBpM4eARlPXdv+XVoGVb5iPFaZIlKiMUJ8eWKOhlqi8U6GvOAn8yhR4//Q==",
"version": "1.12.0",
"resolved": "https://registry.npmjs.org/@types/luxon/-/luxon-1.12.0.tgz",
"integrity": "sha512-+UzPmwHSEEyv7aGlNkVpuFxp/BirXgl8NnPGCtmyx2KXIzAapoW3IqSVk87/Z3PUk8vEL8Pe1HXEMJbNBOQgtg==",
"dev": true
},
"@types/marked": {
@@ -18199,9 +18199,9 @@
}
},
"luxon": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/luxon/-/luxon-1.3.1.tgz",
"integrity": "sha512-GnX4PVEfDcTZT3xDQhm2jvEyZTxfi2b0gOmkJuwrrggA1b46e8ZshmPYePA6gdAT6mvvOxYnGFfb3iE9ZsWn9g=="
"version": "1.12.0",
"resolved": "https://registry.npmjs.org/luxon/-/luxon-1.12.0.tgz",
"integrity": "sha512-enPnPIHd5ZnZT0vpj9Xv8aq4j0yueAkhnh4xUKUHpqlgSm1r/8s6xTMjfyp2ugOWP7zivqJqgVTkW+rpHed61w=="
},
"lz-string": {
"version": "1.4.4",
@@ -28190,9 +28190,9 @@
"dev": true
},
"typescript": {
"version": "3.1.6",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-3.1.6.tgz",
"integrity": "sha512-tDMYfVtvpb96msS1lDX9MEdHrW4yOuZ4Kdc4Him9oU796XldPYF/t2+uKoX0BBa0hXXwDlqYQbXY5Rzjzc5hBA==",
"version": "3.3.4000",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-3.3.4000.tgz",
"integrity": "sha512-jjOcCZvpkl2+z7JFn0yBOoLQyLoIkNZAs/fYJkUG6VKy6zLPHJGfQJYFHzibB6GJaF/8QrcECtlQ5cpvRHSMEA==",
"dev": true
},
"typescript-snapshots-plugin": {
+2 -2
View File
@@ -156,7 +156,7 @@
"@types/linkify-it": "^2.0.4",
"@types/linkifyjs": "^2.1.0",
"@types/lodash": "^4.14.118",
"@types/luxon": "^0.5.3",
"@types/luxon": "^1.12.0",
"@types/marked": "^0.6.0",
"@types/mini-css-extract-plugin": "^0.2.0",
"@types/mongodb": "^3.1.22",
@@ -302,7 +302,7 @@
"typed-css-modules": "^0.3.4",
"typeface-manuale": "0.0.54",
"typeface-source-sans-pro": "0.0.54",
"typescript": "3.1.6",
"typescript": "3.3.4000",
"typescript-snapshots-plugin": "^1.2.0",
"wait-for-expect": "^1.1.0",
"webpack": "^4.27.1",
@@ -2,20 +2,17 @@ import { Match, Router, withRouter } from "found";
import React from "react";
import { AuthCheckContainerQueryResponse } from "talk-admin/__generated__/AuthCheckContainerQuery.graphql";
import {
SetRedirectPathMutation,
withSetRedirectPathMutation,
} from "talk-admin/mutations";
import { SetRedirectPathMutation } from "talk-admin/mutations";
import { AbilityType, can } from "talk-admin/permissions";
import RestrictedContainer from "talk-admin/views/restricted/containers/RestrictedContainer";
import { graphql } from "talk-framework/lib/relay";
import { graphql, MutationProp, withMutation } from "talk-framework/lib/relay";
import { withRouteConfig } from "talk-framework/lib/router";
import { GQLUSER_ROLE } from "talk-framework/schema";
interface Props {
match: Match;
router: Router;
setRedirectPath: SetRedirectPathMutation;
setRedirectPath: MutationProp<typeof SetRedirectPathMutation>;
data:
| AuthCheckContainerQueryResponse & {
route: {
@@ -124,6 +121,6 @@ const enhanced = withRouteConfig({
}
}
`,
})(withRouter(withSetRedirectPathMutation(AuthCheckContainer)));
})(withRouter(withMutation(SetRedirectPathMutation)(AuthCheckContainer)));
export default enhanced;
@@ -1,80 +1,69 @@
import { graphql } from "react-relay";
import { ConnectionHandler, Environment } from "relay-runtime";
import {
commitMutationPromiseNormalized,
createMutationContainer,
MutationInput,
MutationResponsePromise,
} from "talk-framework/lib/relay";
import { AcceptCommentMutation as MutationTypes } from "talk-admin/__generated__/AcceptCommentMutation.graphql";
import { getQueueConnection } from "talk-admin/helpers";
export type AcceptCommentInput = MutationInput<MutationTypes>;
const mutation = graphql`
mutation AcceptCommentMutation($input: AcceptCommentInput!) {
acceptComment(input: $input) {
comment {
id
status
}
moderationQueues {
unmoderated {
count
}
reported {
count
}
pending {
count
}
}
clientMutationId
}
}
`;
import {
commitMutationPromiseNormalized,
createMutation,
MutationInput,
} from "talk-framework/lib/relay";
let clientMutationId = 0;
function commit(environment: Environment, input: AcceptCommentInput) {
return commitMutationPromiseNormalized<MutationTypes>(environment, {
mutation,
variables: {
input: {
...input,
clientMutationId: clientMutationId.toString(),
},
},
optimisticResponse: {
acceptComment: {
comment: {
id: input.commentID,
status: "ACCEPTED",
},
clientMutationId: (clientMutationId++).toString(),
},
} as any, // TODO: (cvle) generated types should contain one for the optimistic response.
updater: store => {
const connections = [
getQueueConnection("reported", store),
getQueueConnection("pending", store),
getQueueConnection("unmoderated", store),
getQueueConnection("rejected", store),
].filter(c => c);
connections.forEach(con =>
ConnectionHandler.deleteNode(con, input.commentID)
);
},
});
}
export const withAcceptCommentMutation = createMutationContainer(
const AcceptCommentMutation = createMutation(
"acceptComment",
commit
(environment: Environment, input: MutationInput<MutationTypes>) =>
commitMutationPromiseNormalized<MutationTypes>(environment, {
mutation: graphql`
mutation AcceptCommentMutation($input: AcceptCommentInput!) {
acceptComment(input: $input) {
comment {
id
status
}
moderationQueues {
unmoderated {
count
}
reported {
count
}
pending {
count
}
}
clientMutationId
}
}
`,
variables: {
input: {
...input,
clientMutationId: clientMutationId.toString(),
},
},
optimisticResponse: {
acceptComment: {
comment: {
id: input.commentID,
status: "ACCEPTED",
},
clientMutationId: (clientMutationId++).toString(),
},
},
updater: store => {
const connections = [
getQueueConnection("reported", store),
getQueueConnection("pending", store),
getQueueConnection("unmoderated", store),
getQueueConnection("rejected", store),
].filter(c => c);
connections.forEach(con =>
ConnectionHandler.deleteNode(con, input.commentID)
);
},
})
);
export type AcceptCommentMutation = (
input: AcceptCommentInput
) => MutationResponsePromise<MutationTypes, "acceptComment">;
export default AcceptCommentMutation;
@@ -0,0 +1,62 @@
import { graphql } from "react-relay";
import { Environment } from "relay-runtime";
import { BanUserMutation as MutationTypes } from "talk-admin/__generated__/BanUserMutation.graphql";
import {
commitMutationPromiseNormalized,
createMutation,
lookup,
MutationInput,
} from "talk-framework/lib/relay";
import { GQLUser, GQLUSER_STATUS } from "talk-framework/schema";
let clientMutationId = 0;
const BanUserMutation = createMutation(
"banUser",
(environment: Environment, input: MutationInput<MutationTypes>) => {
return commitMutationPromiseNormalized<MutationTypes>(environment, {
mutation: graphql`
mutation BanUserMutation($input: BanUserInput!) {
banUser(input: $input) {
user {
id
status {
current
ban {
active
}
}
}
clientMutationId
}
}
`,
variables: {
input: {
...input,
clientMutationId: clientMutationId.toString(),
},
},
optimisticResponse: {
banUser: {
user: {
id: input.userID,
status: {
current: lookup<GQLUser>(
environment,
input.userID
)!.status!.current!.concat([GQLUSER_STATUS.BANNED]),
ban: {
active: true,
},
},
},
clientMutationId: (clientMutationId++).toString(),
},
},
});
}
);
export default BanUserMutation;
@@ -1,18 +1,15 @@
import { commitLocalUpdate, Environment } from "relay-runtime";
import { createMutationContainer } from "talk-framework/lib/relay";
import { createMutation } from "talk-framework/lib/relay";
import { LOCAL_ID } from "talk-framework/lib/relay/withLocalStateContainer";
export type ClearAuthErrorMutation = () => Promise<void>;
export async function commit(environment: Environment, input: undefined) {
return commitLocalUpdate(environment, store => {
const record = store.get(LOCAL_ID)!;
record.setValue(null, "authError");
});
}
export const withClearAuthErrorMutation = createMutationContainer(
const ClearAuthErrorMutation = createMutation(
"clearAuthError",
commit
(environment: Environment) =>
commitLocalUpdate(environment, store => {
const record = store.get(LOCAL_ID)!;
record.setValue(null, "authError");
})
);
export default ClearAuthErrorMutation;
@@ -1,62 +1,51 @@
import { graphql } from "react-relay";
import { Environment } from "relay-runtime";
import { CloseStoryMutation as MutationTypes } from "talk-admin/__generated__/CloseStoryMutation.graphql";
import {
commitMutationPromiseNormalized,
createMutationContainer,
createMutation,
MutationInput,
MutationResponsePromise,
} from "talk-framework/lib/relay";
import { CloseStoryMutation as MutationTypes } from "talk-admin/__generated__/CloseStoryMutation.graphql";
import { GQLSTORY_STATUS } from "talk-framework/schema";
export type CloseStoryInput = MutationInput<MutationTypes>;
const mutation = graphql`
mutation CloseStoryMutation($input: CloseStoryInput!) {
closeStory(input: $input) {
story {
id
status
closedAt
isClosed
}
clientMutationId
}
}
`;
let clientMutationId = 0;
function commit(environment: Environment, input: CloseStoryInput) {
return commitMutationPromiseNormalized<MutationTypes>(environment, {
mutation,
optimisticResponse: {
closeStory: {
story: {
id: input.id,
status: GQLSTORY_STATUS.CLOSED,
closedAt: new Date().toISOString(),
isClosed: true,
},
clientMutationId: clientMutationId.toString(),
},
},
variables: {
input: {
...input,
clientMutationId: (clientMutationId++).toString(),
},
},
});
}
export const withCloseStoryMutation = createMutationContainer(
const CloseStoryMutation = createMutation(
"closeStory",
commit
(environment: Environment, input: MutationInput<MutationTypes>) =>
commitMutationPromiseNormalized<MutationTypes>(environment, {
mutation: graphql`
mutation CloseStoryMutation($input: CloseStoryInput!) {
closeStory(input: $input) {
story {
id
status
closedAt
isClosed
}
clientMutationId
}
}
`,
optimisticResponse: {
closeStory: {
story: {
id: input.id,
status: GQLSTORY_STATUS.CLOSED,
closedAt: new Date().toISOString(),
isClosed: true,
},
clientMutationId: clientMutationId.toString(),
},
},
variables: {
input: {
...input,
clientMutationId: (clientMutationId++).toString(),
},
},
})
);
export type CloseStoryMutation = (
input: CloseStoryInput
) => MutationResponsePromise<MutationTypes, "closeStory">;
export default CloseStoryMutation;
@@ -1,29 +1,23 @@
import { Environment } from "relay-runtime";
import { TalkContext } from "talk-framework/lib/bootstrap";
import { createMutationContainer } from "talk-framework/lib/relay";
import { createMutation } from "talk-framework/lib/relay";
import { commit as setAccessToken } from "talk-framework/mutations/SetAccessTokenMutation";
interface CompleteAccountInput {
accessToken: string;
}
export type CompleteAccountMutation = (
input: CompleteAccountInput
) => Promise<void>;
export async function commit(
environment: Environment,
input: CompleteAccountInput,
context: TalkContext
) {
await setAccessToken(
environment,
{ accessToken: input.accessToken },
context
);
}
export const withCompleteAccountMutation = createMutationContainer(
const CompleteAccountMutation = createMutation(
"completeAccount",
commit
async (
environment: Environment,
input: {
accessToken: string;
},
context: TalkContext
) =>
await setAccessToken(
environment,
{ accessToken: input.accessToken },
context
)
);
export default CompleteAccountMutation;
@@ -1,62 +1,51 @@
import { graphql } from "react-relay";
import { Environment } from "relay-runtime";
import { OpenStoryMutation as MutationTypes } from "talk-admin/__generated__/OpenStoryMutation.graphql";
import {
commitMutationPromiseNormalized,
createMutationContainer,
createMutation,
MutationInput,
MutationResponsePromise,
} from "talk-framework/lib/relay";
import { OpenStoryMutation as MutationTypes } from "talk-admin/__generated__/OpenStoryMutation.graphql";
import { GQLSTORY_STATUS } from "talk-framework/schema";
export type OpenStoryInput = MutationInput<MutationTypes>;
const mutation = graphql`
mutation OpenStoryMutation($input: OpenStoryInput!) {
openStory(input: $input) {
story {
id
status
closedAt
isClosed
}
clientMutationId
}
}
`;
let clientMutationId = 0;
function commit(environment: Environment, input: OpenStoryInput) {
return commitMutationPromiseNormalized<MutationTypes>(environment, {
mutation,
optimisticResponse: {
openStory: {
story: {
id: input.id,
status: GQLSTORY_STATUS.OPEN,
closedAt: null,
isClosed: false,
},
clientMutationId: clientMutationId.toString(),
},
},
variables: {
input: {
...input,
clientMutationId: (clientMutationId++).toString(),
},
},
});
}
export const withOpenStoryMutation = createMutationContainer(
const OpenStoryMutation = createMutation(
"openStory",
commit
(environment: Environment, input: MutationInput<MutationTypes>) =>
commitMutationPromiseNormalized<MutationTypes>(environment, {
mutation: graphql`
mutation OpenStoryMutation($input: OpenStoryInput!) {
openStory(input: $input) {
story {
id
status
closedAt
isClosed
}
clientMutationId
}
}
`,
optimisticResponse: {
openStory: {
story: {
id: input.id,
status: GQLSTORY_STATUS.OPEN,
closedAt: null,
isClosed: false,
},
clientMutationId: clientMutationId.toString(),
},
},
variables: {
input: {
...input,
clientMutationId: (clientMutationId++).toString(),
},
},
})
);
export type OpenStoryMutation = (
input: OpenStoryInput
) => MutationResponsePromise<MutationTypes, "openStory">;
export default OpenStoryMutation;
@@ -1,51 +1,41 @@
import { graphql } from "react-relay";
import { Environment } from "relay-runtime";
import { RegenerateSSOKeyMutation as MutationTypes } from "talk-admin/__generated__/RegenerateSSOKeyMutation.graphql";
import {
commitMutationPromiseNormalized,
createMutationContainer,
MutationResponsePromise,
createMutation,
} from "talk-framework/lib/relay";
import { RegenerateSSOKeyMutation as MutationTypes } from "talk-admin/__generated__/RegenerateSSOKeyMutation.graphql";
const mutation = graphql`
mutation RegenerateSSOKeyMutation($input: RegenerateSSOKeyInput!) {
regenerateSSOKey(input: $input) {
settings {
auth {
integrations {
sso {
key
keyGeneratedAt
}
}
}
}
clientMutationId
}
}
`;
let clientMutationId = 0;
function commit(environment: Environment) {
return commitMutationPromiseNormalized<MutationTypes>(environment, {
mutation,
variables: {
input: {
clientMutationId: (clientMutationId++).toString(),
},
},
});
}
export const withRegenerateSSOKeyMutation = createMutationContainer(
const RegenerateSSOKeyMutation = createMutation(
"regenerateSSOKey",
commit
(environment: Environment) =>
commitMutationPromiseNormalized<MutationTypes>(environment, {
mutation: graphql`
mutation RegenerateSSOKeyMutation($input: RegenerateSSOKeyInput!) {
regenerateSSOKey(input: $input) {
settings {
auth {
integrations {
sso {
key
keyGeneratedAt
}
}
}
}
clientMutationId
}
}
`,
variables: {
input: {
clientMutationId: (clientMutationId++).toString(),
},
},
})
);
export type RegenerateSSOKeyMutation = () => MutationResponsePromise<
MutationTypes,
"regenerateSSOKey"
>;
export default RegenerateSSOKeyMutation;
@@ -1,79 +1,68 @@
import { graphql } from "react-relay";
import { ConnectionHandler, Environment } from "relay-runtime";
import {
commitMutationPromiseNormalized,
createMutationContainer,
MutationInput,
MutationResponsePromise,
} from "talk-framework/lib/relay";
import { RejectCommentMutation as MutationTypes } from "talk-admin/__generated__/RejectCommentMutation.graphql";
import { getQueueConnection } from "talk-admin/helpers";
export type RejectCommentInput = MutationInput<MutationTypes>;
const mutation = graphql`
mutation RejectCommentMutation($input: RejectCommentInput!) {
rejectComment(input: $input) {
comment {
id
status
}
moderationQueues {
unmoderated {
count
}
reported {
count
}
pending {
count
}
}
clientMutationId
}
}
`;
import {
commitMutationPromiseNormalized,
createMutation,
MutationInput,
} from "talk-framework/lib/relay";
let clientMutationId = 0;
function commit(environment: Environment, input: RejectCommentInput) {
return commitMutationPromiseNormalized<MutationTypes>(environment, {
mutation,
variables: {
input: {
...input,
clientMutationId: clientMutationId.toString(),
},
},
optimisticResponse: {
rejectComment: {
comment: {
id: input.commentID,
status: "REJECTED",
},
clientMutationId: (clientMutationId++).toString(),
},
} as any, // TODO: (cvle) generated types should contain one for the optimistic response.
updater: store => {
const connections = [
getQueueConnection("reported", store),
getQueueConnection("pending", store),
getQueueConnection("unmoderated", store),
].filter(c => c);
connections.forEach(con =>
ConnectionHandler.deleteNode(con, input.commentID)
);
},
});
}
export const withRejectCommentMutation = createMutationContainer(
const RejectCommentMutation = createMutation(
"rejectComment",
commit
(environment: Environment, input: MutationInput<MutationTypes>) =>
commitMutationPromiseNormalized<MutationTypes>(environment, {
mutation: graphql`
mutation RejectCommentMutation($input: RejectCommentInput!) {
rejectComment(input: $input) {
comment {
id
status
}
moderationQueues {
unmoderated {
count
}
reported {
count
}
pending {
count
}
}
clientMutationId
}
}
`,
variables: {
input: {
...input,
clientMutationId: clientMutationId.toString(),
},
},
optimisticResponse: {
rejectComment: {
comment: {
id: input.commentID,
status: "REJECTED",
},
clientMutationId: (clientMutationId++).toString(),
},
},
updater: store => {
const connections = [
getQueueConnection("reported", store),
getQueueConnection("pending", store),
getQueueConnection("unmoderated", store),
].filter(c => c);
connections.forEach(con =>
ConnectionHandler.deleteNode(con, input.commentID)
);
},
})
);
export type RejectCommentMutation = (
input: RejectCommentInput
) => MutationResponsePromise<MutationTypes, "rejectComment">;
export default RejectCommentMutation;
@@ -0,0 +1,61 @@
import { graphql } from "react-relay";
import { Environment } from "relay-runtime";
import { RemoveUserBanMutation as MutationTypes } from "talk-admin/__generated__/RemoveUserBanMutation.graphql";
import {
commitMutationPromiseNormalized,
createMutation,
lookup,
MutationInput,
} from "talk-framework/lib/relay";
import { GQLUser, GQLUSER_STATUS } from "talk-framework/schema";
let clientMutationId = 0;
const RemoveUserBanMutation = createMutation(
"removeUserBan",
(environment: Environment, input: MutationInput<MutationTypes>) =>
commitMutationPromiseNormalized<MutationTypes>(environment, {
mutation: graphql`
mutation RemoveUserBanMutation($input: RemoveUserBanInput!) {
removeUserBan(input: $input) {
user {
id
status {
current
ban {
active
}
}
}
clientMutationId
}
}
`,
variables: {
input: {
...input,
clientMutationId: clientMutationId.toString(),
},
},
optimisticResponse: {
removeUserBan: {
user: {
id: input.userID,
status: {
current: lookup<GQLUser>(
environment,
input.userID
)!.status!.current!.filter(s => s !== GQLUSER_STATUS.BANNED),
ban: {
active: false,
},
},
},
clientMutationId: (clientMutationId++).toString(),
},
},
})
);
export default RemoveUserBanMutation;
@@ -3,7 +3,7 @@ import { Environment, RecordSource } from "relay-runtime";
import { LOCAL_ID } from "talk-framework/lib/relay";
import { createRelayEnvironment } from "talk-framework/testHelpers";
import { commit } from "./SetAuthViewMutation";
import SetAuthViewMutation from "./SetAuthViewMutation";
let environment: Environment;
const source: RecordSource = new RecordSource();
@@ -16,6 +16,6 @@ beforeAll(() => {
it("Sets view", () => {
const view = "SIGN_IN";
commit(environment, { view }, {} as any);
SetAuthViewMutation.commit(environment, { view }, {} as any);
expect(source.get(LOCAL_ID)!.authView).toEqual(view);
});
@@ -1,7 +1,6 @@
import { commitLocalUpdate, Environment } from "relay-runtime";
import { TalkContext } from "talk-framework/lib/bootstrap";
import { createMutationContainer } from "talk-framework/lib/relay";
import { createMutation } from "talk-framework/lib/relay";
import { LOCAL_ID } from "talk-framework/lib/relay/withLocalStateContainer";
export interface SetAuthViewInput {
@@ -9,20 +8,13 @@ export interface SetAuthViewInput {
view: "SIGN_IN" | "ADD_EMAIL_ADDRESS" | "CREATE_USERNAME" | "CREATE_PASSWORD";
}
export type SetAuthViewMutation = (input: SetAuthViewInput) => Promise<void>;
export async function commit(
environment: Environment,
input: SetAuthViewInput,
{ pym }: TalkContext
) {
return commitLocalUpdate(environment, store => {
const record = store.get(LOCAL_ID)!;
record.setValue(input.view, "authView");
});
}
export const withSetAuthViewMutation = createMutationContainer(
const SetAuthViewMutation = createMutation(
"setAuthView",
commit
(environment: Environment, input: SetAuthViewInput) =>
commitLocalUpdate(environment, store => {
const record = store.get(LOCAL_ID)!;
record.setValue(input.view, "authView");
})
);
export default SetAuthViewMutation;
@@ -1,44 +1,36 @@
import { graphql } from "react-relay";
import { Environment } from "relay-runtime";
import { SetEmailMutation as MutationTypes } from "talk-admin/__generated__/SetEmailMutation.graphql";
import {
commitMutationPromiseNormalized,
createMutationContainer,
createMutation,
MutationInput,
MutationResponsePromise,
} from "talk-framework/lib/relay";
import { SetEmailMutation as MutationTypes } from "talk-admin/__generated__/SetEmailMutation.graphql";
export type SetEmailInput = MutationInput<MutationTypes>;
const mutation = graphql`
mutation SetEmailMutation($input: SetEmailInput!) {
setEmail(input: $input) {
user {
email
}
clientMutationId
}
}
`;
let clientMutationId = 0;
function commit(environment: Environment, input: SetEmailInput) {
return commitMutationPromiseNormalized<MutationTypes>(environment, {
mutation,
variables: {
input: {
...input,
clientMutationId: (clientMutationId++).toString(),
const SetEmailMutation = createMutation(
"setEmail",
(environment: Environment, input: MutationInput<MutationTypes>) =>
commitMutationPromiseNormalized<MutationTypes>(environment, {
mutation: graphql`
mutation SetEmailMutation($input: SetEmailInput!) {
setEmail(input: $input) {
user {
email
}
clientMutationId
}
}
`,
variables: {
input: {
...input,
clientMutationId: (clientMutationId++).toString(),
},
},
},
});
}
})
);
export const withSetEmailMutation = createMutationContainer("setEmail", commit);
export type SetEmailMutation = (
input: SetEmailInput
) => MutationResponsePromise<MutationTypes, "setEmail">;
export default SetEmailMutation;
@@ -1,49 +1,38 @@
import { graphql } from "react-relay";
import { Environment } from "relay-runtime";
import { SetPasswordMutation as MutationTypes } from "talk-admin/__generated__/SetPasswordMutation.graphql";
import {
commitMutationPromiseNormalized,
createMutationContainer,
createMutation,
MutationInput,
MutationResponsePromise,
} from "talk-framework/lib/relay";
import { SetPasswordMutation as MutationTypes } from "talk-admin/__generated__/SetPasswordMutation.graphql";
export type SetPasswordInput = MutationInput<MutationTypes>;
const mutation = graphql`
mutation SetPasswordMutation($input: SetPasswordInput!) {
setPassword(input: $input) {
user {
profiles {
__typename
}
}
clientMutationId
}
}
`;
let clientMutationId = 0;
function commit(environment: Environment, input: SetPasswordInput) {
return commitMutationPromiseNormalized<MutationTypes>(environment, {
mutation,
variables: {
input: {
...input,
clientMutationId: (clientMutationId++).toString(),
},
},
});
}
export const withSetPasswordMutation = createMutationContainer(
const SetPasswordMutation = createMutation(
"setPassword",
commit
(environment: Environment, input: MutationInput<MutationTypes>) =>
commitMutationPromiseNormalized<MutationTypes>(environment, {
mutation: graphql`
mutation SetPasswordMutation($input: SetPasswordInput!) {
setPassword(input: $input) {
user {
profiles {
__typename
}
}
clientMutationId
}
}
`,
variables: {
input: {
...input,
clientMutationId: (clientMutationId++).toString(),
},
},
})
);
export type SetPasswordMutation = (
input: SetPasswordInput
) => MutationResponsePromise<MutationTypes, "setPassword">;
export default SetPasswordMutation;
@@ -8,7 +8,7 @@ import {
createInMemoryStorage,
createPromisifiedStorage,
} from "talk-framework/lib/storage";
import { commit } from "./SetRedirectPathMutation";
import SetRedirectPathMutation from "./SetRedirectPathMutation";
let environment: Environment;
const source: RecordSource = new RecordSource();
@@ -21,7 +21,7 @@ beforeAll(() => {
it("Sets redirectPath", async () => {
const storage = createInMemoryStorage();
await commit(environment, { path: "/path" }, {
await SetRedirectPathMutation.commit(environment, { path: "/path" }, {
localStorage: createPromisifiedStorage(storage),
} as any);
expect(source.get(LOCAL_ID)!.redirectPath).toEqual("/path");
@@ -30,7 +30,7 @@ it("Sets redirectPath", async () => {
it("Removes redirectPath", async () => {
const storage = createInMemoryStorage();
await commit(environment, { path: null }, {
await SetRedirectPathMutation.commit(environment, { path: null }, {
localStorage: createPromisifiedStorage(storage),
} as any);
expect(source.get(LOCAL_ID)!.redirectPath).toEqual(null);
@@ -2,34 +2,30 @@ import { commitLocalUpdate, Environment } from "relay-runtime";
import { REDIRECT_PATH_KEY } from "talk-admin/constants";
import { TalkContext } from "talk-framework/lib/bootstrap";
import { createMutationContainer, LOCAL_ID } from "talk-framework/lib/relay";
import { createMutation, LOCAL_ID } from "talk-framework/lib/relay";
export interface SetRedirectPathInput {
path: string | null;
}
export type SetRedirectPathMutation = (
input: SetRedirectPathInput
) => Promise<void>;
export async function commit(
environment: Environment,
input: SetRedirectPathInput,
{ localStorage }: TalkContext
) {
if (!input.path) {
await localStorage.removeItem(REDIRECT_PATH_KEY);
} else {
await localStorage.setItem(REDIRECT_PATH_KEY, input.path);
}
return commitLocalUpdate(environment, store => {
const record = store.get(LOCAL_ID)!;
record.setValue(input.path, "redirectPath");
});
}
export const withSetRedirectPathMutation = createMutationContainer(
const SetRedirectPathMutation = createMutation(
"setRedirectPath",
commit
async (
environment: Environment,
input: SetRedirectPathInput,
{ localStorage }: TalkContext
) => {
if (!input.path) {
await localStorage.removeItem(REDIRECT_PATH_KEY);
} else {
await localStorage.setItem(REDIRECT_PATH_KEY, input.path);
}
return commitLocalUpdate(environment, store => {
const record = store.get(LOCAL_ID)!;
record.setValue(input.path, "redirectPath");
});
}
);
export default SetRedirectPathMutation;
@@ -1,47 +1,36 @@
import { graphql } from "react-relay";
import { Environment } from "relay-runtime";
import { SetUsernameMutation as MutationTypes } from "talk-admin/__generated__/SetUsernameMutation.graphql";
import {
commitMutationPromiseNormalized,
createMutationContainer,
createMutation,
MutationInput,
MutationResponsePromise,
} from "talk-framework/lib/relay";
import { SetUsernameMutation as MutationTypes } from "talk-admin/__generated__/SetUsernameMutation.graphql";
export type SetUsernameInput = MutationInput<MutationTypes>;
const mutation = graphql`
mutation SetUsernameMutation($input: SetUsernameInput!) {
setUsername(input: $input) {
user {
username
}
clientMutationId
}
}
`;
let clientMutationId = 0;
function commit(environment: Environment, input: SetUsernameInput) {
return commitMutationPromiseNormalized<MutationTypes>(environment, {
mutation,
variables: {
input: {
...input,
clientMutationId: (clientMutationId++).toString(),
},
},
});
}
export const withSetUsernameMutation = createMutationContainer(
const SetUsernameMutation = createMutation(
"setUsername",
commit
(environment: Environment, input: MutationInput<MutationTypes>) =>
commitMutationPromiseNormalized<MutationTypes>(environment, {
mutation: graphql`
mutation SetUsernameMutation($input: SetUsernameInput!) {
setUsername(input: $input) {
user {
username
}
clientMutationId
}
}
`,
variables: {
input: {
...input,
clientMutationId: (clientMutationId++).toString(),
},
},
})
);
export type SetUsernameMutation = (
input: SetUsernameInput
) => MutationResponsePromise<MutationTypes, "setUsername">;
export default SetUsernameMutation;
@@ -1,24 +1,25 @@
import { Environment } from "relay-runtime";
import { TalkContext } from "talk-framework/lib/bootstrap";
import { createMutationContainer } from "talk-framework/lib/relay";
import { createMutation } from "talk-framework/lib/relay";
import { signIn, SignInInput } from "talk-framework/rest";
export type SignInMutation = (input: SignInInput) => Promise<void>;
const SignInMutation = createMutation(
"signIn",
async (
environment: Environment,
input: SignInInput,
context: TalkContext
) => {
const result = await signIn(context.rest, input);
export async function commit(
environment: Environment,
input: SignInInput,
context: TalkContext
) {
const result = await signIn(context.rest, input);
// Put the token on the hash and clean the session.
// It'll be picked up by initLocalState.
location.hash = `accessToken=${result.token}`;
await context.clearSession();
// TODO: (cvle) A better way would be if `context.clearSession` would return the new session and
// we set the accessToken directly in there.
}
);
// Put the token on the hash and clean the session.
// It'll be picked up by initLocalState.
location.hash = `accessToken=${result.token}`;
await context.clearSession();
// TODO: (cvle) A better way would be if `context.clearSession` would return the new session and
// we set the accessToken directly in there.
}
export const withSignInMutation = createMutationContainer("signIn", commit);
export default SignInMutation;
@@ -1,54 +1,43 @@
import { graphql } from "react-relay";
import { Environment } from "relay-runtime";
import { UpdateSettingsMutation as MutationTypes } from "talk-admin/__generated__/UpdateSettingsMutation.graphql";
import {
commitMutationPromiseNormalized,
createMutationContainer,
createMutation,
MutationInput,
MutationResponsePromise,
} from "talk-framework/lib/relay";
import { UpdateSettingsMutation as MutationTypes } from "talk-admin/__generated__/UpdateSettingsMutation.graphql";
export type UpdateSettingsInput = MutationInput<MutationTypes>;
const mutation = graphql`
mutation UpdateSettingsMutation($input: UpdateSettingsInput!) {
updateSettings(input: $input) {
settings {
auth {
...AuthConfigContainer_auth
}
...ModerationConfigContainer_settings
...GeneralConfigContainer_settings
...OrganizationConfigContainer_settings
...WordListConfigContainer_settings
...AdvancedConfigContainer_settings
}
clientMutationId
}
}
`;
let clientMutationId = 0;
function commit(environment: Environment, input: UpdateSettingsInput) {
return commitMutationPromiseNormalized<MutationTypes>(environment, {
mutation,
variables: {
input: {
...input,
clientMutationId: (clientMutationId++).toString(),
},
},
});
}
export const withUpdateSettingsMutation = createMutationContainer(
const UpdateSettingsMutation = createMutation(
"updateSettings",
commit
(environment: Environment, input: MutationInput<MutationTypes>) =>
commitMutationPromiseNormalized<MutationTypes>(environment, {
mutation: graphql`
mutation UpdateSettingsMutation($input: UpdateSettingsInput!) {
updateSettings(input: $input) {
settings {
auth {
...AuthConfigContainer_auth
}
...ModerationConfigContainer_settings
...GeneralConfigContainer_settings
...OrganizationConfigContainer_settings
...WordListConfigContainer_settings
...AdvancedConfigContainer_settings
}
clientMutationId
}
}
`,
variables: {
input: {
...input,
clientMutationId: (clientMutationId++).toString(),
},
},
})
);
export type UpdateSettingsMutation = (
input: UpdateSettingsInput
) => MutationResponsePromise<MutationTypes, "updateSettings">;
export default UpdateSettingsMutation;
@@ -1,56 +1,46 @@
import { graphql } from "react-relay";
import { Environment } from "relay-runtime";
import { UpdateUserRoleMutation as MutationTypes } from "talk-admin/__generated__/UpdateUserRoleMutation.graphql";
import {
commitMutationPromiseNormalized,
createMutationContainer,
createMutation,
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(
const UpdateUserRoleMutation = createMutation(
"updateUserRole",
commit
(environment: Environment, input: MutationInput<MutationTypes>) =>
commitMutationPromiseNormalized<MutationTypes>(environment, {
mutation: graphql`
mutation UpdateUserRoleMutation($input: UpdateUserRoleInput!) {
updateUserRole(input: $input) {
user {
id
role
}
clientMutationId
}
}
`,
optimisticResponse: {
updateUserRole: {
user: {
id: input.userID,
role: input.role,
},
clientMutationId: clientMutationId.toString(),
},
},
variables: {
input: {
...input,
clientMutationId: (clientMutationId++).toString(),
},
},
})
);
export type UpdateUserRoleMutation = (
input: UpdateUserRoleInput
) => MutationResponsePromise<MutationTypes, "updateUserRole">;
export default UpdateUserRoleMutation;
+17 -55
View File
@@ -1,57 +1,19 @@
export { withSignInMutation, SignInMutation } from "./SignInMutation";
export { default as SignInMutation } from "./SignInMutation";
export { default as SetRedirectPathMutation } from "./SetRedirectPathMutation";
export { default as AcceptCommentMutation } from "./AcceptCommentMutation";
export { default as RejectCommentMutation } from "./RejectCommentMutation";
export { default as UpdateSettingsMutation } from "./UpdateSettingsMutation";
export {
withSetRedirectPathMutation,
SetRedirectPathMutation,
} from "./SetRedirectPathMutation";
export {
withAcceptCommentMutation,
AcceptCommentMutation,
} from "./AcceptCommentMutation";
export {
withRejectCommentMutation,
RejectCommentMutation,
} from "./RejectCommentMutation";
export {
withUpdateSettingsMutation,
UpdateSettingsMutation,
UpdateSettingsInput,
} from "./UpdateSettingsMutation";
export {
withRegenerateSSOKeyMutation,
RegenerateSSOKeyMutation,
default as RegenerateSSOKeyMutation,
} from "./RegenerateSSOKeyMutation";
export {
withSetAuthViewMutation,
SetAuthViewMutation,
} from "./SetAuthViewMutation";
export {
withClearAuthErrorMutation,
ClearAuthErrorMutation,
} from "./ClearAuthErrorMutation";
export {
withCompleteAccountMutation,
CompleteAccountMutation,
} from "./CompleteAccountMutation";
export { withSetEmailMutation, SetEmailMutation } from "./SetEmailMutation";
export {
withSetUsernameMutation,
SetUsernameMutation,
} from "./SetUsernameMutation";
export {
withSetPasswordMutation,
SetPasswordMutation,
} from "./SetPasswordMutation";
export {
withUpdateUserRoleMutation,
UpdateUserRoleMutation,
} from "./UpdateUserRoleMutation";
export {
OpenStoryInput,
withOpenStoryMutation,
OpenStoryMutation,
} from "./OpenStoryMutation";
export {
CloseStoryInput,
withCloseStoryMutation,
CloseStoryMutation,
} from "./CloseStoryMutation";
export { default as SetAuthViewMutation } from "./SetAuthViewMutation";
export { default as ClearAuthErrorMutation } from "./ClearAuthErrorMutation";
export { default as CompleteAccountMutation } from "./CompleteAccountMutation";
export { default as SetEmailMutation } from "./SetEmailMutation";
export { default as SetUsernameMutation } from "./SetUsernameMutation";
export { default as SetPasswordMutation } from "./SetPasswordMutation";
export { default as UpdateUserRoleMutation } from "./UpdateUserRoleMutation";
export { default as OpenStoryMutation } from "./OpenStoryMutation";
export { default as CloseStoryMutation } from "./CloseStoryMutation";
export { default as BanUserMutation } from "./BanUserMutation";
export { default as RemoveUserBanMutation } from "./RemoveUserBanMutation";
@@ -0,0 +1,3 @@
.card {
max-width: 500px;
}
@@ -0,0 +1,76 @@
import { Localized } from "fluent-react/compat";
import React, { StatelessComponent } from "react";
import NotAvailable from "talk-admin/components/NotAvailable";
import {
Button,
Card,
CardCloseButton,
Flex,
HorizontalGutter,
Modal,
Typography,
} from "talk-ui/components";
import styles from "./BanModal.css";
interface Props {
username: string | null;
open: boolean;
onClose: () => void;
onConfirm: () => void;
}
const BanModal: StatelessComponent<Props> = ({
open,
onClose,
onConfirm,
username,
}) => (
<Modal open={open} onClose={onClose} aria-labelledby="banModal-title">
{({ firstFocusableRef, lastFocusableRef }) => (
<Card className={styles.card}>
<CardCloseButton onClick={onClose} ref={firstFocusableRef} />
<HorizontalGutter size="double">
<HorizontalGutter>
<Localized
id="community-banModal-areYouSure"
strong={<strong />}
$username={username || <NotAvailable />}
>
<Typography variant="header2" id="banModal-title">
Are you sure you want to ban{" "}
<strong>{username || <NotAvailable />}</strong>?
</Typography>
</Localized>
<Localized id="community-banModal-consequence">
<Typography>
Once banned, this user will no longer be able to comment, use
reactions, or report comments.
</Typography>
</Localized>
</HorizontalGutter>
<Flex justifyContent="flex-end" itemGutter="half">
<Localized id="community-banModal-cancel">
<Button variant="outlined" onClick={onClose}>
Cancel
</Button>
</Localized>
<Localized id="community-banModal-banUser">
<Button
variant="filled"
color="primary"
onClick={onConfirm}
ref={lastFocusableRef}
>
Ban User
</Button>
</Localized>
</Flex>
</HorizontalGutter>
</Card>
)}
</Modal>
);
export default BanModal;
@@ -0,0 +1,3 @@
.root {
padding-top: 3px;
}
@@ -0,0 +1,9 @@
import React, { FunctionComponent } from "react";
import styles from "./ButtonPadding.css";
const ButtonPadding: FunctionComponent = props => (
<div className={styles.root}>{props.children}</div>
);
export default ButtonPadding;
@@ -5,6 +5,8 @@ import { PropTypesOf } from "talk-framework/types";
import { TableCell, TableRow, TextLink } from "talk-ui/components";
import RoleChangeContainer from "../containers/RoleChangeContainer";
import UserStatusChangeContainer from "../containers/UserStatusChangeContainer";
import ButtonPadding from "./ButtonPadding";
import RoleText from "./RoleText";
import styles from "./UserRow.css";
@@ -16,6 +18,7 @@ interface Props {
email: string | null;
memberSince: string;
role: PropTypesOf<typeof RoleChangeContainer>["role"];
user: PropTypesOf<typeof UserStatusChangeContainer>["user"];
}
const UserRow: StatelessComponent<Props> = props => (
@@ -35,9 +38,14 @@ const UserRow: StatelessComponent<Props> = props => (
{props.canChangeRole ? (
<RoleChangeContainer userID={props.userID} role={props.role} />
) : (
<RoleText>{props.role}</RoleText>
<ButtonPadding>
<RoleText>{props.role}</RoleText>
</ButtonPadding>
)}
</TableCell>
<TableCell className={styles.roleColumn}>
<UserStatusChangeContainer user={props.user} />
</TableCell>
</TableRow>
);
@@ -0,0 +1,4 @@
.dot {
font-size: calc(22rem / var(--rem-base));
padding-bottom: 3px;
}
@@ -0,0 +1,56 @@
import { Localized } from "fluent-react/compat";
import React, { StatelessComponent } from "react";
import { Flex, Typography } from "talk-ui/components";
import { PropTypesOf } from "talk-ui/types";
import styles from "./UserStatus.css";
interface Props {
banned: boolean;
suspended: boolean;
}
const render = (
color: PropTypesOf<typeof Typography>["color"],
content: React.ReactNode
) => (
<Typography color={color} variant="detail" container="div">
<Flex alignItems="center" itemGutter="half">
<div aria-hidden className={styles.dot}>
</div>
{content}
</Flex>
</Typography>
);
const UserStatus: StatelessComponent<Props> = props => {
if (props.banned) {
return render(
"error",
// tslint:disable-next-line:jsx-wrap-multiline
<Localized id="userStatus-banned">
<div>Banned</div>
</Localized>
);
}
if (props.suspended) {
return render(
"warning",
// tslint:disable-next-line:jsx-wrap-multiline
<Localized id="userStatus-suspended">
<div>Suspended</div>
</Localized>
);
}
return render(
"success",
// tslint:disable-next-line:jsx-wrap-multiline
<Localized id="userStatus-active">
<div>Active</div>
</Localized>
);
};
export default UserStatus;
@@ -0,0 +1,9 @@
.button {
width: 100%;
justify-content: space-between;
padding: 0;
}
.dropdownButton {
min-width: 80px;
}
@@ -0,0 +1,116 @@
import { Localized } from "fluent-react/compat";
import React, { StatelessComponent } from "react";
import {
Button,
ButtonIcon,
ClickOutside,
Dropdown,
DropdownButton,
Popover,
} from "talk-ui/components";
import styles from "./UserStatusChange.css";
interface Props {
onBan: () => void;
onRemoveBan: () => void;
onSuspend: () => void;
onRemoveSuspension: () => void;
banned: boolean;
suspended: boolean;
children: React.ReactNode;
}
const UserStatusChange: StatelessComponent<Props> = props => (
<Localized id="community-userStatus-popover" attrs={{ description: true }}>
<Popover
id="community-statusChange"
placement="bottom-start"
description="A dropdown to change the user status"
body={({ toggleVisibility }) => (
<ClickOutside onClickOutside={toggleVisibility}>
<Dropdown>
{!props.banned && (
<Localized id="community-userStatus-banUser">
<DropdownButton
className={styles.dropdownButton}
onClick={() => {
props.onBan();
toggleVisibility();
}}
>
Ban User
</DropdownButton>
</Localized>
)}
{props.banned && (
<Localized id="community-userStatus-removeBan">
<DropdownButton
className={styles.dropdownButton}
onClick={() => {
props.onRemoveBan();
toggleVisibility();
}}
>
Remove Ban
</DropdownButton>
</Localized>
)}
{!props.suspended && (
<Localized id="community-userStatus-suspendUser">
<DropdownButton
className={styles.dropdownButton}
onClick={() => {
props.onSuspend();
toggleVisibility();
}}
>
Suspend User
</DropdownButton>
</Localized>
)}
{props.suspended && (
<Localized id="community-userStatus-removeSuspension">
<DropdownButton
className={styles.dropdownButton}
onClick={() => {
props.onRemoveSuspension();
toggleVisibility();
}}
>
Remove Suspension
</DropdownButton>
</Localized>
)}
</Dropdown>
</ClickOutside>
)}
>
{({ toggleVisibility, ref, visible }) => (
<Localized
id="community-userStatus-changeButton"
attrs={{ "aria-label": true }}
>
<Button
aria-label="Change user status"
className={styles.button}
onClick={toggleVisibility}
ref={ref}
variant="regular"
size="small"
>
{props.children}
{
<ButtonIcon size="lg">
{visible ? "arrow_drop_up" : "arrow_drop_down"}
</ButtonIcon>
}
</Button>
</Localized>
)}
</Popover>
</Localized>
);
export default UserStatusChange;
@@ -2,11 +2,14 @@
width: 25%;
}
.emailColumn {
width: 40%;
width: 30%;
}
.memberSinceColumn {
width: 20%;
width: 15%;
}
.roleColumn {
width: 15%;
}
.statusColumn {
width: 15%;
}
@@ -50,6 +50,9 @@ const UserTable: StatelessComponent<Props> = props => (
<Localized id="community-column-role">
<TableCell className={styles.roleColumn}>Role</TableCell>
</Localized>
<Localized id="community-column-status">
<TableCell className={styles.statusColumn}>Status</TableCell>
</Localized>
</TableRow>
</TableHead>
<TableBody>
@@ -11,3 +11,7 @@
.adornment {
padding: 0 var(--spacing-unit);
}
.selectField {
min-width: calc(13 * var(--spacing-unit));
}
@@ -2,7 +2,12 @@ import { Localized } from "fluent-react/compat";
import React, { StatelessComponent } from "react";
import { Field, Form } from "react-final-form";
import { GQLUSER_ROLE, GQLUSER_ROLE_RL } from "talk-framework/schema";
import {
GQLUSER_ROLE,
GQLUSER_ROLE_RL,
GQLUSER_STATUS,
GQLUSER_STATUS_RL,
} from "talk-framework/schema";
import {
Button,
FieldSet,
@@ -20,6 +25,8 @@ import styles from "./UserTableFilter.css";
interface Props {
roleFilter: GQLUSER_ROLE_RL | null;
onSetRoleFilter: (role: GQLUSER_ROLE_RL) => void;
statusFilter: GQLUSER_STATUS_RL | null;
onSetStatusFilter: (role: GQLUSER_STATUS_RL) => void;
searchFilter: string;
onSetSearchFilter: (search: string) => void;
}
@@ -91,46 +98,77 @@ const UserTableFilter: StatelessComponent<Props> = props => (
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) || null)}
<Flex itemGutter>
<Localized
id="community-filter-roleSelectField"
attrs={{ "aria-label": true }}
>
<Localized id="community-filter-everyone">
<Option value="">Everyone</Option>
</Localized>
<Localized
id="community-filter-optGroupAudience"
attrs={{ label: true }}
<SelectField
aria-label="Search by role"
value={props.roleFilter || ""}
className={styles.selectField}
onChange={e =>
props.onSetRoleFilter((e.target.value as any) || null)
}
>
<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 }}
<Localized id="community-filter-allRoles">
<Option value="">All Roles</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>
<Localized
id="community-filter-statusSelectField"
attrs={{ "aria-label": true }}
>
<SelectField
aria-label="Search by status"
value={props.statusFilter || ""}
className={styles.selectField}
onChange={e =>
props.onSetStatusFilter((e.target.value as any) || null)
}
>
<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>
<Localized id="community-filter-allStatuses">
<Option value="">All Statuses</Option>
</Localized>
<Localized id="userStatus-active">
<Option value={GQLUSER_STATUS.ACTIVE}>Active</Option>
</Localized>
<Localized id="userStatus-suspended">
<Option value={GQLUSER_STATUS.SUSPENDED}>Suspended</Option>
</Localized>
<Localized id="userStatus-banned">
<Option value={GQLUSER_STATUS.BANNED}>Banned</Option>
</Localized>
</SelectField>
</Localized>
</Flex>
</FieldSet>
</Flex>
);
@@ -1,9 +1,7 @@
import React, { StatelessComponent, useCallback } from "react";
import {
UpdateUserRoleMutation,
withUpdateUserRoleMutation,
} from "talk-admin/mutations/UpdateUserRoleMutation";
import { UpdateUserRoleMutation } from "talk-admin/mutations";
import { useMutation } from "talk-framework/lib/relay";
import { GQLUSER_ROLE_RL } from "talk-framework/schema";
import RoleChange from "../components/RoleChange";
@@ -11,22 +9,20 @@ import RoleChange from "../components/RoleChange";
interface Props {
userID: string;
role: GQLUSER_ROLE_RL;
updateUserRole: UpdateUserRoleMutation;
}
const RoleChangeContainer: StatelessComponent<Props> = props => {
const updateUserRole = useMutation(UpdateUserRoleMutation);
const handleOnChangeRole = useCallback(
(role: GQLUSER_ROLE_RL) => {
if (role === props.role) {
return;
}
props.updateUserRole({ userID: props.userID, role });
updateUserRole({ userID: props.userID, role });
},
[props.userID, props.role, props.updateUserRole]
[props.userID, props.role, updateUserRole]
);
return <RoleChange onChangeRole={handleOnChangeRole} role={props.role} />;
};
const enhanced = withUpdateUserRoleMutation(RoleChangeContainer);
export default enhanced;
export default RoleChangeContainer;
@@ -18,6 +18,7 @@ const UserRowContainer: StatelessComponent<Props> = props => {
const { locales } = useTalkContext();
return (
<UserRow
user={props.user}
userID={props.user.id}
username={props.user.username!}
email={props.user.email}
@@ -44,6 +45,7 @@ const enhanced = withFragmentContainer<Props>({
`,
user: graphql`
fragment UserRowContainer_user on User {
...UserStatusChangeContainer_user
id
username
email
@@ -0,0 +1,114 @@
import React, { StatelessComponent, useCallback, useState } from "react";
import { UserStatusChangeContainer_user as UserData } from "talk-admin/__generated__/UserStatusChangeContainer_user.graphql";
import { BanUserMutation, RemoveUserBanMutation } from "talk-admin/mutations";
import {
graphql,
useMutation,
withFragmentContainer,
} from "talk-framework/lib/relay";
import { GQLUSER_ROLE } from "talk-framework/schema";
import BanModal from "../components/BanModal";
import ButtonPadding from "../components/ButtonPadding";
import UserStatusChange from "../components/UserStatusChange";
import UserStatusContainer from "./UserStatusContainer";
interface Props {
user: UserData;
}
const UserStatusChangeContainer: StatelessComponent<Props> = props => {
const banUser = useMutation(BanUserMutation);
const removeUserBan = useMutation(RemoveUserBanMutation);
const [showBanned, setShowBanned] = useState<boolean>(false);
const handleBan = useCallback(
() => {
if (props.user.status.ban.active) {
return;
}
setShowBanned(true);
},
[props.user, setShowBanned]
);
const handleRemoveBan = useCallback(
() => {
if (!props.user.status.ban.active) {
return;
}
removeUserBan({ userID: props.user.id });
},
[props.user, removeUserBan]
);
const handleSuspend = useCallback(
() => {
if (props.user.status.suspension.active) {
return;
}
// TODO: (cvle)
},
[props.user]
);
const handleRemoveSuspension = useCallback(
() => {
if (!props.user.status.suspension.active) {
return;
}
// TODO: (cvle)
},
[props.user]
);
if (props.user.role !== GQLUSER_ROLE.COMMENTER) {
return (
<ButtonPadding>
<UserStatusContainer user={props.user} />
</ButtonPadding>
);
}
return (
<>
<UserStatusChange
onBan={handleBan}
onRemoveBan={handleRemoveBan}
onSuspend={handleSuspend}
onRemoveSuspension={handleRemoveSuspension}
banned={props.user.status.ban.active}
suspended={props.user.status.suspension.active}
>
<UserStatusContainer user={props.user} />
</UserStatusChange>
<BanModal
username={props.user.username}
open={showBanned}
onClose={() => setShowBanned(false)}
onConfirm={() => {
banUser({ userID: props.user.id });
setShowBanned(false);
}}
/>
</>
);
};
const enhanced = withFragmentContainer<Props>({
user: graphql`
fragment UserStatusChangeContainer_user on User {
...UserStatusContainer_user
id
role
username
status {
ban {
active
}
suspension {
active
}
}
}
`,
})(UserStatusChangeContainer);
export default enhanced;
@@ -0,0 +1,33 @@
import React, { StatelessComponent } from "react";
import { graphql } from "react-relay";
import { UserStatusContainer_user as UserData } from "talk-admin/__generated__/UserStatusContainer_user.graphql";
import { withFragmentContainer } from "talk-framework/lib/relay";
import { GQLUSER_STATUS } from "talk-framework/schema";
import UserStatus from "../components/UserStatus";
interface Props {
user: UserData;
}
const UserStatusContainer: StatelessComponent<Props> = props => {
return (
<UserStatus
banned={props.user.status.current.includes(GQLUSER_STATUS.BANNED)}
suspended={props.user.status.current.includes(GQLUSER_STATUS.SUSPENDED)}
/>
);
};
const enhanced = withFragmentContainer<Props>({
user: graphql`
fragment UserStatusContainer_user on User {
status {
current
}
}
`,
})(UserStatusContainer);
export default enhanced;
@@ -9,7 +9,7 @@ import {
useRefetch,
withPaginationContainer,
} from "talk-framework/lib/relay";
import { GQLUSER_ROLE_RL } from "talk-framework/schema";
import { GQLUSER_ROLE_RL, GQLUSER_STATUS_RL } from "talk-framework/schema";
import { HorizontalGutter } from "talk-ui/components";
import UserTable from "../components/UserTable";
@@ -28,14 +28,13 @@ const UserTableContainer: StatelessComponent<Props> = props => {
const [loadMore, isLoadingMore] = useLoadMore(props.relay, 10);
const [searchFilter, setSearchFilter] = useState<string>("");
const [roleFilter, setRoleFilter] = useState<GQLUSER_ROLE_RL | null>(null);
const [, isRefetching] = useRefetch<
Pick<
UserTableContainerPaginationQueryVariables,
"searchFilter" | "roleFilter"
>
>(props.relay, {
const [statusFilter, setStatusFilter] = useState<GQLUSER_STATUS_RL | null>(
null
);
const [, isRefetching] = useRefetch(props.relay, {
searchFilter: searchFilter || null,
roleFilter,
statusFilter,
});
return (
@@ -43,7 +42,9 @@ const UserTableContainer: StatelessComponent<Props> = props => {
<HorizontalGutter size="double">
<UserTableFilter
onSetRoleFilter={setRoleFilter}
onSetStatusFilter={setStatusFilter}
roleFilter={roleFilter}
statusFilter={statusFilter}
onSetSearchFilter={setSearchFilter}
searchFilter={searchFilter}
/>
@@ -75,6 +76,7 @@ const enhanced = withPaginationContainer<
count: { type: "Int!", defaultValue: 10 }
cursor: { type: "Cursor" }
roleFilter: { type: "USER_ROLE" }
statusFilter: { type: "USER_STATUS" }
searchFilter: { type: "String" }
) {
viewer {
@@ -84,6 +86,7 @@ const enhanced = withPaginationContainer<
first: $count
after: $cursor
role: $roleFilter
status: $statusFilter
query: $searchFilter
) @connection(key: "UserTable_users") {
edges {
@@ -113,6 +116,7 @@ const enhanced = withPaginationContainer<
count,
cursor,
roleFilter: fragmentVariables.roleFilter,
statusFilter: fragmentVariables.statusFilter,
searchFilter: fragmentVariables.searchFilter,
};
},
@@ -123,6 +127,7 @@ const enhanced = withPaginationContainer<
$count: Int!
$cursor: Cursor
$roleFilter: USER_ROLE
$statusFilter: USER_STATUS
$searchFilter: String
) {
...UserTableContainer_query
@@ -130,6 +135,7 @@ const enhanced = withPaginationContainer<
count: $count
cursor: $cursor
roleFilter: $roleFilter
statusFilter: $statusFilter
searchFilter: $searchFilter
)
}
@@ -1,18 +1,15 @@
import { FormApi, FormState } from "final-form";
import React from "react";
import {
UpdateSettingsInput,
UpdateSettingsMutation,
withUpdateSettingsMutation,
} from "talk-admin/mutations";
import { UpdateSettingsMutation } from "talk-admin/mutations";
import { SubmitHookHandler } from "talk-framework/lib/form";
import { MutationProp, withMutation } from "talk-framework/lib/relay";
import Configure from "../components/Configure";
import NavigationWarningContainer from "./NavigationWarningContainer";
interface Props {
updateSettings: UpdateSettingsMutation;
updateSettings: MutationProp<typeof UpdateSettingsMutation>;
children: React.ReactElement;
}
@@ -26,7 +23,7 @@ class ConfigureContainer extends React.Component<Props, State> {
};
private handleExecute = async (
data: UpdateSettingsInput["settings"],
data: Parameters<Props["updateSettings"]>[0]["settings"],
form: FormApi
) => {
await this.props.updateSettings({ settings: data });
@@ -55,5 +52,5 @@ class ConfigureContainer extends React.Component<Props, State> {
}
}
const enhanced = withUpdateSettingsMutation(ConfigureContainer);
const enhanced = withMutation(UpdateSettingsMutation)(ConfigureContainer);
export default enhanced;
@@ -2,18 +2,19 @@ import React from "react";
import { graphql } from "react-relay";
import { SSOKeyFieldContainer_sso as SSOData } from "talk-admin/__generated__/SSOKeyFieldContainer_sso.graphql";
import { RegenerateSSOKeyMutation } from "talk-admin/mutations";
import {
RegenerateSSOKeyMutation,
withRegenerateSSOKeyMutation,
} from "talk-admin/mutations";
import { withFragmentContainer } from "talk-framework/lib/relay";
MutationProp,
withFragmentContainer,
withMutation,
} from "talk-framework/lib/relay";
import SSOKeyField from "../components/SSOKeyField";
interface Props {
sso: SSOData;
disabled?: boolean;
regenerateSSOKey: RegenerateSSOKeyMutation;
regenerateSSOKey: MutationProp<typeof RegenerateSSOKeyMutation>;
}
interface State {
@@ -44,7 +45,7 @@ class SSOKeyFieldContainer extends React.Component<Props, State> {
}
}
const enhanced = withRegenerateSSOKeyMutation(
const enhanced = withMutation(RegenerateSSOKeyMutation)(
withFragmentContainer<Props>({
sso: graphql`
fragment SSOKeyFieldContainer_sso on SSOAuthIntegration {
@@ -9,23 +9,22 @@ import {
CompleteAccountMutation,
SetAuthViewMutation,
SetRedirectPathMutation,
withCompleteAccountMutation,
withSetAuthViewMutation,
withSetRedirectPathMutation,
} from "talk-admin/mutations";
import {
graphql,
MutationProp,
withFragmentContainer,
withLocalStateContainer,
withMutation,
} from "talk-framework/lib/relay";
type Props = {
completeAccount: CompleteAccountMutation;
setAuthView: SetAuthViewMutation;
completeAccount: MutationProp<typeof CompleteAccountMutation>;
setAuthView: MutationProp<typeof SetAuthViewMutation>;
local: Local;
auth: AuthData;
viewer: UserData | null;
setRedirectPath: SetRedirectPathMutation;
setRedirectPath: MutationProp<typeof SetRedirectPathMutation>;
} & WithRouter;
function handleAccountCompletion(props: Props) {
@@ -132,9 +131,11 @@ const enhanced = withLocalStateContainer(
}
`,
})(
withSetAuthViewMutation(
withSetRedirectPathMutation(
withCompleteAccountMutation(withRouter(AccountCompletionContainer))
withMutation(SetAuthViewMutation)(
withMutation(SetRedirectPathMutation)(
withMutation(CompleteAccountMutation)(
withRouter(AccountCompletionContainer)
)
)
)
)
@@ -1,16 +1,14 @@
import { FORM_ERROR } from "final-form";
import React, { Component } from "react";
import {
SetEmailMutation,
withSetEmailMutation,
} from "talk-admin/mutations/SetEmailMutation";
import { SetEmailMutation } from "talk-admin/mutations";
import { MutationProp, withMutation } from "talk-framework/lib/relay";
import { PropTypesOf } from "talk-framework/types";
import AddEmailAddress from "../components/AddEmailAddress";
interface Props {
setEmail: SetEmailMutation;
setEmail: MutationProp<typeof SetEmailMutation>;
}
class AddEmailAddressContainer extends Component<Props> {
@@ -31,5 +29,5 @@ class AddEmailAddressContainer extends Component<Props> {
}
}
const enhanced = withSetEmailMutation(AddEmailAddressContainer);
const enhanced = withMutation(SetEmailMutation)(AddEmailAddressContainer);
export default enhanced;
@@ -1,16 +1,14 @@
import { FORM_ERROR } from "final-form";
import React, { Component } from "react";
import {
SetPasswordMutation,
withSetPasswordMutation,
} from "talk-admin/mutations/SetPasswordMutation";
import { SetPasswordMutation } from "talk-admin/mutations";
import { MutationProp, withMutation } from "talk-framework/lib/relay";
import { PropTypesOf } from "talk-framework/types";
import CreatePassword from "../components/CreatePassword";
interface Props {
setPassword: SetPasswordMutation;
setPassword: MutationProp<typeof SetPasswordMutation>;
}
class CreatePasswordContainer extends Component<Props> {
@@ -32,5 +30,5 @@ class CreatePasswordContainer extends Component<Props> {
}
}
const enhanced = withSetPasswordMutation(CreatePasswordContainer);
const enhanced = withMutation(SetPasswordMutation)(CreatePasswordContainer);
export default enhanced;
@@ -1,16 +1,14 @@
import { FORM_ERROR } from "final-form";
import React, { Component } from "react";
import {
SetUsernameMutation,
withSetUsernameMutation,
} from "talk-admin/mutations/SetUsernameMutation";
import { SetUsernameMutation } from "talk-admin/mutations";
import { MutationProp, withMutation } from "talk-framework/lib/relay";
import { PropTypesOf } from "talk-framework/types";
import CreateUsername from "../components/CreateUsername";
interface Props {
setUsername: SetUsernameMutation;
setUsername: MutationProp<typeof SetUsernameMutation>;
}
class CreateUsernameContainer extends Component<Props> {
@@ -32,5 +30,5 @@ class CreateUsernameContainer extends Component<Props> {
}
}
const enhanced = withSetUsernameMutation(CreateUsernameContainer);
const enhanced = withMutation(SetUsernameMutation)(CreateUsernameContainer);
export default enhanced;
@@ -3,16 +3,13 @@ import React, { Component } from "react";
import { SignInContainer_auth as AuthData } from "talk-admin/__generated__/SignInContainer_auth.graphql";
import { SignInContainerLocal as LocalData } from "talk-admin/__generated__/SignInContainerLocal.graphql";
import {
ClearAuthErrorMutation,
SignInMutation,
withClearAuthErrorMutation,
withSignInMutation,
} from "talk-admin/mutations";
import { ClearAuthErrorMutation, SignInMutation } from "talk-admin/mutations";
import {
graphql,
MutationProp,
withFragmentContainer,
withLocalStateContainer,
withMutation,
} from "talk-framework/lib/relay";
import SignIn from "../components/SignIn";
@@ -21,8 +18,8 @@ interface Props {
local: LocalData;
auth: AuthData;
error?: Error | null;
signIn: SignInMutation;
clearAuthError: ClearAuthErrorMutation;
signIn: MutationProp<typeof SignInMutation>;
clearAuthError: MutationProp<typeof ClearAuthErrorMutation>;
}
class SignInContainer extends Component<Props> {
@@ -91,8 +88,8 @@ const enhanced = withFragmentContainer<Props>({
}
`,
})(
withClearAuthErrorMutation(
withSignInMutation(
withMutation(ClearAuthErrorMutation)(
withMutation(SignInMutation)(
withLocalStateContainer(
graphql`
fragment SignInContainerLocal on Local {
@@ -1,14 +1,15 @@
import { FORM_ERROR } from "final-form";
import React, { Component } from "react";
import { SignInMutation, withSignInMutation } from "talk-admin/mutations";
import { SignInMutation } from "talk-admin/mutations";
import { MutationProp, withMutation } from "talk-framework/lib/relay";
import SignInWithEmail, {
SignInWithEmailForm,
} from "../components/SignInWithEmail";
interface SignInContainerProps {
signIn: SignInMutation;
signIn: MutationProp<typeof SignInMutation>;
}
class SignInContainer extends Component<SignInContainerProps> {
@@ -25,5 +26,5 @@ class SignInContainer extends Component<SignInContainerProps> {
}
}
const enhanced = withSignInMutation(SignInContainer);
const enhanced = withMutation(SignInMutation)(SignInContainer);
export default enhanced;
@@ -6,23 +6,21 @@ import {
ModerateCardContainer_comment as CommentData,
} from "talk-admin/__generated__/ModerateCardContainer_comment.graphql";
import { ModerateCardContainer_settings as SettingsData } from "talk-admin/__generated__/ModerateCardContainer_settings.graphql";
import { AcceptCommentMutation } from "talk-admin/mutations";
import { RejectCommentMutation } from "talk-admin/mutations";
import {
AcceptCommentMutation,
withAcceptCommentMutation,
} from "talk-admin/mutations";
import {
RejectCommentMutation,
withRejectCommentMutation,
} from "talk-admin/mutations";
import { withFragmentContainer } from "talk-framework/lib/relay";
MutationProp,
withFragmentContainer,
withMutation,
} from "talk-framework/lib/relay";
import ModerateCard from "../components/ModerateCard";
interface ModerateCardContainerProps {
comment: CommentData;
settings: SettingsData;
acceptComment: AcceptCommentMutation;
rejectComment: RejectCommentMutation;
acceptComment: MutationProp<typeof AcceptCommentMutation>;
rejectComment: MutationProp<typeof RejectCommentMutation>;
danglingLogic: (status: COMMENT_STATUS) => boolean;
}
@@ -106,6 +104,10 @@ const enhanced = withFragmentContainer<ModerateCardContainerProps>({
}
}
`,
})(withAcceptCommentMutation(withRejectCommentMutation(ModerateCardContainer)));
})(
withMutation(AcceptCommentMutation)(
withMutation(RejectCommentMutation)(ModerateCardContainer)
)
);
export default enhanced;
@@ -1,20 +1,16 @@
import React, { StatelessComponent, useCallback } from "react";
import {
CloseStoryMutation,
OpenStoryMutation,
withCloseStoryMutation,
withOpenStoryMutation,
} from "talk-admin/mutations";
import { CloseStoryMutation, OpenStoryMutation } from "talk-admin/mutations";
import { GQLSTORY_STATUS, GQLSTORY_STATUS_RL } from "talk-framework/schema";
import { MutationProp, withMutation } from "talk-framework/lib/relay";
import StatusChange from "../components/StatusChange";
interface Props {
storyID: string;
status: GQLSTORY_STATUS_RL;
openStory: OpenStoryMutation;
closeStory: CloseStoryMutation;
openStory: MutationProp<typeof OpenStoryMutation>;
closeStory: MutationProp<typeof CloseStoryMutation>;
}
const StatusChangeContainer: StatelessComponent<Props> = props => {
@@ -36,8 +32,8 @@ const StatusChangeContainer: StatelessComponent<Props> = props => {
);
};
const enhanced = withOpenStoryMutation(
withCloseStoryMutation(StatusChangeContainer)
const enhanced = withMutation(OpenStoryMutation)(
withMutation(CloseStoryMutation)(StatusChangeContainer)
);
export default enhanced;
@@ -29,7 +29,7 @@ async function createTestRenderer(
.stub()
.returns(
merge(
{ ...users[0], email: "", username: "", profiles: [] },
{ ...users.admins[0], email: "", username: "", profiles: [] },
get(customResolver, "Query.viewer")
)
),
@@ -25,7 +25,7 @@ async function createTestRenderer(
settings: sinon
.stub()
.returns(merge({}, settings, get(customResolver, "Query.settings"))),
viewer: sinon.stub().returns({ ...users[0], email: "" }),
viewer: sinon.stub().returns({ ...users.admins[0], email: "" }),
},
};
@@ -25,7 +25,7 @@ async function createTestRenderer(
settings: sinon
.stub()
.returns(merge({}, settings, get(customResolver, "Query.settings"))),
viewer: sinon.stub().returns({ ...users[0], profiles: [] }),
viewer: sinon.stub().returns({ ...users.admins[0], profiles: [] }),
},
};
@@ -18,7 +18,7 @@ const resolvers = {
settings: sinon.stub().returns(settings),
moderationQueues: sinon.stub().returns(emptyModerationQueues),
comments: sinon.stub().returns(emptyRejectedComments),
viewer: sinon.stub().returns(users[0]),
viewer: sinon.stub().returns(users.admins[0]),
},
};
@@ -32,7 +32,7 @@ function createTestRenderer(
settings: sinon.stub().returns(settings),
moderationQueues: sinon.stub().returns(emptyModerationQueues),
comments: sinon.stub().returns(emptyRejectedComments),
viewer: sinon.stub().returns({ ...users[0], ...userDiff }),
viewer: sinon.stub().returns({ ...users.admins[0], ...userDiff }),
},
};
const { testRenderer, context } = create({
@@ -21,7 +21,7 @@ const resolvers = {
settings: sinon.stub().returns(settings),
moderationQueues: sinon.stub().returns(emptyModerationQueues),
comments: sinon.stub().returns(emptyRejectedComments),
viewer: sinon.stub().returns(users[0]),
viewer: sinon.stub().returns(users.admins[0]),
},
};
@@ -47,7 +47,7 @@ it("logs out", async () => {
.returns({});
const userMenu = await waitForElement(() =>
within(testRenderer.root).getByText(users[0].username, {
within(testRenderer.root).getByText(users.admins[0].username!, {
selector: "button",
})
);
@@ -1,5 +1,104 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`ban user 1`] = `
<div
aria-labelledby="banModal-title"
className="Modal-root"
onKeyDown={[Function]}
role="modal"
>
<div
className="Backdrop-root Backdrop-active"
data-testid="backdrop"
onClick={[Function]}
/>
<div
onFocus={[Function]}
tabIndex={0}
/>
<div
tabIndex={-1}
/>
<div
className="Card-root BanModal-card"
>
<button
className="BaseButton-root CloseButton-root"
onBlur={[Function]}
onClick={[Function]}
onFocus={[Function]}
onMouseOut={[Function]}
onMouseOver={[Function]}
onTouchEnd={[Function]}
type="button"
>
<span
aria-hidden="true"
className="Icon-root Icon-md CloseButton-icon"
>
close
</span>
</button>
<div
className="HorizontalGutter-root HorizontalGutter-double"
>
<div
className="HorizontalGutter-root HorizontalGutter-full"
>
<h1
className="Typography-root Typography-header2 Typography-colorTextPrimary"
id="banModal-title"
>
Are you sure you want to ban
<strong>
Isabelle
</strong>
?
</h1>
<p
className="Typography-root Typography-bodyCopy Typography-colorTextPrimary"
>
Once banned, this user will no longer be able to comment, use
reactions, or report comments.
</p>
</div>
<div
className="Flex-root Flex-flex Flex-halfItemGutter Flex-justifyFlexEnd"
>
<button
className="BaseButton-root Button-root Button-sizeRegular Button-colorRegular Button-variantOutlined"
onBlur={[Function]}
onClick={[Function]}
onFocus={[Function]}
onMouseOut={[Function]}
onMouseOver={[Function]}
onTouchEnd={[Function]}
type="button"
>
Cancel
</button>
<button
className="BaseButton-root Button-root Button-sizeRegular Button-colorPrimary Button-variantFilled"
onBlur={[Function]}
onClick={[Function]}
onFocus={[Function]}
onMouseOut={[Function]}
onMouseOver={[Function]}
onTouchEnd={[Function]}
type="button"
>
Ban User
</button>
</div>
</div>
</div>
<div
onFocus={[Function]}
tabIndex={0}
/>
</div>
`;
exports[`renders community 1`] = `
<div
className="MainLayout-root Community-root"
@@ -68,63 +167,111 @@ exports[`renders community 1`] = `
>
Show Me
</legend>
<span
className="SelectField-root"
<div
className="Flex-root Flex-flex Flex-itemGutter"
>
<select
aria-label="Search by role"
className="SelectField-select"
onBlur={[Function]}
onChange={[Function]}
onFocus={[Function]}
value=""
<span
className="SelectField-root UserTableFilter-selectField"
>
<option
<select
aria-label="Search by role"
className="SelectField-select"
onBlur={[Function]}
onChange={[Function]}
onFocus={[Function]}
value=""
>
Everyone
</option>
<optgroup
label="Audience"
>
<option
value="COMMENTER"
value=""
>
Commenters
All Roles
</option>
</optgroup>
<optgroup
label="Organization"
>
<option
value="ADMIN"
<optgroup
label="Audience"
>
Admins
</option>
<option
value="MODERATOR"
<option
value="COMMENTER"
>
Commenters
</option>
</optgroup>
<optgroup
label="Organization"
>
Moderators
</option>
<option
value="STAFF"
>
Staff
</option>
</optgroup>
</select>
<span
aria-hidden={true}
className="SelectField-afterWrapper"
>
<option
value="ADMIN"
>
Admins
</option>
<option
value="MODERATOR"
>
Moderators
</option>
<option
value="STAFF"
>
Staff
</option>
</optgroup>
</select>
<span
aria-hidden="true"
className="Icon-root Icon-sm"
aria-hidden={true}
className="SelectField-afterWrapper"
>
expand_more
<span
aria-hidden="true"
className="Icon-root Icon-sm"
>
expand_more
</span>
</span>
</span>
</span>
<span
className="SelectField-root UserTableFilter-selectField"
>
<select
aria-label="Search by user status"
className="SelectField-select"
onBlur={[Function]}
onChange={[Function]}
onFocus={[Function]}
value=""
>
<option
value=""
>
All Statuses
</option>
<option
value="ACTIVE"
>
Active
</option>
<option
value="SUSPENDED"
>
Suspended
</option>
<option
value="BANNED"
>
Banned
</option>
</select>
<span
aria-hidden={true}
className="SelectField-afterWrapper"
>
<span
aria-hidden="true"
className="Icon-root Icon-sm"
>
expand_more
</span>
</span>
</span>
</div>
</fieldset>
</div>
<div
@@ -159,6 +306,11 @@ exports[`renders community 1`] = `
>
Role
</th>
<th
className="TableCell-root UserTable-statusColumn TableCell-header"
>
Status
</th>
</tr>
</thead>
<tbody
@@ -190,11 +342,40 @@ exports[`renders community 1`] = `
<td
className="TableCell-root UserRow-roleColumn TableCell-body"
>
<span
className="RoleText-root"
<div
className="ButtonPadding-root"
>
Admin
</span>
<span
className="RoleText-root"
>
Admin
</span>
</div>
</td>
<td
className="TableCell-root UserRow-roleColumn TableCell-body"
>
<div
className="ButtonPadding-root"
>
<div
className="Typography-root Typography-detail Typography-colorSuccess"
>
<div
className="Flex-root Flex-flex Flex-halfItemGutter Flex-alignCenter"
>
<div
aria-hidden={true}
className="UserStatus-dot"
>
</div>
<div>
Active
</div>
</div>
</div>
</div>
</td>
</tr>
<tr
@@ -264,6 +445,248 @@ exports[`renders community 1`] = `
</div>
</div>
</td>
<td
className="TableCell-root UserRow-roleColumn TableCell-body"
>
<div
className="ButtonPadding-root"
>
<div
className="Typography-root Typography-detail Typography-colorSuccess"
>
<div
className="Flex-root Flex-flex Flex-halfItemGutter Flex-alignCenter"
>
<div
aria-hidden={true}
className="UserStatus-dot"
>
</div>
<div>
Active
</div>
</div>
</div>
</div>
</td>
</tr>
<tr
className="TableRow-root TableRow-body"
>
<td
className="TableCell-root UserRow-usernameColumn TableCell-body"
>
Huy
</td>
<td
className="TableCell-root UserRow-emailColumn TableCell-body"
>
<a
className="TextLink-root"
href="mailto:huy@test.com"
>
huy@test.com
</a>
</td>
<td
className="TableCell-root UserRow-memberSinceColumn TableCell-body"
>
07/06/2018
</td>
<td
className="TableCell-root UserRow-roleColumn 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"
>
Staff
</span>
<span
aria-hidden="true"
className="Icon-root Icon-lg ButtonIcon-root"
>
arrow_drop_down
</span>
</button>
<div
aria-hidden={true}
aria-labelledby="community-roleChange-ariainfo"
id="community-roleChange"
role="popup"
>
<div
className="AriaInfo-root"
id="community-roleChange-ariainfo"
>
A dropdown to change the user role
</div>
</div>
</div>
</td>
<td
className="TableCell-root UserRow-roleColumn TableCell-body"
>
<div
className="ButtonPadding-root"
>
<div
className="Typography-root Typography-detail Typography-colorSuccess"
>
<div
className="Flex-root Flex-flex Flex-halfItemGutter Flex-alignCenter"
>
<div
aria-hidden={true}
className="UserStatus-dot"
>
</div>
<div>
Active
</div>
</div>
</div>
</div>
</td>
</tr>
<tr
className="TableRow-root TableRow-body"
>
<td
className="TableCell-root UserRow-usernameColumn TableCell-body"
>
Isabelle
</td>
<td
className="TableCell-root UserRow-emailColumn TableCell-body"
>
<a
className="TextLink-root"
href="mailto:isabelle@test.com"
>
isabelle@test.com
</a>
</td>
<td
className="TableCell-root UserRow-memberSinceColumn TableCell-body"
>
07/06/2018
</td>
<td
className="TableCell-root UserRow-roleColumn 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 RoleText-commenter"
>
Commenter
</span>
<span
aria-hidden="true"
className="Icon-root Icon-lg ButtonIcon-root"
>
arrow_drop_down
</span>
</button>
<div
aria-hidden={true}
aria-labelledby="community-roleChange-ariainfo"
id="community-roleChange"
role="popup"
>
<div
className="AriaInfo-root"
id="community-roleChange-ariainfo"
>
A dropdown to change the user role
</div>
</div>
</div>
</td>
<td
className="TableCell-root UserRow-roleColumn TableCell-body"
>
<div
className="Popover-root"
>
<button
aria-label="Change user status"
className="BaseButton-root Button-root UserStatusChange-button Button-sizeSmall Button-colorRegular Button-variantRegular"
onBlur={[Function]}
onClick={[Function]}
onFocus={[Function]}
onMouseOut={[Function]}
onMouseOver={[Function]}
onTouchEnd={[Function]}
type="button"
>
<div
className="Typography-root Typography-detail Typography-colorSuccess"
>
<div
className="Flex-root Flex-flex Flex-halfItemGutter Flex-alignCenter"
>
<div
aria-hidden={true}
className="UserStatus-dot"
>
</div>
<div>
Active
</div>
</div>
</div>
<span
aria-hidden="true"
className="Icon-root Icon-lg ButtonIcon-root"
>
arrow_drop_down
</span>
</button>
<div
aria-hidden={true}
aria-labelledby="community-statusChange-ariainfo"
id="community-statusChange"
role="popup"
>
<div
className="AriaInfo-root"
id="community-statusChange-ariainfo"
>
A dropdown to change the user status
</div>
</div>
</div>
</td>
</tr>
</tbody>
</table>
@@ -340,63 +763,111 @@ exports[`renders empty community 1`] = `
>
Show Me
</legend>
<span
className="SelectField-root"
<div
className="Flex-root Flex-flex Flex-itemGutter"
>
<select
aria-label="Search by role"
className="SelectField-select"
onBlur={[Function]}
onChange={[Function]}
onFocus={[Function]}
value=""
<span
className="SelectField-root UserTableFilter-selectField"
>
<option
<select
aria-label="Search by role"
className="SelectField-select"
onBlur={[Function]}
onChange={[Function]}
onFocus={[Function]}
value=""
>
Everyone
</option>
<optgroup
label="Audience"
>
<option
value="COMMENTER"
value=""
>
Commenters
All Roles
</option>
</optgroup>
<optgroup
label="Organization"
>
<option
value="ADMIN"
<optgroup
label="Audience"
>
Admins
</option>
<option
value="MODERATOR"
<option
value="COMMENTER"
>
Commenters
</option>
</optgroup>
<optgroup
label="Organization"
>
Moderators
</option>
<option
value="STAFF"
>
Staff
</option>
</optgroup>
</select>
<span
aria-hidden={true}
className="SelectField-afterWrapper"
>
<option
value="ADMIN"
>
Admins
</option>
<option
value="MODERATOR"
>
Moderators
</option>
<option
value="STAFF"
>
Staff
</option>
</optgroup>
</select>
<span
aria-hidden="true"
className="Icon-root Icon-sm"
aria-hidden={true}
className="SelectField-afterWrapper"
>
expand_more
<span
aria-hidden="true"
className="Icon-root Icon-sm"
>
expand_more
</span>
</span>
</span>
</span>
<span
className="SelectField-root UserTableFilter-selectField"
>
<select
aria-label="Search by user status"
className="SelectField-select"
onBlur={[Function]}
onChange={[Function]}
onFocus={[Function]}
value=""
>
<option
value=""
>
All Statuses
</option>
<option
value="ACTIVE"
>
Active
</option>
<option
value="SUSPENDED"
>
Suspended
</option>
<option
value="BANNED"
>
Banned
</option>
</select>
<span
aria-hidden={true}
className="SelectField-afterWrapper"
>
<span
aria-hidden="true"
className="Icon-root Icon-sm"
>
expand_more
</span>
</span>
</span>
</div>
</fieldset>
</div>
<div
@@ -431,6 +902,11 @@ exports[`renders empty community 1`] = `
>
Role
</th>
<th
className="TableCell-root UserTable-statusColumn TableCell-header"
>
Status
</th>
</tr>
</thead>
<tbody
@@ -1,10 +1,16 @@
import { get, merge } from "lodash";
import { merge } from "lodash";
import TestRenderer from "react-test-renderer";
import sinon from "sinon";
import { GQLUSER_ROLE } from "talk-framework/schema";
import {
createSinonStub,
GQLUSER_ROLE,
GQLUSER_STATUS,
QueryToSettingsResolver,
QueryToUsersResolver,
QueryToViewerResolver,
} from "talk-framework/schema";
import {
createMutationResolverStub,
createQueryResolverStub,
findParentWithType,
replaceHistoryLocation,
waitForElement,
@@ -12,6 +18,11 @@ import {
within,
} from "talk-framework/testHelpers";
import {
BanUserMutation,
RemoveUserBanMutation,
UpdateUserRoleMutation,
} from "talk-admin/mutations";
import create from "../create";
import {
communityUsers,
@@ -28,14 +39,16 @@ 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();
settings: createQueryResolverStub<QueryToSettingsResolver>(
() => settings
),
users: createQueryResolverStub<QueryToUsersResolver>(variables => {
expectAndFail(variables.role).toBeFalsy();
return communityUsers;
}),
viewer: sinon.stub().returns(users[0]),
viewer: createQueryResolverStub<QueryToViewerResolver>(
() => users.admins[0]
),
...resolver.Query,
},
};
@@ -61,7 +74,9 @@ it("renders community", async () => {
it("renders empty community", async () => {
const { container } = await createTestRenderer({
Query: {
users: sinon.stub().returns(emptyCommunityUsers),
users: createQueryResolverStub<QueryToUsersResolver>(
() => emptyCommunityUsers
),
},
});
expect(within(container).toJSON()).toMatchSnapshot();
@@ -70,13 +85,16 @@ it("renders empty community", async () => {
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(GQLUSER_ROLE.COMMENTER);
return emptyCommunityUsers;
})
users: createQueryResolverStub<QueryToUsersResolver>(
(variables, callCount) => {
switch (callCount) {
case 0:
return communityUsers;
default:
expectAndFail(variables.role).toBe(GQLUSER_ROLE.COMMENTER);
return emptyCommunityUsers;
}
}
),
},
});
@@ -98,26 +116,27 @@ it("filter by role", async () => {
});
it("can't change viewer role", async () => {
const viewer = users[0];
const viewer = users.admins[0];
const { container } = await createTestRenderer();
const viewerRow = within(container).getByText(viewer.username, {
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({
const user = users.commenters[0];
const updateUserRole = createMutationResolverStub<
typeof UpdateUserRoleMutation
>(variables => {
expectAndFail(variables).toMatchObject({
userID: user.id,
role: GQLUSER_ROLE.STAFF,
});
const userRecord = merge({}, user, { role: data.input.role });
const userRecord = merge({}, user, { role: variables.role });
return {
user: userRecord,
clientMutationId: data.input.clientMutationId,
};
});
@@ -125,7 +144,7 @@ it("change user role", async () => {
Mutation: { updateUserRole },
});
const userRow = within(container).getByText(user.username, {
const userRow = within(container).getByText(user.username!, {
selector: "tr",
});
@@ -150,10 +169,10 @@ it("change user role", async () => {
});
it("can't change role as a moderator", async () => {
const viewer = users[1];
const viewer = users.moderators[0];
const { container } = await createTestRenderer({
Query: {
viewer: sinon.stub().returns(viewer),
viewer: createQueryResolverStub<QueryToViewerResolver>(() => viewer),
},
});
expect(() => within(container).getByLabelText("Change role")).toThrow();
@@ -162,20 +181,38 @@ it("can't change role as a moderator", async () => {
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 },
})
users: createQueryResolverStub<QueryToUsersResolver>(
(variables, callCount) => {
switch (callCount) {
case 0:
return {
edges: [
{ node: users.admins[0], cursor: users.admins[0].createdAt },
{
node: users.commenters[0],
cursor: users.commenters[0].createdAt,
},
],
pageInfo: {
endCursor: users.commenters[0].createdAt,
hasNextPage: true,
},
};
default:
return {
edges: [
{
node: users.commenters[1],
cursor: users.commenters[1].createdAt,
},
],
pageInfo: {
endCursor: users.commenters[1].createdAt,
hasNextPage: false,
},
};
}
}
),
},
});
@@ -188,19 +225,22 @@ it("load more", async () => {
await waitUntilThrow(() => within(container).getByText("Load More"));
// Make sure third user was added.
within(container).getByText(users[2].username);
within(container).getByText(users.commenters[1].username!);
});
it("filter by search", async () => {
const { container } = await createTestRenderer({
Query: {
users: createSinonStub(
s => s.onFirstCall().returns(communityUsers),
s =>
s.onSecondCall().callsFake((_, data) => {
expectAndFail(data.query).toBe("search");
return emptyCommunityUsers;
})
users: createQueryResolverStub<QueryToUsersResolver>(
(variables, callCount) => {
switch (callCount) {
case 0:
return communityUsers;
default:
expectAndFail(variables.query).toBe("search");
return emptyCommunityUsers;
}
}
),
},
});
@@ -221,3 +261,170 @@ it("filter by search", async () => {
within(container).getByText("could not find anyone", { exact: false })
);
});
it("filter by status", async () => {
const { container } = await createTestRenderer({
Query: {
users: createQueryResolverStub<QueryToUsersResolver>(
(variables, callCount) => {
switch (callCount) {
case 0:
return communityUsers;
default:
expectAndFail(variables.status).toBe("BANNED");
return emptyCommunityUsers;
}
}
),
},
});
const statusField = within(container).getByLabelText(
"Search by user status",
{
exact: false,
}
);
const bannedOption = within(statusField).getByText("Banned");
TestRenderer.act(() => {
statusField.props.onChange({
target: { value: bannedOption.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 staff, moderator and admin status", async () => {
const { container } = await createTestRenderer();
["Admin", "Moderator", "Staff"].forEach(role => {
const viewerRow = within(container).getByText(role, {
selector: "tr",
});
expect(() =>
within(viewerRow).getByLabelText("Change user status")
).toThrow();
});
});
it("ban user", async () => {
const user = users.commenters[0];
const banUser = createMutationResolverStub<typeof BanUserMutation>(
variables => {
expectAndFail(variables).toMatchObject({
userID: user.id,
});
const userRecord = merge({}, user, {
status: {
current: user.status.current.concat(GQLUSER_STATUS.BANNED),
banned: { active: true },
},
});
return {
user: userRecord,
};
}
);
const { container, testRenderer } = await createTestRenderer({
Mutation: { banUser },
});
const userRow = within(container).getByText(user.username!, {
selector: "tr",
});
TestRenderer.act(() => {
within(userRow)
.getByLabelText("Change user status")
.props.onClick();
});
const popup = within(userRow).getByLabelText(
"A dropdown to change the user status"
);
TestRenderer.act(() => {
within(popup)
.getByText("Ban User")
.props.onClick();
});
const modal = within(testRenderer.root).getByLabelText(
"Are you sure you want to ban",
{
exact: false,
}
);
expect(within(modal).toJSON()).toMatchSnapshot();
within(modal)
.getByText("Ban User")
.props.onClick();
within(userRow).getByText("Banned");
expect(banUser.called).toBe(true);
});
it("remove user ban", async () => {
const user = users.bannedCommenter;
const removeUserBan = createMutationResolverStub<
typeof RemoveUserBanMutation
>(variables => {
expectAndFail(variables).toMatchObject({
userID: user.id,
});
const userRecord = merge({}, user, {
status: {
current: user.status.current.filter(s => s !== GQLUSER_STATUS.BANNED),
banned: { active: false },
},
});
return {
user: userRecord,
};
});
const { container } = await createTestRenderer({
Mutation: { removeUserBan },
Query: {
users: createQueryResolverStub<QueryToUsersResolver>(() => ({
edges: [
{
node: user,
cursor: user.createdAt,
},
],
pageInfo: { endCursor: null, hasNextPage: false },
})),
},
});
const userRow = within(container).getByText(user.username!, {
selector: "tr",
});
TestRenderer.act(() => {
within(userRow)
.getByLabelText("Change user status")
.props.onClick();
});
const popup = within(userRow).getByLabelText(
"A dropdown to change the user status"
);
TestRenderer.act(() => {
within(popup)
.getByText("Remove Ban")
.props.onClick();
});
within(userRow).getByText("Active");
expect(removeUserBan.called).toBe(true);
});
@@ -24,7 +24,7 @@ const createTestRenderer = async (resolver: any = {}) => {
settings: sinon
.stub()
.returns(merge({}, settings, get(resolver, "Query.settings"))),
viewer: sinon.stub().returns(users[0]),
viewer: sinon.stub().returns(users.admins[0]),
},
};
const { testRenderer } = create({
@@ -28,7 +28,7 @@ const createTestRenderer = async (resolver: any = {}) => {
.returns(
merge({}, settingsWithEmptyAuth, get(resolver, "Query.settings"))
),
viewer: sinon.stub().returns(users[0]),
viewer: sinon.stub().returns(users.admins[0]),
},
};
const { testRenderer } = create({
@@ -29,7 +29,7 @@ const createTestRenderer = async (
settings: sinon
.stub()
.returns(merge({}, settings, get(resolver, "Query.settings"))),
viewer: sinon.stub().returns(users[0]),
viewer: sinon.stub().returns(users.admins[0]),
},
};
const { testRenderer } = create({
@@ -24,7 +24,7 @@ const createTestRenderer = async (resolver: any = {}) => {
settings: sinon
.stub()
.returns(merge({}, settings, get(resolver, "Query.settings"))),
viewer: sinon.stub().returns(users[0]),
viewer: sinon.stub().returns(users.admins[0]),
},
};
const { testRenderer } = create({
@@ -24,7 +24,7 @@ const createTestRenderer = async (resolver: any = {}) => {
settings: sinon
.stub()
.returns(merge({}, settings, get(resolver, "Query.settings"))),
viewer: sinon.stub().returns(users[0]),
viewer: sinon.stub().returns(users.admins[0]),
},
};
const { testRenderer } = create({
@@ -47,7 +47,7 @@ it("denies access to moderators", async () => {
for (const r of deniedRoles) {
const { testRenderer } = await createTestRenderer({
Query: {
viewer: sinon.stub().returns({ ...users[0], role: r }),
viewer: sinon.stub().returns({ ...users.admins[0], role: r }),
},
});
await waitForElement(() =>
@@ -61,7 +61,7 @@ it("allows access to admins", async () => {
for (const r of deniedRoles) {
const { testRenderer } = await createTestRenderer({
Query: {
viewer: sinon.stub().returns({ ...users[0], role: r }),
viewer: sinon.stub().returns({ ...users.admins[0], role: r }),
},
});
await waitForElement(() =>
@@ -24,7 +24,7 @@ const createTestRenderer = async (resolver: any = {}) => {
settings: sinon
.stub()
.returns(merge({}, settings, get(resolver, "Query.settings"))),
viewer: sinon.stub().returns(users[0]),
viewer: sinon.stub().returns(users.admins[0]),
},
};
const { testRenderer } = create({
@@ -62,7 +62,7 @@ const createTestRenderer = async (resolver: any = {}) => {
Query: {
...resolver.Query,
viewer: sinon.stub().returns({
...users[0],
...users.admins[0],
commentModerationActionHistory,
}),
settings: sinon
+240 -159
View File
@@ -1,10 +1,24 @@
import { merge } from "lodash";
import {
GQLComment,
GQLCOMMENT_STATUS,
GQLCommentModerationAction,
GQLCommentsConnection,
GQLMODERATION_MODE,
GQLModerationQueues,
GQLSettings,
GQLStoriesConnection,
GQLStory,
GQLSTORY_STATUS,
GQLUser,
GQLUSER_ROLE,
GQLUSER_STATUS,
GQLUsersConnection,
} from "talk-framework/schema";
import { createFixture, createFixtures } from "talk-framework/testHelpers";
import { GQLStory, GQLSTORY_STATUS, GQLUSER_ROLE } from "talk-framework/schema";
export const settings = {
export const settings = createFixture<GQLSettings>({
id: "settings",
moderation: "POST",
moderation: GQLMODERATION_MODE.POST,
premodLinksEnable: false,
wordList: {
suspect: ["idiot", "stupid"],
@@ -24,7 +38,7 @@ export const settings = {
timeout: 604800,
message: "Comments are closed on this story.",
},
customCSSURL: null,
customCSSURL: "",
domains: ["localhost:8080"],
editCommentWindowLength: 30000,
communityGuidelines: {
@@ -61,7 +75,7 @@ export const settings = {
admin: true,
stream: true,
},
key: null,
key: "",
keyGeneratedAt: null,
},
google: {
@@ -101,71 +115,73 @@ export const settings = {
},
},
},
};
});
export const settingsWithEmptyAuth = {
...settings,
id: "settings",
auth: {
integrations: {
local: {
enabled: true,
allowRegistration: true,
targetFilter: {
admin: true,
stream: true,
export const settingsWithEmptyAuth = createFixture<GQLSettings>(
{
id: "settings",
auth: {
integrations: {
local: {
enabled: true,
allowRegistration: true,
targetFilter: {
admin: true,
stream: true,
},
},
},
sso: {
enabled: false,
allowRegistration: true,
targetFilter: {
admin: true,
stream: true,
sso: {
enabled: false,
allowRegistration: true,
targetFilter: {
admin: true,
stream: true,
},
key: "",
keyGeneratedAt: null,
},
key: null,
keyGeneratedAt: null,
},
google: {
enabled: false,
allowRegistration: true,
targetFilter: {
admin: true,
stream: true,
google: {
enabled: false,
allowRegistration: true,
targetFilter: {
admin: true,
stream: true,
},
clientID: "",
clientSecret: "",
callbackURL: "http://localhost/google/callback",
redirectURL: "http://localhost/google",
},
clientID: "",
clientSecret: "",
callbackURL: "http://localhost/google/callback",
redirectURL: "http://localhost/google",
},
facebook: {
enabled: false,
allowRegistration: true,
targetFilter: {
admin: true,
stream: true,
facebook: {
enabled: false,
allowRegistration: true,
targetFilter: {
admin: true,
stream: true,
},
clientID: "",
clientSecret: "",
callbackURL: "http://localhost/facebook/callback",
redirectURL: "http://localhost/facebook",
},
clientID: "",
clientSecret: "",
callbackURL: "http://localhost/facebook/callback",
redirectURL: "http://localhost/facebook",
},
oidc: {
enabled: false,
allowRegistration: false,
targetFilter: {
admin: true,
stream: true,
oidc: {
enabled: false,
allowRegistration: false,
targetFilter: {
admin: true,
stream: true,
},
name: "",
callbackURL: "http://localhost/oidc/callback",
redirectURL: "http://localhost/oidc",
},
name: "",
callbackURL: "http://localhost/oidc/callback",
redirectURL: "http://localhost/oidc",
},
},
},
};
settings
);
export const moderationActions = [
export const moderationActions = createFixtures<GQLCommentModerationAction>([
{
id: "07e8f815-e165-4b5d-b438-7163415c8cf7",
revision: {
@@ -179,7 +195,7 @@ export const moderationActions = [
},
},
createdAt: "2018-11-29T16:01:51.897Z",
status: "ACCEPTED",
status: GQLCOMMENT_STATUS.ACCEPTED,
__typename: "CommentModerationAction",
},
{
@@ -195,7 +211,7 @@ export const moderationActions = [
},
},
createdAt: "2018-11-29T16:01:45.644Z",
status: "REJECTED",
status: GQLCOMMENT_STATUS.REJECTED,
__typename: "CommentModerationAction",
},
{
@@ -211,7 +227,7 @@ export const moderationActions = [
},
},
createdAt: "2018-11-29T16:01:42.060Z",
status: "ACCEPTED",
status: GQLCOMMENT_STATUS.ACCEPTED,
__typename: "CommentModerationAction",
},
{
@@ -227,7 +243,7 @@ export const moderationActions = [
},
},
createdAt: "2018-11-29T16:01:34.539Z",
status: "REJECTED",
status: GQLCOMMENT_STATUS.REJECTED,
__typename: "CommentModerationAction",
},
{
@@ -243,45 +259,105 @@ export const moderationActions = [
},
},
createdAt: "2018-11-29T16:01:30.648Z",
status: "ACCEPTED",
status: GQLCOMMENT_STATUS.ACCEPTED,
__typename: "CommentModerationAction",
},
];
]);
export const baseUser = {
export const baseUser = createFixture<GQLUser>({
profiles: [{ __typename: "LocalProfile" }],
createdAt: "2018-07-06T18:24:00.000Z",
status: {
current: [],
ban: {
active: false,
history: [],
},
suspension: {
active: false,
history: [],
},
},
});
export const users = {
admins: createFixtures<GQLUser>(
[
{
id: "user-admin-0",
username: "Markus",
email: "markus@test.com",
role: GQLUSER_ROLE.ADMIN,
},
],
baseUser
),
moderators: createFixtures<GQLUser>(
[
{
id: "user-moderator-0",
username: "Lukas",
email: "lukas@test.com",
role: GQLUSER_ROLE.MODERATOR,
},
],
baseUser
),
staff: createFixtures<GQLUser>(
[
{
id: "user-staff-0",
username: "Huy",
email: "huy@test.com",
role: GQLUSER_ROLE.STAFF,
},
],
baseUser
),
commenters: createFixtures<GQLUser>(
[
{
id: "user-commenter-0",
username: "Isabelle",
email: "isabelle@test.com",
role: GQLUSER_ROLE.COMMENTER,
},
{
id: "user-commenter-1",
username: "Ngoc",
email: "ngoc@test.com",
role: GQLUSER_ROLE.COMMENTER,
},
{
id: "user-commenter-2",
username: "Max",
email: "max@test.com",
role: GQLUSER_ROLE.COMMENTER,
},
],
baseUser
),
bannedCommenter: createFixture<GQLUser>(
{
id: "user-banned-0",
username: "Ingrid",
email: "ingrid@test.com",
role: GQLUSER_ROLE.COMMENTER,
status: {
current: [GQLUSER_STATUS.BANNED],
ban: { active: true },
suspension: { active: true },
},
},
baseUser
),
};
export const users = [
{
...baseUser,
id: "user-0",
username: "Markus",
email: "markus@test.com",
role: GQLUSER_ROLE.ADMIN,
},
{
...baseUser,
id: "user-1",
username: "Lukas",
email: "lukas@test.com",
role: GQLUSER_ROLE.MODERATOR,
},
{
...baseUser,
id: "user-2",
username: "Isabelle",
email: "isabelle@test.com",
role: GQLUSER_ROLE.COMMENTER,
},
];
export const baseComment = {
author: users[0],
export const baseComment = createFixture<GQLComment>({
author: users.commenters[0],
body: "Comment Body",
createdAt: "2018-07-06T18:24:00.000Z",
status: "NONE",
status: GQLCOMMENT_STATUS.NONE,
actionCounts: {
flag: {
reasons: {
@@ -296,68 +372,71 @@ export const baseComment = {
},
},
},
};
});
export const reportedComments = [
merge({}, baseComment, {
id: "comment-0",
author: users[0],
revision: {
id: "comment-0-revision-0",
},
permalink: "http://localhost/comment/0",
body:
"This is the last random sentence I will be writing and I am going to stop mid-sent",
actionCounts: {
flag: {
reasons: {
COMMENT_REPORTED_SPAM: 2,
export const reportedComments = createFixtures<GQLComment>(
[
{
id: "comment-0",
author: users.commenters[0],
revision: {
id: "comment-0-revision-0",
},
permalink: "http://localhost/comment/0",
body:
"This is the last random sentence I will be writing and I am going to stop mid-sent",
actionCounts: {
flag: {
reasons: {
COMMENT_REPORTED_SPAM: 2,
},
},
},
},
}),
merge({}, baseComment, {
id: "comment-1",
revision: {
id: "comment-1-revision-1",
},
permalink: "http://localhost/comment/1",
author: users[1],
body: "Don't fool with me",
actionCounts: {
flag: {
reasons: {
COMMENT_REPORTED_OFFENSIVE: 3,
{
id: "comment-1",
revision: {
id: "comment-1-revision-1",
},
permalink: "http://localhost/comment/1",
author: users.commenters[1],
body: "Don't fool with me",
actionCounts: {
flag: {
reasons: {
COMMENT_REPORTED_OFFENSIVE: 3,
},
},
},
},
}),
merge({}, baseComment, {
id: "comment-2",
revision: {
id: "comment-2-revision-2",
},
permalink: "http://localhost/comment/2",
status: "PREMOD",
author: users[2],
body: "I think I deserve better",
actionCounts: {
flag: {
reasons: {
COMMENT_REPORTED_SPAM: 1,
COMMENT_REPORTED_OFFENSIVE: 1,
{
id: "comment-2",
revision: {
id: "comment-2-revision-2",
},
permalink: "http://localhost/comment/2",
status: GQLCOMMENT_STATUS.PREMOD,
author: users.commenters[2],
body: "I think I deserve better",
actionCounts: {
flag: {
reasons: {
COMMENT_REPORTED_SPAM: 1,
COMMENT_REPORTED_OFFENSIVE: 1,
},
},
},
},
}),
];
],
baseComment
);
export const rejectedComments = reportedComments.map(c => ({
export const rejectedComments = reportedComments.map<GQLComment>(c => ({
...c,
status: "REJECTED",
status: GQLCOMMENT_STATUS.REJECTED,
}));
export const emptyModerationQueues = {
export const emptyModerationQueues = createFixture<GQLModerationQueues>({
reported: {
id: "reported",
count: 0,
@@ -373,27 +452,29 @@ export const emptyModerationQueues = {
count: 0,
comments: { edges: [], pageInfo: { endCursor: null, hasNextPage: false } },
},
};
});
export const emptyRejectedComments = {
export const emptyRejectedComments = createFixture<GQLCommentsConnection>({
edges: [],
pageInfo: { endCursor: null, hasNextPage: false },
};
});
export const communityUsers = {
export const communityUsers = createFixture<GQLUsersConnection>({
edges: [
{ node: users[0], cursor: users[0].createdAt },
{ node: users[1], cursor: users[1].createdAt },
{ node: users.admins[0], cursor: users.admins[0].createdAt },
{ node: users.moderators[0], cursor: users.moderators[0].createdAt },
{ node: users.staff[0], cursor: users.staff[0].createdAt },
{ node: users.commenters[0], cursor: users.commenters[0].createdAt },
],
pageInfo: { endCursor: null, hasNextPage: false },
};
});
export const emptyCommunityUsers = {
export const emptyCommunityUsers = createFixture<GQLUsersConnection>({
edges: [],
pageInfo: { endCursor: null, hasNextPage: false },
};
});
export const stories: Array<Partial<GQLStory>> = [
export const stories = createFixtures<GQLStory>([
{
id: "story-1",
closedAt: null,
@@ -430,17 +511,17 @@ export const stories: Array<Partial<GQLStory>> = [
publishedAt: "2018-11-29T16:01:51.897Z",
},
},
];
]);
export const storyConnection = {
export const storyConnection = createFixture<GQLStoriesConnection>({
edges: [
{ node: stories[0], cursor: stories[0].createdAt },
{ node: stories[1], cursor: stories[1].createdAt },
],
pageInfo: { endCursor: null, hasNextPage: false },
};
});
export const emptyStories = {
export const emptyStories = createFixture<GQLStoriesConnection>({
edges: [],
pageInfo: { endCursor: null, hasNextPage: false },
};
});
@@ -157,7 +157,7 @@ exports[`rejected queue accepts comment in rejected queue: dangling 1`] = `
<span
className="Typography-root Typography-heading2 Typography-colorTextPrimary ModerateCard-username Username-root"
>
Markus
Isabelle
</span>
<time
className="Timestamp-root RelativeTime-root"
@@ -310,7 +310,7 @@ exports[`rejected queue renders rejected queue with comments 1`] = `
<span
className="Typography-root Typography-heading2 Typography-colorTextPrimary ModerateCard-username Username-root"
>
Markus
Isabelle
</span>
<time
className="Timestamp-root RelativeTime-root"
@@ -450,7 +450,7 @@ exports[`rejected queue renders rejected queue with comments 1`] = `
<span
className="Typography-root Typography-heading2 Typography-colorTextPrimary ModerateCard-username Username-root"
>
Lukas
Ngoc
</span>
<time
className="Timestamp-root RelativeTime-root"
@@ -597,7 +597,7 @@ exports[`rejected queue renders rejected queue with comments and load more 1`] =
<span
className="Typography-root Typography-heading2 Typography-colorTextPrimary ModerateCard-username Username-root"
>
Isabelle
Max
</span>
<time
className="Timestamp-root RelativeTime-root"
@@ -766,7 +766,7 @@ exports[`reported queue accepts comment in reported queue: dangling 1`] = `
<span
className="Typography-root Typography-heading2 Typography-colorTextPrimary ModerateCard-username Username-root"
>
Markus
Isabelle
</span>
<time
className="Timestamp-root RelativeTime-root"
@@ -922,7 +922,7 @@ exports[`reported queue rejects comment in reported queue: dangling 1`] = `
<span
className="Typography-root Typography-heading2 Typography-colorTextPrimary ModerateCard-username Username-root"
>
Markus
Isabelle
</span>
<time
className="Timestamp-root RelativeTime-root"
@@ -1090,7 +1090,7 @@ exports[`reported queue renders reported queue with comments 1`] = `
<span
className="Typography-root Typography-heading2 Typography-colorTextPrimary ModerateCard-username Username-root"
>
Markus
Isabelle
</span>
<time
className="Timestamp-root RelativeTime-root"
@@ -1230,7 +1230,7 @@ exports[`reported queue renders reported queue with comments 1`] = `
<span
className="Typography-root Typography-heading2 Typography-colorTextPrimary ModerateCard-username Username-root"
>
Lukas
Ngoc
</span>
<time
className="Timestamp-root RelativeTime-root"
@@ -1376,7 +1376,7 @@ exports[`reported queue renders reported queue with comments and load more 1`] =
<span
className="Typography-root Typography-heading2 Typography-colorTextPrimary ModerateCard-username Username-root"
>
Isabelle
Max
</span>
<time
className="Timestamp-root RelativeTime-root"
@@ -1537,7 +1537,7 @@ exports[`single comment view accepts single comment 1`] = `
<span
className="Typography-root Typography-heading2 Typography-colorTextPrimary ModerateCard-username Username-root"
>
Markus
Isabelle
</span>
<time
className="Timestamp-root RelativeTime-root"
@@ -1680,7 +1680,7 @@ exports[`single comment view rejects single comment 1`] = `
<span
className="Typography-root Typography-heading2 Typography-colorTextPrimary ModerateCard-username Username-root"
>
Markus
Isabelle
</span>
<time
className="Timestamp-root RelativeTime-root"
@@ -1858,7 +1858,7 @@ exports[`single comment view renders single comment view 1`] = `
<span
className="Typography-root Typography-heading2 Typography-colorTextPrimary ModerateCard-username Username-root"
>
Markus
Isabelle
</span>
<time
className="Timestamp-root RelativeTime-root"
@@ -44,7 +44,7 @@ const createTestRenderer = async (resolver: any = {}) => {
comments:
get(resolver, "Query.comments") ||
sinon.stub().returns(emptyRejectedComments),
viewer: sinon.stub().returns(users[0]),
viewer: sinon.stub().returns(users.admins[0]),
},
};
const { testRenderer } = create({
@@ -36,7 +36,7 @@ const createTestRenderer = async (resolver: any = {}) => {
expectAndFail(data.status).toBeFalsy();
return storyConnection;
}),
viewer: sinon.stub().returns(users[0]),
viewer: sinon.stub().returns(users.admins[0]),
...resolver.Query,
},
};
@@ -2,21 +2,23 @@ import { RouteProps } from "found";
import React, { Component } from "react";
import { RestrictedContainer_viewer as ViewerData } from "talk-admin/__generated__/RestrictedContainer_viewer.graphql";
import { SetRedirectPathMutation } from "talk-admin/mutations";
import { timeout } from "talk-common/utils";
import {
SetRedirectPathMutation,
withSetRedirectPathMutation,
} from "talk-admin/mutations";
import { graphql, withFragmentContainer } from "talk-framework/lib/relay";
graphql,
MutationProp,
withFragmentContainer,
withMutation,
} from "talk-framework/lib/relay";
import { SignOutMutation, withSignOutMutation } from "talk-framework/mutations";
import { timeout } from "talk-common/utils";
import Restricted from "../components/Restricted";
interface Props {
viewer: ViewerData;
error?: Error | null;
signOut: SignOutMutation;
setRedirectPath: SetRedirectPathMutation;
setRedirectPath: MutationProp<typeof SetRedirectPathMutation>;
}
class RestrictedContainer extends Component<Props> {
@@ -51,6 +53,10 @@ const enhanced = withFragmentContainer<Props>({
username
}
`,
})(withSetRedirectPathMutation(withSignOutMutation(RestrictedContainer)));
})(
withMutation(SetRedirectPathMutation)(
withSignOutMutation(RestrictedContainer)
)
);
export default enhanced;
@@ -14,6 +14,14 @@ interface Props {
localeBundles: TalkContext["localeBundles"];
}
interface InjectedProps {
getMessage: GetMessage;
}
interface Props {
localeBundles: TalkContext["localeBundles"];
}
/**
* withGetMessage provides a property `getMessage: (id: string) => string`
* that'll provide a translated string associated with `id`.
@@ -22,13 +30,17 @@ const withGetMessage: DefaultingInferableComponentEnhancer<
InjectedProps
> = hoistStatics<InjectedProps>(
<T extends InjectedProps>(BaseComponent: React.ComponentType<T>) => {
// TODO: (cvle) This is a workaround for a typescript bug
// https://github.com/Microsoft/TypeScript/issues/30762
const Workaround = BaseComponent as React.ComponentType<InjectedProps>;
class WithGetMessage extends React.Component<Props> {
private getMessage = (id: string, defaultTo?: string) => {
return getMessage(this.props.localeBundles, id, defaultTo);
};
public render() {
const { localeBundles: _, ...rest } = this.props;
return <BaseComponent {...rest} getMessage={this.getMessage} />;
return <Workaround {...rest} getMessage={this.getMessage} />;
}
}
@@ -25,6 +25,10 @@ const withInView: DefaultingInferableComponentEnhancer<
InjectedProps
> = hoistStatics<InjectedProps>(
<T extends InjectedProps>(BaseComponent: React.ComponentType<T>) => {
// TODO: (cvle) This is a workaround for a typescript bug
// https://github.com/Microsoft/TypeScript/issues/30762
const Workaround = BaseComponent as React.ComponentType<InjectedProps>;
class WithInView extends React.Component<Props, State> {
private unobserve: (() => void) | null = null;
@@ -62,7 +66,7 @@ const withInView: DefaultingInferableComponentEnhancer<
public render() {
return (
<BaseComponent
<Workaround
{...this.props}
inView={this.state.inView}
intersectionRef={this.changeRef}
@@ -1,7 +1,7 @@
import { commitMutation } from "react-relay";
import { Environment, MutationConfig, OperationBase } from "relay-runtime";
import { Omit } from "talk-framework/types";
import { DeepPartial, Omit } from "talk-framework/types";
import extractPayload from "./extractPayload";
@@ -11,8 +11,8 @@ import extractPayload from "./extractPayload";
*/
export type MutationPromiseConfig<T extends OperationBase> = Omit<
MutationConfig<T>,
"onCompleted" | "onError"
>;
"onCompleted" | "onError" | "optimisticResponse"
> & { optimisticResponse?: DeepPartial<T["response"]> };
/**
* Normalizes response and error from `commitMutationPromise`.
@@ -6,24 +6,9 @@ import {
wrapDisplayName,
} from "recompose";
import { Environment } from "relay-runtime";
import { Omit } from "talk-framework/types";
import { TalkContext, withContext } from "../bootstrap";
export type MutationInput<
T extends { variables: { input: { clientMutationId: string } } }
> = Omit<T["variables"]["input"], "clientMutationId">;
export type MutationResponse<
T extends { response: { [P in U]: any } },
U extends string
> = Exclude<T["response"][U], null>;
export type MutationResponsePromise<
T extends { response: { [P in U]: any } },
U extends string
> = Promise<MutationResponse<T, U>>;
/**
* createMutationContainer creates a HOC that
* injects a property with the name specified in `propName`
+7 -2
View File
@@ -5,12 +5,16 @@ export { default as withLocalStateContainer } from "./withLocalStateContainer";
export * from "./withLocalStateContainer";
export { default as QueryRenderer } from "./QueryRenderer";
export * from "./QueryRenderer";
export { default as createMutationContainer } from "./createMutationContainer";
export {
default as createMutationContainer,
createMutation,
useMutation,
withMutation,
MutationInput,
MutationResponse,
MutationResponsePromise,
} from "./createMutationContainer";
MutationProp,
} from "./mutation";
export { default as createFetchContainer } from "./createFetchContainer";
export { default as createAndRetain } from "./createAndRetain";
export { default as wrapFetchWithLogger } from "./wrapFetchWithLogger";
@@ -26,3 +30,4 @@ export { initLocalBaseState, setAccessTokenInLocalState } from "./localState";
export { default as fetchQuery } from "./fetchQuery";
export { default as useRefetch } from "./useRefetch";
export { default as useLoadMore } from "./useLoadMore";
export { default as lookup } from "./lookup";
@@ -0,0 +1,49 @@
import { Environment, RelayInMemoryRecordSource } from "relay-runtime";
/**
* RecordSourceProxy has the same shape as the underlying Schema Type, but
* makes all fields optional and readonly.
*/
type RecordSourceProxy<T> = T extends object
? {
readonly [P in keyof T]?: T[P] extends Array<infer U>
? ReadonlyArray<RecordSourceProxy<U>>
: T[P] extends ReadonlyArray<infer V>
? ReadonlyArray<RecordSourceProxy<V>>
: RecordSourceProxy<T[P]>
}
: T;
/**
* createProxy returns a proxy for `recordSource`, that automatically
* resolves references to other record sources.
*/
const createProxy = <T = any>(
environment: Environment,
recordSource: RelayInMemoryRecordSource
) => {
const proxy: ProxyHandler<any> = {
get(_, prop) {
if ((recordSource as any)[prop].__ref) {
return lookup(environment, (recordSource as any)[prop].__ref);
}
return (recordSource as any)[prop];
},
};
return new Proxy({}, proxy) as RecordSourceProxy<T>;
};
/**
* Lookup the Relay Cache with given object id. Returns a `RecordSourceProxy``
* for easy traversing through the Relay Cache.
*/
export default function lookup<T = any>(environment: Environment, id: string) {
const recordSource = environment
.getStore()
.getSource()
.get(id);
if (!recordSource) {
return null;
}
return createProxy<T>(environment, recordSource);
}
@@ -0,0 +1,118 @@
import React, { useCallback } from "react";
import {
compose,
hoistStatics,
InferableComponentEnhancer,
wrapDisplayName,
} from "recompose";
import { Environment } from "relay-runtime";
import { Omit } from "talk-framework/types";
import { TalkContext, useTalkContext, withContext } from "../bootstrap";
export interface Mutation<N, I, R> {
name: N;
commit: (environment: Environment, input: I, context: TalkContext) => R;
}
export type MutationInput<
T extends { variables: { input: { clientMutationId: string } } }
> = Omit<T["variables"]["input"], "clientMutationId">;
export type MutationResponse<
T extends { response: { [P in U]: any } },
U extends string | number | symbol
> = Exclude<T["response"][U], null>;
export type MutationResponsePromise<
T extends { response: { [P in U]: any } },
U extends string | number | symbol
> = Promise<MutationResponse<T, U>>;
export type MutationProp<
T extends Mutation<any, any, any>
> = T extends Mutation<any, infer I, infer R>
? Parameters<T["commit"]>[1] extends undefined
? () => R
: keyof Parameters<T["commit"]>[1] extends never ? () => R : (input: I) => R
: never;
type RemoveClientMutationID<T> = T extends Promise<infer U>
? Promise<
U extends { clientMutationId: any } ? Omit<U, "clientMutationId"> : U
>
: T extends { clientMutationId: any } ? Omit<T, "clientMutationId"> : T;
export function createMutation<N extends string, I, R>(
name: N,
commit: (environment: Environment, input: I, context: TalkContext) => R
// (cvle) We remove `clientMutationId` from the response, so we don't use it inside our app.
// It is a Relay implementation detail that is pending for removal.
// https://github.com/facebook/relay/pull/2349
): Mutation<N, I, RemoveClientMutationID<R>> {
return {
name,
commit,
} as any;
}
/**
* useMutation is a React Hook that
* returns a callback to call the mutation.
*/
export function useMutation<I, R>(
mutation: Mutation<any, I, R>
): MutationProp<typeof mutation> {
const context = useTalkContext();
return useCallback<MutationProp<typeof mutation>>(
((input: I) => {
context.eventEmitter.emit(`mutation.${mutation.name}`, input);
return mutation.commit(context.relayEnvironment, input, context);
}) as any,
[context]
);
}
/**
* withMutation creates a HOC that injects the mutation as
* a property.
*/
export function withMutation<N extends string, I, R>(
mutation: Mutation<N, I, R>
): InferableComponentEnhancer<{ [P in N]: MutationProp<typeof mutation> }> {
return compose(
withContext(context => ({ context })),
hoistStatics((BaseComponent: React.ComponentType<any>) => {
class WithMutation extends React.Component<{
context: TalkContext;
}> {
public static displayName = wrapDisplayName(
BaseComponent,
"withMutation"
);
private commit = (input: I) => {
this.props.context.eventEmitter.emit(
`mutation.${mutation.name}`,
input
);
return mutation.commit(
this.props.context.relayEnvironment,
input,
this.props.context
);
};
public render() {
const { context: _, ...rest } = this.props;
const inject = {
[mutation.name]: this.commit,
};
return <BaseComponent {...rest} {...inject} />;
}
}
return WithMutation as React.ComponentType<any>;
})
);
}
@@ -15,9 +15,11 @@ import {
GQLSTORY_STATUS,
GQLUSER_AUTH_CONDITIONS,
GQLUSER_ROLE,
GQLUSER_STATUS,
} from "./__generated__/types";
export type GQLUSER_ROLE_RL = RelayEnumLiteral<typeof GQLUSER_ROLE>;
export type GQLUSER_STATUS_RL = RelayEnumLiteral<typeof GQLUSER_STATUS>;
export type GQLCOMMENT_FLAG_DETECTED_REASON_RL = RelayEnumLiteral<
typeof GQLCOMMENT_FLAG_DETECTED_REASON
>;
@@ -0,0 +1,34 @@
import { merge } from "lodash";
/**
* Fixture prepares schema type to be used in fixtures.
* It adds an optional `__typename` to the schema type and
* marks fields as optional.
*/
export type Fixture<T> = T extends object
? {
// (cvle): We don't use & { __typename?: string } because for some reason
// typescript would allow field names that are not defined!
[P in keyof T | "__typename"]?: P extends keyof T
? T[P] extends Array<infer U>
? Array<Fixture<U>>
: T[P] extends ReadonlyArray<infer V>
? ReadonlyArray<Fixture<V>>
: Fixture<T[P]>
: string
}
: T;
/**
* createFixture lets you input the data of a schema object as deep partial
* including it's `__typename`, merged it with `base` and return it as the
* schema object's type. In the future the result could be trimmed down
* to only include fields that exists in `data` and `base` though to
* type this it seems we need partial generic inferation support.
*/
export default function createFixture<T>(data: Fixture<T>, base?: T): T {
if (base) {
return merge({}, base, data);
}
return data as T;
}
@@ -0,0 +1,15 @@
import createFixture, { Fixture } from "./createFixture";
/**
* createFixtures lets you input an array of data of a schema object as deep partial
* including it's `__typename`, merged it with `base` and return it as any array of the
* schema object's type. In the future the result could be trimmed down
* to only include fields that exists in `data` and `base` though to
* type this it seems we need partial generic inferation support.
*/
export default function createFixtures<T>(
data: Array<Fixture<T>>,
base?: T
): T[] {
return data.map(d => createFixture(d, base)) as T[];
}
@@ -0,0 +1,27 @@
import sinon from "sinon";
import { Mutation } from "talk-framework/lib/relay/mutation";
export default function createMutationResolverStub<
T extends Mutation<any, any, any>
>(
callback: (
variables: T extends Mutation<any, infer I, any> ? I : never,
callCount: number
) => T extends Mutation<any, any, infer R>
? R extends Promise<infer U> ? U | R : R | Promise<R>
: never
) {
let callCount = 0;
const lastClientMutationIds: any[] = [];
const resolver = async (_: any, data: any) => {
const clientMutationId = data.input.clientMutationId;
expectAndFail(clientMutationId).toBeTruthy();
expectAndFail(lastClientMutationIds).not.toContain(clientMutationId);
lastClientMutationIds.push(clientMutationId);
const result = await callback(data.input, callCount++);
expectAndFail(result.clientMutationId).toBeUndefined();
result.clientMutationId = clientMutationId;
return result;
};
return sinon.stub().callsFake(resolver);
}
@@ -0,0 +1,17 @@
import sinon from "sinon";
type Resolver<V, R> = (parent: any, args: V, context: any, info: any) => R;
export default function createQueryResolverStub<T extends Resolver<any, any>>(
callback: (
variables: T extends Resolver<infer V, any> ? V : never,
callCount: number
) => T extends Resolver<any, infer R>
? R extends Promise<infer U> ? U | R : R | Promise<R>
: never
) {
let callCount = 0;
return sinon
.stub()
.callsFake((_: any, data: any) => callback(data, callCount++));
}
@@ -22,3 +22,9 @@ export { default as replaceHistoryLocation } from "./replaceHistoryLocation";
export { default as createAccessToken } from "./createAccessToken";
export { default as findParentsWithType } from "./findParentsWithType";
export { default as findParentWithType } from "./findParentWithType";
export { default as createFixture } from "./createFixture";
export { default as createFixtures } from "./createFixtures";
export {
default as createMutationResolverStub,
} from "./createMutationResolverStub";
export { default as createQueryResolverStub } from "./createQueryResolverStub";
@@ -20,10 +20,14 @@ export interface CloseButtonProps
}
const CloseButton: StatelessComponent<CloseButtonProps> = props => {
const { classes, forwardRef, ...rest } = props;
const {
classes: { icon: iconClassName, ...restClasses },
forwardRef,
...rest
} = props;
return (
<BaseButton classes={classes} ref={forwardRef} {...rest}>
<Icon className={classes.icon} size="md">
<BaseButton classes={restClasses} ref={forwardRef} {...rest}>
<Icon className={iconClassName} size="md">
close
</Icon>
</BaseButton>
@@ -29,15 +29,14 @@ import styles from "./Modal.mdx.css";
<Button variant="filled" color="primary" onClick={show}>
Show Modal
</Button>
<Modal open={open} onClose={hide} aria-labeledby="modal-title">
<Modal open={open} onClose={hide} aria-labelledby="modal-title">
{({ firstFocusableRef, lastFocusableRef }) => (
<Card className={styles.card}>
<CardCloseButton onClick={hide} ref={firstFocusableRef} />
<HorizontalGutter size="double">
<HorizontalGutter>
<Typography variant="header2" id="modal-title">
Are you sure you want to ban <strong>ButFirstCoffee</strong>
?
Are you sure you want to ban <strong>ButFirstCoffee</strong>?
</Typography>
<Typography>
Once banned, this user will no longer be able to comment,
@@ -28,6 +28,7 @@
appearance: none;
outline: none;
color: var(--palette-text-primary);
width: 100%;
&::-moz-focus-inner {
border: 0;
@@ -122,6 +122,10 @@
color: var(--palette-error-main);
}
.colorWarning {
color: var(--palette-warning-main);
}
.colorSuccess {
color: var(--palette-success-main);
color: var(--palette-success-darkest);
}
@@ -53,6 +53,7 @@ interface Props extends HTMLAttributes<any> {
| "textPrimary"
| "textSecondary"
| "textLight"
| "warning"
| "error"
| "success";
/**
@@ -114,6 +115,7 @@ const Typography: StatelessComponent<Props> = props => {
[classes.colorPrimary]: color === "primary",
[classes.colorError]: color === "error",
[classes.colorSuccess]: color === "success",
[classes.colorWarning]: color === "warning",
[classes.noWrap]: noWrap,
[classes.gutterBottom]: gutterBottom,
[classes.paragraph]: paragraph,
+1
View File
@@ -55,3 +55,4 @@ export {
} from "./Dropdown";
export { Table, TableBody, TableHead, TableRow, TableCell } from "./Table";
export { default as Tag } from "./Tag";
export { default as Modal } from "./Modal";
@@ -17,6 +17,10 @@ const withKeyboardFocus: DefaultingInferableComponentEnhancer<
InjectedProps
> = hoistStatics<InjectedProps>(
<T extends InjectedProps>(BaseComponent: React.ComponentType<T>) => {
// TODO: (cvle) This is a workaround for a typescript bug
// https://github.com/Microsoft/TypeScript/issues/30762
const Workaround = BaseComponent as React.ComponentType<InjectedProps>;
class WithKeyboardFocus extends React.Component<any> {
private lastMouseDownTime: number = 0;
public state = {
@@ -55,7 +59,7 @@ const withKeyboardFocus: DefaultingInferableComponentEnhancer<
public render() {
return (
<BaseComponent
<Workaround
{...this.props}
onFocus={this.handleFocus}
onBlur={this.handleBlur}
+5 -1
View File
@@ -18,6 +18,10 @@ const withMouseHover: DefaultingInferableComponentEnhancer<
InjectedProps
> = hoistStatics<InjectedProps>(
<T extends InjectedProps>(BaseComponent: React.ComponentType<T>) => {
// TODO: (cvle) This is a workaround for a typescript bug
// https://github.com/Microsoft/TypeScript/issues/30762
const Workaround = BaseComponent as React.ComponentType<InjectedProps>;
class WithMouseHover extends React.Component<InjectedProps> {
private lastTouchEndTime = 0;
public state = {
@@ -50,7 +54,7 @@ const withMouseHover: DefaultingInferableComponentEnhancer<
public render() {
return (
<BaseComponent
<Workaround
{...this.props}
onMouseOver={this.handleMouseOver}
onMouseOut={this.handleMouseOut}
+4
View File
@@ -44,6 +44,10 @@ const variables = {
lighter: "#FC9795",
lightest: "#FEF0EF",
},
/* Warning colors */
warning: {
main: "#DC8400",
},
/* Text colors */
text: {
primary: "#3B4A53",
+24
View File
@@ -198,4 +198,28 @@ export enum ERROR_CODES {
* enabled.
*/
SPAM_COMMENT = "SPAM_COMMENT",
/**
* USER_ALREADY_SUSPENDED is returned when a moderator or administrator
* attempts to suspend a user that already has an active suspension.
*/
USER_ALREADY_SUSPENDED = "USER_ALREADY_SUSPENDED",
/**
* USER_ALREADY_BANNED is returned when a moderator or administrator attempts
* to ban a user that is already banned.
*/
USER_ALREADY_BANNED = "USER_ALREADY_BANNED",
/**
* USER_SUSPENDED is returned when the user attempts to perform an action that
* is not permitted if they are suspended.
*/
USER_SUSPENDED = "USER_SUSPENDED",
/**
* USER_BANNED is returned when the user attempts to perform an action that
* is not permitted if they are banned.
*/
USER_BANNED = "USER_BANNED",
}
+9 -7
View File
@@ -23,10 +23,12 @@ export type Promiseable<T> = Promise<T> | T;
/**
* Like Partial, but recurses down the object marking each field as Partial.
*/
export type DeepPartial<T> = {
[P in keyof T]?: T[P] extends Array<infer U>
? Array<DeepPartial<U>>
: T[P] extends ReadonlyArray<infer V>
? ReadonlyArray<DeepPartial<V>>
: DeepPartial<T[P]>
};
export type DeepPartial<T> = T extends object
? {
[P in keyof T]?: T[P] extends Array<infer U>
? Array<DeepPartial<U>>
: T[P] extends ReadonlyArray<infer V>
? ReadonlyArray<DeepPartial<V>>
: DeepPartial<T[P]>
}
: T;
+14 -8
View File
@@ -38,6 +38,7 @@ export const signupHandler = ({
// Tenant is guaranteed at this point.
const tenant = req.talk!.tenant!;
const now = req.talk!.now;
// Check to ensure that the local integration has been enabled.
if (!tenant.auth.integrations.local.enabled) {
@@ -65,14 +66,19 @@ export const signupHandler = ({
};
// Create the new user.
const user = await insert(mongo, tenant, {
email,
username,
profiles: [profile],
// New users signing up via local auth will have the commenter role to
// start with.
role: GQLUSER_ROLE.COMMENTER,
});
const user = await insert(
mongo,
tenant,
{
email,
username,
profiles: [profile],
// New users signing up via local auth will have the commenter role to
// start with.
role: GQLUSER_ROLE.COMMENTER,
},
now
);
// Send off to the passport handler.
return handleSuccessfulLogin(user, signingConfig, req, res, next);
+4 -1
View File
@@ -29,7 +29,8 @@ export const graphQLHandler = ({
throw new Error("talk was not set");
}
const { tenant, cache } = req.talk;
// Pull out some useful properties from Talk.
const { id, now, tenant, cache } = req.talk;
if (!cache) {
throw new Error("cache was not set");
@@ -43,6 +44,8 @@ export const graphQLHandler = ({
schema,
context: new TenantContext({
...options,
id,
now,
req,
config,
tenant,
+25 -14
View File
@@ -93,14 +93,20 @@ export const installHandler = ({
// Install will throw if it can not create a Tenant, or it has already been
// installed.
const tenant = await install(mongo, redis, req.talk.cache.tenant, {
...tenantInput,
// Infer the Tenant domain via the hostname parameter.
domain: req.hostname,
// Add the locale that we had to default to the default locale from the
// config.
locale,
});
const tenant = await install(
mongo,
redis,
req.talk.cache.tenant,
{
...tenantInput,
// Infer the Tenant domain via the hostname parameter.
domain: req.hostname,
// Add the locale that we had to default to the default locale from the
// config.
locale,
},
req.talk.now
);
// Pull the user details out of the input for the user.
const { email, username, password } = userInput;
@@ -113,12 +119,17 @@ export const installHandler = ({
};
// Create the first admin user.
await insert(mongo, tenant, {
email,
username,
profiles: [profile],
role: GQLUSER_ROLE.ADMIN,
});
await insert(
mongo,
tenant,
{
email,
username,
profiles: [profile],
role: GQLUSER_ROLE.ADMIN,
},
req.talk.now
);
// Send back the Tenant.
return res.sendStatus(204);
@@ -75,6 +75,9 @@ export async function handleLogout(redis: Redis, req: Request, res: Response) {
throw new Error("logout requires a token on the request, none was found");
}
// Talk is guarenteed at this point.
const { now } = req.talk!;
// Decode the token.
const decoded = jwt.decode(token, {});
if (!decoded) {
@@ -88,7 +91,7 @@ export async function handleLogout(redis: Redis, req: Request, res: Response) {
const { jti, exp }: LogoutToken = validate(LogoutTokenSchema, decoded);
// Compute the number of seconds that the token will be valid for.
const validFor = exp - Date.now() / 1000;
const validFor = exp - now.valueOf() / 1000;
if (validFor > 0) {
// Invalidate the token, the expiry is in the future and it needs to be
// revoked.

Some files were not shown because too many files have changed in this diff Show More