mirror of
https://github.com/wassname/talk.git
synced 2026-06-27 19:33:06 +08:00
[CORL-166] Live Updates on Mod Queues (#2368)
* feat: client implementation of subscriptions and modqueue live counts * fix: unit tests * feat: live status update in moderation * feat: live update of new comments in moderation * chore: View New instead of View More * feat: fade in transition for new comments * chore: turn websocket proxy back on * feat: initial server impl * fix: make it work :-) * fix: add box shadow * chore: make test subscriptions only support 1 top level field following the spec * fix: linting * feat: support clientID * fix: linting * feat: support commentStatusUpdated subscription * fix: disabled styles for approve and reject button * feat: show moderated by system and update flags * feat: support metrics recording on websocket connections * fix: handle when same comment enters but leaves again
This commit is contained in:
@@ -79,6 +79,11 @@ export default function({
|
||||
public: allowedHost,
|
||||
index: "embed.html",
|
||||
proxy: {
|
||||
// Proxy websocket connections.
|
||||
"/api/graphql/live": {
|
||||
target: `ws://localhost:${serverPort}`,
|
||||
ws: true,
|
||||
},
|
||||
// Proxy to the graphql server.
|
||||
"/api": {
|
||||
target: `http://localhost:${serverPort}`,
|
||||
|
||||
Generated
+55
-10
@@ -4018,6 +4018,15 @@
|
||||
"@types/passport": "*"
|
||||
}
|
||||
},
|
||||
"@types/permit": {
|
||||
"version": "0.2.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/permit/-/permit-0.2.1.tgz",
|
||||
"integrity": "sha512-32fn5mTq0AZHyd61Z+mzx6+LiPojNeC/O2B8w47rfNYqAUU+FZvDsCjCzz7WnRJ/MrE1VtL3rupodTE8I8cLHw==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"@types/prop-types": {
|
||||
"version": "15.5.8",
|
||||
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.5.8.tgz",
|
||||
@@ -4885,6 +4894,16 @@
|
||||
"lodash": "^4.17.10",
|
||||
"subscriptions-transport-ws": "^0.9.11",
|
||||
"ws": "^5.2.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"graphql-subscriptions": {
|
||||
"version": "0.5.8",
|
||||
"resolved": "https://registry.npmjs.org/graphql-subscriptions/-/graphql-subscriptions-0.5.8.tgz",
|
||||
"integrity": "sha512-0CaZnXKBw2pwnIbvmVckby5Ge5e2ecmjofhYCdyeACbCly2j3WXDP/pl+s+Dqd2GQFC7y99NB+53jrt55CKxYQ==",
|
||||
"requires": {
|
||||
"iterall": "^1.2.1"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"apollo-server-env": {
|
||||
@@ -4938,6 +4957,14 @@
|
||||
"type-is": "~1.6.16"
|
||||
}
|
||||
},
|
||||
"graphql-subscriptions": {
|
||||
"version": "0.5.8",
|
||||
"resolved": "https://registry.npmjs.org/graphql-subscriptions/-/graphql-subscriptions-0.5.8.tgz",
|
||||
"integrity": "sha512-0CaZnXKBw2pwnIbvmVckby5Ge5e2ecmjofhYCdyeACbCly2j3WXDP/pl+s+Dqd2GQFC7y99NB+53jrt55CKxYQ==",
|
||||
"requires": {
|
||||
"iterall": "^1.2.1"
|
||||
}
|
||||
},
|
||||
"iconv-lite": {
|
||||
"version": "0.4.23",
|
||||
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.23.tgz",
|
||||
@@ -16093,6 +16120,15 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"graphql-redis-subscriptions": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/graphql-redis-subscriptions/-/graphql-redis-subscriptions-2.1.0.tgz",
|
||||
"integrity": "sha512-edur8YlwIsjk9K1Ao8vgEQkNKvt11FCTlOIFfxMKYIHOVv4zMvDsv7fs282LxMJBJGDCDDcdc1c5iKj0BjXO+Q==",
|
||||
"requires": {
|
||||
"ioredis": "^4.6.3",
|
||||
"iterall": "^1.2.2"
|
||||
}
|
||||
},
|
||||
"graphql-request": {
|
||||
"version": "1.6.0",
|
||||
"resolved": "https://registry.npmjs.org/graphql-request/-/graphql-request-1.6.0.tgz",
|
||||
@@ -16174,9 +16210,9 @@
|
||||
}
|
||||
},
|
||||
"graphql-subscriptions": {
|
||||
"version": "0.5.8",
|
||||
"resolved": "https://registry.npmjs.org/graphql-subscriptions/-/graphql-subscriptions-0.5.8.tgz",
|
||||
"integrity": "sha512-0CaZnXKBw2pwnIbvmVckby5Ge5e2ecmjofhYCdyeACbCly2j3WXDP/pl+s+Dqd2GQFC7y99NB+53jrt55CKxYQ==",
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/graphql-subscriptions/-/graphql-subscriptions-1.1.0.tgz",
|
||||
"integrity": "sha512-6WzlBFC0lWmXJbIVE8OgFgXIP4RJi3OQgTPa0DVMsDXdpRDjTsM1K9wfl5HSYX7R87QAGlvcv2Y4BIZa/ItonA==",
|
||||
"requires": {
|
||||
"iterall": "^1.2.1"
|
||||
}
|
||||
@@ -28474,17 +28510,26 @@
|
||||
"dev": true
|
||||
},
|
||||
"react-transition-group": {
|
||||
"version": "2.5.0",
|
||||
"resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-2.5.0.tgz",
|
||||
"integrity": "sha512-qYB3JBF+9Y4sE4/Mg/9O6WFpdoYjeeYqx0AFb64PTazVy8RPMiE3A47CG9QmM4WJ/mzDiZYslV+Uly6O1Erlgw==",
|
||||
"version": "2.9.0",
|
||||
"resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-2.9.0.tgz",
|
||||
"integrity": "sha512-+HzNTCHpeQyl4MJ/bdE0u6XRMe9+XG/+aL4mCxVN4DnPBQ0/5bfHWPDuOZUzYdMj94daZaZdCCc1Dzt9R/xSSg==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"dom-helpers": "^3.3.1",
|
||||
"dom-helpers": "^3.4.0",
|
||||
"loose-envify": "^1.4.0",
|
||||
"prop-types": "^15.6.2",
|
||||
"react-lifecycles-compat": "^3.0.4"
|
||||
},
|
||||
"dependencies": {
|
||||
"dom-helpers": {
|
||||
"version": "3.4.0",
|
||||
"resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-3.4.0.tgz",
|
||||
"integrity": "sha512-LnuPJ+dwqKDIyotW1VzmOZ5TONUN7CwkCR5hrgawTUbkBGYdeoNLZo6nNfGkCrjtE1nXXaj7iMMpDa8/d9WoIA==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"@babel/runtime": "^7.1.2"
|
||||
}
|
||||
},
|
||||
"loose-envify": {
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
|
||||
@@ -30938,9 +30983,9 @@
|
||||
}
|
||||
},
|
||||
"subscriptions-transport-ws": {
|
||||
"version": "0.9.12",
|
||||
"resolved": "https://registry.npmjs.org/subscriptions-transport-ws/-/subscriptions-transport-ws-0.9.12.tgz",
|
||||
"integrity": "sha512-57Ar8hjr/63fCx1kM3kyDr64FAPQITMguuFuTGgYVx2v1JOaPoTeZyTIenVPgv+7mDYt7E+h+Jyxvznb+UKVWw==",
|
||||
"version": "0.9.16",
|
||||
"resolved": "https://registry.npmjs.org/subscriptions-transport-ws/-/subscriptions-transport-ws-0.9.16.tgz",
|
||||
"integrity": "sha512-pQdoU7nC+EpStXnCfh/+ho0zE0Z+ma+i7xvj7bkXKb1dvYHSZxgRPaU6spRP+Bjzow67c/rRDoix5RT0uU9omw==",
|
||||
"requires": {
|
||||
"backo2": "^1.0.2",
|
||||
"eventemitter3": "^3.1.0",
|
||||
|
||||
+6
-2
@@ -82,6 +82,8 @@
|
||||
"graphql-extensions": "^0.2.1",
|
||||
"graphql-fields": "^1.1.0",
|
||||
"graphql-playground-html": "^1.6.0",
|
||||
"graphql-redis-subscriptions": "^2.1.0",
|
||||
"graphql-subscriptions": "^1.1.0",
|
||||
"graphql-tools": "^3.0.5",
|
||||
"html-minifier": "^3.5.21",
|
||||
"html-to-text": "^4.0.0",
|
||||
@@ -139,8 +141,8 @@
|
||||
"@coralproject/npm-run-all": "^4.1.5",
|
||||
"@coralproject/rte": "^0.10.15",
|
||||
"@intervolga/optimize-cssnano-plugin": "^1.0.6",
|
||||
"@types/basic-auth": "^1.1.2",
|
||||
"@types/agent-base": "^4.2.0",
|
||||
"@types/basic-auth": "^1.1.2",
|
||||
"@types/bcryptjs": "^2.4.1",
|
||||
"@types/bull": "^3.5.12",
|
||||
"@types/bunyan": "^1.8.4",
|
||||
@@ -187,6 +189,7 @@
|
||||
"@types/passport-local": "^1.0.33",
|
||||
"@types/passport-oauth2": "^1.4.5",
|
||||
"@types/passport-strategy": "^0.2.33",
|
||||
"@types/permit": "^0.2.1",
|
||||
"@types/prop-types": "^15.5.8",
|
||||
"@types/react": "^16.8.15",
|
||||
"@types/react-copy-to-clipboard": "^4.2.6",
|
||||
@@ -297,7 +300,7 @@
|
||||
"react-responsive": "^7.0.0",
|
||||
"react-test-renderer": "^16.9.0-alpha.0",
|
||||
"react-timeago": "^4.1.9",
|
||||
"react-transition-group": "^2.5.0",
|
||||
"react-transition-group": "^2.9.0",
|
||||
"react-with-state-props": "^2.0.4",
|
||||
"recompose": "0.27.1",
|
||||
"relay-compiler": "^4.0.0",
|
||||
@@ -312,6 +315,7 @@
|
||||
"sockjs-client": "^1.3.0",
|
||||
"strip-ansi": "^5.2.0",
|
||||
"style-loader": "^0.23.1",
|
||||
"subscriptions-transport-ws": "^0.9.16",
|
||||
"terser-webpack-plugin": "^1.2.3",
|
||||
"thread-loader": "^2.1.2",
|
||||
"timekeeper": "^2.2.0",
|
||||
|
||||
@@ -1,16 +1,24 @@
|
||||
import { ConnectionHandler, RecordSourceSelectorProxy } from "relay-runtime";
|
||||
import {
|
||||
ConnectionHandler,
|
||||
RecordProxy,
|
||||
RecordSourceProxy,
|
||||
RecordSourceSelectorProxy,
|
||||
} from "relay-runtime";
|
||||
|
||||
type Queue = "reported" | "pending" | "unmoderated" | "rejected";
|
||||
import {
|
||||
GQLCOMMENT_STATUS,
|
||||
GQLMODERATION_QUEUE_RL,
|
||||
} from "coral-framework/schema";
|
||||
|
||||
export default function getQueueConnection(
|
||||
store: RecordSourceSelectorProxy,
|
||||
queue: Queue,
|
||||
storyID?: string
|
||||
) {
|
||||
store: RecordSourceSelectorProxy | RecordSourceProxy,
|
||||
queue: GQLMODERATION_QUEUE_RL | "REJECTED",
|
||||
storyID?: string | null
|
||||
): RecordProxy | null {
|
||||
const root = store.getRoot();
|
||||
if (queue === "rejected") {
|
||||
if (queue === "REJECTED") {
|
||||
return ConnectionHandler.getConnection(root, "RejectedQueue_comments", {
|
||||
status: "REJECTED",
|
||||
status: GQLCOMMENT_STATUS.REJECTED,
|
||||
storyID,
|
||||
});
|
||||
}
|
||||
@@ -19,7 +27,7 @@ export default function getQueueConnection(
|
||||
return null;
|
||||
}
|
||||
return ConnectionHandler.getConnection(
|
||||
queuesRecord.getLinkedRecord(queue),
|
||||
queuesRecord.getLinkedRecord(queue.toLowerCase()),
|
||||
"Queue_comments"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -10,6 +10,19 @@ enum View {
|
||||
ADD_EMAIL_ADDRESS
|
||||
}
|
||||
|
||||
extend type Comment {
|
||||
# If true then Comment status was live updated.
|
||||
statusLiveUpdated: Boolean
|
||||
|
||||
# If true then Comment came in live.
|
||||
enteredLive: Boolean
|
||||
}
|
||||
|
||||
extend type CommentsConnection {
|
||||
# Contains comment that came in live and is still behind the `View New` button.
|
||||
viewNewEdges: [CommentEdge!]
|
||||
}
|
||||
|
||||
type Local {
|
||||
network: Network!
|
||||
accessToken: String
|
||||
|
||||
@@ -27,6 +27,15 @@ const ApproveCommentMutation = createMutation(
|
||||
comment {
|
||||
id
|
||||
status
|
||||
statusHistory(first: 1) {
|
||||
edges {
|
||||
node {
|
||||
moderator {
|
||||
username
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
moderationQueues(storyID: $storyID) {
|
||||
unmoderated {
|
||||
@@ -56,10 +65,10 @@ const ApproveCommentMutation = createMutation(
|
||||
},
|
||||
updater: store => {
|
||||
const connections = [
|
||||
getQueueConnection(store, "reported", input.storyID),
|
||||
getQueueConnection(store, "pending", input.storyID),
|
||||
getQueueConnection(store, "unmoderated", input.storyID),
|
||||
getQueueConnection(store, "rejected", input.storyID),
|
||||
getQueueConnection(store, "REPORTED", input.storyID),
|
||||
getQueueConnection(store, "PENDING", input.storyID),
|
||||
getQueueConnection(store, "UNMODERATED", input.storyID),
|
||||
getQueueConnection(store, "REJECTED", input.storyID),
|
||||
].filter(c => c);
|
||||
connections.forEach(con =>
|
||||
ConnectionHandler.deleteNode(con, input.commentID)
|
||||
|
||||
@@ -27,6 +27,15 @@ const RejectCommentMutation = createMutation(
|
||||
comment {
|
||||
id
|
||||
status
|
||||
statusHistory(first: 1) {
|
||||
edges {
|
||||
node {
|
||||
moderator {
|
||||
username
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
moderationQueues(storyID: $storyID) {
|
||||
unmoderated {
|
||||
@@ -56,9 +65,9 @@ const RejectCommentMutation = createMutation(
|
||||
},
|
||||
updater: store => {
|
||||
const connections = [
|
||||
getQueueConnection(store, "reported", input.storyID),
|
||||
getQueueConnection(store, "pending", input.storyID),
|
||||
getQueueConnection(store, "unmoderated", input.storyID),
|
||||
getQueueConnection(store, "REPORTED", input.storyID),
|
||||
getQueueConnection(store, "PENDING", input.storyID),
|
||||
getQueueConnection(store, "UNMODERATED", input.storyID),
|
||||
].filter(c => c);
|
||||
connections.forEach(con =>
|
||||
ConnectionHandler.deleteNode(con, input.commentID)
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
}
|
||||
|
||||
.main {
|
||||
margin: calc(2 * var(--mini-unit)) 0 calc(4 * var(--mini-unit)) 0;
|
||||
margin: var(--spacing-5) 0 var(--spacing-7) 0;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
color: var(--palette-success-main);
|
||||
&:active {
|
||||
&:not(:disabled):active {
|
||||
background-color: var(--palette-success-main);
|
||||
color: var(--palette-common-white);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
.appear {
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
.appearActive {
|
||||
opacity: 1;
|
||||
transition: opacity 400ms;
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import React, { FunctionComponent } from "react";
|
||||
import { CSSTransition } from "react-transition-group";
|
||||
|
||||
import styles from "./FadeInTransition.css";
|
||||
|
||||
interface Props {
|
||||
active: boolean;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
const FadeInTransition: FunctionComponent<Props> = ({ children, active }) => {
|
||||
if (!active) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
return (
|
||||
<CSSTransition
|
||||
in
|
||||
appear
|
||||
enter={false}
|
||||
exit={false}
|
||||
classNames={{
|
||||
appear: styles.appear,
|
||||
appearActive: styles.appearActive,
|
||||
}}
|
||||
timeout={600}
|
||||
>
|
||||
<div>{children}</div>
|
||||
</CSSTransition>
|
||||
);
|
||||
};
|
||||
export default FadeInTransition;
|
||||
@@ -68,10 +68,10 @@ const FeatureCommentMutation = createMutation(
|
||||
},
|
||||
updater: store => {
|
||||
const connections = [
|
||||
getQueueConnection(store, "reported", input.storyID),
|
||||
getQueueConnection(store, "pending", input.storyID),
|
||||
getQueueConnection(store, "unmoderated", input.storyID),
|
||||
getQueueConnection(store, "rejected", input.storyID),
|
||||
getQueueConnection(store, "PENDING", input.storyID),
|
||||
getQueueConnection(store, "REPORTED", input.storyID),
|
||||
getQueueConnection(store, "UNMODERATED", input.storyID),
|
||||
getQueueConnection(store, "REJECTED", input.storyID),
|
||||
].filter(c => c);
|
||||
connections.forEach(con =>
|
||||
ConnectionHandler.deleteNode(con, input.commentID)
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
}
|
||||
|
||||
.content {
|
||||
min-height: calc(4.5 * var(--mini-unit));
|
||||
min-height: calc(5.5 * var(--mini-unit));
|
||||
}
|
||||
|
||||
.mainContainer {
|
||||
@@ -47,11 +47,12 @@
|
||||
}
|
||||
|
||||
.root {
|
||||
transition: background 100ms;
|
||||
transition: background 100ms, box-shadow 100ms;
|
||||
}
|
||||
|
||||
.dangling {
|
||||
background-color: var(--palette-grey-lightest);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.link {
|
||||
@@ -81,3 +82,24 @@
|
||||
letter-spacing: calc(0.2em / 12);
|
||||
color: var(--palette-text-primary);
|
||||
}
|
||||
|
||||
.moderatedBy {
|
||||
font-size: calc(12rem / var(--rem-base));
|
||||
font-weight: var(--font-weight-regular);
|
||||
font-family: var(--font-family-sans-serif);
|
||||
line-height: 1;
|
||||
letter-spacing: 0;
|
||||
color: var(--palette-text-primary);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.moderatedByUsername {
|
||||
font-size: calc(14rem / var(--rem-base));
|
||||
font-weight: var(--font-weight-medium);
|
||||
font-family: var(--font-family-sans-serif);
|
||||
line-height: 1;
|
||||
letter-spacing: 0;
|
||||
color: var(--palette-text-primary);
|
||||
text-align: center;
|
||||
padding-top: 1px;
|
||||
}
|
||||
|
||||
@@ -25,6 +25,7 @@ const baseProps: PropTypesOf<typeof ModerateCardN> = {
|
||||
onReject: noop,
|
||||
onFeature: noop,
|
||||
showStory: false,
|
||||
moderatedBy: null,
|
||||
};
|
||||
|
||||
it("renders correctly", () => {
|
||||
|
||||
@@ -25,6 +25,7 @@ interface Props {
|
||||
comment: PropTypesOf<typeof MarkersContainer>["comment"];
|
||||
status: "approved" | "rejected" | "undecided";
|
||||
featured: boolean;
|
||||
moderatedBy: React.ReactNode | null;
|
||||
viewContextHref: string;
|
||||
suspectWords: ReadonlyArray<string>;
|
||||
bannedWords: ReadonlyArray<string>;
|
||||
@@ -63,6 +64,7 @@ const ModerateCard: FunctionComponent<Props> = ({
|
||||
storyTitle,
|
||||
storyHref,
|
||||
onModerateStory,
|
||||
moderatedBy,
|
||||
}) => (
|
||||
<Card
|
||||
className={cn(styles.root, { [styles.dangling]: dangling })}
|
||||
@@ -148,6 +150,7 @@ const ModerateCard: FunctionComponent<Props> = ({
|
||||
disabled={status === "approved" || dangling}
|
||||
/>
|
||||
</Flex>
|
||||
{moderatedBy}
|
||||
</Flex>
|
||||
</Flex>
|
||||
</Card>
|
||||
|
||||
@@ -4,9 +4,10 @@ import { graphql } from "react-relay";
|
||||
|
||||
import {
|
||||
COMMENT_STATUS,
|
||||
ModerateCardContainer_comment as CommentData,
|
||||
ModerateCardContainer_comment,
|
||||
} from "coral-admin/__generated__/ModerateCardContainer_comment.graphql";
|
||||
import { ModerateCardContainer_settings as SettingsData } from "coral-admin/__generated__/ModerateCardContainer_settings.graphql";
|
||||
import { ModerateCardContainer_settings } from "coral-admin/__generated__/ModerateCardContainer_settings.graphql";
|
||||
import { ModerateCardContainer_viewer } from "coral-admin/__generated__/ModerateCardContainer_viewer.graphql";
|
||||
import NotAvailable from "coral-admin/components/NotAvailable";
|
||||
import { getModerationLink } from "coral-admin/helpers";
|
||||
import { ApproveCommentMutation } from "coral-admin/mutations";
|
||||
@@ -18,13 +19,16 @@ import {
|
||||
} from "coral-framework/lib/relay";
|
||||
import { GQLTAG } from "coral-framework/schema";
|
||||
|
||||
import FadeInTransition from "./FadeInTransition";
|
||||
import FeatureCommentMutation from "./FeatureCommentMutation";
|
||||
import ModerateCard from "./ModerateCard";
|
||||
import ModeratedByContainer from "./ModeratedByContainer";
|
||||
import UnfeatureCommentMutation from "./UnfeatureCommentMutation";
|
||||
|
||||
interface Props {
|
||||
comment: CommentData;
|
||||
settings: SettingsData;
|
||||
comment: ModerateCardContainer_comment;
|
||||
viewer: ModerateCardContainer_viewer;
|
||||
settings: ModerateCardContainer_settings;
|
||||
approveComment: MutationProp<typeof ApproveCommentMutation>;
|
||||
rejectComment: MutationProp<typeof RejectCommentMutation>;
|
||||
featureComment: MutationProp<typeof FeatureCommentMutation>;
|
||||
@@ -35,7 +39,7 @@ interface Props {
|
||||
showStoryInfo: boolean;
|
||||
}
|
||||
|
||||
function getStatus(comment: CommentData) {
|
||||
function getStatus(comment: ModerateCardContainer_comment) {
|
||||
switch (comment.status) {
|
||||
case "APPROVED":
|
||||
return "approved";
|
||||
@@ -46,7 +50,7 @@ function getStatus(comment: CommentData) {
|
||||
}
|
||||
}
|
||||
|
||||
function isFeatured(comment: CommentData) {
|
||||
function isFeatured(comment: ModerateCardContainer_comment) {
|
||||
return comment.tags.some(t => t.code === GQLTAG.FEATURED);
|
||||
}
|
||||
|
||||
@@ -102,33 +106,45 @@ class ModerateCardContainer extends React.Component<Props> {
|
||||
};
|
||||
|
||||
public render() {
|
||||
const { comment, settings, danglingLogic, showStoryInfo } = this.props;
|
||||
const {
|
||||
comment,
|
||||
settings,
|
||||
danglingLogic,
|
||||
showStoryInfo,
|
||||
viewer,
|
||||
} = this.props;
|
||||
const dangling = danglingLogic(comment.status);
|
||||
return (
|
||||
<ModerateCard
|
||||
id={comment.id}
|
||||
username={comment.author!.username!}
|
||||
createdAt={comment.createdAt}
|
||||
body={comment.body!}
|
||||
inReplyTo={comment.parent && comment.parent.author!.username!}
|
||||
comment={comment}
|
||||
status={getStatus(comment)}
|
||||
featured={isFeatured(comment)}
|
||||
viewContextHref={comment.permalink}
|
||||
suspectWords={settings.wordList.suspect}
|
||||
bannedWords={settings.wordList.banned}
|
||||
onApprove={this.handleApprove}
|
||||
onReject={this.handleReject}
|
||||
onFeature={this.onFeature}
|
||||
dangling={danglingLogic(comment.status)}
|
||||
showStory={showStoryInfo}
|
||||
storyTitle={
|
||||
(comment.story.metadata && comment.story.metadata.title) || (
|
||||
<NotAvailable />
|
||||
)
|
||||
}
|
||||
storyHref={getModerationLink("default", comment.story.id)}
|
||||
onModerateStory={this.handleModerateStory}
|
||||
/>
|
||||
<FadeInTransition active={Boolean(comment.enteredLive)}>
|
||||
<ModerateCard
|
||||
id={comment.id}
|
||||
username={comment.author!.username!}
|
||||
createdAt={comment.createdAt}
|
||||
body={comment.body!}
|
||||
inReplyTo={comment.parent && comment.parent.author!.username!}
|
||||
comment={comment}
|
||||
dangling={dangling}
|
||||
status={getStatus(comment)}
|
||||
featured={isFeatured(comment)}
|
||||
viewContextHref={comment.permalink}
|
||||
suspectWords={settings.wordList.suspect}
|
||||
bannedWords={settings.wordList.banned}
|
||||
onApprove={this.handleApprove}
|
||||
onReject={this.handleReject}
|
||||
onFeature={this.onFeature}
|
||||
moderatedBy={
|
||||
<ModeratedByContainer viewer={viewer} comment={comment} />
|
||||
}
|
||||
showStory={showStoryInfo}
|
||||
storyTitle={
|
||||
(comment.story.metadata && comment.story.metadata.title) || (
|
||||
<NotAvailable />
|
||||
)
|
||||
}
|
||||
storyHref={getModerationLink("default", comment.story.id)}
|
||||
onModerateStory={this.handleModerateStory}
|
||||
/>
|
||||
</FadeInTransition>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -140,12 +156,13 @@ const enhanced = withFragmentContainer<Props>({
|
||||
author {
|
||||
username
|
||||
}
|
||||
statusLiveUpdated
|
||||
createdAt
|
||||
body
|
||||
status
|
||||
tags {
|
||||
code
|
||||
}
|
||||
status
|
||||
revision {
|
||||
id
|
||||
}
|
||||
@@ -161,7 +178,9 @@ const enhanced = withFragmentContainer<Props>({
|
||||
}
|
||||
}
|
||||
permalink
|
||||
enteredLive
|
||||
...MarkersContainer_comment
|
||||
...ModeratedByContainer_comment
|
||||
}
|
||||
`,
|
||||
settings: graphql`
|
||||
@@ -172,6 +191,11 @@ const enhanced = withFragmentContainer<Props>({
|
||||
}
|
||||
}
|
||||
`,
|
||||
viewer: graphql`
|
||||
fragment ModerateCardContainer_viewer on User {
|
||||
...ModeratedByContainer_viewer
|
||||
}
|
||||
`,
|
||||
})(
|
||||
withRouter(
|
||||
withMutation(ApproveCommentMutation)(
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
.moderatedBy {
|
||||
font-size: calc(12rem / var(--rem-base));
|
||||
font-weight: var(--font-weight-regular);
|
||||
font-family: var(--font-family-sans-serif);
|
||||
line-height: 1;
|
||||
letter-spacing: 0;
|
||||
color: var(--palette-text-primary);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.moderatedByUsername {
|
||||
font-size: calc(14rem / var(--rem-base));
|
||||
font-weight: var(--font-weight-medium);
|
||||
font-family: var(--font-family-sans-serif);
|
||||
line-height: 1;
|
||||
letter-spacing: 0;
|
||||
color: var(--palette-text-primary);
|
||||
text-align: center;
|
||||
padding-top: 1px;
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import { Localized } from "fluent-react/compat";
|
||||
import React from "react";
|
||||
import { graphql } from "react-relay";
|
||||
|
||||
import { ModeratedByContainer_comment } from "coral-admin/__generated__/ModeratedByContainer_comment.graphql";
|
||||
import { ModeratedByContainer_viewer } from "coral-admin/__generated__/ModeratedByContainer_viewer.graphql";
|
||||
import { withFragmentContainer } from "coral-framework/lib/relay";
|
||||
|
||||
import styles from "./ModeratedByContainer.css";
|
||||
|
||||
interface Props {
|
||||
viewer: ModeratedByContainer_viewer;
|
||||
comment: ModeratedByContainer_comment;
|
||||
}
|
||||
|
||||
const ModeratedByContainer: React.FunctionComponent<Props> = ({
|
||||
comment,
|
||||
viewer,
|
||||
}) => {
|
||||
let moderatedBy: React.ReactElement | null;
|
||||
if (!comment.statusLiveUpdated || comment.statusHistory.edges.length === 0) {
|
||||
moderatedBy = null;
|
||||
} else if (comment.statusHistory.edges[0].node.moderator === null) {
|
||||
moderatedBy = (
|
||||
<Localized id="moderate-comment-moderatedBySystem">System</Localized>
|
||||
);
|
||||
} else if (viewer.id === comment.statusHistory.edges[0].node.moderator.id) {
|
||||
moderatedBy = null;
|
||||
} else {
|
||||
moderatedBy = <>{comment.statusHistory.edges[0].node.moderator.username}</>;
|
||||
}
|
||||
|
||||
if (!moderatedBy) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Localized id="moderate-comment-moderatedBy">
|
||||
<div className={styles.moderatedBy}>Moderated By</div>
|
||||
</Localized>
|
||||
<div className={styles.moderatedByUsername}>{moderatedBy}</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const enhanced = withFragmentContainer<Props>({
|
||||
comment: graphql`
|
||||
fragment ModeratedByContainer_comment on Comment {
|
||||
id
|
||||
statusLiveUpdated
|
||||
statusHistory(first: 1) {
|
||||
edges {
|
||||
node {
|
||||
moderator {
|
||||
id
|
||||
username
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
viewer: graphql`
|
||||
fragment ModeratedByContainer_viewer on User {
|
||||
id
|
||||
}
|
||||
`,
|
||||
})(ModeratedByContainer);
|
||||
|
||||
export default enhanced;
|
||||
@@ -8,7 +8,7 @@
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
color: var(--palette-error-main);
|
||||
&:active {
|
||||
&:not(:disabled):active {
|
||||
background-color: var(--palette-error-main);
|
||||
color: var(--palette-common-white);
|
||||
}
|
||||
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
import { graphql, requestSubscription } from "react-relay";
|
||||
import { Environment } from "relay-runtime";
|
||||
|
||||
import { ModerateCountsCommentEnteredSubscription } from "coral-admin/__generated__/ModerateCountsCommentEnteredSubscription.graphql";
|
||||
import {
|
||||
createSubscription,
|
||||
SubscriptionVariables,
|
||||
} from "coral-framework/lib/relay";
|
||||
import { GQLMODERATION_QUEUE } from "coral-framework/schema";
|
||||
|
||||
import changeQueueCount from "./changeQueueCount";
|
||||
|
||||
const ModerateCountsCommentEnteredSubscription = createSubscription(
|
||||
"subscribeToCommentEntered",
|
||||
(
|
||||
environment: Environment,
|
||||
variables: SubscriptionVariables<ModerateCountsCommentEnteredSubscription>
|
||||
) =>
|
||||
requestSubscription(environment, {
|
||||
subscription: graphql`
|
||||
subscription ModerateCountsCommentEnteredSubscription($storyID: ID) {
|
||||
commentEnteredModerationQueue(storyID: $storyID) {
|
||||
queue
|
||||
}
|
||||
}
|
||||
`,
|
||||
variables,
|
||||
updater: store => {
|
||||
const root = store.getRootField("commentEnteredModerationQueue")!;
|
||||
const queue = root.getValue("queue") as GQLMODERATION_QUEUE;
|
||||
const change = 1;
|
||||
changeQueueCount(store, change, queue, variables.storyID);
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
export default ModerateCountsCommentEnteredSubscription;
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
import { graphql, requestSubscription } from "react-relay";
|
||||
import { Environment } from "relay-runtime";
|
||||
|
||||
import { ModerateCountsCommentLeftSubscription } from "coral-admin/__generated__/ModerateCountsCommentLeftSubscription.graphql";
|
||||
import {
|
||||
createSubscription,
|
||||
SubscriptionVariables,
|
||||
} from "coral-framework/lib/relay";
|
||||
import { GQLMODERATION_QUEUE } from "coral-framework/schema";
|
||||
|
||||
import changeQueueCount from "./changeQueueCount";
|
||||
|
||||
const ModerateCountsCommentLeftSubscription = createSubscription(
|
||||
"subscribeToCommentLeft",
|
||||
(
|
||||
environment: Environment,
|
||||
variables: SubscriptionVariables<ModerateCountsCommentLeftSubscription>
|
||||
) =>
|
||||
requestSubscription(environment, {
|
||||
subscription: graphql`
|
||||
subscription ModerateCountsCommentLeftSubscription($storyID: ID) {
|
||||
commentLeftModerationQueue(storyID: $storyID) {
|
||||
queue
|
||||
}
|
||||
commentLeftModerationQueue(storyID: $storyID) {
|
||||
queue
|
||||
}
|
||||
}
|
||||
`,
|
||||
variables,
|
||||
updater: store => {
|
||||
const root = store.getRootField("commentLeftModerationQueue")!;
|
||||
const queue = root.getValue("queue") as GQLMODERATION_QUEUE;
|
||||
const change = -1;
|
||||
changeQueueCount(store, change, queue, variables.storyID);
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
export default ModerateCountsCommentLeftSubscription;
|
||||
+31
-2
@@ -1,10 +1,16 @@
|
||||
import React from "react";
|
||||
import React, { useEffect } from "react";
|
||||
import { graphql } from "react-relay";
|
||||
|
||||
import { ModerateNavigationContainer_moderationQueues as ModerationQueuesData } from "coral-admin/__generated__/ModerateNavigationContainer_moderationQueues.graphql";
|
||||
import { ModerateNavigationContainer_story as StoryData } from "coral-admin/__generated__/ModerateNavigationContainer_story.graphql";
|
||||
import { withFragmentContainer } from "coral-framework/lib/relay";
|
||||
import {
|
||||
combineDisposables,
|
||||
useSubscription,
|
||||
withFragmentContainer,
|
||||
} from "coral-framework/lib/relay";
|
||||
|
||||
import ModerateCountsCommentEnteredSubscription from "./ModerateCountsCommentEnteredSubscription";
|
||||
import ModerateCountsCommentLeftSubscription from "./ModerateCountsCommentLeftSubscription";
|
||||
import Navigation from "./Navigation";
|
||||
|
||||
interface Props {
|
||||
@@ -13,6 +19,29 @@ interface Props {
|
||||
}
|
||||
|
||||
const ModerateNavigationContainer: React.FunctionComponent<Props> = props => {
|
||||
const subscribeToCommentEntered = useSubscription(
|
||||
ModerateCountsCommentEnteredSubscription
|
||||
);
|
||||
const subscribeToCommentLeft = useSubscription(
|
||||
ModerateCountsCommentLeftSubscription
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!props.moderationQueues) {
|
||||
return;
|
||||
}
|
||||
const vars = {
|
||||
storyID: props.story && props.story.id,
|
||||
};
|
||||
const disposable = combineDisposables(
|
||||
subscribeToCommentEntered(vars),
|
||||
subscribeToCommentLeft(vars)
|
||||
);
|
||||
return () => {
|
||||
disposable.dispose();
|
||||
};
|
||||
}, [Boolean(props.moderationQueues), props.story]);
|
||||
|
||||
if (!props.moderationQueues) {
|
||||
return <Navigation />;
|
||||
}
|
||||
|
||||
+61
@@ -0,0 +1,61 @@
|
||||
import { graphql, requestSubscription } from "react-relay";
|
||||
import { Environment } from "relay-runtime";
|
||||
|
||||
import { ModerationCountsSubscription } from "coral-admin/__generated__/ModerationCountsSubscription.graphql";
|
||||
import {
|
||||
createSubscription,
|
||||
SubscriptionVariables,
|
||||
} from "coral-framework/lib/relay";
|
||||
|
||||
const ModerationCountsSubscription = createSubscription(
|
||||
"subscribeToCounts",
|
||||
(
|
||||
environment: Environment,
|
||||
variables: SubscriptionVariables<ModerationCountsSubscription>
|
||||
) =>
|
||||
requestSubscription(environment, {
|
||||
subscription: graphql`
|
||||
subscription ModerationCountsSubscription($storyID: ID) {
|
||||
commentEnteredModerationQueue(storyID: $storyID) {
|
||||
queue
|
||||
}
|
||||
commentLeftModerationQueue(storyID: $storyID) {
|
||||
queue
|
||||
}
|
||||
}
|
||||
`,
|
||||
variables,
|
||||
updater: store => {
|
||||
let queue: string;
|
||||
let change: number;
|
||||
|
||||
const enteredRoot = store.getRootField("commentEnteredModerationQueue");
|
||||
const leftRoot = store.getRootField("commentLeftModerationQueue");
|
||||
if (enteredRoot) {
|
||||
queue = enteredRoot.getValue("queue") as string;
|
||||
change = +1;
|
||||
} else if (leftRoot) {
|
||||
queue = leftRoot.getValue("queue") as string;
|
||||
change = -1;
|
||||
} else {
|
||||
throw new Error("Expected a subscription result");
|
||||
}
|
||||
|
||||
const moderationQueuesProxy = store
|
||||
.getRoot()
|
||||
.getLinkedRecord("moderationQueues", { storyID: variables.storyID })!;
|
||||
if (!moderationQueuesProxy) {
|
||||
return;
|
||||
}
|
||||
const queueProxy = moderationQueuesProxy.getLinkedRecord(
|
||||
queue!.toLocaleLowerCase()
|
||||
);
|
||||
if (!queueProxy) {
|
||||
return;
|
||||
}
|
||||
queueProxy.setValue(queueProxy.getValue("count") + change, "count");
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
export default ModerationCountsSubscription;
|
||||
@@ -0,0 +1,23 @@
|
||||
import { GQLMODERATION_QUEUE } from "coral-framework/schema";
|
||||
import { RecordSourceSelectorProxy } from "relay-runtime";
|
||||
|
||||
export default function changeQueueCount(
|
||||
store: RecordSourceSelectorProxy,
|
||||
change: number,
|
||||
queue: GQLMODERATION_QUEUE,
|
||||
storyID: string | null = null
|
||||
) {
|
||||
const moderationQueuesProxy = store
|
||||
.getRoot()
|
||||
.getLinkedRecord("moderationQueues", { storyID })!;
|
||||
if (!moderationQueuesProxy) {
|
||||
return;
|
||||
}
|
||||
const queueProxy = moderationQueuesProxy.getLinkedRecord(
|
||||
queue!.toLocaleLowerCase()
|
||||
);
|
||||
if (!queueProxy) {
|
||||
return;
|
||||
}
|
||||
queueProxy.setValue(queueProxy.getValue("count") + change, "count");
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
.root {
|
||||
width: calc(94 * var(--mini-unit));
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.exitTransition {
|
||||
@@ -14,3 +15,13 @@
|
||||
.exitTransitionDone {
|
||||
opacity: 0;
|
||||
}
|
||||
.viewNewButtonContainer {
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
}
|
||||
.viewNewButton {
|
||||
position: absolute;
|
||||
z-index: 10;
|
||||
top: -16px;
|
||||
box-shadow: 0px 1px 3px rgba(0, 0, 0, 0.25);
|
||||
}
|
||||
|
||||
@@ -14,9 +14,11 @@ it("renders correctly with load more", () => {
|
||||
comments: [],
|
||||
settings: {},
|
||||
onLoadMore: noop,
|
||||
hasMore: true,
|
||||
hasLoadMore: true,
|
||||
disableLoadMore: false,
|
||||
danglingLogic: () => true,
|
||||
viewer: { id: "me", username: "Mirai" },
|
||||
onViewNew: noop,
|
||||
};
|
||||
const renderer = createRenderer();
|
||||
renderer.render(<QueueN {...props} />);
|
||||
@@ -28,9 +30,11 @@ it("renders correctly without load more", () => {
|
||||
comments: [],
|
||||
settings: {},
|
||||
onLoadMore: noop,
|
||||
hasMore: false,
|
||||
hasLoadMore: false,
|
||||
disableLoadMore: false,
|
||||
danglingLogic: () => true,
|
||||
viewer: { id: "me", username: "Mirai" },
|
||||
onViewNew: noop,
|
||||
};
|
||||
const renderer = createRenderer();
|
||||
renderer.render(<QueueN {...props} />);
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { Localized } from "fluent-react/compat";
|
||||
import React, { FunctionComponent } from "react";
|
||||
import { CSSTransition, TransitionGroup } from "react-transition-group";
|
||||
|
||||
import AutoLoadMore from "coral-admin/components/AutoLoadMore";
|
||||
import { Flex, HorizontalGutter } from "coral-ui/components";
|
||||
import { Button, Flex, HorizontalGutter } from "coral-ui/components";
|
||||
import { PropTypesOf } from "coral-ui/types";
|
||||
|
||||
import ModerateCardContainer from "../ModerateCard";
|
||||
@@ -14,25 +15,45 @@ interface Props {
|
||||
{ id: string } & PropTypesOf<typeof ModerateCardContainer>["comment"]
|
||||
>;
|
||||
settings: PropTypesOf<typeof ModerateCardContainer>["settings"];
|
||||
viewer: PropTypesOf<typeof ModerateCardContainer>["viewer"];
|
||||
onLoadMore: () => void;
|
||||
hasMore: boolean;
|
||||
onViewNew?: () => void;
|
||||
hasLoadMore: boolean;
|
||||
disableLoadMore: boolean;
|
||||
danglingLogic: PropTypesOf<typeof ModerateCardContainer>["danglingLogic"];
|
||||
emptyElement?: React.ReactElement;
|
||||
allStories?: boolean;
|
||||
viewNewCount?: number;
|
||||
}
|
||||
|
||||
const Queue: FunctionComponent<Props> = ({
|
||||
settings,
|
||||
comments,
|
||||
hasMore,
|
||||
hasLoadMore: hasMore,
|
||||
disableLoadMore,
|
||||
onLoadMore,
|
||||
danglingLogic,
|
||||
emptyElement,
|
||||
allStories,
|
||||
viewer,
|
||||
viewNewCount,
|
||||
onViewNew,
|
||||
}) => (
|
||||
<HorizontalGutter className={styles.root} size="double">
|
||||
{Boolean(viewNewCount && viewNewCount > 0) && (
|
||||
<Flex justifyContent="center" className={styles.viewNewButtonContainer}>
|
||||
<Localized id="moderate-queue-viewNew" $count={viewNewCount}>
|
||||
<Button
|
||||
color="primary"
|
||||
variant="filled"
|
||||
onClick={onViewNew}
|
||||
className={styles.viewNewButton}
|
||||
>
|
||||
View {viewNewCount} new comments
|
||||
</Button>
|
||||
</Localized>
|
||||
</Flex>
|
||||
)}
|
||||
<TransitionGroup component={null} appear={false} enter={false} exit>
|
||||
{comments.map(c => (
|
||||
<CSSTransition
|
||||
@@ -46,6 +67,7 @@ const Queue: FunctionComponent<Props> = ({
|
||||
>
|
||||
<ModerateCardContainer
|
||||
settings={settings}
|
||||
viewer={viewer}
|
||||
comment={c}
|
||||
danglingLogic={danglingLogic}
|
||||
showStoryInfo={Boolean(allStories)}
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import { graphql, requestSubscription } from "react-relay";
|
||||
import { Environment, RecordSourceSelectorProxy } from "relay-runtime";
|
||||
|
||||
import { QueueCommentEnteredSubscription } from "coral-admin/__generated__/QueueCommentEnteredSubscription.graphql";
|
||||
import { getQueueConnection } from "coral-admin/helpers";
|
||||
import {
|
||||
createSubscription,
|
||||
SubscriptionVariables,
|
||||
} from "coral-framework/lib/relay";
|
||||
import { GQLMODERATION_QUEUE_RL } from "coral-framework/schema";
|
||||
|
||||
function handleCommentEnteredModerationQueue(
|
||||
store: RecordSourceSelectorProxy,
|
||||
queue: GQLMODERATION_QUEUE_RL,
|
||||
storyID: string | null
|
||||
) {
|
||||
const rootField = store.getRootField("commentEnteredModerationQueue");
|
||||
if (!rootField) {
|
||||
return;
|
||||
}
|
||||
const comment = rootField.getLinkedRecord("comment")!;
|
||||
comment.setValue(true, "enteredLive");
|
||||
const commentsEdge = store.create(
|
||||
`edge-${comment.getValue("id")!}`,
|
||||
"CommentsEdge"
|
||||
);
|
||||
commentsEdge.setValue(comment.getValue("createdAt"), "cursor");
|
||||
commentsEdge.setLinkedRecord(comment, "node");
|
||||
const connection = getQueueConnection(store, queue, storyID);
|
||||
if (connection) {
|
||||
const linked = connection.getLinkedRecords("viewNewEdges") || [];
|
||||
connection.setLinkedRecords(linked.concat(commentsEdge), "viewNewEdges");
|
||||
}
|
||||
}
|
||||
|
||||
const QueueSubscription = createSubscription(
|
||||
"subscribeToQueueCommentEntered",
|
||||
(
|
||||
environment: Environment,
|
||||
variables: SubscriptionVariables<QueueCommentEnteredSubscription>
|
||||
) =>
|
||||
requestSubscription(environment, {
|
||||
subscription: graphql`
|
||||
subscription QueueCommentEnteredSubscription(
|
||||
$storyID: ID
|
||||
$queue: MODERATION_QUEUE!
|
||||
) {
|
||||
commentEnteredModerationQueue(storyID: $storyID, queue: $queue) {
|
||||
comment {
|
||||
id
|
||||
createdAt
|
||||
...ModerateCardContainer_comment
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
variables,
|
||||
updater: store => {
|
||||
handleCommentEnteredModerationQueue(
|
||||
store,
|
||||
variables.queue,
|
||||
variables.storyID || null
|
||||
);
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
export default QueueSubscription;
|
||||
@@ -0,0 +1,72 @@
|
||||
import { graphql, requestSubscription } from "react-relay";
|
||||
import { Environment, RecordSourceSelectorProxy } from "relay-runtime";
|
||||
|
||||
import { QueueCommentLeftSubscription } from "coral-admin/__generated__/QueueCommentLeftSubscription.graphql";
|
||||
import { getQueueConnection } from "coral-admin/helpers";
|
||||
import {
|
||||
createSubscription,
|
||||
SubscriptionVariables,
|
||||
} from "coral-framework/lib/relay";
|
||||
import { GQLMODERATION_QUEUE_RL } from "coral-framework/schema";
|
||||
|
||||
function handleCommentLeftModerationQueue(
|
||||
store: RecordSourceSelectorProxy,
|
||||
queue: GQLMODERATION_QUEUE_RL,
|
||||
storyID: string | null
|
||||
) {
|
||||
const rootField = store.getRootField("commentLeftModerationQueue");
|
||||
if (!rootField) {
|
||||
return;
|
||||
}
|
||||
const commentID = rootField.getLinkedRecord("comment")!.getValue("id")!;
|
||||
const commentInStore = store.get(commentID);
|
||||
if (commentInStore) {
|
||||
// Mark that the status of the comment was live updated.
|
||||
commentInStore.setValue(true, "statusLiveUpdated");
|
||||
}
|
||||
const connection = getQueueConnection(store, queue, storyID);
|
||||
if (connection) {
|
||||
const linked = connection.getLinkedRecords("viewNewEdges") || [];
|
||||
connection.setLinkedRecords(
|
||||
linked.filter(
|
||||
r => r!.getLinkedRecord("node")!.getValue("id") !== commentID
|
||||
),
|
||||
"viewNewEdges"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const QueueSubscription = createSubscription(
|
||||
"subscribeToQueueCommentLeft",
|
||||
(
|
||||
environment: Environment,
|
||||
variables: SubscriptionVariables<QueueCommentLeftSubscription>
|
||||
) =>
|
||||
requestSubscription(environment, {
|
||||
subscription: graphql`
|
||||
subscription QueueCommentLeftSubscription(
|
||||
$storyID: ID
|
||||
$queue: MODERATION_QUEUE!
|
||||
) {
|
||||
commentLeftModerationQueue(storyID: $storyID, queue: $queue) {
|
||||
comment {
|
||||
id
|
||||
status
|
||||
...MarkersContainer_comment @relay(mask: false)
|
||||
...ModeratedByContainer_comment @relay(mask: false)
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
variables,
|
||||
updater: store => {
|
||||
handleCommentLeftModerationQueue(
|
||||
store,
|
||||
variables.queue,
|
||||
variables.storyID || null
|
||||
);
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
export default QueueSubscription;
|
||||
@@ -1,21 +1,35 @@
|
||||
import { Localized } from "fluent-react/compat";
|
||||
import { RouteProps } from "found";
|
||||
import React from "react";
|
||||
import React, { FunctionComponent, useCallback, useEffect } from "react";
|
||||
import { graphql, GraphQLTaggedNode, RelayPaginationProp } from "react-relay";
|
||||
|
||||
import { QueueRoute_queue as QueueData } from "coral-admin/__generated__/QueueRoute_queue.graphql";
|
||||
import { QueueRoute_settings as SettingsData } from "coral-admin/__generated__/QueueRoute_settings.graphql";
|
||||
import { QueueRoute_queue } from "coral-admin/__generated__/QueueRoute_queue.graphql";
|
||||
import { QueueRoute_settings } from "coral-admin/__generated__/QueueRoute_settings.graphql";
|
||||
import { QueueRoute_viewer } from "coral-admin/__generated__/QueueRoute_viewer.graphql";
|
||||
import { QueueRoutePaginationPendingQueryVariables } from "coral-admin/__generated__/QueueRoutePaginationPendingQuery.graphql";
|
||||
import { IntersectionProvider } from "coral-framework/lib/intersection";
|
||||
import { withPaginationContainer } from "coral-framework/lib/relay";
|
||||
import {
|
||||
combineDisposables,
|
||||
useLoadMore,
|
||||
useMutation,
|
||||
useSubscription,
|
||||
withPaginationContainer,
|
||||
} from "coral-framework/lib/relay";
|
||||
import { GQLMODERATION_QUEUE } from "coral-framework/schema";
|
||||
|
||||
import { withRouteConfig } from "coral-framework/lib/router";
|
||||
import EmptyMessage from "./EmptyMessage";
|
||||
import LoadingQueue from "./LoadingQueue";
|
||||
import Queue from "./Queue";
|
||||
import QueueCommentEnteredSubscription from "./QueueCommentEnteredSubscription";
|
||||
import QueueCommentLeftSubscription from "./QueueCommentLeftSubscription";
|
||||
import QueueViewNewMutation from "./QueueViewNewMutation";
|
||||
|
||||
interface QueueRouteProps {
|
||||
queue: QueueData;
|
||||
settings: SettingsData;
|
||||
interface Props {
|
||||
isLoading: boolean;
|
||||
queueName: GQLMODERATION_QUEUE;
|
||||
queue: QueueRoute_queue | null;
|
||||
settings: QueueRoute_settings | null;
|
||||
viewer: QueueRoute_viewer | null;
|
||||
relay: RelayPaginationProp;
|
||||
emptyElement: React.ReactElement;
|
||||
storyID?: string;
|
||||
@@ -25,136 +39,174 @@ interface QueueRouteProps {
|
||||
const danglingLogic = (status: string) =>
|
||||
["APPROVED", "REJECTED"].indexOf(status) >= 0;
|
||||
|
||||
export class QueueRoute extends React.Component<QueueRouteProps> {
|
||||
public static routeConfig: RouteProps;
|
||||
|
||||
public state = {
|
||||
disableLoadMore: false,
|
||||
};
|
||||
|
||||
public render() {
|
||||
const comments = this.props.queue.comments.edges.map(edge => edge.node);
|
||||
return (
|
||||
<IntersectionProvider>
|
||||
<Queue
|
||||
comments={comments}
|
||||
settings={this.props.settings}
|
||||
onLoadMore={this.loadMore}
|
||||
hasMore={this.props.relay.hasMore()}
|
||||
disableLoadMore={this.state.disableLoadMore}
|
||||
danglingLogic={danglingLogic}
|
||||
emptyElement={this.props.emptyElement}
|
||||
allStories={!Boolean(this.props.storyID)}
|
||||
/>
|
||||
</IntersectionProvider>
|
||||
export const QueueRoute: FunctionComponent<Props> = props => {
|
||||
const [loadMore, isLoadingMore] = useLoadMore(props.relay, 10);
|
||||
const subscribeToQueueCommentEntered = useSubscription(
|
||||
QueueCommentEnteredSubscription
|
||||
);
|
||||
const subscribeToQueueCommentLeft = useSubscription(
|
||||
QueueCommentLeftSubscription
|
||||
);
|
||||
const viewNew = useMutation(QueueViewNewMutation);
|
||||
const onViewNew = useCallback(() => {
|
||||
viewNew({ queue: props.queueName, storyID: props.storyID || null });
|
||||
}, [props.queueName, props.storyID, viewNew]);
|
||||
useEffect(() => {
|
||||
const vars = {
|
||||
queue: props.queueName,
|
||||
storyID: props.storyID || null,
|
||||
};
|
||||
const disposable = combineDisposables(
|
||||
subscribeToQueueCommentEntered(vars),
|
||||
subscribeToQueueCommentLeft(vars)
|
||||
);
|
||||
return () => {
|
||||
disposable.dispose();
|
||||
};
|
||||
}, [
|
||||
props.storyID,
|
||||
props.queueName,
|
||||
subscribeToQueueCommentEntered,
|
||||
subscribeToQueueCommentLeft,
|
||||
]);
|
||||
if (props.isLoading) {
|
||||
return <LoadingQueue />;
|
||||
}
|
||||
|
||||
private loadMore = () => {
|
||||
if (!this.props.relay.hasMore() || this.props.relay.isLoading()) {
|
||||
return;
|
||||
}
|
||||
this.setState({ disableLoadMore: true });
|
||||
this.props.relay.loadMore(
|
||||
10, // Fetch the next 10 feed items
|
||||
error => {
|
||||
this.setState({ disableLoadMore: false });
|
||||
if (error) {
|
||||
// tslint:disable-next-line:no-console
|
||||
console.error(error);
|
||||
}
|
||||
}
|
||||
);
|
||||
};
|
||||
}
|
||||
const comments = props.queue!.comments.edges.map(edge => edge.node);
|
||||
const viewNewCount =
|
||||
(props.queue!.comments.viewNewEdges &&
|
||||
props.queue!.comments.viewNewEdges.length) ||
|
||||
0;
|
||||
return (
|
||||
<IntersectionProvider>
|
||||
<Queue
|
||||
comments={comments}
|
||||
viewer={props.viewer!}
|
||||
settings={props.settings!}
|
||||
onLoadMore={loadMore}
|
||||
hasLoadMore={props.relay.hasMore()}
|
||||
disableLoadMore={isLoadingMore}
|
||||
danglingLogic={danglingLogic}
|
||||
emptyElement={props.emptyElement}
|
||||
allStories={!Boolean(props.storyID)}
|
||||
viewNewCount={viewNewCount}
|
||||
onViewNew={onViewNew}
|
||||
/>
|
||||
</IntersectionProvider>
|
||||
);
|
||||
};
|
||||
|
||||
// TODO: (cvle) If this could be autogenerated..
|
||||
type FragmentVariables = QueueRoutePaginationPendingQueryVariables;
|
||||
|
||||
const createQueueRoute = (
|
||||
queueName: GQLMODERATION_QUEUE,
|
||||
queueQuery: GraphQLTaggedNode,
|
||||
paginationQuery: GraphQLTaggedNode,
|
||||
emptyElement: React.ReactElement
|
||||
) => {
|
||||
const enhanced = (withPaginationContainer<
|
||||
QueueRouteProps,
|
||||
QueueRoutePaginationPendingQueryVariables,
|
||||
FragmentVariables
|
||||
>(
|
||||
{
|
||||
queue: graphql`
|
||||
fragment QueueRoute_queue on ModerationQueue
|
||||
@argumentDefinitions(
|
||||
count: { type: "Int!", defaultValue: 5 }
|
||||
cursor: { type: "Cursor" }
|
||||
) {
|
||||
count
|
||||
comments(first: $count, after: $cursor)
|
||||
@connection(key: "Queue_comments") {
|
||||
edges {
|
||||
node {
|
||||
id
|
||||
...ModerateCardContainer_comment
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
settings: graphql`
|
||||
fragment QueueRoute_settings on Settings {
|
||||
...ModerateCardContainer_settings
|
||||
}
|
||||
`,
|
||||
},
|
||||
{
|
||||
direction: "forward",
|
||||
getConnectionFromProps(props) {
|
||||
return props.queue && props.queue.comments;
|
||||
},
|
||||
// This is also the default implementation of `getFragmentVariables` if it isn't provided.
|
||||
getFragmentVariables(prevVars, totalCount) {
|
||||
return {
|
||||
...prevVars,
|
||||
count: totalCount,
|
||||
};
|
||||
},
|
||||
getVariables(props, { count, cursor }, fragmentVariables) {
|
||||
return {
|
||||
...fragmentVariables,
|
||||
count,
|
||||
cursor,
|
||||
};
|
||||
},
|
||||
query: paginationQuery,
|
||||
}
|
||||
)(QueueRoute) as any) as typeof QueueRoute;
|
||||
|
||||
enhanced.routeConfig = {
|
||||
Component: enhanced,
|
||||
const enhanced = withRouteConfig<Props, any>({
|
||||
query: queueQuery,
|
||||
cacheConfig: { force: true },
|
||||
render: ({ Component, props, match }) => {
|
||||
const anyProps = props as any;
|
||||
if (Component && props) {
|
||||
const queue =
|
||||
anyProps.moderationQueues[Object.keys(anyProps.moderationQueues)[0]];
|
||||
render: ({ Component, data, match }) => {
|
||||
if (!Component) {
|
||||
throw new Error("Missing component");
|
||||
}
|
||||
if (!data || !data.moderationQueues) {
|
||||
return (
|
||||
<Component
|
||||
queue={queue}
|
||||
settings={anyProps.settings}
|
||||
isLoading
|
||||
queueName={queueName}
|
||||
queue={null}
|
||||
settings={null}
|
||||
viewer={null}
|
||||
emptyElement={emptyElement}
|
||||
storyID={match.params.storyID}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return <LoadingQueue />;
|
||||
const queue =
|
||||
data.moderationQueues[Object.keys(data.moderationQueues)[0]];
|
||||
return (
|
||||
<Component
|
||||
isLoading={false}
|
||||
queueName={queueName}
|
||||
queue={queue}
|
||||
settings={data.settings}
|
||||
viewer={data.viewer}
|
||||
emptyElement={emptyElement}
|
||||
storyID={match.params.storyID}
|
||||
/>
|
||||
);
|
||||
},
|
||||
};
|
||||
})(
|
||||
withPaginationContainer<
|
||||
Props,
|
||||
QueueRoutePaginationPendingQueryVariables,
|
||||
FragmentVariables
|
||||
>(
|
||||
{
|
||||
queue: graphql`
|
||||
fragment QueueRoute_queue on ModerationQueue
|
||||
@argumentDefinitions(
|
||||
count: { type: "Int!", defaultValue: 5 }
|
||||
cursor: { type: "Cursor" }
|
||||
) {
|
||||
count
|
||||
comments(first: $count, after: $cursor)
|
||||
@connection(key: "Queue_comments") {
|
||||
viewNewEdges {
|
||||
cursor
|
||||
}
|
||||
edges {
|
||||
node {
|
||||
id
|
||||
...ModerateCardContainer_comment
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
settings: graphql`
|
||||
fragment QueueRoute_settings on Settings {
|
||||
...ModerateCardContainer_settings
|
||||
}
|
||||
`,
|
||||
viewer: graphql`
|
||||
fragment QueueRoute_viewer on User {
|
||||
...ModerateCardContainer_viewer
|
||||
}
|
||||
`,
|
||||
},
|
||||
{
|
||||
direction: "forward",
|
||||
getConnectionFromProps(props) {
|
||||
return props.queue && props.queue.comments;
|
||||
},
|
||||
// This is also the default implementation of `getFragmentVariables` if it isn't provided.
|
||||
getFragmentVariables(prevVars, totalCount) {
|
||||
return {
|
||||
...prevVars,
|
||||
count: totalCount,
|
||||
};
|
||||
},
|
||||
getVariables(props, { count, cursor }, fragmentVariables) {
|
||||
return {
|
||||
...fragmentVariables,
|
||||
count,
|
||||
cursor,
|
||||
};
|
||||
},
|
||||
query: paginationQuery,
|
||||
}
|
||||
)(QueueRoute)
|
||||
);
|
||||
|
||||
return enhanced;
|
||||
};
|
||||
|
||||
export const PendingQueueRoute = createQueueRoute(
|
||||
GQLMODERATION_QUEUE.PENDING,
|
||||
graphql`
|
||||
query QueueRoutePendingQuery($storyID: ID) {
|
||||
moderationQueues(storyID: $storyID) {
|
||||
@@ -165,6 +217,9 @@ export const PendingQueueRoute = createQueueRoute(
|
||||
settings {
|
||||
...QueueRoute_settings
|
||||
}
|
||||
viewer {
|
||||
...QueueRoute_viewer
|
||||
}
|
||||
}
|
||||
`,
|
||||
graphql`
|
||||
@@ -191,6 +246,7 @@ export const PendingQueueRoute = createQueueRoute(
|
||||
);
|
||||
|
||||
export const ReportedQueueRoute = createQueueRoute(
|
||||
GQLMODERATION_QUEUE.REPORTED,
|
||||
graphql`
|
||||
query QueueRouteReportedQuery($storyID: ID) {
|
||||
moderationQueues(storyID: $storyID) {
|
||||
@@ -201,6 +257,9 @@ export const ReportedQueueRoute = createQueueRoute(
|
||||
settings {
|
||||
...QueueRoute_settings
|
||||
}
|
||||
viewer {
|
||||
...QueueRoute_viewer
|
||||
}
|
||||
}
|
||||
`,
|
||||
graphql`
|
||||
@@ -227,6 +286,7 @@ export const ReportedQueueRoute = createQueueRoute(
|
||||
);
|
||||
|
||||
export const UnmoderatedQueueRoute = createQueueRoute(
|
||||
GQLMODERATION_QUEUE.UNMODERATED,
|
||||
graphql`
|
||||
query QueueRouteUnmoderatedQuery($storyID: ID) {
|
||||
moderationQueues(storyID: $storyID) {
|
||||
@@ -237,6 +297,9 @@ export const UnmoderatedQueueRoute = createQueueRoute(
|
||||
settings {
|
||||
...QueueRoute_settings
|
||||
}
|
||||
viewer {
|
||||
...QueueRoute_viewer
|
||||
}
|
||||
}
|
||||
`,
|
||||
graphql`
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import { ConnectionHandler, Environment } from "relay-runtime";
|
||||
|
||||
import { getQueueConnection } from "coral-admin/helpers";
|
||||
import {
|
||||
commitLocalUpdatePromisified,
|
||||
createMutation,
|
||||
} from "coral-framework/lib/relay";
|
||||
import { GQLMODERATION_QUEUE } from "coral-framework/schema";
|
||||
|
||||
interface QueueViewNewInput {
|
||||
storyID: string | null;
|
||||
queue: GQLMODERATION_QUEUE;
|
||||
}
|
||||
|
||||
const QueueViewNewMutation = createMutation(
|
||||
"viewNew",
|
||||
async (environment: Environment, input: QueueViewNewInput) => {
|
||||
await commitLocalUpdatePromisified(environment, async store => {
|
||||
const connection = getQueueConnection(store, input.queue, input.storyID);
|
||||
if (!connection) {
|
||||
return;
|
||||
}
|
||||
const viewNewEdges = connection.getLinkedRecords("viewNewEdges");
|
||||
if (!viewNewEdges || viewNewEdges.length === 0) {
|
||||
return;
|
||||
}
|
||||
viewNewEdges.forEach(edge => {
|
||||
ConnectionHandler.insertEdgeBefore(connection, edge);
|
||||
});
|
||||
connection.setLinkedRecords([], "viewNewEdges");
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
export default QueueViewNewMutation;
|
||||
@@ -3,7 +3,7 @@ import { RouteProps } from "found";
|
||||
import React from "react";
|
||||
import { graphql, RelayPaginationProp } from "react-relay";
|
||||
|
||||
import { RejectedQueueRoute_query as QueryData } from "coral-admin/__generated__/RejectedQueueRoute_query.graphql";
|
||||
import { RejectedQueueRoute_query } from "coral-admin/__generated__/RejectedQueueRoute_query.graphql";
|
||||
import { RejectedQueueRoutePaginationQueryVariables } from "coral-admin/__generated__/RejectedQueueRoutePaginationQuery.graphql";
|
||||
import { IntersectionProvider } from "coral-framework/lib/intersection";
|
||||
import { withPaginationContainer } from "coral-framework/lib/relay";
|
||||
@@ -13,7 +13,7 @@ import LoadingQueue from "./LoadingQueue";
|
||||
import Queue from "./Queue";
|
||||
|
||||
interface RejectedQueueRouteProps {
|
||||
query: QueryData;
|
||||
query: RejectedQueueRoute_query;
|
||||
relay: RelayPaginationProp;
|
||||
storyID?: string;
|
||||
}
|
||||
@@ -35,10 +35,11 @@ export class RejectedQueueRoute extends React.Component<
|
||||
return (
|
||||
<IntersectionProvider>
|
||||
<Queue
|
||||
viewer={this.props.query.viewer!}
|
||||
settings={this.props.query.settings}
|
||||
comments={comments}
|
||||
onLoadMore={this.loadMore}
|
||||
hasMore={this.props.relay.hasMore()}
|
||||
hasLoadMore={this.props.relay.hasMore()}
|
||||
disableLoadMore={this.state.disableLoadMore}
|
||||
danglingLogic={danglingLogic}
|
||||
emptyElement={
|
||||
@@ -102,6 +103,9 @@ const enhanced = (withPaginationContainer<
|
||||
settings {
|
||||
...ModerateCardContainer_settings
|
||||
}
|
||||
viewer {
|
||||
...ModerateCardContainer_viewer
|
||||
}
|
||||
}
|
||||
`,
|
||||
},
|
||||
|
||||
@@ -1,42 +1,53 @@
|
||||
import { RouteProps } from "found";
|
||||
import { noop } from "lodash";
|
||||
import React from "react";
|
||||
import React, { FunctionComponent, useEffect } from "react";
|
||||
import { graphql } from "react-relay";
|
||||
|
||||
import { SingleModerateRouteQueryResponse } from "coral-admin/__generated__/SingleModerateRouteQuery.graphql";
|
||||
import { useSubscription } from "coral-framework/lib/relay";
|
||||
import { withRouteConfig } from "coral-framework/lib/router";
|
||||
|
||||
import NotFound from "../../NotFound";
|
||||
import { LoadingQueue, Queue } from "../Queue";
|
||||
import SingleModerate from "./SingleModerate";
|
||||
import SingleModerateSubscription from "./SingleModerateSubscription";
|
||||
|
||||
type Props = SingleModerateRouteQueryResponse;
|
||||
|
||||
const danglingLogic = () => false;
|
||||
|
||||
export default class SingleModerateRoute extends React.Component<Props> {
|
||||
public static routeConfig: RouteProps;
|
||||
|
||||
public render() {
|
||||
if (!this.props.comment) {
|
||||
return <NotFound />;
|
||||
const SingleModerateRoute: FunctionComponent<Props> = props => {
|
||||
const subscribeToSingleModerate = useSubscription(SingleModerateSubscription);
|
||||
useEffect(() => {
|
||||
if (!props.comment) {
|
||||
return;
|
||||
}
|
||||
return (
|
||||
<SingleModerate>
|
||||
<Queue
|
||||
comments={[this.props.comment]}
|
||||
settings={this.props.settings}
|
||||
onLoadMore={noop}
|
||||
hasMore={false}
|
||||
disableLoadMore={false}
|
||||
danglingLogic={danglingLogic}
|
||||
/>
|
||||
</SingleModerate>
|
||||
);
|
||||
}
|
||||
}
|
||||
const disposable = subscribeToSingleModerate({
|
||||
commentID: props.comment.id,
|
||||
});
|
||||
return () => {
|
||||
disposable.dispose();
|
||||
};
|
||||
}, [props.comment, subscribeToSingleModerate]);
|
||||
|
||||
SingleModerateRoute.routeConfig = {
|
||||
Component: SingleModerateRoute,
|
||||
if (!props.comment) {
|
||||
return <NotFound />;
|
||||
}
|
||||
return (
|
||||
<SingleModerate>
|
||||
<Queue
|
||||
comments={[props.comment]}
|
||||
settings={props.settings}
|
||||
viewer={props.viewer!}
|
||||
onLoadMore={noop}
|
||||
hasLoadMore={false}
|
||||
disableLoadMore={false}
|
||||
danglingLogic={danglingLogic}
|
||||
/>
|
||||
</SingleModerate>
|
||||
);
|
||||
};
|
||||
|
||||
const enhanced = withRouteConfig<Props, SingleModerateRouteQueryResponse>({
|
||||
query: graphql`
|
||||
query SingleModerateRouteQuery($commentID: ID!) {
|
||||
comment(id: $commentID) {
|
||||
@@ -46,13 +57,18 @@ SingleModerateRoute.routeConfig = {
|
||||
settings {
|
||||
...ModerateCardContainer_settings
|
||||
}
|
||||
viewer {
|
||||
...ModerateCardContainer_viewer
|
||||
}
|
||||
}
|
||||
`,
|
||||
cacheConfig: { force: true },
|
||||
render: ({ Component, props }) => {
|
||||
if (Component && props) {
|
||||
return <Component {...props} />;
|
||||
render: ({ Component, data }) => {
|
||||
if (Component && data) {
|
||||
return <Component {...data} />;
|
||||
}
|
||||
return <LoadingQueue />;
|
||||
},
|
||||
};
|
||||
})(SingleModerateRoute);
|
||||
|
||||
export default enhanced;
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import { graphql, requestSubscription } from "react-relay";
|
||||
import { Environment } from "relay-runtime";
|
||||
|
||||
import { SingleModerateSubscription } from "coral-admin/__generated__/SingleModerateSubscription.graphql";
|
||||
import {
|
||||
createSubscription,
|
||||
SubscriptionVariables,
|
||||
} from "coral-framework/lib/relay";
|
||||
|
||||
const SingleModerateSubscription = createSubscription(
|
||||
"subscribeToSingleModerate",
|
||||
(
|
||||
environment: Environment,
|
||||
variables: SubscriptionVariables<SingleModerateSubscription>
|
||||
) =>
|
||||
requestSubscription(environment, {
|
||||
subscription: graphql`
|
||||
subscription SingleModerateSubscription($commentID: ID!) {
|
||||
commentStatusUpdated(id: $commentID) {
|
||||
comment {
|
||||
id
|
||||
status
|
||||
...MarkersContainer_comment @relay(mask: false)
|
||||
...ModeratedByContainer_comment @relay(mask: false)
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
variables,
|
||||
updater: store => {
|
||||
const commentID = store
|
||||
.getRootField("commentStatusUpdated")!
|
||||
.getLinkedRecord("comment")!
|
||||
.getValue("id")!;
|
||||
const commentInStore = store.get(commentID);
|
||||
if (commentInStore) {
|
||||
// Mark that the status of the comment was live updated.
|
||||
commentInStore.setValue(true, "statusLiveUpdated");
|
||||
}
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
export default SingleModerateSubscription;
|
||||
@@ -418,8 +418,12 @@ export const baseComment = createFixture<GQLComment>({
|
||||
author: users.commenters[0],
|
||||
body: "Comment Body",
|
||||
createdAt: "2018-07-06T18:24:00.000Z",
|
||||
status: GQLCOMMENT_STATUS.NONE,
|
||||
tags: [],
|
||||
status: GQLCOMMENT_STATUS.NONE,
|
||||
statusHistory: {
|
||||
edges: [],
|
||||
pageInfo: { endCursor: null, hasNextPage: false },
|
||||
},
|
||||
actionCounts: {
|
||||
flag: {
|
||||
reasons: {
|
||||
@@ -438,6 +442,8 @@ export const baseComment = createFixture<GQLComment>({
|
||||
nodes: [],
|
||||
},
|
||||
story: stories[0],
|
||||
// TODO: Should be allowed to pass null here..
|
||||
parent: undefined,
|
||||
});
|
||||
|
||||
export const unmoderatedComments = createFixtures<GQLComment>(
|
||||
@@ -577,6 +583,32 @@ export const reportedComments = createFixtures<GQLComment>(
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "comment-3",
|
||||
revision: {
|
||||
id: "comment-3-revision-3",
|
||||
},
|
||||
permalink: "http://localhost/comment/3",
|
||||
status: GQLCOMMENT_STATUS.PREMOD,
|
||||
author: users.commenters[3],
|
||||
body: "World peace at last",
|
||||
actionCounts: {
|
||||
flag: {
|
||||
reasons: {
|
||||
COMMENT_REPORTED_SPAM: 1,
|
||||
},
|
||||
},
|
||||
},
|
||||
flags: {
|
||||
nodes: [
|
||||
{
|
||||
reason: GQLCOMMENT_FLAG_REASON.COMMENT_REPORTED_SPAM,
|
||||
flagger: users.commenters[2],
|
||||
additionalDetails: "",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
baseComment
|
||||
);
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,849 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`approves comment in rejected queue: count should be 1 1`] = `
|
||||
<span
|
||||
className="Counter-root Counter-colorInherit"
|
||||
data-testid="moderate-navigation-reported-count"
|
||||
>
|
||||
<span
|
||||
className="Counter-text"
|
||||
>
|
||||
1
|
||||
</span>
|
||||
</span>
|
||||
`;
|
||||
|
||||
exports[`approves comment in rejected queue: dangling 1`] = `
|
||||
<div
|
||||
className="Card-root ModerateCard-root ModerateCard-dangling"
|
||||
data-testid="moderate-comment-comment-0"
|
||||
>
|
||||
<div
|
||||
className="Box-root Flex-root Flex-flex"
|
||||
>
|
||||
<div
|
||||
className="ModerateCard-mainContainer"
|
||||
>
|
||||
<div
|
||||
className="ModerateCard-topBar"
|
||||
>
|
||||
<div>
|
||||
<span
|
||||
className="Box-root Typography-root Typography-heading4 Typography-colorTextPrimary ModerateCard-username Username-root"
|
||||
>
|
||||
Isabelle
|
||||
</span>
|
||||
<time
|
||||
className="Timestamp-root RelativeTime-root"
|
||||
dateTime="2018-07-06T18:24:00.000Z"
|
||||
title="2018-07-06T18:24:00.000Z"
|
||||
>
|
||||
2018-07-06T18:24:00.000Z
|
||||
</time>
|
||||
<button
|
||||
className="BaseButton-root FeatureButton-root"
|
||||
onBlur={[Function]}
|
||||
onClick={[Function]}
|
||||
onFocus={[Function]}
|
||||
onMouseOut={[Function]}
|
||||
onMouseOver={[Function]}
|
||||
onTouchEnd={[Function]}
|
||||
type="button"
|
||||
>
|
||||
<span>
|
||||
Feature
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className="Box-root Typography-root Typography-bodyCopy Typography-colorTextPrimary ModerateCard-content CommentContent-root"
|
||||
dangerouslySetInnerHTML={
|
||||
Object {
|
||||
"__html": "This is the last random sentence I will be writing and I am going to stop mid-sent",
|
||||
}
|
||||
}
|
||||
/>
|
||||
<div
|
||||
className="ModerateCard-footer"
|
||||
>
|
||||
<div
|
||||
className="Box-root HorizontalGutter-root HorizontalGutter-full"
|
||||
>
|
||||
<div>
|
||||
<a
|
||||
className="TextLink-root ModerateCard-link"
|
||||
href="http://localhost/comment/0"
|
||||
target="_blank"
|
||||
>
|
||||
View Context
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="Icon-root Icon-xs TextLink-icon"
|
||||
>
|
||||
open_in_new
|
||||
</span>
|
||||
</a>
|
||||
</div>
|
||||
<div>
|
||||
<span
|
||||
className="ModerateCard-story"
|
||||
>
|
||||
Story
|
||||
</span>
|
||||
:
|
||||
<span
|
||||
className="ModerateCard-storyTitle"
|
||||
>
|
||||
Finally a Cure for Cancer
|
||||
</span>
|
||||
|
||||
<a
|
||||
className="TextLink-root ModerateCard-link"
|
||||
href="/admin/moderate/story-1"
|
||||
onClick={[Function]}
|
||||
>
|
||||
Moderate Story
|
||||
</a>
|
||||
</div>
|
||||
<div
|
||||
className="Box-root HorizontalGutter-root HorizontalGutter-double"
|
||||
>
|
||||
<div
|
||||
className="Box-root Flex-root Flex-flex"
|
||||
>
|
||||
<div
|
||||
className="Box-root Flex-root Flex-flex Flex-itemGutter gutter"
|
||||
>
|
||||
<span
|
||||
className="Marker-root Marker-colorError Marker-variantRegular"
|
||||
>
|
||||
<span>
|
||||
Spam
|
||||
</span>
|
||||
|
||||
<span
|
||||
className="Count-root"
|
||||
>
|
||||
2
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
aria-controls="uuid-0"
|
||||
aria-expanded={false}
|
||||
className="BaseButton-root Button-root Button-sizeSmall Button-colorRegular Markers-detailsButtonColorRegular Button-variantRegular Markers-detailsButton"
|
||||
onBlur={[Function]}
|
||||
onClick={[Function]}
|
||||
onFocus={[Function]}
|
||||
onMouseOut={[Function]}
|
||||
onMouseOver={[Function]}
|
||||
onTouchEnd={[Function]}
|
||||
type="button"
|
||||
>
|
||||
<span
|
||||
className="Markers-detailsText"
|
||||
>
|
||||
Details
|
||||
</span>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="Icon-root Icon-sm"
|
||||
>
|
||||
arrow_drop_down
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className="ModerateCard-separator"
|
||||
/>
|
||||
<div
|
||||
className="Box-root Flex-root ModerateCard-aside ModerateCard-asideWithoutReplyTo Flex-flex Flex-itemGutter Flex-alignCenter Flex-directionColumn gutter"
|
||||
>
|
||||
<div
|
||||
className="ModerateCard-decision"
|
||||
>
|
||||
Decision
|
||||
</div>
|
||||
<div
|
||||
className="Box-root Flex-root Flex-flex Flex-itemGutter gutter"
|
||||
>
|
||||
<button
|
||||
aria-label="Reject"
|
||||
className="BaseButton-root RejectButton-root"
|
||||
disabled={true}
|
||||
onBlur={[Function]}
|
||||
onClick={[Function]}
|
||||
onFocus={[Function]}
|
||||
onMouseOut={[Function]}
|
||||
onMouseOver={[Function]}
|
||||
onTouchEnd={[Function]}
|
||||
type="button"
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="Icon-root Icon-lg RejectButton-icon"
|
||||
>
|
||||
close
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
aria-label="Approve"
|
||||
className="BaseButton-root ApproveButton-root ApproveButton-invert"
|
||||
disabled={true}
|
||||
onBlur={[Function]}
|
||||
onClick={[Function]}
|
||||
onFocus={[Function]}
|
||||
onMouseOut={[Function]}
|
||||
onMouseOver={[Function]}
|
||||
onTouchEnd={[Function]}
|
||||
type="button"
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="Icon-root Icon-lg ApproveButton-icon"
|
||||
>
|
||||
done
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`renders rejected queue with comments 1`] = `
|
||||
<div
|
||||
className="MainLayout-root"
|
||||
data-testid="moderate-main-container"
|
||||
>
|
||||
<main
|
||||
className="Moderate-main"
|
||||
>
|
||||
<div
|
||||
className="Box-root HorizontalGutter-root Queue-root HorizontalGutter-double"
|
||||
>
|
||||
<div
|
||||
className="Card-root ModerateCard-root"
|
||||
data-testid="moderate-comment-comment-0"
|
||||
>
|
||||
<div
|
||||
className="Box-root Flex-root Flex-flex"
|
||||
>
|
||||
<div
|
||||
className="ModerateCard-mainContainer"
|
||||
>
|
||||
<div
|
||||
className="ModerateCard-topBar"
|
||||
>
|
||||
<div>
|
||||
<span
|
||||
className="Box-root Typography-root Typography-heading4 Typography-colorTextPrimary ModerateCard-username Username-root"
|
||||
>
|
||||
Isabelle
|
||||
</span>
|
||||
<time
|
||||
className="Timestamp-root RelativeTime-root"
|
||||
dateTime="2018-07-06T18:24:00.000Z"
|
||||
title="2018-07-06T18:24:00.000Z"
|
||||
>
|
||||
2018-07-06T18:24:00.000Z
|
||||
</time>
|
||||
<button
|
||||
className="BaseButton-root FeatureButton-root"
|
||||
onBlur={[Function]}
|
||||
onClick={[Function]}
|
||||
onFocus={[Function]}
|
||||
onMouseOut={[Function]}
|
||||
onMouseOver={[Function]}
|
||||
onTouchEnd={[Function]}
|
||||
type="button"
|
||||
>
|
||||
<span>
|
||||
Feature
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className="Box-root Typography-root Typography-bodyCopy Typography-colorTextPrimary ModerateCard-content CommentContent-root"
|
||||
dangerouslySetInnerHTML={
|
||||
Object {
|
||||
"__html": "This is the last random sentence I will be writing and I am going to stop mid-sent",
|
||||
}
|
||||
}
|
||||
/>
|
||||
<div
|
||||
className="ModerateCard-footer"
|
||||
>
|
||||
<div
|
||||
className="Box-root HorizontalGutter-root HorizontalGutter-full"
|
||||
>
|
||||
<div>
|
||||
<a
|
||||
className="TextLink-root ModerateCard-link"
|
||||
href="http://localhost/comment/0"
|
||||
target="_blank"
|
||||
>
|
||||
View Context
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="Icon-root Icon-xs TextLink-icon"
|
||||
>
|
||||
open_in_new
|
||||
</span>
|
||||
</a>
|
||||
</div>
|
||||
<div>
|
||||
<span
|
||||
className="ModerateCard-story"
|
||||
>
|
||||
Story
|
||||
</span>
|
||||
:
|
||||
<span
|
||||
className="ModerateCard-storyTitle"
|
||||
>
|
||||
Finally a Cure for Cancer
|
||||
</span>
|
||||
|
||||
<a
|
||||
className="TextLink-root ModerateCard-link"
|
||||
href="/admin/moderate/story-1"
|
||||
onClick={[Function]}
|
||||
>
|
||||
Moderate Story
|
||||
</a>
|
||||
</div>
|
||||
<div
|
||||
className="Box-root HorizontalGutter-root HorizontalGutter-double"
|
||||
>
|
||||
<div
|
||||
className="Box-root Flex-root Flex-flex"
|
||||
>
|
||||
<div
|
||||
className="Box-root Flex-root Flex-flex Flex-itemGutter gutter"
|
||||
>
|
||||
<span
|
||||
className="Marker-root Marker-colorError Marker-variantRegular"
|
||||
>
|
||||
<span>
|
||||
Spam
|
||||
</span>
|
||||
|
||||
<span
|
||||
className="Count-root"
|
||||
>
|
||||
2
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
aria-controls="uuid-0"
|
||||
aria-expanded={false}
|
||||
className="BaseButton-root Button-root Button-sizeSmall Button-colorRegular Markers-detailsButtonColorRegular Button-variantRegular Markers-detailsButton"
|
||||
onBlur={[Function]}
|
||||
onClick={[Function]}
|
||||
onFocus={[Function]}
|
||||
onMouseOut={[Function]}
|
||||
onMouseOver={[Function]}
|
||||
onTouchEnd={[Function]}
|
||||
type="button"
|
||||
>
|
||||
<span
|
||||
className="Markers-detailsText"
|
||||
>
|
||||
Details
|
||||
</span>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="Icon-root Icon-sm"
|
||||
>
|
||||
arrow_drop_down
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className="ModerateCard-separator"
|
||||
/>
|
||||
<div
|
||||
className="Box-root Flex-root ModerateCard-aside ModerateCard-asideWithoutReplyTo Flex-flex Flex-itemGutter Flex-alignCenter Flex-directionColumn gutter"
|
||||
>
|
||||
<div
|
||||
className="ModerateCard-decision"
|
||||
>
|
||||
Decision
|
||||
</div>
|
||||
<div
|
||||
className="Box-root Flex-root Flex-flex Flex-itemGutter gutter"
|
||||
>
|
||||
<button
|
||||
aria-label="Reject"
|
||||
className="BaseButton-root RejectButton-root RejectButton-invert"
|
||||
disabled={true}
|
||||
onBlur={[Function]}
|
||||
onClick={[Function]}
|
||||
onFocus={[Function]}
|
||||
onMouseOut={[Function]}
|
||||
onMouseOver={[Function]}
|
||||
onTouchEnd={[Function]}
|
||||
type="button"
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="Icon-root Icon-lg RejectButton-icon"
|
||||
>
|
||||
close
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
aria-label="Approve"
|
||||
className="BaseButton-root ApproveButton-root"
|
||||
disabled={false}
|
||||
onBlur={[Function]}
|
||||
onClick={[Function]}
|
||||
onFocus={[Function]}
|
||||
onMouseOut={[Function]}
|
||||
onMouseOver={[Function]}
|
||||
onTouchEnd={[Function]}
|
||||
type="button"
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="Icon-root Icon-lg ApproveButton-icon"
|
||||
>
|
||||
done
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className="Card-root ModerateCard-root"
|
||||
data-testid="moderate-comment-comment-1"
|
||||
>
|
||||
<div
|
||||
className="Box-root Flex-root Flex-flex"
|
||||
>
|
||||
<div
|
||||
className="ModerateCard-mainContainer"
|
||||
>
|
||||
<div
|
||||
className="ModerateCard-topBar"
|
||||
>
|
||||
<div>
|
||||
<span
|
||||
className="Box-root Typography-root Typography-heading4 Typography-colorTextPrimary ModerateCard-username Username-root"
|
||||
>
|
||||
Ngoc
|
||||
</span>
|
||||
<time
|
||||
className="Timestamp-root RelativeTime-root"
|
||||
dateTime="2018-07-06T18:24:00.000Z"
|
||||
title="2018-07-06T18:24:00.000Z"
|
||||
>
|
||||
2018-07-06T18:24:00.000Z
|
||||
</time>
|
||||
<button
|
||||
className="BaseButton-root FeatureButton-root"
|
||||
onBlur={[Function]}
|
||||
onClick={[Function]}
|
||||
onFocus={[Function]}
|
||||
onMouseOut={[Function]}
|
||||
onMouseOver={[Function]}
|
||||
onTouchEnd={[Function]}
|
||||
type="button"
|
||||
>
|
||||
<span>
|
||||
Feature
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className="Box-root Typography-root Typography-bodyCopy Typography-colorTextPrimary ModerateCard-content CommentContent-root"
|
||||
dangerouslySetInnerHTML={
|
||||
Object {
|
||||
"__html": "Don't fool with me",
|
||||
}
|
||||
}
|
||||
/>
|
||||
<div
|
||||
className="ModerateCard-footer"
|
||||
>
|
||||
<div
|
||||
className="Box-root HorizontalGutter-root HorizontalGutter-full"
|
||||
>
|
||||
<div>
|
||||
<a
|
||||
className="TextLink-root ModerateCard-link"
|
||||
href="http://localhost/comment/1"
|
||||
target="_blank"
|
||||
>
|
||||
View Context
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="Icon-root Icon-xs TextLink-icon"
|
||||
>
|
||||
open_in_new
|
||||
</span>
|
||||
</a>
|
||||
</div>
|
||||
<div>
|
||||
<span
|
||||
className="ModerateCard-story"
|
||||
>
|
||||
Story
|
||||
</span>
|
||||
:
|
||||
<span
|
||||
className="ModerateCard-storyTitle"
|
||||
>
|
||||
Finally a Cure for Cancer
|
||||
</span>
|
||||
|
||||
<a
|
||||
className="TextLink-root ModerateCard-link"
|
||||
href="/admin/moderate/story-1"
|
||||
onClick={[Function]}
|
||||
>
|
||||
Moderate Story
|
||||
</a>
|
||||
</div>
|
||||
<div
|
||||
className="Box-root HorizontalGutter-root HorizontalGutter-double"
|
||||
>
|
||||
<div
|
||||
className="Box-root Flex-root Flex-flex"
|
||||
>
|
||||
<div
|
||||
className="Box-root Flex-root Flex-flex Flex-itemGutter gutter"
|
||||
>
|
||||
<span
|
||||
className="Marker-root Marker-colorError Marker-variantRegular"
|
||||
>
|
||||
<span>
|
||||
Offensive
|
||||
</span>
|
||||
|
||||
<span
|
||||
className="Count-root"
|
||||
>
|
||||
3
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
aria-controls="uuid-1"
|
||||
aria-expanded={false}
|
||||
className="BaseButton-root Button-root Button-sizeSmall Button-colorRegular Markers-detailsButtonColorRegular Button-variantRegular Markers-detailsButton"
|
||||
onBlur={[Function]}
|
||||
onClick={[Function]}
|
||||
onFocus={[Function]}
|
||||
onMouseOut={[Function]}
|
||||
onMouseOver={[Function]}
|
||||
onTouchEnd={[Function]}
|
||||
type="button"
|
||||
>
|
||||
<span
|
||||
className="Markers-detailsText"
|
||||
>
|
||||
Details
|
||||
</span>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="Icon-root Icon-sm"
|
||||
>
|
||||
arrow_drop_down
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className="ModerateCard-separator"
|
||||
/>
|
||||
<div
|
||||
className="Box-root Flex-root ModerateCard-aside ModerateCard-asideWithoutReplyTo Flex-flex Flex-itemGutter Flex-alignCenter Flex-directionColumn gutter"
|
||||
>
|
||||
<div
|
||||
className="ModerateCard-decision"
|
||||
>
|
||||
Decision
|
||||
</div>
|
||||
<div
|
||||
className="Box-root Flex-root Flex-flex Flex-itemGutter gutter"
|
||||
>
|
||||
<button
|
||||
aria-label="Reject"
|
||||
className="BaseButton-root RejectButton-root RejectButton-invert"
|
||||
disabled={true}
|
||||
onBlur={[Function]}
|
||||
onClick={[Function]}
|
||||
onFocus={[Function]}
|
||||
onMouseOut={[Function]}
|
||||
onMouseOver={[Function]}
|
||||
onTouchEnd={[Function]}
|
||||
type="button"
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="Icon-root Icon-lg RejectButton-icon"
|
||||
>
|
||||
close
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
aria-label="Approve"
|
||||
className="BaseButton-root ApproveButton-root"
|
||||
disabled={false}
|
||||
onBlur={[Function]}
|
||||
onClick={[Function]}
|
||||
onFocus={[Function]}
|
||||
onMouseOut={[Function]}
|
||||
onMouseOver={[Function]}
|
||||
onTouchEnd={[Function]}
|
||||
type="button"
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="Icon-root Icon-lg ApproveButton-icon"
|
||||
>
|
||||
done
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`renders rejected queue with comments and load more 1`] = `
|
||||
<div
|
||||
className="Card-root ModerateCard-root"
|
||||
data-testid="moderate-comment-comment-2"
|
||||
>
|
||||
<div
|
||||
className="Box-root Flex-root Flex-flex"
|
||||
>
|
||||
<div
|
||||
className="ModerateCard-mainContainer"
|
||||
>
|
||||
<div
|
||||
className="ModerateCard-topBar"
|
||||
>
|
||||
<div>
|
||||
<span
|
||||
className="Box-root Typography-root Typography-heading4 Typography-colorTextPrimary ModerateCard-username Username-root"
|
||||
>
|
||||
Max
|
||||
</span>
|
||||
<time
|
||||
className="Timestamp-root RelativeTime-root"
|
||||
dateTime="2018-07-06T18:24:00.000Z"
|
||||
title="2018-07-06T18:24:00.000Z"
|
||||
>
|
||||
2018-07-06T18:24:00.000Z
|
||||
</time>
|
||||
<button
|
||||
className="BaseButton-root FeatureButton-root"
|
||||
onBlur={[Function]}
|
||||
onClick={[Function]}
|
||||
onFocus={[Function]}
|
||||
onMouseOut={[Function]}
|
||||
onMouseOver={[Function]}
|
||||
onTouchEnd={[Function]}
|
||||
type="button"
|
||||
>
|
||||
<span>
|
||||
Feature
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className="Box-root Typography-root Typography-bodyCopy Typography-colorTextPrimary ModerateCard-content CommentContent-root"
|
||||
dangerouslySetInnerHTML={
|
||||
Object {
|
||||
"__html": "I think I deserve better",
|
||||
}
|
||||
}
|
||||
/>
|
||||
<div
|
||||
className="ModerateCard-footer"
|
||||
>
|
||||
<div
|
||||
className="Box-root HorizontalGutter-root HorizontalGutter-full"
|
||||
>
|
||||
<div>
|
||||
<a
|
||||
className="TextLink-root ModerateCard-link"
|
||||
href="http://localhost/comment/2"
|
||||
target="_blank"
|
||||
>
|
||||
View Context
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="Icon-root Icon-xs TextLink-icon"
|
||||
>
|
||||
open_in_new
|
||||
</span>
|
||||
</a>
|
||||
</div>
|
||||
<div>
|
||||
<span
|
||||
className="ModerateCard-story"
|
||||
>
|
||||
Story
|
||||
</span>
|
||||
:
|
||||
<span
|
||||
className="ModerateCard-storyTitle"
|
||||
>
|
||||
Finally a Cure for Cancer
|
||||
</span>
|
||||
|
||||
<a
|
||||
className="TextLink-root ModerateCard-link"
|
||||
href="/admin/moderate/story-1"
|
||||
onClick={[Function]}
|
||||
>
|
||||
Moderate Story
|
||||
</a>
|
||||
</div>
|
||||
<div
|
||||
className="Box-root HorizontalGutter-root HorizontalGutter-double"
|
||||
>
|
||||
<div
|
||||
className="Box-root Flex-root Flex-flex"
|
||||
>
|
||||
<div
|
||||
className="Box-root Flex-root Flex-flex Flex-itemGutter gutter"
|
||||
>
|
||||
<span
|
||||
className="Marker-root Marker-colorError Marker-variantRegular"
|
||||
>
|
||||
<span>
|
||||
Offensive
|
||||
</span>
|
||||
|
||||
<span
|
||||
className="Count-root"
|
||||
>
|
||||
1
|
||||
</span>
|
||||
</span>
|
||||
<span
|
||||
className="Marker-root Marker-colorError Marker-variantRegular"
|
||||
>
|
||||
<span>
|
||||
Spam
|
||||
</span>
|
||||
|
||||
<span
|
||||
className="Count-root"
|
||||
>
|
||||
1
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
aria-controls="uuid-2"
|
||||
aria-expanded={false}
|
||||
className="BaseButton-root Button-root Button-sizeSmall Button-colorRegular Markers-detailsButtonColorRegular Button-variantRegular Markers-detailsButton"
|
||||
onBlur={[Function]}
|
||||
onClick={[Function]}
|
||||
onFocus={[Function]}
|
||||
onMouseOut={[Function]}
|
||||
onMouseOver={[Function]}
|
||||
onTouchEnd={[Function]}
|
||||
type="button"
|
||||
>
|
||||
<span
|
||||
className="Markers-detailsText"
|
||||
>
|
||||
Details
|
||||
</span>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="Icon-root Icon-sm"
|
||||
>
|
||||
arrow_drop_down
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className="ModerateCard-separator"
|
||||
/>
|
||||
<div
|
||||
className="Box-root Flex-root ModerateCard-aside ModerateCard-asideWithoutReplyTo Flex-flex Flex-itemGutter Flex-alignCenter Flex-directionColumn gutter"
|
||||
>
|
||||
<div
|
||||
className="ModerateCard-decision"
|
||||
>
|
||||
Decision
|
||||
</div>
|
||||
<div
|
||||
className="Box-root Flex-root Flex-flex Flex-itemGutter gutter"
|
||||
>
|
||||
<button
|
||||
aria-label="Reject"
|
||||
className="BaseButton-root RejectButton-root RejectButton-invert"
|
||||
disabled={true}
|
||||
onBlur={[Function]}
|
||||
onClick={[Function]}
|
||||
onFocus={[Function]}
|
||||
onMouseOut={[Function]}
|
||||
onMouseOver={[Function]}
|
||||
onTouchEnd={[Function]}
|
||||
type="button"
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="Icon-root Icon-lg RejectButton-icon"
|
||||
>
|
||||
close
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
aria-label="Approve"
|
||||
className="BaseButton-root ApproveButton-root"
|
||||
disabled={false}
|
||||
onBlur={[Function]}
|
||||
onClick={[Function]}
|
||||
onFocus={[Function]}
|
||||
onMouseOut={[Function]}
|
||||
onMouseOver={[Function]}
|
||||
onTouchEnd={[Function]}
|
||||
type="button"
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="Icon-root Icon-lg ApproveButton-icon"
|
||||
>
|
||||
done
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
@@ -0,0 +1,255 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`all stories active search with no results 1`] = `
|
||||
<div
|
||||
aria-expanded={true}
|
||||
aria-haspopup="listbox"
|
||||
aria-label="Search or jump to story"
|
||||
aria-owns="moderate-searchBar-listBox"
|
||||
className="SubBar-root Bar-root"
|
||||
data-testid="moderate-searchBar-container"
|
||||
role="combobox"
|
||||
>
|
||||
<div
|
||||
className="Box-root Flex-root SubBar-container Flex-flex Flex-justifyCenter Flex-alignCenter"
|
||||
>
|
||||
<div
|
||||
className="Backdrop-root Backdrop-active Bar-bumpZIndex"
|
||||
/>
|
||||
<form
|
||||
aria-label="Stories"
|
||||
className="Bar-bumpZIndex"
|
||||
onMouseDown={[Function]}
|
||||
onSubmit={[Function]}
|
||||
role="search"
|
||||
>
|
||||
<div
|
||||
className="Popover-root"
|
||||
>
|
||||
<div>
|
||||
<div
|
||||
className="Box-root Flex-root Field-root Flex-flex Flex-alignStretch"
|
||||
>
|
||||
<div
|
||||
className="Box-root Flex-root Field-begin Flex-flex Flex-alignCenter"
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="Icon-root Icon-md Field-searchIcon"
|
||||
>
|
||||
search
|
||||
</span>
|
||||
<div
|
||||
className="Field-beginStories"
|
||||
>
|
||||
Stories:
|
||||
</div>
|
||||
</div>
|
||||
<input
|
||||
aria-autocomplete="list"
|
||||
aria-controls="moderate-searchBar-listBox"
|
||||
aria-label="Search or jump to story..."
|
||||
autoComplete="off"
|
||||
className="Field-input"
|
||||
name="search"
|
||||
onBlur={[Function]}
|
||||
onChange={[Function]}
|
||||
onFocus={[Function]}
|
||||
onKeyDown={[Function]}
|
||||
placeholder="Use quotation marks around each search term (e.g. “team”, “St. Louis”)"
|
||||
spellCheck={false}
|
||||
value=""
|
||||
/>
|
||||
<div
|
||||
className="Box-root Flex-root Field-end Flex-flex Flex-alignCenter"
|
||||
>
|
||||
<button
|
||||
className="BaseButton-root Field-searchButton"
|
||||
disabled={true}
|
||||
onBlur={[Function]}
|
||||
onFocus={[Function]}
|
||||
onMouseOut={[Function]}
|
||||
onMouseOver={[Function]}
|
||||
onTouchEnd={[Function]}
|
||||
type="submit"
|
||||
>
|
||||
Search
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
aria-hidden={false}
|
||||
aria-labelledby="moderate-searchBar-popover-ariainfo"
|
||||
id="moderate-searchBar-popover"
|
||||
role="popup"
|
||||
>
|
||||
<div
|
||||
className="Popover-popover Bar-popover Popover-bottom"
|
||||
style={
|
||||
Object {
|
||||
"left": 0,
|
||||
"opacity": 0,
|
||||
"pointerEvents": "none",
|
||||
"position": "absolute",
|
||||
"top": 0,
|
||||
}
|
||||
}
|
||||
>
|
||||
<ul
|
||||
className="Bar-listBox"
|
||||
id="moderate-searchBar-listBox"
|
||||
role="listbox"
|
||||
>
|
||||
<ul
|
||||
aria-labelledby="moderate-searchBar-context-title"
|
||||
className="Group-root"
|
||||
id="moderate-searchBar-context"
|
||||
role="group"
|
||||
>
|
||||
<li
|
||||
className="Group-title"
|
||||
id="moderate-searchBar-context-title"
|
||||
>
|
||||
Currently moderating
|
||||
</li>
|
||||
<li
|
||||
aria-selected={false}
|
||||
className="Option-root"
|
||||
id="moderate-searchBar-listBoxOption-0"
|
||||
onClick={[Function]}
|
||||
role="option"
|
||||
>
|
||||
<a
|
||||
className="Option-link"
|
||||
href="/admin/moderate"
|
||||
tabIndex={-1}
|
||||
>
|
||||
<div
|
||||
className="Option-container"
|
||||
>
|
||||
<div
|
||||
className="Option-title"
|
||||
>
|
||||
<div
|
||||
className="AriaInfo-root"
|
||||
>
|
||||
Go to
|
||||
</div>
|
||||
<span>
|
||||
All stories
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
className="Option-details"
|
||||
/>
|
||||
</div>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`all stories active search with too many results 1`] = `
|
||||
<li
|
||||
aria-selected={false}
|
||||
className="SeeAllOption-root"
|
||||
id="moderate-searchBar-listBoxOption-3"
|
||||
onClick={[Function]}
|
||||
role="option"
|
||||
>
|
||||
<a
|
||||
className="SeeAllOption-link"
|
||||
href="/admin/stories?q=InterestingStory"
|
||||
tabIndex={-1}
|
||||
>
|
||||
<span>
|
||||
See all results
|
||||
</span>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="Icon-root Icon-sm SeeAllOption-icon"
|
||||
>
|
||||
arrow_forward
|
||||
</span>
|
||||
</a>
|
||||
</li>
|
||||
`;
|
||||
|
||||
exports[`all stories renders search bar 1`] = `
|
||||
<div
|
||||
aria-expanded={false}
|
||||
aria-haspopup="listbox"
|
||||
aria-label="Search or jump to story"
|
||||
aria-owns="moderate-searchBar-listBox"
|
||||
className="SubBar-root Bar-root"
|
||||
data-testid="moderate-searchBar-container"
|
||||
role="combobox"
|
||||
>
|
||||
<div
|
||||
className="Box-root Flex-root SubBar-container Flex-flex Flex-justifyCenter Flex-alignCenter"
|
||||
>
|
||||
<div
|
||||
className="Backdrop-root Bar-bumpZIndex"
|
||||
/>
|
||||
<form
|
||||
aria-label="Stories"
|
||||
className="Bar-bumpZIndex"
|
||||
onMouseDown={[Function]}
|
||||
onSubmit={[Function]}
|
||||
role="search"
|
||||
>
|
||||
<div
|
||||
className="Popover-root"
|
||||
>
|
||||
<div>
|
||||
<div
|
||||
className="Box-root Flex-root Field-root Flex-flex Flex-alignStretch"
|
||||
>
|
||||
<div
|
||||
className="Box-root Flex-root Field-begin Flex-flex Flex-alignCenter"
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="Icon-root Icon-md Field-searchIcon"
|
||||
>
|
||||
search
|
||||
</span>
|
||||
</div>
|
||||
<input
|
||||
aria-autocomplete="list"
|
||||
aria-controls="moderate-searchBar-listBox"
|
||||
aria-label="Search or jump to story..."
|
||||
autoComplete="off"
|
||||
className="Field-input Field-inputWithTitle"
|
||||
name="search"
|
||||
onBlur={[Function]}
|
||||
onChange={[Function]}
|
||||
onFocus={[Function]}
|
||||
onKeyDown={[Function]}
|
||||
placeholder="All stories"
|
||||
spellCheck={false}
|
||||
value=""
|
||||
/>
|
||||
<div
|
||||
className="Box-root Flex-root Field-end Flex-flex Flex-alignCenter"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
aria-hidden={true}
|
||||
aria-labelledby="moderate-searchBar-popover-ariainfo"
|
||||
id="moderate-searchBar-popover"
|
||||
role="popup"
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
@@ -0,0 +1,586 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`approves single comment 1`] = `
|
||||
<div
|
||||
className="Card-root ModerateCard-root"
|
||||
data-testid="moderate-comment-comment-0"
|
||||
>
|
||||
<div
|
||||
className="Box-root Flex-root Flex-flex"
|
||||
>
|
||||
<div
|
||||
className="ModerateCard-mainContainer"
|
||||
>
|
||||
<div
|
||||
className="ModerateCard-topBar"
|
||||
>
|
||||
<div>
|
||||
<span
|
||||
className="Box-root Typography-root Typography-heading4 Typography-colorTextPrimary ModerateCard-username Username-root"
|
||||
>
|
||||
Isabelle
|
||||
</span>
|
||||
<time
|
||||
className="Timestamp-root RelativeTime-root"
|
||||
dateTime="2018-07-06T18:24:00.000Z"
|
||||
title="2018-07-06T18:24:00.000Z"
|
||||
>
|
||||
2018-07-06T18:24:00.000Z
|
||||
</time>
|
||||
<button
|
||||
className="BaseButton-root FeatureButton-root"
|
||||
onBlur={[Function]}
|
||||
onClick={[Function]}
|
||||
onFocus={[Function]}
|
||||
onMouseOut={[Function]}
|
||||
onMouseOver={[Function]}
|
||||
onTouchEnd={[Function]}
|
||||
type="button"
|
||||
>
|
||||
<span>
|
||||
Feature
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className="Box-root Typography-root Typography-bodyCopy Typography-colorTextPrimary ModerateCard-content CommentContent-root"
|
||||
dangerouslySetInnerHTML={
|
||||
Object {
|
||||
"__html": "This is the last random sentence I will be writing and I am going to stop mid-sent",
|
||||
}
|
||||
}
|
||||
/>
|
||||
<div
|
||||
className="ModerateCard-footer"
|
||||
>
|
||||
<div
|
||||
className="Box-root HorizontalGutter-root HorizontalGutter-full"
|
||||
>
|
||||
<div>
|
||||
<a
|
||||
className="TextLink-root ModerateCard-link"
|
||||
href="http://localhost/comment/0"
|
||||
target="_blank"
|
||||
>
|
||||
View Context
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="Icon-root Icon-xs TextLink-icon"
|
||||
>
|
||||
open_in_new
|
||||
</span>
|
||||
</a>
|
||||
</div>
|
||||
<div
|
||||
className="Box-root HorizontalGutter-root HorizontalGutter-double"
|
||||
>
|
||||
<div
|
||||
className="Box-root Flex-root Flex-flex"
|
||||
>
|
||||
<div
|
||||
className="Box-root Flex-root Flex-flex Flex-itemGutter gutter"
|
||||
>
|
||||
<span
|
||||
className="Marker-root Marker-colorError Marker-variantRegular"
|
||||
>
|
||||
<span>
|
||||
Spam
|
||||
</span>
|
||||
|
||||
<span
|
||||
className="Count-root"
|
||||
>
|
||||
2
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
aria-controls="uuid-0"
|
||||
aria-expanded={false}
|
||||
className="BaseButton-root Button-root Button-sizeSmall Button-colorRegular Markers-detailsButtonColorRegular Button-variantRegular Markers-detailsButton"
|
||||
onBlur={[Function]}
|
||||
onClick={[Function]}
|
||||
onFocus={[Function]}
|
||||
onMouseOut={[Function]}
|
||||
onMouseOver={[Function]}
|
||||
onTouchEnd={[Function]}
|
||||
type="button"
|
||||
>
|
||||
<span
|
||||
className="Markers-detailsText"
|
||||
>
|
||||
Details
|
||||
</span>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="Icon-root Icon-sm"
|
||||
>
|
||||
arrow_drop_down
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className="ModerateCard-separator"
|
||||
/>
|
||||
<div
|
||||
className="Box-root Flex-root ModerateCard-aside ModerateCard-asideWithoutReplyTo Flex-flex Flex-itemGutter Flex-alignCenter Flex-directionColumn gutter"
|
||||
>
|
||||
<div
|
||||
className="ModerateCard-decision"
|
||||
>
|
||||
Decision
|
||||
</div>
|
||||
<div
|
||||
className="Box-root Flex-root Flex-flex Flex-itemGutter gutter"
|
||||
>
|
||||
<button
|
||||
aria-label="Reject"
|
||||
className="BaseButton-root RejectButton-root"
|
||||
disabled={false}
|
||||
onBlur={[Function]}
|
||||
onClick={[Function]}
|
||||
onFocus={[Function]}
|
||||
onMouseOut={[Function]}
|
||||
onMouseOver={[Function]}
|
||||
onTouchEnd={[Function]}
|
||||
type="button"
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="Icon-root Icon-lg RejectButton-icon"
|
||||
>
|
||||
close
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
aria-label="Approve"
|
||||
className="BaseButton-root ApproveButton-root ApproveButton-invert"
|
||||
disabled={true}
|
||||
onBlur={[Function]}
|
||||
onClick={[Function]}
|
||||
onFocus={[Function]}
|
||||
onMouseOut={[Function]}
|
||||
onMouseOver={[Function]}
|
||||
onTouchEnd={[Function]}
|
||||
type="button"
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="Icon-root Icon-lg ApproveButton-icon"
|
||||
>
|
||||
done
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`rejects single comment 1`] = `
|
||||
<div
|
||||
className="Card-root ModerateCard-root"
|
||||
data-testid="moderate-comment-comment-0"
|
||||
>
|
||||
<div
|
||||
className="Box-root Flex-root Flex-flex"
|
||||
>
|
||||
<div
|
||||
className="ModerateCard-mainContainer"
|
||||
>
|
||||
<div
|
||||
className="ModerateCard-topBar"
|
||||
>
|
||||
<div>
|
||||
<span
|
||||
className="Box-root Typography-root Typography-heading4 Typography-colorTextPrimary ModerateCard-username Username-root"
|
||||
>
|
||||
Isabelle
|
||||
</span>
|
||||
<time
|
||||
className="Timestamp-root RelativeTime-root"
|
||||
dateTime="2018-07-06T18:24:00.000Z"
|
||||
title="2018-07-06T18:24:00.000Z"
|
||||
>
|
||||
2018-07-06T18:24:00.000Z
|
||||
</time>
|
||||
<button
|
||||
className="BaseButton-root FeatureButton-root"
|
||||
onBlur={[Function]}
|
||||
onClick={[Function]}
|
||||
onFocus={[Function]}
|
||||
onMouseOut={[Function]}
|
||||
onMouseOver={[Function]}
|
||||
onTouchEnd={[Function]}
|
||||
type="button"
|
||||
>
|
||||
<span>
|
||||
Feature
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className="Box-root Typography-root Typography-bodyCopy Typography-colorTextPrimary ModerateCard-content CommentContent-root"
|
||||
dangerouslySetInnerHTML={
|
||||
Object {
|
||||
"__html": "This is the last random sentence I will be writing and I am going to stop mid-sent",
|
||||
}
|
||||
}
|
||||
/>
|
||||
<div
|
||||
className="ModerateCard-footer"
|
||||
>
|
||||
<div
|
||||
className="Box-root HorizontalGutter-root HorizontalGutter-full"
|
||||
>
|
||||
<div>
|
||||
<a
|
||||
className="TextLink-root ModerateCard-link"
|
||||
href="http://localhost/comment/0"
|
||||
target="_blank"
|
||||
>
|
||||
View Context
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="Icon-root Icon-xs TextLink-icon"
|
||||
>
|
||||
open_in_new
|
||||
</span>
|
||||
</a>
|
||||
</div>
|
||||
<div
|
||||
className="Box-root HorizontalGutter-root HorizontalGutter-double"
|
||||
>
|
||||
<div
|
||||
className="Box-root Flex-root Flex-flex"
|
||||
>
|
||||
<div
|
||||
className="Box-root Flex-root Flex-flex Flex-itemGutter gutter"
|
||||
>
|
||||
<span
|
||||
className="Marker-root Marker-colorError Marker-variantRegular"
|
||||
>
|
||||
<span>
|
||||
Spam
|
||||
</span>
|
||||
|
||||
<span
|
||||
className="Count-root"
|
||||
>
|
||||
2
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
aria-controls="uuid-0"
|
||||
aria-expanded={false}
|
||||
className="BaseButton-root Button-root Button-sizeSmall Button-colorRegular Markers-detailsButtonColorRegular Button-variantRegular Markers-detailsButton"
|
||||
onBlur={[Function]}
|
||||
onClick={[Function]}
|
||||
onFocus={[Function]}
|
||||
onMouseOut={[Function]}
|
||||
onMouseOver={[Function]}
|
||||
onTouchEnd={[Function]}
|
||||
type="button"
|
||||
>
|
||||
<span
|
||||
className="Markers-detailsText"
|
||||
>
|
||||
Details
|
||||
</span>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="Icon-root Icon-sm"
|
||||
>
|
||||
arrow_drop_down
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className="ModerateCard-separator"
|
||||
/>
|
||||
<div
|
||||
className="Box-root Flex-root ModerateCard-aside ModerateCard-asideWithoutReplyTo Flex-flex Flex-itemGutter Flex-alignCenter Flex-directionColumn gutter"
|
||||
>
|
||||
<div
|
||||
className="ModerateCard-decision"
|
||||
>
|
||||
Decision
|
||||
</div>
|
||||
<div
|
||||
className="Box-root Flex-root Flex-flex Flex-itemGutter gutter"
|
||||
>
|
||||
<button
|
||||
aria-label="Reject"
|
||||
className="BaseButton-root RejectButton-root RejectButton-invert"
|
||||
disabled={true}
|
||||
onBlur={[Function]}
|
||||
onClick={[Function]}
|
||||
onFocus={[Function]}
|
||||
onMouseOut={[Function]}
|
||||
onMouseOver={[Function]}
|
||||
onTouchEnd={[Function]}
|
||||
type="button"
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="Icon-root Icon-lg RejectButton-icon"
|
||||
>
|
||||
close
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
aria-label="Approve"
|
||||
className="BaseButton-root ApproveButton-root"
|
||||
disabled={false}
|
||||
onBlur={[Function]}
|
||||
onClick={[Function]}
|
||||
onFocus={[Function]}
|
||||
onMouseOut={[Function]}
|
||||
onMouseOver={[Function]}
|
||||
onTouchEnd={[Function]}
|
||||
type="button"
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="Icon-root Icon-lg ApproveButton-icon"
|
||||
>
|
||||
done
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`renders single comment view 1`] = `
|
||||
<div
|
||||
data-testid="single-moderate-container"
|
||||
>
|
||||
<div
|
||||
className="SubBar-root SingleModerate-subBar SubBar-gutterBegin SubBar-gutterEnd"
|
||||
>
|
||||
<div
|
||||
className="Box-root Flex-root SubBar-container Flex-flex Flex-justifyCenter Flex-alignCenter"
|
||||
>
|
||||
<a
|
||||
className="SingleModerate-subBarBegin"
|
||||
href="/admin/moderate/"
|
||||
onClick={[Function]}
|
||||
>
|
||||
Go to moderation queues
|
||||
</a>
|
||||
<div
|
||||
className="SingleModerate-subBarTitle"
|
||||
>
|
||||
Single Comment View
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className="SingleModerate-background"
|
||||
/>
|
||||
<div
|
||||
className="MainLayout-root"
|
||||
>
|
||||
<main
|
||||
className="SingleModerate-main"
|
||||
>
|
||||
<div
|
||||
className="Box-root HorizontalGutter-root Queue-root HorizontalGutter-double"
|
||||
>
|
||||
<div
|
||||
className="Card-root ModerateCard-root"
|
||||
data-testid="moderate-comment-comment-0"
|
||||
>
|
||||
<div
|
||||
className="Box-root Flex-root Flex-flex"
|
||||
>
|
||||
<div
|
||||
className="ModerateCard-mainContainer"
|
||||
>
|
||||
<div
|
||||
className="ModerateCard-topBar"
|
||||
>
|
||||
<div>
|
||||
<span
|
||||
className="Box-root Typography-root Typography-heading4 Typography-colorTextPrimary ModerateCard-username Username-root"
|
||||
>
|
||||
Isabelle
|
||||
</span>
|
||||
<time
|
||||
className="Timestamp-root RelativeTime-root"
|
||||
dateTime="2018-07-06T18:24:00.000Z"
|
||||
title="2018-07-06T18:24:00.000Z"
|
||||
>
|
||||
2018-07-06T18:24:00.000Z
|
||||
</time>
|
||||
<button
|
||||
className="BaseButton-root FeatureButton-root"
|
||||
onBlur={[Function]}
|
||||
onClick={[Function]}
|
||||
onFocus={[Function]}
|
||||
onMouseOut={[Function]}
|
||||
onMouseOver={[Function]}
|
||||
onTouchEnd={[Function]}
|
||||
type="button"
|
||||
>
|
||||
<span>
|
||||
Feature
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className="Box-root Typography-root Typography-bodyCopy Typography-colorTextPrimary ModerateCard-content CommentContent-root"
|
||||
dangerouslySetInnerHTML={
|
||||
Object {
|
||||
"__html": "This is the last random sentence I will be writing and I am going to stop mid-sent",
|
||||
}
|
||||
}
|
||||
/>
|
||||
<div
|
||||
className="ModerateCard-footer"
|
||||
>
|
||||
<div
|
||||
className="Box-root HorizontalGutter-root HorizontalGutter-full"
|
||||
>
|
||||
<div>
|
||||
<a
|
||||
className="TextLink-root ModerateCard-link"
|
||||
href="http://localhost/comment/0"
|
||||
target="_blank"
|
||||
>
|
||||
View Context
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="Icon-root Icon-xs TextLink-icon"
|
||||
>
|
||||
open_in_new
|
||||
</span>
|
||||
</a>
|
||||
</div>
|
||||
<div
|
||||
className="Box-root HorizontalGutter-root HorizontalGutter-double"
|
||||
>
|
||||
<div
|
||||
className="Box-root Flex-root Flex-flex"
|
||||
>
|
||||
<div
|
||||
className="Box-root Flex-root Flex-flex Flex-itemGutter gutter"
|
||||
>
|
||||
<span
|
||||
className="Marker-root Marker-colorError Marker-variantRegular"
|
||||
>
|
||||
<span>
|
||||
Spam
|
||||
</span>
|
||||
|
||||
<span
|
||||
className="Count-root"
|
||||
>
|
||||
2
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
aria-controls="uuid-0"
|
||||
aria-expanded={false}
|
||||
className="BaseButton-root Button-root Button-sizeSmall Button-colorRegular Markers-detailsButtonColorRegular Button-variantRegular Markers-detailsButton"
|
||||
onBlur={[Function]}
|
||||
onClick={[Function]}
|
||||
onFocus={[Function]}
|
||||
onMouseOut={[Function]}
|
||||
onMouseOver={[Function]}
|
||||
onTouchEnd={[Function]}
|
||||
type="button"
|
||||
>
|
||||
<span
|
||||
className="Markers-detailsText"
|
||||
>
|
||||
Details
|
||||
</span>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="Icon-root Icon-sm"
|
||||
>
|
||||
arrow_drop_down
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className="ModerateCard-separator"
|
||||
/>
|
||||
<div
|
||||
className="Box-root Flex-root ModerateCard-aside ModerateCard-asideWithoutReplyTo Flex-flex Flex-itemGutter Flex-alignCenter Flex-directionColumn gutter"
|
||||
>
|
||||
<div
|
||||
className="ModerateCard-decision"
|
||||
>
|
||||
Decision
|
||||
</div>
|
||||
<div
|
||||
className="Box-root Flex-root Flex-flex Flex-itemGutter gutter"
|
||||
>
|
||||
<button
|
||||
aria-label="Reject"
|
||||
className="BaseButton-root RejectButton-root"
|
||||
disabled={false}
|
||||
onBlur={[Function]}
|
||||
onClick={[Function]}
|
||||
onFocus={[Function]}
|
||||
onMouseOut={[Function]}
|
||||
onMouseOver={[Function]}
|
||||
onTouchEnd={[Function]}
|
||||
type="button"
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="Icon-root Icon-lg RejectButton-icon"
|
||||
>
|
||||
close
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
aria-label="Approve"
|
||||
className="BaseButton-root ApproveButton-root"
|
||||
disabled={false}
|
||||
onBlur={[Function]}
|
||||
onClick={[Function]}
|
||||
onFocus={[Function]}
|
||||
onMouseOut={[Function]}
|
||||
onMouseOver={[Function]}
|
||||
onTouchEnd={[Function]}
|
||||
type="button"
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="Icon-root Icon-lg ApproveButton-icon"
|
||||
>
|
||||
done
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
@@ -0,0 +1,127 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`tab bar renders tab bar (empty queues) 1`] = `
|
||||
<div
|
||||
className="SubBar-root"
|
||||
data-testid="moderate-tabBar-container"
|
||||
>
|
||||
<div
|
||||
className="Box-root Flex-root SubBar-container Flex-flex Flex-justifyCenter Flex-alignCenter"
|
||||
>
|
||||
<nav
|
||||
className="Navigation-root"
|
||||
>
|
||||
<ul
|
||||
className="Navigation-ul"
|
||||
>
|
||||
<li
|
||||
className="NavigationItem-root"
|
||||
>
|
||||
<a
|
||||
className="NavigationItem-anchor NavigationItem-active"
|
||||
href="/admin/moderate/reported"
|
||||
onClick={[Function]}
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="Icon-root Icon-sm"
|
||||
>
|
||||
flag
|
||||
</span>
|
||||
<span>
|
||||
reported
|
||||
</span>
|
||||
<span
|
||||
className="Counter-root Counter-colorInherit"
|
||||
data-testid="moderate-navigation-reported-count"
|
||||
>
|
||||
<span
|
||||
className="Counter-text"
|
||||
>
|
||||
0
|
||||
</span>
|
||||
</span>
|
||||
</a>
|
||||
</li>
|
||||
<li
|
||||
className="NavigationItem-root"
|
||||
>
|
||||
<a
|
||||
className="NavigationItem-anchor"
|
||||
href="/admin/moderate/pending"
|
||||
onClick={[Function]}
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="Icon-root Icon-sm"
|
||||
>
|
||||
access_time
|
||||
</span>
|
||||
<span>
|
||||
Pending
|
||||
</span>
|
||||
<span
|
||||
className="Counter-root Counter-colorInherit"
|
||||
data-testid="moderate-navigation-pending-count"
|
||||
>
|
||||
<span
|
||||
className="Counter-text"
|
||||
>
|
||||
0
|
||||
</span>
|
||||
</span>
|
||||
</a>
|
||||
</li>
|
||||
<li
|
||||
className="NavigationItem-root"
|
||||
>
|
||||
<a
|
||||
className="NavigationItem-anchor"
|
||||
href="/admin/moderate/unmoderated"
|
||||
onClick={[Function]}
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="Icon-root Icon-sm"
|
||||
>
|
||||
forum
|
||||
</span>
|
||||
<span>
|
||||
unmoderated
|
||||
</span>
|
||||
<span
|
||||
className="Counter-root Counter-colorInherit"
|
||||
data-testid="moderate-navigation-unmoderated-count"
|
||||
>
|
||||
<span
|
||||
className="Counter-text"
|
||||
>
|
||||
0
|
||||
</span>
|
||||
</span>
|
||||
</a>
|
||||
</li>
|
||||
<li
|
||||
className="NavigationItem-root"
|
||||
>
|
||||
<a
|
||||
className="NavigationItem-anchor"
|
||||
href="/admin/moderate/rejected"
|
||||
onClick={[Function]}
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="Icon-root Icon-sm"
|
||||
>
|
||||
cancel
|
||||
</span>
|
||||
<span>
|
||||
rejected
|
||||
</span>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
@@ -0,0 +1,123 @@
|
||||
import { pureMerge } from "coral-common/utils";
|
||||
import {
|
||||
GQLComment,
|
||||
GQLMODERATION_QUEUE,
|
||||
GQLResolver,
|
||||
SubscriptionToCommentEnteredModerationQueueResolver,
|
||||
} from "coral-framework/schema";
|
||||
import {
|
||||
act,
|
||||
createResolversStub,
|
||||
CreateTestRendererParams,
|
||||
replaceHistoryLocation,
|
||||
wait,
|
||||
within,
|
||||
} from "coral-framework/testHelpers";
|
||||
|
||||
import create from "../create";
|
||||
import {
|
||||
emptyModerationQueues,
|
||||
emptyRejectedComments,
|
||||
reportedComments,
|
||||
settings,
|
||||
users,
|
||||
} from "../fixtures";
|
||||
|
||||
const viewer = users.admins[0];
|
||||
|
||||
beforeEach(async () => {
|
||||
replaceHistoryLocation("http://localhost/admin/moderate");
|
||||
});
|
||||
|
||||
async function createTestRenderer(
|
||||
params: CreateTestRendererParams<GQLResolver> = {}
|
||||
) {
|
||||
const { testRenderer, context, subscriptionHandler } = create({
|
||||
...params,
|
||||
resolvers: pureMerge(
|
||||
createResolversStub<GQLResolver>({
|
||||
Query: {
|
||||
settings: () => settings,
|
||||
viewer: () => viewer,
|
||||
moderationQueues: () => emptyModerationQueues,
|
||||
comments: () => emptyRejectedComments,
|
||||
},
|
||||
}),
|
||||
params.resolvers
|
||||
),
|
||||
initLocalState: (localRecord, source, environment) => {
|
||||
localRecord.setValue(true, "loggedIn");
|
||||
if (params.initLocalState) {
|
||||
params.initLocalState(localRecord, source, environment);
|
||||
}
|
||||
},
|
||||
});
|
||||
return { testRenderer, context, subscriptionHandler };
|
||||
}
|
||||
|
||||
it("live update count", async () => {
|
||||
const { testRenderer, subscriptionHandler } = await createTestRenderer();
|
||||
const verifyCount = (queue: GQLMODERATION_QUEUE, no: number) => {
|
||||
within(
|
||||
within(testRenderer.root).getByTestID(
|
||||
`moderate-navigation-${queue.toLocaleLowerCase()}-count`
|
||||
)
|
||||
).getByText(no.toString());
|
||||
};
|
||||
const commentEntered = (queue: GQLMODERATION_QUEUE, comment: GQLComment) => {
|
||||
subscriptionHandler.dispatch<
|
||||
SubscriptionToCommentEnteredModerationQueueResolver
|
||||
>("commentEnteredModerationQueue", variables => {
|
||||
if (variables.queue && variables.queue !== queue) {
|
||||
return;
|
||||
}
|
||||
return {
|
||||
queue,
|
||||
comment,
|
||||
};
|
||||
});
|
||||
};
|
||||
const commentLeft = (queue: GQLMODERATION_QUEUE, comment: GQLComment) => {
|
||||
subscriptionHandler.dispatch<
|
||||
SubscriptionToCommentEnteredModerationQueueResolver
|
||||
>("commentLeftModerationQueue", variables => {
|
||||
if (variables.queue && variables.queue !== queue) {
|
||||
return;
|
||||
}
|
||||
return {
|
||||
queue,
|
||||
comment,
|
||||
};
|
||||
});
|
||||
};
|
||||
await act(async () => {
|
||||
await wait(() =>
|
||||
expect(subscriptionHandler.has("commentEnteredModerationQueue")).toBe(
|
||||
true
|
||||
)
|
||||
);
|
||||
verifyCount(GQLMODERATION_QUEUE.REPORTED, 0);
|
||||
verifyCount(GQLMODERATION_QUEUE.PENDING, 0);
|
||||
verifyCount(GQLMODERATION_QUEUE.UNMODERATED, 0);
|
||||
|
||||
commentEntered(GQLMODERATION_QUEUE.REPORTED, reportedComments[0]);
|
||||
|
||||
verifyCount(GQLMODERATION_QUEUE.REPORTED, 1);
|
||||
verifyCount(GQLMODERATION_QUEUE.PENDING, 0);
|
||||
verifyCount(GQLMODERATION_QUEUE.UNMODERATED, 0);
|
||||
|
||||
commentEntered(GQLMODERATION_QUEUE.REPORTED, reportedComments[1]);
|
||||
|
||||
verifyCount(GQLMODERATION_QUEUE.REPORTED, 2);
|
||||
verifyCount(GQLMODERATION_QUEUE.PENDING, 0);
|
||||
verifyCount(GQLMODERATION_QUEUE.UNMODERATED, 0);
|
||||
|
||||
commentEntered(GQLMODERATION_QUEUE.PENDING, reportedComments[2]);
|
||||
commentEntered(GQLMODERATION_QUEUE.PENDING, reportedComments[2]);
|
||||
commentLeft(GQLMODERATION_QUEUE.REPORTED, reportedComments[1]);
|
||||
|
||||
verifyCount(GQLMODERATION_QUEUE.REPORTED, 1);
|
||||
verifyCount(GQLMODERATION_QUEUE.PENDING, 2);
|
||||
verifyCount(GQLMODERATION_QUEUE.UNMODERATED, 0);
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,554 @@
|
||||
import { pureMerge } from "coral-common/utils";
|
||||
import {
|
||||
GQLCOMMENT_STATUS,
|
||||
GQLResolver,
|
||||
ModerationQueueToCommentsResolver,
|
||||
MutationToApproveCommentResolver,
|
||||
MutationToRejectCommentResolver,
|
||||
} from "coral-framework/schema";
|
||||
import {
|
||||
act,
|
||||
createMutationResolverStub,
|
||||
createQueryResolverStub,
|
||||
createResolversStub,
|
||||
CreateTestRendererParams,
|
||||
replaceHistoryLocation,
|
||||
toJSON,
|
||||
waitForElement,
|
||||
waitUntilThrow,
|
||||
within,
|
||||
} from "coral-framework/testHelpers";
|
||||
|
||||
import create from "../create";
|
||||
import {
|
||||
emptyModerationQueues,
|
||||
emptyRejectedComments,
|
||||
reportedComments,
|
||||
settings,
|
||||
users,
|
||||
} from "../fixtures";
|
||||
|
||||
const viewer = users.admins[0];
|
||||
|
||||
beforeEach(async () => {
|
||||
replaceHistoryLocation("http://localhost/admin/moderate");
|
||||
});
|
||||
|
||||
async function createTestRenderer(
|
||||
params: CreateTestRendererParams<GQLResolver> = {}
|
||||
) {
|
||||
const { testRenderer, context, subscriptionHandler } = create({
|
||||
...params,
|
||||
resolvers: pureMerge(
|
||||
createResolversStub<GQLResolver>({
|
||||
Query: {
|
||||
settings: () => settings,
|
||||
viewer: () => viewer,
|
||||
moderationQueues: () => emptyModerationQueues,
|
||||
comments: () => emptyRejectedComments,
|
||||
},
|
||||
}),
|
||||
params.resolvers
|
||||
),
|
||||
initLocalState: (localRecord, source, environment) => {
|
||||
localRecord.setValue(true, "loggedIn");
|
||||
if (params.initLocalState) {
|
||||
params.initLocalState(localRecord, source, environment);
|
||||
}
|
||||
},
|
||||
});
|
||||
return { testRenderer, context, subscriptionHandler };
|
||||
}
|
||||
|
||||
it("renders empty reported queue", async () => {
|
||||
await act(async () => {
|
||||
const { testRenderer } = await createTestRenderer();
|
||||
const { getByText } = within(testRenderer.root);
|
||||
await waitForElement(() => getByText("no more reported", { exact: false }));
|
||||
});
|
||||
});
|
||||
|
||||
it("renders empty pending queue", async () => {
|
||||
replaceHistoryLocation("http://localhost/admin/moderate/pending");
|
||||
const { testRenderer } = await createTestRenderer();
|
||||
const { getByText } = within(testRenderer.root);
|
||||
await waitForElement(() => getByText("no more pending", { exact: false }));
|
||||
});
|
||||
|
||||
it("renders empty unmoderated queue", async () => {
|
||||
replaceHistoryLocation("http://localhost/admin/moderate/unmoderated");
|
||||
const { testRenderer } = await createTestRenderer();
|
||||
const { getByText } = within(testRenderer.root);
|
||||
await waitForElement(() =>
|
||||
getByText("comments have been moderated", { exact: false })
|
||||
);
|
||||
});
|
||||
|
||||
it("renders empty rejected queue", async () => {
|
||||
replaceHistoryLocation("http://localhost/admin/moderate/rejected");
|
||||
const { testRenderer } = await createTestRenderer();
|
||||
const { getByText } = within(testRenderer.root);
|
||||
await waitForElement(() =>
|
||||
getByText("no rejected comments", { exact: false })
|
||||
);
|
||||
});
|
||||
|
||||
it("renders reported queue with comments", async () => {
|
||||
await act(async () => {
|
||||
const { testRenderer } = await createTestRenderer({
|
||||
resolvers: createResolversStub<GQLResolver>({
|
||||
Query: {
|
||||
moderationQueues: () =>
|
||||
pureMerge(emptyModerationQueues, {
|
||||
reported: {
|
||||
count: 2,
|
||||
comments: createQueryResolverStub<
|
||||
ModerationQueueToCommentsResolver
|
||||
>(({ variables }) => {
|
||||
expectAndFail(variables).toEqual({ first: 5 });
|
||||
return {
|
||||
edges: [
|
||||
{
|
||||
node: reportedComments[0],
|
||||
cursor: reportedComments[0].createdAt,
|
||||
},
|
||||
{
|
||||
node: reportedComments[1],
|
||||
cursor: reportedComments[1].createdAt,
|
||||
},
|
||||
],
|
||||
pageInfo: {
|
||||
endCursor: reportedComments[1].createdAt,
|
||||
hasNextPage: false,
|
||||
},
|
||||
};
|
||||
}) as any,
|
||||
},
|
||||
}),
|
||||
},
|
||||
}),
|
||||
});
|
||||
const { getByTestID } = within(testRenderer.root);
|
||||
await waitForElement(() => getByTestID("moderate-container"));
|
||||
expect(toJSON(getByTestID("moderate-main-container"))).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
|
||||
it("renders reported queue with comments", async () => {
|
||||
await act(async () => {
|
||||
const { testRenderer } = await createTestRenderer({
|
||||
resolvers: createResolversStub<GQLResolver>({
|
||||
Query: {
|
||||
moderationQueues: () =>
|
||||
pureMerge(emptyModerationQueues, {
|
||||
reported: {
|
||||
count: 2,
|
||||
comments: createQueryResolverStub<
|
||||
ModerationQueueToCommentsResolver
|
||||
>(({ variables }) => {
|
||||
expectAndFail(variables).toEqual({ first: 5 });
|
||||
return {
|
||||
edges: [
|
||||
{
|
||||
node: reportedComments[0],
|
||||
cursor: reportedComments[0].createdAt,
|
||||
},
|
||||
{
|
||||
node: reportedComments[1],
|
||||
cursor: reportedComments[1].createdAt,
|
||||
},
|
||||
],
|
||||
pageInfo: {
|
||||
endCursor: reportedComments[1].createdAt,
|
||||
hasNextPage: false,
|
||||
},
|
||||
};
|
||||
}),
|
||||
},
|
||||
}),
|
||||
},
|
||||
}),
|
||||
});
|
||||
const { getByTestID } = within(testRenderer.root);
|
||||
await waitForElement(() => getByTestID("moderate-container"));
|
||||
expect(toJSON(getByTestID("moderate-main-container"))).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
it("show details of comment with flags", async () => {
|
||||
await act(async () => {
|
||||
const { testRenderer } = await createTestRenderer({
|
||||
resolvers: createResolversStub<GQLResolver>({
|
||||
Query: {
|
||||
moderationQueues: () =>
|
||||
pureMerge(emptyModerationQueues, {
|
||||
reported: {
|
||||
count: 1,
|
||||
comments: createQueryResolverStub<
|
||||
ModerationQueueToCommentsResolver
|
||||
>(({ variables }) => {
|
||||
expectAndFail(variables).toEqual({ first: 5 });
|
||||
return {
|
||||
edges: [
|
||||
{
|
||||
node: reportedComments[0],
|
||||
cursor: reportedComments[0].createdAt,
|
||||
},
|
||||
],
|
||||
pageInfo: {
|
||||
endCursor: reportedComments[0].createdAt,
|
||||
hasNextPage: false,
|
||||
},
|
||||
};
|
||||
}),
|
||||
},
|
||||
}),
|
||||
},
|
||||
}),
|
||||
});
|
||||
const { getByTestID } = within(testRenderer.root);
|
||||
const reported = await waitForElement(() =>
|
||||
getByTestID(`moderate-comment-${reportedComments[0].id}`)
|
||||
);
|
||||
expect(
|
||||
within(reported).queryByText(
|
||||
reportedComments[0].flags.nodes[0].additionalDetails!
|
||||
)
|
||||
).toBeNull();
|
||||
within(reported)
|
||||
.getByText("Details", { selector: "button" })
|
||||
.props.onClick();
|
||||
within(reported).getByText(
|
||||
reportedComments[0].flags.nodes[0].additionalDetails!
|
||||
);
|
||||
});
|
||||
});
|
||||
it("shows a moderate story", async () => {
|
||||
await act(async () => {
|
||||
const {
|
||||
testRenderer,
|
||||
context: { transitionControl },
|
||||
} = await createTestRenderer({
|
||||
resolvers: createResolversStub<GQLResolver>({
|
||||
Query: {
|
||||
moderationQueues: () =>
|
||||
pureMerge(emptyModerationQueues, {
|
||||
reported: {
|
||||
count: 2,
|
||||
comments: createQueryResolverStub<
|
||||
ModerationQueueToCommentsResolver
|
||||
>(({ variables }) => {
|
||||
expectAndFail(variables).toEqual({ first: 5 });
|
||||
return {
|
||||
edges: [
|
||||
{
|
||||
node: reportedComments[0],
|
||||
cursor: reportedComments[0].createdAt,
|
||||
},
|
||||
{
|
||||
node: reportedComments[1],
|
||||
cursor: reportedComments[1].createdAt,
|
||||
},
|
||||
],
|
||||
pageInfo: {
|
||||
endCursor: reportedComments[1].createdAt,
|
||||
hasNextPage: false,
|
||||
},
|
||||
};
|
||||
}) as any,
|
||||
},
|
||||
}),
|
||||
},
|
||||
}),
|
||||
});
|
||||
const moderateStory = await waitForElement(
|
||||
() => within(testRenderer.root).getAllByText("Moderate Story")[0]
|
||||
);
|
||||
transitionControl.allowTransition = false;
|
||||
moderateStory.props.onClick({});
|
||||
// Expect a routing request was made to the right url.
|
||||
expect(transitionControl.history[0].pathname).toBe(
|
||||
`/admin/moderate/${reportedComments[0].story.id}`
|
||||
);
|
||||
});
|
||||
});
|
||||
it("renders reported queue with comments and load more", async () => {
|
||||
await act(async () => {
|
||||
const moderationQueuesStub = pureMerge(emptyModerationQueues, {
|
||||
reported: {
|
||||
count: 2,
|
||||
comments: createQueryResolverStub<ModerationQueueToCommentsResolver>(
|
||||
({ variables, callCount }) => {
|
||||
switch (callCount) {
|
||||
case 0:
|
||||
expectAndFail(variables).toEqual({ first: 5 });
|
||||
return {
|
||||
edges: [
|
||||
{
|
||||
node: reportedComments[0],
|
||||
cursor: reportedComments[0].createdAt,
|
||||
},
|
||||
{
|
||||
node: reportedComments[1],
|
||||
cursor: reportedComments[1].createdAt,
|
||||
},
|
||||
],
|
||||
pageInfo: {
|
||||
endCursor: reportedComments[1].createdAt,
|
||||
hasNextPage: true,
|
||||
},
|
||||
};
|
||||
default:
|
||||
expectAndFail(variables).toEqual({
|
||||
first: 10,
|
||||
after: reportedComments[1].createdAt,
|
||||
});
|
||||
return {
|
||||
edges: [
|
||||
{
|
||||
node: reportedComments[2],
|
||||
cursor: reportedComments[2].createdAt,
|
||||
},
|
||||
],
|
||||
pageInfo: {
|
||||
endCursor: reportedComments[2].createdAt,
|
||||
hasNextPage: false,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
) as any,
|
||||
},
|
||||
});
|
||||
|
||||
const { testRenderer } = await createTestRenderer({
|
||||
resolvers: createResolversStub<GQLResolver>({
|
||||
Query: {
|
||||
moderationQueues: () => moderationQueuesStub,
|
||||
},
|
||||
}),
|
||||
});
|
||||
const moderateContainer = await waitForElement(() =>
|
||||
within(testRenderer.root).getByTestID("moderate-container")
|
||||
);
|
||||
|
||||
const { getByText, getAllByTestID, getByTestID } = within(
|
||||
moderateContainer
|
||||
);
|
||||
|
||||
// Get previous count of comments.
|
||||
const previousCount = getAllByTestID(/^moderate-comment-.*$/).length;
|
||||
|
||||
const loadMore = await waitForElement(() => getByText("Load More"));
|
||||
loadMore.props.onClick();
|
||||
|
||||
// Wait for load more to disappear.
|
||||
await waitUntilThrow(() => getByText("Load More"));
|
||||
|
||||
// Verify we have one more item now.
|
||||
const comments = getAllByTestID(/^moderate-comment-.*$/);
|
||||
expect(comments.length).toBe(previousCount + 1);
|
||||
|
||||
// Verify last one added was our new one
|
||||
expect(comments[comments.length - 1].props["data-testid"]).toBe(
|
||||
`moderate-comment-${reportedComments[2].id}`
|
||||
);
|
||||
|
||||
// Snapshot of added comment.
|
||||
expect(
|
||||
toJSON(getByTestID(`moderate-comment-${reportedComments[2].id}`))
|
||||
).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
|
||||
it("approves comment in reported queue", async () => {
|
||||
await act(async () => {
|
||||
const approveCommentStub = createMutationResolverStub<
|
||||
MutationToApproveCommentResolver
|
||||
>(({ variables }) => {
|
||||
expectAndFail(variables).toMatchObject({
|
||||
commentID: reportedComments[0].id,
|
||||
commentRevisionID: reportedComments[0].revision.id,
|
||||
});
|
||||
return {
|
||||
comment: {
|
||||
id: reportedComments[0].id,
|
||||
status: GQLCOMMENT_STATUS.APPROVED,
|
||||
statusHistory: {
|
||||
edges: [
|
||||
{
|
||||
node: {
|
||||
id: "mod-action",
|
||||
author: {
|
||||
id: viewer.id,
|
||||
username: viewer.username,
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
moderationQueues: pureMerge(emptyModerationQueues, {
|
||||
reported: {
|
||||
count: 1,
|
||||
},
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
const moderationQueuesStub = pureMerge(emptyModerationQueues, {
|
||||
reported: {
|
||||
count: 2,
|
||||
comments: createQueryResolverStub<ModerationQueueToCommentsResolver>(
|
||||
({ variables }) => {
|
||||
expectAndFail(variables).toEqual({ first: 5 });
|
||||
return {
|
||||
edges: [
|
||||
{
|
||||
node: reportedComments[0],
|
||||
cursor: reportedComments[0].createdAt,
|
||||
},
|
||||
{
|
||||
node: reportedComments[1],
|
||||
cursor: reportedComments[1].createdAt,
|
||||
},
|
||||
],
|
||||
pageInfo: {
|
||||
endCursor: reportedComments[1].createdAt,
|
||||
hasNextPage: false,
|
||||
},
|
||||
};
|
||||
}
|
||||
) as any,
|
||||
},
|
||||
});
|
||||
|
||||
const { testRenderer } = await createTestRenderer({
|
||||
resolvers: createResolversStub<GQLResolver>({
|
||||
Query: {
|
||||
moderationQueues: () => moderationQueuesStub,
|
||||
},
|
||||
Mutation: {
|
||||
approveComment: approveCommentStub,
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
const testID = `moderate-comment-${reportedComments[0].id}`;
|
||||
const { getByTestID } = within(testRenderer.root);
|
||||
const comment = await waitForElement(() => getByTestID(testID));
|
||||
|
||||
const ApproveButton = await waitForElement(() =>
|
||||
within(comment).getByLabelText("Approve")
|
||||
);
|
||||
ApproveButton.props.onClick();
|
||||
|
||||
// Snapshot dangling state of comment.
|
||||
expect(toJSON(comment)).toMatchSnapshot("dangling");
|
||||
|
||||
// Wait until comment is gone.
|
||||
await waitUntilThrow(() => getByTestID(testID));
|
||||
|
||||
expect(approveCommentStub.called).toBe(true);
|
||||
|
||||
// Count should have been updated.
|
||||
expect(
|
||||
toJSON(getByTestID("moderate-navigation-reported-count"))
|
||||
).toMatchSnapshot("count should be 1");
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects comment in reported queue", async () => {
|
||||
await act(async () => {
|
||||
const rejectCommentStub = createMutationResolverStub<
|
||||
MutationToRejectCommentResolver
|
||||
>(({ variables }) => {
|
||||
expectAndFail(variables).toMatchObject({
|
||||
commentID: reportedComments[0].id,
|
||||
commentRevisionID: reportedComments[0].revision.id,
|
||||
});
|
||||
return {
|
||||
comment: {
|
||||
id: reportedComments[0].id,
|
||||
status: GQLCOMMENT_STATUS.REJECTED,
|
||||
statusHistory: {
|
||||
edges: [
|
||||
{
|
||||
node: {
|
||||
id: "mod-action",
|
||||
author: {
|
||||
id: viewer.id,
|
||||
username: viewer.username,
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
moderationQueues: pureMerge(emptyModerationQueues, {
|
||||
reported: {
|
||||
count: 1,
|
||||
},
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
const { testRenderer } = await createTestRenderer({
|
||||
resolvers: createResolversStub<GQLResolver>({
|
||||
Query: {
|
||||
moderationQueues: () =>
|
||||
pureMerge(emptyModerationQueues, {
|
||||
reported: {
|
||||
count: 2,
|
||||
comments: createQueryResolverStub<
|
||||
ModerationQueueToCommentsResolver
|
||||
>(({ variables }) => {
|
||||
expectAndFail(variables).toEqual({ first: 5 });
|
||||
return {
|
||||
edges: [
|
||||
{
|
||||
node: reportedComments[0],
|
||||
cursor: reportedComments[0].createdAt,
|
||||
},
|
||||
{
|
||||
node: reportedComments[1],
|
||||
cursor: reportedComments[1].createdAt,
|
||||
},
|
||||
],
|
||||
pageInfo: {
|
||||
endCursor: reportedComments[1].createdAt,
|
||||
hasNextPage: false,
|
||||
},
|
||||
};
|
||||
}) as any,
|
||||
},
|
||||
}),
|
||||
},
|
||||
Mutation: {
|
||||
rejectComment: rejectCommentStub,
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
const testID = `moderate-comment-${reportedComments[0].id}`;
|
||||
const { getByTestID } = within(testRenderer.root);
|
||||
const comment = await waitForElement(() => getByTestID(testID));
|
||||
|
||||
const RejectButton = await waitForElement(() =>
|
||||
within(comment).getByLabelText("Reject")
|
||||
);
|
||||
RejectButton.props.onClick();
|
||||
|
||||
// Snapshot dangling state of comment.
|
||||
expect(toJSON(comment)).toMatchSnapshot("dangling");
|
||||
|
||||
// Wait until comment is gone.
|
||||
await waitUntilThrow(() => getByTestID(testID));
|
||||
|
||||
expect(rejectCommentStub.called).toBe(true);
|
||||
|
||||
// Count should have been updated.
|
||||
expect(
|
||||
toJSON(getByTestID("moderate-navigation-reported-count"))
|
||||
).toMatchSnapshot("count should be 1");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,139 @@
|
||||
import { pureMerge } from "coral-common/utils";
|
||||
import {
|
||||
GQLMODERATION_QUEUE,
|
||||
GQLResolver,
|
||||
SubscriptionToCommentEnteredModerationQueueResolver,
|
||||
SubscriptionToCommentLeftModerationQueueResolver,
|
||||
} from "coral-framework/schema";
|
||||
import {
|
||||
act,
|
||||
createResolversStub,
|
||||
CreateTestRendererParams,
|
||||
replaceHistoryLocation,
|
||||
waitForElement,
|
||||
within,
|
||||
} from "coral-framework/testHelpers";
|
||||
|
||||
import create from "../create";
|
||||
import {
|
||||
emptyModerationQueues,
|
||||
emptyRejectedComments,
|
||||
reportedComments,
|
||||
settings,
|
||||
users,
|
||||
} from "../fixtures";
|
||||
|
||||
const viewer = users.admins[0];
|
||||
|
||||
async function createTestRenderer(
|
||||
params: CreateTestRendererParams<GQLResolver> = {}
|
||||
) {
|
||||
replaceHistoryLocation(`http://localhost/admin/moderate/reported`);
|
||||
const { testRenderer, context, subscriptionHandler } = create({
|
||||
...params,
|
||||
resolvers: pureMerge(
|
||||
createResolversStub<GQLResolver>({
|
||||
Query: {
|
||||
settings: () => settings,
|
||||
viewer: () => viewer,
|
||||
moderationQueues: () => emptyModerationQueues,
|
||||
comments: () => emptyRejectedComments,
|
||||
},
|
||||
}),
|
||||
params.resolvers
|
||||
),
|
||||
initLocalState: (localRecord, source, environment) => {
|
||||
localRecord.setValue(true, "loggedIn");
|
||||
if (params.initLocalState) {
|
||||
params.initLocalState(localRecord, source, environment);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const container = await waitForElement(() =>
|
||||
within(testRenderer.root).getByTestID("moderate-main-container")
|
||||
);
|
||||
|
||||
return { testRenderer, context, container, subscriptionHandler };
|
||||
}
|
||||
|
||||
it("allows viewing new when new comments come in", async () => {
|
||||
const { subscriptionHandler, container } = await createTestRenderer();
|
||||
const commentData = reportedComments[0];
|
||||
expect(subscriptionHandler.has("commentEnteredModerationQueue")).toBe(true);
|
||||
|
||||
subscriptionHandler.dispatch<
|
||||
SubscriptionToCommentEnteredModerationQueueResolver
|
||||
>("commentEnteredModerationQueue", variables => {
|
||||
if (
|
||||
variables.storyID !== null ||
|
||||
variables.queue !== GQLMODERATION_QUEUE.REPORTED
|
||||
) {
|
||||
return;
|
||||
}
|
||||
return {
|
||||
queue: GQLMODERATION_QUEUE.REPORTED,
|
||||
comment: commentData,
|
||||
};
|
||||
});
|
||||
|
||||
const viewNewButton = await waitForElement(() =>
|
||||
within(container).getByText("View 1 new", {
|
||||
exact: false,
|
||||
selector: "button",
|
||||
})
|
||||
);
|
||||
act(() => {
|
||||
viewNewButton.props.onClick();
|
||||
});
|
||||
// View New Button should disappear.
|
||||
expect(() => within(container).getByText(/View \d+ new/)).toThrow();
|
||||
// New comment should appear.
|
||||
within(container).getByTestID(`moderate-comment-${commentData.id}`);
|
||||
});
|
||||
|
||||
it("recognizes when same comment enters and leaves again", async () => {
|
||||
const { subscriptionHandler, container } = await createTestRenderer();
|
||||
const commentData = reportedComments[0];
|
||||
expect(subscriptionHandler.has("commentEnteredModerationQueue")).toBe(true);
|
||||
|
||||
subscriptionHandler.dispatch<
|
||||
SubscriptionToCommentEnteredModerationQueueResolver
|
||||
>("commentEnteredModerationQueue", variables => {
|
||||
if (
|
||||
variables.storyID !== null ||
|
||||
variables.queue !== GQLMODERATION_QUEUE.REPORTED
|
||||
) {
|
||||
return;
|
||||
}
|
||||
return {
|
||||
queue: GQLMODERATION_QUEUE.REPORTED,
|
||||
comment: commentData,
|
||||
};
|
||||
});
|
||||
|
||||
await waitForElement(() =>
|
||||
within(container).getByText(/View \d+ new/, {
|
||||
exact: false,
|
||||
selector: "button",
|
||||
})
|
||||
);
|
||||
|
||||
subscriptionHandler.dispatch<
|
||||
SubscriptionToCommentLeftModerationQueueResolver
|
||||
>("commentLeftModerationQueue", variables => {
|
||||
if (
|
||||
variables.storyID !== null ||
|
||||
variables.queue !== GQLMODERATION_QUEUE.REPORTED
|
||||
) {
|
||||
return;
|
||||
}
|
||||
return {
|
||||
queue: GQLMODERATION_QUEUE.REPORTED,
|
||||
comment: commentData,
|
||||
};
|
||||
});
|
||||
|
||||
// View New Button should disappear.
|
||||
expect(() => within(container).getByText(/View \d+ new/)).toThrow();
|
||||
});
|
||||
@@ -0,0 +1,126 @@
|
||||
import { pureMerge } from "coral-common/utils";
|
||||
import {
|
||||
GQLCOMMENT_STATUS,
|
||||
GQLMODERATION_QUEUE,
|
||||
GQLResolver,
|
||||
ModerationQueueToCommentsResolver,
|
||||
SubscriptionToCommentLeftModerationQueueResolver,
|
||||
} from "coral-framework/schema";
|
||||
import {
|
||||
createQueryResolverStub,
|
||||
createResolversStub,
|
||||
CreateTestRendererParams,
|
||||
replaceHistoryLocation,
|
||||
waitForElement,
|
||||
within,
|
||||
} from "coral-framework/testHelpers";
|
||||
|
||||
import create from "../create";
|
||||
import {
|
||||
emptyModerationQueues,
|
||||
emptyRejectedComments,
|
||||
reportedComments,
|
||||
settings,
|
||||
users,
|
||||
} from "../fixtures";
|
||||
|
||||
const viewer = users.admins[0];
|
||||
const commentData = reportedComments[0];
|
||||
|
||||
async function createTestRenderer(
|
||||
params: CreateTestRendererParams<GQLResolver> = {}
|
||||
) {
|
||||
replaceHistoryLocation(`http://localhost/admin/moderate/reported`);
|
||||
const { testRenderer, context, subscriptionHandler } = create({
|
||||
...params,
|
||||
resolvers: pureMerge(
|
||||
createResolversStub<GQLResolver>({
|
||||
Query: {
|
||||
settings: () => settings,
|
||||
viewer: () => viewer,
|
||||
moderationQueues: () =>
|
||||
pureMerge(emptyModerationQueues, {
|
||||
reported: {
|
||||
count: 1,
|
||||
comments: createQueryResolverStub<
|
||||
ModerationQueueToCommentsResolver
|
||||
>(({ variables }) => {
|
||||
expectAndFail(variables).toEqual({ first: 5 });
|
||||
return {
|
||||
edges: [
|
||||
{
|
||||
node: commentData,
|
||||
cursor: commentData.createdAt,
|
||||
},
|
||||
],
|
||||
pageInfo: {
|
||||
endCursor: commentData.createdAt,
|
||||
hasNextPage: false,
|
||||
},
|
||||
};
|
||||
}),
|
||||
},
|
||||
}),
|
||||
comments: () => emptyRejectedComments,
|
||||
},
|
||||
}),
|
||||
params.resolvers
|
||||
),
|
||||
initLocalState: (localRecord, source, environment) => {
|
||||
localRecord.setValue(true, "loggedIn");
|
||||
if (params.initLocalState) {
|
||||
params.initLocalState(localRecord, source, environment);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const container = await waitForElement(() =>
|
||||
within(testRenderer.root).getByTestID("moderate-main-container")
|
||||
);
|
||||
|
||||
const comment = within(container).getByTestID(
|
||||
`moderate-comment-${commentData.id}`
|
||||
);
|
||||
|
||||
return { testRenderer, context, container, comment, subscriptionHandler };
|
||||
}
|
||||
|
||||
it("update comment status live", async () => {
|
||||
const { subscriptionHandler, comment } = await createTestRenderer();
|
||||
expect(subscriptionHandler.has("commentLeftModerationQueue")).toBe(true);
|
||||
expect(() =>
|
||||
within(comment).getByText("Moderated By", { exact: false })
|
||||
).toThrow();
|
||||
|
||||
subscriptionHandler.dispatch<
|
||||
SubscriptionToCommentLeftModerationQueueResolver
|
||||
>("commentLeftModerationQueue", variables => {
|
||||
if (
|
||||
variables.storyID !== null ||
|
||||
variables.queue !== GQLMODERATION_QUEUE.REPORTED
|
||||
) {
|
||||
return;
|
||||
}
|
||||
return {
|
||||
queue: GQLMODERATION_QUEUE.REPORTED,
|
||||
comment: pureMerge<typeof commentData>(commentData, {
|
||||
status: GQLCOMMENT_STATUS.APPROVED,
|
||||
statusHistory: {
|
||||
edges: [
|
||||
{
|
||||
node: {
|
||||
id: "mod-action-1",
|
||||
moderator: users.moderators[0],
|
||||
status: GQLCOMMENT_STATUS.APPROVED,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
await waitForElement(() =>
|
||||
within(comment).getByText("Moderated By", { exact: false })
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,314 @@
|
||||
import { pureMerge } from "coral-common/utils";
|
||||
import {
|
||||
GQLCOMMENT_STATUS,
|
||||
GQLResolver,
|
||||
MutationToApproveCommentResolver,
|
||||
} from "coral-framework/schema";
|
||||
import {
|
||||
createMutationResolverStub,
|
||||
createResolversStub,
|
||||
CreateTestRendererParams,
|
||||
replaceHistoryLocation,
|
||||
toJSON,
|
||||
waitForElement,
|
||||
waitUntilThrow,
|
||||
within,
|
||||
} from "coral-framework/testHelpers";
|
||||
|
||||
import create from "../create";
|
||||
import {
|
||||
emptyModerationQueues,
|
||||
emptyRejectedComments,
|
||||
rejectedComments,
|
||||
reportedComments,
|
||||
settings,
|
||||
users,
|
||||
} from "../fixtures";
|
||||
|
||||
const viewer = users.admins[0];
|
||||
|
||||
beforeEach(async () => {
|
||||
replaceHistoryLocation("http://localhost/admin/moderate");
|
||||
});
|
||||
|
||||
async function createTestRenderer(
|
||||
params: CreateTestRendererParams<GQLResolver> = {}
|
||||
) {
|
||||
const { testRenderer, context, subscriptionHandler } = create({
|
||||
...params,
|
||||
resolvers: pureMerge(
|
||||
createResolversStub<GQLResolver>({
|
||||
Query: {
|
||||
settings: () => settings,
|
||||
viewer: () => viewer,
|
||||
moderationQueues: () => emptyModerationQueues,
|
||||
comments: () => emptyRejectedComments,
|
||||
},
|
||||
}),
|
||||
params.resolvers
|
||||
),
|
||||
initLocalState: (localRecord, source, environment) => {
|
||||
localRecord.setValue(true, "loggedIn");
|
||||
if (params.initLocalState) {
|
||||
params.initLocalState(localRecord, source, environment);
|
||||
}
|
||||
},
|
||||
});
|
||||
return { testRenderer, context, subscriptionHandler };
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
replaceHistoryLocation(`http://localhost/admin/moderate/rejected`);
|
||||
});
|
||||
|
||||
it("renders rejected queue with comments", async () => {
|
||||
const { testRenderer } = await createTestRenderer({
|
||||
resolvers: createResolversStub<GQLResolver>({
|
||||
Query: {
|
||||
comments: ({ variables }) => {
|
||||
expectAndFail(variables).toEqual({
|
||||
first: 5,
|
||||
status: "REJECTED",
|
||||
storyID: null,
|
||||
});
|
||||
return {
|
||||
edges: [
|
||||
{
|
||||
node: rejectedComments[0],
|
||||
cursor: rejectedComments[0].createdAt,
|
||||
},
|
||||
{
|
||||
node: rejectedComments[1],
|
||||
cursor: rejectedComments[1].createdAt,
|
||||
},
|
||||
],
|
||||
pageInfo: {
|
||||
endCursor: rejectedComments[1].createdAt,
|
||||
hasNextPage: false,
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
}),
|
||||
});
|
||||
const { getByTestID } = within(testRenderer.root);
|
||||
await waitForElement(() => getByTestID("moderate-container"));
|
||||
expect(toJSON(getByTestID("moderate-main-container"))).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("shows a moderate story", async () => {
|
||||
const {
|
||||
testRenderer,
|
||||
context: { transitionControl },
|
||||
} = await createTestRenderer({
|
||||
resolvers: createResolversStub<GQLResolver>({
|
||||
Query: {
|
||||
comments: ({ variables }) => {
|
||||
expectAndFail(variables).toEqual({
|
||||
first: 5,
|
||||
status: "REJECTED",
|
||||
storyID: null,
|
||||
});
|
||||
return {
|
||||
edges: [
|
||||
{
|
||||
node: rejectedComments[0],
|
||||
cursor: rejectedComments[0].createdAt,
|
||||
},
|
||||
{
|
||||
node: rejectedComments[1],
|
||||
cursor: rejectedComments[1].createdAt,
|
||||
},
|
||||
],
|
||||
pageInfo: {
|
||||
endCursor: rejectedComments[1].createdAt,
|
||||
hasNextPage: false,
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
}),
|
||||
});
|
||||
const moderateStory = await waitForElement(
|
||||
() => within(testRenderer.root).getAllByText("Moderate Story")[0]
|
||||
);
|
||||
transitionControl.allowTransition = false;
|
||||
moderateStory.props.onClick({});
|
||||
// Expect a routing request was made to the right url.
|
||||
expect(transitionControl.history[0].pathname).toBe(
|
||||
`/admin/moderate/${reportedComments[0].story.id}`
|
||||
);
|
||||
});
|
||||
|
||||
it("renders rejected queue with comments and load more", async () => {
|
||||
const { testRenderer } = await createTestRenderer({
|
||||
resolvers: createResolversStub<GQLResolver>({
|
||||
Query: {
|
||||
comments: ({ variables, callCount }) => {
|
||||
switch (callCount) {
|
||||
case 0:
|
||||
expectAndFail(variables).toEqual({
|
||||
first: 5,
|
||||
status: GQLCOMMENT_STATUS.REJECTED,
|
||||
storyID: null,
|
||||
});
|
||||
return {
|
||||
edges: [
|
||||
{
|
||||
node: rejectedComments[0],
|
||||
cursor: rejectedComments[0].createdAt,
|
||||
},
|
||||
{
|
||||
node: rejectedComments[1],
|
||||
cursor: rejectedComments[1].createdAt,
|
||||
},
|
||||
],
|
||||
pageInfo: {
|
||||
endCursor: rejectedComments[1].createdAt,
|
||||
hasNextPage: true,
|
||||
},
|
||||
};
|
||||
default:
|
||||
expectAndFail(variables).toEqual({
|
||||
first: 10,
|
||||
after: rejectedComments[1].createdAt,
|
||||
status: GQLCOMMENT_STATUS.REJECTED,
|
||||
storyID: null,
|
||||
});
|
||||
return {
|
||||
edges: [
|
||||
{
|
||||
node: rejectedComments[2],
|
||||
cursor: rejectedComments[2].createdAt,
|
||||
},
|
||||
],
|
||||
pageInfo: {
|
||||
endCursor: rejectedComments[2].createdAt,
|
||||
hasNextPage: false,
|
||||
},
|
||||
};
|
||||
}
|
||||
},
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
const moderateContainer = await waitForElement(() =>
|
||||
within(testRenderer.root).getByTestID("moderate-container")
|
||||
);
|
||||
|
||||
const { getByText, getAllByTestID, getByTestID } = within(moderateContainer);
|
||||
|
||||
// Get previous count of comments.
|
||||
const previousCount = getAllByTestID(/^moderate-comment-.*$/).length;
|
||||
|
||||
const loadMore = await waitForElement(() => getByText("Load More"));
|
||||
loadMore.props.onClick();
|
||||
|
||||
// Wait for load more to disappear.
|
||||
await waitUntilThrow(() => getByText("Load More"));
|
||||
|
||||
// Verify we have one more item now.
|
||||
const comments = getAllByTestID(/^moderate-comment-.*$/);
|
||||
expect(comments.length).toBe(previousCount + 1);
|
||||
|
||||
// Verify last one added was our new one
|
||||
expect(comments[comments.length - 1].props["data-testid"]).toBe(
|
||||
`moderate-comment-${rejectedComments[2].id}`
|
||||
);
|
||||
|
||||
// Snapshot of added comment.
|
||||
expect(
|
||||
toJSON(getByTestID(`moderate-comment-${rejectedComments[2].id}`))
|
||||
).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("approves comment in rejected queue", async () => {
|
||||
const approveCommentStub = createMutationResolverStub<
|
||||
MutationToApproveCommentResolver
|
||||
>(({ variables }) => {
|
||||
expectAndFail(variables).toMatchObject({
|
||||
commentID: rejectedComments[0].id,
|
||||
commentRevisionID: rejectedComments[0].revision.id,
|
||||
});
|
||||
return {
|
||||
comment: {
|
||||
id: rejectedComments[0].id,
|
||||
status: GQLCOMMENT_STATUS.APPROVED,
|
||||
statusHistory: {
|
||||
edges: [
|
||||
{
|
||||
node: {
|
||||
id: "mod-action",
|
||||
author: {
|
||||
id: viewer.id,
|
||||
username: viewer.username,
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
moderationQueues: pureMerge(emptyModerationQueues, {
|
||||
reported: {
|
||||
count: 1,
|
||||
},
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
const { testRenderer } = await createTestRenderer({
|
||||
resolvers: createResolversStub<GQLResolver>({
|
||||
Query: {
|
||||
comments: ({ variables }) => {
|
||||
expectAndFail(variables).toEqual({
|
||||
first: 5,
|
||||
status: "REJECTED",
|
||||
storyID: null,
|
||||
});
|
||||
return {
|
||||
edges: [
|
||||
{
|
||||
node: rejectedComments[0],
|
||||
cursor: rejectedComments[0].createdAt,
|
||||
},
|
||||
{
|
||||
node: rejectedComments[1],
|
||||
cursor: rejectedComments[1].createdAt,
|
||||
},
|
||||
],
|
||||
pageInfo: {
|
||||
endCursor: rejectedComments[1].createdAt,
|
||||
hasNextPage: false,
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
Mutation: {
|
||||
approveComment: approveCommentStub,
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
const testID = `moderate-comment-${rejectedComments[0].id}`;
|
||||
const { getByTestID } = within(testRenderer.root);
|
||||
const comment = await waitForElement(() => getByTestID(testID));
|
||||
|
||||
const ApproveButton = await waitForElement(() =>
|
||||
within(comment).getByLabelText("Approve")
|
||||
);
|
||||
ApproveButton.props.onClick();
|
||||
|
||||
// Snapshot dangling state of comment.
|
||||
expect(toJSON(getByTestID(testID))).toMatchSnapshot("dangling");
|
||||
|
||||
// Wait until comment is gone.
|
||||
await waitUntilThrow(() => getByTestID(testID));
|
||||
|
||||
expect(approveCommentStub.called).toBe(true);
|
||||
|
||||
// Count should have been updated.
|
||||
expect(
|
||||
toJSON(getByTestID("moderate-navigation-reported-count"))
|
||||
).toMatchSnapshot("count should be 1");
|
||||
});
|
||||
@@ -0,0 +1,264 @@
|
||||
import { noop } from "lodash";
|
||||
import { ReactTestInstance, ReactTestRenderer } from "react-test-renderer";
|
||||
|
||||
import { pureMerge } from "coral-common/utils";
|
||||
import { GQLResolver } from "coral-framework/schema";
|
||||
import {
|
||||
act,
|
||||
createResolversStub,
|
||||
CreateTestRendererParams,
|
||||
findParentWithType,
|
||||
replaceHistoryLocation,
|
||||
wait,
|
||||
waitForElement,
|
||||
within,
|
||||
} from "coral-framework/testHelpers";
|
||||
|
||||
import create from "../create";
|
||||
import {
|
||||
emptyModerationQueues,
|
||||
emptyRejectedComments,
|
||||
emptyStories,
|
||||
settings,
|
||||
stories,
|
||||
storyConnection,
|
||||
users,
|
||||
} from "../fixtures";
|
||||
|
||||
const viewer = users.admins[0];
|
||||
|
||||
beforeEach(async () => {
|
||||
replaceHistoryLocation("http://localhost/admin/moderate");
|
||||
});
|
||||
|
||||
async function createTestRenderer(
|
||||
params: CreateTestRendererParams<GQLResolver> = {}
|
||||
) {
|
||||
const { testRenderer, context, subscriptionHandler } = create({
|
||||
...params,
|
||||
resolvers: pureMerge(
|
||||
createResolversStub<GQLResolver>({
|
||||
Query: {
|
||||
settings: () => settings,
|
||||
viewer: () => viewer,
|
||||
moderationQueues: () => emptyModerationQueues,
|
||||
comments: () => emptyRejectedComments,
|
||||
},
|
||||
}),
|
||||
params.resolvers
|
||||
),
|
||||
initLocalState: (localRecord, source, environment) => {
|
||||
localRecord.setValue(true, "loggedIn");
|
||||
if (params.initLocalState) {
|
||||
params.initLocalState(localRecord, source, environment);
|
||||
}
|
||||
},
|
||||
});
|
||||
return { testRenderer, context, subscriptionHandler };
|
||||
}
|
||||
|
||||
const openSearchBar = async (testRenderer: ReactTestRenderer) => {
|
||||
await act(async () => {
|
||||
await waitForElement(() =>
|
||||
within(testRenderer.root).getByTestID("moderate-searchBar-container")
|
||||
);
|
||||
});
|
||||
const searchBar = within(testRenderer.root).getByTestID(
|
||||
"moderate-searchBar-container"
|
||||
);
|
||||
const textField = within(searchBar).getByLabelText(
|
||||
"Search or jump to story..."
|
||||
);
|
||||
const form = findParentWithType(textField, "form")!;
|
||||
act(() => textField.props.onFocus({}));
|
||||
return { searchBar, textField, form };
|
||||
};
|
||||
|
||||
describe("all stories", () => {
|
||||
it("renders search bar", async () => {
|
||||
let searchBar: ReactTestInstance;
|
||||
await act(async () => {
|
||||
const { testRenderer } = await createTestRenderer();
|
||||
searchBar = await waitForElement(() =>
|
||||
within(testRenderer.root).getByTestID("moderate-searchBar-container")
|
||||
);
|
||||
});
|
||||
expect(within(searchBar!).toJSON()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
describe("active", () => {
|
||||
it("search with no results", async () => {
|
||||
const query = "InterestingStory";
|
||||
const { testRenderer } = await createTestRenderer({
|
||||
resolvers: createResolversStub<GQLResolver>({
|
||||
Query: {
|
||||
stories: ({ variables }) => {
|
||||
expectAndFail(variables.query).toBe(query);
|
||||
return emptyStories;
|
||||
},
|
||||
},
|
||||
}),
|
||||
});
|
||||
const { searchBar, textField, form } = await openSearchBar(testRenderer);
|
||||
expect(within(searchBar).toJSON()).toMatchSnapshot();
|
||||
|
||||
await act(async () => {
|
||||
// Search for sth.
|
||||
textField.props.onChange(query);
|
||||
form.props.onSubmit();
|
||||
|
||||
// Ensure no results message is shown.
|
||||
await wait(() =>
|
||||
within(searchBar).getByText("No results", { exact: false })
|
||||
);
|
||||
});
|
||||
|
||||
act(() => {
|
||||
// Blurring should close the listbox.
|
||||
textField.props.onBlur({});
|
||||
});
|
||||
expect(within(searchBar).queryByText("No results")).toBeNull();
|
||||
});
|
||||
it("search with actual results", async () => {
|
||||
const query = "InterestingStory";
|
||||
const {
|
||||
testRenderer,
|
||||
context: { transitionControl },
|
||||
} = await createTestRenderer({
|
||||
resolvers: createResolversStub<GQLResolver>({
|
||||
Query: {
|
||||
stories: ({ variables }) => {
|
||||
expectAndFail(variables.query).toBe(query);
|
||||
return storyConnection;
|
||||
},
|
||||
},
|
||||
}),
|
||||
});
|
||||
transitionControl.allowTransition = false;
|
||||
const { searchBar, textField, form } = await openSearchBar(testRenderer);
|
||||
|
||||
const story = storyConnection.edges[0].node;
|
||||
|
||||
let storyOption: ReactTestInstance;
|
||||
|
||||
await act(async () => {
|
||||
// Search for sth.
|
||||
textField.props.onChange(query);
|
||||
form.props.onSubmit();
|
||||
|
||||
// Find the story in the search results.
|
||||
storyOption = findParentWithType(
|
||||
await waitForElement(() =>
|
||||
within(searchBar).getByText(story.metadata!.title!, {
|
||||
exact: false,
|
||||
})
|
||||
),
|
||||
"li"
|
||||
)!;
|
||||
});
|
||||
|
||||
// Go to story.
|
||||
storyOption!.props.onClick({ button: 0, preventDefault: noop });
|
||||
|
||||
// Expect a routing request was made to the right url.
|
||||
expect(transitionControl.history[0].pathname).toBe(
|
||||
`/admin/moderate/${story.id}`
|
||||
);
|
||||
});
|
||||
it("search with too many results", async () => {
|
||||
const query = "InterestingStory";
|
||||
const {
|
||||
testRenderer,
|
||||
context: { transitionControl },
|
||||
} = await createTestRenderer({
|
||||
resolvers: createResolversStub<GQLResolver>({
|
||||
Query: {
|
||||
stories: ({ variables }) => {
|
||||
expectAndFail(variables.query).toBe(query);
|
||||
return pureMerge<typeof storyConnection>(storyConnection, {
|
||||
pageInfo: { hasNextPage: true },
|
||||
});
|
||||
},
|
||||
},
|
||||
}),
|
||||
});
|
||||
transitionControl.allowTransition = false;
|
||||
const { searchBar, textField, form } = await openSearchBar(testRenderer);
|
||||
|
||||
let seeAllOption: ReactTestInstance;
|
||||
await act(async () => {
|
||||
// Search for sth.
|
||||
textField.props.onChange(query);
|
||||
form.props.onSubmit();
|
||||
|
||||
// Find see all options in the search results.
|
||||
seeAllOption = findParentWithType(
|
||||
await waitForElement(() =>
|
||||
within(searchBar).getByText("See all results", { exact: false })
|
||||
),
|
||||
"li"
|
||||
)!;
|
||||
});
|
||||
|
||||
expect(within(seeAllOption!).toJSON()).toMatchSnapshot();
|
||||
|
||||
// Go to story.
|
||||
seeAllOption!.props.onClick({ button: 0, preventDefault: noop });
|
||||
|
||||
// Expect a routing request was made to the right url.
|
||||
expect(transitionControl.history[0].pathname).toBe("/admin/stories");
|
||||
expect(transitionControl.history[0].search).toBe(`?q=${query}`);
|
||||
});
|
||||
});
|
||||
});
|
||||
describe("specified story", () => {
|
||||
beforeEach(() => {
|
||||
replaceHistoryLocation(`http://localhost/admin/moderate/${stories[0].id}`);
|
||||
});
|
||||
it("renders search bar", async () => {
|
||||
await act(async () => {
|
||||
const { testRenderer } = await createTestRenderer({
|
||||
resolvers: createResolversStub<GQLResolver>({
|
||||
Query: {
|
||||
story: () => stories[0],
|
||||
},
|
||||
}),
|
||||
});
|
||||
const searchBar = await waitForElement(() =>
|
||||
within(testRenderer.root).getByTestID("moderate-searchBar-container")
|
||||
);
|
||||
const textField = within(searchBar).getByLabelText(
|
||||
"Search or jump to story..."
|
||||
);
|
||||
expect(textField.props.placeholder).toBe(stories[0].metadata!.title);
|
||||
});
|
||||
});
|
||||
it("shows moderate all option", async () => {
|
||||
const {
|
||||
testRenderer,
|
||||
context: { transitionControl },
|
||||
} = await createTestRenderer({
|
||||
resolvers: createResolversStub<GQLResolver>({
|
||||
Query: {
|
||||
story: () => stories[0],
|
||||
},
|
||||
}),
|
||||
});
|
||||
transitionControl.allowTransition = false;
|
||||
const { searchBar } = await openSearchBar(testRenderer);
|
||||
|
||||
// Find see all options in the search results.
|
||||
const moderateAllOptions = findParentWithType(
|
||||
await waitForElement(() =>
|
||||
within(searchBar).getByText("Moderate all", { exact: false })
|
||||
),
|
||||
"li"
|
||||
)!;
|
||||
|
||||
// Activate moderate all.
|
||||
moderateAllOptions.props.onClick({ button: 0, preventDefault: noop });
|
||||
|
||||
// Expect a routing request was made to the right url.
|
||||
expect(transitionControl.history[0].pathname).toBe("/admin/moderate");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,189 @@
|
||||
import { pureMerge } from "coral-common/utils";
|
||||
import {
|
||||
GQLCOMMENT_STATUS,
|
||||
MutationToApproveCommentResolver,
|
||||
MutationToRejectCommentResolver,
|
||||
QueryToCommentResolver,
|
||||
} from "coral-framework/schema";
|
||||
import { GQLResolver } from "coral-framework/schema";
|
||||
import {
|
||||
createMutationResolverStub,
|
||||
createQueryResolverStub,
|
||||
createResolversStub,
|
||||
CreateTestRendererParams,
|
||||
replaceHistoryLocation,
|
||||
toJSON,
|
||||
waitForElement,
|
||||
within,
|
||||
} from "coral-framework/testHelpers";
|
||||
|
||||
import create from "../create";
|
||||
import {
|
||||
emptyModerationQueues,
|
||||
rejectedComments,
|
||||
reportedComments,
|
||||
settings,
|
||||
users,
|
||||
} from "../fixtures";
|
||||
|
||||
const viewer = users.admins[0];
|
||||
|
||||
beforeEach(async () => {
|
||||
replaceHistoryLocation("http://localhost/admin/moderate");
|
||||
});
|
||||
|
||||
async function createTestRenderer(
|
||||
params: CreateTestRendererParams<GQLResolver> = {}
|
||||
) {
|
||||
const { testRenderer, context, subscriptionHandler } = create({
|
||||
...params,
|
||||
resolvers: pureMerge(
|
||||
createResolversStub<GQLResolver>({
|
||||
Query: {
|
||||
settings: () => settings,
|
||||
viewer: () => viewer,
|
||||
},
|
||||
}),
|
||||
params.resolvers
|
||||
),
|
||||
initLocalState: (localRecord, source, environment) => {
|
||||
localRecord.setValue(true, "loggedIn");
|
||||
if (params.initLocalState) {
|
||||
params.initLocalState(localRecord, source, environment);
|
||||
}
|
||||
},
|
||||
});
|
||||
return { testRenderer, context, subscriptionHandler };
|
||||
}
|
||||
|
||||
const comment = rejectedComments[0];
|
||||
const commentStub = createQueryResolverStub<QueryToCommentResolver>(
|
||||
({ variables }) => {
|
||||
expectAndFail(variables).toEqual({ id: comment.id });
|
||||
return reportedComments[0];
|
||||
}
|
||||
);
|
||||
|
||||
beforeEach(() => {
|
||||
replaceHistoryLocation(
|
||||
`http://localhost/admin/moderate/comment/${comment.id}`
|
||||
);
|
||||
});
|
||||
|
||||
it("renders single comment view", async () => {
|
||||
const { testRenderer } = await createTestRenderer({
|
||||
resolvers: {
|
||||
Query: {
|
||||
comment: commentStub,
|
||||
},
|
||||
},
|
||||
});
|
||||
const { getByTestID } = within(testRenderer.root);
|
||||
const container = await waitForElement(() =>
|
||||
getByTestID("single-moderate-container")
|
||||
);
|
||||
expect(toJSON(container)).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("approves single comment", async () => {
|
||||
const approveCommentStub = createMutationResolverStub<
|
||||
MutationToApproveCommentResolver
|
||||
>(({ variables }) => {
|
||||
expectAndFail(variables).toMatchObject({
|
||||
commentID: comment.id,
|
||||
commentRevisionID: comment.revision.id,
|
||||
});
|
||||
return {
|
||||
comment: {
|
||||
id: comment.id,
|
||||
status: GQLCOMMENT_STATUS.APPROVED,
|
||||
statusHistory: {
|
||||
edges: [
|
||||
{
|
||||
node: {
|
||||
id: "mod-action",
|
||||
author: {
|
||||
id: viewer.id,
|
||||
username: viewer.username,
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
moderationQueues: emptyModerationQueues,
|
||||
};
|
||||
});
|
||||
|
||||
const { testRenderer } = await createTestRenderer({
|
||||
resolvers: {
|
||||
Query: {
|
||||
comment: commentStub,
|
||||
},
|
||||
Mutation: {
|
||||
approveComment: approveCommentStub,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const { getByLabelText, getByTestID } = within(testRenderer.root);
|
||||
const ApproveButton = await waitForElement(() => getByLabelText("Approve"));
|
||||
ApproveButton.props.onClick();
|
||||
|
||||
expect(
|
||||
toJSON(getByTestID(`moderate-comment-${comment.id}`))
|
||||
).toMatchSnapshot();
|
||||
|
||||
expect(approveCommentStub.called).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects single comment", async () => {
|
||||
const rejectCommentStub = createMutationResolverStub<
|
||||
MutationToRejectCommentResolver
|
||||
>(({ variables }) => {
|
||||
expectAndFail(variables).toMatchObject({
|
||||
commentID: comment.id,
|
||||
commentRevisionID: comment.revision.id,
|
||||
});
|
||||
return {
|
||||
comment: {
|
||||
id: comment.id,
|
||||
status: GQLCOMMENT_STATUS.REJECTED,
|
||||
statusHistory: {
|
||||
edges: [
|
||||
{
|
||||
node: {
|
||||
id: "mod-action",
|
||||
author: {
|
||||
id: viewer.id,
|
||||
username: viewer.username,
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
moderationQueues: emptyModerationQueues,
|
||||
};
|
||||
});
|
||||
|
||||
const { testRenderer } = await createTestRenderer({
|
||||
resolvers: {
|
||||
Query: {
|
||||
comment: commentStub,
|
||||
},
|
||||
Mutation: {
|
||||
rejectComment: rejectCommentStub,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const { getByLabelText, getByTestID } = within(testRenderer.root);
|
||||
const RejectButton = await waitForElement(() => getByLabelText("Reject"));
|
||||
RejectButton.props.onClick();
|
||||
|
||||
expect(
|
||||
toJSON(getByTestID(`moderate-comment-${comment.id}`))
|
||||
).toMatchSnapshot();
|
||||
expect(rejectCommentStub.called).toBe(true);
|
||||
});
|
||||
@@ -0,0 +1,99 @@
|
||||
import { pureMerge } from "coral-common/utils";
|
||||
import {
|
||||
GQLCOMMENT_STATUS,
|
||||
GQLResolver,
|
||||
SubscriptionToCommentStatusUpdatedResolver,
|
||||
} from "coral-framework/schema";
|
||||
import {
|
||||
createResolversStub,
|
||||
CreateTestRendererParams,
|
||||
replaceHistoryLocation,
|
||||
waitForElement,
|
||||
within,
|
||||
} from "coral-framework/testHelpers";
|
||||
|
||||
import create from "../create";
|
||||
import { reportedComments, settings, users } from "../fixtures";
|
||||
|
||||
const viewer = users.admins[0];
|
||||
const commentData = reportedComments[0];
|
||||
|
||||
beforeEach(async () => {
|
||||
replaceHistoryLocation("http://localhost/admin/moderate");
|
||||
});
|
||||
|
||||
async function createTestRenderer(
|
||||
params: CreateTestRendererParams<GQLResolver> = {}
|
||||
) {
|
||||
replaceHistoryLocation(
|
||||
`http://localhost/admin/moderate/comment/${commentData.id}`
|
||||
);
|
||||
const { testRenderer, context, subscriptionHandler } = create({
|
||||
...params,
|
||||
resolvers: pureMerge(
|
||||
createResolversStub<GQLResolver>({
|
||||
Query: {
|
||||
settings: () => settings,
|
||||
viewer: () => viewer,
|
||||
comment: () => commentData,
|
||||
},
|
||||
}),
|
||||
params.resolvers
|
||||
),
|
||||
initLocalState: (localRecord, source, environment) => {
|
||||
localRecord.setValue(true, "loggedIn");
|
||||
if (params.initLocalState) {
|
||||
params.initLocalState(localRecord, source, environment);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const container = await waitForElement(() =>
|
||||
within(testRenderer.root).getByTestID("single-moderate-container")
|
||||
);
|
||||
|
||||
const comment = within(container).getByTestID(
|
||||
`moderate-comment-${commentData.id}`
|
||||
);
|
||||
|
||||
return { testRenderer, context, container, comment, subscriptionHandler };
|
||||
}
|
||||
|
||||
it("update comment status live", async () => {
|
||||
const { subscriptionHandler, comment } = await createTestRenderer();
|
||||
expect(subscriptionHandler.has("commentStatusUpdated")).toBe(true);
|
||||
expect(() =>
|
||||
within(comment).getByText("Moderated By", { exact: false })
|
||||
).toThrow();
|
||||
|
||||
subscriptionHandler.dispatch<SubscriptionToCommentStatusUpdatedResolver>(
|
||||
"commentStatusUpdated",
|
||||
variables => {
|
||||
if (variables.id !== commentData.id) {
|
||||
return;
|
||||
}
|
||||
return {
|
||||
newStatus: GQLCOMMENT_STATUS.APPROVED,
|
||||
comment: pureMerge<typeof commentData>(commentData, {
|
||||
status: GQLCOMMENT_STATUS.APPROVED,
|
||||
statusHistory: {
|
||||
edges: [
|
||||
{
|
||||
node: {
|
||||
id: "mod-action-1",
|
||||
moderator: users.moderators[0],
|
||||
status: GQLCOMMENT_STATUS.APPROVED,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
// When status was changed by another user, the moderated by info should appear.
|
||||
await waitForElement(() =>
|
||||
within(comment).getByText("Moderated By", { exact: false })
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,69 @@
|
||||
import { pureMerge } from "coral-common/utils";
|
||||
import { GQLResolver } from "coral-framework/schema";
|
||||
import {
|
||||
act,
|
||||
createResolversStub,
|
||||
CreateTestRendererParams,
|
||||
replaceHistoryLocation,
|
||||
} from "coral-framework/testHelpers";
|
||||
|
||||
import create from "../create";
|
||||
import {
|
||||
emptyModerationQueues,
|
||||
emptyRejectedComments,
|
||||
settings,
|
||||
stories,
|
||||
users,
|
||||
} from "../fixtures";
|
||||
|
||||
const viewer = users.admins[0];
|
||||
|
||||
beforeEach(async () => {
|
||||
replaceHistoryLocation("http://localhost/admin/moderate");
|
||||
});
|
||||
|
||||
async function createTestRenderer(
|
||||
params: CreateTestRendererParams<GQLResolver> = {}
|
||||
) {
|
||||
const { testRenderer, context, subscriptionHandler } = create({
|
||||
...params,
|
||||
resolvers: pureMerge(
|
||||
createResolversStub<GQLResolver>({
|
||||
Query: {
|
||||
settings: () => settings,
|
||||
viewer: () => viewer,
|
||||
moderationQueues: () => emptyModerationQueues,
|
||||
comments: () => emptyRejectedComments,
|
||||
},
|
||||
}),
|
||||
params.resolvers
|
||||
),
|
||||
initLocalState: (localRecord, source, environment) => {
|
||||
localRecord.setValue(true, "loggedIn");
|
||||
if (params.initLocalState) {
|
||||
params.initLocalState(localRecord, source, environment);
|
||||
}
|
||||
},
|
||||
});
|
||||
return { testRenderer, context, subscriptionHandler };
|
||||
}
|
||||
|
||||
it("passes storyID to the endpoints", async () => {
|
||||
replaceHistoryLocation(`http://localhost/admin/moderate/${stories[0].id}`);
|
||||
await act(async () => {
|
||||
await createTestRenderer({
|
||||
resolvers: createResolversStub<GQLResolver>({
|
||||
Query: {
|
||||
moderationQueues: ({ variables }) => {
|
||||
expectAndFail(variables.storyID).toBe(stories[0].id);
|
||||
return emptyModerationQueues;
|
||||
},
|
||||
comments: ({ variables }) => {
|
||||
expectAndFail(variables.storyID).toBe(stories[0].id);
|
||||
return emptyRejectedComments;
|
||||
},
|
||||
},
|
||||
}),
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,74 @@
|
||||
import { pureMerge } from "coral-common/utils";
|
||||
import { GQLResolver } from "coral-framework/schema";
|
||||
import {
|
||||
act,
|
||||
createResolversStub,
|
||||
CreateTestRendererParams,
|
||||
replaceHistoryLocation,
|
||||
toJSON,
|
||||
waitForElement,
|
||||
within,
|
||||
} from "coral-framework/testHelpers";
|
||||
|
||||
import create from "../create";
|
||||
import {
|
||||
emptyModerationQueues,
|
||||
emptyRejectedComments,
|
||||
settings,
|
||||
users,
|
||||
} from "../fixtures";
|
||||
|
||||
const viewer = users.admins[0];
|
||||
|
||||
beforeEach(async () => {
|
||||
replaceHistoryLocation("http://localhost/admin/moderate");
|
||||
});
|
||||
|
||||
async function createTestRenderer(
|
||||
params: CreateTestRendererParams<GQLResolver> = {}
|
||||
) {
|
||||
const { testRenderer, context, subscriptionHandler } = create({
|
||||
...params,
|
||||
resolvers: pureMerge(
|
||||
createResolversStub<GQLResolver>({
|
||||
Query: {
|
||||
settings: () => settings,
|
||||
viewer: () => viewer,
|
||||
moderationQueues: () => emptyModerationQueues,
|
||||
comments: () => emptyRejectedComments,
|
||||
},
|
||||
}),
|
||||
params.resolvers
|
||||
),
|
||||
initLocalState: (localRecord, source, environment) => {
|
||||
localRecord.setValue(true, "loggedIn");
|
||||
if (params.initLocalState) {
|
||||
params.initLocalState(localRecord, source, environment);
|
||||
}
|
||||
},
|
||||
});
|
||||
return { testRenderer, context, subscriptionHandler };
|
||||
}
|
||||
|
||||
describe("tab bar", () => {
|
||||
it("renders tab bar (empty queues)", async () => {
|
||||
await act(async () => {
|
||||
const { testRenderer } = await createTestRenderer();
|
||||
const { getByTestID } = within(testRenderer.root);
|
||||
await waitForElement(() => getByTestID("moderate-container"));
|
||||
expect(
|
||||
toJSON(getByTestID("moderate-tabBar-container"))
|
||||
).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
it("should not show moderate story link in comment cards", async () => {
|
||||
await act(async () => {
|
||||
const { testRenderer } = await createTestRenderer();
|
||||
const { getByTestID } = within(testRenderer.root);
|
||||
await waitForElement(() => getByTestID("moderate-container"));
|
||||
expect(
|
||||
within(testRenderer.root).queryByText("Moderate Story")
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -5,7 +5,7 @@ import { Child as PymChild } from "pym.js";
|
||||
import React, { Component, ComponentType } from "react";
|
||||
import { Formatter } from "react-timeago";
|
||||
import { Environment, RecordSource, Store } from "relay-runtime";
|
||||
import uuid from "uuid/v4";
|
||||
import uuid from "uuid/v1";
|
||||
|
||||
import { getBrowserInfo } from "coral-framework/lib/browserInfo";
|
||||
import { LOCAL_ID } from "coral-framework/lib/relay";
|
||||
@@ -21,7 +21,12 @@ import { RestClient } from "coral-framework/lib/rest";
|
||||
import { ClickFarAwayRegister } from "coral-ui/components/ClickOutside";
|
||||
|
||||
import { generateBundles, LocalesData, negotiateLanguages } from "../i18n";
|
||||
import { createNetwork, TokenGetter } from "../network";
|
||||
import {
|
||||
createManagedSubscriptionClient,
|
||||
createNetwork,
|
||||
ManagedSubscriptionClient,
|
||||
TokenGetter,
|
||||
} from "../network";
|
||||
import { PostMessageService } from "../postMessage";
|
||||
import { CoralContext, CoralContextProvider } from "./CoralContext";
|
||||
import SendPymReady from "./SendPymReady";
|
||||
@@ -48,6 +53,11 @@ interface CreateContextArguments {
|
||||
eventEmitter?: EventEmitter2;
|
||||
}
|
||||
|
||||
/** websocketURL points to our live graphql server */
|
||||
const websocketURL = `${location.protocol === "https:" ? "wss" : "ws"}://${
|
||||
location.hostname
|
||||
}:${location.port}/api/graphql/live`;
|
||||
|
||||
/**
|
||||
* timeagoFormatter integrates timeago into our translation
|
||||
* framework. It gets injected into the UIContext.
|
||||
@@ -87,8 +97,10 @@ function areWeInIframe() {
|
||||
}
|
||||
}
|
||||
|
||||
function createRelayEnvironment() {
|
||||
// Initialize Relay.
|
||||
function createRelayEnvironment(
|
||||
subscriptionClient: ManagedSubscriptionClient,
|
||||
clientID: string
|
||||
) {
|
||||
const source = new RecordSource();
|
||||
const tokenGetter: TokenGetter = () => {
|
||||
const localState = source.get(LOCAL_ID);
|
||||
@@ -98,14 +110,14 @@ function createRelayEnvironment() {
|
||||
return "";
|
||||
};
|
||||
const environment = new Environment({
|
||||
network: createNetwork(tokenGetter),
|
||||
network: createNetwork(subscriptionClient, tokenGetter, clientID),
|
||||
store: new Store(source),
|
||||
});
|
||||
return { environment, tokenGetter };
|
||||
return { environment, tokenGetter, subscriptionClient };
|
||||
}
|
||||
|
||||
function createRestClient(tokenGetter: () => string) {
|
||||
return new RestClient("/api", tokenGetter);
|
||||
function createRestClient(tokenGetter: () => string, clientID: string) {
|
||||
return new RestClient("/api", tokenGetter, clientID);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -114,6 +126,8 @@ function createRestClient(tokenGetter: () => string) {
|
||||
*/
|
||||
function createMangedCoralContextProvider(
|
||||
context: CoralContext,
|
||||
subscriptionClient: ManagedSubscriptionClient,
|
||||
clientID: string,
|
||||
initLocalState: InitLocalState
|
||||
) {
|
||||
const ManagedCoralContextProvider = class extends Component<
|
||||
@@ -135,25 +149,40 @@ function createMangedCoralContextProvider(
|
||||
// Clear session storage.
|
||||
this.state.context.sessionStorage.clear();
|
||||
|
||||
// Pause subscriptions.
|
||||
subscriptionClient.pause();
|
||||
|
||||
// Create a new context with a new Relay Environment.
|
||||
const {
|
||||
environment: newEnvironment,
|
||||
tokenGetter: newTokenGetter,
|
||||
} = createRelayEnvironment();
|
||||
} = createRelayEnvironment(subscriptionClient, clientID);
|
||||
|
||||
const newContext = {
|
||||
...this.state.context,
|
||||
relayEnvironment: newEnvironment,
|
||||
rest: createRestClient(newTokenGetter),
|
||||
rest: createRestClient(newTokenGetter, clientID),
|
||||
};
|
||||
|
||||
// Initialize local state.
|
||||
await initLocalState(newContext.relayEnvironment, newContext);
|
||||
|
||||
// Set new token for the websocket connection.
|
||||
// TODO: (cvle) dynamically reset when token changes.
|
||||
// ^ only necessary when we can prolong existing session using
|
||||
// a new token.
|
||||
subscriptionClient.setAccessToken(newTokenGetter());
|
||||
|
||||
// Propagate new context.
|
||||
this.setState({
|
||||
context: newContext,
|
||||
});
|
||||
this.setState(
|
||||
{
|
||||
context: newContext,
|
||||
},
|
||||
() => {
|
||||
// Resume subscriptions after context has changed.
|
||||
subscriptionClient.resume();
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
public render() {
|
||||
@@ -233,7 +262,18 @@ export default async function createManaged({
|
||||
const localStorage = resolveLocalStorage(pym);
|
||||
const sessionStorage = resolveSessionStorage(pym);
|
||||
|
||||
const { environment, tokenGetter } = createRelayEnvironment();
|
||||
/** clientID is sent to the server with every request */
|
||||
const clientID = uuid();
|
||||
|
||||
const subscriptionClient = createManagedSubscriptionClient(
|
||||
websocketURL,
|
||||
clientID
|
||||
);
|
||||
|
||||
const { environment, tokenGetter } = createRelayEnvironment(
|
||||
subscriptionClient,
|
||||
clientID
|
||||
);
|
||||
|
||||
// Assemble context.
|
||||
const context: CoralContext = {
|
||||
@@ -244,7 +284,7 @@ export default async function createManaged({
|
||||
pym,
|
||||
eventEmitter,
|
||||
registerClickFarAway,
|
||||
rest: createRestClient(tokenGetter),
|
||||
rest: createRestClient(tokenGetter, clientID),
|
||||
postMessage: new PostMessageService(),
|
||||
localStorage,
|
||||
sessionStorage,
|
||||
@@ -258,7 +298,18 @@ export default async function createManaged({
|
||||
// Initialize local state.
|
||||
await initLocalState(context.relayEnvironment, context);
|
||||
|
||||
// Set current token for the websocket connection.
|
||||
// TODO: (cvle) dynamically reset when token changes.
|
||||
// ^ only necessary when we can prolong existing session using
|
||||
// a new token.
|
||||
subscriptionClient.setAccessToken(tokenGetter());
|
||||
|
||||
// Returns a managed CoralContextProvider, that includes the above
|
||||
// context and handles context changes, e.g. when a user session changes.
|
||||
return createMangedCoralContextProvider(context, initLocalState);
|
||||
return createMangedCoralContextProvider(
|
||||
context,
|
||||
subscriptionClient,
|
||||
clientID,
|
||||
initLocalState
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import { Middleware } from "react-relay-network-modern/es";
|
||||
|
||||
import { CLIENT_ID_HEADER } from "coral-common/constants";
|
||||
|
||||
/**
|
||||
* Sets clientID on the header.
|
||||
*
|
||||
* @param clientID an identifier for this client.
|
||||
*/
|
||||
const clientIDMiddleware: (
|
||||
clientID: string
|
||||
) => Middleware = clientID => next => async req => {
|
||||
if (!req.fetchOpts.headers) {
|
||||
req.fetchOpts.headers = {};
|
||||
}
|
||||
req.fetchOpts.headers[CLIENT_ID_HEADER] = clientID;
|
||||
return next(req);
|
||||
};
|
||||
|
||||
export default clientIDMiddleware;
|
||||
@@ -0,0 +1,179 @@
|
||||
import {
|
||||
CacheConfig,
|
||||
ConcreteBatch,
|
||||
Disposable,
|
||||
Variables,
|
||||
} from "react-relay-network-modern/es";
|
||||
import { SubscriptionClient } from "subscriptions-transport-ws";
|
||||
|
||||
import { ACCESS_TOKEN_PARAM, CLIENT_ID_PARAM } from "coral-common/constants";
|
||||
|
||||
/**
|
||||
* SubscriptionRequest containts the subscription
|
||||
* request data that comes from Relay.
|
||||
*/
|
||||
export interface SubscriptionRequest {
|
||||
operation: ConcreteBatch;
|
||||
variables: Variables;
|
||||
cacheConfig: CacheConfig;
|
||||
observer: any;
|
||||
subscribe: () => void;
|
||||
unsubscribe: (() => void) | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* ManagedSubscriptionClient builts on top of `SubscriptionClient`
|
||||
* and manages the websocket connection economically. A connection is
|
||||
* only establish when there is at least 1 active susbcription and closes
|
||||
* when there is no more active subscriptions.
|
||||
*/
|
||||
export interface ManagedSubscriptionClient {
|
||||
/**
|
||||
* Susbcribe to a GraphQL subscription, this is usually called from
|
||||
* the SubscriptionFunction provided to Relay.
|
||||
*/
|
||||
subscribe(
|
||||
operation: ConcreteBatch,
|
||||
variables: Variables,
|
||||
cacheConfig: CacheConfig,
|
||||
observer: any
|
||||
): Disposable;
|
||||
/** Pauses all active subscriptions causing websocket connection to close. */
|
||||
pause(): void;
|
||||
/** Resume all subscriptions eventually causing websocket to start with new connection parameters */
|
||||
resume(): void;
|
||||
/** Sets access token and restarts the websocket connection */
|
||||
setAccessToken(accessToken: string): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a ManagedSubscriptionClient
|
||||
* @param url url of the graphql live server
|
||||
* @param clientID a clientID that is provided to the graphql live server
|
||||
*/
|
||||
export default function createManagedSubscriptionClient(
|
||||
url: string,
|
||||
clientID: string
|
||||
): ManagedSubscriptionClient {
|
||||
const requests: SubscriptionRequest[] = [];
|
||||
let subscriptionClient: SubscriptionClient | null = null;
|
||||
let paused = false;
|
||||
let accessToken = "";
|
||||
|
||||
const closeClient = () => {
|
||||
if (subscriptionClient) {
|
||||
subscriptionClient.close();
|
||||
// Stop current retry attempt.
|
||||
// TODO: (cvle) This relies on internals.
|
||||
(subscriptionClient as any).clearMaxConnectTimeout();
|
||||
(subscriptionClient as any).clearTryReconnectTimeout();
|
||||
subscriptionClient = null;
|
||||
}
|
||||
};
|
||||
|
||||
const subscribe = (
|
||||
operation: ConcreteBatch,
|
||||
variables: Variables,
|
||||
cacheConfig: CacheConfig,
|
||||
observer: any
|
||||
) => {
|
||||
// Capture request into an `SubscriptionRequest` object.
|
||||
const request: Partial<SubscriptionRequest> = {
|
||||
operation,
|
||||
variables,
|
||||
cacheConfig,
|
||||
observer,
|
||||
};
|
||||
request.subscribe = () => {
|
||||
if (!subscriptionClient) {
|
||||
subscriptionClient = new SubscriptionClient(url, {
|
||||
reconnect: true,
|
||||
connectionParams: {
|
||||
[ACCESS_TOKEN_PARAM]: accessToken,
|
||||
[CLIENT_ID_PARAM]: clientID,
|
||||
},
|
||||
});
|
||||
}
|
||||
const subscription = subscriptionClient
|
||||
.request({
|
||||
operationName: operation.name,
|
||||
query: operation.text!,
|
||||
variables,
|
||||
})
|
||||
.subscribe({
|
||||
next({ data }) {
|
||||
observer.onNext({ data });
|
||||
},
|
||||
});
|
||||
request.unsubscribe = () => {
|
||||
subscription.unsubscribe();
|
||||
};
|
||||
};
|
||||
// Register the request.
|
||||
requests.push(request as SubscriptionRequest);
|
||||
|
||||
// Start susbcription if we are not paused.
|
||||
if (!paused) {
|
||||
request.subscribe();
|
||||
}
|
||||
return {
|
||||
dispose: () => {
|
||||
const i = requests.findIndex(r => r === request);
|
||||
if (i !== -1) {
|
||||
// Unsubscribe if available.
|
||||
if (request.unsubscribe) {
|
||||
request.unsubscribe();
|
||||
}
|
||||
// Remove from requests list.
|
||||
requests.splice(i, 1);
|
||||
|
||||
// Close client if there is no active subscription.
|
||||
if (
|
||||
subscriptionClient &&
|
||||
(requests.length === 0 || requests.every(r => !r.unsubscribe))
|
||||
) {
|
||||
closeClient();
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const pause = () => {
|
||||
paused = true;
|
||||
// Unsubscribe from all active subscriptions.
|
||||
for (const r of requests) {
|
||||
if (r.unsubscribe) {
|
||||
r.unsubscribe();
|
||||
r.unsubscribe = null;
|
||||
}
|
||||
}
|
||||
// Close websocket conncetion.
|
||||
closeClient();
|
||||
};
|
||||
|
||||
const resume = () => {
|
||||
// Resume all subscriptions.
|
||||
for (const r of requests) {
|
||||
if (!r.unsubscribe) {
|
||||
r.subscribe();
|
||||
}
|
||||
}
|
||||
paused = false;
|
||||
};
|
||||
|
||||
const setAccessToken = (t: string) => {
|
||||
accessToken = t;
|
||||
if (!paused) {
|
||||
pause();
|
||||
resume();
|
||||
}
|
||||
};
|
||||
|
||||
return Object.freeze({
|
||||
subscribe,
|
||||
pause,
|
||||
resume,
|
||||
setAccessToken,
|
||||
});
|
||||
}
|
||||
@@ -4,39 +4,69 @@ import {
|
||||
cacheMiddleware,
|
||||
RelayNetworkLayer,
|
||||
retryMiddleware,
|
||||
SubscribeFunction,
|
||||
urlMiddleware,
|
||||
} from "react-relay-network-modern/es";
|
||||
|
||||
import clientIDMiddleware from "./clientIDMiddleware";
|
||||
import { ManagedSubscriptionClient } from "./createManagedSubscriptionClient";
|
||||
import customErrorMiddleware from "./customErrorMiddleware";
|
||||
|
||||
export type TokenGetter = () => string;
|
||||
|
||||
const graphqlURL = "/api/graphql";
|
||||
|
||||
export default function createNetwork(tokenGetter: TokenGetter) {
|
||||
return new RelayNetworkLayer([
|
||||
customErrorMiddleware,
|
||||
cacheMiddleware({
|
||||
size: 100, // max 100 requests
|
||||
ttl: 900000, // 15 minutes
|
||||
clearOnMutation: true,
|
||||
}),
|
||||
urlMiddleware({
|
||||
url: req => Promise.resolve(graphqlURL),
|
||||
}),
|
||||
batchMiddleware({
|
||||
batchUrl: (requestMap: any) => Promise.resolve(graphqlURL),
|
||||
batchTimeout: 0,
|
||||
allowMutations: true,
|
||||
}),
|
||||
retryMiddleware({
|
||||
fetchTimeout: 15000,
|
||||
retryDelays: (attempt: number) => Math.pow(2, attempt + 4) * 100,
|
||||
// or simple array [3200, 6400, 12800, 25600, 51200, 102400, 204800, 409600],
|
||||
statusCodes: [500, 503, 504],
|
||||
}),
|
||||
authMiddleware({
|
||||
token: tokenGetter,
|
||||
}),
|
||||
]);
|
||||
function createSubscriptionFunction(
|
||||
subscriptionClient: ManagedSubscriptionClient
|
||||
): SubscribeFunction {
|
||||
const fn: SubscribeFunction = (
|
||||
operation,
|
||||
variables,
|
||||
cacheConfig,
|
||||
observer
|
||||
) => {
|
||||
return subscriptionClient.subscribe(
|
||||
operation,
|
||||
variables,
|
||||
cacheConfig,
|
||||
observer
|
||||
);
|
||||
};
|
||||
return fn;
|
||||
}
|
||||
|
||||
export default function createNetwork(
|
||||
subscriptionClient: ManagedSubscriptionClient,
|
||||
tokenGetter: TokenGetter,
|
||||
clientID: string
|
||||
) {
|
||||
return new RelayNetworkLayer(
|
||||
[
|
||||
customErrorMiddleware,
|
||||
cacheMiddleware({
|
||||
size: 100, // max 100 requests
|
||||
ttl: 900000, // 15 minutes
|
||||
clearOnMutation: true,
|
||||
}),
|
||||
urlMiddleware({
|
||||
url: () => Promise.resolve(graphqlURL),
|
||||
}),
|
||||
batchMiddleware({
|
||||
batchUrl: (requestMap: any) => Promise.resolve(graphqlURL),
|
||||
batchTimeout: 0,
|
||||
allowMutations: true,
|
||||
}),
|
||||
retryMiddleware({
|
||||
fetchTimeout: 15000,
|
||||
retryDelays: (attempt: number) => Math.pow(2, attempt + 4) * 100,
|
||||
// or simple array [3200, 6400, 12800, 25600, 51200, 102400, 204800, 409600],
|
||||
statusCodes: [500, 503, 504],
|
||||
}),
|
||||
authMiddleware({
|
||||
token: tokenGetter,
|
||||
}),
|
||||
clientIDMiddleware(clientID),
|
||||
],
|
||||
{ subscribeFn: createSubscriptionFunction(subscriptionClient) }
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
export { default as createNetwork, TokenGetter } from "./createNetwork";
|
||||
export { default as extractGraphQLError } from "./extractGraphQLError";
|
||||
export { default as extractError } from "./extractError";
|
||||
export {
|
||||
default as createManagedSubscriptionClient,
|
||||
ManagedSubscriptionClient,
|
||||
} from "./createManagedSubscriptionClient";
|
||||
|
||||
@@ -43,3 +43,11 @@ export { default as useRefetch } from "./useRefetch";
|
||||
export { default as useLoadMore } from "./useLoadMore";
|
||||
export { default as lookup } from "./lookup";
|
||||
export { default as useLocal } from "./useLocal";
|
||||
export {
|
||||
useSubscription,
|
||||
createSubscription,
|
||||
SubscriptionProp,
|
||||
SubscriptionVariables,
|
||||
withSubscription,
|
||||
combineDisposables,
|
||||
} from "./subscription";
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
import { useCallback } from "react";
|
||||
import { InferableComponentEnhancer } from "recompose";
|
||||
import { Disposable, Environment } from "relay-runtime";
|
||||
|
||||
import { CoralContext, useCoralContext } from "../bootstrap";
|
||||
|
||||
export type SubscriptionVariables<
|
||||
T extends { variables: any }
|
||||
> = T["variables"];
|
||||
export interface Subscription<N, V> {
|
||||
name: N;
|
||||
subscribe: (
|
||||
environment: Environment,
|
||||
variables: V,
|
||||
context: CoralContext
|
||||
) => Disposable;
|
||||
}
|
||||
|
||||
export type SubscriptionProp<
|
||||
T extends Subscription<any, any>
|
||||
> = T extends Subscription<any, infer V>
|
||||
? Parameters<T["subscribe"]>[1] extends undefined
|
||||
? () => Disposable
|
||||
: keyof Parameters<T["subscribe"]>[1] extends never
|
||||
? () => Disposable
|
||||
: (variables: V) => Disposable
|
||||
: never;
|
||||
|
||||
export function createSubscription<N extends string, V>(
|
||||
name: N,
|
||||
subscribe: (
|
||||
environment: Environment,
|
||||
variables: V,
|
||||
context: CoralContext
|
||||
) => Disposable
|
||||
): Subscription<N, V> {
|
||||
return {
|
||||
name,
|
||||
subscribe,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* useSubscription is a React Hook that
|
||||
* returns a callback to subscribes to a Subscription.
|
||||
*/
|
||||
export function useSubscription<V>(
|
||||
subscription: Subscription<any, V>
|
||||
): SubscriptionProp<typeof subscription> {
|
||||
const context = useCoralContext();
|
||||
return useCallback<SubscriptionProp<typeof subscription>>(
|
||||
((variables: V) => {
|
||||
context.eventEmitter.emit(`subscription.${subscription.name}`, variables);
|
||||
return subscription.subscribe(
|
||||
context.relayEnvironment,
|
||||
variables,
|
||||
context
|
||||
);
|
||||
}) as any,
|
||||
[context]
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* withSubscription creates a HOC that injects the subscription as
|
||||
* a property.
|
||||
*
|
||||
* @deprecated use `useFetch` instead
|
||||
*/
|
||||
export function withSubscription<N extends string, V, R>(
|
||||
subscription: Subscription<N, V>
|
||||
): InferableComponentEnhancer<
|
||||
{ [P in N]: SubscriptionProp<typeof subscription> }
|
||||
> {
|
||||
return (BaseComponent: React.ComponentType<any>) => {
|
||||
{
|
||||
const sub = useSubscription(subscription);
|
||||
return props => {
|
||||
const finalProps = {
|
||||
...props,
|
||||
[subscription.name]: sub,
|
||||
};
|
||||
return <BaseComponent {...finalProps} />;
|
||||
};
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Combines disposables into one.
|
||||
*/
|
||||
export function combineDisposables(...disposables: Disposable[]): Disposable {
|
||||
return {
|
||||
dispose: () => {
|
||||
disposables.forEach(d => d.dispose());
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
import { Overwrite } from "coral-framework/types";
|
||||
import { merge } from "lodash";
|
||||
|
||||
import { CLIENT_ID_HEADER } from "coral-common/constants";
|
||||
import { Overwrite } from "coral-framework/types";
|
||||
|
||||
import { extractError } from "./network";
|
||||
|
||||
const buildOptions = (inputOptions: RequestInit = {}) => {
|
||||
@@ -52,10 +54,12 @@ type PartialRequestInit = Overwrite<Partial<RequestInit>, { body?: any }> & {
|
||||
export class RestClient {
|
||||
public readonly uri: string;
|
||||
private tokenGetter?: () => string;
|
||||
private clientID?: string;
|
||||
|
||||
constructor(uri: string, tokenGetter?: () => string) {
|
||||
constructor(uri: string, tokenGetter?: () => string, clientID?: string) {
|
||||
this.uri = uri;
|
||||
this.tokenGetter = tokenGetter;
|
||||
this.clientID = clientID;
|
||||
}
|
||||
|
||||
public async fetch<T = {}>(
|
||||
@@ -71,6 +75,13 @@ export class RestClient {
|
||||
},
|
||||
});
|
||||
}
|
||||
if (this.clientID) {
|
||||
opts = merge({}, opts, {
|
||||
headers: {
|
||||
[CLIENT_ID_HEADER]: this.clientID,
|
||||
},
|
||||
});
|
||||
}
|
||||
const response = await fetch(`${this.uri}${path}`, buildOptions(opts));
|
||||
return handleResp(response);
|
||||
}
|
||||
|
||||
@@ -12,12 +12,16 @@ import {
|
||||
GQLCOMMENT_STATUS,
|
||||
GQLLOCALES,
|
||||
GQLMODERATION_MODE,
|
||||
GQLMODERATION_QUEUE,
|
||||
GQLSTORY_STATUS,
|
||||
GQLUSER_AUTH_CONDITIONS,
|
||||
GQLUSER_ROLE,
|
||||
GQLUSER_STATUS,
|
||||
} from "./__generated__/types";
|
||||
|
||||
export type GQLMODERATION_QUEUE_RL = RelayEnumLiteral<
|
||||
typeof GQLMODERATION_QUEUE
|
||||
>;
|
||||
export type GQLUSER_ROLE_RL = RelayEnumLiteral<typeof GQLUSER_ROLE>;
|
||||
export type GQLUSER_STATUS_RL = RelayEnumLiteral<typeof GQLUSER_STATUS>;
|
||||
export type GQLCOMMENT_FLAG_DETECTED_REASON_RL = RelayEnumLiteral<
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { graphql, GraphQLSchema } from "graphql";
|
||||
import { graphql, GraphQLSchema, parse } from "graphql";
|
||||
import { IResolvers } from "graphql-tools";
|
||||
|
||||
import { SubscribeFunction } from "react-relay-network-modern/es";
|
||||
import {
|
||||
commitLocalUpdate,
|
||||
Environment,
|
||||
@@ -24,6 +24,8 @@ import {
|
||||
ModerationNudgeError,
|
||||
} from "coral-framework/lib/errors";
|
||||
|
||||
import { SubscriptionHandler } from "./createSubscriptionHandler";
|
||||
|
||||
export interface CreateRelayEnvironmentNetworkParams {
|
||||
/** project name of graphql-config */
|
||||
projectName: string;
|
||||
@@ -33,6 +35,8 @@ export interface CreateRelayEnvironmentNetworkParams {
|
||||
logNetwork?: boolean;
|
||||
/** If enabled, graphql errors will be muted */
|
||||
muteNetworkErrors?: boolean;
|
||||
/** handler for subscriptions */
|
||||
subscriptionHandler?: SubscriptionHandler;
|
||||
}
|
||||
|
||||
export interface CreateRelayEnvironmentParams {
|
||||
@@ -89,6 +93,55 @@ function createFetch({
|
||||
};
|
||||
}
|
||||
|
||||
function resolveArguments(
|
||||
variables: Record<string, any> = {},
|
||||
args: any[] = []
|
||||
) {
|
||||
return args.reduce((res, a) => {
|
||||
const argName = a.name.value;
|
||||
const variableName = a.value.name.value;
|
||||
if (variableName in variables) {
|
||||
res[argName] = variables[variableName];
|
||||
}
|
||||
return res;
|
||||
}, {});
|
||||
}
|
||||
|
||||
function createSubscribe(
|
||||
subscriptionHandler: SubscriptionHandler
|
||||
): SubscribeFunction {
|
||||
const fn: SubscribeFunction = (
|
||||
operation,
|
||||
variables,
|
||||
cacheConfig,
|
||||
observer
|
||||
) => {
|
||||
// TODO: (cvle) This could probably made less brittle to changes in the order of the
|
||||
// document AST.
|
||||
const subscriptionSelections = (parse(operation.text!) as any)
|
||||
.definitions[0].selectionSet.selections as any[];
|
||||
const sel = subscriptionSelections[0];
|
||||
const subscription = {
|
||||
field: sel.name.value,
|
||||
variables: resolveArguments(variables, sel.arguments),
|
||||
dispatch: (response: any) => {
|
||||
observer.onNext({
|
||||
data: {
|
||||
[sel.name.value]: response,
|
||||
},
|
||||
});
|
||||
},
|
||||
};
|
||||
subscriptionHandler.add(subscription);
|
||||
return {
|
||||
dispose: () => {
|
||||
subscriptionHandler.remove(subscription);
|
||||
},
|
||||
};
|
||||
};
|
||||
return fn;
|
||||
}
|
||||
|
||||
/**
|
||||
* create Relay environment for tests environments.
|
||||
*/
|
||||
@@ -106,7 +159,10 @@ export default function createRelayEnvironment(
|
||||
wrapFetchWithLogger(createFetch({ schema }), {
|
||||
logResult: params.network.logNetwork,
|
||||
muteErrors: params.network.muteNetworkErrors,
|
||||
})
|
||||
}),
|
||||
params.network.subscriptionHandler
|
||||
? (createSubscribe(params.network.subscriptionHandler) as any)
|
||||
: undefined
|
||||
);
|
||||
}
|
||||
const environment = new Environment({
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
import { GQLSubscription } from "coral-framework/schema";
|
||||
|
||||
import { DeepPartial } from "coral-framework/types";
|
||||
|
||||
export type SubscriptionVariables<
|
||||
T extends SubscriptionResolver<any, any>
|
||||
> = T extends SubscriptionResolver<infer V, any> ? V : any;
|
||||
|
||||
export type SubscriptionResponse<
|
||||
T extends SubscriptionResolver<any, any>
|
||||
> = T extends SubscriptionResolver<any, infer R> ? DeepPartial<R> : any;
|
||||
|
||||
/**
|
||||
* SubscriptionResolver matches the shape of Subscription
|
||||
* resolvers in the schema generated types.
|
||||
*/
|
||||
export interface SubscriptionResolver<V, R> {
|
||||
resolve?: (parent: any, args: V, context: any, info: any) => R;
|
||||
}
|
||||
|
||||
type SubscriptionField = keyof GQLSubscription;
|
||||
|
||||
/**
|
||||
* Subscription represents a subscription currently requested from the client.
|
||||
*/
|
||||
export interface Subscription<T extends SubscriptionResolver<any, any> = any> {
|
||||
/** field of the subscription field being requested */
|
||||
field: SubscriptionField;
|
||||
/** variables of the subscription field being requested */
|
||||
variables: SubscriptionVariables<T>;
|
||||
/** dispatch data to this subscription */
|
||||
dispatch(data: SubscriptionResponse<T>): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* SubscriptionHandlerReadOnly enables to write tests with subscriptions.
|
||||
*/
|
||||
export interface SubscriptionHandlerReadOnly {
|
||||
/** List of current active subscriptions */
|
||||
readonly subscriptions: ReadonlyArray<Subscription>;
|
||||
/**
|
||||
* dispatch will look for subscriptions of the field `field` and
|
||||
* calls the `callback` for each of them. If `callback` returns data,
|
||||
* it'll be dispatched to that subscription.
|
||||
* @param field name of subscription field to look for.
|
||||
* @param callback callback is called for every subscription on this field.
|
||||
*/
|
||||
dispatch<T extends SubscriptionResolver<any, any> = any>(
|
||||
field: SubscriptionField,
|
||||
callback: (
|
||||
variables: SubscriptionVariables<T>
|
||||
) => SubscriptionResponse<T> | void
|
||||
): void;
|
||||
has(field: SubscriptionField): boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* SubscriptionHandler enables to write tests with subscriptions
|
||||
* and to manipulate the current list of subscriptions.
|
||||
*/
|
||||
export interface SubscriptionHandler extends SubscriptionHandlerReadOnly {
|
||||
add(subscription: Subscription): void;
|
||||
remove(subscription: Subscription): void;
|
||||
}
|
||||
|
||||
export default function createSubscriptionHandler(): SubscriptionHandler {
|
||||
const subscriptions: Subscription[] = [];
|
||||
const handler: SubscriptionHandler = {
|
||||
subscriptions,
|
||||
dispatch: (field, callback) => {
|
||||
subscriptions.forEach(s => {
|
||||
if (s.field === field) {
|
||||
const data = callback(s.variables as any);
|
||||
if (data) {
|
||||
s.dispatch(data);
|
||||
}
|
||||
}
|
||||
});
|
||||
},
|
||||
has: field => subscriptions.some(s => s.field === field),
|
||||
add: s => {
|
||||
subscriptions.push(s);
|
||||
},
|
||||
remove: s => {
|
||||
const index = subscriptions.findIndex(x => x === s);
|
||||
if (index !== -1) {
|
||||
subscriptions.splice(index, 1);
|
||||
}
|
||||
},
|
||||
};
|
||||
return handler;
|
||||
}
|
||||
@@ -18,6 +18,9 @@ import { createUUIDGenerator } from "coral-framework/testHelpers";
|
||||
|
||||
import createFluentBundle from "./createFluentBundle";
|
||||
import createRelayEnvironment from "./createRelayEnvironment";
|
||||
import createSubscriptionHandler, {
|
||||
SubscriptionHandlerReadOnly,
|
||||
} from "./createSubscriptionHandler";
|
||||
|
||||
export type Resolver<V, R> = (
|
||||
parent: any,
|
||||
@@ -66,6 +69,7 @@ export default function createTestRenderer<
|
||||
element: React.ReactNode,
|
||||
params: CreateTestRendererParams<T>
|
||||
) {
|
||||
const subscriptionHandler = createSubscriptionHandler();
|
||||
const environment = createRelayEnvironment({
|
||||
network: {
|
||||
// Set this to true, to see graphql responses.
|
||||
@@ -73,6 +77,7 @@ export default function createTestRenderer<
|
||||
resolvers: params.resolvers as IResolvers<any, any>,
|
||||
muteNetworkErrors: params.muteNetworkErrors,
|
||||
projectName: "tenant",
|
||||
subscriptionHandler,
|
||||
},
|
||||
initLocalState: (localRecord, source, env) => {
|
||||
if (params.initLocalState) {
|
||||
@@ -111,5 +116,9 @@ export default function createTestRenderer<
|
||||
{ createNodeMock }
|
||||
);
|
||||
});
|
||||
return { context, testRenderer: testRenderer! };
|
||||
return {
|
||||
context,
|
||||
testRenderer: testRenderer!,
|
||||
subscriptionHandler: subscriptionHandler as SubscriptionHandlerReadOnly,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -11,25 +11,21 @@ export default function matchText(
|
||||
text: string,
|
||||
options: TextMatchOptions = {}
|
||||
) {
|
||||
if (typeof pattern === "string") {
|
||||
let a = text;
|
||||
let b = pattern;
|
||||
if (options.trim || options.trim === undefined) {
|
||||
a = a.trim();
|
||||
b = b.trim();
|
||||
}
|
||||
if (
|
||||
options.collapseWhitespace ||
|
||||
options.collapseWhitespace === undefined
|
||||
) {
|
||||
a = a.replace(/\s+/g, " ");
|
||||
b = b.replace(/\s+/g, " ");
|
||||
}
|
||||
|
||||
let a = pattern;
|
||||
let b = text;
|
||||
if (options.trim || options.trim === undefined) {
|
||||
a = typeof a === "string" ? a.trim() : a;
|
||||
b = b.trim();
|
||||
}
|
||||
if (options.collapseWhitespace || options.collapseWhitespace === undefined) {
|
||||
a = typeof a === "string" ? a.replace(/\s+/g, " ") : a;
|
||||
b = b.replace(/\s+/g, " ");
|
||||
}
|
||||
if (typeof a === "string") {
|
||||
if (options.exact || options.exact === undefined) {
|
||||
return a === b;
|
||||
}
|
||||
return text.toLowerCase().includes(pattern.toLowerCase());
|
||||
return b.toLowerCase().includes(a.toLowerCase());
|
||||
}
|
||||
return pattern.test(text);
|
||||
return a.test(b);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
/**
|
||||
* CLIENT_ID_HEADER references the name of the header used to extract/send the
|
||||
* client ID to enable automatic de-duplication.
|
||||
*/
|
||||
export const CLIENT_ID_HEADER = "X-Coral-Client-ID";
|
||||
|
||||
/**
|
||||
* CLIENT_ID_PARAM references the name of the param used to send the client ID
|
||||
* via connectionParams when authenticating a websocket connection to enable
|
||||
* automatic de-duplication.
|
||||
*/
|
||||
export const CLIENT_ID_PARAM = "clientID";
|
||||
|
||||
/**
|
||||
* ACCESS_TOKEN_PARAM references the name of the param used to send the access
|
||||
* token in connectionParams when authenticating a websocket connection.
|
||||
*/
|
||||
export const ACCESS_TOKEN_PARAM = "accessToken";
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
} from "coral-server/errors";
|
||||
import { GQLUSER_ROLE } from "coral-server/graph/tenant/schema/__generated__/types";
|
||||
import { retrieveUser, User } from "coral-server/models/user";
|
||||
import { decodeJWT, extractJWTFromRequest } from "coral-server/services/jwt";
|
||||
import { decodeJWT, extractTokenFromRequest } from "coral-server/services/jwt";
|
||||
import {
|
||||
confirmEmail,
|
||||
sendConfirmationEmail,
|
||||
@@ -165,7 +165,7 @@ export const confirmCheckHandler = ({
|
||||
// TODO: evaluate verifying if the Tenant allows verifications to short circuit.
|
||||
|
||||
// Grab the token from the request.
|
||||
const tokenString = extractJWTFromRequest(req, true);
|
||||
const tokenString = extractTokenFromRequest(req, true);
|
||||
if (!tokenString) {
|
||||
return res.sendStatus(400);
|
||||
}
|
||||
@@ -225,7 +225,7 @@ export const confirmHandler = ({
|
||||
const tenant = coral.tenant!;
|
||||
|
||||
// Grab the token from the request.
|
||||
const tokenString = extractJWTFromRequest(req, true);
|
||||
const tokenString = extractTokenFromRequest(req, true);
|
||||
if (!tokenString) {
|
||||
return res.sendStatus(400);
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ import { validate } from "coral-server/app/request/body";
|
||||
import { RequestLimiter } from "coral-server/app/request/limiter";
|
||||
import { IntegrationDisabled } from "coral-server/errors";
|
||||
import { retrieveUserWithProfile } from "coral-server/models/user";
|
||||
import { decodeJWT, extractJWTFromRequest } from "coral-server/services/jwt";
|
||||
import { decodeJWT, extractTokenFromRequest } from "coral-server/services/jwt";
|
||||
import {
|
||||
generateResetURL,
|
||||
resetPassword,
|
||||
@@ -182,7 +182,7 @@ export const forgotResetHandler = ({
|
||||
);
|
||||
|
||||
// Grab the token from the request.
|
||||
const tokenString = extractJWTFromRequest(req, true);
|
||||
const tokenString = extractTokenFromRequest(req, true);
|
||||
if (!tokenString) {
|
||||
return res.sendStatus(400);
|
||||
}
|
||||
@@ -248,7 +248,7 @@ export const forgotCheckHandler = ({
|
||||
}
|
||||
|
||||
// Grab the token from the request.
|
||||
const tokenString = extractJWTFromRequest(req, true);
|
||||
const tokenString = extractTokenFromRequest(req, true);
|
||||
if (!tokenString) {
|
||||
return res.sendStatus(400);
|
||||
}
|
||||
|
||||
@@ -1,48 +1,56 @@
|
||||
import { CLIENT_ID_HEADER } from "coral-common/constants";
|
||||
import { AppOptions } from "coral-server/app";
|
||||
import {
|
||||
graphqlBatchMiddleware,
|
||||
graphqlMiddleware,
|
||||
} from "coral-server/app/middleware/graphql";
|
||||
import TenantContext from "coral-server/graph/tenant/context";
|
||||
import TenantContext, {
|
||||
TenantContextOptions,
|
||||
} from "coral-server/graph/tenant/context";
|
||||
import { Request, RequestHandler } from "coral-server/types/express";
|
||||
|
||||
export type GraphMiddlewareOptions = Pick<
|
||||
AppOptions,
|
||||
| "schema"
|
||||
| "config"
|
||||
| "i18n"
|
||||
| "mailerQueue"
|
||||
| "mongo"
|
||||
| "redis"
|
||||
| "mailerQueue"
|
||||
| "schema"
|
||||
| "scraperQueue"
|
||||
| "signingConfig"
|
||||
| "i18n"
|
||||
| "pubsub"
|
||||
| "tenantCache"
|
||||
| "metrics"
|
||||
>;
|
||||
|
||||
export const graphQLHandler = ({
|
||||
schema,
|
||||
config,
|
||||
metrics,
|
||||
...options
|
||||
}: GraphMiddlewareOptions): RequestHandler =>
|
||||
graphqlBatchMiddleware(
|
||||
graphqlMiddleware(config, async (req: Request) => {
|
||||
if (!req.coral) {
|
||||
throw new Error("coral was not set");
|
||||
}
|
||||
graphqlMiddleware(
|
||||
config,
|
||||
async (req: Request) => {
|
||||
if (!req.coral) {
|
||||
throw new Error("coral was not set");
|
||||
}
|
||||
|
||||
// Pull out some useful properties from Coral.
|
||||
const { id, now, tenant, cache, logger } = req.coral;
|
||||
// Pull out some useful properties from Coral.
|
||||
const { id, now, tenant, cache, logger } = req.coral;
|
||||
|
||||
if (!cache) {
|
||||
throw new Error("cache was not set");
|
||||
}
|
||||
if (!cache) {
|
||||
throw new Error("cache was not set");
|
||||
}
|
||||
|
||||
if (!tenant) {
|
||||
throw new Error("tenant was not set");
|
||||
}
|
||||
if (!tenant) {
|
||||
throw new Error("tenant was not set");
|
||||
}
|
||||
|
||||
return {
|
||||
schema,
|
||||
context: new TenantContext({
|
||||
// Create some new options to store the tenant context details inside.
|
||||
const opts: TenantContextOptions = {
|
||||
...options,
|
||||
id,
|
||||
now,
|
||||
@@ -50,9 +58,25 @@ export const graphQLHandler = ({
|
||||
config,
|
||||
tenant,
|
||||
logger,
|
||||
user: req.user,
|
||||
tenantCache: cache.tenant,
|
||||
}),
|
||||
};
|
||||
})
|
||||
};
|
||||
|
||||
// Add the user if there is one.
|
||||
if (req.user) {
|
||||
opts.user = req.user;
|
||||
}
|
||||
|
||||
// Add the clientID if there is one on the request.
|
||||
const clientID = req.get(CLIENT_ID_HEADER);
|
||||
if (clientID) {
|
||||
// TODO: (wyattjoh) validate length
|
||||
opts.clientID = clientID;
|
||||
}
|
||||
|
||||
return {
|
||||
schema,
|
||||
context: new TenantContext(opts),
|
||||
};
|
||||
},
|
||||
metrics
|
||||
)
|
||||
);
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import { IncomingMessage } from "http";
|
||||
|
||||
/**
|
||||
* Duplicates the functionality from expressjs:
|
||||
*
|
||||
* https://github.com/expressjs/express/blob/b8e50568af9c73ef1ade434e92c60d389868361d/lib/request.js#L416-L450
|
||||
*
|
||||
* @param req incoming request
|
||||
*/
|
||||
export function getHostname(req: IncomingMessage) {
|
||||
const host = req.headers["x-forwarded-host"] || req.headers.host;
|
||||
if (!host || Array.isArray(host)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// IPv6 literal support
|
||||
const offset = host[0] === "[" ? host.indexOf("]") + 1 : 0;
|
||||
const index = host.indexOf(":", offset);
|
||||
|
||||
return index !== -1 ? host.substring(0, index) : host;
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import cons from "consolidate";
|
||||
import cors from "cors";
|
||||
import { Express } from "express";
|
||||
import { GraphQLSchema } from "graphql";
|
||||
import { RedisPubSub } from "graphql-redis-subscriptions";
|
||||
import http from "http";
|
||||
import { Db } from "mongodb";
|
||||
import nunjucks from "nunjucks";
|
||||
@@ -16,6 +17,7 @@ import { MailerQueue } from "coral-server/queue/tasks/mailer";
|
||||
import { ScraperQueue } from "coral-server/queue/tasks/scraper";
|
||||
import { I18n } from "coral-server/services/i18n";
|
||||
import { JWTSigningConfig } from "coral-server/services/jwt";
|
||||
import { Metrics } from "coral-server/services/metrics";
|
||||
import { AugmentedRedis } from "coral-server/services/redis";
|
||||
import TenantCache from "coral-server/services/tenant/cache";
|
||||
|
||||
@@ -35,8 +37,9 @@ export interface AppOptions {
|
||||
schema: GraphQLSchema;
|
||||
signingConfig: JWTSigningConfig;
|
||||
tenantCache: TenantCache;
|
||||
metrics: boolean;
|
||||
disableClientRoutes: boolean;
|
||||
metrics?: Metrics;
|
||||
pubsub: RedisPubSub;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -52,8 +55,10 @@ export async function createApp(options: AppOptions): Promise<Express> {
|
||||
// Logging
|
||||
parent.use(accessLogger);
|
||||
|
||||
// Capturing metrics.
|
||||
parent.use(metricsRecorder());
|
||||
if (options.metrics) {
|
||||
// Capturing metrics.
|
||||
parent.use(metricsRecorder(options.metrics));
|
||||
}
|
||||
|
||||
// Create some services for the router.
|
||||
const passport = createPassport(options);
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { GraphQLOptions } from "apollo-server-express";
|
||||
import { GraphQLExtension, GraphQLOptions } from "apollo-server-express";
|
||||
import { Handler } from "express";
|
||||
import { FieldDefinitionNode, GraphQLError, ValidationContext } from "graphql";
|
||||
import { Counter, Histogram } from "prom-client";
|
||||
|
||||
// TODO: when https://github.com/apollographql/apollo-server/pull/1907 is merged, update this import path
|
||||
import {
|
||||
@@ -16,6 +15,7 @@ import {
|
||||
LoggerExtension,
|
||||
MetricsExtension,
|
||||
} from "coral-server/graph/common/extensions";
|
||||
import { Metrics } from "coral-server/services/metrics";
|
||||
|
||||
export * from "./batch";
|
||||
|
||||
@@ -42,37 +42,28 @@ const NoIntrospection = (context: ValidationContext) => ({
|
||||
*/
|
||||
export const graphqlMiddleware = (
|
||||
config: Config,
|
||||
requestOptions: ExpressGraphQLOptionsFunction
|
||||
requestOptions: ExpressGraphQLOptionsFunction,
|
||||
metrics?: Metrics
|
||||
): Handler => {
|
||||
// Configure the metrics handlers.
|
||||
const executedGraphQueriesTotalCounter = new Counter({
|
||||
name: "coral_executed_graph_queries_total",
|
||||
help: "number of GraphQL queries executed",
|
||||
labelNames: ["operation_type", "operation_name"],
|
||||
});
|
||||
const extensions: Array<() => GraphQLExtension> = [
|
||||
() => new ErrorWrappingExtension(),
|
||||
() => new LoggerExtension(),
|
||||
];
|
||||
|
||||
const graphQLExecutionTimingsHistogram = new Histogram({
|
||||
name: "coral_executed_graph_queries_timings",
|
||||
help: "timings for execution times of GraphQL operations",
|
||||
buckets: [0.1, 5, 15, 50, 100, 500],
|
||||
labelNames: ["operation_type", "operation_name"],
|
||||
});
|
||||
// Add the metrics extension if provided.
|
||||
if (metrics) {
|
||||
extensions.push(
|
||||
() =>
|
||||
// Pass the metrics to the extension so it can increment.
|
||||
new MetricsExtension(metrics)
|
||||
);
|
||||
}
|
||||
|
||||
// Create a new baseOptions that will be merged into the new options.
|
||||
const baseOptions: Omit<GraphQLOptions, "schema"> = {
|
||||
// Disable the debug mode, as we already add in our logging function.
|
||||
debug: false,
|
||||
// Include extensions.
|
||||
extensions: [
|
||||
() => new ErrorWrappingExtension(),
|
||||
() => new LoggerExtension(),
|
||||
() =>
|
||||
// Pass the metrics to the extension so it can increment.
|
||||
new MetricsExtension({
|
||||
executedGraphQueriesTotalCounter,
|
||||
graphQLExecutionTimingsHistogram,
|
||||
}),
|
||||
],
|
||||
extensions,
|
||||
};
|
||||
|
||||
if (config.get("env") === "production" && !config.get("enable_graphiql")) {
|
||||
|
||||
@@ -1,22 +1,15 @@
|
||||
import { RequestHandler } from "express";
|
||||
import onFinished from "on-finished";
|
||||
import now from "performance-now";
|
||||
import { Counter, Histogram } from "prom-client";
|
||||
|
||||
export const metricsRecorder = (): RequestHandler => {
|
||||
const httpRequestsTotal = new Counter({
|
||||
name: "http_requests_total",
|
||||
help: "Total number of HTTP requests made.",
|
||||
labelNames: ["code", "method"],
|
||||
});
|
||||
import { Metrics } from "coral-server/services/metrics";
|
||||
import { RequestHandler } from "coral-server/types/express";
|
||||
|
||||
const httpRequestDurationMilliseconds = new Histogram({
|
||||
name: "http_request_duration_milliseconds",
|
||||
help: "Histogram of latencies for HTTP requests.",
|
||||
buckets: [0.1, 5, 15, 50, 100, 500],
|
||||
labelNames: ["method", "handler"],
|
||||
});
|
||||
export type MetricsRecorderOptions = Metrics;
|
||||
|
||||
export const metricsRecorder = ({
|
||||
httpRequestsTotal,
|
||||
httpRequestDurationMilliseconds,
|
||||
}: Metrics): RequestHandler => {
|
||||
return (req, res, next) => {
|
||||
const startTime = now();
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ import { validate } from "coral-server/app/request/body";
|
||||
import { AuthenticationError } from "coral-server/errors";
|
||||
import { User } from "coral-server/models/user";
|
||||
import {
|
||||
extractJWTFromRequest,
|
||||
extractTokenFromRequest,
|
||||
JWTSigningConfig,
|
||||
revokeJWT,
|
||||
signTokenString,
|
||||
@@ -68,7 +68,7 @@ const LogoutTokenSchema = Joi.object().keys({
|
||||
|
||||
export async function handleLogout(redis: Redis, req: Request, res: Response) {
|
||||
// Extract the token from the request.
|
||||
const token = extractJWTFromRequest(req);
|
||||
const token = extractTokenFromRequest(req);
|
||||
if (!token) {
|
||||
// TODO: (wyattjoh) return a better error.
|
||||
throw new Error("logout requires a token on the request, none was found");
|
||||
|
||||
@@ -5,7 +5,7 @@ import { AppOptions } from "coral-server/app";
|
||||
import { TenantNotFoundError, TokenInvalidError } from "coral-server/errors";
|
||||
import { Tenant } from "coral-server/models/tenant";
|
||||
import { User } from "coral-server/models/user";
|
||||
import { extractJWTFromRequest } from "coral-server/services/jwt";
|
||||
import { extractTokenFromRequest } from "coral-server/services/jwt";
|
||||
import { Request } from "coral-server/types/express";
|
||||
|
||||
import { JWTToken, JWTVerifier } from "./verifiers/jwt";
|
||||
@@ -20,7 +20,7 @@ export type JWTStrategyOptions = Pick<
|
||||
/**
|
||||
* Token is the various forms of the Token that can be verified.
|
||||
*/
|
||||
type Token = OIDCIDToken | SSOToken | JWTToken | object | string | null;
|
||||
export type Token = OIDCIDToken | SSOToken | JWTToken | object | string | null;
|
||||
|
||||
/**
|
||||
* Verifier allows different implementations to offer ways to verify a given
|
||||
@@ -44,6 +44,41 @@ export interface Verifier<T = Token> {
|
||||
supports: (token: T | object, tenant: Tenant) => token is T;
|
||||
}
|
||||
|
||||
export function createVerifiers(
|
||||
options: JWTStrategyOptions
|
||||
): Array<Verifier<Token>> {
|
||||
return [
|
||||
new OIDCVerifier(options),
|
||||
new SSOVerifier(options),
|
||||
new JWTVerifier(options),
|
||||
];
|
||||
}
|
||||
|
||||
export function verifyAndRetrieveUser(
|
||||
verifiers: Array<Verifier<Token>>,
|
||||
tenant: Tenant,
|
||||
tokenString: string,
|
||||
now = new Date()
|
||||
) {
|
||||
const token: Token = jwt.decode(tokenString);
|
||||
if (!token || typeof token === "string") {
|
||||
throw new TokenInvalidError(tokenString, "token could not be decoded");
|
||||
}
|
||||
|
||||
// Try to verify the token.
|
||||
for (const verifier of verifiers) {
|
||||
if (verifier.supports(token, tenant)) {
|
||||
return verifier.verify(tokenString, token, tenant, now);
|
||||
}
|
||||
}
|
||||
|
||||
// No verifier could be found.
|
||||
throw new TokenInvalidError(
|
||||
tokenString,
|
||||
"no suitable jwt verifier could be found"
|
||||
);
|
||||
}
|
||||
|
||||
export class JWTStrategy extends Strategy {
|
||||
public name = "jwt";
|
||||
|
||||
@@ -52,36 +87,12 @@ export class JWTStrategy extends Strategy {
|
||||
constructor(options: JWTStrategyOptions) {
|
||||
super();
|
||||
|
||||
this.verifiers = [
|
||||
new OIDCVerifier(options),
|
||||
new SSOVerifier(options),
|
||||
new JWTVerifier(options),
|
||||
];
|
||||
}
|
||||
|
||||
private async verify(tokenString: string, tenant: Tenant, now = new Date()) {
|
||||
const token: Token = jwt.decode(tokenString);
|
||||
if (!token || typeof token === "string") {
|
||||
throw new TokenInvalidError(tokenString, "token could not be decoded");
|
||||
}
|
||||
|
||||
// Try to verify the token.
|
||||
for (const verifier of this.verifiers) {
|
||||
if (verifier.supports(token, tenant)) {
|
||||
return verifier.verify(tokenString, token, tenant, now);
|
||||
}
|
||||
}
|
||||
|
||||
// No verifier could be found.
|
||||
throw new TokenInvalidError(
|
||||
tokenString,
|
||||
"no suitable jwt verifier could be found"
|
||||
);
|
||||
this.verifiers = createVerifiers(options);
|
||||
}
|
||||
|
||||
public async authenticate(req: Request) {
|
||||
// Get the token from the request.
|
||||
const token = extractJWTFromRequest(req);
|
||||
const token = extractTokenFromRequest(req);
|
||||
if (!token) {
|
||||
// There was no token on the request, so don't bother actually checking
|
||||
// anything further.
|
||||
@@ -94,7 +105,12 @@ export class JWTStrategy extends Strategy {
|
||||
}
|
||||
|
||||
try {
|
||||
const user = await this.verify(token, tenant, now);
|
||||
const user = await verifyAndRetrieveUser(
|
||||
this.verifiers,
|
||||
tenant,
|
||||
token,
|
||||
now
|
||||
);
|
||||
if (!user) {
|
||||
return this.pass();
|
||||
}
|
||||
|
||||
@@ -69,5 +69,11 @@ function attachGraphiQL(router: Router, app: AppOptions) {
|
||||
}
|
||||
|
||||
// GraphiQL
|
||||
router.get("/graphiql", playground({ endpoint: "/api/graphql" }));
|
||||
router.get(
|
||||
"/graphiql",
|
||||
playground({
|
||||
endpoint: "/api/graphql",
|
||||
subscriptionEndpoint: "/api/graphql/live",
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
@@ -406,10 +406,10 @@ export class StoryNotFoundError extends CoralError {
|
||||
}
|
||||
|
||||
export class CommentNotFoundError extends CoralError {
|
||||
constructor(commentID: string) {
|
||||
constructor(commentID: string, commentRevisionID?: string) {
|
||||
super({
|
||||
code: ERROR_CODES.COMMENT_NOT_FOUND,
|
||||
context: { pvt: { commentID } },
|
||||
context: { pvt: { commentID, commentRevisionID } },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { Db } from "mongodb";
|
||||
import uuid from "uuid";
|
||||
|
||||
import { LanguageCode } from "coral-common/helpers/i18n/locales";
|
||||
@@ -5,7 +6,9 @@ import { Config } from "coral-server/config";
|
||||
import logger, { Logger } from "coral-server/logger";
|
||||
import { User } from "coral-server/models/user";
|
||||
import { I18n } from "coral-server/services/i18n";
|
||||
import { AugmentedRedis } from "coral-server/services/redis";
|
||||
import { Request } from "coral-server/types/express";
|
||||
import { RedisPubSub } from "graphql-redis-subscriptions";
|
||||
|
||||
export interface CommonContextOptions {
|
||||
id?: string;
|
||||
@@ -14,8 +17,12 @@ export interface CommonContextOptions {
|
||||
req?: Request;
|
||||
logger?: Logger;
|
||||
lang?: LanguageCode;
|
||||
disableCaching?: boolean;
|
||||
config: Config;
|
||||
i18n: I18n;
|
||||
pubsub: RedisPubSub;
|
||||
mongo: Db;
|
||||
redis: AugmentedRedis;
|
||||
}
|
||||
|
||||
export default class CommonContext {
|
||||
@@ -27,6 +34,10 @@ export default class CommonContext {
|
||||
public readonly lang: LanguageCode;
|
||||
public readonly now: Date;
|
||||
public readonly logger: Logger;
|
||||
public readonly pubsub: RedisPubSub;
|
||||
public readonly mongo: Db;
|
||||
public readonly redis: AugmentedRedis;
|
||||
public readonly disableCaching: boolean;
|
||||
|
||||
constructor({
|
||||
id = uuid.v1(),
|
||||
@@ -37,6 +48,10 @@ export default class CommonContext {
|
||||
config,
|
||||
i18n,
|
||||
lang = i18n.getDefaultLang(),
|
||||
pubsub,
|
||||
mongo,
|
||||
redis,
|
||||
disableCaching = false,
|
||||
}: CommonContextOptions) {
|
||||
this.id = id;
|
||||
this.logger = log.child({
|
||||
@@ -49,5 +64,9 @@ export default class CommonContext {
|
||||
this.config = config;
|
||||
this.i18n = i18n;
|
||||
this.lang = lang;
|
||||
this.pubsub = pubsub;
|
||||
this.mongo = mongo;
|
||||
this.redis = redis;
|
||||
this.disableCaching = disableCaching;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ExecutionArgs, GraphQLError } from "graphql";
|
||||
import { DocumentNode, ExecutionArgs, GraphQLError } from "graphql";
|
||||
import {
|
||||
EndHandler,
|
||||
GraphQLExtension,
|
||||
@@ -13,6 +13,20 @@ export function logError(ctx: CommonContext, err: GraphQLError) {
|
||||
ctx.logger.error({ err }, "graphql query error");
|
||||
}
|
||||
|
||||
export function logQuery(
|
||||
ctx: CommonContext,
|
||||
document: DocumentNode,
|
||||
responseTime?: number
|
||||
) {
|
||||
ctx.logger.debug(
|
||||
{
|
||||
responseTime,
|
||||
...getOperationMetadata(document),
|
||||
},
|
||||
"graphql query"
|
||||
);
|
||||
}
|
||||
|
||||
export class LoggerExtension implements GraphQLExtension<CommonContext> {
|
||||
public executionDidStart(o: {
|
||||
executionArgs: ExecutionArgs;
|
||||
@@ -27,12 +41,10 @@ export class LoggerExtension implements GraphQLExtension<CommonContext> {
|
||||
const responseTime = Math.round(now() - startTime);
|
||||
|
||||
// Log out the details of the request.
|
||||
o.executionArgs.contextValue.logger.debug(
|
||||
{
|
||||
responseTime,
|
||||
...getOperationMetadata(o.executionArgs.document),
|
||||
},
|
||||
"graphql query"
|
||||
logQuery(
|
||||
o.executionArgs.contextValue,
|
||||
o.executionArgs.document,
|
||||
responseTime
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,22 +1,13 @@
|
||||
import CommonContext from "coral-server/graph/common/context";
|
||||
import { Metrics } from "coral-server/services/metrics";
|
||||
import { ExecutionArgs } from "graphql";
|
||||
import { EndHandler, GraphQLExtension } from "graphql-extensions";
|
||||
import now from "performance-now";
|
||||
import { Counter, Histogram } from "prom-client";
|
||||
|
||||
import CommonContext from "coral-server/graph/common/context";
|
||||
import { getOperationMetadata } from "./helpers";
|
||||
|
||||
export interface MetricsExtensionOptions {
|
||||
executedGraphQueriesTotalCounter: Counter;
|
||||
graphQLExecutionTimingsHistogram: Histogram;
|
||||
}
|
||||
|
||||
export class MetricsExtension implements GraphQLExtension<CommonContext> {
|
||||
private options: MetricsExtensionOptions;
|
||||
|
||||
constructor(options: MetricsExtensionOptions) {
|
||||
this.options = options;
|
||||
}
|
||||
constructor(private metrics: Metrics) {}
|
||||
|
||||
public executionDidStart(o: {
|
||||
executionArgs: ExecutionArgs;
|
||||
@@ -37,11 +28,11 @@ export class MetricsExtension implements GraphQLExtension<CommonContext> {
|
||||
|
||||
if (operation && operationName) {
|
||||
// Increment the graph query value, tagging with the name of the query.
|
||||
this.options.executedGraphQueriesTotalCounter
|
||||
this.metrics.executedGraphQueriesTotalCounter
|
||||
.labels(operation, operationName)
|
||||
.inc();
|
||||
|
||||
this.options.graphQLExecutionTimingsHistogram
|
||||
this.metrics.graphQLExecutionTimingsHistogram
|
||||
.labels(operation, operationName)
|
||||
.observe(responseTime);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import { RedisPubSub } from "graphql-redis-subscriptions";
|
||||
import { Redis } from "ioredis";
|
||||
|
||||
export function createPubSubClient(
|
||||
publisher: Redis,
|
||||
subscriber: Redis
|
||||
): RedisPubSub {
|
||||
return new RedisPubSub({
|
||||
publisher,
|
||||
subscriber,
|
||||
});
|
||||
}
|
||||
@@ -1,52 +1,56 @@
|
||||
import { Db } from "mongodb";
|
||||
|
||||
import CommonContext, {
|
||||
CommonContextOptions,
|
||||
} from "coral-server/graph/common/context";
|
||||
import {
|
||||
createPublisher,
|
||||
Publisher,
|
||||
} from "coral-server/graph/tenant/subscriptions/publisher";
|
||||
import { Tenant } from "coral-server/models/tenant";
|
||||
import { User } from "coral-server/models/user";
|
||||
import { MailerQueue } from "coral-server/queue/tasks/mailer";
|
||||
import { ScraperQueue } from "coral-server/queue/tasks/scraper";
|
||||
import { JWTSigningConfig } from "coral-server/services/jwt";
|
||||
import { AugmentedRedis } from "coral-server/services/redis";
|
||||
import TenantCache from "coral-server/services/tenant/cache";
|
||||
|
||||
import loaders from "./loaders";
|
||||
import mutators from "./mutators";
|
||||
|
||||
export interface TenantContextOptions extends CommonContextOptions {
|
||||
mongo: Db;
|
||||
redis: AugmentedRedis;
|
||||
tenant: Tenant;
|
||||
tenantCache: TenantCache;
|
||||
mailerQueue: MailerQueue;
|
||||
scraperQueue: ScraperQueue;
|
||||
signingConfig?: JWTSigningConfig;
|
||||
clientID?: string;
|
||||
}
|
||||
|
||||
export default class TenantContext extends CommonContext {
|
||||
public readonly tenant: Tenant;
|
||||
public readonly tenantCache: TenantCache;
|
||||
public readonly mongo: Db;
|
||||
public readonly redis: AugmentedRedis;
|
||||
|
||||
public readonly mailerQueue: MailerQueue;
|
||||
public readonly scraperQueue: ScraperQueue;
|
||||
public readonly loaders: ReturnType<typeof loaders>;
|
||||
public readonly mutators: ReturnType<typeof mutators>;
|
||||
public readonly publisher: Publisher;
|
||||
public readonly user?: User;
|
||||
public readonly signingConfig?: JWTSigningConfig;
|
||||
public readonly clientID?: string;
|
||||
public readonly loaders: ReturnType<typeof loaders>;
|
||||
public readonly mutators: ReturnType<typeof mutators>;
|
||||
|
||||
constructor(options: TenantContextOptions) {
|
||||
super({ ...options, lang: options.tenant.locale });
|
||||
|
||||
this.tenant = options.tenant;
|
||||
this.tenantCache = options.tenantCache;
|
||||
this.user = options.user;
|
||||
this.mongo = options.mongo;
|
||||
this.redis = options.redis;
|
||||
this.scraperQueue = options.scraperQueue;
|
||||
this.mailerQueue = options.mailerQueue;
|
||||
this.signingConfig = options.signingConfig;
|
||||
this.clientID = options.clientID;
|
||||
this.publisher = createPublisher(
|
||||
this.pubsub,
|
||||
this.tenant.id,
|
||||
this.clientID
|
||||
);
|
||||
this.loaders = loaders(this);
|
||||
this.mutators = mutators(this);
|
||||
}
|
||||
|
||||
@@ -8,7 +8,13 @@ export default (ctx: TenantContext) => ({
|
||||
discoverOIDCConfiguration: new DataLoader<
|
||||
string,
|
||||
GQLDiscoveredOIDCConfiguration | null
|
||||
>(issuers =>
|
||||
Promise.all(issuers.map(issuer => discoverOIDCConfiguration(issuer)))
|
||||
>(
|
||||
issuers =>
|
||||
Promise.all(issuers.map(issuer => discoverOIDCConfiguration(issuer))),
|
||||
{
|
||||
// Disable caching for the DataLoader if the Context is designed to be
|
||||
// long lived.
|
||||
cache: !ctx.disableCaching,
|
||||
}
|
||||
),
|
||||
});
|
||||
|
||||
@@ -50,10 +50,12 @@ const tagFilter = (tag?: GQLTAG): CommentConnectionInput["filter"] => {
|
||||
const primeCommentsFromConnection = (ctx: Context) => (
|
||||
connection: Readonly<Connection<Readonly<Comment>>>
|
||||
) => {
|
||||
// For each of the nodes, prime the comment loader.
|
||||
connection.nodes.forEach(comment => {
|
||||
ctx.loaders.Comments.comment.prime(comment.id, comment);
|
||||
});
|
||||
if (!ctx.disableCaching) {
|
||||
// For each of the nodes, prime the comment loader.
|
||||
connection.nodes.forEach(comment => {
|
||||
ctx.loaders.Comments.comment.prime(comment.id, comment);
|
||||
});
|
||||
}
|
||||
|
||||
return connection;
|
||||
};
|
||||
@@ -96,10 +98,16 @@ const mapVisibleComments = (user?: Pick<User, "role">) => (
|
||||
): Array<Readonly<Comment> | null> => comments.map(mapVisibleComment(user));
|
||||
|
||||
export default (ctx: Context) => ({
|
||||
comment: new DataLoader((ids: string[]) =>
|
||||
retrieveManyComments(ctx.mongo, ctx.tenant.id, ids).then(
|
||||
mapVisibleComments(ctx.user)
|
||||
)
|
||||
comment: new DataLoader(
|
||||
(ids: string[]) =>
|
||||
retrieveManyComments(ctx.mongo, ctx.tenant.id, ids).then(
|
||||
mapVisibleComments(ctx.user)
|
||||
),
|
||||
{
|
||||
// Disable caching for the DataLoader if the Context is designed to be
|
||||
// long lived.
|
||||
cache: !ctx.disableCaching,
|
||||
}
|
||||
),
|
||||
forFilter: ({
|
||||
first = 10,
|
||||
@@ -217,13 +225,19 @@ export default (ctx: Context) => ({
|
||||
// The cursor passed here is always going to be a number.
|
||||
before: before as number,
|
||||
}).then(primeCommentsFromConnection(ctx)),
|
||||
sharedModerationQueueQueuesCounts: new SingletonResolver(() =>
|
||||
retrieveSharedModerationQueueQueuesCounts(
|
||||
ctx.mongo,
|
||||
ctx.redis,
|
||||
ctx.tenant.id,
|
||||
ctx.now
|
||||
)
|
||||
sharedModerationQueueQueuesCounts: new SingletonResolver(
|
||||
() =>
|
||||
retrieveSharedModerationQueueQueuesCounts(
|
||||
ctx.mongo,
|
||||
ctx.redis,
|
||||
ctx.tenant.id,
|
||||
ctx.now
|
||||
),
|
||||
{
|
||||
// Disable caching for the DataLoader if the Context is designed to be
|
||||
// long lived.
|
||||
cacheable: !ctx.disableCaching,
|
||||
}
|
||||
),
|
||||
tagCounts: new DataLoader((storyIDs: string[]) =>
|
||||
retrieveStoryCommentTagCounts(ctx.mongo, ctx.tenant.id, storyIDs)
|
||||
|
||||
@@ -56,10 +56,12 @@ const queryFilter = (query?: string): StoryConnectionInput["filter"] => {
|
||||
const primeStoriesFromConnection = (ctx: TenantContext) => (
|
||||
connection: Readonly<Connection<Readonly<Story>>>
|
||||
) => {
|
||||
// For each of these nodes, prime the story loader.
|
||||
connection.nodes.forEach(story => {
|
||||
ctx.loaders.Stories.story.prime(story.id, story);
|
||||
});
|
||||
if (!ctx.disableCaching) {
|
||||
// For each of these nodes, prime the story loader.
|
||||
connection.nodes.forEach(story => {
|
||||
ctx.loaders.Stories.story.prime(story.id, story);
|
||||
});
|
||||
}
|
||||
|
||||
return connection;
|
||||
};
|
||||
@@ -72,6 +74,9 @@ export default (ctx: TenantContext) => ({
|
||||
{
|
||||
// TODO: (wyattjoh) see if there's something we can do to improve the cache key
|
||||
cacheKeyFn: (input: FindOrCreateStory) => `${input.id}:${input.url}`,
|
||||
// Disable caching for the DataLoader if the Context is designed to be
|
||||
// long lived.
|
||||
cache: !ctx.disableCaching,
|
||||
}
|
||||
),
|
||||
find: new DataLoader(
|
||||
@@ -81,10 +86,18 @@ export default (ctx: TenantContext) => ({
|
||||
{
|
||||
// TODO: (wyattjoh) see if there's something we can do to improve the cache key
|
||||
cacheKeyFn: (input: FindStory) => `${input.id}:${input.url}`,
|
||||
// Disable caching for the DataLoader if the Context is designed to be
|
||||
// long lived.
|
||||
cache: !ctx.disableCaching,
|
||||
}
|
||||
),
|
||||
story: new DataLoader<string, Story | null>(ids =>
|
||||
retrieveManyStories(ctx.mongo, ctx.tenant.id, ids)
|
||||
story: new DataLoader<string, Story | null>(
|
||||
ids => retrieveManyStories(ctx.mongo, ctx.tenant.id, ids),
|
||||
{
|
||||
// Disable caching for the DataLoader if the Context is designed to be
|
||||
// long lived.
|
||||
cache: !ctx.disableCaching,
|
||||
}
|
||||
),
|
||||
connection: ({ first = 10, after, status, query }: QueryToStoriesArgs) =>
|
||||
retrieveStoryConnection(ctx.mongo, ctx.tenant.id, {
|
||||
@@ -99,6 +112,11 @@ export default (ctx: TenantContext) => ({
|
||||
},
|
||||
}).then(primeStoriesFromConnection(ctx)),
|
||||
debugScrapeMetadata: new DataLoader(
|
||||
createManyBatchLoadFn((url: string) => scraper.scrape(url))
|
||||
createManyBatchLoadFn((url: string) => scraper.scrape(url)),
|
||||
{
|
||||
// Disable caching for the DataLoader if the Context is designed to be
|
||||
// long lived.
|
||||
cache: !ctx.disableCaching,
|
||||
}
|
||||
),
|
||||
});
|
||||
|
||||
@@ -82,20 +82,27 @@ const statusFilter = (
|
||||
const primeUsersFromConnection = (ctx: Context) => (
|
||||
connection: Readonly<Connection<Readonly<User>>>
|
||||
) => {
|
||||
// For each of the nodes, prime the user loader.
|
||||
connection.nodes.forEach(user => {
|
||||
ctx.loaders.Users.user.prime(user.id, user);
|
||||
});
|
||||
if (!ctx.disableCaching) {
|
||||
// For each of the nodes, prime the user loader.
|
||||
connection.nodes.forEach(user => {
|
||||
ctx.loaders.Users.user.prime(user.id, user);
|
||||
});
|
||||
}
|
||||
|
||||
return connection;
|
||||
};
|
||||
|
||||
export default (ctx: Context) => {
|
||||
const user = new DataLoader<string, User | null>(ids =>
|
||||
retrieveManyUsers(ctx.mongo, ctx.tenant.id, ids)
|
||||
const user = new DataLoader<string, User | null>(
|
||||
ids => retrieveManyUsers(ctx.mongo, ctx.tenant.id, ids),
|
||||
{
|
||||
// Disable caching for the DataLoader if the Context is designed to be
|
||||
// long lived.
|
||||
cache: !ctx.disableCaching,
|
||||
}
|
||||
);
|
||||
|
||||
if (ctx.user) {
|
||||
if (ctx.user && !ctx.disableCaching) {
|
||||
// Prime the current logged in user in the dataloader cache.
|
||||
user.prime(ctx.user.id, ctx.user);
|
||||
}
|
||||
|
||||
@@ -1,15 +1,28 @@
|
||||
export interface SingletonResolverOptions {
|
||||
cacheable?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* SingletonResolver is a cached loader for a single result.
|
||||
*/
|
||||
export class SingletonResolver<T> {
|
||||
private cache: Promise<T> | null = null;
|
||||
private resolver: () => Promise<T>;
|
||||
private cacheable: boolean;
|
||||
|
||||
constructor(resolver: () => Promise<T>) {
|
||||
constructor(
|
||||
resolver: () => Promise<T>,
|
||||
{ cacheable = true }: SingletonResolverOptions = {}
|
||||
) {
|
||||
this.resolver = resolver;
|
||||
this.cacheable = cacheable;
|
||||
}
|
||||
|
||||
public load() {
|
||||
if (!this.cacheable) {
|
||||
return this.resolver();
|
||||
}
|
||||
|
||||
if (this.cache) {
|
||||
return this.cache;
|
||||
}
|
||||
|
||||
@@ -7,13 +7,13 @@ import {
|
||||
|
||||
export const Actions = (ctx: TenantContext) => ({
|
||||
approveComment: (input: GQLApproveCommentInput) =>
|
||||
approve(ctx.mongo, ctx.redis, ctx.tenant, {
|
||||
approve(ctx.mongo, ctx.redis, ctx.publisher, ctx.tenant, {
|
||||
commentID: input.commentID,
|
||||
commentRevisionID: input.commentRevisionID,
|
||||
moderatorID: ctx.user!.id,
|
||||
}),
|
||||
rejectComment: (input: GQLRejectCommentInput) =>
|
||||
reject(ctx.mongo, ctx.redis, ctx.tenant, {
|
||||
reject(ctx.mongo, ctx.redis, ctx.publisher, ctx.tenant, {
|
||||
commentID: input.commentID,
|
||||
commentRevisionID: input.commentRevisionID,
|
||||
moderatorID: ctx.user!.id,
|
||||
|
||||
@@ -43,6 +43,7 @@ export const Comments = (ctx: TenantContext) => ({
|
||||
create(
|
||||
ctx.mongo,
|
||||
ctx.redis,
|
||||
ctx.publisher,
|
||||
ctx.tenant,
|
||||
ctx.user!,
|
||||
{ authorID: ctx.user!.id, ...comment },
|
||||
@@ -64,6 +65,7 @@ export const Comments = (ctx: TenantContext) => ({
|
||||
edit(
|
||||
ctx.mongo,
|
||||
ctx.redis,
|
||||
ctx.publisher,
|
||||
ctx.tenant,
|
||||
ctx.user!,
|
||||
{
|
||||
@@ -87,6 +89,7 @@ export const Comments = (ctx: TenantContext) => ({
|
||||
createReaction(
|
||||
ctx.mongo,
|
||||
ctx.redis,
|
||||
ctx.publisher,
|
||||
ctx.tenant,
|
||||
ctx.user!,
|
||||
{
|
||||
@@ -107,6 +110,7 @@ export const Comments = (ctx: TenantContext) => ({
|
||||
createDontAgree(
|
||||
ctx.mongo,
|
||||
ctx.redis,
|
||||
ctx.publisher,
|
||||
ctx.tenant,
|
||||
ctx.user!,
|
||||
{
|
||||
@@ -133,6 +137,7 @@ export const Comments = (ctx: TenantContext) => ({
|
||||
createFlag(
|
||||
ctx.mongo,
|
||||
ctx.redis,
|
||||
ctx.publisher,
|
||||
ctx.tenant,
|
||||
ctx.user!,
|
||||
{
|
||||
@@ -161,7 +166,7 @@ export const Comments = (ctx: TenantContext) => ({
|
||||
ctx.now
|
||||
).then(comment =>
|
||||
comment.status !== GQLCOMMENT_STATUS.APPROVED
|
||||
? approve(ctx.mongo, ctx.redis, ctx.tenant, {
|
||||
? approve(ctx.mongo, ctx.redis, ctx.publisher, ctx.tenant, {
|
||||
commentID,
|
||||
commentRevisionID,
|
||||
moderatorID: ctx.user!.id,
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import {
|
||||
GQLMODERATION_QUEUE,
|
||||
SubscriptionToCommentEnteredModerationQueueResolver,
|
||||
} from "coral-server/graph/tenant/schema/__generated__/types";
|
||||
|
||||
import { createIterator } from "./helpers";
|
||||
import { SUBSCRIPTION_CHANNELS, SubscriptionPayload } from "./types";
|
||||
|
||||
export interface CommentEnteredModerationQueueInput
|
||||
extends SubscriptionPayload {
|
||||
queue: GQLMODERATION_QUEUE;
|
||||
commentID: string;
|
||||
storyID: string;
|
||||
}
|
||||
|
||||
export const commentEnteredModerationQueue: SubscriptionToCommentEnteredModerationQueueResolver<
|
||||
CommentEnteredModerationQueueInput
|
||||
> = createIterator(SUBSCRIPTION_CHANNELS.COMMENT_ENTERED_MODERATION_QUEUE, {
|
||||
filter: (source, { storyID, queue }) => {
|
||||
// If we're filtering by storyID, then only send back comments with the
|
||||
// specific storyID.
|
||||
if (storyID && source.storyID !== storyID) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// If we're filtering by queue, then only send back comments from the
|
||||
// specific queue.
|
||||
if (queue && source.queue !== queue) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
},
|
||||
resolve: ({ queue, commentID }, args, ctx) => ({
|
||||
queue: () => queue,
|
||||
comment: () => ctx.loaders.Comments.comment.load(commentID),
|
||||
}),
|
||||
});
|
||||
@@ -0,0 +1,37 @@
|
||||
import {
|
||||
GQLMODERATION_QUEUE,
|
||||
SubscriptionToCommentLeftModerationQueueResolver,
|
||||
} from "coral-server/graph/tenant/schema/__generated__/types";
|
||||
|
||||
import { createIterator } from "./helpers";
|
||||
import { SUBSCRIPTION_CHANNELS, SubscriptionPayload } from "./types";
|
||||
|
||||
export interface CommentLeftModerationQueueInput extends SubscriptionPayload {
|
||||
queue: GQLMODERATION_QUEUE;
|
||||
commentID: string;
|
||||
storyID: string;
|
||||
}
|
||||
|
||||
export const commentLeftModerationQueue: SubscriptionToCommentLeftModerationQueueResolver<
|
||||
CommentLeftModerationQueueInput
|
||||
> = createIterator(SUBSCRIPTION_CHANNELS.COMMENT_LEFT_MODERATION_QUEUE, {
|
||||
filter: (source, { storyID, queue }) => {
|
||||
// If we're filtering by storyID, then only send back comments with the
|
||||
// specific storyID.
|
||||
if (storyID && source.storyID !== storyID) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// If we're filtering by queue, then only send back comments from the
|
||||
// specific queue.
|
||||
if (queue && source.queue !== queue) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
},
|
||||
resolve: ({ queue, commentID }, args, ctx) => ({
|
||||
queue: () => queue,
|
||||
comment: () => ctx.loaders.Comments.comment.load(commentID),
|
||||
}),
|
||||
});
|
||||
@@ -0,0 +1,40 @@
|
||||
import {
|
||||
GQLCOMMENT_STATUS,
|
||||
SubscriptionToCommentStatusUpdatedResolver,
|
||||
} from "coral-server/graph/tenant/schema/__generated__/types";
|
||||
|
||||
import { createIterator } from "./helpers";
|
||||
import { SUBSCRIPTION_CHANNELS, SubscriptionPayload } from "./types";
|
||||
|
||||
export interface CommentStatusUpdatedInput extends SubscriptionPayload {
|
||||
newStatus: GQLCOMMENT_STATUS;
|
||||
oldStatus: GQLCOMMENT_STATUS;
|
||||
moderatorID: string | null;
|
||||
commentID: string;
|
||||
}
|
||||
|
||||
export const commentStatusUpdated: SubscriptionToCommentStatusUpdatedResolver<
|
||||
CommentStatusUpdatedInput
|
||||
> = createIterator(SUBSCRIPTION_CHANNELS.COMMENT_STATUS_UPDATED, {
|
||||
filter: (source, { id }) => {
|
||||
// If we're filtering by id, then only send back updates for the specified
|
||||
// comment.
|
||||
if (id && source.commentID !== id) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
},
|
||||
resolve: ({ newStatus, oldStatus, moderatorID, commentID }, args, ctx) => ({
|
||||
newStatus: () => newStatus,
|
||||
oldStatus: () => oldStatus,
|
||||
moderator: () => {
|
||||
if (moderatorID) {
|
||||
return ctx.loaders.Users.user.load(moderatorID);
|
||||
}
|
||||
|
||||
return null;
|
||||
},
|
||||
comment: () => ctx.loaders.Comments.comment.load(commentID),
|
||||
}),
|
||||
});
|
||||
@@ -0,0 +1,108 @@
|
||||
import { GraphQLResolveInfo } from "graphql";
|
||||
import { withFilter } from "graphql-subscriptions";
|
||||
|
||||
import TenantContext from "../../context";
|
||||
import { SUBSCRIPTION_CHANNELS, SubscriptionPayload } from "./types";
|
||||
|
||||
type FilterFn<TParent, TArgs, TContext> = (
|
||||
parent: TParent,
|
||||
args: TArgs,
|
||||
context: TContext,
|
||||
info: GraphQLResolveInfo
|
||||
) => boolean | Promise<boolean>;
|
||||
|
||||
type Resolver<TParent, TArgs, TResult> = (
|
||||
source: TParent,
|
||||
args: TArgs,
|
||||
ctx: TenantContext,
|
||||
info: GraphQLResolveInfo
|
||||
) => TResult;
|
||||
|
||||
interface SubscriptionResolver<TParent, TArgs, TResult> {
|
||||
subscribe: Resolver<TParent, TArgs, AsyncIterator<TResult>>;
|
||||
resolve?: Resolver<TParent, TArgs, TResult>;
|
||||
}
|
||||
|
||||
export function createTenantAsyncIterator<TParent, TArgs, TResult>(
|
||||
channel: SUBSCRIPTION_CHANNELS
|
||||
): Resolver<TParent, TArgs, AsyncIterator<TResult>> {
|
||||
return (source, args, ctx) =>
|
||||
ctx.pubsub.asyncIterator<TResult>(
|
||||
createSubscriptionChannelName(ctx.tenant.id, channel)
|
||||
);
|
||||
}
|
||||
|
||||
export function createSubscriptionChannelName(
|
||||
tenantID: string,
|
||||
channel: SUBSCRIPTION_CHANNELS
|
||||
): string {
|
||||
return `TENANT[${tenantID}][${channel}]`;
|
||||
}
|
||||
|
||||
/**
|
||||
* defaultFilterFn will perform filtering operations on the subscription
|
||||
* responses to ensure that mutations issued by one user is not sent back as a
|
||||
* subscription to the same requesting User, as they already implement the
|
||||
* update via the mutation response.
|
||||
*
|
||||
* @param source the source for the document passed down, we don't actually need
|
||||
* it here.
|
||||
* @param args the arguments for the specific subscription operation, we don't
|
||||
* actually need it here.
|
||||
* @param ctx the context for the request, this contains the references we'll
|
||||
* need to determine eligibility to send the subscription back or
|
||||
* not.
|
||||
*/
|
||||
export function defaultFilterFn<TParent extends SubscriptionPayload, TArgs>(
|
||||
source: TParent,
|
||||
args: TArgs,
|
||||
ctx: TenantContext
|
||||
): boolean {
|
||||
if (source.clientID && ctx.clientID && source.clientID === ctx.clientID) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure that even when we're provided with a domain specific filtering
|
||||
* function we respect the subscription id that is sent back with the request to
|
||||
* prevent double responses.
|
||||
*/
|
||||
export function createFilterFn<TParent, TArgs>(
|
||||
filter?: FilterFn<TParent, TArgs, TenantContext>
|
||||
): FilterFn<TParent, TArgs, TenantContext> {
|
||||
return filter
|
||||
? // Combine the filters, preferring the defaultFilterFn first.
|
||||
(source, args, ctx, info) => {
|
||||
if (!defaultFilterFn(source, args, ctx)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return filter(source, args, ctx, info);
|
||||
}
|
||||
: defaultFilterFn;
|
||||
}
|
||||
|
||||
export interface CreateIteratorInput<TParent, TArgs, TResult> {
|
||||
filter?: FilterFn<TParent, TArgs, TenantContext>;
|
||||
resolve?: Resolver<TParent, TArgs, TResult>;
|
||||
}
|
||||
|
||||
export function createIterator<
|
||||
TParent extends SubscriptionPayload,
|
||||
TArgs,
|
||||
TResult
|
||||
>(
|
||||
channel: SUBSCRIPTION_CHANNELS,
|
||||
{ filter, resolve }: CreateIteratorInput<TParent, TArgs, TResult> = {}
|
||||
): SubscriptionResolver<TParent, TArgs, TResult> {
|
||||
return {
|
||||
subscribe: withFilter(
|
||||
createTenantAsyncIterator(channel),
|
||||
createFilterFn(filter)
|
||||
),
|
||||
resolve,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { GQLSubscriptionTypeResolver } from "coral-server/graph/tenant/schema/__generated__/types";
|
||||
|
||||
import { commentEnteredModerationQueue } from "./commentEnteredModerationQueue";
|
||||
import { commentLeftModerationQueue } from "./commentLeftModerationQueue";
|
||||
import { commentStatusUpdated } from "./commentStatusUpdated";
|
||||
|
||||
export const Subscription: GQLSubscriptionTypeResolver = {
|
||||
commentEnteredModerationQueue,
|
||||
commentLeftModerationQueue,
|
||||
commentStatusUpdated,
|
||||
};
|
||||
@@ -0,0 +1,35 @@
|
||||
import { CommentEnteredModerationQueueInput } from "./commentEnteredModerationQueue";
|
||||
import { CommentLeftModerationQueueInput } from "./commentLeftModerationQueue";
|
||||
import { CommentStatusUpdatedInput } from "./commentStatusUpdated";
|
||||
|
||||
export enum SUBSCRIPTION_CHANNELS {
|
||||
COMMENT_ENTERED_MODERATION_QUEUE = "COMMENT_ENTERED_MODERATION_QUEUE",
|
||||
COMMENT_LEFT_MODERATION_QUEUE = "COMMENT_LEFT_MODERATION_QUEUE",
|
||||
COMMENT_STATUS_UPDATED = "COMMENT_STATUS_UPDATED",
|
||||
}
|
||||
|
||||
export interface SubscriptionPayload {
|
||||
clientID?: string;
|
||||
}
|
||||
|
||||
export interface SubscriptionType<
|
||||
TChannel extends SUBSCRIPTION_CHANNELS,
|
||||
TPayload extends SubscriptionPayload
|
||||
> {
|
||||
channel: TChannel;
|
||||
payload: TPayload;
|
||||
}
|
||||
|
||||
export type SUBSCRIPTION_INPUT =
|
||||
| SubscriptionType<
|
||||
SUBSCRIPTION_CHANNELS.COMMENT_ENTERED_MODERATION_QUEUE,
|
||||
CommentEnteredModerationQueueInput
|
||||
>
|
||||
| SubscriptionType<
|
||||
SUBSCRIPTION_CHANNELS.COMMENT_LEFT_MODERATION_QUEUE,
|
||||
CommentLeftModerationQueueInput
|
||||
>
|
||||
| SubscriptionType<
|
||||
SUBSCRIPTION_CHANNELS.COMMENT_STATUS_UPDATED,
|
||||
CommentStatusUpdatedInput
|
||||
>;
|
||||
@@ -25,6 +25,7 @@ import { Query } from "./Query";
|
||||
import { RejectCommentPayload } from "./RejectCommentPayload";
|
||||
import { Story } from "./Story";
|
||||
import { StorySettings } from "./StorySettings";
|
||||
import { Subscription } from "./Subscription";
|
||||
import { SuspensionStatus } from "./SuspensionStatus";
|
||||
import { SuspensionStatusHistory } from "./SuspensionStatusHistory";
|
||||
import { Tag } from "./Tag";
|
||||
@@ -56,6 +57,7 @@ const Resolvers: GQLResolver = {
|
||||
RejectCommentPayload,
|
||||
Story,
|
||||
StorySettings,
|
||||
Subscription,
|
||||
SuspensionStatus,
|
||||
SuspensionStatusHistory,
|
||||
Tag,
|
||||
|
||||
@@ -4459,3 +4459,121 @@ type Mutation {
|
||||
removeUserIgnore(input: RemoveUserIgnoreInput!): RemoveUserIgnorePayload!
|
||||
@auth(permit: [SUSPENDED, BANNED])
|
||||
}
|
||||
|
||||
##################
|
||||
## Subscriptions
|
||||
##################
|
||||
|
||||
"""
|
||||
CommentStatusUpdatedPayload is returned when a Comment has it's status updated
|
||||
after it was created.
|
||||
"""
|
||||
type CommentStatusUpdatedPayload {
|
||||
"""
|
||||
newStatus is the new status assigned to the Comment. This status may not
|
||||
match the status provided by `comment.status` due to race conditions in the
|
||||
data loaders.
|
||||
"""
|
||||
newStatus: COMMENT_STATUS!
|
||||
|
||||
"""
|
||||
oldStatus is the old status that was previously assigned to the Comment.
|
||||
"""
|
||||
oldStatus: COMMENT_STATUS!
|
||||
|
||||
"""
|
||||
moderator is the User that updated the Comment's status. If null, then the
|
||||
system assigned the new Comment status (for example, when a comment is edited
|
||||
by the author, and now contains a banned word).
|
||||
"""
|
||||
moderator: User
|
||||
|
||||
"""
|
||||
comment is the updated Comment after the status has been updated.
|
||||
"""
|
||||
comment: Comment!
|
||||
}
|
||||
|
||||
"""
|
||||
MODERATION_QUEUE references the specific ModerationQueue that a given Comment
|
||||
can be associated with.
|
||||
"""
|
||||
enum MODERATION_QUEUE {
|
||||
"""
|
||||
UNMODERATED refers to the ModerationQueue for all Comments that have not been
|
||||
moderated yet.
|
||||
"""
|
||||
UNMODERATED
|
||||
|
||||
"""
|
||||
REPORTED refers to the ModerationQueue for all Comments that have been
|
||||
published, have not been moderated by a human yet, and have been reported by
|
||||
a User via a flag.
|
||||
"""
|
||||
REPORTED
|
||||
|
||||
"""
|
||||
PENDING refers to the ModerationQueue for all Comments that were held back by
|
||||
the system and require moderation in order to be published.
|
||||
"""
|
||||
PENDING
|
||||
}
|
||||
|
||||
"""
|
||||
CommentEnteredModerationQueuePayload is returned when a Comment enters a
|
||||
specific ModerationQueue.
|
||||
"""
|
||||
type CommentEnteredModerationQueuePayload {
|
||||
"""
|
||||
queue refers to the specific ModerationQueue that a given Comment entered.
|
||||
"""
|
||||
queue: MODERATION_QUEUE!
|
||||
|
||||
"""
|
||||
comment is the Comment that entered the ModerationQueue.
|
||||
"""
|
||||
comment: Comment!
|
||||
}
|
||||
|
||||
"""
|
||||
CommentLeftModerationQueuePayload is returned when a Comment leaves a specific
|
||||
ModerationQueue.
|
||||
"""
|
||||
type CommentLeftModerationQueuePayload {
|
||||
"""
|
||||
queue refers to the specific ModerationQueue that a given Comment left.
|
||||
"""
|
||||
queue: MODERATION_QUEUE!
|
||||
|
||||
"""
|
||||
comment is the Comment that left the ModerationQueue.
|
||||
"""
|
||||
comment: Comment!
|
||||
}
|
||||
|
||||
type Subscription {
|
||||
"""
|
||||
commentEnteredModerationQueue returns when a Comment enters a ModerationQueue.
|
||||
Note that a Comment may enter multiple moderation queues.
|
||||
"""
|
||||
commentEnteredModerationQueue(
|
||||
storyID: ID
|
||||
queue: MODERATION_QUEUE
|
||||
): CommentEnteredModerationQueuePayload! @auth(roles: [MODERATOR, ADMIN])
|
||||
|
||||
"""
|
||||
commentLeftModerationQueue returns when a Comment leaves a ModerationQueue.
|
||||
Note that a Comment may leave multiple moderation queues.
|
||||
"""
|
||||
commentLeftModerationQueue(
|
||||
storyID: ID
|
||||
queue: MODERATION_QUEUE
|
||||
): CommentLeftModerationQueuePayload! @auth(roles: [MODERATOR, ADMIN])
|
||||
|
||||
"""
|
||||
commentStatusUpdated returns when a Comment has it's status changed after
|
||||
being created.
|
||||
"""
|
||||
commentStatusUpdated(id: ID): CommentStatusUpdatedPayload!
|
||||
@auth(roles: [MODERATOR, ADMIN])
|
||||
}
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import { RedisPubSub } from "graphql-redis-subscriptions";
|
||||
|
||||
import { createSubscriptionChannelName } from "coral-server/graph/tenant/resolvers/Subscription/helpers";
|
||||
import { SUBSCRIPTION_INPUT } from "coral-server/graph/tenant/resolvers/Subscription/types";
|
||||
import logger from "coral-server/logger";
|
||||
|
||||
export type Publisher = (input: SUBSCRIPTION_INPUT) => Promise<void>;
|
||||
|
||||
/**
|
||||
* createPublisher will create a new Publisher that can be used to send events
|
||||
* over the pubsub broker to facilitate live updates.
|
||||
*
|
||||
* @param pubsub the pubsub broker to be used to facilitate the publish action
|
||||
* @param tenantID the ID of the Tenant where the event will be published with
|
||||
* @param clientID the ID of the client to de-duplicate mutation responses
|
||||
*/
|
||||
export const createPublisher = (
|
||||
pubsub: RedisPubSub,
|
||||
tenantID: string,
|
||||
clientID?: string
|
||||
): Publisher => async ({ channel, payload }) => {
|
||||
logger.trace({ channel, tenantID, clientID }, "publishing event");
|
||||
|
||||
return pubsub.publish(createSubscriptionChannelName(tenantID, channel), {
|
||||
...payload,
|
||||
clientID,
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,222 @@
|
||||
import {
|
||||
execute,
|
||||
ExecutionResult,
|
||||
GraphQLSchema,
|
||||
parse,
|
||||
subscribe,
|
||||
} from "graphql";
|
||||
import http, { IncomingMessage } from "http";
|
||||
import {
|
||||
ConnectionContext,
|
||||
ExecutionParams,
|
||||
OperationMessagePayload,
|
||||
SubscriptionServer,
|
||||
} from "subscriptions-transport-ws";
|
||||
|
||||
import { ACCESS_TOKEN_PARAM, CLIENT_ID_PARAM } from "coral-common/constants";
|
||||
import { Omit } from "coral-common/types";
|
||||
import { AppOptions } from "coral-server/app";
|
||||
import { getHostname } from "coral-server/app/helpers/hostname";
|
||||
import {
|
||||
createVerifiers,
|
||||
verifyAndRetrieveUser,
|
||||
} from "coral-server/app/middleware/passport/strategies/jwt";
|
||||
import {
|
||||
CoralError,
|
||||
InternalError,
|
||||
TenantNotFoundError,
|
||||
} from "coral-server/errors";
|
||||
import {
|
||||
enrichError,
|
||||
logError,
|
||||
logQuery,
|
||||
} from "coral-server/graph/common/extensions";
|
||||
import { getOperationMetadata } from "coral-server/graph/common/extensions/helpers";
|
||||
import logger from "coral-server/logger";
|
||||
import { extractTokenFromRequest } from "coral-server/services/jwt";
|
||||
|
||||
import TenantContext, { TenantContextOptions } from "../context";
|
||||
|
||||
type OnConnectFn = (
|
||||
params: OperationMessagePayload,
|
||||
socket: any,
|
||||
context: ConnectionContext
|
||||
) => Promise<TenantContext>;
|
||||
|
||||
export function extractTokenFromWSRequest(
|
||||
connectionParams: OperationMessagePayload,
|
||||
req: IncomingMessage
|
||||
): string | null {
|
||||
// Try to grab the token from the connection params if available.
|
||||
if (
|
||||
typeof connectionParams[ACCESS_TOKEN_PARAM] === "string" &&
|
||||
connectionParams[ACCESS_TOKEN_PARAM].length > 0
|
||||
) {
|
||||
return connectionParams[ACCESS_TOKEN_PARAM];
|
||||
}
|
||||
|
||||
// Try to get the access token from the request.
|
||||
return extractTokenFromRequest(req);
|
||||
}
|
||||
|
||||
export function extractClientID(connectionParams: OperationMessagePayload) {
|
||||
if (
|
||||
typeof connectionParams[CLIENT_ID_PARAM] === "string" &&
|
||||
connectionParams[CLIENT_ID_PARAM].length > 0
|
||||
) {
|
||||
return connectionParams[CLIENT_ID_PARAM];
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export type OnConnectOptions = Omit<
|
||||
TenantContextOptions,
|
||||
"tenant" | "signingConfig" | "disableCaching"
|
||||
> &
|
||||
Required<Pick<TenantContextOptions, "signingConfig">>;
|
||||
|
||||
export function onConnect(options: OnConnectOptions): OnConnectFn {
|
||||
// Create the JWT verifiers that will be used to verify all the requests
|
||||
// coming in.
|
||||
const verifiers = createVerifiers(options);
|
||||
|
||||
// Return the per-connection operation.
|
||||
return async (connectionParams, socket) => {
|
||||
try {
|
||||
// Pull the upgrade request off of the connection.
|
||||
const req: IncomingMessage = socket.upgradeReq;
|
||||
|
||||
// Get the hostname of the request.
|
||||
const hostname = getHostname(req);
|
||||
if (!hostname) {
|
||||
throw new Error("could not detect hostname");
|
||||
}
|
||||
|
||||
// Get the Tenant for this hostname.
|
||||
const tenant = await options.tenantCache.retrieveByDomain(hostname);
|
||||
if (!tenant) {
|
||||
throw new TenantNotFoundError(hostname);
|
||||
}
|
||||
|
||||
// Create some new options to store the tenant context details inside.
|
||||
const opts: TenantContextOptions = {
|
||||
...options,
|
||||
// Disable caching with this Context to ensure that every call (besides)
|
||||
// to the tenant, is not cached, and is instead fresh.
|
||||
disableCaching: true,
|
||||
tenant,
|
||||
};
|
||||
|
||||
// If the token is available, try to get the user.
|
||||
const tokenString = extractTokenFromWSRequest(connectionParams, req);
|
||||
if (tokenString) {
|
||||
const user = await verifyAndRetrieveUser(
|
||||
verifiers,
|
||||
tenant,
|
||||
tokenString
|
||||
);
|
||||
if (user) {
|
||||
opts.user = user;
|
||||
}
|
||||
}
|
||||
|
||||
// Extract the users clientID from the request.
|
||||
const clientID = extractClientID(connectionParams);
|
||||
if (clientID) {
|
||||
opts.clientID = clientID;
|
||||
}
|
||||
|
||||
return new TenantContext(opts);
|
||||
} catch (err) {
|
||||
logger.error({ err }, "could not setup websocket connection");
|
||||
|
||||
if (!(err instanceof CoralError)) {
|
||||
err = new InternalError(err, "could not setup websocket connection");
|
||||
}
|
||||
const { message } = err.serializeExtensions(
|
||||
options.i18n.getDefaultBundle()
|
||||
);
|
||||
|
||||
throw { message };
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export type FormatResponseOptions = Pick<AppOptions, "metrics">;
|
||||
|
||||
export function formatResponse({ metrics }: FormatResponseOptions) {
|
||||
return (
|
||||
value: ExecutionResult,
|
||||
{ context, query }: ExecutionParams<TenantContext>
|
||||
) => {
|
||||
// Parse the query in order to extract operation metadata.
|
||||
if (typeof query === "string") {
|
||||
query = parse(query);
|
||||
}
|
||||
|
||||
// Log out the query.
|
||||
logQuery(context, query);
|
||||
|
||||
// Increment the metrics if enabled.
|
||||
if (metrics) {
|
||||
// Get the request metadata.
|
||||
const { operation, operationName } = getOperationMetadata(query);
|
||||
if (operation && operationName) {
|
||||
// Increment the graph query value, tagging with the name of the query.
|
||||
metrics.executedGraphQueriesTotalCounter
|
||||
.labels(operation, operationName)
|
||||
.inc();
|
||||
}
|
||||
}
|
||||
|
||||
if (value.errors && value.errors.length > 0) {
|
||||
return {
|
||||
...value,
|
||||
errors: value.errors.map(err => {
|
||||
const enriched = enrichError(context, err);
|
||||
|
||||
// Log the error out.
|
||||
logError(context, enriched);
|
||||
|
||||
return enriched;
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
return value;
|
||||
};
|
||||
}
|
||||
|
||||
export type OnOperationOptions = FormatResponseOptions;
|
||||
|
||||
export function onOperation(options: OnOperationOptions) {
|
||||
return (message: any, params: ExecutionParams<TenantContext>) => {
|
||||
// Attach the response formatter.
|
||||
params.formatResponse = formatResponse(options);
|
||||
|
||||
return params;
|
||||
};
|
||||
}
|
||||
|
||||
export type Options = OnConnectOptions & OnOperationOptions;
|
||||
|
||||
export function createSubscriptionServer(
|
||||
server: http.Server,
|
||||
schema: GraphQLSchema,
|
||||
options: Options
|
||||
) {
|
||||
return SubscriptionServer.create(
|
||||
{
|
||||
schema,
|
||||
execute,
|
||||
subscribe,
|
||||
onConnect: onConnect(options),
|
||||
onOperation: onOperation(options),
|
||||
},
|
||||
{
|
||||
server,
|
||||
path: "/api/graphql/live",
|
||||
}
|
||||
);
|
||||
}
|
||||
+42
-15
@@ -1,18 +1,28 @@
|
||||
import cluster from "cluster";
|
||||
import express, { Express } from "express";
|
||||
import { GraphQLSchema } from "graphql";
|
||||
import { RedisPubSub } from "graphql-redis-subscriptions";
|
||||
import http from "http";
|
||||
import { Db } from "mongodb";
|
||||
import { AggregatorRegistry, collectDefaultMetrics } from "prom-client";
|
||||
import { SubscriptionServer } from "subscriptions-transport-ws";
|
||||
|
||||
import { LanguageCode } from "coral-common/helpers/i18n/locales";
|
||||
import { createApp, listenAndServe } from "coral-server/app";
|
||||
import { AppOptions, createApp, listenAndServe } from "coral-server/app";
|
||||
import { basicAuth } from "coral-server/app/middleware/basicAuth";
|
||||
import { noCacheMiddleware } from "coral-server/app/middleware/cacheHeaders";
|
||||
import { JSONErrorHandler } from "coral-server/app/middleware/error";
|
||||
import { accessLogger, errorLogger } from "coral-server/app/middleware/logging";
|
||||
import { notFoundMiddleware } from "coral-server/app/middleware/notFound";
|
||||
import config, { Config } from "coral-server/config";
|
||||
import { createPubSubClient } from "coral-server/graph/common/subscriptions/pubsub";
|
||||
import getTenantSchema from "coral-server/graph/tenant/schema";
|
||||
import { createSubscriptionServer } from "coral-server/graph/tenant/subscriptions/server";
|
||||
import logger from "coral-server/logger";
|
||||
import { createQueue, TaskQueue } from "coral-server/queue";
|
||||
import { I18n } from "coral-server/services/i18n";
|
||||
import { createJWTSigningConfig } from "coral-server/services/jwt";
|
||||
import { createMetrics } from "coral-server/services/metrics";
|
||||
import { createMongoDB } from "coral-server/services/mongodb";
|
||||
import { ensureIndexes } from "coral-server/services/mongodb/indexes";
|
||||
import {
|
||||
@@ -21,11 +31,6 @@ import {
|
||||
createRedisClient,
|
||||
} from "coral-server/services/redis";
|
||||
import TenantCache from "coral-server/services/tenant/cache";
|
||||
import { basicAuth } from "./app/middleware/basicAuth";
|
||||
import { noCacheMiddleware } from "./app/middleware/cacheHeaders";
|
||||
import { JSONErrorHandler } from "./app/middleware/error";
|
||||
import { accessLogger, errorLogger } from "./app/middleware/logging";
|
||||
import { notFoundMiddleware } from "./app/middleware/notFound";
|
||||
|
||||
export interface ServerOptions {
|
||||
/**
|
||||
@@ -55,12 +60,19 @@ class Server {
|
||||
// the requested port.
|
||||
public httpServer: http.Server;
|
||||
|
||||
// subscriptionServer is the running instance of the HTTP server that will
|
||||
// bind to the requested port to serve websocket traffic.
|
||||
public subscriptionServer: SubscriptionServer;
|
||||
|
||||
// tasks stores a reference to the queues that can process operations.
|
||||
private tasks: TaskQueue;
|
||||
|
||||
// redis stores the redis connection used by the application.
|
||||
private redis: AugmentedRedis;
|
||||
|
||||
// pubsub stores the pubsub engine used by the application.
|
||||
private pubsub: RedisPubSub;
|
||||
|
||||
// mongo stores the mongo connection used by the application.
|
||||
private mongo: Db;
|
||||
|
||||
@@ -134,6 +146,12 @@ class Server {
|
||||
i18n: this.i18n,
|
||||
});
|
||||
|
||||
// Create the pubsub client.
|
||||
this.pubsub = createPubSubClient(
|
||||
createRedisClient(this.config),
|
||||
createRedisClient(this.config)
|
||||
);
|
||||
|
||||
// Setup the metrics collectors.
|
||||
collectDefaultMetrics({ timeout: 5000 });
|
||||
}
|
||||
@@ -238,16 +256,13 @@ class Server {
|
||||
// Create the signing config.
|
||||
const signingConfig = createJWTSigningConfig(this.config);
|
||||
|
||||
// Only enable the metrics server if concurrency is set to 1.
|
||||
const metrics = this.config.get("concurrency") === 1;
|
||||
|
||||
// Disables the client routes to serve bundles etc. Useful for devleoping with
|
||||
// Disables the client routes to serve bundles etc. Useful for developing with
|
||||
// Webpack Dev Server.
|
||||
const disableClientRoutes = this.config.get("disable_client_routes");
|
||||
|
||||
// Create the Coral App, branching off from the parent app.
|
||||
const app: Express = await createApp({
|
||||
const options: AppOptions = {
|
||||
parent,
|
||||
pubsub: this.pubsub,
|
||||
mongo: this.mongo,
|
||||
redis: this.redis,
|
||||
signingConfig,
|
||||
@@ -257,16 +272,28 @@ class Server {
|
||||
i18n: this.i18n,
|
||||
mailerQueue: this.tasks.mailer,
|
||||
scraperQueue: this.tasks.scraper,
|
||||
metrics,
|
||||
disableClientRoutes,
|
||||
});
|
||||
};
|
||||
|
||||
// Only enable the metrics server if concurrency is set to 1.
|
||||
if (this.config.get("concurrency") === 1) {
|
||||
options.metrics = createMetrics();
|
||||
}
|
||||
|
||||
// Create the Coral App, branching off from the parent app.
|
||||
const app: Express = await createApp(options);
|
||||
|
||||
// Start the application and store the resulting http.Server. The server
|
||||
// will return when the server starts listening. The NodeJS application will
|
||||
// not exit until all tasks are handled, which for an open socket, is never.
|
||||
this.httpServer = await listenAndServe(app, port);
|
||||
|
||||
// TODO: (wyattjoh) add the subscription handler here
|
||||
// Setup subscriptions and attach it to the httpServer.
|
||||
this.subscriptionServer = createSubscriptionServer(
|
||||
this.httpServer,
|
||||
this.schema,
|
||||
options
|
||||
);
|
||||
|
||||
logger.info({ port }, "now listening");
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user