* [CORL-863] Allow streams to be converted/configured to Q&A (#2809)

* Create preliminary schema changes for Q&A

Adds a mode and expert User onto the StorySettings.
Adds a mode selection drop down on a story's
Configure tab.

CORL-863

* Allow multiple experts, remove form elements from search

Makes the previous expert user on a Q&A story now
an array of users who can be assigned.

Converts the previous form based search that was
pulled from the admin community area into a set
of events built on callbacks.

CORL-863

* Create addExpertToStory mutation

CORL-863

* Create removeExpertFromStory mutation

CORL-863

* Conditionally show the the expert selection options

CORL-863

* Create a dropdown search control for Q&A experts

CORL-863

* Fixing up tests to match new QA stream options

Adds a few localization fixes to make sure tests
pass.
Updates existing snapshots.

CORL-863

* Add load more button to expert search list

CORL-863

* Update experts query to match react upgrades

CORL-863

* Move the Q&A config to its own section under stream config

Create enable and disable Q&A mutations/button toggle.

CORL-863

* Fix alignment and layout of expert list items

CORL-863

* Define translations and update tests

CORL-863

* Use official copy for Q&A config

CORL-863

* [CORL-856] Show expert badge on comments (#2829)

* Create preliminary schema changes for Q&A

Adds a mode and expert User onto the StorySettings.
Adds a mode selection drop down on a story's
Configure tab.

CORL-863

* Allow multiple experts, remove form elements from search

Makes the previous expert user on a Q&A story now
an array of users who can be assigned.

Converts the previous form based search that was
pulled from the admin community area into a set
of events built on callbacks.

CORL-863

* Create addExpertToStory mutation

CORL-863

* Create removeExpertFromStory mutation

CORL-863

* Conditionally show the the expert selection options

CORL-863

* Create a dropdown search control for Q&A experts

CORL-863

* Fixing up tests to match new QA stream options

Adds a few localization fixes to make sure tests
pass.
Updates existing snapshots.

CORL-863

* Add load more button to expert search list

CORL-863

* Update experts query to match react upgrades

CORL-863

* Move the Q&A config to its own section under stream config

Create enable and disable Q&A mutations/button toggle.

CORL-863

* Fix alignment and layout of expert list items

CORL-863

* Define translations and update tests

CORL-863

* Use official copy for Q&A config

CORL-863

* Show expert badges on comments when Q&A is enabled

CORL-856

* Update mutation responses and tests due to added expert fields

CORL-856

* Use EXPERT user tags to denote expert users

Removes the need for viewerIsExpert and
authorIsExpert loader/resolvers on Stories
and Comments respectively.

CORL-856

* [CORL-879] Add an unanswered tab to stream when in Q&A mode (#2838)

* Create preliminary schema changes for Q&A

Adds a mode and expert User onto the StorySettings.
Adds a mode selection drop down on a story's
Configure tab.

CORL-863

* Allow multiple experts, remove form elements from search

Makes the previous expert user on a Q&A story now
an array of users who can be assigned.

Converts the previous form based search that was
pulled from the admin community area into a set
of events built on callbacks.

CORL-863

* Create addExpertToStory mutation

CORL-863

* Create removeExpertFromStory mutation

CORL-863

* Conditionally show the the expert selection options

CORL-863

* Create a dropdown search control for Q&A experts

CORL-863

* Fixing up tests to match new QA stream options

Adds a few localization fixes to make sure tests
pass.
Updates existing snapshots.

CORL-863

* Add load more button to expert search list

CORL-863

* Update experts query to match react upgrades

CORL-863

* Move the Q&A config to its own section under stream config

Create enable and disable Q&A mutations/button toggle.

CORL-863

* Fix alignment and layout of expert list items

CORL-863

* Define translations and update tests

CORL-863

* Use official copy for Q&A config

CORL-863

* Show expert badges on comments when Q&A is enabled

CORL-856

* Update mutation responses and tests due to added expert fields

CORL-856

* Use EXPERT user tags to denote expert users

Removes the need for viewerIsExpert and
authorIsExpert loader/resolvers on Stories
and Comments respectively.

CORL-856

* Show an unanswered comment stream when Q&A is enabled

CORL-879

* Do not visually show the unanswered tag

CORL-879

* [CORL-859] Convert Featured stream into Answered for Q&A (#2842)

* Create preliminary schema changes for Q&A

Adds a mode and expert User onto the StorySettings.
Adds a mode selection drop down on a story's
Configure tab.

CORL-863

* Allow multiple experts, remove form elements from search

Makes the previous expert user on a Q&A story now
an array of users who can be assigned.

Converts the previous form based search that was
pulled from the admin community area into a set
of events built on callbacks.

CORL-863

* Create addExpertToStory mutation

CORL-863

* Create removeExpertFromStory mutation

CORL-863

* Conditionally show the the expert selection options

CORL-863

* Create a dropdown search control for Q&A experts

CORL-863

* Fixing up tests to match new QA stream options

Adds a few localization fixes to make sure tests
pass.
Updates existing snapshots.

CORL-863

* Add load more button to expert search list

CORL-863

* Update experts query to match react upgrades

CORL-863

* Move the Q&A config to its own section under stream config

Create enable and disable Q&A mutations/button toggle.

CORL-863

* Fix alignment and layout of expert list items

CORL-863

* Define translations and update tests

CORL-863

* Use official copy for Q&A config

CORL-863

* Show expert badges on comments when Q&A is enabled

CORL-856

* Update mutation responses and tests due to added expert fields

CORL-856

* Use EXPERT user tags to denote expert users

Removes the need for viewerIsExpert and
authorIsExpert loader/resolvers on Stories
and Comments respectively.

CORL-856

* Show an unanswered comment stream when Q&A is enabled

CORL-879

* Create preliminary schema changes for Q&A

Adds a mode and expert User onto the StorySettings.
Adds a mode selection drop down on a story's
Configure tab.

CORL-863

* Do not visually show the unanswered tag

CORL-879

* Allow multiple experts, remove form elements from search

Makes the previous expert user on a Q&A story now
an array of users who can be assigned.

Converts the previous form based search that was
pulled from the admin community area into a set
of events built on callbacks.

CORL-863

* Create addExpertToStory mutation

CORL-863

* Create removeExpertFromStory mutation

CORL-863

* Create a dropdown search control for Q&A experts

CORL-863

* Fixing up tests to match new QA stream options

Adds a few localization fixes to make sure tests
pass.
Updates existing snapshots.

CORL-863

* Add load more button to expert search list

CORL-863

* Update experts query to match react upgrades

CORL-863

* Move the Q&A config to its own section under stream config

Create enable and disable Q&A mutations/button toggle.

CORL-863

* Fix alignment and layout of expert list items

CORL-863

* Define translations and update tests

CORL-863

* Show expert badges on comments when Q&A is enabled

CORL-856

* Use official copy for Q&A config

CORL-863

* Update mutation responses and tests due to added expert fields

CORL-856

* Use EXPERT user tags to denote expert users

Removes the need for viewerIsExpert and
authorIsExpert loader/resolvers on Stories
and Comments respectively.

CORL-856

* Create the answered stream for Q&A

CORL-859

* Sort the Q&A on Most Voted by default

CORL-859

* Fix type mismatch between post comment form and fragments

CORL-859

* Adding localizations for Q&A tags

* Hide feature flags in Q&A streams

* Allow experts to clear answered questions

Can click a button that shows up in the unanswered
tab under answered questions to refresh the stream,
clearing the answered questions from that tab.

* Show arrow upvote icon when in Q&A mode

Also localized the upvote text so it can
be translated.

* Hide mod/report buttons on answered questions

* Remove unnecessary fragment container

* Remove errant debug console log

* Make story mode required on story settings

* Make remove button outlined, not filled

* Further schema changes around Q&A and experts

Rename add/remove story expert to removeStoryExpert
and addStoryExpert naming.

Replace enableQA and disableQA mutators with single
updateStoryMode mutator.

* Remove story mode from UpdateStorySettings

* Replace inline string val's with enum

* add dependencies to useEffect

* docs cleanup around tags.type index check

* Approve a question when it is answered in Q&A

Approves using the author's id as the moderator

* Add comment around use of TAG on comments edge

* Use tagFilter instead of $elemMatch to filter by tags

* Improve responsive styles for expert list items

* Update copy to "Done" from "Remove answered questions"

* Text styling for no users found text

Styles the expert search list to have proper
text styling when no users are found for the
search keyword.

* Remove duplicate checks around story experts

Not necessary as Mongo does this for us.

* Fix a missed "sort imports" during rebase

* Refactor Q&A moderation phases for clarity

Simplify logic and update comments.
Rename: "answered" -> "tagExpertAnswers"
Rename: "unanswered" -> "tagUnansweredQuestions"

* Remove username & email from add expert mutation

* Format expert list emails with Localized

* Break out no comments logic into fragment

* Remove ref handling from expert search field

Use value assignment on TextField instead.

* Replace Box with Flex and CSS

* Show Q&A tooltip on Answered tab

Co-authored-by: Kim Gardner <kgardnr@gmail.com>
This commit is contained in:
Nick Funk
2020-02-25 18:10:26 -07:00
committed by GitHub
parent 184f145c4a
commit 1a3401710f
100 changed files with 3761 additions and 114 deletions
+1 -1
View File
@@ -54,5 +54,5 @@
"search.exclude": {
"package-lock.json": true
},
"debug.node.autoAttach": "off"
"debug.node.autoAttach": "on"
}
+11 -3
View File
@@ -1,6 +1,7 @@
import { Localized } from "@fluent/react/compat";
import React, { FunctionComponent } from "react";
import { GQLSTORY_MODE } from "coral-framework/schema";
import CLASSES from "coral-stream/classes";
import { Icon, MatchMedia, Tab, TabBar } from "coral-ui/components";
@@ -11,6 +12,7 @@ export interface Props {
onTabClick: (tab: TabValue) => void;
showProfileTab: boolean;
showConfigureTab: boolean;
mode: "%future added value" | "COMMENTS" | "QA" | null;
}
const AppTabBar: FunctionComponent<Props> = props => {
@@ -21,9 +23,15 @@ const AppTabBar: FunctionComponent<Props> = props => {
onTabClick={props.onTabClick}
>
<Tab className={CLASSES.tabBar.comments} tabID="COMMENTS">
<Localized id="general-tabBar-commentsTab">
<span>Comments</span>
</Localized>
{props.mode === GQLSTORY_MODE.QA ? (
<Localized id="general-tabBar-qaTab">
<span>Q&A</span>
</Localized>
) : (
<Localized id="general-tabBar-commentsTab">
<span>Comments</span>
</Localized>
)}
</Tab>
{props.showProfileTab && (
<Tab className={CLASSES.tabBar.myProfile} tabID="PROFILE">
@@ -5,8 +5,10 @@ import {
withFragmentContainer,
withLocalStateContainer,
} from "coral-framework/lib/relay";
import { GQLSTORY_MODE } from "coral-framework/schema";
import { Ability, can } from "coral-stream/permissions";
import { TabBarContainer_story } from "coral-stream/__generated__/TabBarContainer_story.graphql";
import { TabBarContainer_viewer as ViewerData } from "coral-stream/__generated__/TabBarContainer_viewer.graphql";
import { TabBarContainerLocal as Local } from "coral-stream/__generated__/TabBarContainerLocal.graphql";
@@ -18,6 +20,7 @@ import {
import TabBar from "./TabBar";
interface Props {
story: TabBarContainer_story | null;
viewer: ViewerData | null;
local: Local;
setActiveTab: SetActiveTabMutation;
@@ -36,6 +39,11 @@ export class TabBarContainer extends Component<Props> {
return (
<TabBar
mode={
this.props.story
? this.props.story.settings.mode
: GQLSTORY_MODE.COMMENTS
}
activeTab={activeTab}
showProfileTab={Boolean(viewer)}
showConfigureTab={
@@ -61,6 +69,13 @@ const enhanced = withSetActiveTabMutation(
role
}
`,
story: graphql`
fragment TabBarContainer_story on Story {
settings {
mode
}
}
`,
})(TabBarContainer)
)
);
+10 -2
View File
@@ -21,10 +21,13 @@ class TabBarQuery extends Component<Props> {
return (
<QueryRenderer<QueryTypes>
query={graphql`
query TabBarQuery {
query TabBarQuery($storyID: ID, $storyURL: String) {
viewer {
...TabBarContainer_viewer
}
story(id: $storyID, url: $storyURL) {
...TabBarContainer_story
}
}
`}
variables={{
@@ -36,7 +39,12 @@ class TabBarQuery extends Component<Props> {
return <div>{error.message}</div>;
}
return <TabBarContainer viewer={(props && props.viewer) || null} />;
return (
<TabBarContainer
story={(props && props.story) || null}
viewer={(props && props.viewer) || null}
/>
);
}}
/>
);
@@ -23,6 +23,7 @@ enum COMMENTS_TAB {
NONE
FEATURED_COMMENTS
ALL_COMMENTS
UNANSWERED_COMMENTS
}
enum COMMENT_VIEWER_ACTION {
@@ -0,0 +1,19 @@
.tagIcon {
margin-right: var(--v2-spacing-1);
}
.answeredTag {
color: #03AB61;
background: #E6FAF1;
border-color: #03AB61;
border-style: solid;
border-radius: 2px;
border-width: 1px;
text-transform: uppercase;
}
.removeAnswered {
width: 100%;
}
@@ -22,6 +22,9 @@ function createDefaultProps(add: DeepPartial<Props> = {}): Props {
story: {
url: "http://localhost/story",
isClosed: false,
settings: {
mode: "COMMENTS",
},
},
comment: {
id: "comment-id",
@@ -53,6 +56,7 @@ function createDefaultProps(add: DeepPartial<Props> = {}): Props {
setCommentID: noop as any,
localReply: false,
disableReplies: false,
onRemoveAnswered: undefined,
},
add
);
@@ -8,7 +8,7 @@ import { isBeforeDate } from "coral-common/utils";
import { getURLWithCommentID } from "coral-framework/helpers";
import { withContext } from "coral-framework/lib/bootstrap";
import withFragmentContainer from "coral-framework/lib/relay/withFragmentContainer";
import { GQLTAG, GQLUSER_STATUS } from "coral-framework/schema";
import { GQLSTORY_MODE, GQLTAG, GQLUSER_STATUS } from "coral-framework/schema";
import { PropTypesOf } from "coral-framework/types";
import CLASSES from "coral-stream/classes";
import {
@@ -23,7 +23,7 @@ import {
withShowAuthPopupMutation,
} from "coral-stream/mutations";
import { Ability, can } from "coral-stream/permissions";
import { Button, Flex, HorizontalGutter, Tag } from "coral-ui/components";
import { Button, Flex, HorizontalGutter, Icon, Tag } from "coral-ui/components";
import { CommentContainer_comment as CommentData } from "coral-stream/__generated__/CommentContainer_comment.graphql";
import { CommentContainer_settings as SettingsData } from "coral-stream/__generated__/CommentContainer_settings.graphql";
@@ -47,6 +47,8 @@ import ShowConversationLink from "./ShowConversationLink";
import { UsernameWithPopoverContainer } from "./Username";
import UserTagsContainer from "./UserTagsContainer";
import styles from "./CommentContainer.css";
interface Props {
viewer: ViewerData | null;
comment: CommentData;
@@ -67,6 +69,11 @@ interface Props {
showConversationLink?: boolean;
highlight?: boolean;
className?: string;
hideAnsweredTag?: boolean;
hideReportButton?: boolean;
hideModerationCarat?: boolean;
onRemoveAnswered?: () => void;
}
interface State {
@@ -192,14 +199,40 @@ export class CommentContainer extends Component<Props, State> {
highlight,
viewer,
className,
hideAnsweredTag,
} = this.props;
const { showReplyDialog, showEditDialog, editable } = this.state;
const hasFeaturedTag = Boolean(
comment.tags.find(t => t.code === GQLTAG.FEATURED)
);
// We are in a Q&A if the story mode is set to QA.
const isQA = Boolean(story.settings.mode === GQLSTORY_MODE.QA);
// Author is expert if comment is tagged Expert and the
// story mode is Q&A.
const authorIsExpert =
isQA && comment.tags.find(t => t.code === GQLTAG.EXPERT);
// Only show a button to clear removed answers if
// this comment is by an expert, reply to a top level
// comment (question) with an answer
const showRemoveAnswered = Boolean(
!comment.deleted &&
isQA &&
authorIsExpert &&
indentLevel === 1 &&
this.props.onRemoveAnswered
);
// When we're in Q&A and we are not un-answered (answered)
// and we're a top level comment (no parent), then we
// are an answered question
const hasAnsweredTag = Boolean(
!hideAnsweredTag &&
isQA &&
comment.tags.every(t => t.code !== GQLTAG.UNANSWERED) &&
!comment.parent
);
const commentTags = (
<>
{hasFeaturedTag && (
{hasFeaturedTag && !isQA && (
<Tag
className={CLASSES.comment.topBar.commentTag}
color="primary"
@@ -210,6 +243,16 @@ export class CommentContainer extends Component<Props, State> {
</Localized>
</Tag>
)}
{hasAnsweredTag && isQA && (
<Tag variant="regular" color="primary" className={styles.answeredTag}>
<Flex alignItems="center">
<Icon size="xs" className={styles.tagIcon}>
check
</Icon>
<Localized id="qa-answered-tag">answered</Localized>
</Flex>
</Tag>
)}
</>
);
const banned = Boolean(
@@ -224,7 +267,9 @@ export class CommentContainer extends Component<Props, State> {
this.props.viewer && this.props.viewer.scheduledDeletionDate
);
const showCaret =
this.props.viewer && can(this.props.viewer, Ability.MODERATE);
this.props.viewer &&
can(this.props.viewer, Ability.MODERATE) &&
!this.props.hideModerationCarat;
if (showEditDialog) {
return (
<div data-testid={`comment-${comment.id}`}>
@@ -277,6 +322,7 @@ export class CommentContainer extends Component<Props, State> {
/>
<UserTagsContainer
className={CLASSES.comment.topBar.userTag}
story={story}
comment={comment}
settings={settings}
/>
@@ -327,6 +373,7 @@ export class CommentContainer extends Component<Props, State> {
reactedClassName={
CLASSES.comment.actionBar.reactedButton
}
isQA={story.settings.mode === GQLSTORY_MODE.QA}
/>
{!disableReplies &&
!banned &&
@@ -350,16 +397,18 @@ export class CommentContainer extends Component<Props, State> {
/>
</ButtonsBar>
<ButtonsBar>
{!banned && !suspended && (
<ReportButtonContainer
comment={comment}
viewer={viewer}
className={CLASSES.comment.actionBar.reportButton}
reportedClassName={
CLASSES.comment.actionBar.reportedButton
}
/>
)}
{!banned &&
!suspended &&
!this.props.hideReportButton && (
<ReportButtonContainer
comment={comment}
viewer={viewer}
className={CLASSES.comment.actionBar.reportButton}
reportedClassName={
CLASSES.comment.actionBar.reportedButton
}
/>
)}
</ButtonsBar>
</Flex>
{showConversationLink && (
@@ -386,6 +435,18 @@ export class CommentContainer extends Component<Props, State> {
localReply={localReply}
/>
)}
{showRemoveAnswered && (
<Localized id="qa-unansweredTab-doneAnswering">
<Button
variant="filled"
color="regular"
className={styles.removeAnswered}
onClick={this.props.onRemoveAnswered}
>
Done
</Button>
</Localized>
)}
</HorizontalGutter>
</div>
);
@@ -418,10 +479,14 @@ const enhanced = withContext(({ eventEmitter }) => ({ eventEmitter }))(
fragment CommentContainer_story on Story {
url
isClosed
settings {
mode
}
...CaretContainer_story
...ReplyCommentFormContainer_story
...PermalinkButtonContainer_story
...EditCommentFormContainer_story
...UserTagsContainer_story
}
`,
comment: graphql`
@@ -1,3 +1,4 @@
import { Localized } from "@fluent/react/compat";
import cn from "classnames";
import React from "react";
@@ -17,6 +18,7 @@ interface ReactionButtonProps {
readOnly?: boolean;
className?: string;
color?: typeof styles & ButtonProps["color"];
isQA?: boolean;
}
class ReactionButton extends React.Component<ReactionButtonProps> {
@@ -36,15 +38,29 @@ class ReactionButton extends React.Component<ReactionButtonProps> {
)}
>
<MatchMedia gtWidth="xs">
<Icon>
{reacted
? this.props.iconActive
{this.props.isQA ? (
<Icon>arrow_upward</Icon>
) : (
<Icon>
{reacted
? this.props.iconActive
: this.props.icon
: this.props.icon}
</Icon>
? this.props.iconActive
: this.props.icon
: this.props.icon}
</Icon>
)}
</MatchMedia>
<span>{reacted ? this.props.labelActive : this.props.label}</span>
{this.props.isQA ? (
<span>
{reacted ? (
<Localized id="qa-reaction-voted">Voted</Localized>
) : (
<Localized id="qa-reaction-vote">Vote</Localized>
)}
</span>
) : (
<span>{reacted ? this.props.labelActive : this.props.label}</span>
)}
{!!totalReactions && <span>{totalReactions}</span>}
</Button>
);
@@ -32,6 +32,7 @@ interface Props {
readOnly?: boolean;
className?: string;
reactedClassName?: string;
isQA?: boolean;
}
class ReactionButtonContainer extends React.Component<Props> {
@@ -85,6 +86,7 @@ class ReactionButtonContainer extends React.Component<Props> {
icon={icon}
iconActive={iconActive}
readOnly={readOnly}
isQA={this.props.isQA}
/>
) : null;
}
@@ -39,7 +39,8 @@ function sharedUpdater(
const commentEdge = store
.getRootField("createCommentReply")!
.getLinkedRecord("edge")!;
const status = commentEdge.getLinkedRecord("node")!.getValue("status");
const node = commentEdge.getLinkedRecord("node")!;
const status = node.getValue("status");
// If comment is not published, we don't need to add it.
if (!isPublished(status)) {
@@ -134,6 +135,12 @@ const mutation = graphql`
node {
...AllCommentsTabContainer_comment @relay(mask: false)
status
parent {
id
tags {
code
}
}
}
}
clientMutationId
@@ -207,6 +214,7 @@ async function commit(
author: parentComment.author
? pick(parentComment.author, "username", "id")
: null,
tags: [],
},
editing: {
editableUntil: new Date(Date.now() + 10000).toISOString(),
@@ -0,0 +1,7 @@
.icon {
margin-right: var(--v2-spacing-1);
}
.tag {
margin-right: var(--v2-spacing-1);
}
@@ -1,26 +1,50 @@
import { Localized } from "@fluent/react/compat";
import cn from "classnames";
import React, { FunctionComponent } from "react";
import { graphql } from "react-relay";
import withFragmentContainer from "coral-framework/lib/relay/withFragmentContainer";
import { Tag } from "coral-ui/components";
import { GQLSTORY_MODE } from "coral-framework/schema";
import { Flex, Icon, Tag } from "coral-ui/components";
import { UserTagsContainer_comment } from "coral-stream/__generated__/UserTagsContainer_comment.graphql";
import { UserTagsContainer_settings } from "coral-stream/__generated__/UserTagsContainer_settings.graphql";
import { UserTagsContainer_story } from "coral-stream/__generated__/UserTagsContainer_story.graphql";
import styles from "./UserTagsContainer.css";
interface Props {
story: UserTagsContainer_story;
comment: UserTagsContainer_comment;
settings: UserTagsContainer_settings;
className?: string;
}
const UserTagsContainer: FunctionComponent<Props> = ({
story,
settings,
comment,
className,
}) => {
const isQA = story.settings.mode === GQLSTORY_MODE.QA;
const staffTag = comment.tags.find(t => t.code === "STAFF");
const expertTag = isQA && comment.tags.find(t => t.code === "EXPERT");
return (
<>{staffTag && <Tag className={className}>{settings.staff.label}</Tag>}</>
<Flex alignItems="center">
{expertTag && (
<Tag variant="regular" color="primary" className={styles.tag}>
<Flex alignItems="center">
<Icon size="xs" className={styles.icon}>
star
</Icon>
<Localized id="qa-expert-tag">expert</Localized>
</Flex>
</Tag>
)}
{staffTag && (
<Tag className={cn(className, styles.tag)}>{settings.staff.label}</Tag>
)}
</Flex>
);
};
@@ -32,6 +56,13 @@ const enhanced = withFragmentContainer<Props>({
}
}
`,
story: graphql`
fragment UserTagsContainer_story on Story {
settings {
mode
}
}
`,
settings: graphql`
fragment UserTagsContainer_settings on Settings {
staff {
@@ -41,6 +41,7 @@ exports[`hide reply button 1`] = `
"tags": Array [],
}
}
isQA={false}
reactedClassName="coral-reactedButton coral-comment-reactedButton"
readOnly={false}
settings={
@@ -58,6 +59,9 @@ exports[`hide reply button 1`] = `
story={
Object {
"isClosed": false,
"settings": Object {
"mode": "COMMENTS",
},
"url": "http://localhost/story",
}
}
@@ -165,6 +169,15 @@ exports[`hide reply button 1`] = `
},
}
}
story={
Object {
"isClosed": false,
"settings": Object {
"mode": "COMMENTS",
},
"url": "http://localhost/story",
}
}
/>
<Relay(AuthorBadgesContainer)
className="coral coral-userBadge coral-comment-userBadge"
@@ -239,6 +252,7 @@ exports[`renders body only 1`] = `
"tags": Array [],
}
}
isQA={false}
reactedClassName="coral-reactedButton coral-comment-reactedButton"
readOnly={false}
settings={
@@ -263,6 +277,9 @@ exports[`renders body only 1`] = `
story={
Object {
"isClosed": false,
"settings": Object {
"mode": "COMMENTS",
},
"url": "http://localhost/story",
}
}
@@ -370,6 +387,15 @@ exports[`renders body only 1`] = `
},
}
}
story={
Object {
"isClosed": false,
"settings": Object {
"mode": "COMMENTS",
},
"url": "http://localhost/story",
}
}
/>
<Relay(AuthorBadgesContainer)
className="coral coral-userBadge coral-comment-userBadge"
@@ -444,6 +470,7 @@ exports[`renders disabled reply when commenting has been disabled 1`] = `
"tags": Array [],
}
}
isQA={false}
reactedClassName="coral-reactedButton coral-comment-reactedButton"
readOnly={false}
settings={
@@ -468,6 +495,9 @@ exports[`renders disabled reply when commenting has been disabled 1`] = `
story={
Object {
"isClosed": false,
"settings": Object {
"mode": "COMMENTS",
},
"url": "http://localhost/story",
}
}
@@ -575,6 +605,15 @@ exports[`renders disabled reply when commenting has been disabled 1`] = `
},
}
}
story={
Object {
"isClosed": false,
"settings": Object {
"mode": "COMMENTS",
},
"url": "http://localhost/story",
}
}
/>
<Relay(AuthorBadgesContainer)
className="coral coral-userBadge coral-comment-userBadge"
@@ -649,6 +688,7 @@ exports[`renders disabled reply when story is closed 1`] = `
"tags": Array [],
}
}
isQA={false}
reactedClassName="coral-reactedButton coral-comment-reactedButton"
readOnly={false}
settings={
@@ -673,6 +713,9 @@ exports[`renders disabled reply when story is closed 1`] = `
story={
Object {
"isClosed": true,
"settings": Object {
"mode": "COMMENTS",
},
"url": "http://localhost/story",
}
}
@@ -780,6 +823,15 @@ exports[`renders disabled reply when story is closed 1`] = `
},
}
}
story={
Object {
"isClosed": true,
"settings": Object {
"mode": "COMMENTS",
},
"url": "http://localhost/story",
}
}
/>
<Relay(AuthorBadgesContainer)
className="coral coral-userBadge coral-comment-userBadge"
@@ -858,6 +910,7 @@ exports[`renders in reply to 1`] = `
"tags": Array [],
}
}
isQA={false}
reactedClassName="coral-reactedButton coral-comment-reactedButton"
readOnly={false}
settings={
@@ -882,6 +935,9 @@ exports[`renders in reply to 1`] = `
story={
Object {
"isClosed": false,
"settings": Object {
"mode": "COMMENTS",
},
"url": "http://localhost/story",
}
}
@@ -1001,6 +1057,15 @@ exports[`renders in reply to 1`] = `
},
}
}
story={
Object {
"isClosed": false,
"settings": Object {
"mode": "COMMENTS",
},
"url": "http://localhost/story",
}
}
/>
<Relay(AuthorBadgesContainer)
className="coral coral-userBadge coral-comment-userBadge"
@@ -1079,6 +1144,7 @@ exports[`renders username and body 1`] = `
"tags": Array [],
}
}
isQA={false}
reactedClassName="coral-reactedButton coral-comment-reactedButton"
readOnly={false}
settings={
@@ -1103,6 +1169,9 @@ exports[`renders username and body 1`] = `
story={
Object {
"isClosed": false,
"settings": Object {
"mode": "COMMENTS",
},
"url": "http://localhost/story",
}
}
@@ -1210,6 +1279,15 @@ exports[`renders username and body 1`] = `
},
}
}
story={
Object {
"isClosed": false,
"settings": Object {
"mode": "COMMENTS",
},
"url": "http://localhost/story",
}
}
/>
<Relay(AuthorBadgesContainer)
className="coral coral-userBadge coral-comment-userBadge"
@@ -1293,6 +1371,7 @@ exports[`shows conversation link 1`] = `
"tags": Array [],
}
}
isQA={false}
reactedClassName="coral-reactedButton coral-comment-reactedButton"
readOnly={false}
settings={
@@ -1317,6 +1396,9 @@ exports[`shows conversation link 1`] = `
story={
Object {
"isClosed": false,
"settings": Object {
"mode": "COMMENTS",
},
"url": "http://localhost/story",
}
}
@@ -1430,6 +1512,15 @@ exports[`shows conversation link 1`] = `
},
}
}
story={
Object {
"isClosed": false,
"settings": Object {
"mode": "COMMENTS",
},
"url": "http://localhost/story",
}
}
/>
<Relay(AuthorBadgesContainer)
className="coral coral-userBadge coral-comment-userBadge"
@@ -99,6 +99,7 @@ const ConversationThreadContainer: FunctionComponent<Props> = ({
tags={
<UserTagsContainer
className={CLASSES.conversationThread.rootParent.userTag}
story={story}
comment={rootParent}
settings={settings}
/>
@@ -186,6 +187,7 @@ const enhanced = withContext(ctx => ({
fragment ConversationThreadContainer_story on Story {
...CommentContainer_story
...LocalReplyListContainer_story
...UserTagsContainer_story
}
`,
settings: graphql`
@@ -35,6 +35,7 @@ export interface ReplyListProps {
disableReplies?: boolean;
viewNewCount?: number;
onViewNew?: () => void;
onRemoveAnswered?: () => void;
}
const ReplyList: FunctionComponent<ReplyListProps> = props => {
@@ -64,6 +65,7 @@ const ReplyList: FunctionComponent<ReplyListProps> = props => {
localReply={props.localReply}
disableReplies={props.disableReplies}
showConversationLink={!!comment.showConversationLink}
onRemoveAnswered={props.onRemoveAnswered}
/>
{comment.replyListElement}
</HorizontalGutter>
@@ -50,6 +50,7 @@ type Props = BaseProps & {
* instead of hiding behind a button.
*/
liveDirectRepliesInsertion?: boolean;
onRemoveAnswered?: () => void;
};
// TODO: (cvle) If this could be autogenerated.
@@ -151,6 +152,7 @@ export const ReplyListContainer: React.FunctionComponent<Props> = props => {
localReply={props.localReply}
viewNewCount={viewNewCount}
onViewNew={onViewNew}
onRemoveAnswered={props.onRemoveAnswered}
/>
);
};
@@ -11,11 +11,11 @@ import {
useSubscription,
withPaginationContainer,
} from "coral-framework/lib/relay";
import { GQLCOMMENT_SORT } from "coral-framework/schema";
import { GQLCOMMENT_SORT, GQLSTORY_MODE } from "coral-framework/schema";
import { Omit, PropTypesOf } from "coral-framework/types";
import CLASSES from "coral-stream/classes";
import { LoadMoreAllCommentsEvent } from "coral-stream/events";
import { Box, Button, CallOut, HorizontalGutter } from "coral-ui/components";
import { Box, Button, HorizontalGutter } from "coral-ui/components";
import { AllCommentsTabContainer_settings } from "coral-stream/__generated__/AllCommentsTabContainer_settings.graphql";
import { AllCommentsTabContainer_story } from "coral-stream/__generated__/AllCommentsTabContainer_story.graphql";
@@ -29,6 +29,7 @@ import { ReplyListContainer } from "../../ReplyList";
import AllCommentsTabViewNewMutation from "./AllCommentsTabViewNewMutation";
import CommentCreatedSubscription from "./CommentCreatedSubscription";
import CommentReleasedSubscription from "./CommentReleasedSubscription";
import NoComments from "./NoComments";
import styles from "./AllCommentsTabContainer.css";
@@ -139,9 +140,15 @@ export const AllCommentsTabContainer: FunctionComponent<Props> = props => {
className={CLASSES.allCommentsTabPane.viewNewButton}
fullWidth
>
<Localized id="comments-viewNew" $count={viewNewCount}>
<span>View {viewNewCount} New Comments</span>
</Localized>
{props.story.settings.mode === GQLSTORY_MODE.QA ? (
<Localized id="qa-viewNew" $count={viewNewCount}>
<span>View {viewNewCount} New Questions</span>
</Localized>
) : (
<Localized id="comments-viewNew" $count={viewNewCount}>
<span>View {viewNewCount} New Comments</span>
</Localized>
)}
</Button>
</Box>
)}
@@ -153,17 +160,11 @@ export const AllCommentsTabContainer: FunctionComponent<Props> = props => {
size="oneAndAHalf"
className={styles.stream}
>
{comments.length <= 0 && props.story.isClosed && (
<Localized id="comments-noCommentsAtAll">
<CallOut fullWidth>There are no comments on this story.</CallOut>
</Localized>
)}
{comments.length <= 0 && !props.story.isClosed && (
<Localized id="comments-noCommentsYet">
<CallOut fullWidth>
There are no comments yet. Why don't you write one?
</CallOut>
</Localized>
{comments.length <= 0 && (
<NoComments
mode={props.story.settings.mode}
isClosed={props.story.isClosed}
></NoComments>
)}
{comments.length > 0 &&
!props.story.isClosed &&
@@ -235,6 +236,7 @@ const enhanced = withPaginationContainer<
live {
enabled
}
mode
}
comments(first: $count, after: $cursor, orderBy: $orderBy)
@connection(key: "Stream_comments") {
@@ -0,0 +1,50 @@
import { Localized } from "@fluent/react/compat";
import React, { FunctionComponent } from "react";
import { GQLSTORY_MODE } from "coral-framework/schema";
import { CallOut } from "coral-ui/components";
interface Props {
mode: "COMMENTS" | "QA" | "%future added value";
isClosed: boolean;
}
const NoComments: FunctionComponent<Props> = ({ mode, isClosed }) => {
if (mode === GQLSTORY_MODE.COMMENTS) {
if (isClosed) {
return (
<Localized id="comments-noCommentsAtAll">
<CallOut fullWidth>There are no comments on this story.</CallOut>
</Localized>
);
} else {
return (
<Localized id="comments-noCommentsYet">
<CallOut fullWidth>
There are no comments yet. Why don't you write one?
</CallOut>
</Localized>
);
}
} else if (mode === GQLSTORY_MODE.QA) {
if (isClosed) {
return (
<Localized id="qa-noQuestionsAtAll">
<CallOut fullWidth>There are no questions on this story.</CallOut>
</Localized>
);
} else {
return (
<Localized id="qa-noQuestionsYet">
<CallOut fullWidth>
There are no questions yet. Why don't you ask one?
</CallOut>
</Localized>
);
}
}
return null;
};
export default NoComments;
@@ -0,0 +1,45 @@
.root {
color: var(--palette-text-primary);
padding-left: var(--spacing-4);
box-sizing: border-box;
border-left-width: 2px;
border-left-style: solid;
border-left-color: #3498DB;
margin-left: var(--spacing-2);
}
.replies,
.gotoConversation {
font-size: calc(14rem / var(--rem-base));
font-weight: var(--font-weight-medium);
font-family: var(--font-family-sans-serif);
line-height: 0;
}
.gotoArrow {
display: inline-block;
text-decoration: none;
margin-left: var(--spacing-1);
}
.username {
margin-right: var(--spacing-1);
}
.timestamp {
margin-left: var(--spacing-2);
}
.replies {
color: var(--palette-grey-main);
}
.repliesText {
margin: var(--spacing-2);
}
.repliesDivider {
margin: var(--spacing-2);
}
@@ -0,0 +1,221 @@
import { Localized } from "@fluent/react/compat";
import cn from "classnames";
import React, { FunctionComponent, MouseEvent, useCallback } from "react";
import { graphql } from "react-relay";
import { getURLWithCommentID } from "coral-framework/helpers";
import { useViewerEvent } from "coral-framework/lib/events";
import withFragmentContainer from "coral-framework/lib/relay/withFragmentContainer";
import { GQLUSER_STATUS } from "coral-framework/schema";
import CLASSES from "coral-stream/classes";
import HTMLContent from "coral-stream/common/HTMLContent";
import Timestamp from "coral-stream/common/Timestamp";
import { ViewConversationEvent } from "coral-stream/events";
import {
SetCommentIDMutation,
withSetCommentIDMutation,
} from "coral-stream/mutations";
import { Flex, Icon, TextLink } from "coral-ui/components";
import { AnsweredCommentContainer_comment as CommentData } from "coral-stream/__generated__/AnsweredCommentContainer_comment.graphql";
import { AnsweredCommentContainer_settings as SettingsData } from "coral-stream/__generated__/AnsweredCommentContainer_settings.graphql";
import { AnsweredCommentContainer_story as StoryData } from "coral-stream/__generated__/AnsweredCommentContainer_story.graphql";
import { AnsweredCommentContainer_viewer as ViewerData } from "coral-stream/__generated__/AnsweredCommentContainer_viewer.graphql";
import { CommentContainer, UserTagsContainer } from "../../Comment";
import ReactionButtonContainer from "../../Comment/ReactionButton";
import { UsernameWithPopoverContainer } from "../../Comment/Username";
import styles from "./AnsweredCommentContainer.css";
interface Props {
viewer: ViewerData | null;
comment: CommentData;
story: StoryData;
settings: SettingsData;
setCommentID: SetCommentIDMutation;
}
const AnsweredCommentContainer: FunctionComponent<Props> = props => {
const { comment, settings, story, viewer, setCommentID } = props;
const banned = Boolean(
viewer && viewer.status.current.includes(GQLUSER_STATUS.BANNED)
);
const emitViewConversationEvent = useViewerEvent(ViewConversationEvent);
const onGotoConversation = useCallback(
(e: MouseEvent) => {
e.preventDefault();
emitViewConversationEvent({
from: "FEATURED_COMMENTS",
commentID: comment.id,
});
setCommentID({ id: comment.id });
return false;
},
[setCommentID, comment]
);
return (
<>
{comment.parent && (
<CommentContainer
viewer={props.viewer}
settings={props.settings}
comment={comment.parent}
story={props.story}
hideAnsweredTag
hideReportButton
hideModerationCarat
disableReplies
highlight
/>
)}
<div
className={cn(CLASSES.featuredComment.$root, styles.root)}
data-testid={`featuredComment-${comment.id}`}
>
<Flex
direction="row"
alignItems="center"
mt={4}
className={CLASSES.featuredComment.authorBar.$root}
>
{comment.author && (
<UsernameWithPopoverContainer
className={cn(
CLASSES.featuredComment.authorBar.username,
styles.username
)}
comment={comment}
viewer={viewer}
/>
)}
<Flex alignItems="flex-start" justifyContent="center">
<UserTagsContainer
className={CLASSES.featuredComment.authorBar.userTag}
story={story}
comment={comment}
settings={settings}
/>
<Timestamp
className={cn(
CLASSES.featuredComment.authorBar.timestamp,
styles.timestamp
)}
>
{comment.createdAt}
</Timestamp>
</Flex>
</Flex>
<HTMLContent className={CLASSES.featuredComment.content}>
{comment.body || ""}
</HTMLContent>
<Flex
justifyContent="space-between"
mt={2}
className={CLASSES.featuredComment.actionBar.$root}
>
<ReactionButtonContainer
comment={comment}
settings={settings}
viewer={viewer}
readOnly={banned}
className={CLASSES.featuredComment.actionBar.reactButton}
reactedClassName={CLASSES.featuredComment.actionBar.reactedButton}
isQA
/>
<Flex alignItems="center">
{comment.replyCount > 0 && (
<Flex alignItems="center" className={styles.replies}>
<Flex
alignItems="center"
className={CLASSES.featuredComment.actionBar.replies}
>
<Icon size="md">reply</Icon>
<Localized id="comments-featured-replies">
<span className={styles.repliesText}>Replies</span>
</Localized>
<span>{comment.replyCount}</span>
</Flex>
<span className={styles.repliesDivider}>|</span>
</Flex>
)}
<div>
<TextLink
className={cn(
CLASSES.featuredComment.actionBar.goToConversation,
styles.gotoConversation
)}
onClick={onGotoConversation}
href={getURLWithCommentID(story.url, comment.id)}
>
<Localized id="comments-featured-gotoConversation">
<span>Go to Conversation</span>
</Localized>
<span className={styles.gotoArrow}>&gt;</span>
</TextLink>
</div>
</Flex>
</Flex>
</div>
</>
);
};
const enhanced = withSetCommentIDMutation(
withFragmentContainer<Props>({
viewer: graphql`
fragment AnsweredCommentContainer_viewer on User {
id
status {
current
}
ignoredUsers {
id
}
role
...UsernameWithPopoverContainer_viewer
...ReactionButtonContainer_viewer
...CommentContainer_viewer
}
`,
story: graphql`
fragment AnsweredCommentContainer_story on Story {
url
...UserTagsContainer_story
...CommentContainer_story
}
`,
comment: graphql`
fragment AnsweredCommentContainer_comment on Comment {
id
author {
id
username
}
parent {
author {
username
}
...CommentContainer_comment
}
body
createdAt
lastViewerAction
replyCount
...UsernameWithPopoverContainer_comment
...ReactionButtonContainer_comment
...UserTagsContainer_comment
}
`,
settings: graphql`
fragment AnsweredCommentContainer_settings on Settings {
...ReactionButtonContainer_settings
...UserTagsContainer_settings
...CommentContainer_settings
}
`,
})(AnsweredCommentContainer)
);
export default enhanced;
@@ -0,0 +1,180 @@
import { Localized } from "@fluent/react/compat";
import React, { FunctionComponent, useCallback } from "react";
import { graphql, RelayPaginationProp } from "react-relay";
import { useViewerNetworkEvent } from "coral-framework/lib/events";
import {
useLoadMore,
withPaginationContainer,
} from "coral-framework/lib/relay";
import { Omit, PropTypesOf } from "coral-framework/types";
import CLASSES from "coral-stream/classes";
import { LoadMoreFeaturedCommentsEvent } from "coral-stream/events";
import { Button, HorizontalGutter } from "coral-ui/components";
import { AnsweredCommentsContainer_settings as SettingsData } from "coral-stream/__generated__/AnsweredCommentsContainer_settings.graphql";
import { AnsweredCommentsContainer_story as StoryData } from "coral-stream/__generated__/AnsweredCommentsContainer_story.graphql";
import { AnsweredCommentsContainer_viewer as ViewerData } from "coral-stream/__generated__/AnsweredCommentsContainer_viewer.graphql";
import { AnsweredCommentsContainerPaginationQueryVariables } from "coral-stream/__generated__/AnsweredCommentsContainerPaginationQuery.graphql";
import IgnoredTombstoneOrHideContainer from "../../IgnoredTombstoneOrHideContainer";
import AnsweredCommentContainer from "./AnsweredCommentContainer";
interface Props {
story: StoryData;
settings: SettingsData;
viewer: ViewerData | null;
relay: RelayPaginationProp;
}
export const AnsweredCommentsContainer: FunctionComponent<Props> = props => {
const [loadMore, isLoadingMore] = useLoadMore(props.relay, 10);
const beginLoadMoreEvent = useViewerNetworkEvent(
LoadMoreFeaturedCommentsEvent
);
const loadMoreAndEmit = useCallback(async () => {
const loadMoreEvent = beginLoadMoreEvent({ storyID: props.story.id });
try {
await loadMore();
loadMoreEvent.success();
} catch (error) {
loadMoreEvent.error({ message: error.message, code: error.code });
// eslint-disable-next-line no-console
console.error(error);
}
}, [loadMore, beginLoadMoreEvent, props.story.id]);
const comments = props.story.featuredComments.edges.map(edge => edge.node);
return (
<HorizontalGutter
id="comments-featuredComments-log"
data-testid="comments-featuredComments-log"
role="log"
aria-live="polite"
spacing={3}
>
{comments.map(comment => (
<IgnoredTombstoneOrHideContainer
key={comment.id}
viewer={props.viewer}
comment={comment}
>
<AnsweredCommentContainer
viewer={props.viewer}
settings={props.settings}
comment={comment}
story={props.story}
/>
</IgnoredTombstoneOrHideContainer>
))}
{props.relay.hasMore() && (
<Localized id="comments-loadMore">
<Button
onClick={loadMoreAndEmit}
variant="outlined"
fullWidth
disabled={isLoadingMore}
aria-controls="comments-featuredComments-log"
className={CLASSES.featuredCommentsTabPane.loadMoreButton}
>
Load More
</Button>
</Localized>
)}
</HorizontalGutter>
);
};
// TODO: (cvle) if this could be autogenerated..
type FragmentVariables = Omit<
AnsweredCommentsContainerPaginationQueryVariables,
"storyID"
>;
const enhanced = withPaginationContainer<
Props,
AnsweredCommentsContainerPaginationQueryVariables,
FragmentVariables
>(
{
story: graphql`
fragment AnsweredCommentsContainer_story on Story
@argumentDefinitions(
count: { type: "Int!", defaultValue: 5 }
cursor: { type: "Cursor" }
orderBy: { type: "COMMENT_SORT!", defaultValue: CREATED_AT_DESC }
) {
id
featuredComments(first: $count, after: $cursor, orderBy: $orderBy)
@connection(key: "Stream_featuredComments") {
edges {
node {
id
...AnsweredCommentContainer_comment
...IgnoredTombstoneOrHideContainer_comment
}
}
}
...PostCommentFormContainer_story
...AnsweredCommentContainer_story
}
`,
viewer: graphql`
fragment AnsweredCommentsContainer_viewer on User {
...AnsweredCommentContainer_viewer
...IgnoredTombstoneOrHideContainer_viewer
status {
current
}
}
`,
settings: graphql`
fragment AnsweredCommentsContainer_settings on Settings {
reaction {
sortLabel
}
...AnsweredCommentContainer_settings
}
`,
},
{
direction: "forward",
getConnectionFromProps(props) {
return props.story && props.story.featuredComments;
},
// 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 {
count,
cursor,
orderBy: fragmentVariables.orderBy,
// storyID isn't specified as an @argument for the fragment, but it should be a
// variable available for the fragment under the query root.
storyID: props.story.id,
};
},
query: graphql`
# Pagination query to be fetched upon calling 'loadMore'.
# Notice that we re-use our fragment, and the shape of this query matches our fragment spec.
query AnsweredCommentsContainerPaginationQuery(
$count: Int!
$cursor: Cursor
$orderBy: COMMENT_SORT!
$storyID: ID
) {
story(id: $storyID) {
...AnsweredCommentsContainer_story
@arguments(count: $count, cursor: $cursor, orderBy: $orderBy)
}
}
`,
}
)(AnsweredCommentsContainer);
export type AnsweredCommentsContainerProps = PropTypesOf<typeof enhanced>;
export default enhanced;
@@ -0,0 +1,107 @@
import { Localized } from "@fluent/react/compat";
import React, { FunctionComponent } from "react";
import {
graphql,
QueryRenderData,
QueryRenderer,
withLocalStateContainer,
} from "coral-framework/lib/relay";
import Spinner from "coral-stream/common/Spinner";
import { Delay, Flex } from "coral-ui/components";
import { AnsweredCommentsQuery as QueryTypes } from "coral-stream/__generated__/AnsweredCommentsQuery.graphql";
import { AnsweredCommentsQueryLocal as Local } from "coral-stream/__generated__/AnsweredCommentsQueryLocal.graphql";
import AnsweredCommentsContainer from "./AnsweredCommentsContainer";
interface Props {
local: Local;
preload?: boolean;
}
export const render = (data: QueryRenderData<QueryTypes>) => {
if (data.error) {
return <div>{data.error.message}</div>;
}
if (!data.props) {
return (
<Flex justifyContent="center">
<Spinner />
</Flex>
);
}
if (data.props) {
if (!data.props.story) {
return (
<Localized id="comments-streamQuery-storyNotFound">
<div>Story not found</div>
</Localized>
);
}
return (
<AnsweredCommentsContainer
settings={data.props.settings}
viewer={data.props.viewer}
story={data.props.story}
/>
);
}
return (
<Delay>
<Flex justifyContent="center">
<Spinner />
</Flex>
</Delay>
);
};
const AnsweredCommentsQuery: FunctionComponent<Props> = props => {
const {
local: { storyID, storyURL, commentsOrderBy },
} = props;
return (
<QueryRenderer<QueryTypes>
query={graphql`
query AnsweredCommentsQuery(
$storyID: ID
$storyURL: String
$commentsOrderBy: COMMENT_SORT
) {
viewer {
...AnsweredCommentsContainer_viewer
}
story: stream(id: $storyID, url: $storyURL) {
...AnsweredCommentsContainer_story
@arguments(orderBy: $commentsOrderBy)
}
settings {
...AnsweredCommentsContainer_settings
}
}
`}
variables={{
storyID,
storyURL,
commentsOrderBy,
}}
render={data => (props.preload ? null : render(data))}
/>
);
};
const enhanced = withLocalStateContainer(
graphql`
fragment AnsweredCommentsQueryLocal on Local {
storyID
storyURL
commentsOrderBy
}
`
)(AnsweredCommentsQuery);
export default enhanced;
@@ -0,0 +1,4 @@
export {
default,
default as AnsweredCommentsQuery,
} from "./AnsweredCommentsQuery";
@@ -5,16 +5,32 @@ import { useViewerEvent } from "coral-framework/lib/events";
import { ShowFeaturedCommentTooltipEvent } from "coral-stream/events";
import { Tooltip, TooltipButton } from "coral-ui/components";
interface Props {
interface TooltipProps {
className?: string;
active?: boolean;
isQA?: boolean;
}
const FeaturedCommentTooltipContent: FunctionComponent = props => {
interface ContentProps {
isQA?: boolean;
}
const FeaturedCommentTooltipContent: FunctionComponent<
ContentProps
> = props => {
const emitShowTooltipEvent = useViewerEvent(ShowFeaturedCommentTooltipEvent);
useEffect(() => {
emitShowTooltipEvent();
}, []);
if (props.isQA) {
return (
<Localized id="qa-answeredTooltip-answeredComments">
<span>Questions are answered by a Q&A expert.</span>
</Localized>
);
}
return (
<Localized id="comments-featuredCommentTooltip-handSelectedComments">
<span>Comments are hand selected by our team as worth reading.</span>
@@ -22,7 +38,37 @@ const FeaturedCommentTooltipContent: FunctionComponent = props => {
);
};
export const FeaturedCommentTooltip: FunctionComponent<Props> = props => {
export const FeaturedCommentTooltip: FunctionComponent<
TooltipProps
> = props => {
if (props.isQA) {
return (
<Tooltip
id="qa-AnsweredPopover"
className={props.className}
title={
<Localized id="qa-answeredTooltip-how">
<span>How is a question answered?</span>
</Localized>
}
body={<FeaturedCommentTooltipContent isQA={props.isQA} />}
button={({ toggleVisibility, ref, visible }) => (
<Localized
id="qa-answeredTooltip-toggleButton"
attrs={{ "aria-label": true }}
>
<TooltipButton
active={props.active}
aria-label="Toggle answered questions tooltip"
toggleVisibility={toggleVisibility}
ref={ref}
/>
</Localized>
)}
/>
);
}
return (
<Tooltip
id="comments-featuredCommentPopover"
@@ -79,6 +79,7 @@ const FeaturedCommentContainer: FunctionComponent<Props> = props => {
<Box ml={1} container="span">
<UserTagsContainer
className={CLASSES.featuredComment.authorBar.userTag}
story={story}
comment={comment}
settings={settings}
/>
@@ -158,6 +159,7 @@ const enhanced = withSetCommentIDMutation(
story: graphql`
fragment FeaturedCommentContainer_story on Story {
url
...UserTagsContainer_story
}
`,
comment: graphql`
@@ -45,6 +45,7 @@ const enhanced = withFragmentContainer<Props>({
content
icon
}
mode
}
}
`,
@@ -6,6 +6,7 @@ import { Field, Form, FormSpy } from "react-final-form";
import { useViewerEvent } from "coral-framework/lib/events";
import { FormError, OnSubmit } from "coral-framework/lib/form";
import { GQLSTORY_MODE } from "coral-framework/schema";
import { PropTypesOf } from "coral-framework/types";
import CLASSES from "coral-stream/classes";
import ValidationMessage from "coral-stream/common/ValidationMessage";
@@ -26,6 +27,12 @@ interface FormProps {
interface FormSubmitProps extends FormProps, FormError {}
interface StorySettings {
settings?: {
mode?: "COMMENTS" | "QA" | "%future added value" | null;
};
}
interface Props {
onSubmit: OnSubmit<FormSubmitProps>;
onChange?: (state: FormState<any>, form: FormApi) => void;
@@ -36,7 +43,7 @@ interface Props {
disabledMessage?: React.ReactNode;
submitStatus: PropTypesOf<PostCommentSubmitStatusContainer>["status"];
showMessageBox?: boolean;
story: PropTypesOf<typeof MessageBoxContainer>["story"];
story: PropTypesOf<typeof MessageBoxContainer>["story"] & StorySettings;
}
const PostCommentForm: FunctionComponent<Props> = props => {
@@ -44,6 +51,8 @@ const PostCommentForm: FunctionComponent<Props> = props => {
const onFocus = useCallback(() => {
emitFocusEvent();
}, [emitFocusEvent]);
const isQA =
props.story.settings && props.story.settings.mode === GQLSTORY_MODE.QA;
return (
<div className={CLASSES.createComment.$root}>
{props.showMessageBox && (
@@ -70,16 +79,31 @@ const PostCommentForm: FunctionComponent<Props> = props => {
{({ input, meta }) => (
<>
<HorizontalGutter size="half">
<Localized id="comments-postCommentForm-rteLabel">
<AriaInfo
component="label"
htmlFor="comments-postCommentForm-field"
>
Post a comment
</AriaInfo>
</Localized>
{isQA ? (
<Localized id="qa-postQuestionForm-rteLabel">
<AriaInfo
component="label"
htmlFor="comments-postCommentForm-field"
>
Post a question
</AriaInfo>
</Localized>
) : (
<Localized id="comments-postCommentForm-rteLabel">
<AriaInfo
component="label"
htmlFor="comments-postCommentForm-field"
>
Post a comment
</AriaInfo>
</Localized>
)}
<Localized
id="comments-postCommentForm-rte"
id={
isQA
? "qa-postQuestionForm-rte"
: "comments-postCommentForm-rte"
}
attrs={{ placeholder: true }}
>
<RTE
@@ -30,6 +30,7 @@ function createDefaultProps(add: DeepPartial<Props> = {}): Props {
messageBox: {
enabled: false,
},
mode: "COMMENTS",
},
},
sessionStorage: createPromisifiedStorage(),
@@ -295,6 +295,7 @@ const enhanced = withContext(({ sessionStorage }) => ({
messageBox {
enabled
}
mode
}
}
`,
@@ -3,6 +3,7 @@ import cn from "classnames";
import React, { FunctionComponent, useCallback } from "react";
import { useViewerEvent } from "coral-framework/lib/events";
import { GQLSTORY_MODE } from "coral-framework/schema";
import { PropTypesOf } from "coral-framework/types";
import CLASSES from "coral-stream/classes";
import { CreateCommentFocusEvent } from "coral-stream/events";
@@ -13,9 +14,15 @@ import MessageBoxContainer from "../MessageBoxContainer";
import styles from "./PostCommentFormFake.css";
interface StorySettings {
settings?: {
mode?: "COMMENTS" | "QA" | "%future added value" | null;
};
}
interface Props {
showMessageBox?: boolean;
story: PropTypesOf<typeof MessageBoxContainer>["story"];
story: PropTypesOf<typeof MessageBoxContainer>["story"] & StorySettings;
draft: string;
onDraftChange: (draft: string) => void;
onSignIn: () => void;
@@ -30,6 +37,8 @@ const PostCommentFormFake: FunctionComponent<Props> = props => {
(data: { html: string; text: string }) => props.onDraftChange(data.html),
[props.onDraftChange]
);
const isQA =
props.story.settings && props.story.settings.mode === GQLSTORY_MODE.QA;
return (
<div className={CLASSES.createComment.$root}>
{props.showMessageBox && (
@@ -41,11 +50,15 @@ const PostCommentFormFake: FunctionComponent<Props> = props => {
<HorizontalGutter className={styles.root}>
<div>
<Localized
id="comments-postCommentFormFake-rte"
id={
isQA
? "qa-postQuestionFormFake-rte"
: "comments-postCommentFormFake-rte"
}
attrs={{ placeholder: true }}
>
<RTE
placeholder="Post a comment"
placeholder={isQA ? "Post a question" : "Post a comment"}
value={props.draft}
onChange={onChange}
onFocus={onFocus}
@@ -22,6 +22,7 @@ exports[`renders correctly 1`] = `
"messageBox": Object {
"enabled": false,
},
"mode": "COMMENTS",
},
}
}
@@ -41,6 +42,7 @@ exports[`renders when commenting has been disabled (collapsing) 1`] = `
"messageBox": Object {
"enabled": false,
},
"mode": "COMMENTS",
},
}
}
@@ -69,6 +71,7 @@ exports[`renders when commenting has been disabled (non-collapsing) 1`] = `
"messageBox": Object {
"enabled": false,
},
"mode": "COMMENTS",
},
}
}
@@ -88,6 +91,7 @@ exports[`renders when story has been closed (collapsing) 1`] = `
"messageBox": Object {
"enabled": false,
},
"mode": "COMMENTS",
},
}
}
@@ -116,6 +120,7 @@ exports[`renders when story has been closed (non-collapsing) 1`] = `
"messageBox": Object {
"enabled": false,
},
"mode": "COMMENTS",
},
}
}
@@ -151,6 +156,7 @@ exports[`renders when user is scheduled to be deleted 1`] = `
"messageBox": Object {
"enabled": false,
},
"mode": "COMMENTS",
},
}
}
@@ -180,6 +186,7 @@ exports[`renders with initialValues 1`] = `
"messageBox": Object {
"enabled": false,
},
"mode": "COMMENTS",
},
}
}
@@ -26,6 +26,7 @@ interface Props {
| "%future added value";
onChange: (e: React.ChangeEvent<HTMLSelectElement>) => void;
reactionSortLabel: string;
isQA?: boolean;
}
const SortMenu: FunctionComponent<Props> = props => {
@@ -72,7 +73,13 @@ const SortMenu: FunctionComponent<Props> = props => {
<Localized id="comments-sortMenu-mostReplies">
<Option value="REPLIES_DESC">Most Replies</Option>
</Localized>
<Option value="REACTION_DESC">{props.reactionSortLabel}</Option>
{props.isQA ? (
<Localized id="qa-sortMenu-mostVoted">
<Option value="REACTION_DESC">Most Voted</Option>
</Localized>
) : (
<Option value="REACTION_DESC">{props.reactionSortLabel}</Option>
)}
</SelectField>
</Flex>
)}
@@ -5,7 +5,7 @@ import { graphql } from "react-relay";
import { useViewerEvent } from "coral-framework/lib/events";
import { useLocal, withFragmentContainer } from "coral-framework/lib/relay";
import { GQLUSER_STATUS } from "coral-framework/schema";
import { GQLSTORY_MODE, GQLUSER_STATUS } from "coral-framework/schema";
import CLASSES from "coral-stream/classes";
import Counter from "coral-stream/common/Counter";
import { UserBoxContainer } from "coral-stream/common/UserBox";
@@ -33,6 +33,7 @@ import {
import AllCommentsTab from "./AllCommentsTab";
import AnnouncementContainer from "./Announcement";
import AnsweredComments from "./AnsweredCommentsTab";
import BannedInfo from "./BannedInfo";
import { CommunityGuidelinesContainer } from "./CommunityGuidelines";
import StreamDeletionRequestCalloutContainer from "./DeleteAccount/StreamDeletionRequestCalloutContainer";
@@ -42,6 +43,7 @@ import { PostCommentFormContainer } from "./PostCommentForm";
import SortMenu from "./SortMenu";
import StoryClosedTimeoutContainer from "./StoryClosedTimeout";
import { SuspendedInfoContainer } from "./SuspendedInfo/index";
import UnansweredCommentsTab from "./UnansweredCommentsTab";
import useCommentCountEvent from "./useCommentCountEvent";
import styles from "./StreamContainer.css";
@@ -52,10 +54,15 @@ interface Props {
viewer: ViewerData | null;
}
interface TooltipTabProps extends PropTypesOf<typeof Tab> {
isQA?: boolean;
}
// Use a custom tab for featured comments, because we need to put the tooltip
// button logically next to the tab as both are buttons and position them together
// using absolute positioning.
const TabWithFeaturedTooltip: FunctionComponent<PropTypesOf<typeof Tab>> = ({
const TabWithFeaturedTooltip: FunctionComponent<TooltipTabProps> = ({
isQA,
...props
}) => (
<div className={styles.featuredCommentsTabContainer}>
@@ -72,6 +79,7 @@ const TabWithFeaturedTooltip: FunctionComponent<PropTypesOf<typeof Tab>> = ({
/>
<FeaturedCommentTooltip
active={props.active}
isQA={isQA}
className={cn(
styles.featuredCommentsInfo,
CLASSES.tabBarComments.featuredTooltip
@@ -124,6 +132,8 @@ export const StreamContainer: FunctionComponent<Props> = props => {
const allCommentsCount = props.story.commentCounts.totalPublished;
const featuredCommentsCount = props.story.commentCounts.tags.FEATURED;
const unansweredCommentsCount = props.story.commentCounts.tags.UNANSWERED;
const isQA = Boolean(props.story.settings.mode === GQLSTORY_MODE.QA);
// Emit comment count event.
useCommentCountEvent(props.story.id, props.story.url, allCommentsCount);
@@ -140,8 +150,14 @@ export const StreamContainer: FunctionComponent<Props> = props => {
} else {
onChangeTab("FEATURED_COMMENTS", false);
}
// If we are in Q&A mode, we default to most voted
// sorting by default
if (props.story.settings.mode === GQLSTORY_MODE.QA) {
setLocal({ commentsOrderBy: "REACTION_DESC" });
}
}
}, [featuredCommentsCount, local.commentsTab, onChangeTab]);
}, [local, setLocal, props, featuredCommentsCount, onChangeTab]);
return (
<>
@@ -188,6 +204,7 @@ export const StreamContainer: FunctionComponent<Props> = props => {
orderBy={local.commentsOrderBy}
onChange={onChangeOrder}
reactionSortLabel={props.settings.reaction.sortLabel}
isQA={isQA}
/>
<TabBar
variant="secondary"
@@ -196,11 +213,18 @@ export const StreamContainer: FunctionComponent<Props> = props => {
className={cn(CLASSES.tabBarComments.$root, styles.tabBarRoot)}
>
{featuredCommentsCount > 0 && (
<TabWithFeaturedTooltip tabID="FEATURED_COMMENTS">
<TabWithFeaturedTooltip tabID="FEATURED_COMMENTS" isQA={isQA}>
<Flex spacing={1} alignItems="center">
<Localized id="comments-featuredTab">
<span>Featured</span>
</Localized>
{isQA ? (
<Localized id="qa-answeredTab">
<span>Answered</span>
</Localized>
) : (
<Localized id="comments-featuredTab">
<span>Featured</span>
</Localized>
)}
<Counter
data-testid="comments-featuredCount"
size="sm"
@@ -220,6 +244,33 @@ export const StreamContainer: FunctionComponent<Props> = props => {
</Flex>
</TabWithFeaturedTooltip>
)}
{isQA && (
<Tab
tabID="UNANSWERED_COMMENTS"
className={cn(
{
[styles.fixedTab]: featuredCommentsCount > 0,
},
CLASSES.tabBarComments.allComments
)}
>
<Flex alignItems="center" spacing={1}>
<Localized id="comments-unansweredCommentsTab">
<span>Unanswered</span>
</Localized>
<Counter
size="sm"
color={
local.commentsTab === "UNANSWERED_COMMENTS"
? "primary"
: "grey"
}
>
{unansweredCommentsCount}
</Counter>
</Flex>
</Tab>
)}
<Tab
tabID="ALL_COMMENTS"
className={cn(
@@ -230,9 +281,16 @@ export const StreamContainer: FunctionComponent<Props> = props => {
)}
>
<Flex alignItems="center" spacing={1}>
<Localized id="comments-allCommentsTab">
<span>All Comments</span>
</Localized>
{isQA ? (
<Localized id="qa-allCommentsTab">
<span>All</span>
</Localized>
) : (
<Localized id="comments-allCommentsTab">
<span>All Comments</span>
</Localized>
)}
<Counter
size="sm"
color={
@@ -251,12 +309,29 @@ export const StreamContainer: FunctionComponent<Props> = props => {
</TabBar>
</Flex>
<TabContent activeTab={local.commentsTab}>
<TabPane
className={CLASSES.featuredCommentsTabPane.$root}
tabID="FEATURED_COMMENTS"
>
<FeaturedComments />
</TabPane>
{isQA ? (
<TabPane
className={CLASSES.featuredCommentsTabPane.$root}
tabID="FEATURED_COMMENTS"
>
<AnsweredComments />
</TabPane>
) : (
<TabPane
className={CLASSES.featuredCommentsTabPane.$root}
tabID="FEATURED_COMMENTS"
>
<FeaturedComments />
</TabPane>
)}
{isQA && (
<TabPane
className={CLASSES.allCommentsTabPane.$root}
tabID="UNANSWERED_COMMENTS"
>
<UnansweredCommentsTab />
</TabPane>
)}
<TabPane
className={CLASSES.allCommentsTabPane.$root}
tabID="ALL_COMMENTS"
@@ -279,10 +354,14 @@ const enhanced = withFragmentContainer<Props>({
...CreateCommentMutation_story
id
url
settings {
mode
}
commentCounts {
totalPublished
tags {
FEATURED
UNANSWERED
}
}
}
@@ -0,0 +1,64 @@
import React, { FunctionComponent, useEffect, useState } from "react";
import Spinner from "coral-stream/common/Spinner";
import { Flex } from "coral-ui/components";
interface Props {
children: React.ReactNode;
}
function callWhenReallyIdle(callback: () => void) {
let handle: any = null;
const rIC = (cb: () => void) => {
if ((window as any).requestIdleCallback) {
handle = (window as any).requestIdleCallback(cb, { timeout: 300 });
} else {
handle = setTimeout(cb, 0);
}
};
// Call `requestIdleCallback` multiple times to ensure
// that the browser is really idelling.
const times = 5;
let chained = callback;
for (let i = 0; i <= times; i++) {
const cur = chained;
chained = () => rIC(cur);
}
chained();
return () => {
if ((window as any).requestIdleCallback) {
(window as any).cancelIdleCallback(handle);
} else {
clearTimeout(handle);
}
};
}
/**
* Show spinner, wait for browser to idle and start rendering.
*/
const SpinnerWhileRendering: FunctionComponent<Props> = props => {
// In our tests, we don't actually "render", so just skip this.
if (process.env.NODE_ENV === "test") {
return <>{props.children}</>;
}
const [hidden, setHidden] = useState(true);
useEffect(() => {
// Ensure window has bee
return callWhenReallyIdle(() => setHidden(false));
}, [setHidden]);
return (
<>
{hidden && (
<Flex justifyContent="center">
<Spinner />
</Flex>
)}
{!hidden && props.children}
</>
);
};
export default SpinnerWhileRendering;
@@ -0,0 +1,92 @@
import { graphql, requestSubscription } from "react-relay";
import {
ConnectionHandler,
Environment,
RecordProxy,
RecordSourceSelectorProxy,
} from "relay-runtime";
import {
createSubscription,
SubscriptionVariables,
} from "coral-framework/lib/relay";
import { GQLCOMMENT_SORT, GQLCOMMENT_SORT_RL } from "coral-framework/schema";
import { UnansweredCommentCreatedSubscription } from "coral-stream/__generated__/UnansweredCommentCreatedSubscription.graphql";
function updateForNewestFirst(
store: RecordSourceSelectorProxy,
storyID: string
) {
const rootField = store.getRootField("commentCreated");
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 story = store.get(storyID)!;
const connection = ConnectionHandler.getConnection(story, "Stream_comments", {
orderBy: GQLCOMMENT_SORT.CREATED_AT_DESC,
})!;
const linked = connection.getLinkedRecords("viewNewEdges") || [];
connection.setLinkedRecords(linked.concat(commentsEdge), "viewNewEdges");
}
function updateForOldestFirst(
store: RecordSourceSelectorProxy,
storyID: string
) {
const story = store.get(storyID)!;
const connection = ConnectionHandler.getConnection(story, "Stream_comments", {
orderBy: GQLCOMMENT_SORT.CREATED_AT_ASC,
})!;
// eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion
const pageInfo = connection.getLinkedRecord("pageInfo") as RecordProxy;
pageInfo.setValue(true, "hasNextPage");
}
const UnansweredCommentCreatedSubscription = createSubscription(
"subscribeToUnansweredCommentCreated",
(
environment: Environment,
variables: SubscriptionVariables<UnansweredCommentCreatedSubscription> & {
orderBy: GQLCOMMENT_SORT_RL;
}
) =>
requestSubscription(environment, {
subscription: graphql`
subscription UnansweredCommentCreatedSubscription($storyID: ID!) {
commentCreated(storyID: $storyID) {
comment {
id
createdAt
...UnansweredCommentsTabContainer_comment
}
}
}
`,
variables,
updater: store => {
if (variables.orderBy === GQLCOMMENT_SORT.CREATED_AT_DESC) {
updateForNewestFirst(store, variables.storyID);
return;
}
if (variables.orderBy === GQLCOMMENT_SORT.CREATED_AT_ASC) {
updateForOldestFirst(store, variables.storyID);
return;
}
throw new Error(
`Unsupport new top level comment live updates for sort ${variables.orderBy}`
);
},
})
);
export default UnansweredCommentCreatedSubscription;
@@ -0,0 +1,92 @@
import { graphql, requestSubscription } from "react-relay";
import {
ConnectionHandler,
Environment,
RecordProxy,
RecordSourceSelectorProxy,
} from "relay-runtime";
import {
createSubscription,
SubscriptionVariables,
} from "coral-framework/lib/relay";
import { GQLCOMMENT_SORT, GQLCOMMENT_SORT_RL } from "coral-framework/schema";
import { UnansweredCommentReleasedSubscription } from "coral-stream/__generated__/UnansweredCommentReleasedSubscription.graphql";
function updateForNewestFirst(
store: RecordSourceSelectorProxy,
storyID: string
) {
const rootField = store.getRootField("commentReleased");
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 story = store.get(storyID)!;
const connection = ConnectionHandler.getConnection(story, "Stream_comments", {
orderBy: GQLCOMMENT_SORT.CREATED_AT_DESC,
})!;
const linked = connection.getLinkedRecords("viewNewEdges") || [];
connection.setLinkedRecords(linked.concat(commentsEdge), "viewNewEdges");
}
function updateForOldestFirst(
store: RecordSourceSelectorProxy,
storyID: string
) {
const story = store.get(storyID)!;
const connection = ConnectionHandler.getConnection(story, "Stream_comments", {
orderBy: GQLCOMMENT_SORT.CREATED_AT_ASC,
})!;
// eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion
const pageInfo = connection.getLinkedRecord("pageInfo") as RecordProxy;
pageInfo.setValue(true, "hasNextPage");
}
const UnansweredCommentReleasedSubscription = createSubscription(
"subscribeToUnansweredCommentReleased",
(
environment: Environment,
variables: SubscriptionVariables<UnansweredCommentReleasedSubscription> & {
orderBy: GQLCOMMENT_SORT_RL;
}
) =>
requestSubscription(environment, {
subscription: graphql`
subscription UnansweredCommentReleasedSubscription($storyID: ID!) {
commentReleased(storyID: $storyID) {
comment {
id
createdAt
...UnansweredCommentsTabContainer_comment
}
}
}
`,
variables,
updater: store => {
if (variables.orderBy === GQLCOMMENT_SORT.CREATED_AT_DESC) {
updateForNewestFirst(store, variables.storyID);
return;
}
if (variables.orderBy === GQLCOMMENT_SORT.CREATED_AT_ASC) {
updateForOldestFirst(store, variables.storyID);
return;
}
throw new Error(
`Unsupport new top level comment live updates for sort ${variables.orderBy}`
);
},
})
);
export default UnansweredCommentReleasedSubscription;
@@ -0,0 +1,4 @@
.stream > *:not(:first-child):not(button) {
border-top: 1px solid var(--palette-divider);
padding-top: var(--spacing-3)
}
@@ -0,0 +1,334 @@
import { Localized } from "@fluent/react/compat";
import React, { FunctionComponent, useCallback, useEffect } from "react";
import { graphql, RelayPaginationProp } from "react-relay";
import FadeInTransition from "coral-framework/components/FadeInTransition";
import { useViewerNetworkEvent } from "coral-framework/lib/events";
import {
useLoadMore,
useLocal,
useMutation,
useSubscription,
withPaginationContainer,
} from "coral-framework/lib/relay";
import { GQLCOMMENT_SORT, GQLTAG } from "coral-framework/schema";
import { Omit, PropTypesOf } from "coral-framework/types";
import CLASSES from "coral-stream/classes";
import { LoadMoreAllCommentsEvent } from "coral-stream/events";
import { Box, Button, CallOut, HorizontalGutter } from "coral-ui/components";
import { UnansweredCommentsTabContainer_settings } from "coral-stream/__generated__/UnansweredCommentsTabContainer_settings.graphql";
import { UnansweredCommentsTabContainer_story } from "coral-stream/__generated__/UnansweredCommentsTabContainer_story.graphql";
import { UnansweredCommentsTabContainer_viewer } from "coral-stream/__generated__/UnansweredCommentsTabContainer_viewer.graphql";
import { UnansweredCommentsTabContainerLocal } from "coral-stream/__generated__/UnansweredCommentsTabContainerLocal.graphql";
import { UnansweredCommentsTabContainerPaginationQueryVariables } from "coral-stream/__generated__/UnansweredCommentsTabContainerPaginationQuery.graphql";
import { CommentContainer } from "../../Comment";
import IgnoredTombstoneOrHideContainer from "../../IgnoredTombstoneOrHideContainer";
import { ReplyListContainer } from "../../ReplyList";
import CommentCreatedSubscription from "./UnansweredCommentCreatedSubscription";
import CommentReleasedSubscription from "./UnansweredCommentReleasedSubscription";
import UnansweredCommentsTabViewNewMutation from "./UnansweredCommentsTabViewNewMutation";
import styles from "./UnansweredCommentsTabContainer.css";
interface Props {
story: UnansweredCommentsTabContainer_story;
settings: UnansweredCommentsTabContainer_settings;
viewer: UnansweredCommentsTabContainer_viewer | null;
relay: RelayPaginationProp;
}
// eslint-disable-next-line no-unused-expressions
graphql`
fragment UnansweredCommentsTabContainer_comment on Comment {
id
...CommentContainer_comment
...ReplyListContainer1_comment
...IgnoredTombstoneOrHideContainer_comment
}
`;
export const UnansweredCommentsTabContainer: FunctionComponent<
Props
> = props => {
const [{ commentsOrderBy }] = useLocal<UnansweredCommentsTabContainerLocal>(
graphql`
fragment UnansweredCommentsTabContainerLocal on Local {
commentsOrderBy
}
`
);
const subscribeToCommentCreated = useSubscription(CommentCreatedSubscription);
const subscribeToCommentReleased = useSubscription(
CommentReleasedSubscription
);
useEffect(() => {
if (!props.story.settings.live.enabled) {
return;
}
if (props.story.isClosed || props.settings.disableCommenting.enabled) {
return;
}
if (
commentsOrderBy === GQLCOMMENT_SORT.CREATED_AT_ASC &&
props.relay.hasMore()
) {
// If sort by oldest we only need to know if there is more to load.
return;
}
if (
![
GQLCOMMENT_SORT.CREATED_AT_ASC,
GQLCOMMENT_SORT.CREATED_AT_DESC,
// eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion
].includes(commentsOrderBy as GQLCOMMENT_SORT)
) {
// Only chronological sort supports top level live updates of incoming comments.
return;
}
const newCommentDisposable = subscribeToCommentCreated({
storyID: props.story.id,
orderBy: commentsOrderBy,
});
const releasedCommentDisposable = subscribeToCommentReleased({
storyID: props.story.id,
orderBy: commentsOrderBy,
});
return () => {
newCommentDisposable.dispose();
releasedCommentDisposable.dispose();
};
}, [
commentsOrderBy,
subscribeToCommentCreated,
subscribeToCommentReleased,
props.story.id,
props.relay.hasMore(),
props.story.settings.live.enabled,
]);
const [loadMore, isLoadingMore] = useLoadMore(props.relay, 20);
const beginLoadMoreEvent = useViewerNetworkEvent(LoadMoreAllCommentsEvent);
const loadMoreAndEmit = useCallback(async () => {
const loadMoreEvent = beginLoadMoreEvent({ storyID: props.story.id });
try {
await loadMore();
loadMoreEvent.success();
} catch (error) {
loadMoreEvent.error({ message: error.message, code: error.code });
// eslint-disable-next-line no-console
console.error(error);
}
}, [loadMore, beginLoadMoreEvent, props.story.id]);
const viewMore = useMutation(UnansweredCommentsTabViewNewMutation);
const onViewMore = useCallback(() => viewMore({ storyID: props.story.id }), [
props.story.id,
viewMore,
]);
const comments = props.story.comments.edges.map(edge => edge.node);
const viewNewCount =
(props.story.comments.viewNewEdges &&
props.story.comments.viewNewEdges.length) ||
0;
const onRemoveAnswered = useCallback(() => {
const { relay } = props;
relay.refetchConnection(20);
}, [props]);
return (
<>
{Boolean(viewNewCount && viewNewCount > 0) && (
<Box mb={4} clone>
<Button
variant="outlined"
color="primary"
onClick={onViewMore}
className={CLASSES.allCommentsTabPane.viewNewButton}
fullWidth
>
<Localized id="qa-viewNew" $count={viewNewCount}>
<span>View {viewNewCount} New Questions</span>
</Localized>
</Button>
</Box>
)}
<HorizontalGutter
id="comments-unansweredComments-log"
data-testid="comments-unansweredComments-log"
role="log"
aria-live="polite"
size="oneAndAHalf"
className={styles.stream}
>
{comments.length <= 0 && props.story.isClosed && (
<Localized id="qa-noQuestionsAtAll">
<CallOut fullWidth>There are no questions on this story.</CallOut>
</Localized>
)}
{comments.length <= 0 && !props.story.isClosed && (
<Localized id="qa-noQuestionsYet">
<CallOut fullWidth>
There are no questions yet. Why don't you ask one?
</CallOut>
</Localized>
)}
{comments.length > 0 &&
!props.story.isClosed &&
comments.map(comment => (
<IgnoredTombstoneOrHideContainer
key={comment.id}
viewer={props.viewer}
comment={comment}
>
<FadeInTransition active={Boolean(comment.enteredLive)}>
<HorizontalGutter>
<CommentContainer
viewer={props.viewer}
settings={props.settings}
comment={comment}
story={props.story}
/>
<ReplyListContainer
settings={props.settings}
viewer={props.viewer}
comment={comment}
story={props.story}
onRemoveAnswered={onRemoveAnswered}
/>
</HorizontalGutter>
</FadeInTransition>
</IgnoredTombstoneOrHideContainer>
))}
{props.relay.hasMore() && (
<Localized id="comments-loadMore">
<Button
onClick={loadMoreAndEmit}
variant="outlineFilled"
fullWidth
disabled={isLoadingMore}
aria-controls="comments-allComments-log"
className={CLASSES.allCommentsTabPane.loadMoreButton}
>
Load More
</Button>
</Localized>
)}
</HorizontalGutter>
</>
);
};
// TODO: (cvle) if this could be autogenerated..
type FragmentVariables = Omit<
UnansweredCommentsTabContainerPaginationQueryVariables,
"storyID"
>;
const enhanced = withPaginationContainer<
Props,
UnansweredCommentsTabContainerPaginationQueryVariables,
FragmentVariables
>(
{
story: graphql`
fragment UnansweredCommentsTabContainer_story on Story
@argumentDefinitions(
count: { type: "Int!", defaultValue: 20 }
cursor: { type: "Cursor" }
orderBy: { type: "COMMENT_SORT!", defaultValue: CREATED_AT_DESC }
tag: { type: "TAG!", defaultValue: UNANSWERED }
) {
id
isClosed
settings {
live {
enabled
}
}
comments(first: $count, after: $cursor, orderBy: $orderBy, tag: $tag)
@connection(key: "UnansweredStream_comments") {
viewNewEdges {
cursor
}
edges {
node {
enteredLive
...UnansweredCommentsTabContainer_comment @relay(mask: false)
}
}
}
...PostCommentFormContainer_story
...CommentContainer_story
...ReplyListContainer1_story
...CreateCommentReplyMutation_story
...CreateCommentMutation_story
}
`,
viewer: graphql`
fragment UnansweredCommentsTabContainer_viewer on User {
...ReplyListContainer1_viewer
...CommentContainer_viewer
...CreateCommentReplyMutation_viewer
...CreateCommentMutation_viewer
...IgnoredTombstoneOrHideContainer_viewer
status {
current
}
}
`,
settings: graphql`
fragment UnansweredCommentsTabContainer_settings on Settings {
reaction {
sortLabel
}
disableCommenting {
enabled
}
...ReplyListContainer1_settings
...CommentContainer_settings
}
`,
},
{
direction: "forward",
getConnectionFromProps(props) {
return props.story && props.story.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 {
count,
cursor,
orderBy: fragmentVariables.orderBy,
// storyID isn't specified as an @argument for the fragment, but it should be a
// variable available for the fragment under the query root.
storyID: props.story.id,
tag: GQLTAG.UNANSWERED,
};
},
query: graphql`
# Pagination query to be fetched upon calling 'loadMore'.
# Notice that we re-use our fragment, and the shape of this query matches our fragment spec.
query UnansweredCommentsTabContainerPaginationQuery(
$count: Int!
$cursor: Cursor
$orderBy: COMMENT_SORT!
$storyID: ID
) {
story(id: $storyID) {
...UnansweredCommentsTabContainer_story
@arguments(count: $count, cursor: $cursor, orderBy: $orderBy)
}
}
`,
}
)(UnansweredCommentsTabContainer);
export type UnansweredCommentsTabContainerProps = PropTypesOf<typeof enhanced>;
export default enhanced;
@@ -0,0 +1,99 @@
import { Localized } from "@fluent/react/compat";
import React, { FunctionComponent } from "react";
import {
graphql,
QueryRenderData,
QueryRenderer,
withLocalStateContainer,
} from "coral-framework/lib/relay";
import Spinner from "coral-stream/common/Spinner";
import { Flex } from "coral-ui/components";
import { UnansweredCommentsTabQuery as QueryTypes } from "coral-stream/__generated__/UnansweredCommentsTabQuery.graphql";
import { UnansweredCommentsTabQueryLocal as Local } from "coral-stream/__generated__/UnansweredCommentsTabQueryLocal.graphql";
import SpinnerWhileRendering from "./SpinnerWhileRendering";
import UnansweredCommentsTabContainer from "./UnansweredCommentsTabContainer";
interface Props {
local: Local;
preload?: boolean;
}
export const render = (data: QueryRenderData<QueryTypes>) => {
if (data.error) {
return <div>{data.error.message}</div>;
}
if (data.props) {
if (!data.props.story) {
return (
<Localized id="comments-streamQuery-storyNotFound">
<div>Story not found</div>
</Localized>
);
}
return (
<SpinnerWhileRendering>
<UnansweredCommentsTabContainer
settings={data.props.settings}
viewer={data.props.viewer}
story={data.props.story}
/>
</SpinnerWhileRendering>
);
}
return (
<Flex justifyContent="center">
<Spinner />
</Flex>
);
};
const UnansweredCommentsTabQuery: FunctionComponent<Props> = props => {
const {
local: { storyID, storyURL, commentsOrderBy },
} = props;
return (
<QueryRenderer<QueryTypes>
query={graphql`
query UnansweredCommentsTabQuery(
$storyID: ID
$storyURL: String
$commentsOrderBy: COMMENT_SORT
$tag: TAG
) {
viewer {
...UnansweredCommentsTabContainer_viewer
}
story: stream(id: $storyID, url: $storyURL) {
...UnansweredCommentsTabContainer_story
@arguments(orderBy: $commentsOrderBy, tag: UNANSWERED)
}
settings {
...UnansweredCommentsTabContainer_settings
}
}
`}
variables={{
storyID,
storyURL,
commentsOrderBy,
}}
render={data => (props.preload ? null : render(data))}
/>
);
};
const enhanced = withLocalStateContainer(
graphql`
fragment UnansweredCommentsTabQueryLocal on Local {
storyID
storyURL
commentsOrderBy
}
`
)(UnansweredCommentsTabQuery);
export default enhanced;
@@ -0,0 +1,51 @@
import { ConnectionHandler, Environment, RecordProxy } from "relay-runtime";
import { CoralContext } from "coral-framework/lib/bootstrap";
import {
commitLocalUpdatePromisified,
createMutation,
} from "coral-framework/lib/relay";
import { GQLCOMMENT_SORT } from "coral-framework/schema";
import { ViewNewCommentsEvent } from "coral-stream/events";
interface Input {
storyID: string;
}
const UnansweredCommentsTabViewNewMutation = createMutation(
"viewNewUnanswered",
async (
environment: Environment,
input: Input,
{ eventEmitter }: CoralContext
) => {
await commitLocalUpdatePromisified(environment, async store => {
const story = store.get(input.storyID)!;
const connection = ConnectionHandler.getConnection(
story,
"UnansweredStream_comments",
{
orderBy: GQLCOMMENT_SORT.CREATED_AT_DESC,
}
)!;
// eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion
const viewNewEdges = connection.getLinkedRecords(
"viewNewEdges"
) as ReadonlyArray<RecordProxy>;
if (!viewNewEdges || viewNewEdges.length === 0) {
return;
}
viewNewEdges.forEach(edge => {
ConnectionHandler.insertEdgeBefore(connection, edge);
});
ViewNewCommentsEvent.emit(eventEmitter, {
storyID: input.storyID,
count: viewNewEdges.length,
});
connection.setLinkedRecords([], "viewNewEdges");
});
}
);
export default UnansweredCommentsTabViewNewMutation;
@@ -0,0 +1,4 @@
export {
default,
default as UnansweredCommentsTabQuery,
} from "./UnansweredCommentsTabQuery";
@@ -8,6 +8,7 @@ import ConfigureStreamContainer from "./ConfigureStream";
import HorizontalRule from "./HorizontalRule";
import ModerateStreamContainer from "./ModerateStreamContainer";
import OpenOrCloseStreamContainer from "./OpenOrCloseStream";
import { QAConfigContainer } from "./Q&A";
export interface Props {
viewer: PropTypesOf<typeof UserBoxContainer>["viewer"];
@@ -15,7 +16,8 @@ export interface Props {
PropTypesOf<typeof ModerateStreamContainer>["settings"];
story: PropTypesOf<typeof ConfigureStreamContainer>["story"] &
PropTypesOf<typeof OpenOrCloseStreamContainer>["story"] &
PropTypesOf<typeof ModerateStreamContainer>["story"];
PropTypesOf<typeof ModerateStreamContainer>["story"] &
PropTypesOf<typeof QAConfigContainer>["story"];
}
const Configure: FunctionComponent<Props> = props => {
@@ -30,6 +32,8 @@ const Configure: FunctionComponent<Props> = props => {
<HorizontalRule />
<ConfigureStreamContainer story={props.story} />
<HorizontalRule />
<QAConfigContainer story={props.story} />
<HorizontalRule />
<OpenOrCloseStreamContainer story={props.story} />
</HorizontalGutter>
</div>
@@ -32,6 +32,7 @@ const enhanced = withFragmentContainer<ConfigureContainerProps>({
...ConfigureStreamContainer_story
...OpenOrCloseStreamContainer_story
...ModerateStreamContainer_story
...QAConfigContainer_story
}
`,
viewer: graphql`
@@ -22,6 +22,7 @@ import PremodLinksConfigContainer from "./PremodLinksConfig";
import styles from "./ConfigureStream.css";
interface Props {
storyID: string;
onSubmit: (settings: any, form: FormApi) => void;
storySettings: PropTypesOf<
typeof LiveUpdatesConfigContainer
@@ -41,9 +42,9 @@ const ConfigureStream: FunctionComponent<Props> = ({
id="configure-form"
>
<Flex justifyContent="space-between" alignItems="flex-start" itemGutter>
<Localized id="configure-stream-title">
<Localized id="configure-stream-title-configureThisStream">
<Typography variant="heading2" className={styles.heading}>
Configure this Comment Stream
Configure this Stream
</Typography>
</Localized>
<Localized id="configure-stream-apply">
@@ -36,6 +36,7 @@ class ConfigureStreamContainer extends React.Component<Props> {
{({ onSubmit }) => (
<ConfigureStream
onSubmit={onSubmit}
storyID={this.props.story.id}
storySettings={this.props.story.settings}
/>
)}
@@ -0,0 +1,45 @@
import { graphql } from "react-relay";
import { Environment } from "relay-runtime";
import {
commitMutationPromiseNormalized,
createMutation,
MutationInput,
} from "coral-framework/lib/relay";
import { AddExpertMutation } from "coral-stream/__generated__/AddExpertMutation.graphql";
let clientMutationId = 0;
const AddExpertMutation = createMutation(
"addExpert",
(environment: Environment, input: MutationInput<AddExpertMutation>) =>
commitMutationPromiseNormalized<AddExpertMutation>(environment, {
mutation: graphql`
mutation AddExpertMutation($input: AddExpertInput!) {
addStoryExpert(input: $input) {
story {
id
settings {
experts {
id
username
email
}
}
}
clientMutationId
}
}
`,
variables: {
input: {
storyID: input.storyID,
userID: input.userID,
clientMutationId: (clientMutationId++).toString(),
},
},
})
);
export default AddExpertMutation;
@@ -0,0 +1,7 @@
.heading {
padding-bottom: calc(1.5 * var(--mini-unit));
}
.button {
flex-shrink: 0;
}
@@ -0,0 +1,44 @@
import { Localized } from "@fluent/react/compat";
import cn from "classnames";
import React, { FunctionComponent } from "react";
import CLASSES from "coral-stream/classes";
import { Button, Flex, Typography } from "coral-ui/components";
import styles from "./DisableQA.css";
interface Props {
onClick: () => void;
disableButton?: boolean;
}
const DisableQA: FunctionComponent<Props> = ({ onClick, disableButton }) => (
<div className={CLASSES.openCommentStream.$root}>
<Localized id="configure-disableQA-title">
<Typography variant="heading2" className={styles.heading}>
Configure this Q&A
</Typography>
</Localized>
<Flex alignItems="flex-start" itemGutter>
<Localized id="configure-disableQA-description">
<Typography>
The Q&A format allows community members to submit questions for chosen
experts to answer.
</Typography>
</Localized>
<Localized id="configure-disableQA-disableQA">
<Button
variant="outlined"
color="primary"
className={cn(styles.button, CLASSES.openCommentStream.openButton)}
onClick={onClick}
disabled={disableButton}
>
Switch to Comments
</Button>
</Localized>
</Flex>
</div>
);
export default DisableQA;
@@ -0,0 +1,7 @@
.heading {
padding-bottom: calc(1.5 * var(--mini-unit));
}
.button {
flex-shrink: 0;
}
@@ -0,0 +1,44 @@
import { Localized } from "@fluent/react/compat";
import cn from "classnames";
import React, { FunctionComponent } from "react";
import CLASSES from "coral-stream/classes";
import { Button, Flex, Typography } from "coral-ui/components";
import styles from "./EnableQA.css";
interface Props {
onClick: () => void;
disableButton?: boolean;
}
const EnableQA: FunctionComponent<Props> = ({ onClick, disableButton }) => (
<div className={CLASSES.openCommentStream.$root}>
<Localized id="configure-enableQA-title">
<Typography variant="heading2" className={styles.heading}>
Switch to Q&A Format
</Typography>
</Localized>
<Flex alignItems="flex-start" itemGutter>
<Localized id="configure-enableQA-description">
<Typography>
The Q&A format allows community members to submit questions for chosen
experts to answer.
</Typography>
</Localized>
<Localized id="configure-enableQA-enableQA">
<Button
variant="outlined"
color="primary"
className={cn(styles.button, CLASSES.openCommentStream.openButton)}
onClick={onClick}
disabled={disableButton}
>
Switch to Q&A
</Button>
</Localized>
</Flex>
</div>
);
export default EnableQA;
@@ -0,0 +1,45 @@
.root {
margin-left: var(--v2-spacing-1);
margin-bottom: var(--v2-spacing-1);
display: flex;
flex-direction: row;
flex-wrap: wrap;
justify-content: space-between;
align-items: center;
max-width: 80%;
}
.usernameEmail {
display: flex;
flex-direction: row;
flex-wrap: wrap;
justify-content: space-between;
}
.username {
color: var(--palette-text-dark);
font-size: calc(16rem / var(--rem-base));
font-weight: var(--font-weight-medium);
font-family: var(--font-family-sans-serif);
line-height: 1.375em;
letter-spacing: 0.0125em;
margin-right: var(--v2-spacing-1);
}
.email {
color: var(--palette-text-primary);
font-size: calc(16rem / var(--rem-base));
font-weight: var(--font-weight-regular);
font-family: var(--font-family-sans-serif);
line-height: 1.375em;
letter-spacing: 0.0125em;
margin-bottom: var(--v2-spacing-1);
}
.removeButton {
padding: var(--v2-spacing-1);
}
@@ -0,0 +1,49 @@
import { Localized } from "@fluent/react/compat";
import React, { FunctionComponent, useCallback } from "react";
import { Button } from "coral-ui/components";
import styles from "./ExpertListItem.css";
interface Props {
id: string;
username: string | null;
email: string | null;
onClickRemove: (id: string) => void;
}
const ExpertListItem: FunctionComponent<Props> = ({
id,
username,
email,
onClickRemove,
}) => {
const onClick = useCallback(() => {
onClickRemove(id);
}, [id, onClickRemove]);
return (
<li key={id} className={styles.root}>
<div className={styles.usernameEmail}>
{username && <span className={styles.username}>{username}</span>}
{email && (
<span className={styles.email}>
<Localized id="qa-expert-email" $email={email}>
email
</Localized>
</span>
)}
</div>
<Button
variant="outlined"
color="error"
onClick={onClick}
className={styles.removeButton}
>
<Localized id="configure-experts-remove-button">Remove</Localized>
</Button>
</li>
);
};
export default ExpertListItem;
@@ -0,0 +1,31 @@
.button {
padding-top: var(--v2-spacing-1);
padding-bottom: var(--v2-spacing-1);
}
.usernameEmail {
display: flex;
flex-direction: row;
flex-wrap: wrap;
justify-content: space-between;
}
.username {
color: var(--palette-text-dark);
font-size: calc(16rem / var(--rem-base));
font-weight: var(--font-weight-medium);
font-family: var(--font-family-sans-serif);
line-height: 1.375em;
letter-spacing: 0.0125em;
margin-right: var(--v2-spacing-1);
}
.email {
color: var(--palette-text-primary);
font-size: calc(16rem / var(--rem-base));
font-weight: var(--font-weight-regular);
font-family: var(--font-family-sans-serif);
line-height: 1.375em;
letter-spacing: 0.0125em;
}
@@ -0,0 +1,43 @@
import { Localized } from "@fluent/react/compat";
import React, { FunctionComponent, useCallback } from "react";
import { Button, Flex } from "coral-ui/components";
import styles from "./ExpertSearchItem.css";
interface Props {
id: string;
username: string | null;
email: string | null;
onClickAdd: (id: string) => void;
}
const ExpertSearchItem: FunctionComponent<Props> = ({
id,
username,
email,
onClickAdd,
}) => {
const onClick = useCallback(() => {
onClickAdd(id);
}, [id, onClickAdd]);
return (
<Flex alignItems="center" key={id}>
<Button onClick={onClick} className={styles.button}>
<div className={styles.usernameEmail}>
{username && <span className={styles.username}>{username}</span>}
{email && (
<span className={styles.email}>
<Localized id="qa-expert-email" $email={email}>
email
</Localized>
</span>
)}
</div>
</Button>
</Flex>
);
};
export default ExpertSearchItem;
@@ -0,0 +1,37 @@
.list {
position: absolute;
z-index: 99;
min-width: calc(80% - 47px);
max-height: 120px;
overflow-y: auto;
padding-left: 0;
padding-right: 0;
padding-top: var(--v2-spacing-1);
margin-bottom: var(--v2-spacing-2);
}
.loadMore {
margin-top: var(--v2-spacing-1);
padding-top: var(--v2-spacing-1);
padding-bottom: var(--v2-spacing-1);
width: 100%;
}
.noneFound {
width: 100%;
padding-top: var(--v2-spacing-2);
text-align: center;
color: var(--palette-text-dark);
font-size: calc(16rem / var(--rem-base));
font-weight: var(--font-weight-medium);
font-family: var(--font-family-sans-serif);
line-height: 1.375em;
letter-spacing: 0.0125em;
}
@@ -0,0 +1,90 @@
import { Localized } from "@fluent/react/compat";
import React, { FunctionComponent, useCallback } from "react";
import { Button, Card, Flex, Spinner } from "coral-ui/components";
import ExpertSearchItem from "./ExpertSearchItem";
import styles from "./ExpertSearchList.css";
interface UserItem {
id: string;
username: string | null;
email: string | null;
}
interface Props {
isVisible: boolean;
users: UserItem[];
onAdd: (id: string) => void;
hasMore: boolean;
loading: boolean;
disableLoadMore: boolean;
onLoadMore: () => void;
}
const ExpertSearchList: FunctionComponent<Props> = ({
isVisible,
users,
loading,
hasMore,
disableLoadMore,
onLoadMore,
onAdd,
}) => {
const onAddClick = useCallback(
(id: string) => {
onAdd(id);
},
[onAdd]
);
const loadMore = useCallback(() => {
onLoadMore();
}, [onLoadMore]);
if (!isVisible) {
return null;
}
return (
<Card className={styles.list}>
{users.map(u => (
<ExpertSearchItem
key={u.id}
id={u.id}
username={u.username}
email={u.email}
onClickAdd={onAddClick}
/>
))}
{!loading && users.length === 0 && (
<div className={styles.noneFound}>
<Localized id="configure-experts-search-none-found">
No users were found with that email or username
</Localized>
</div>
)}
{loading && (
<Flex justifyContent="center">
<Spinner />
</Flex>
)}
{hasMore && (
<Localized id="configure-experts-load-more">
<Button
variant="filled"
color="primary"
onClick={loadMore}
disabled={disableLoadMore}
className={styles.loadMore}
>
Load More
</Button>
</Localized>
)}
</Card>
);
};
export default ExpertSearchList;
@@ -0,0 +1,29 @@
.searchRoot {
position: relative;
width: 100%;
}
.searchField {
width: 80%;
border-top-right-radius: 0;
border-bottom-right-radius: 0;
}
.searchButton {
border-top-left-radius: 0;
border-bottom-left-radius: 0;
}
.description {
max-width: 350px;
}
.expertListTitle {
margin-top: var(--v2-spacing-3);
}
.list {
padding: 0;
list-style-type: none;
}
@@ -0,0 +1,348 @@
import { Localized } from "@fluent/react/compat";
import React, {
FunctionComponent,
useCallback,
useEffect,
useState,
} from "react";
import { RelayPaginationProp } from "react-relay";
import {
graphql,
useLoadMore,
useMutation,
useRefetch,
withPaginationContainer,
} from "coral-framework/lib/relay";
import { GQLUSER_ROLE_RL, GQLUSER_STATUS_RL } from "coral-framework/schema";
import { Button, Flex, Icon, Typography } from "coral-ui/components";
import { TextField as SearchTextField } from "coral-ui/components/v2";
import { ExpertSelectionContainer_query as QueryData } from "coral-stream/__generated__/ExpertSelectionContainer_query.graphql";
import { ExpertSelectionContainerPaginationQueryVariables } from "coral-stream/__generated__/ExpertSelectionContainerPaginationQuery.graphql";
import AddExpertMutation from "./AddExpertMutation";
import ExpertListItem from "./ExpertListItem";
import ExpertSearchList from "./ExpertSearchList";
import RemoveExpertMutation from "./RemoveExpertMutation";
import styles from "./ExpertSelectionContainer.css";
interface Props {
storyID: string;
query: QueryData | null;
relay: RelayPaginationProp;
}
function computeUsers(query: QueryData | null) {
if (!query) {
return [];
}
return query.users.edges.map(edge => edge.node);
}
function computeExperts(query: QueryData | null) {
if (!query) {
return [];
}
if (!query.story) {
return [];
}
return query.story.settings.experts;
}
const ExpertSelectionContainer: FunctionComponent<Props> = ({
storyID,
query,
relay,
}) => {
const users = computeUsers(query);
const experts = computeExperts(query);
const searchRootRef = React.createRef<HTMLDivElement>();
const [loadMore, isLoadingMore] = useLoadMore(relay, 10);
const [searchFilter, setSearchFilter] = useState<string>("");
const [roleFilter] = useState<GQLUSER_ROLE_RL | null>(null);
const [statusFilter] = useState<GQLUSER_STATUS_RL | null>(null);
const [, isRefetching] = useRefetch(relay, {
searchFilter: searchFilter || null,
roleFilter,
statusFilter,
});
const [tempSearchFilter, setTempSearchFilter] = useState<string>("");
const loading = !query || isRefetching;
const hasMore = !isRefetching && relay.hasMore();
const addExpertMutation = useMutation(AddExpertMutation);
const removeExpertMutation = useMutation(RemoveExpertMutation);
const clearSearchFilter = useCallback(() => {
setTempSearchFilter("");
setSearchFilter("");
}, [setSearchFilter, setTempSearchFilter]);
const onClickOutside = useCallback(
(e: any) => {
if (
searchRootRef &&
searchRootRef.current &&
searchRootRef.current.contains(e.target)
) {
return;
}
clearSearchFilter();
},
[clearSearchFilter, searchRootRef]
);
useEffect(() => {
document.addEventListener("mousedown", onClickOutside);
return () => {
document.removeEventListener("mousedown", onClickOutside);
};
}, [onClickOutside]);
const onAddExpert = useCallback(
(id: string) => {
addExpertMutation({
storyID,
userID: id,
});
clearSearchFilter();
},
[addExpertMutation, clearSearchFilter]
);
const onRemoveExpert = useCallback(
(id: string) => {
removeExpertMutation({
storyID,
userID: id,
});
},
[removeExpertMutation]
);
const onSubmitSearch = useCallback(() => {
setSearchFilter(tempSearchFilter);
}, [tempSearchFilter]);
const onSearchTextChanged = useCallback(
(event: React.ChangeEvent<HTMLInputElement>) => {
setTempSearchFilter(event.target.value);
},
[setTempSearchFilter]
);
const onSearchKeyDown = useCallback(
(event: React.KeyboardEvent) => {
if (event.key === "Escape") {
clearSearchFilter();
}
},
[clearSearchFilter]
);
const onSearchKeyPress = useCallback(
(event: React.KeyboardEvent) => {
if (event.key === "Enter") {
onSubmitSearch();
}
if (event.key === "Escape") {
clearSearchFilter();
}
},
[onSubmitSearch, clearSearchFilter]
);
return (
<>
<Typography variant="heading3" container="div">
<Localized id="configure-experts-title">
<span>Add an Expert</span>
</Localized>
</Typography>
<Localized id="configure-experts-filter-description">
<Typography
variant="detail"
color="textSecondary"
className={styles.description}
>
Adds an Expert Badge to comments by registered users, only on this
page. New users must first sign up and open the comments on a page to
create their account.
</Typography>
</Localized>
<div className={styles.searchRoot} ref={searchRootRef}>
<Flex>
<Localized
id="configure-experts-filter-searchField"
attrs={{ placeholder: true, "aria-label": true }}
>
<SearchTextField
color="regular"
placeholder="Search by email or username"
aria-label="Search by email or username"
onChange={onSearchTextChanged}
onKeyPress={onSearchKeyPress}
onKeyDown={onSearchKeyDown}
className={styles.searchField}
variant="seamlessAdornment"
value={tempSearchFilter}
adornment={
<Localized
id="configure-experts-filter-searchButton"
attrs={{ "aria-label": true }}
>
<Button
className={styles.searchButton}
variant="filled"
color="primary"
aria-label="Search"
onClick={onSubmitSearch}
>
<Icon size="md">search</Icon>
</Button>
</Localized>
}
/>
</Localized>
</Flex>
<ExpertSearchList
isVisible={searchFilter.length > 0}
users={users}
onAdd={onAddExpert}
loading={loading}
hasMore={hasMore}
disableLoadMore={isLoadingMore}
onLoadMore={loadMore}
/>
<Typography
variant="heading3"
container="div"
className={styles.expertListTitle}
>
<Localized id="configure-experts-assigned-title">
<span>Experts</span>
</Localized>
</Typography>
{experts.length > 0 ? (
<ul className={styles.list}>
{experts.map(u => (
<ExpertListItem
key={u.id}
id={u.id}
username={u.username}
email={u.email}
onClickRemove={onRemoveExpert}
/>
))}
</ul>
) : (
<Localized id="configure-experts-none-yet">
<Typography color="textSecondary">
There are currently no experts for this Q&A.
</Typography>
</Localized>
)}
</div>
</>
);
};
type FragmentVariables = ExpertSelectionContainerPaginationQueryVariables;
const enhanced = withPaginationContainer<
Props,
ExpertSelectionContainerPaginationQueryVariables,
FragmentVariables
>(
{
query: graphql`
fragment ExpertSelectionContainer_query on Query
@argumentDefinitions(
storyID: { type: "ID!" }
count: { type: "Int!", defaultValue: 10 }
cursor: { type: "Cursor" }
roleFilter: { type: "USER_ROLE" }
statusFilter: { type: "USER_STATUS" }
searchFilter: { type: "String" }
) {
viewer {
id
username
}
story(id: $storyID) {
settings {
experts {
id
email
username
}
}
}
users(
first: $count
after: $cursor
role: $roleFilter
status: $statusFilter
query: $searchFilter
) @connection(key: "ExpertSelection_users") {
edges {
node {
id
email
username
}
}
}
}
`,
},
{
direction: "forward",
getConnectionFromProps(props) {
return props.query && props.query.users;
},
// 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 {
storyID: props.storyID,
count,
cursor,
roleFilter: fragmentVariables.roleFilter,
statusFilter: fragmentVariables.statusFilter,
searchFilter: fragmentVariables.searchFilter,
};
},
query: graphql`
# Pagination query to be fetched upon calling 'loadMore'.
# Notice that we re-use our fragment, and the shape of this query matches our fragment spec.
query ExpertSelectionContainerPaginationQuery(
$storyID: ID!
$count: Int!
$cursor: Cursor
$roleFilter: USER_ROLE
$statusFilter: USER_STATUS
$searchFilter: String
) {
...ExpertSelectionContainer_query
@arguments(
storyID: $storyID
count: $count
cursor: $cursor
roleFilter: $roleFilter
statusFilter: $statusFilter
searchFilter: $searchFilter
)
}
`,
}
)(ExpertSelectionContainer);
export default enhanced;
@@ -0,0 +1,43 @@
import React, { FunctionComponent } from "react";
import { graphql } from "react-relay";
import { QueryRenderer } from "coral-framework/lib/relay";
import { Flex, Spinner } from "coral-ui/components";
import { ExpertSelectionQuery as QueryTypes } from "coral-stream/__generated__/ExpertSelectionQuery.graphql";
import ExpertSelectionContainer from "./ExpertSelectionContainer";
interface Props {
storyID: string;
}
const ExpertSelectionQuery: FunctionComponent<Props> = ({ storyID }) => (
<QueryRenderer<QueryTypes>
query={graphql`
query ExpertSelectionQuery($storyID: ID) {
...ExpertSelectionContainer_query @arguments(storyID: $storyID)
}
`}
cacheConfig={{ force: true }}
variables={{
storyID,
}}
render={({ error, props }: any) => {
if (error) {
return <div>{error.message}</div>;
}
if (!props) {
return (
<Flex justifyContent="center">
<Spinner />
</Flex>
);
}
return <ExpertSelectionContainer query={props} storyID={storyID} />;
}}
/>
);
export default ExpertSelectionQuery;
@@ -0,0 +1,59 @@
import React, { FunctionComponent, useCallback, useState } from "react";
import {
graphql,
useMutation,
withFragmentContainer,
} from "coral-framework/lib/relay";
import { GQLSTORY_MODE } from "coral-framework/schema";
import { QAConfigContainer_story } from "coral-stream/__generated__/QAConfigContainer_story.graphql";
import DisableQA from "./DisableQA";
import EnableQA from "./EnableQA";
import ExpertSelectionQuery from "./ExpertSelectionQuery";
import UpdateStoryModeMutation from "./UpdateStoryModeMutation";
interface Props {
story: QAConfigContainer_story;
}
const QAConfigContainer: FunctionComponent<Props> = ({ story }) => {
const [waiting, setWaiting] = useState(false);
const updateStoryMode = useMutation(UpdateStoryModeMutation);
const handleOnClick = useCallback(async () => {
if (!waiting) {
setWaiting(true);
if (story.settings.mode === GQLSTORY_MODE.COMMENTS) {
updateStoryMode({ storyID: story.id, mode: GQLSTORY_MODE.QA });
} else {
updateStoryMode({ storyID: story.id, mode: GQLSTORY_MODE.COMMENTS });
}
setWaiting(false);
}
}, [waiting, setWaiting, story, updateStoryMode]);
const isQA = story.settings.mode === GQLSTORY_MODE.QA;
return isQA ? (
<>
<DisableQA onClick={handleOnClick} disableButton={waiting} />
<ExpertSelectionQuery storyID={story.id} />
</>
) : (
<EnableQA onClick={handleOnClick} disableButton={waiting} />
);
};
const enhanced = withFragmentContainer<Props>({
story: graphql`
fragment QAConfigContainer_story on Story {
id
settings {
mode
}
}
`,
})(QAConfigContainer);
export default enhanced;
@@ -0,0 +1,45 @@
import { graphql } from "react-relay";
import { Environment } from "relay-runtime";
import {
commitMutationPromiseNormalized,
createMutation,
MutationInput,
} from "coral-framework/lib/relay";
import { RemoveExpertMutation } from "coral-stream/__generated__/RemoveExpertMutation.graphql";
let clientMutationId = 0;
const RemoveExpertMutation = createMutation(
"removeExpert",
(environment: Environment, input: MutationInput<RemoveExpertMutation>) =>
commitMutationPromiseNormalized<RemoveExpertMutation>(environment, {
mutation: graphql`
mutation RemoveExpertMutation($input: RemoveExpertInput!) {
removeStoryExpert(input: $input) {
story {
id
settings {
experts {
id
username
email
}
}
}
clientMutationId
}
}
`,
variables: {
input: {
storyID: input.storyID,
userID: input.userID,
clientMutationId: (clientMutationId++).toString(),
},
},
})
);
export default RemoveExpertMutation;
@@ -0,0 +1,47 @@
import { graphql } from "react-relay";
import { Environment } from "relay-runtime";
import {
commitMutationPromiseNormalized,
createMutation,
MutationInput,
} from "coral-framework/lib/relay";
import { UpdateStoryModeMutation } from "coral-stream/__generated__/UpdateStoryModeMutation.graphql";
let clientMutationId = 0;
const UpdateStoryModeMutation = createMutation(
"disableQA",
(
environment: Environment,
input: MutationInput<UpdateStoryModeMutation> & {
storyID: string;
mode: string;
}
) =>
commitMutationPromiseNormalized<UpdateStoryModeMutation>(environment, {
mutation: graphql`
mutation UpdateStoryModeMutation($input: UpdateStoryModeInput!) {
updateStoryMode(input: $input) {
story {
id
settings {
mode
}
}
clientMutationId
}
}
`,
variables: {
input: {
storyID: input.storyID,
mode: input.mode,
clientMutationId: (clientMutationId++).toString(),
},
},
})
);
export default UpdateStoryModeMutation;
@@ -0,0 +1 @@
export { default, default as QAConfigContainer } from "./QAConfigContainer";
@@ -424,7 +424,11 @@ exports[`renders comment stream 1`] = `
</div>
<span
className="Box-root Box-ml-1"
/>
>
<div
className="Box-root Flex-root Flex-flex Flex-alignCenter"
/>
</span>
<button
className="BaseButton-root Timestamp-root"
onBlur={[Function]}
@@ -537,7 +541,11 @@ exports[`renders comment stream 1`] = `
</div>
<span
className="Box-root Box-ml-1"
/>
>
<div
className="Box-root Flex-root Flex-flex Flex-alignCenter"
/>
</span>
<button
className="BaseButton-root Timestamp-root"
onBlur={[Function]}
@@ -164,6 +164,9 @@ exports[`renders permalink view 1`] = `
</div>
</div>
</div>
<div
className="Box-root Flex-root Flex-flex Flex-alignCenter"
/>
</div>
<div
className="Box-root Flex-root Flex-flex Flex-itemGutter Flex-alignBaseline Flex-directionRow gutter"
@@ -392,6 +395,9 @@ exports[`renders permalink view 1`] = `
</div>
</div>
</div>
<div
className="Box-root Flex-root Flex-flex Flex-alignCenter"
/>
</div>
<div
className="Box-root Flex-root Flex-flex Flex-itemGutter Flex-alignBaseline Flex-directionRow gutter"
@@ -618,6 +624,9 @@ exports[`renders permalink view 1`] = `
</div>
</div>
</div>
<div
className="Box-root Flex-root Flex-flex Flex-alignCenter"
/>
</div>
<div
className="Box-root Flex-root Flex-flex Flex-itemGutter Flex-alignBaseline Flex-directionRow gutter"
@@ -840,6 +849,9 @@ exports[`renders permalink view 1`] = `
</div>
</div>
</div>
<div
className="Box-root Flex-root Flex-flex Flex-alignCenter"
/>
</div>
<div
className="Box-root Flex-root Flex-flex Flex-itemGutter Flex-alignBaseline Flex-directionRow gutter"
@@ -1078,6 +1090,9 @@ exports[`renders permalink view 1`] = `
</div>
</div>
</div>
<div
className="Box-root Flex-root Flex-flex Flex-alignCenter"
/>
</div>
<div
className="Box-root Flex-root Flex-flex Flex-itemGutter Flex-alignBaseline Flex-directionRow gutter"
@@ -43,11 +43,15 @@ exports[`renders conversation thread 1`] = `
Moderator
</span>
</div>
<span
className="Tag-root coral coral-userTag coral-rootParent-userTag Tag-colorGrey Tag-uppercase"
<div
className="Box-root Flex-root Flex-flex Flex-alignCenter"
>
Staff
</span>
<span
className="Tag-root coral coral-userTag coral-rootParent-userTag UserTagsContainer-tag Tag-colorGrey Tag-uppercase"
>
Staff
</span>
</div>
</div>
<div
className="Box-root Flex-root Flex-flex Flex-itemGutter Flex-alignBaseline Flex-directionRow gutter"
@@ -205,6 +209,9 @@ exports[`renders conversation thread 1`] = `
</div>
</div>
</div>
<div
className="Box-root Flex-root Flex-flex Flex-alignCenter"
/>
</div>
<div
className="Box-root Flex-root Flex-flex Flex-itemGutter Flex-alignBaseline Flex-directionRow gutter"
@@ -446,6 +453,9 @@ exports[`shows more of this conversation 1`] = `
</div>
</div>
</div>
<div
className="Box-root Flex-root Flex-flex Flex-alignCenter"
/>
</div>
<div
className="Box-root Flex-root Flex-flex Flex-itemGutter Flex-alignBaseline Flex-directionRow gutter"
@@ -674,11 +684,15 @@ exports[`shows more of this conversation 1`] = `
</div>
</div>
</div>
<span
className="Tag-root coral coral-userTag coral-comment-userTag Tag-colorGrey Tag-uppercase"
<div
className="Box-root Flex-root Flex-flex Flex-alignCenter"
>
Staff
</span>
<span
className="Tag-root coral coral-userTag coral-comment-userTag UserTagsContainer-tag Tag-colorGrey Tag-uppercase"
>
Staff
</span>
</div>
</div>
<div
className="Box-root Flex-root Flex-flex Flex-itemGutter Flex-alignBaseline Flex-directionRow gutter"
@@ -905,6 +919,9 @@ exports[`shows more of this conversation 1`] = `
</div>
</div>
</div>
<div
className="Box-root Flex-root Flex-flex Flex-alignCenter"
/>
</div>
<div
className="Box-root Flex-root Flex-flex Flex-itemGutter Flex-alignBaseline Flex-directionRow gutter"
@@ -61,6 +61,9 @@ exports[`cancel edit 1`] = `
</div>
</div>
</div>
<div
className="Box-root Flex-root Flex-flex Flex-alignCenter"
/>
</div>
<div
className="Box-root Flex-root Flex-flex Flex-itemGutter Flex-alignBaseline Flex-directionRow gutter"
@@ -875,6 +878,9 @@ exports[`edit a comment: render comment with edit button 1`] = `
</div>
</div>
</div>
<div
className="Box-root Flex-root Flex-flex Flex-alignCenter"
/>
</div>
<div
className="Box-root Flex-root Flex-flex Flex-itemGutter Flex-alignBaseline Flex-directionRow gutter"
@@ -1119,6 +1125,9 @@ exports[`edit a comment: server response 1`] = `
</div>
</div>
</div>
<div
className="Box-root Flex-root Flex-flex Flex-alignCenter"
/>
</div>
<div
className="Box-root Flex-root Flex-flex Flex-itemGutter Flex-alignBaseline Flex-directionRow gutter"
@@ -1372,6 +1381,9 @@ exports[`shows expiry message: edit form closed 1`] = `
</div>
</div>
</div>
<div
className="Box-root Flex-root Flex-flex Flex-alignCenter"
/>
</div>
<div
className="Box-root Flex-root Flex-flex Flex-itemGutter Flex-alignBaseline Flex-directionRow gutter"
@@ -71,6 +71,9 @@ exports[`renders comment stream with load more button 1`] = `
</div>
</div>
</div>
<div
className="Box-root Flex-root Flex-flex Flex-alignCenter"
/>
</div>
<div
className="Box-root Flex-root Flex-flex Flex-itemGutter Flex-alignBaseline Flex-directionRow gutter"
@@ -282,6 +285,9 @@ exports[`renders comment stream with load more button 1`] = `
</div>
</div>
</div>
<div
className="Box-root Flex-root Flex-flex Flex-alignCenter"
/>
</div>
<div
className="Box-root Flex-root Flex-flex Flex-itemGutter Flex-alignBaseline Flex-directionRow gutter"
@@ -61,6 +61,9 @@ exports[`post a comment: optimistic response 1`] = `
</div>
</div>
</div>
<div
className="Box-root Flex-root Flex-flex Flex-alignCenter"
/>
</div>
<div
className="Box-root Flex-root Flex-flex Flex-itemGutter Flex-alignBaseline Flex-directionRow gutter"
@@ -61,6 +61,9 @@ exports[`post a reply: open reply form 1`] = `
</div>
</div>
</div>
<div
className="Box-root Flex-root Flex-flex Flex-alignCenter"
/>
</div>
<div
className="Box-root Flex-root Flex-flex Flex-itemGutter Flex-alignBaseline Flex-directionRow gutter"
@@ -506,6 +509,9 @@ exports[`post a reply: optimistic response 1`] = `
</div>
</div>
</div>
<div
className="Box-root Flex-root Flex-flex Flex-alignCenter"
/>
</div>
<div
className="Box-root Flex-root Flex-flex Flex-itemGutter Flex-alignBaseline Flex-directionRow gutter"
@@ -770,6 +776,9 @@ exports[`renders comment stream 1`] = `
</div>
</div>
</div>
<div
className="Box-root Flex-root Flex-flex Flex-alignCenter"
/>
</div>
<div
className="Box-root Flex-root Flex-flex Flex-itemGutter Flex-alignBaseline Flex-directionRow gutter"
@@ -1005,6 +1014,9 @@ exports[`renders comment stream 1`] = `
</div>
</div>
</div>
<div
className="Box-root Flex-root Flex-flex Flex-alignCenter"
/>
</div>
<div
className="Box-root Flex-root Flex-flex Flex-itemGutter Flex-alignBaseline Flex-directionRow gutter"
@@ -1267,6 +1279,9 @@ exports[`renders comment stream 1`] = `
</div>
</div>
</div>
<div
className="Box-root Flex-root Flex-flex Flex-alignCenter"
/>
</div>
<div
className="Box-root Flex-root Flex-flex Flex-itemGutter Flex-alignBaseline Flex-directionRow gutter"
@@ -1529,6 +1544,9 @@ exports[`renders comment stream 1`] = `
</div>
</div>
</div>
<div
className="Box-root Flex-root Flex-flex Flex-alignCenter"
/>
</div>
<div
className="Box-root Flex-root Flex-flex Flex-itemGutter Flex-alignBaseline Flex-directionRow gutter"
@@ -61,6 +61,9 @@ exports[`post a reply: open reply form 1`] = `
</div>
</div>
</div>
<div
className="Box-root Flex-root Flex-flex Flex-alignCenter"
/>
</div>
<div
className="Box-root Flex-root Flex-flex Flex-itemGutter Flex-alignBaseline Flex-directionRow gutter"
@@ -462,6 +465,9 @@ exports[`post a reply: optimistic response 1`] = `
</div>
</div>
</div>
<div
className="Box-root Flex-root Flex-flex Flex-alignCenter"
/>
</div>
<div
className="Box-root Flex-root Flex-flex Flex-itemGutter Flex-alignBaseline Flex-directionRow gutter"
@@ -345,6 +345,9 @@ exports[`renders comment stream with community guidelines 1`] = `
</div>
</div>
</div>
<div
className="Box-root Flex-root Flex-flex Flex-alignCenter"
/>
</div>
<div
className="Box-root Flex-root Flex-flex Flex-itemGutter Flex-alignBaseline Flex-directionRow gutter"
@@ -556,6 +559,9 @@ exports[`renders comment stream with community guidelines 1`] = `
</div>
</div>
</div>
<div
className="Box-root Flex-root Flex-flex Flex-alignCenter"
/>
</div>
<div
className="Box-root Flex-root Flex-flex Flex-itemGutter Flex-alignBaseline Flex-directionRow gutter"
@@ -70,6 +70,9 @@ exports[`renders reply list 1`] = `
</div>
</div>
</div>
<div
className="Box-root Flex-root Flex-flex Flex-alignCenter"
/>
</div>
<div
className="Box-root Flex-root Flex-flex Flex-itemGutter Flex-alignBaseline Flex-directionRow gutter"
@@ -313,6 +316,9 @@ exports[`renders reply list 1`] = `
</div>
</div>
</div>
<div
className="Box-root Flex-root Flex-flex Flex-alignCenter"
/>
</div>
<div
className="Box-root Flex-root Flex-flex Flex-itemGutter Flex-alignBaseline Flex-directionRow gutter"
@@ -551,6 +557,9 @@ exports[`renders reply list 1`] = `
</div>
</div>
</div>
<div
className="Box-root Flex-root Flex-flex Flex-alignCenter"
/>
</div>
<div
className="Box-root Flex-root Flex-flex Flex-itemGutter Flex-alignBaseline Flex-directionRow gutter"
@@ -791,6 +800,9 @@ exports[`renders reply list 1`] = `
</div>
</div>
</div>
<div
className="Box-root Flex-root Flex-flex Flex-alignCenter"
/>
</div>
<div
className="Box-root Flex-root Flex-flex Flex-itemGutter Flex-alignBaseline Flex-directionRow gutter"
@@ -367,6 +367,9 @@ exports[`renders comment stream 1`] = `
</div>
</div>
</div>
<div
className="Box-root Flex-root Flex-flex Flex-alignCenter"
/>
</div>
<div
className="Box-root Flex-root Flex-flex Flex-itemGutter Flex-alignBaseline Flex-directionRow gutter"
@@ -578,11 +581,15 @@ exports[`renders comment stream 1`] = `
</div>
</div>
</div>
<span
className="Tag-root coral coral-userTag coral-comment-userTag Tag-colorGrey Tag-uppercase"
<div
className="Box-root Flex-root Flex-flex Flex-alignCenter"
>
Staff
</span>
<span
className="Tag-root coral coral-userTag coral-comment-userTag UserTagsContainer-tag Tag-colorGrey Tag-uppercase"
>
Staff
</span>
</div>
</div>
<div
className="Box-root Flex-root Flex-flex Flex-itemGutter Flex-alignBaseline Flex-directionRow gutter"
@@ -70,6 +70,9 @@ exports[`renders comment stream 1`] = `
</div>
</div>
</div>
<div
className="Box-root Flex-root Flex-flex Flex-alignCenter"
/>
</div>
<div
className="Box-root Flex-root Flex-flex Flex-itemGutter Flex-alignBaseline Flex-directionRow gutter"
@@ -61,6 +61,9 @@ exports[`renders deepest comment with link 1`] = `
</div>
</div>
</div>
<div
className="Box-root Flex-root Flex-flex Flex-alignCenter"
/>
</div>
<div
className="Box-root Flex-root Flex-flex Flex-itemGutter Flex-alignBaseline Flex-directionRow gutter"
@@ -71,6 +71,9 @@ exports[`renders app with comment stream 1`] = `
</div>
</div>
</div>
<div
className="Box-root Flex-root Flex-flex Flex-alignCenter"
/>
</div>
<div
className="Box-root Flex-root Flex-flex Flex-itemGutter Flex-alignBaseline Flex-directionRow gutter"
@@ -282,6 +285,9 @@ exports[`renders app with comment stream 1`] = `
</div>
</div>
</div>
<div
className="Box-root Flex-root Flex-flex Flex-alignCenter"
/>
</div>
<div
className="Box-root Flex-root Flex-flex Flex-itemGutter Flex-alignBaseline Flex-directionRow gutter"
@@ -81,7 +81,7 @@ exports[`renders configure 1`] = `
<h1
className="Box-root Typography-root Typography-heading2 Typography-colorTextPrimary ConfigureStream-heading"
>
Configure this Comment Stream
Configure this Stream
</h1>
<button
className="BaseButton-root Button-root Button-sizeRegular Button-colorSuccess Button-variantFilled Button-disabled coral coral-configureCommentStream-applyButton"
@@ -287,6 +287,43 @@ announcements relating to the comments on this story.
<hr
className="HorizontalRule-root"
/>
<div
className="coral coral-openCommentStream"
>
<h1
className="Box-root Typography-root Typography-heading2 Typography-colorTextPrimary EnableQA-heading"
>
Switch to Q&A Format
</h1>
<div
className="Box-root Flex-root Flex-flex Flex-itemGutter Flex-alignFlexStart gutter"
>
<p
className="Box-root Typography-root Typography-bodyCopy Typography-colorTextPrimary"
>
The Q&A format allows community members to submit questions for chosen
experts to answer.
</p>
<button
className="BaseButton-root Button-root Button-sizeRegular Button-colorPrimary Button-variantOutlined EnableQA-button coral coral-openCommentStream-openButton"
data-color="primary"
data-variant="outlined"
disabled={false}
onBlur={[Function]}
onClick={[Function]}
onFocus={[Function]}
onMouseOut={[Function]}
onMouseOver={[Function]}
onTouchEnd={[Function]}
type="button"
>
Switch to Q&A
</button>
</div>
</div>
<hr
className="HorizontalRule-root"
/>
<div
className="coral coral-closeCommentStream"
>
+4
View File
@@ -6,6 +6,7 @@ import {
GQLSettings,
GQLSite,
GQLStory,
GQLSTORY_MODE,
GQLTAG,
GQLTag,
GQLUser,
@@ -527,6 +528,7 @@ export const baseStory = createFixture<GQLStory>({
totalPublished: 0,
tags: {
FEATURED: 0,
UNANSWERED: 0,
},
},
settings: {
@@ -539,6 +541,8 @@ export const baseStory = createFixture<GQLStory>({
enabled: true,
configurable: true,
},
mode: GQLSTORY_MODE.COMMENTS,
experts: [],
},
site,
});
@@ -5,6 +5,7 @@ import {
GQLCOMMENT_STATUS,
GQLMODERATION_MODE,
GQLStory,
GQLSTORY_MODE,
GQLUser,
GQLUSER_ROLE,
GQLUSER_STATUS,
@@ -124,6 +125,7 @@ export function createStory(createComments = true) {
totalPublished: 0,
tags: {
FEATURED: 0,
UNANSWERED: 0,
},
},
settings: {
@@ -136,6 +138,8 @@ export function createStory(createComments = true) {
enabled: true,
configurable: true,
},
mode: GQLSTORY_MODE.COMMENTS,
experts: [],
},
})
);
@@ -64,6 +64,8 @@ export interface TextFieldProps {
* onChange
*/
onChange?: EventHandler<ChangeEvent<HTMLInputElement>>;
onKeyPress?: React.EventHandler<React.KeyboardEvent>;
onKeyDown?: React.EventHandler<React.KeyboardEvent>;
disabled?: boolean;
@@ -1,8 +1,12 @@
import cn from "classnames";
import React, { AllHTMLAttributes, ChangeEvent, EventHandler } from "react";
import React, {
AllHTMLAttributes,
ChangeEvent,
EventHandler,
FunctionComponent,
} from "react";
import { withStyles } from "coral-ui/hocs";
import { FunctionComponent } from "react";
import styles from "./TextField.css";
@@ -60,6 +64,8 @@ export interface TextFieldProps {
* onChange
*/
onChange?: EventHandler<ChangeEvent<HTMLInputElement>>;
onKeyPress?: React.EventHandler<React.KeyboardEvent>;
onKeyDown?: React.EventHandler<React.KeyboardEvent>;
disabled?: boolean;
+6 -1
View File
@@ -200,12 +200,17 @@ export default (ctx: Context) => ({
"tags.type": tag,
},
}).then(primeCommentsFromConnection(ctx)),
forStory: (storyID: string, { first, orderBy, after }: StoryToCommentsArgs) =>
forStory: (
storyID: string,
{ first, orderBy, after, tag }: StoryToCommentsArgs
) =>
retrieveCommentStoryConnection(ctx.mongo, ctx.tenant.id, storyID, {
first: defaultTo(first, 10),
orderBy: defaultTo(orderBy, GQLCOMMENT_SORT.CREATED_AT_DESC),
after,
filter: {
// Ensure we filter by the requested tag
...tagFilter(tag),
// Only get Comments that are top level. If the client wants to load
// another layer, they can request another nested connection.
parentID: null,
+12
View File
@@ -5,24 +5,30 @@ import GraphContext from "coral-server/graph/context";
import { mapFieldsetToErrorCodes } from "coral-server/graph/errors";
import { Story } from "coral-server/models/story";
import {
addStoryExpert,
close,
create,
merge,
open,
remove,
removeStoryExpert,
update,
updateSettings,
updateStoryMode,
} from "coral-server/services/stories";
import { scrape } from "coral-server/services/stories/scraper";
import {
GQLAddExpertInput,
GQLCloseStoryInput,
GQLCreateStoryInput,
GQLMergeStoriesInput,
GQLOpenStoryInput,
GQLRemoveExpertInput,
GQLRemoveStoryInput,
GQLScrapeStoryInput,
GQLUpdateStoryInput,
GQLUpdateStoryModeInput,
GQLUpdateStorySettingsInput,
} from "coral-server/graph/schema/__generated__/types";
@@ -70,4 +76,10 @@ export const Stories = (ctx: GraphContext) => ({
remove(ctx.mongo, ctx.tenant, input.id, input.includeComments),
scrape: async (input: GQLScrapeStoryInput): Promise<Readonly<Story> | null> =>
scrape(ctx.mongo, ctx.config, ctx.tenant.id, input.id),
updateStoryMode: async (input: GQLUpdateStoryModeInput) =>
updateStoryMode(ctx.mongo, ctx.tenant, input.storyID, input.mode),
addStoryExpert: async (input: GQLAddExpertInput) =>
addStoryExpert(ctx.mongo, ctx.tenant, input.storyID, input.userID),
removeStoryExpert: async (input: GQLRemoveExpertInput) =>
removeStoryExpert(ctx.mongo, ctx.tenant, input.storyID, input.userID),
});
@@ -256,6 +256,18 @@ export const Mutation: Required<GQLMutationTypeResolver<void>> = {
site: await ctx.mutators.Sites.update(input),
clientMutationId: input.clientMutationId,
}),
updateStoryMode: async (source, { input }, ctx) => ({
story: await ctx.mutators.Stories.updateStoryMode(input),
clientMutationId: input.clientMutationId,
}),
addStoryExpert: async (source, { input }, ctx) => ({
story: await ctx.mutators.Stories.addStoryExpert(input),
clientMutationId: input.clientMutationId,
}),
removeStoryExpert: async (source, { input }, ctx) => ({
story: await ctx.mutators.Stories.removeStoryExpert(input),
clientMutationId: input.clientMutationId,
}),
createWebhookEndpoint: async (
source,
{ input: { clientMutationId, ...input } },
@@ -1,6 +1,9 @@
import * as story from "coral-server/models/story";
import { GQLStorySettingsTypeResolver } from "../schema/__generated__/types";
import {
GQLSTORY_MODE,
GQLStorySettingsTypeResolver,
} from "../schema/__generated__/types";
export const StorySettings: GQLStorySettingsTypeResolver<
story.StorySettings
@@ -18,4 +21,18 @@ export const StorySettings: GQLStorySettingsTypeResolver<
enabled: false,
};
},
mode: s => {
if (s.mode) {
return s.mode;
}
return GQLSTORY_MODE.COMMENTS;
},
experts: (s, input, ctx) => {
if (s.expertIDs) {
return ctx.loaders.Users.user.loadMany(s.expertIDs);
}
return [];
},
};
+167
View File
@@ -372,6 +372,18 @@ type WordList {
suspect: [String!]!
}
enum STORY_MODE {
"""
Coments is when a story is used for general commenting.
"""
COMMENTS
"""
QA is used for when the story is in Q&A mode.
"""
QA
}
################################################################################
## Moderation
################################################################################
@@ -2345,6 +2357,18 @@ enum TAG {
FEATURED is used when a Comment is marked as such by a staff member.
"""
FEATURED
"""
EXPERT is used when an a Comment is written by a User that is assigned as
an expert on a story.
"""
EXPERT
"""
UNANSWERED is used when a Comment is written by a User and is unanswered
by an expert for that Comment's story.
"""
UNANSWERED
}
"""
@@ -2376,6 +2400,11 @@ type CommentTagCounts {
FEATURED is the count of Comment's with the FEATURED tag.
"""
FEATURED: Int!
"""
UNANSWERED is the count of Comment's with the UNANSWERED tag.
"""
UNANSWERED: Int!
}
"""
@@ -2720,6 +2749,19 @@ type StorySettings {
messageBox stores settings related to the Story Message Box.
"""
messageBox: StoryMessageBox!
"""
mode is whether the story stream is in commenting or Q&A mode.
This will determine the appearance of the stream and how it functions.
"""
mode: STORY_MODE!
"""
experts are used during Q&A mode to assign users to answer questions
on a Q&A stream. It is an optional parameter and is only used when
the story stream is in Q&A mode.
"""
experts: [User!]!
}
"""
@@ -2783,6 +2825,14 @@ type Story {
first: Int = 20 @constraint(max: 50)
orderBy: COMMENT_SORT = CREATED_AT_DESC
after: Cursor
"""
This is a workaround to allow filtering for current Q&A
functionality. This is used to filter on UNANSWERED to
populate its corresponding Unanswered questions tab. In
the future, we want a dedicated edge for unansweredComments.
"""
tag: TAG
): CommentsConnection!
"""
@@ -5827,6 +5877,97 @@ type DisableFeatureFlagPayload {
flags: [FEATURE_FLAG!]!
}
#########################
# Add / Remove Expert
#########################
input AddExpertInput {
"""
clientMutationId is required for Relay support.
"""
clientMutationId: String!
"""
storyID is the story to add the expert to.
"""
storyID: ID!
"""
userID is the user to add as an expert to the story.
"""
userID: ID!
}
type AddExpertPayload {
"""
clientMutationId is required for Relay support.
"""
clientMutationId: String!
"""
story is the resultant story the expert was added to.
"""
story: Story!
}
input RemoveExpertInput {
"""
clientMutationId is required for Relay support.
"""
clientMutationId: String!
"""
storyID is the story to remove the expert from.
"""
storyID: ID!
"""
userID is the user to remove as an expert from the story.
"""
userID: ID!
}
type RemoveExpertPayload {
"""
clientMutationId is required for Relay support.
"""
clientMutationId: String!
"""
story is the resultant story the expert was removed from.
"""
story: Story!
}
input UpdateStoryModeInput {
"""
storyID is the story id to enable Q&A on.
"""
storyID: ID!
"""
mode is the mode to set the story to.
"""
mode: STORY_MODE!
"""
clientMutationId is required for Relay support.
"""
clientMutationId: String!
}
type UpdateStoryModePayload {
"""
story is the resultant story that Q&A was enabled on.
"""
story: Story!
"""
clientMutationId is required for Relay support.
"""
clientMutationId: String!
}
##################
## Mutation
##################
@@ -6199,10 +6340,16 @@ type Mutation {
input: DisableFeatureFlagInput!
): DisableFeatureFlagPayload! @auth(roles: [ADMIN])
"""
createAnnouncement creates a global announcement.
"""
createAnnouncement(
input: CreateAnnouncementInput!
): CreateAnnouncementPayload! @auth(roles: [ADMIN])
"""
deleteAnnouncement removes a global announcement.
"""
deleteAnnouncement(
input: DeleteAnnouncementInput!
): DeleteAnnouncementPayload! @auth(roles: [ADMIN])
@@ -6253,6 +6400,26 @@ type Mutation {
rotateWebhookEndpointSecret(
input: RotateWebhookEndpointSecretInput!
): RotateWebhookEndpointSecretPayload! @auth(roles: [ADMIN])
"""
updateStoryMode will set the story mode.
"""
updateStoryMode(input: UpdateStoryModeInput!): UpdateStoryModePayload!
@auth(roles: [ADMIN, MODERATOR])
"""
addStoryExpert adds an expert to a story.
"""
addStoryExpert(
input: AddExpertInput!
): AddExpertPayload! @auth(roles: [ADMIN, MODERATOR])
"""
removeStoryExpert removes an expert from a story.
"""
removeStoryExpert(
input: RemoveExpertInput!
): RemoveExpertPayload! @auth(roles: [ADMIN, MODERATOR])
}
##################
+4 -6
View File
@@ -926,12 +926,9 @@ export async function retrieveStoryCommentTagCounts(
// Build up the $match query.
const $match: FilterQuery<Comment> = {
tenantID,
// We're filtering only for featured comments for now because that's all
// that is returned by the tag counts at the moment. If we ever extend this
// we should switch this out to something like
// `"tags.type": { $exists: true }` to ensure that we are using the
// specified index.
"tags.type": GQLTAG.FEATURED,
// ensure we are using the tags.type index, this
// currently includes FEATURED and UNANSWERED tags
"tags.type": { $exists: true },
// Only show published comment's tag counts.
status: { $in: PUBLISHED_STATUSES },
};
@@ -986,6 +983,7 @@ export async function retrieveStoryCommentTagCounts(
// missing/extra tags here.
{
[GQLTAG.FEATURED]: 0,
[GQLTAG.UNANSWERED]: 0,
}
);
});
+120 -1
View File
@@ -6,6 +6,7 @@ import { dotize } from "coral-common/utils/dotize";
import {
DuplicateStoryIDError,
DuplicateStoryURLError,
StoryNotFoundError,
} from "coral-server/errors";
import {
Connection,
@@ -18,6 +19,7 @@ import { TenantResource } from "coral-server/models/tenant";
import { stories as collection } from "coral-server/services/mongodb/collections";
import {
GQLSTORY_MODE,
GQLStoryMetadata,
GQLStorySettings,
} from "coral-server/graph/schema/__generated__/types";
@@ -30,8 +32,26 @@ import {
export * from "./helpers";
export interface StreamModeSettings {
/**
* mode is whether the story stream is in commenting or Q&A mode.
* This will determine the appearance of the stream and how it functions.
* This is an optional parameter and if unset, defaults to commenting.
*/
mode?: GQLSTORY_MODE;
/**
* experts are used during Q&A mode to assign users to answer questions
* on a Q&A stream. It is an optional parameter and is only used when
* the story stream is in Q&A mode.
*/
expertIDs?: string[];
}
export type StorySettings = DeepPartial<
Pick<GQLStorySettings, "messageBox"> & GlobalModerationSettings
StreamModeSettings &
GlobalModerationSettings &
Pick<GQLStorySettings, "messageBox" | "mode" | "experts">
>;
export type StoryMetadata = GQLStoryMetadata;
@@ -544,3 +564,102 @@ export const updateStoryCounts = (
id: string,
commentCounts: FirstDeepPartial<RelatedCommentCounts>
) => updateRelatedCommentCounts(collection(mongo), tenantID, id, commentCounts);
export async function addExpert(
mongo: Db,
tenantID: string,
storyID: string,
userID: string
) {
const story = await collection(mongo).findOne({ tenantID, id: storyID });
if (!story) {
throw new StoryNotFoundError(storyID);
}
const result = await collection(mongo).findOneAndUpdate(
{
tenantID,
id: storyID,
},
{
$addToSet: {
"settings.expertIDs": userID,
},
},
{
returnOriginal: false,
}
);
if (!result.ok) {
throw new Error("unable to add expert to story");
}
return result.value || null;
}
export async function removeExpert(
mongo: Db,
tenantID: string,
storyID: string,
userID: string
) {
const story = await collection(mongo).findOne({ tenantID, id: storyID });
if (!story) {
throw new StoryNotFoundError(storyID);
}
const result = await collection(mongo).findOneAndUpdate(
{
tenantID,
id: storyID,
},
{
$pull: {
"settings.expertIDs": userID,
},
},
{
returnOriginal: false,
}
);
if (!result.ok) {
throw new Error("unable to remove expert from story");
}
return result.value || null;
}
export async function setStoryMode(
mongo: Db,
tenantID: string,
storyID: string,
mode: GQLSTORY_MODE
) {
const story = await collection(mongo).findOne({ tenantID, id: storyID });
if (!story) {
throw new StoryNotFoundError(storyID);
}
const result = await collection(mongo).findOneAndUpdate(
{
tenantID,
id: storyID,
},
{
$set: {
"settings.mode": mode,
},
},
{
returnOriginal: false,
}
);
if (!result.ok) {
throw new Error("unable to enable Q&A on story");
}
return result.value || null;
}
@@ -0,0 +1,41 @@
import { CommentTag } from "coral-server/models/comment/tag";
import {
IntermediateModerationPhase,
IntermediatePhaseResult,
} from "coral-server/services/comments/pipeline";
import {
GQLCOMMENT_STATUS,
GQLTAG,
} from "coral-server/graph/schema/__generated__/types";
interface Result {
status?: GQLCOMMENT_STATUS;
tags: CommentTag[];
}
export const approve: IntermediateModerationPhase = ({
tags,
now,
}): IntermediatePhaseResult | void => {
const result: Result = {
tags: [],
};
// If the user is tagged STAFF or EXPERT then we approve
// their comment.
//
// STAFF: all staff comments are automatically approved.
//
// EXPERT: when in Q&A mode, all expert comments are
// automatically approved. We will only see EXPERT
// tags assigned when we are in Q&A mode, so we can
// trust this simple tag type check.
if (
tags.some(tag => tag.type === GQLTAG.STAFF || tag.type === GQLTAG.EXPERT)
) {
result.status = GQLCOMMENT_STATUS.APPROVED;
}
return result;
};
@@ -1,5 +1,6 @@
import { IntermediateModerationPhase } from "coral-server/services/comments/pipeline";
import { approve } from "./approve";
import { commentingDisabled } from "./commentingDisabled";
import { commentLength } from "./commentLength";
import { detectLinks } from "./detectLinks";
@@ -13,6 +14,8 @@ import { repeatPost } from "./repeatPost";
import { spam } from "./spam";
import { staff } from "./staff";
import { storyClosed } from "./storyClosed";
import { tagExpertAnswers } from "./tagExpertAnswers";
import { tagUnansweredQuestions } from "./tagUnansweredQuestions";
import { toxic } from "./toxic";
import { wordList } from "./wordList";
@@ -27,7 +30,10 @@ export const moderationPhases: IntermediateModerationPhase[] = [
purify,
repeatPost,
wordList,
tagExpertAnswers,
staff,
tagUnansweredQuestions,
approve,
toxic,
recentCommentHistory,
spam,
@@ -1,5 +1,4 @@
import {
GQLCOMMENT_STATUS,
GQLTAG,
GQLUSER_ROLE,
} from "coral-server/graph/schema/__generated__/types";
@@ -15,7 +14,6 @@ export const staff: IntermediateModerationPhase = ({
}): IntermediatePhaseResult | void => {
if (author.role !== GQLUSER_ROLE.COMMENTER) {
return {
status: GQLCOMMENT_STATUS.APPROVED,
tags: [
{
type: GQLTAG.STAFF,
@@ -0,0 +1,34 @@
import {
IntermediateModerationPhase,
IntermediatePhaseResult,
} from "coral-server/services/comments/pipeline";
import {
GQLSTORY_MODE,
GQLTAG,
} from "coral-server/graph/schema/__generated__/types";
export const tagExpertAnswers: IntermediateModerationPhase = ({
author,
now,
story,
}): IntermediatePhaseResult | void => {
if (
story.settings.mode === GQLSTORY_MODE.QA &&
story.settings.expertIDs &&
story.settings.expertIDs.some(id => id === author.id)
) {
return {
tags: [
{
type: GQLTAG.EXPERT,
createdAt: now,
},
{
type: GQLTAG.FEATURED,
createdAt: now,
},
],
};
}
};
@@ -0,0 +1,42 @@
import {
IntermediateModerationPhase,
IntermediatePhaseResult,
} from "coral-server/services/comments/pipeline";
import {
GQLSTORY_MODE,
GQLTAG,
} from "coral-server/graph/schema/__generated__/types";
export const tagUnansweredQuestions: IntermediateModerationPhase = ({
comment,
story,
now,
}): IntermediatePhaseResult | void => {
// We only show unanswered tags in Q&A.
if (story.settings.mode !== GQLSTORY_MODE.QA) {
return;
}
// Only top level comments are questions,
// everything else is replies for Q&A.
if (comment.parentID) {
return;
}
// If we have no experts, or the current author is
// not an expert, then this is an UNANSWERED comment.
if (
!story.settings.expertIDs ||
story.settings.expertIDs.every(id => id !== comment.authorID)
) {
return {
tags: [
{
type: GQLTAG.UNANSWERED,
createdAt: now,
},
],
};
}
};
@@ -6,7 +6,7 @@ import { Config } from "coral-server/config";
import { Logger } from "coral-server/logger";
import { CreateActionInput } from "coral-server/models/action/comment";
import {
EditCommentInput,
CreateCommentInput,
RevisionMetadata,
} from "coral-server/models/comment";
import { CommentTag } from "coral-server/models/comment/tag";
@@ -40,7 +40,7 @@ export interface ModerationPhaseContextInput {
log: Logger;
story: Story;
tenant: Tenant;
comment: RequireProperty<Partial<EditCommentInput>, "body">;
comment: RequireProperty<Partial<CreateCommentInput>, "body">;
author: User;
now: Date;
action: "NEW" | "EDIT";
@@ -64,6 +64,7 @@ export type IntermediatePhaseResult = Partial<PhaseResult> | void;
export interface IntermediateModerationPhaseContext
extends ModerationPhaseContext {
metadata: RevisionMetadata;
tags: CommentTag[];
}
export type IntermediateModerationPhase = (
@@ -103,6 +104,7 @@ export const compose = (
...context.comment,
body: final.body,
},
tags: final.tags,
htmlStripped,
metadata: final.metadata,
});
+44 -1
View File
@@ -3,7 +3,7 @@ import { Db } from "mongodb";
import isNonNullArray from "coral-common/helpers/isNonNullArray";
import { Config } from "coral-server/config";
import { StoryURLInvalidError } from "coral-server/errors";
import { StoryURLInvalidError, UserNotFoundError } from "coral-server/errors";
import { StoryCreatedCoralEvent } from "coral-server/events";
import { CoralEventPublisherBroker } from "coral-server/events/publisher";
import logger from "coral-server/logger";
@@ -20,6 +20,7 @@ import {
removeStoryComments,
} from "coral-server/models/comment";
import {
addExpert,
closeStory,
createStory,
CreateStoryInput,
@@ -28,10 +29,12 @@ import {
findStory,
FindStoryInput,
openStory,
removeExpert,
removeStories,
removeStory,
retrieveManyStories,
retrieveStory,
setStoryMode,
Story,
updateStory,
updateStoryCounts,
@@ -40,10 +43,13 @@ import {
UpdateStorySettingsInput,
} from "coral-server/models/story";
import { Tenant } from "coral-server/models/tenant";
import { retrieveUser } from "coral-server/models/user";
import { ScraperQueue } from "coral-server/queue/tasks/scraper";
import { findSiteByURL } from "coral-server/services/sites";
import { scrape } from "coral-server/services/stories/scraper";
import { GQLSTORY_MODE } from "coral-server/graph/schema/__generated__/types";
export type FindStory = FindStoryInput;
export async function find(mongo: Db, tenant: Tenant, input: FindStory) {
@@ -367,3 +373,40 @@ export async function merge(
// Return the story that had the other stories merged into.
return destinationStory;
}
export async function addStoryExpert(
mongo: Db,
tenant: Tenant,
storyID: string,
userID: string
) {
const user = await retrieveUser(mongo, tenant.id, userID);
if (!user) {
throw new UserNotFoundError(userID);
}
return addExpert(mongo, tenant.id, storyID, userID);
}
export async function removeStoryExpert(
mongo: Db,
tenant: Tenant,
storyID: string,
userID: string
) {
const user = await retrieveUser(mongo, tenant.id, userID);
if (!user) {
throw new UserNotFoundError(userID);
}
return removeExpert(mongo, tenant.id, storyID, userID);
}
export async function updateStoryMode(
mongo: Db,
tenant: Tenant,
storyID: string,
mode: GQLSTORY_MODE
) {
return setStoryMode(mongo, tenant.id, storyID, mode);
}
+69
View File
@@ -16,6 +16,7 @@ import {
filterDuplicateActions,
} from "coral-server/models/action/comment";
import {
Comment,
createComment,
CreateCommentInput,
pushChildCommentIDOntoParent,
@@ -28,10 +29,12 @@ import {
} from "coral-server/models/comment/helpers";
import {
retrieveStory,
Story,
updateStoryLastCommentedAt,
} from "coral-server/models/story";
import { Tenant } from "coral-server/models/tenant";
import { User } from "coral-server/models/user";
import { removeTag } from "coral-server/services/comments";
import {
addCommentActions,
CreateAction,
@@ -48,6 +51,12 @@ import { AugmentedRedis } from "coral-server/services/redis";
import { updateUserLastCommentID } from "coral-server/services/users";
import { Request } from "coral-server/types/express";
import {
GQLSTORY_MODE,
GQLTAG,
} from "coral-server/graph/schema/__generated__/types";
import approveComment from "./approveComment";
import { publishChanges, updateAllCommentCounts } from "./helpers";
export type CreateComment = Omit<
@@ -55,6 +64,55 @@ export type CreateComment = Omit<
"status" | "metadata" | "ancestorIDs" | "actionCounts" | "tags" | "siteID"
>;
const markCommentAsAnswered = async (
mongo: Db,
redis: AugmentedRedis,
config: Config,
broker: CoralEventPublisherBroker,
tenant: Tenant,
comment: Readonly<Comment>,
story: Story,
author: User,
now: Date
) => {
// We only process this if we're in Q&A mode.
if (story.settings.mode !== GQLSTORY_MODE.QA) {
return;
}
// Answers are always a reply to another comment.
// If we have a parentID and a parentRevisionID, then
// we have a parent, which means we are replying.
if (!comment.parentID || !comment.parentRevisionID) {
return;
}
// If we have no experts, there cannot be anyone
// providing expert answers.
if (!story.settings.expertIDs) {
return;
}
// If we have experts and this reply is created by
// one of them, then this is an expert's answer.
if (story.settings.expertIDs.some(id => id === author.id)) {
// We need to mark this question as answered now.
// We can now remove the unanswered tag.
await removeTag(mongo, tenant, comment.parentID, GQLTAG.UNANSWERED);
await approveComment(
mongo,
redis,
config,
broker,
tenant,
comment.parentID,
comment.parentRevisionID,
author.id,
now
);
}
};
export default async function create(
mongo: Db,
redis: AugmentedRedis,
@@ -180,6 +238,17 @@ export default async function create(
await Promise.all([
updateUserLastCommentID(redis, tenant, author, comment.id),
updateStoryLastCommentedAt(mongo, tenant.id, story.id, now),
markCommentAsAnswered(
mongo,
redis,
config,
broker,
tenant,
comment,
story,
author,
now
),
]);
// Pull the revision out.
+5 -1
View File
@@ -111,7 +111,11 @@ export default async function edit(
config,
story,
tenant,
comment: input,
comment: {
...originalStaleComment,
...input,
authorID: author.id,
},
author,
req,
now,
+71
View File
@@ -168,6 +168,47 @@ comments-rejectedTombstone =
comments-featuredTag = Featured
### Q&A
general-tabBar-qaTab = Q&A
qa-answeredTab = Answered
qa-allCommentsTab = All
qa-noQuestionsAtAll =
There are no questions on this story.
qa-noQuestionsYet =
There are no questions yet. Why don't you ask one?
qa-viewNew =
{ $count ->
[1] View {$count} New Question
*[other] View {$count} New Questions
}
qa-postQuestionForm-rteLabel = Post a question
qa-postQuestionForm-rte =
.placeholder = { qa-postQuestionForm-rteLabel }
qa-postQuestionFormFake-rte =
.placeholder = { qa-postQuestionForm-rteLabel }
qa-sortMenu-mostVoted = Most Voted
qa-answered-tag = answered
qa-expert-tag = expert
qa-reaction-vote = Vote
qa-reaction-voted = Voted
qa-unansweredTab-doneAnswering = Done
qa-expert-email = ({ $email })
qa-answeredTooltip-how = How is a question answered?
qa-answeredTooltip-answeredComments =
Questions are answered by a Q&A expert.
qa-answeredTooltip-toggleButton =
.aria-label = Toggle answered questions tooltip
### Account Deletion Stream
comments-stream-deleteAccount-callOut-title =
@@ -397,6 +438,8 @@ profile-changeUsername-close = Close
## Comment Stream
configure-stream-title = Configure this Comment Stream
configure-stream-title-configureThisStream =
Configure this Stream
configure-stream-apply = Apply
configure-premod-title = Enable Pre-Moderation
@@ -444,6 +487,34 @@ configure-openStream-openStream = Open Stream
configure-moderateThisStream = Moderate this stream
configure-enableQA-title = Switch to Q&A Format
configure-enableQA-description =
The Q&A format allows community members to submit questions for chosen
experts to answer.
configure-enableQA-enableQA = Switch to Q&A
configure-disableQA-title = Configure this Q&A
configure-disableQA-description =
The Q&A format allows community members to submit questions for chosen
experts to answer.
configure-disableQA-disableQA = Switch to Comments
configure-experts-title = Add an Expert
configure-experts-filter-searchField =
.placeholder = Search by email or username
.aria-label = Search by email or username
configure-experts-filter-searchButton =
.aria-label = Search
configure-experts-filter-description =
Adds an Expert Badge to comments by registered users, only on this
page. New users must first sign up and open the comments on a page
to create their account.
configure-experts-search-none-found = No users were found with that email or username
configure-experts-remove-button = Remove
configure-experts-load-more = Load More
configure-experts-none-yet = There are currently no experts for this Q&A.
configure-experts-assigned-title = Experts
comments-tombstone-ignore = This comment is hidden because you ignored {$username}
comments-tombstone-deleted =
This comment is no longer available. The commenter has deleted their account.