[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:
Vinh
2019-06-21 19:01:07 +02:00
committed by Wyatt Johnson
parent 0e247ba383
commit 413f3e2f1e
111 changed files with 8230 additions and 5017 deletions
+5
View File
@@ -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}`,
+55 -10
View File
@@ -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
View File
@@ -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"
);
}
+13
View File
@@ -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);
}
@@ -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;
@@ -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;
@@ -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 />;
}
@@ -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;
+33 -1
View File
@@ -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());
},
};
}
+13 -2
View File
@@ -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);
}
+18
View File
@@ -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);
}
+48 -24
View File
@@ -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
)
);
+21
View File
@@ -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;
}
+8 -3
View File
@@ -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);
+17 -26
View File
@@ -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")) {
+7 -14
View File
@@ -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();
}
+7 -1
View File
@@ -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",
})
);
}
+2 -2
View File
@@ -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 } },
});
}
}
+19
View File
@@ -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,
});
}
+16 -12
View File
@@ -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 -2
View File
@@ -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,
}
),
});
+14 -7
View File
@@ -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);
}
+14 -1
View File
@@ -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
View File
@@ -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