[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 17:01:07 +00:00
committed by Wyatt Johnson
parent 0e247ba383
commit 413f3e2f1e
111 changed files with 8230 additions and 5017 deletions
@@ -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);
}