From dbbc1af42e1d5a97cad30f407bd6242a563b593a Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Mon, 22 Apr 2019 22:57:32 +0000 Subject: [PATCH] [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 --- package-lock.json | 18 +- package.json | 4 +- .../admin/containers/AuthCheckContainer.tsx | 11 +- .../admin/mutations/AcceptCommentMutation.ts | 127 ++-- .../client/admin/mutations/BanUserMutation.ts | 62 ++ .../admin/mutations/ClearAuthErrorMutation.ts | 21 +- .../admin/mutations/CloseStoryMutation.ts | 85 +-- .../mutations/CompleteAccountMutation.ts | 38 +- .../admin/mutations/OpenStoryMutation.ts | 85 +-- .../mutations/RegenerateSSOKeyMutation.ts | 68 +- .../admin/mutations/RejectCommentMutation.ts | 125 ++-- .../admin/mutations/RemoveUserBanMutation.ts | 61 ++ .../mutations/SetAuthViewMutation.spec.ts | 4 +- .../admin/mutations/SetAuthViewMutation.ts | 26 +- .../admin/mutations/SetEmailMutation.ts | 56 +- .../admin/mutations/SetPasswordMutation.ts | 61 +- .../mutations/SetRedirectPathMutation.spec.ts | 6 +- .../mutations/SetRedirectPathMutation.ts | 44 +- .../admin/mutations/SetUsernameMutation.ts | 57 +- .../client/admin/mutations/SignInMutation.ts | 35 +- .../admin/mutations/UpdateSettingsMutation.ts | 71 +- .../admin/mutations/UpdateUserRoleMutation.ts | 76 +- src/core/client/admin/mutations/index.ts | 72 +- .../routes/community/components/BanModal.css | 3 + .../routes/community/components/BanModal.tsx | 76 ++ .../community/components/ButtonPadding.css | 3 + .../community/components/ButtonPadding.tsx | 9 + .../routes/community/components/UserRow.tsx | 10 +- .../community/components/UserStatus.css | 4 + .../community/components/UserStatus.tsx | 56 ++ .../community/components/UserStatusChange.css | 9 + .../community/components/UserStatusChange.tsx | 116 +++ .../routes/community/components/UserTable.css | 7 +- .../routes/community/components/UserTable.tsx | 3 + .../community/components/UserTableFilter.css | 4 + .../community/components/UserTableFilter.tsx | 114 ++- .../containers/RoleChangeContainer.tsx | 16 +- .../community/containers/UserRowContainer.tsx | 2 + .../containers/UserStatusChangeContainer.tsx | 114 +++ .../containers/UserStatusContainer.tsx | 33 + .../containers/UserTableContainer.tsx | 20 +- .../containers/ConfigureContainer.tsx | 13 +- .../auth/containers/SSOKeyFieldContainer.tsx | 13 +- .../containers/AccountCompletionContainer.tsx | 19 +- .../containers/AddEmailAddressContainer.tsx | 10 +- .../containers/CreatePasswordContainer.tsx | 10 +- .../containers/CreateUsernameContainer.tsx | 10 +- .../signIn/containers/SignInContainer.tsx | 17 +- .../containers/SignInWithEmailContainer.tsx | 7 +- .../containers/ModerateCardContainer.tsx | 24 +- .../containers/StatusChangeContainer.tsx | 16 +- .../test/auth/accountCompletion.spec.tsx | 2 +- .../admin/test/auth/addEmailAddress.spec.tsx | 2 +- .../admin/test/auth/createPassword.spec.tsx | 2 +- .../admin/test/auth/redirectLoggedIn.spec.tsx | 2 +- .../admin/test/auth/restricted.spec.tsx | 2 +- .../client/admin/test/auth/signOut.spec.tsx | 4 +- .../__snapshots__/community.spec.tsx.snap | 660 +++++++++++++++--- .../admin/test/community/community.spec.tsx | 307 ++++++-- .../admin/test/configure/advanced.spec.tsx | 2 +- .../client/admin/test/configure/auth.spec.tsx | 2 +- .../admin/test/configure/general.spec.tsx | 2 +- .../admin/test/configure/moderation.spec.tsx | 2 +- .../test/configure/organization.spec.tsx | 2 +- .../admin/test/configure/permission.spec.tsx | 4 +- .../admin/test/configure/wordList.spec.tsx | 2 +- .../decisionHistory/decisionHistory.spec.tsx | 2 +- src/core/client/admin/test/fixtures.ts | 399 ++++++----- .../__snapshots__/moderate.spec.tsx.snap | 24 +- .../admin/test/moderate/moderate.spec.tsx | 2 +- .../admin/test/stories/stories.spec.tsx | 2 +- .../containers/RestrictedContainer.tsx | 20 +- .../framework/lib/i18n/withGetMessage.tsx | 14 +- .../framework/lib/intersection/withInView.tsx | 6 +- .../lib/relay/commitMutationPromise.ts | 6 +- .../lib/relay/createMutationContainer.tsx | 15 - src/core/client/framework/lib/relay/index.ts | 9 +- src/core/client/framework/lib/relay/lookup.ts | 49 ++ .../client/framework/lib/relay/mutation.tsx | 118 ++++ src/core/client/framework/schema/custom.ts | 2 + .../framework/testHelpers/createFixture.ts | 34 + .../framework/testHelpers/createFixtures.ts | 15 + .../testHelpers/createMutationResolverStub.ts | 27 + .../testHelpers/createQueryResolverStub.ts | 17 + .../client/framework/testHelpers/index.ts | 6 + .../client/ui/components/Card/CloseButton.tsx | 10 +- src/core/client/ui/components/Modal/Modal.mdx | 5 +- .../ui/components/SelectField/SelectField.css | 1 + .../ui/components/Typography/Typography.css | 6 +- .../ui/components/Typography/Typography.tsx | 2 + src/core/client/ui/components/index.ts | 1 + src/core/client/ui/hocs/withKeyboardFocus.tsx | 6 +- src/core/client/ui/hocs/withMouseHover.tsx | 6 +- src/core/client/ui/theme/variables.ts | 4 + src/core/common/errors.ts | 24 + src/core/common/types.ts | 16 +- .../server/app/handlers/api/auth/local.ts | 22 +- src/core/server/app/handlers/api/graphql.ts | 5 +- src/core/server/app/handlers/api/install.ts | 39 +- .../server/app/middleware/passport/index.ts | 5 +- .../passport/strategies/facebook.ts | 24 +- .../middleware/passport/strategies/google.ts | 24 +- .../app/middleware/passport/strategies/jwt.ts | 11 +- .../middleware/passport/strategies/oauth2.ts | 7 +- .../passport/strategies/oidc/index.ts | 38 +- .../passport/strategies/verifiers/oidc.ts | 10 +- .../passport/strategies/verifiers/sso.ts | 31 +- src/core/server/app/middleware/tenant.ts | 15 +- src/core/server/errors/index.ts | 58 +- src/core/server/errors/translations.ts | 8 +- src/core/server/graph/common/context.ts | 19 +- .../server/graph/common/directives/auth.ts | 87 ++- .../server/graph/tenant/loaders/Comments.ts | 3 +- .../server/graph/tenant/loaders/Stories.ts | 2 +- src/core/server/graph/tenant/loaders/Users.ts | 61 +- .../server/graph/tenant/mutators/Comments.ts | 71 +- .../server/graph/tenant/mutators/Stories.ts | 11 +- .../server/graph/tenant/mutators/Users.ts | 26 +- .../graph/tenant/resolvers/BanStatus.ts | 12 + .../tenant/resolvers/BanStatusHistory.ts | 16 + .../server/graph/tenant/resolvers/Mutation.ts | 16 + .../tenant/resolvers/SuspensionStatus.ts | 15 + .../resolvers/SuspensionStatusHistory.ts | 26 + .../server/graph/tenant/resolvers/User.ts | 6 + .../graph/tenant/resolvers/UserStatus.ts | 46 ++ .../server/graph/tenant/resolvers/index.ts | 10 + .../server/graph/tenant/schema/schema.graphql | 376 +++++++++- src/core/server/locales/en-US/errors.ftl | 8 +- src/core/server/models/action/comment.ts | 12 +- src/core/server/models/comment/index.ts | 16 +- src/core/server/models/settings.ts | 1 + src/core/server/models/story/counts/shared.ts | 57 +- src/core/server/models/story/index.ts | 58 +- src/core/server/models/tenant.ts | 9 +- src/core/server/models/user.ts | 552 ++++++++++++++- src/core/server/services/comments/actions.ts | 79 ++- src/core/server/services/comments/index.ts | 88 ++- .../services/comments/pipeline/index.ts | 1 + .../comments/pipeline/phases/staff.ts | 4 +- .../pipeline/phases/storyClosed.spec.ts | 5 + .../comments/pipeline/phases/storyClosed.ts | 3 +- src/core/server/services/stories/index.ts | 49 +- .../server/services/stories/scraper/index.ts | 16 +- src/core/server/services/tenant/index.ts | 5 +- src/core/server/services/users/index.ts | 186 ++++- src/core/server/types/express.ts | 2 + src/locales/en-US/admin.ftl | 30 +- 147 files changed, 4609 insertions(+), 1468 deletions(-) create mode 100644 src/core/client/admin/mutations/BanUserMutation.ts create mode 100644 src/core/client/admin/mutations/RemoveUserBanMutation.ts create mode 100644 src/core/client/admin/routes/community/components/BanModal.css create mode 100644 src/core/client/admin/routes/community/components/BanModal.tsx create mode 100644 src/core/client/admin/routes/community/components/ButtonPadding.css create mode 100644 src/core/client/admin/routes/community/components/ButtonPadding.tsx create mode 100644 src/core/client/admin/routes/community/components/UserStatus.css create mode 100644 src/core/client/admin/routes/community/components/UserStatus.tsx create mode 100644 src/core/client/admin/routes/community/components/UserStatusChange.css create mode 100644 src/core/client/admin/routes/community/components/UserStatusChange.tsx create mode 100644 src/core/client/admin/routes/community/containers/UserStatusChangeContainer.tsx create mode 100644 src/core/client/admin/routes/community/containers/UserStatusContainer.tsx create mode 100644 src/core/client/framework/lib/relay/lookup.ts create mode 100644 src/core/client/framework/lib/relay/mutation.tsx create mode 100644 src/core/client/framework/testHelpers/createFixture.ts create mode 100644 src/core/client/framework/testHelpers/createFixtures.ts create mode 100644 src/core/client/framework/testHelpers/createMutationResolverStub.ts create mode 100644 src/core/client/framework/testHelpers/createQueryResolverStub.ts create mode 100644 src/core/server/graph/tenant/resolvers/BanStatus.ts create mode 100644 src/core/server/graph/tenant/resolvers/BanStatusHistory.ts create mode 100644 src/core/server/graph/tenant/resolvers/SuspensionStatus.ts create mode 100644 src/core/server/graph/tenant/resolvers/SuspensionStatusHistory.ts create mode 100644 src/core/server/graph/tenant/resolvers/UserStatus.ts diff --git a/package-lock.json b/package-lock.json index e7917f2dc..840b00c92 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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": { diff --git a/package.json b/package.json index 71e88230a..ccc5a9175 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/core/client/admin/containers/AuthCheckContainer.tsx b/src/core/client/admin/containers/AuthCheckContainer.tsx index 87f6b3ce5..ac89148f1 100644 --- a/src/core/client/admin/containers/AuthCheckContainer.tsx +++ b/src/core/client/admin/containers/AuthCheckContainer.tsx @@ -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; data: | AuthCheckContainerQueryResponse & { route: { @@ -124,6 +121,6 @@ const enhanced = withRouteConfig({ } } `, -})(withRouter(withSetRedirectPathMutation(AuthCheckContainer))); +})(withRouter(withMutation(SetRedirectPathMutation)(AuthCheckContainer))); export default enhanced; diff --git a/src/core/client/admin/mutations/AcceptCommentMutation.ts b/src/core/client/admin/mutations/AcceptCommentMutation.ts index 518e55061..39544c2a4 100644 --- a/src/core/client/admin/mutations/AcceptCommentMutation.ts +++ b/src/core/client/admin/mutations/AcceptCommentMutation.ts @@ -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; - -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(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) => + commitMutationPromiseNormalized(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; +export default AcceptCommentMutation; diff --git a/src/core/client/admin/mutations/BanUserMutation.ts b/src/core/client/admin/mutations/BanUserMutation.ts new file mode 100644 index 000000000..f38c3fc9b --- /dev/null +++ b/src/core/client/admin/mutations/BanUserMutation.ts @@ -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) => { + return commitMutationPromiseNormalized(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( + environment, + input.userID + )!.status!.current!.concat([GQLUSER_STATUS.BANNED]), + ban: { + active: true, + }, + }, + }, + clientMutationId: (clientMutationId++).toString(), + }, + }, + }); + } +); + +export default BanUserMutation; diff --git a/src/core/client/admin/mutations/ClearAuthErrorMutation.ts b/src/core/client/admin/mutations/ClearAuthErrorMutation.ts index d907e6d19..61c1fda2c 100644 --- a/src/core/client/admin/mutations/ClearAuthErrorMutation.ts +++ b/src/core/client/admin/mutations/ClearAuthErrorMutation.ts @@ -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; - -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; diff --git a/src/core/client/admin/mutations/CloseStoryMutation.ts b/src/core/client/admin/mutations/CloseStoryMutation.ts index be7c689d9..4e2f3c9a7 100644 --- a/src/core/client/admin/mutations/CloseStoryMutation.ts +++ b/src/core/client/admin/mutations/CloseStoryMutation.ts @@ -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; - -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(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) => + commitMutationPromiseNormalized(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; +export default CloseStoryMutation; diff --git a/src/core/client/admin/mutations/CompleteAccountMutation.ts b/src/core/client/admin/mutations/CompleteAccountMutation.ts index f878137b4..f8d93554d 100644 --- a/src/core/client/admin/mutations/CompleteAccountMutation.ts +++ b/src/core/client/admin/mutations/CompleteAccountMutation.ts @@ -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; - -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; diff --git a/src/core/client/admin/mutations/OpenStoryMutation.ts b/src/core/client/admin/mutations/OpenStoryMutation.ts index 7698d0f29..c4343c957 100644 --- a/src/core/client/admin/mutations/OpenStoryMutation.ts +++ b/src/core/client/admin/mutations/OpenStoryMutation.ts @@ -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; - -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(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) => + commitMutationPromiseNormalized(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; +export default OpenStoryMutation; diff --git a/src/core/client/admin/mutations/RegenerateSSOKeyMutation.ts b/src/core/client/admin/mutations/RegenerateSSOKeyMutation.ts index 8440173ea..e0a3f2885 100644 --- a/src/core/client/admin/mutations/RegenerateSSOKeyMutation.ts +++ b/src/core/client/admin/mutations/RegenerateSSOKeyMutation.ts @@ -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(environment, { - mutation, - variables: { - input: { - clientMutationId: (clientMutationId++).toString(), - }, - }, - }); -} - -export const withRegenerateSSOKeyMutation = createMutationContainer( +const RegenerateSSOKeyMutation = createMutation( "regenerateSSOKey", - commit + (environment: Environment) => + commitMutationPromiseNormalized(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; diff --git a/src/core/client/admin/mutations/RejectCommentMutation.ts b/src/core/client/admin/mutations/RejectCommentMutation.ts index 8dc0a3f31..42e9b0673 100644 --- a/src/core/client/admin/mutations/RejectCommentMutation.ts +++ b/src/core/client/admin/mutations/RejectCommentMutation.ts @@ -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; - -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(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) => + commitMutationPromiseNormalized(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; +export default RejectCommentMutation; diff --git a/src/core/client/admin/mutations/RemoveUserBanMutation.ts b/src/core/client/admin/mutations/RemoveUserBanMutation.ts new file mode 100644 index 000000000..c4f79e4e7 --- /dev/null +++ b/src/core/client/admin/mutations/RemoveUserBanMutation.ts @@ -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) => + commitMutationPromiseNormalized(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( + environment, + input.userID + )!.status!.current!.filter(s => s !== GQLUSER_STATUS.BANNED), + ban: { + active: false, + }, + }, + }, + clientMutationId: (clientMutationId++).toString(), + }, + }, + }) +); + +export default RemoveUserBanMutation; diff --git a/src/core/client/admin/mutations/SetAuthViewMutation.spec.ts b/src/core/client/admin/mutations/SetAuthViewMutation.spec.ts index 177659366..b8ce4beaa 100644 --- a/src/core/client/admin/mutations/SetAuthViewMutation.spec.ts +++ b/src/core/client/admin/mutations/SetAuthViewMutation.spec.ts @@ -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); }); diff --git a/src/core/client/admin/mutations/SetAuthViewMutation.ts b/src/core/client/admin/mutations/SetAuthViewMutation.ts index e05ed7b0a..755e5bbd7 100644 --- a/src/core/client/admin/mutations/SetAuthViewMutation.ts +++ b/src/core/client/admin/mutations/SetAuthViewMutation.ts @@ -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; - -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; diff --git a/src/core/client/admin/mutations/SetEmailMutation.ts b/src/core/client/admin/mutations/SetEmailMutation.ts index 804540283..effb8bb3e 100644 --- a/src/core/client/admin/mutations/SetEmailMutation.ts +++ b/src/core/client/admin/mutations/SetEmailMutation.ts @@ -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; - -const mutation = graphql` - mutation SetEmailMutation($input: SetEmailInput!) { - setEmail(input: $input) { - user { - email - } - clientMutationId - } - } -`; - let clientMutationId = 0; -function commit(environment: Environment, input: SetEmailInput) { - return commitMutationPromiseNormalized(environment, { - mutation, - variables: { - input: { - ...input, - clientMutationId: (clientMutationId++).toString(), +const SetEmailMutation = createMutation( + "setEmail", + (environment: Environment, input: MutationInput) => + commitMutationPromiseNormalized(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; +export default SetEmailMutation; diff --git a/src/core/client/admin/mutations/SetPasswordMutation.ts b/src/core/client/admin/mutations/SetPasswordMutation.ts index b99d2970c..43a5b37a3 100644 --- a/src/core/client/admin/mutations/SetPasswordMutation.ts +++ b/src/core/client/admin/mutations/SetPasswordMutation.ts @@ -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; - -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(environment, { - mutation, - variables: { - input: { - ...input, - clientMutationId: (clientMutationId++).toString(), - }, - }, - }); -} - -export const withSetPasswordMutation = createMutationContainer( +const SetPasswordMutation = createMutation( "setPassword", - commit + (environment: Environment, input: MutationInput) => + commitMutationPromiseNormalized(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; +export default SetPasswordMutation; diff --git a/src/core/client/admin/mutations/SetRedirectPathMutation.spec.ts b/src/core/client/admin/mutations/SetRedirectPathMutation.spec.ts index f801e838b..6829a6ece 100644 --- a/src/core/client/admin/mutations/SetRedirectPathMutation.spec.ts +++ b/src/core/client/admin/mutations/SetRedirectPathMutation.spec.ts @@ -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); diff --git a/src/core/client/admin/mutations/SetRedirectPathMutation.ts b/src/core/client/admin/mutations/SetRedirectPathMutation.ts index 635adafd4..9c46b061e 100644 --- a/src/core/client/admin/mutations/SetRedirectPathMutation.ts +++ b/src/core/client/admin/mutations/SetRedirectPathMutation.ts @@ -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; - -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; diff --git a/src/core/client/admin/mutations/SetUsernameMutation.ts b/src/core/client/admin/mutations/SetUsernameMutation.ts index 5e8518519..5f0bea318 100644 --- a/src/core/client/admin/mutations/SetUsernameMutation.ts +++ b/src/core/client/admin/mutations/SetUsernameMutation.ts @@ -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; - -const mutation = graphql` - mutation SetUsernameMutation($input: SetUsernameInput!) { - setUsername(input: $input) { - user { - username - } - clientMutationId - } - } -`; - let clientMutationId = 0; -function commit(environment: Environment, input: SetUsernameInput) { - return commitMutationPromiseNormalized(environment, { - mutation, - variables: { - input: { - ...input, - clientMutationId: (clientMutationId++).toString(), - }, - }, - }); -} - -export const withSetUsernameMutation = createMutationContainer( +const SetUsernameMutation = createMutation( "setUsername", - commit + (environment: Environment, input: MutationInput) => + commitMutationPromiseNormalized(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; +export default SetUsernameMutation; diff --git a/src/core/client/admin/mutations/SignInMutation.ts b/src/core/client/admin/mutations/SignInMutation.ts index c5e5f80ae..9c0a1c0f1 100644 --- a/src/core/client/admin/mutations/SignInMutation.ts +++ b/src/core/client/admin/mutations/SignInMutation.ts @@ -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; +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; diff --git a/src/core/client/admin/mutations/UpdateSettingsMutation.ts b/src/core/client/admin/mutations/UpdateSettingsMutation.ts index 3d4a28833..f4543454e 100644 --- a/src/core/client/admin/mutations/UpdateSettingsMutation.ts +++ b/src/core/client/admin/mutations/UpdateSettingsMutation.ts @@ -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; - -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(environment, { - mutation, - variables: { - input: { - ...input, - clientMutationId: (clientMutationId++).toString(), - }, - }, - }); -} - -export const withUpdateSettingsMutation = createMutationContainer( +const UpdateSettingsMutation = createMutation( "updateSettings", - commit + (environment: Environment, input: MutationInput) => + commitMutationPromiseNormalized(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; +export default UpdateSettingsMutation; diff --git a/src/core/client/admin/mutations/UpdateUserRoleMutation.ts b/src/core/client/admin/mutations/UpdateUserRoleMutation.ts index 00626a69a..2fe21bf9b 100644 --- a/src/core/client/admin/mutations/UpdateUserRoleMutation.ts +++ b/src/core/client/admin/mutations/UpdateUserRoleMutation.ts @@ -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; - -const mutation = graphql` - mutation UpdateUserRoleMutation($input: UpdateUserRoleInput!) { - updateUserRole(input: $input) { - user { - role - } - clientMutationId - } - } -`; - let clientMutationId = 0; -function commit(environment: Environment, input: UpdateUserRoleInput) { - return commitMutationPromiseNormalized(environment, { - mutation, - optimisticResponse: { - updateUserRole: { - user: { - id: input.userID, - role: input.role, - }, - clientMutationId: clientMutationId.toString(), - }, - } as any, // TODO: (cvle) generated types should contain one for the optimistic response. - variables: { - input: { - ...input, - clientMutationId: (clientMutationId++).toString(), - }, - }, - }); -} - -export const withUpdateUserRoleMutation = createMutationContainer( +const UpdateUserRoleMutation = createMutation( "updateUserRole", - commit + (environment: Environment, input: MutationInput) => + commitMutationPromiseNormalized(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; +export default UpdateUserRoleMutation; diff --git a/src/core/client/admin/mutations/index.ts b/src/core/client/admin/mutations/index.ts index 72cbc3467..2730bd096 100644 --- a/src/core/client/admin/mutations/index.ts +++ b/src/core/client/admin/mutations/index.ts @@ -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"; diff --git a/src/core/client/admin/routes/community/components/BanModal.css b/src/core/client/admin/routes/community/components/BanModal.css new file mode 100644 index 000000000..9b2098d45 --- /dev/null +++ b/src/core/client/admin/routes/community/components/BanModal.css @@ -0,0 +1,3 @@ +.card { + max-width: 500px; +} diff --git a/src/core/client/admin/routes/community/components/BanModal.tsx b/src/core/client/admin/routes/community/components/BanModal.tsx new file mode 100644 index 000000000..f0f906947 --- /dev/null +++ b/src/core/client/admin/routes/community/components/BanModal.tsx @@ -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 = ({ + open, + onClose, + onConfirm, + username, +}) => ( + + {({ firstFocusableRef, lastFocusableRef }) => ( + + + + + } + $username={username || } + > + + Are you sure you want to ban{" "} + {username || }? + + + + + Once banned, this user will no longer be able to comment, use + reactions, or report comments. + + + + + + + + + + + + + + )} + +); + +export default BanModal; diff --git a/src/core/client/admin/routes/community/components/ButtonPadding.css b/src/core/client/admin/routes/community/components/ButtonPadding.css new file mode 100644 index 000000000..a5fbcdf5f --- /dev/null +++ b/src/core/client/admin/routes/community/components/ButtonPadding.css @@ -0,0 +1,3 @@ +.root { + padding-top: 3px; +} diff --git a/src/core/client/admin/routes/community/components/ButtonPadding.tsx b/src/core/client/admin/routes/community/components/ButtonPadding.tsx new file mode 100644 index 000000000..72af38768 --- /dev/null +++ b/src/core/client/admin/routes/community/components/ButtonPadding.tsx @@ -0,0 +1,9 @@ +import React, { FunctionComponent } from "react"; + +import styles from "./ButtonPadding.css"; + +const ButtonPadding: FunctionComponent = props => ( +
{props.children}
+); + +export default ButtonPadding; diff --git a/src/core/client/admin/routes/community/components/UserRow.tsx b/src/core/client/admin/routes/community/components/UserRow.tsx index baabc75fe..2bfb69c22 100644 --- a/src/core/client/admin/routes/community/components/UserRow.tsx +++ b/src/core/client/admin/routes/community/components/UserRow.tsx @@ -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["role"]; + user: PropTypesOf["user"]; } const UserRow: StatelessComponent = props => ( @@ -35,9 +38,14 @@ const UserRow: StatelessComponent = props => ( {props.canChangeRole ? ( ) : ( - {props.role} + + {props.role} + )} + + + ); diff --git a/src/core/client/admin/routes/community/components/UserStatus.css b/src/core/client/admin/routes/community/components/UserStatus.css new file mode 100644 index 000000000..77d105927 --- /dev/null +++ b/src/core/client/admin/routes/community/components/UserStatus.css @@ -0,0 +1,4 @@ +.dot { + font-size: calc(22rem / var(--rem-base)); + padding-bottom: 3px; +} diff --git a/src/core/client/admin/routes/community/components/UserStatus.tsx b/src/core/client/admin/routes/community/components/UserStatus.tsx new file mode 100644 index 000000000..aebe28d42 --- /dev/null +++ b/src/core/client/admin/routes/community/components/UserStatus.tsx @@ -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["color"], + content: React.ReactNode +) => ( + + +
+ • +
+ {content} +
+
+); + +const UserStatus: StatelessComponent = props => { + if (props.banned) { + return render( + "error", + // tslint:disable-next-line:jsx-wrap-multiline + +
Banned
+
+ ); + } + if (props.suspended) { + return render( + "warning", + // tslint:disable-next-line:jsx-wrap-multiline + +
Suspended
+
+ ); + } + return render( + "success", + // tslint:disable-next-line:jsx-wrap-multiline + +
Active
+
+ ); +}; + +export default UserStatus; diff --git a/src/core/client/admin/routes/community/components/UserStatusChange.css b/src/core/client/admin/routes/community/components/UserStatusChange.css new file mode 100644 index 000000000..6e953b31e --- /dev/null +++ b/src/core/client/admin/routes/community/components/UserStatusChange.css @@ -0,0 +1,9 @@ +.button { + width: 100%; + justify-content: space-between; + padding: 0; +} + +.dropdownButton { + min-width: 80px; +} diff --git a/src/core/client/admin/routes/community/components/UserStatusChange.tsx b/src/core/client/admin/routes/community/components/UserStatusChange.tsx new file mode 100644 index 000000000..d3411cbcb --- /dev/null +++ b/src/core/client/admin/routes/community/components/UserStatusChange.tsx @@ -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.banned && ( + + { + props.onBan(); + toggleVisibility(); + }} + > + Ban User + + + )} + {props.banned && ( + + { + props.onRemoveBan(); + toggleVisibility(); + }} + > + Remove Ban + + + )} + {!props.suspended && ( + + { + props.onSuspend(); + toggleVisibility(); + }} + > + Suspend User + + + )} + {props.suspended && ( + + { + props.onRemoveSuspension(); + toggleVisibility(); + }} + > + Remove Suspension + + + )} + + + )} + > + {({ toggleVisibility, ref, visible }) => ( + + + + )} + + +); + +export default UserStatusChange; diff --git a/src/core/client/admin/routes/community/components/UserTable.css b/src/core/client/admin/routes/community/components/UserTable.css index d86456cb0..928c3ac86 100644 --- a/src/core/client/admin/routes/community/components/UserTable.css +++ b/src/core/client/admin/routes/community/components/UserTable.css @@ -2,11 +2,14 @@ width: 25%; } .emailColumn { - width: 40%; + width: 30%; } .memberSinceColumn { - width: 20%; + width: 15%; } .roleColumn { width: 15%; } +.statusColumn { + width: 15%; +} diff --git a/src/core/client/admin/routes/community/components/UserTable.tsx b/src/core/client/admin/routes/community/components/UserTable.tsx index c39c0ebb4..c036340e9 100644 --- a/src/core/client/admin/routes/community/components/UserTable.tsx +++ b/src/core/client/admin/routes/community/components/UserTable.tsx @@ -50,6 +50,9 @@ const UserTable: StatelessComponent = props => ( Role + + Status + diff --git a/src/core/client/admin/routes/community/components/UserTableFilter.css b/src/core/client/admin/routes/community/components/UserTableFilter.css index 2bb04a279..7d1b3255b 100644 --- a/src/core/client/admin/routes/community/components/UserTableFilter.css +++ b/src/core/client/admin/routes/community/components/UserTableFilter.css @@ -11,3 +11,7 @@ .adornment { padding: 0 var(--spacing-unit); } + +.selectField { + min-width: calc(13 * var(--spacing-unit)); +} diff --git a/src/core/client/admin/routes/community/components/UserTableFilter.tsx b/src/core/client/admin/routes/community/components/UserTableFilter.tsx index 7902c69c2..d7adaf4d8 100644 --- a/src/core/client/admin/routes/community/components/UserTableFilter.tsx +++ b/src/core/client/admin/routes/community/components/UserTableFilter.tsx @@ -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 => ( Show Me - - props.onSetRoleFilter((e.target.value as any) || null)} + + - - - - + props.onSetRoleFilter((e.target.value as any) || null) + } > - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + props.onSetStatusFilter((e.target.value as any) || null) + } > - - - - - - - - - - - - - - + + + + + + + + + + + + + + + ); diff --git a/src/core/client/admin/routes/community/containers/RoleChangeContainer.tsx b/src/core/client/admin/routes/community/containers/RoleChangeContainer.tsx index 09be0e77f..98b80ed17 100644 --- a/src/core/client/admin/routes/community/containers/RoleChangeContainer.tsx +++ b/src/core/client/admin/routes/community/containers/RoleChangeContainer.tsx @@ -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 => { + 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 ; }; -const enhanced = withUpdateUserRoleMutation(RoleChangeContainer); - -export default enhanced; +export default RoleChangeContainer; diff --git a/src/core/client/admin/routes/community/containers/UserRowContainer.tsx b/src/core/client/admin/routes/community/containers/UserRowContainer.tsx index 63525c8db..8c7dca023 100644 --- a/src/core/client/admin/routes/community/containers/UserRowContainer.tsx +++ b/src/core/client/admin/routes/community/containers/UserRowContainer.tsx @@ -18,6 +18,7 @@ const UserRowContainer: StatelessComponent = props => { const { locales } = useTalkContext(); return ( ({ `, user: graphql` fragment UserRowContainer_user on User { + ...UserStatusChangeContainer_user id username email diff --git a/src/core/client/admin/routes/community/containers/UserStatusChangeContainer.tsx b/src/core/client/admin/routes/community/containers/UserStatusChangeContainer.tsx new file mode 100644 index 000000000..fbfba459e --- /dev/null +++ b/src/core/client/admin/routes/community/containers/UserStatusChangeContainer.tsx @@ -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 => { + const banUser = useMutation(BanUserMutation); + const removeUserBan = useMutation(RemoveUserBanMutation); + const [showBanned, setShowBanned] = useState(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 ( + + + + ); + } + + return ( + <> + + + + setShowBanned(false)} + onConfirm={() => { + banUser({ userID: props.user.id }); + setShowBanned(false); + }} + /> + + ); +}; + +const enhanced = withFragmentContainer({ + user: graphql` + fragment UserStatusChangeContainer_user on User { + ...UserStatusContainer_user + id + role + username + status { + ban { + active + } + suspension { + active + } + } + } + `, +})(UserStatusChangeContainer); + +export default enhanced; diff --git a/src/core/client/admin/routes/community/containers/UserStatusContainer.tsx b/src/core/client/admin/routes/community/containers/UserStatusContainer.tsx new file mode 100644 index 000000000..aaa1946c1 --- /dev/null +++ b/src/core/client/admin/routes/community/containers/UserStatusContainer.tsx @@ -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 => { + return ( + + ); +}; + +const enhanced = withFragmentContainer({ + user: graphql` + fragment UserStatusContainer_user on User { + status { + current + } + } + `, +})(UserStatusContainer); + +export default enhanced; diff --git a/src/core/client/admin/routes/community/containers/UserTableContainer.tsx b/src/core/client/admin/routes/community/containers/UserTableContainer.tsx index 79732104f..de5e4eefc 100644 --- a/src/core/client/admin/routes/community/containers/UserTableContainer.tsx +++ b/src/core/client/admin/routes/community/containers/UserTableContainer.tsx @@ -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 => { const [loadMore, isLoadingMore] = useLoadMore(props.relay, 10); const [searchFilter, setSearchFilter] = useState(""); const [roleFilter, setRoleFilter] = useState(null); - const [, isRefetching] = useRefetch< - Pick< - UserTableContainerPaginationQueryVariables, - "searchFilter" | "roleFilter" - > - >(props.relay, { + const [statusFilter, setStatusFilter] = useState( + null + ); + const [, isRefetching] = useRefetch(props.relay, { searchFilter: searchFilter || null, roleFilter, + statusFilter, }); return ( @@ -43,7 +42,9 @@ const UserTableContainer: StatelessComponent = props => { @@ -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 ) } diff --git a/src/core/client/admin/routes/configure/containers/ConfigureContainer.tsx b/src/core/client/admin/routes/configure/containers/ConfigureContainer.tsx index cab17df10..25cff6d06 100644 --- a/src/core/client/admin/routes/configure/containers/ConfigureContainer.tsx +++ b/src/core/client/admin/routes/configure/containers/ConfigureContainer.tsx @@ -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; children: React.ReactElement; } @@ -26,7 +23,7 @@ class ConfigureContainer extends React.Component { }; private handleExecute = async ( - data: UpdateSettingsInput["settings"], + data: Parameters[0]["settings"], form: FormApi ) => { await this.props.updateSettings({ settings: data }); @@ -55,5 +52,5 @@ class ConfigureContainer extends React.Component { } } -const enhanced = withUpdateSettingsMutation(ConfigureContainer); +const enhanced = withMutation(UpdateSettingsMutation)(ConfigureContainer); export default enhanced; diff --git a/src/core/client/admin/routes/configure/sections/auth/containers/SSOKeyFieldContainer.tsx b/src/core/client/admin/routes/configure/sections/auth/containers/SSOKeyFieldContainer.tsx index ebc68ba03..b4154008c 100644 --- a/src/core/client/admin/routes/configure/sections/auth/containers/SSOKeyFieldContainer.tsx +++ b/src/core/client/admin/routes/configure/sections/auth/containers/SSOKeyFieldContainer.tsx @@ -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; } interface State { @@ -44,7 +45,7 @@ class SSOKeyFieldContainer extends React.Component { } } -const enhanced = withRegenerateSSOKeyMutation( +const enhanced = withMutation(RegenerateSSOKeyMutation)( withFragmentContainer({ sso: graphql` fragment SSOKeyFieldContainer_sso on SSOAuthIntegration { diff --git a/src/core/client/admin/routes/login/containers/AccountCompletionContainer.tsx b/src/core/client/admin/routes/login/containers/AccountCompletionContainer.tsx index 33a1df920..0ad57fa7c 100644 --- a/src/core/client/admin/routes/login/containers/AccountCompletionContainer.tsx +++ b/src/core/client/admin/routes/login/containers/AccountCompletionContainer.tsx @@ -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; + setAuthView: MutationProp; local: Local; auth: AuthData; viewer: UserData | null; - setRedirectPath: SetRedirectPathMutation; + setRedirectPath: MutationProp; } & 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) + ) ) ) ) diff --git a/src/core/client/admin/routes/login/views/addEmailAddress/containers/AddEmailAddressContainer.tsx b/src/core/client/admin/routes/login/views/addEmailAddress/containers/AddEmailAddressContainer.tsx index c3bf1987b..f034fa5cb 100644 --- a/src/core/client/admin/routes/login/views/addEmailAddress/containers/AddEmailAddressContainer.tsx +++ b/src/core/client/admin/routes/login/views/addEmailAddress/containers/AddEmailAddressContainer.tsx @@ -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; } class AddEmailAddressContainer extends Component { @@ -31,5 +29,5 @@ class AddEmailAddressContainer extends Component { } } -const enhanced = withSetEmailMutation(AddEmailAddressContainer); +const enhanced = withMutation(SetEmailMutation)(AddEmailAddressContainer); export default enhanced; diff --git a/src/core/client/admin/routes/login/views/createPassword/containers/CreatePasswordContainer.tsx b/src/core/client/admin/routes/login/views/createPassword/containers/CreatePasswordContainer.tsx index 80c867ffa..4743d1949 100644 --- a/src/core/client/admin/routes/login/views/createPassword/containers/CreatePasswordContainer.tsx +++ b/src/core/client/admin/routes/login/views/createPassword/containers/CreatePasswordContainer.tsx @@ -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; } class CreatePasswordContainer extends Component { @@ -32,5 +30,5 @@ class CreatePasswordContainer extends Component { } } -const enhanced = withSetPasswordMutation(CreatePasswordContainer); +const enhanced = withMutation(SetPasswordMutation)(CreatePasswordContainer); export default enhanced; diff --git a/src/core/client/admin/routes/login/views/createUsername/containers/CreateUsernameContainer.tsx b/src/core/client/admin/routes/login/views/createUsername/containers/CreateUsernameContainer.tsx index 126e2651a..dda308b0e 100644 --- a/src/core/client/admin/routes/login/views/createUsername/containers/CreateUsernameContainer.tsx +++ b/src/core/client/admin/routes/login/views/createUsername/containers/CreateUsernameContainer.tsx @@ -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; } class CreateUsernameContainer extends Component { @@ -32,5 +30,5 @@ class CreateUsernameContainer extends Component { } } -const enhanced = withSetUsernameMutation(CreateUsernameContainer); +const enhanced = withMutation(SetUsernameMutation)(CreateUsernameContainer); export default enhanced; diff --git a/src/core/client/admin/routes/login/views/signIn/containers/SignInContainer.tsx b/src/core/client/admin/routes/login/views/signIn/containers/SignInContainer.tsx index cfaea8ff6..b7bbb35b7 100644 --- a/src/core/client/admin/routes/login/views/signIn/containers/SignInContainer.tsx +++ b/src/core/client/admin/routes/login/views/signIn/containers/SignInContainer.tsx @@ -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; + clearAuthError: MutationProp; } class SignInContainer extends Component { @@ -91,8 +88,8 @@ const enhanced = withFragmentContainer({ } `, })( - withClearAuthErrorMutation( - withSignInMutation( + withMutation(ClearAuthErrorMutation)( + withMutation(SignInMutation)( withLocalStateContainer( graphql` fragment SignInContainerLocal on Local { diff --git a/src/core/client/admin/routes/login/views/signIn/containers/SignInWithEmailContainer.tsx b/src/core/client/admin/routes/login/views/signIn/containers/SignInWithEmailContainer.tsx index e63e03ba1..f197863b0 100644 --- a/src/core/client/admin/routes/login/views/signIn/containers/SignInWithEmailContainer.tsx +++ b/src/core/client/admin/routes/login/views/signIn/containers/SignInWithEmailContainer.tsx @@ -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; } class SignInContainer extends Component { @@ -25,5 +26,5 @@ class SignInContainer extends Component { } } -const enhanced = withSignInMutation(SignInContainer); +const enhanced = withMutation(SignInMutation)(SignInContainer); export default enhanced; diff --git a/src/core/client/admin/routes/moderate/containers/ModerateCardContainer.tsx b/src/core/client/admin/routes/moderate/containers/ModerateCardContainer.tsx index bec8fd654..5e53e4612 100644 --- a/src/core/client/admin/routes/moderate/containers/ModerateCardContainer.tsx +++ b/src/core/client/admin/routes/moderate/containers/ModerateCardContainer.tsx @@ -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; + rejectComment: MutationProp; danglingLogic: (status: COMMENT_STATUS) => boolean; } @@ -106,6 +104,10 @@ const enhanced = withFragmentContainer({ } } `, -})(withAcceptCommentMutation(withRejectCommentMutation(ModerateCardContainer))); +})( + withMutation(AcceptCommentMutation)( + withMutation(RejectCommentMutation)(ModerateCardContainer) + ) +); export default enhanced; diff --git a/src/core/client/admin/routes/stories/containers/StatusChangeContainer.tsx b/src/core/client/admin/routes/stories/containers/StatusChangeContainer.tsx index 43075fc28..21b185a2d 100644 --- a/src/core/client/admin/routes/stories/containers/StatusChangeContainer.tsx +++ b/src/core/client/admin/routes/stories/containers/StatusChangeContainer.tsx @@ -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; + closeStory: MutationProp; } const StatusChangeContainer: StatelessComponent = props => { @@ -36,8 +32,8 @@ const StatusChangeContainer: StatelessComponent = props => { ); }; -const enhanced = withOpenStoryMutation( - withCloseStoryMutation(StatusChangeContainer) +const enhanced = withMutation(OpenStoryMutation)( + withMutation(CloseStoryMutation)(StatusChangeContainer) ); export default enhanced; diff --git a/src/core/client/admin/test/auth/accountCompletion.spec.tsx b/src/core/client/admin/test/auth/accountCompletion.spec.tsx index 448215c11..a51cbbf2c 100644 --- a/src/core/client/admin/test/auth/accountCompletion.spec.tsx +++ b/src/core/client/admin/test/auth/accountCompletion.spec.tsx @@ -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") ) ), diff --git a/src/core/client/admin/test/auth/addEmailAddress.spec.tsx b/src/core/client/admin/test/auth/addEmailAddress.spec.tsx index 53eb1786b..d3cfcd72f 100644 --- a/src/core/client/admin/test/auth/addEmailAddress.spec.tsx +++ b/src/core/client/admin/test/auth/addEmailAddress.spec.tsx @@ -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: "" }), }, }; diff --git a/src/core/client/admin/test/auth/createPassword.spec.tsx b/src/core/client/admin/test/auth/createPassword.spec.tsx index b6c172f45..12d711c63 100644 --- a/src/core/client/admin/test/auth/createPassword.spec.tsx +++ b/src/core/client/admin/test/auth/createPassword.spec.tsx @@ -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: [] }), }, }; diff --git a/src/core/client/admin/test/auth/redirectLoggedIn.spec.tsx b/src/core/client/admin/test/auth/redirectLoggedIn.spec.tsx index f5d19a003..62cf420fb 100644 --- a/src/core/client/admin/test/auth/redirectLoggedIn.spec.tsx +++ b/src/core/client/admin/test/auth/redirectLoggedIn.spec.tsx @@ -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]), }, }; diff --git a/src/core/client/admin/test/auth/restricted.spec.tsx b/src/core/client/admin/test/auth/restricted.spec.tsx index 9bed1a3ff..92bbc110e 100644 --- a/src/core/client/admin/test/auth/restricted.spec.tsx +++ b/src/core/client/admin/test/auth/restricted.spec.tsx @@ -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({ diff --git a/src/core/client/admin/test/auth/signOut.spec.tsx b/src/core/client/admin/test/auth/signOut.spec.tsx index 92169f992..312095d35 100644 --- a/src/core/client/admin/test/auth/signOut.spec.tsx +++ b/src/core/client/admin/test/auth/signOut.spec.tsx @@ -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", }) ); diff --git a/src/core/client/admin/test/community/__snapshots__/community.spec.tsx.snap b/src/core/client/admin/test/community/__snapshots__/community.spec.tsx.snap index 94ca069be..2f6816e57 100644 --- a/src/core/client/admin/test/community/__snapshots__/community.spec.tsx.snap +++ b/src/core/client/admin/test/community/__snapshots__/community.spec.tsx.snap @@ -1,5 +1,104 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP +exports[`ban user 1`] = ` +
+
+
+
+
+ +
+
+

+ Are you sure you want to ban + + Isabelle + + ? +

+

+ Once banned, this user will no longer be able to comment, use +reactions, or report comments. +

+
+
+ + +
+
+
+
+
+`; + exports[`renders community 1`] = `
Show Me - - - + + + + + - + + + + + + +
Role + + Status + - - Admin - + + Admin + +
+ + +
+
+
+
+ • +
+
+ Active +
+
+
+
+ +
+
+
+
+ • +
+
+ Active +
+
+
+
+ + + + + Huy + + + + huy@test.com + + + + 07/06/2018 + + +
+ +
+
+ A dropdown to change the user role +
+
+
+ + +
+
+
+
+ • +
+
+ Active +
+
+
+
+ + + + + Isabelle + + + + isabelle@test.com + + + + 07/06/2018 + + +
+ +
+
+ A dropdown to change the user role +
+
+
+ + +
+ +
+
+ A dropdown to change the user status +
+
+
+ @@ -340,63 +763,111 @@ exports[`renders empty community 1`] = ` > Show Me - - - + + + + + - + + + + + + +
Role + + Status + { 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( + () => settings + ), + users: createQueryResolverStub(variables => { + expectAndFail(variables.role).toBeFalsy(); return communityUsers; }), - viewer: sinon.stub().returns(users[0]), + viewer: createQueryResolverStub( + () => 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( + () => 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( + (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(() => 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( + (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( + (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( + (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( + 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(() => ({ + 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); +}); diff --git a/src/core/client/admin/test/configure/advanced.spec.tsx b/src/core/client/admin/test/configure/advanced.spec.tsx index 06ec1d02e..0198dd590 100644 --- a/src/core/client/admin/test/configure/advanced.spec.tsx +++ b/src/core/client/admin/test/configure/advanced.spec.tsx @@ -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({ diff --git a/src/core/client/admin/test/configure/auth.spec.tsx b/src/core/client/admin/test/configure/auth.spec.tsx index 11d1c45e2..12ebca305 100644 --- a/src/core/client/admin/test/configure/auth.spec.tsx +++ b/src/core/client/admin/test/configure/auth.spec.tsx @@ -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({ diff --git a/src/core/client/admin/test/configure/general.spec.tsx b/src/core/client/admin/test/configure/general.spec.tsx index 371f7f138..827f66822 100644 --- a/src/core/client/admin/test/configure/general.spec.tsx +++ b/src/core/client/admin/test/configure/general.spec.tsx @@ -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({ diff --git a/src/core/client/admin/test/configure/moderation.spec.tsx b/src/core/client/admin/test/configure/moderation.spec.tsx index 9e360631a..aaca0681b 100644 --- a/src/core/client/admin/test/configure/moderation.spec.tsx +++ b/src/core/client/admin/test/configure/moderation.spec.tsx @@ -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({ diff --git a/src/core/client/admin/test/configure/organization.spec.tsx b/src/core/client/admin/test/configure/organization.spec.tsx index bfd2076c3..065530be3 100644 --- a/src/core/client/admin/test/configure/organization.spec.tsx +++ b/src/core/client/admin/test/configure/organization.spec.tsx @@ -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({ diff --git a/src/core/client/admin/test/configure/permission.spec.tsx b/src/core/client/admin/test/configure/permission.spec.tsx index e73fc5ca2..3cc0350c8 100644 --- a/src/core/client/admin/test/configure/permission.spec.tsx +++ b/src/core/client/admin/test/configure/permission.spec.tsx @@ -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(() => diff --git a/src/core/client/admin/test/configure/wordList.spec.tsx b/src/core/client/admin/test/configure/wordList.spec.tsx index d2657d1f5..d0364d27d 100644 --- a/src/core/client/admin/test/configure/wordList.spec.tsx +++ b/src/core/client/admin/test/configure/wordList.spec.tsx @@ -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({ diff --git a/src/core/client/admin/test/decisionHistory/decisionHistory.spec.tsx b/src/core/client/admin/test/decisionHistory/decisionHistory.spec.tsx index c1aa6ef8c..39d64fd45 100644 --- a/src/core/client/admin/test/decisionHistory/decisionHistory.spec.tsx +++ b/src/core/client/admin/test/decisionHistory/decisionHistory.spec.tsx @@ -62,7 +62,7 @@ const createTestRenderer = async (resolver: any = {}) => { Query: { ...resolver.Query, viewer: sinon.stub().returns({ - ...users[0], + ...users.admins[0], commentModerationActionHistory, }), settings: sinon diff --git a/src/core/client/admin/test/fixtures.ts b/src/core/client/admin/test/fixtures.ts index 90e2672cd..d926e8a0b 100644 --- a/src/core/client/admin/test/fixtures.ts +++ b/src/core/client/admin/test/fixtures.ts @@ -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({ 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( + { + 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([ { 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({ 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( + [ + { + id: "user-admin-0", + username: "Markus", + email: "markus@test.com", + role: GQLUSER_ROLE.ADMIN, + }, + ], + baseUser + ), + moderators: createFixtures( + [ + { + id: "user-moderator-0", + username: "Lukas", + email: "lukas@test.com", + role: GQLUSER_ROLE.MODERATOR, + }, + ], + baseUser + ), + staff: createFixtures( + [ + { + id: "user-staff-0", + username: "Huy", + email: "huy@test.com", + role: GQLUSER_ROLE.STAFF, + }, + ], + baseUser + ), + commenters: createFixtures( + [ + { + 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( + { + 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({ + 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( + [ + { + 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(c => ({ ...c, - status: "REJECTED", + status: GQLCOMMENT_STATUS.REJECTED, })); -export const emptyModerationQueues = { +export const emptyModerationQueues = createFixture({ 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({ edges: [], pageInfo: { endCursor: null, hasNextPage: false }, -}; +}); -export const communityUsers = { +export const communityUsers = createFixture({ 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({ edges: [], pageInfo: { endCursor: null, hasNextPage: false }, -}; +}); -export const stories: Array> = [ +export const stories = createFixtures([ { id: "story-1", closedAt: null, @@ -430,17 +511,17 @@ export const stories: Array> = [ publishedAt: "2018-11-29T16:01:51.897Z", }, }, -]; +]); -export const storyConnection = { +export const storyConnection = createFixture({ 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({ edges: [], pageInfo: { endCursor: null, hasNextPage: false }, -}; +}); diff --git a/src/core/client/admin/test/moderate/__snapshots__/moderate.spec.tsx.snap b/src/core/client/admin/test/moderate/__snapshots__/moderate.spec.tsx.snap index 72a0150bc..73d338419 100644 --- a/src/core/client/admin/test/moderate/__snapshots__/moderate.spec.tsx.snap +++ b/src/core/client/admin/test/moderate/__snapshots__/moderate.spec.tsx.snap @@ -157,7 +157,7 @@ exports[`rejected queue accepts comment in rejected queue: dangling 1`] = ` - Markus + Isabelle