[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
+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();
});
});
});