mirror of
https://github.com/wassname/talk.git
synced 2026-06-27 16:32:15 +08:00
Q&A Beta (#2845)
* [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:
Vendored
+1
-1
@@ -54,5 +54,5 @@
|
||||
"search.exclude": {
|
||||
"package-lock.json": true
|
||||
},
|
||||
"debug.node.autoAttach": "off"
|
||||
"debug.node.autoAttach": "on"
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
+2
@@ -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;
|
||||
}
|
||||
|
||||
+9
-1
@@ -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 {
|
||||
|
||||
+91
@@ -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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
+18
-16
@@ -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;
|
||||
+45
@@ -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);
|
||||
}
|
||||
+221
@@ -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}>></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;
|
||||
+180
@@ -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;
|
||||
+107
@@ -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"
|
||||
|
||||
+2
@@ -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
|
||||
|
||||
+1
@@ -30,6 +30,7 @@ function createDefaultProps(add: DeepPartial<Props> = {}): Props {
|
||||
messageBox: {
|
||||
enabled: false,
|
||||
},
|
||||
mode: "COMMENTS",
|
||||
},
|
||||
},
|
||||
sessionStorage: createPromisifiedStorage(),
|
||||
|
||||
+1
@@ -295,6 +295,7 @@ const enhanced = withContext(({ sessionStorage }) => ({
|
||||
messageBox {
|
||||
enabled
|
||||
}
|
||||
mode
|
||||
}
|
||||
}
|
||||
`,
|
||||
|
||||
+16
-3
@@ -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}
|
||||
|
||||
+7
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+64
@@ -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;
|
||||
+92
@@ -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;
|
||||
+92
@@ -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;
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
.stream > *:not(:first-child):not(button) {
|
||||
border-top: 1px solid var(--palette-divider);
|
||||
padding-top: var(--spacing-3)
|
||||
}
|
||||
+334
@@ -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;
|
||||
+99
@@ -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;
|
||||
+51
@@ -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";
|
||||
+10
-2
@@ -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]}
|
||||
|
||||
+15
@@ -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"
|
||||
|
||||
+25
-8
@@ -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"
|
||||
|
||||
+18
@@ -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"
|
||||
|
||||
+6
@@ -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"
|
||||
|
||||
+11
-4
@@ -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"
|
||||
|
||||
+3
@@ -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"
|
||||
>
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 [];
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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])
|
||||
}
|
||||
|
||||
##################
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -111,7 +111,11 @@ export default async function edit(
|
||||
config,
|
||||
story,
|
||||
tenant,
|
||||
comment: input,
|
||||
comment: {
|
||||
...originalStaleComment,
|
||||
...input,
|
||||
authorID: author.id,
|
||||
},
|
||||
author,
|
||||
req,
|
||||
now,
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user