[CORL-1150] Discussions Tab (#3050)

* feat: Added Site.topStories edge

* feat: Added User.ongoingDiscussions edge

* connect discussions tab to relay

* style discussions tab

* add message if no ongoing discussions

* style site name in story row

* fix: make line-heights relative

* add custom class hooks

* feat: condition display based on feature flag

* add target attribute to story links

* fix: expose some featureFlags

* fix: fixed sorting

* fix: copy fixes

Co-authored-by: tessalt <tessathornton@gmail.com>
Co-authored-by: Chi Vinh Le <vinh@vinh.tech>
This commit is contained in:
Wyatt Johnson
2020-07-30 17:18:59 -04:00
committed by GitHub
co-authored by tessalt Chi Vinh Le
parent 9a9c92d360
commit dfdea2b9d2
25 changed files with 886 additions and 48 deletions
+9 -1
View File
@@ -11,12 +11,13 @@ import {
import Comments from "../tabs/Comments";
import Configure from "../tabs/Configure";
import Discussions from "../tabs/Discussions";
import Profile from "../tabs/Profile";
import TabBarQuery from "./TabBarQuery";
import styles from "./App.css";
type TabValue = "COMMENTS" | "PROFILE" | "%future added value";
type TabValue = "COMMENTS" | "PROFILE" | "DISCUSSIONS" | "%future added value";
export interface AppProps {
activeTab: TabValue;
@@ -38,6 +39,13 @@ const App: FunctionComponent<AppProps> = (props) => {
>
<Comments />
</TabPane>
<TabPane
className={CLASSES.discussionsTabPane.$root}
tabID="DISCUSSIONS"
data-testid="current-tab-pane"
>
<Discussions />
</TabPane>
<TabPane
className={CLASSES.myProfileTabPane.$root}
tabID="PROFILE"
@@ -5,7 +5,7 @@ import { createMutationContainer, LOCAL_ID } from "coral-framework/lib/relay";
import { SetMainTabEvent } from "coral-stream/events";
export interface SetActiveTabInput {
tab: "COMMENTS" | "PROFILE" | "%future added value";
tab: "COMMENTS" | "PROFILE" | "DISCUSSIONS" | "%future added value";
}
export type SetActiveTabMutation = (input: SetActiveTabInput) => Promise<void>;
+28 -1
View File
@@ -8,12 +8,13 @@ import { Icon, MatchMedia, Tab, TabBar } from "coral-ui/components/v2";
import styles from "./TabBar.css";
type TabValue = "COMMENTS" | "PROFILE" | "%future added value";
type TabValue = "COMMENTS" | "PROFILE" | "DISCUSSIONS" | "%future added value";
export interface Props {
activeTab: TabValue;
onTabClick: (tab: TabValue) => void;
showProfileTab: boolean;
showDiscussionsTab: boolean;
showConfigureTab: boolean;
mode: "%future added value" | "COMMENTS" | "QA" | null;
}
@@ -69,6 +70,31 @@ const AppTabBar: FunctionComponent<Props> = (props) => {
)}
</Tab>
{props.showDiscussionsTab && (
<Tab
className={cn(CLASSES.tabBar.discussions, {
[CLASSES.tabBar.activeTab]: props.activeTab === "DISCUSSIONS",
[styles.smallTab]: !matches,
})}
tabID="DISCUSSIONS"
variant="streamPrimary"
localizationId="general-tabBar-aria-discussions"
>
{matches ? (
<Localized id="general-tabBar-discussionsTab">
<span>Discussions</span>
</Localized>
) : (
<div>
<Icon size="lg">list_alt</Icon>
<Localized id="general-tabBar-discussionsTab">
<div className={styles.smallText}>Discussions</div>
</Localized>
</div>
)}
</Tab>
)}
{props.showProfileTab && (
<Tab
className={cn(CLASSES.tabBar.myProfile, {
@@ -93,6 +119,7 @@ const AppTabBar: FunctionComponent<Props> = (props) => {
)}
</Tab>
)}
{props.showConfigureTab && (
<Tab
className={cn(CLASSES.tabBar.configure, {
+53 -36
View File
@@ -1,16 +1,17 @@
import React, { Component } from "react";
import React, { FunctionComponent, useCallback, useMemo } from "react";
import { graphql } from "react-relay";
import {
withFragmentContainer,
withLocalStateContainer,
} from "coral-framework/lib/relay";
import { GQLSTORY_MODE } from "coral-framework/schema";
import { GQLFEATURE_FLAG, GQLSTORY_MODE } from "coral-framework/schema";
import { Ability, can } from "coral-stream/permissions";
import { TabBarContainer_settings } from "coral-stream/__generated__/TabBarContainer_settings.graphql";
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";
import { TabBarContainer_viewer } from "coral-stream/__generated__/TabBarContainer_viewer.graphql";
import { TabBarContainerLocal } from "coral-stream/__generated__/TabBarContainerLocal.graphql";
import {
SetActiveTabInput,
@@ -21,43 +22,54 @@ import TabBar from "./TabBar";
interface Props {
story: TabBarContainer_story | null;
viewer: ViewerData | null;
local: Local;
settings: TabBarContainer_settings | null;
viewer: TabBarContainer_viewer | null;
local: TabBarContainerLocal;
setActiveTab: SetActiveTabMutation;
}
export class TabBarContainer extends Component<Props> {
private handleSetActiveTab = (tab: SetActiveTabInput["tab"]) => {
void this.props.setActiveTab({ tab });
};
export const TabBarContainer: FunctionComponent<Props> = ({
local: { activeTab },
viewer,
story,
settings,
setActiveTab,
}) => {
const handleSetActiveTab = useCallback(
(tab: SetActiveTabInput["tab"]) => {
void setActiveTab({ tab });
},
[setActiveTab]
);
public render() {
const {
local: { activeTab },
viewer,
story,
} = this.props;
const showDiscussionsTab = useMemo(
() =>
!!viewer &&
!!settings &&
settings.featureFlags.includes(GQLFEATURE_FLAG.DISCUSSIONS),
[viewer, settings]
);
return (
<TabBar
mode={
this.props.story
? this.props.story.settings.mode
: GQLSTORY_MODE.COMMENTS
}
activeTab={activeTab}
showProfileTab={Boolean(viewer)}
showConfigureTab={
!!viewer &&
!!story &&
story.canModerate &&
can(viewer, Ability.CHANGE_STORY_CONFIGURATION)
}
onTabClick={this.handleSetActiveTab}
/>
);
}
}
const showConfigureTab = useMemo(
() =>
!!viewer &&
!!story &&
story.canModerate &&
can(viewer, Ability.CHANGE_STORY_CONFIGURATION),
[viewer, story]
);
return (
<TabBar
mode={story ? story.settings.mode : GQLSTORY_MODE.COMMENTS}
activeTab={activeTab}
showProfileTab={!!viewer}
showDiscussionsTab={showDiscussionsTab}
showConfigureTab={showConfigureTab}
onTabClick={handleSetActiveTab}
/>
);
};
const enhanced = withSetActiveTabMutation(
withLocalStateContainer(
@@ -81,6 +93,11 @@ const enhanced = withSetActiveTabMutation(
}
}
`,
settings: graphql`
fragment TabBarContainer_settings on Settings {
featureFlags
}
`,
})(TabBarContainer)
)
);
@@ -28,6 +28,9 @@ class TabBarQuery extends Component<Props> {
story(id: $storyID, url: $storyURL) {
...TabBarContainer_story
}
settings {
...TabBarContainer_settings
}
}
`}
variables={{
@@ -41,6 +44,7 @@ class TabBarQuery extends Component<Props> {
return (
<TabBarContainer
settings={(props && props.settings) || null}
story={(props && props.story) || null}
viewer={(props && props.viewer) || null}
/>
@@ -24,6 +24,13 @@ exports[`renders correctly 1`] = `
>
<CommentsPane />
</TabPane>
<TabPane
className="coral coral-discussions"
data-testid="current-tab-pane"
tabID="DISCUSSIONS"
>
<withContext(withLocalStateContainer(DiscussionsQuery)) />
</TabPane>
<TabPane
className="coral coral-myProfile"
data-testid="current-tab-pane"
+24
View File
@@ -60,6 +60,8 @@ const CLASSES = {
*/
myProfile: "coral coral-tabBar-tab coral-tabBar-myProfile",
discussions: "coral coral-tabBar-tab coral-tabBar-discussions",
/**
* configure is the button for the "Configure" tab.
*/
@@ -714,6 +716,10 @@ const CLASSES = {
$root: "coral coral-myProfile",
},
discussionsTabPane: {
$root: "coral coral-discussions",
},
/**
* myUsername is the username part of my profile.
*/
@@ -981,6 +987,24 @@ const CLASSES = {
},
moderateStream: "coral coral-general-moderateStreamLink",
discussions: {
$root: "coral coral-discussions",
mostActiveDiscussions: "coral coral-mostActiveDiscussions",
myOngoingDiscussions: "coral coral-myOngoingDiscussions",
header: "coral coral-discussions-header",
subHeader: "coral coral-discussions-subHeader",
discussionsList: "coral coral-discussions-list",
story: {
$root: "coral coral-discussions-story",
header: "coral coral-discussions-story-header",
commentsCount: "coral coral-discussions-story-commentsCount",
commentsCountIcon: "coral coral-discussions-story-commentsCountIcon",
date: "coral coral-discussions-story-date",
siteName: "coral coral-discussions-story-siteName",
},
viewHistoryButton: "coral coral-discussions-viewHistoryButton",
},
};
export default CLASSES;
@@ -0,0 +1,85 @@
import { Localized } from "@fluent/react/compat";
import React, { FunctionComponent, useCallback } from "react";
import { graphql } from "react-relay";
import {
createMutation,
useMutation,
withFragmentContainer,
} from "coral-framework/lib/relay";
import CLASSES from "coral-stream/classes";
import UserBoxContainer from "coral-stream/common/UserBox";
import { Button, HorizontalGutter } from "coral-ui/components/v2";
import { DiscussionsContainer_settings } from "coral-stream/__generated__/DiscussionsContainer_settings.graphql";
import { DiscussionsContainer_story } from "coral-stream/__generated__/DiscussionsContainer_story.graphql";
import { DiscussionsContainer_viewer } from "coral-stream/__generated__/DiscussionsContainer_viewer.graphql";
import { commit } from "../../App/SetActiveTabMutation";
import MostActiveDiscussionsContainer from "./MostActiveDiscussionsContainer";
import MyOngoingDiscussionsContainer from "./MyOngoingDiscussionsContainer";
interface Props {
viewer: DiscussionsContainer_viewer;
settings: DiscussionsContainer_settings;
story: DiscussionsContainer_story;
}
const SetActiveTabMutation = createMutation("setActiveTab", commit);
const DiscussionsContainer: FunctionComponent<Props> = (props) => {
const setActiveTab = useMutation(SetActiveTabMutation);
const onFullHistoryClick = useCallback(
async () => await setActiveTab({ tab: "PROFILE" }),
[]
);
return (
<HorizontalGutter spacing={3} className={CLASSES.discussions.$root}>
<UserBoxContainer settings={props.settings} viewer={props.viewer} />
<MostActiveDiscussionsContainer site={props.story.site} />
<MyOngoingDiscussionsContainer
viewer={props.viewer}
currentSiteID={props.story.site.id}
settings={props.settings}
/>
<Localized id="discussions-viewFullHistory">
<Button
variant="outline"
color="stream"
onClick={onFullHistoryClick}
className={CLASSES.discussions.viewHistoryButton}
>
View full comment history
</Button>
</Localized>
</HorizontalGutter>
);
};
const enhanced = withFragmentContainer<Props>({
story: graphql`
fragment DiscussionsContainer_story on Story {
site {
id
...MostActiveDiscussionsContainer_site
}
}
`,
viewer: graphql`
fragment DiscussionsContainer_viewer on User {
...MyOngoingDiscussionsContainer_viewer
...UserBoxContainer_viewer
}
`,
settings: graphql`
fragment DiscussionsContainer_settings on Settings {
...MyOngoingDiscussionsContainer_settings
...UserBoxContainer_settings
organization {
name
}
}
`,
})(DiscussionsContainer);
export default enhanced;
@@ -0,0 +1,19 @@
.root {
border-bottom: 2px solid var(--palette-grey-200);
padding-bottom: var(--spacing-2);
font-family: var(--font-family-primary);
}
.header {
font-weight: var(--font-weight-primary-bold);
font-size: var(--font-size-4);
line-height: 1;
color: var(--palette-text-900);
margin: 0;
}
.subHeader {
color: var(--palette-text-100);
font-size: var(--font-size-2);
line-height: 1.285;
}
@@ -0,0 +1,36 @@
import cn from "classnames";
import React, { FunctionComponent } from "react";
import CLASSES from "coral-stream/classes";
import { Flex, HorizontalGutter, Icon } from "coral-ui/components/v2";
import styles from "./DiscussionsHeader.css";
interface Props {
header: React.ReactNode;
subHeader: React.ReactNode;
icon: string;
}
const DiscussionsHeader: FunctionComponent<Props> = ({
header,
subHeader,
icon,
}) => {
return (
<HorizontalGutter spacing={1} className={styles.root}>
<Flex spacing={1} alignItems="center">
<Icon size="md">{icon}</Icon>
<h2 className={cn(styles.header, CLASSES.discussions.header)}>
{header}
</h2>
</Flex>
<div className={cn(styles.subHeader, CLASSES.discussions.subHeader)}>
{subHeader}
</div>
</HorizontalGutter>
);
};
export default DiscussionsHeader;
@@ -0,0 +1,126 @@
import { Localized } from "@fluent/react/compat";
import { once } from "lodash";
import React, { FunctionComponent, Suspense } from "react";
import { graphql } from "react-relay";
import { polyfillCSSVarsForIE11 } from "coral-framework/helpers";
import {
QueryRenderData,
QueryRenderer,
withLocalStateContainer,
} from "coral-framework/lib/relay";
import useHandleIncompleteAccount from "coral-stream/common/useHandleIncompleteAccount";
import { CallOut, Delay, Spinner } from "coral-ui/components/v2";
import { DiscussionsQuery as QueryTypes } from "coral-stream/__generated__/DiscussionsQuery.graphql";
import { DiscussionsQueryLocal as Local } from "coral-stream/__generated__/DiscussionsQueryLocal.graphql";
const loadDiscussionsContainer = () =>
import("./DiscussionsContainer" /* webpackChunkName: "profile" */).then(
(x) => {
// New css is loaded, take care of polyfilling those css vars for IE11.
void polyfillCSSVarsForIE11();
return x;
}
);
// (cvle) For some reason without `setTimeout` this request will block other requests.
const preloadDiscussionsContainer = once(() =>
setTimeout(loadDiscussionsContainer)
);
const LazyDiscussionsContainer = React.lazy(loadDiscussionsContainer);
interface Props {
local: Local;
}
export const render = ({ error, props }: QueryRenderData<QueryTypes>) => {
if (error) {
return (
<CallOut color="error" fullWidth>
{error.message}
</CallOut>
);
}
// TODO: use official React API once it has one :-)
preloadDiscussionsContainer();
if (props) {
if (!props.viewer) {
return (
<Localized id="discussions-discussionsQuery-errorLoadingProfile">
<CallOut color="error" fullWidth>
Error loading profile
</CallOut>
</Localized>
);
}
if (!props.story) {
return (
<Localized id="discussions-discussionsQuery-storyNotFound">
<CallOut>Story not found</CallOut>
</Localized>
);
}
return (
<Suspense fallback={<Spinner />}>
<LazyDiscussionsContainer
viewer={props.viewer}
story={props.story}
settings={props.settings}
/>
</Suspense>
);
}
return (
<Delay>
<Spinner />
</Delay>
);
};
const DiscussionsQuery: FunctionComponent<Props> = ({
local: { storyID, storyURL },
}) => {
const handleIncompleteAccount = useHandleIncompleteAccount();
return (
<QueryRenderer<QueryTypes>
query={graphql`
query DiscussionsQuery($storyID: ID, $storyURL: String) {
story: stream(id: $storyID, url: $storyURL) {
...DiscussionsContainer_story
}
viewer {
...DiscussionsContainer_viewer
}
settings {
...DiscussionsContainer_settings
}
}
`}
variables={{
storyID,
storyURL,
}}
render={(data) => {
if (handleIncompleteAccount(data)) {
return null;
}
return render(data);
}}
/>
);
};
const enhanced = withLocalStateContainer(
graphql`
fragment DiscussionsQueryLocal on Local {
storyID
storyURL
}
`
)(DiscussionsQuery);
export default enhanced;
@@ -0,0 +1,25 @@
.root {
font-family: var(--font-family-primary);
}
.list {
margin-left: 0;
padding-left: 0;
counter-reset: activeDiscussionsCounter;
list-style: none;
}
.listItem {
counter-increment: activeDiscussionsCounter;
display: flex;
align-items: flex-start;
}
.listItem:before {
content: counter(activeDiscussionsCounter);
color: var(--palette-text-100);
font-size: var(--font-size-3);
font-weight: var(--font-weight-primary-bold);
line-height: 1.125;
padding-right: var(--spacing-3);
}
@@ -0,0 +1,69 @@
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";
import CLASSES from "coral-stream/classes";
import { MostActiveDiscussionsContainer_site } from "coral-stream/__generated__/MostActiveDiscussionsContainer_site.graphql";
import DiscussionsHeader from "./DiscussionsHeader";
import StoryRowContainer from "./StoryRowContainer";
import styles from "./MostActiveDiscussionsContainer.css";
interface Props {
site: MostActiveDiscussionsContainer_site;
}
const MostActiveDiscussionsContainer: FunctionComponent<Props> = ({ site }) => {
return (
<div className={cn(styles.root, CLASSES.discussions.mostActiveDiscussions)}>
<DiscussionsHeader
header={
<Localized id="discussions-mostActiveDiscussions">
Most active discussions
</Localized>
}
subHeader={
<Localized
id="discussions-mostActiveDiscussions-subhead"
$siteName={site.name}
>
<>
Ranked by the most comments received over the last 24 hours on{" "}
{site.name}
</>
</Localized>
}
icon="show_chart"
/>
<ol className={cn(styles.list, CLASSES.discussions.discussionsList)}>
{site.topStories.map((story) => (
<li
className={cn(styles.listItem, CLASSES.discussions.story.$root)}
key={story.id}
>
<StoryRowContainer story={story} currentSiteID={site.id} />
</li>
))}
</ol>
</div>
);
};
const enhanced = withFragmentContainer<Props>({
site: graphql`
fragment MostActiveDiscussionsContainer_site on Site {
id
name
topStories {
id
...StoryRowContainer_story
}
}
`,
})(MostActiveDiscussionsContainer);
export default enhanced;
@@ -0,0 +1,14 @@
.root {
font-family: var(--font-family-primary);
}
.list {
margin-left: 0;
padding-left: 0;
}
.emptyList {
margin: var(--spacing-3) 0 0 0;
color: var(--palette-text-100);
font-weight: var(--font-weight-primary-semi-bold);
}
@@ -0,0 +1,82 @@
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";
import CLASSES from "coral-stream/classes";
import { MyOngoingDiscussionsContainer_settings } from "coral-stream/__generated__/MyOngoingDiscussionsContainer_settings.graphql";
import { MyOngoingDiscussionsContainer_viewer } from "coral-stream/__generated__/MyOngoingDiscussionsContainer_viewer.graphql";
import DiscussionsHeader from "./DiscussionsHeader";
import StoryRowContainer from "./StoryRowContainer";
import styles from "./MyOngoingDiscussionsContainer.css";
interface Props {
viewer: MyOngoingDiscussionsContainer_viewer;
settings: MyOngoingDiscussionsContainer_settings;
currentSiteID: string;
}
const MyOngoingDiscussionsContainer: FunctionComponent<Props> = ({
viewer,
settings,
currentSiteID,
}) => {
return (
<div className={cn(styles.root, CLASSES.discussions.myOngoingDiscussions)}>
<DiscussionsHeader
header={
<Localized id="discussions-myOngoingDiscussions">
My ongoing discussions
</Localized>
}
subHeader={
<Localized
id="discussions-myOngoingDiscussions-subhead"
$orgName={settings.organization.name}
>
<>Where youve commented across {settings.organization.name}</>
</Localized>
}
icon="history"
/>
{viewer.ongoingDiscussions.length === 0 && (
<Localized id="discussions-mostActiveDiscussions-empty">
<p className={styles.emptyList}>
You havent participated in any discussions
</p>
</Localized>
)}
<ul className={cn(styles.list, CLASSES.discussions.discussionsList)}>
{viewer.ongoingDiscussions.map((story) => (
<li key={cn(story.id, CLASSES.discussions.story.$root)}>
<StoryRowContainer story={story} currentSiteID={currentSiteID} />
</li>
))}
</ul>
</div>
);
};
const enhanced = withFragmentContainer<Props>({
viewer: graphql`
fragment MyOngoingDiscussionsContainer_viewer on User {
ongoingDiscussions {
id
...StoryRowContainer_story
}
}
`,
settings: graphql`
fragment MyOngoingDiscussionsContainer_settings on Settings {
organization {
name
}
}
`,
})(MyOngoingDiscussionsContainer);
export default enhanced;
@@ -0,0 +1,37 @@
.root {
text-decoration: none;
font-family: var(--font-family-primary);
color: var(--palette-text-100);
margin-bottom: var(--spacing-4);
display: block;
}
.siteName {
text-transform: uppercase;
font-size: var(--font-size-1);
margin-bottom: 0;
font-weight: var(--font-weight-primary-bold);
}
.storyTitle {
font-weight: var(--font-weight-primary-semi-bold);
color: var(--palette-text-500);
font-size: var(--font-size-3);
line-height: 1.125;
}
.commentsCount {
color: var(--palette-primary-500);
font-weight: var(--font-weight-primary-bold);
font-size: var(--font-size-1);
}
.commentsCountIcon {
color: var(--palette-primary-400);
font-size: var(--font-size-1);
}
.time {
font-size: var(--font-size-1);
font-weight: var(--font-weight-primary-semi-bold);
}
@@ -0,0 +1,95 @@
import cn from "classnames";
import React, { FunctionComponent } from "react";
import { graphql } from "react-relay";
import { withFragmentContainer } from "coral-framework/lib/relay";
import CLASSES from "coral-stream/classes";
import {
Flex,
HorizontalGutter,
Icon,
RelativeTime,
} from "coral-ui/components/v2";
import { StoryRowContainer_story } from "coral-stream/__generated__/StoryRowContainer_story.graphql";
import styles from "./StoryRowContainer.css";
interface Props {
story: StoryRowContainer_story;
currentSiteID: string;
}
const StoryRowContainer: FunctionComponent<Props> = ({
story,
currentSiteID,
}) => {
return (
<a href={story.url} className={styles.root} target="_parent">
<HorizontalGutter spacing={1}>
{currentSiteID !== story.site.id && (
<p
className={cn(styles.siteName, CLASSES.discussions.story.siteName)}
>
{story.site.name}
</p>
)}
{story.metadata && story.metadata.title && (
<h3
className={cn(styles.storyTitle, CLASSES.discussions.story.header)}
>
{story.metadata.title}
</h3>
)}
<Flex spacing={3}>
{story.metadata && story.metadata.publishedAt && (
<RelativeTime
date={story.metadata.publishedAt}
className={cn(styles.time, CLASSES.discussions.story.date)}
/>
)}
<Flex spacing={1} alignItems="center">
<Icon
className={cn(
styles.commentsCountIcon,
CLASSES.discussions.story.commentsCountIcon
)}
>
mode_comment
</Icon>
<span
className={cn(
styles.commentsCount,
CLASSES.discussions.story.commentsCount
)}
>
{story.commentCounts.totalPublished}
</span>
</Flex>
</Flex>
</HorizontalGutter>
</a>
);
};
const enhanced = withFragmentContainer<Props>({
story: graphql`
fragment StoryRowContainer_story on Story {
site {
id
name
}
id
url
metadata {
title
publishedAt
}
commentCounts {
totalPublished
}
}
`,
})(StoryRowContainer);
export default enhanced;
@@ -0,0 +1 @@
export { default } from "./DiscussionsQuery";