mirror of
https://github.com/wassname/talk.git
synced 2026-08-18 12:30:39 +08:00
Merge branch 'next' into user-status-refactor
This commit is contained in:
@@ -3,7 +3,6 @@ import PropTypes from 'prop-types';
|
||||
import {Router, Route, IndexRedirect, IndexRoute} from 'react-router';
|
||||
|
||||
import Configure from 'routes/Configure';
|
||||
import Dashboard from 'routes/Dashboard';
|
||||
import Install from 'routes/Install';
|
||||
import Stories from 'routes/Stories';
|
||||
import Community from 'routes/Community/containers/Community';
|
||||
@@ -18,7 +17,6 @@ const routes = (
|
||||
<IndexRedirect to='/admin/moderate' />
|
||||
<Route path='configure' component={Configure} />
|
||||
<Route path='stories' component={Stories} />
|
||||
<Route path='dashboard' component={Dashboard} />
|
||||
|
||||
{/* Community Routes */}
|
||||
|
||||
|
||||
@@ -12,20 +12,14 @@ const CoralDrawer = ({handleLogout, auth = {}}) => (
|
||||
{ auth && auth.user && can(auth.user, 'ACCESS_ADMIN') ?
|
||||
<div>
|
||||
<Navigation className={styles.nav}>
|
||||
<IndexLink
|
||||
className={cn('talk-admin-nav-dashboard', styles.navLink)}
|
||||
to="/admin/dashboard"
|
||||
activeClassName={styles.active}>
|
||||
{t('configure.dashboard')}
|
||||
</IndexLink>
|
||||
{
|
||||
can(auth.user, 'MODERATE_COMMENTS') && (
|
||||
<Link
|
||||
<IndexLink
|
||||
className={cn('talk-admin-nav-moderate', styles.navLink)}
|
||||
to="/admin/moderate"
|
||||
activeClassName={styles.active}>
|
||||
{t('configure.moderate')}
|
||||
</Link>
|
||||
</IndexLink>
|
||||
)
|
||||
}
|
||||
<Link
|
||||
|
||||
@@ -23,23 +23,16 @@ const CoralHeader = ({
|
||||
{
|
||||
auth && auth.user && can(auth.user, 'ACCESS_ADMIN') ?
|
||||
<Navigation className={styles.nav}>
|
||||
<IndexLink
|
||||
id='dashboardNav'
|
||||
className={cn('talk-admin-nav-dashboard', styles.navLink)}
|
||||
to="/admin/dashboard"
|
||||
activeClassName={styles.active}>
|
||||
{t('configure.dashboard')}
|
||||
</IndexLink>
|
||||
{
|
||||
can(auth.user, 'MODERATE_COMMENTS') && (
|
||||
<Link
|
||||
<IndexLink
|
||||
id='moderateNav'
|
||||
className={cn('talk-admin-nav-moderate', styles.navLink)}
|
||||
to="/admin/moderate"
|
||||
activeClassName={styles.active}>
|
||||
{t('configure.moderate')}
|
||||
{(root.premodCount !== 0 || root.reportedCount !== 0) && <Indicator />}
|
||||
</Link>
|
||||
</IndexLink>
|
||||
)
|
||||
}
|
||||
<Link
|
||||
|
||||
@@ -23,6 +23,6 @@ export default withQuery(gql`
|
||||
}
|
||||
`, {
|
||||
options: {
|
||||
pollInterval: 5000
|
||||
pollInterval: 10000
|
||||
}
|
||||
})(Header);
|
||||
|
||||
@@ -20,7 +20,7 @@ import update from 'immutability-helper';
|
||||
import {notify} from 'coral-framework/actions/notification';
|
||||
|
||||
const commentConnectionFragment = gql`
|
||||
fragment CoralAdmin_Moderation_CommentConnection on CommentConnection {
|
||||
fragment CoralAdmin_UserDetail_CommentConnection on CommentConnection {
|
||||
nodes {
|
||||
...${getDefinitionName(UserDetailComment.fragments.comment)}
|
||||
}
|
||||
@@ -198,7 +198,7 @@ export const withUserDetailQuery = withQuery(gql`
|
||||
author_id: $author_id,
|
||||
statuses: $statuses
|
||||
}) {
|
||||
...CoralAdmin_Moderation_CommentConnection
|
||||
...CoralAdmin_UserDetail_CommentConnection
|
||||
}
|
||||
...${getDefinitionName(UserDetailComment.fragments.root)}
|
||||
${getSlotFragmentSpreads(slots, 'root')}
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
// this is initialized here because
|
||||
// currently you have to reload the dashboard to get new stats
|
||||
// cleaner updates are planned in the future.
|
||||
const DASHBOARD_WINDOW_MINUTES = 5;
|
||||
let then = new Date();
|
||||
then.setMinutes(then.getMinutes() - DASHBOARD_WINDOW_MINUTES);
|
||||
|
||||
const initialState = {
|
||||
windowStart: then.toISOString(),
|
||||
windowEnd: new Date().toISOString(),
|
||||
};
|
||||
|
||||
export default function dashboard (state = initialState, _action) {
|
||||
return state;
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
import auth from './auth';
|
||||
import stories from './stories';
|
||||
import dashboard from './dashboard';
|
||||
import configure from './configure';
|
||||
import community from './community';
|
||||
import moderation from './moderation';
|
||||
@@ -13,7 +12,6 @@ import userDetail from './userDetail';
|
||||
export default {
|
||||
auth,
|
||||
banUserDialog,
|
||||
dashboard,
|
||||
configure,
|
||||
suspendUserDialog,
|
||||
userDetail,
|
||||
|
||||
@@ -1,47 +0,0 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import {Link} from 'react-router';
|
||||
import styles from './Widget.css';
|
||||
import t from 'coral-framework/services/i18n';
|
||||
|
||||
const ActivityWidget = ({assets}) => {
|
||||
return (
|
||||
<div className={styles.widget}>
|
||||
<h2 className={styles.heading}>{t('dashboard.most_conversations')}</h2>
|
||||
<div className={styles.widgetHead}>
|
||||
<p>{t('streams.article')}</p>
|
||||
<p>{t('dashboard.comment_count')}</p>
|
||||
</div>
|
||||
<div className={styles.widgetTable}>
|
||||
{
|
||||
assets.length
|
||||
? assets.map((asset) => {
|
||||
return (
|
||||
<div className={styles.rowLinkify} key={asset.id}>
|
||||
<Link className={styles.linkToModerate} to={`/admin/moderate/${asset.id}`}>Moderate</Link>
|
||||
<p className={styles.widgetCount}>{asset.commentCount}</p>
|
||||
<a className={styles.linkToAsset} href={`${asset.url}`} target="_blank">
|
||||
<p className={styles.assetTitle}>{asset.title}</p>
|
||||
</a>
|
||||
<p className={styles.lede}>{asset.author} — Published: {new Date(asset.created_at).toLocaleDateString()}</p>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
: <div className={styles.rowLinkify}>{t('dashboard.no_activity')}</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
ActivityWidget.propTypes = {
|
||||
assets: PropTypes.arrayOf(PropTypes.shape({
|
||||
id: PropTypes.string,
|
||||
url: PropTypes.string,
|
||||
commentCount: PropTypes.number,
|
||||
author: PropTypes.string,
|
||||
created_at: PropTypes.string
|
||||
})).isRequired
|
||||
};
|
||||
|
||||
export default ActivityWidget;
|
||||
@@ -1,93 +0,0 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import styles from './Dashboard.css';
|
||||
import {Icon} from 'coral-ui';
|
||||
|
||||
import t from 'coral-framework/services/i18n';
|
||||
const refreshIntervalSeconds = 60 * 5;
|
||||
|
||||
// TODO: refactor out storage code into redux.
|
||||
|
||||
class CountdownTimer extends React.Component {
|
||||
|
||||
static contextTypes = {
|
||||
storage: PropTypes.object,
|
||||
};
|
||||
|
||||
static propTypes = {
|
||||
handleTimeout: PropTypes.func.isRequired
|
||||
}
|
||||
|
||||
constructor (props, context) {
|
||||
super(props, context);
|
||||
const {storage} = context;
|
||||
try {
|
||||
if (storage && storage.getItem('coral:dashboardNote') === null) {
|
||||
storage.setItem('coral:dashboardNote', 'show');
|
||||
}
|
||||
} catch (e) {
|
||||
|
||||
// above will fail in Private Mode in some browsers.
|
||||
}
|
||||
|
||||
this.state = {
|
||||
secondsUntilRefresh: refreshIntervalSeconds,
|
||||
dashboardNote: (storage && storage.getItem('coral:dashboardNote')) || 'show'
|
||||
};
|
||||
}
|
||||
|
||||
componentWillMount () {
|
||||
this.interval = setInterval(() => { // the countdown timer
|
||||
let nextCount = this.state.secondsUntilRefresh - 1;
|
||||
if (nextCount < 0) {
|
||||
nextCount = refreshIntervalSeconds;
|
||||
return this.props.handleTimeout();
|
||||
}
|
||||
this.setState({secondsUntilRefresh: nextCount});
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
componentWillUnmount () {
|
||||
window.clearInterval(this.interval);
|
||||
}
|
||||
|
||||
formatTime = () => {
|
||||
const minutes = Math.floor(this.state.secondsUntilRefresh / 60);
|
||||
let seconds = (this.state.secondsUntilRefresh % 60).toString();
|
||||
if (seconds.length < 2) {
|
||||
seconds = `0${seconds}`;
|
||||
}
|
||||
|
||||
return `${minutes}:${seconds}`;
|
||||
}
|
||||
|
||||
dismissNote = () => {
|
||||
const {storage} = this.context;
|
||||
try {
|
||||
if (storage) {
|
||||
storage.setItem('coral:dashboardNote', 'hide');
|
||||
}
|
||||
} catch (e) {
|
||||
|
||||
// when setItem fails in Safari Private mode
|
||||
this.setState({dashboardNote: 'hide'});
|
||||
}
|
||||
}
|
||||
|
||||
render () {
|
||||
const {storage} = this.context;
|
||||
const hideReloadNote = (storage && storage.getItem('coral:dashboardNote') === 'hide') ||
|
||||
this.state.dashboardNote === 'hide'; // for Safari Incognito
|
||||
return (
|
||||
<p
|
||||
style={{display: hideReloadNote ? 'none' : 'block'}}
|
||||
className={styles.autoUpdate}
|
||||
onClick={this.dismissNote}>
|
||||
<b>×</b>
|
||||
<Icon name='timer' /> <strong>{t('dashboard.next_update', this.formatTime())}</strong> {t('dashboard.auto_update')}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default CountdownTimer;
|
||||
@@ -1,40 +0,0 @@
|
||||
/**
|
||||
* @TODO: deprecated as this file contains styles from multiple components. Please refactor.
|
||||
*/
|
||||
|
||||
.Dashboard {
|
||||
display: flex;
|
||||
max-width: 1280px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.heading {
|
||||
margin: 0;
|
||||
font-size: 1.5rem;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.autoUpdate {
|
||||
background-color: #d5d5d5;
|
||||
padding: 3px 10px 10px 10px;
|
||||
margin-bottom: 0;
|
||||
|
||||
i {
|
||||
position: relative;
|
||||
top: 7px;
|
||||
}
|
||||
|
||||
b {
|
||||
float: right;
|
||||
border-radius: 20px;
|
||||
cursor: pointer;
|
||||
background-color: #c0c0c0;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
text-align: center;
|
||||
top: 4px;
|
||||
position: relative;
|
||||
line-height: 1.7em;
|
||||
font-size: 1.3em;
|
||||
}
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
import React from 'react';
|
||||
import FlagWidget from './FlagWidget';
|
||||
import ActivityWidget from './ActivityWidget';
|
||||
import CountdownTimer from './CountdownTimer';
|
||||
import styles from './Dashboard.css';
|
||||
|
||||
export default ({root: {assetsByActivity, assetsByFlag}, reloadData}) => (
|
||||
<div>
|
||||
<CountdownTimer handleTimeout={reloadData} />
|
||||
<div className={styles.Dashboard}>
|
||||
<FlagWidget assets={assetsByFlag} />
|
||||
<ActivityWidget assets={assetsByActivity} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -1,54 +0,0 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import {Link} from 'react-router';
|
||||
import styles from './Widget.css';
|
||||
|
||||
import t from 'coral-framework/services/i18n';
|
||||
|
||||
const FlagWidget = ({assets}) => {
|
||||
|
||||
return (
|
||||
<div className={styles.widget}>
|
||||
<h2 className={styles.heading}>{t('dashboard.most_flags')}</h2>
|
||||
<div className={styles.widgetHead}>
|
||||
<p>{t('streams.article')}</p>
|
||||
<p>{t('dashboard.flags')}</p>
|
||||
</div>
|
||||
<div className={styles.widgetTable}>
|
||||
{
|
||||
assets.length
|
||||
? assets.map((asset) => {
|
||||
let flagSummary = null;
|
||||
if (asset.action_summaries) {
|
||||
flagSummary = asset.action_summaries.find((s) => s.__typename === 'FlagAssetActionSummary');
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.rowLinkify} key={asset.id}>
|
||||
<Link className={styles.linkToModerate} to={`/admin/moderate/reported/${asset.id}`}>Moderate</Link>
|
||||
<p className={styles.widgetCount}>{flagSummary ? flagSummary.actionCount : 0}</p>
|
||||
<a className={styles.linkToAsset} href={`${asset.url}`} target="_blank">
|
||||
<p className={styles.assetTitle}>{asset.title}</p>
|
||||
</a>
|
||||
<p className={styles.lede}>{asset.author} — Published: {new Date(asset.created_at).toLocaleDateString()}</p>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
: <div className={styles.rowLinkify}>{t('dashboard.no_flags')}</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
FlagWidget.propTypes = {
|
||||
assets: PropTypes.arrayOf(PropTypes.shape({
|
||||
id: PropTypes.string,
|
||||
url: PropTypes.string,
|
||||
action_summaries: PropTypes.array,
|
||||
author: PropTypes.string,
|
||||
created_at: PropTypes.string
|
||||
})).isRequired
|
||||
};
|
||||
|
||||
export default FlagWidget;
|
||||
@@ -1,49 +0,0 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import {Link} from 'react-router';
|
||||
import styles from './Widget.css';
|
||||
import t from 'coral-framework/services/i18n';
|
||||
|
||||
const LikeWidget = ({assets}) => {
|
||||
|
||||
return (
|
||||
<div className={styles.widget}>
|
||||
<h2 className={styles.heading}>Articles with the most likes</h2>
|
||||
<div className={styles.widgetHead}>
|
||||
<p>{t('streams.article')}</p>
|
||||
<p>{t('modqueue.likes')}</p>
|
||||
</div>
|
||||
<div className={styles.widgetTable}>
|
||||
{
|
||||
assets.length
|
||||
? assets.map((asset) => {
|
||||
const likeSummary = asset.action_summaries.find((s) => s.type === 'LikeAssetActionSummary');
|
||||
return (
|
||||
<div className={styles.rowLinkify} key={asset.id}>
|
||||
<Link className={styles.linkToModerate} to={`/admin/moderate/${asset.id}`}>Moderate</Link>
|
||||
<p className={styles.widgetCount}>{likeSummary ? likeSummary.actionCount : 0}</p>
|
||||
<a className={styles.linkToAsset} href={`${asset.url}`} target="_blank">
|
||||
<p className={styles.assetTitle}>{asset.title}</p>
|
||||
</a>
|
||||
<p className={styles.lede}>{asset.author} — Published: {new Date(asset.created_at).toLocaleDateString()}</p>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
: <div className={styles.rowLinkify}>{t('dashboard.no_likes')}</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
LikeWidget.propTypes = {
|
||||
assets: PropTypes.arrayOf(PropTypes.shape({
|
||||
id: PropTypes.string,
|
||||
url: PropTypes.string,
|
||||
action_summaries: PropTypes.array,
|
||||
author: PropTypes.string,
|
||||
created_at: PropTypes.string
|
||||
})).isRequired
|
||||
};
|
||||
|
||||
export default LikeWidget;
|
||||
@@ -1,38 +0,0 @@
|
||||
import React from 'react';
|
||||
|
||||
import ModerationQueue from 'coral-admin/src/containers/ModerationQueue/ModerationQueue';
|
||||
import styles from './Widget.css';
|
||||
import BanUserDialog from 'coral-admin/src/components/BanUserDialog';
|
||||
import t from 'coral-framework/services/i18n';
|
||||
|
||||
const MostLikedCommentsWidget = (props) => {
|
||||
const {
|
||||
comments,
|
||||
moderation,
|
||||
settings,
|
||||
handleBanUser,
|
||||
showBanUserDialog,
|
||||
hideBanUserDialog,
|
||||
acceptComment,
|
||||
rejectComment
|
||||
} = props;
|
||||
|
||||
return (
|
||||
<div className={styles.widget}>
|
||||
<h2 className={styles.heading}>{t('most_liked_comments')}</h2>
|
||||
<ModerationQueue
|
||||
comments={comments}
|
||||
suspectWords={settings.wordlist.suspect}
|
||||
showBanUserDialog={showBanUserDialog}
|
||||
acceptComment={acceptComment}
|
||||
rejectComment={rejectComment} />
|
||||
<BanUserDialog
|
||||
open={moderation.banDialog}
|
||||
user={moderation.user}
|
||||
handleClose={hideBanUserDialog}
|
||||
handleBanUser={handleBanUser} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default MostLikedCommentsWidget;
|
||||
@@ -1,110 +0,0 @@
|
||||
/**
|
||||
* @TODO: deprecated as this file contains styles from multiple components. Please refactor.
|
||||
*/
|
||||
|
||||
:root {
|
||||
--row-height: 60px;
|
||||
}
|
||||
|
||||
.widget {
|
||||
margin: 10px 5px 5px 5px;
|
||||
box-shadow: 0px 0px 5px 0px rgba(0,0,0,0.2);
|
||||
flex: 1;
|
||||
background-color: white;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.widget * {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.heading {
|
||||
margin: 0;
|
||||
padding-left: 10px;
|
||||
font-size: 1.3rem;
|
||||
font-weight: 600;
|
||||
color: #2c2c2c;
|
||||
}
|
||||
|
||||
.widgetTable {
|
||||
height: calc(var(--row-height) * 10);
|
||||
}
|
||||
|
||||
.widgetTable + div:after {
|
||||
content: '';
|
||||
clear: both;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.widgetHead p {
|
||||
color: #2c2c2c;
|
||||
font-weight: 500;
|
||||
padding: 10px;
|
||||
text-align: left;
|
||||
text-transform: capitalize;
|
||||
display: inline-block;
|
||||
box-sizing: border-box;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.widgetHead p:last-child {
|
||||
float: right;
|
||||
margin-right: 100px;
|
||||
}
|
||||
|
||||
.rowLinkify {
|
||||
border-bottom: 1px solid lightgrey;
|
||||
color: #555;
|
||||
height: var(--row-height);
|
||||
padding: 10px;
|
||||
transition: background-color 200ms;
|
||||
}
|
||||
|
||||
.rowLinkify:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.rowLinkify:hover {
|
||||
background-color: #f8f8f8;
|
||||
pointer: default;
|
||||
}
|
||||
|
||||
.linkToAsset {
|
||||
display: inline-block;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.linkToModerate {
|
||||
background-color: #BDBDBD;
|
||||
padding: 10px 14px;
|
||||
text-decoration: none;
|
||||
color: black;
|
||||
float: right;
|
||||
margin-left: 15px;
|
||||
transition: background-color 200ms;
|
||||
}
|
||||
|
||||
.linkToModerate:hover {
|
||||
background-color: #9E9E9E;
|
||||
}
|
||||
|
||||
.lede {
|
||||
font-size: 0.9em;
|
||||
color: #aaa;
|
||||
}
|
||||
|
||||
.assetTitle {
|
||||
color: #555;
|
||||
text-decoration: none;
|
||||
font-size: 1.2em;
|
||||
font-weight: 500;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.widgetCount {
|
||||
color: #555;
|
||||
font-size: 1.3em;
|
||||
font-weight: 400;
|
||||
float: right;
|
||||
margin-top: 7px;
|
||||
}
|
||||
@@ -1,65 +0,0 @@
|
||||
import React from 'react';
|
||||
import {connect} from 'react-redux';
|
||||
import Dashboard from '../components/Dashboard';
|
||||
import {compose, gql} from 'react-apollo';
|
||||
import withQuery from 'coral-framework/hocs/withQuery';
|
||||
|
||||
import {Spinner} from 'coral-ui';
|
||||
|
||||
class DashboardContainer extends React.Component {
|
||||
|
||||
reloadData = () => {
|
||||
this.props.data.refetch();
|
||||
}
|
||||
|
||||
render () {
|
||||
if (this.props.data.loading) {
|
||||
return <Spinner />;
|
||||
}
|
||||
return <Dashboard {...this.props} reloadData={this.reloadData} />;
|
||||
}
|
||||
}
|
||||
|
||||
export const witDashboardQuery = withQuery(gql`
|
||||
query CoralAdmin_Dashboard($from: Date!, $to: Date!) {
|
||||
assetsByFlag: assetMetrics(from: $from, to: $to, sortBy: FLAG) {
|
||||
...CoralAdmin_Metrics
|
||||
}
|
||||
assetsByActivity: assetMetrics(from: $from, to: $to, sortBy: ACTIVITY) {
|
||||
...CoralAdmin_Metrics
|
||||
}
|
||||
}
|
||||
fragment CoralAdmin_Metrics on Asset {
|
||||
id
|
||||
title
|
||||
url
|
||||
author
|
||||
created_at
|
||||
commentCount
|
||||
action_summaries {
|
||||
actionCount
|
||||
actionableItemCount
|
||||
}
|
||||
}
|
||||
`, {
|
||||
options: ({windowStart, windowEnd}) => {
|
||||
return {
|
||||
variables: {
|
||||
from: windowStart,
|
||||
to: windowEnd,
|
||||
}
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
const mapStateToProps = (state) => {
|
||||
return {
|
||||
windowStart: state.dashboard.windowStart,
|
||||
windowEnd: state.dashboard.windowEnd,
|
||||
};
|
||||
};
|
||||
|
||||
export default compose(
|
||||
connect(mapStateToProps),
|
||||
witDashboardQuery,
|
||||
)(DashboardContainer);
|
||||
@@ -1 +0,0 @@
|
||||
export {default} from './containers/Dashboard.js';
|
||||
@@ -138,7 +138,7 @@ class ModerationContainer extends Component {
|
||||
},
|
||||
];
|
||||
|
||||
this.subscriptions = parameters.map((param) => this.props.data.subscribeToMore(param));
|
||||
this.subscriptions = parameters.map((param) => this.props.data.subscribeToMoreThrottled(param));
|
||||
}
|
||||
|
||||
unsubscribe() {
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import React from 'react';
|
||||
import ExtendableTabPanel from '../components/ExtendableTabPanel';
|
||||
import {connect} from 'react-redux';
|
||||
import omit from 'lodash/omit';
|
||||
import {TabPane} from 'coral-ui';
|
||||
import ExtendableTab from '../components/ExtendableTab';
|
||||
import {getShallowChanges} from 'coral-framework/utils';
|
||||
@@ -128,7 +127,7 @@ ExtendableTabPanelContainer.propTypes = {
|
||||
};
|
||||
|
||||
const mapStateToProps = (state) => ({
|
||||
reduxState: omit(state, 'apollo'),
|
||||
reduxState: state,
|
||||
});
|
||||
|
||||
export default connect(mapStateToProps, null)(ExtendableTabPanelContainer);
|
||||
|
||||
@@ -119,6 +119,8 @@
|
||||
|
||||
.commentInfoBar {
|
||||
margin-left: auto;
|
||||
flex: 1;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
@keyframes enter {
|
||||
@@ -131,9 +133,6 @@
|
||||
animation: enter 1000ms;
|
||||
}
|
||||
|
||||
.commentContainer {
|
||||
}
|
||||
|
||||
.commentAvatar {
|
||||
max-width: 60px;
|
||||
}
|
||||
@@ -152,9 +151,32 @@
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin: 10px 0;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.headerContainer {
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
|
||||
@media (min-width: 480px) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-direction: row;
|
||||
}
|
||||
}
|
||||
|
||||
.tagsContainer {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.tagsContainer > * {
|
||||
margin: 3px;
|
||||
|
||||
&:first-child {
|
||||
margin-left: 0px;
|
||||
}
|
||||
}
|
||||
|
||||
.content {
|
||||
|
||||
@@ -452,39 +452,43 @@ export default class Comment extends React.Component {
|
||||
<div className={cn(styles.commentContainer, 'talk-stream-comment-container')}>
|
||||
<div className={cn(styles.header, 'talk-stream-comment-header')}>
|
||||
|
||||
<Slot
|
||||
className={cn(styles.username, 'talk-stream-comment-user-name')}
|
||||
fill="commentAuthorName"
|
||||
defaultComponent={CommentAuthorName}
|
||||
queryData={queryData}
|
||||
{...slotProps}
|
||||
/>
|
||||
|
||||
{isStaff(comment.tags) ? <TagLabel>Staff</TagLabel> : null}
|
||||
|
||||
<Slot
|
||||
className={cn('talk-stream-comment-author-tags')}
|
||||
fill="commentAuthorTags"
|
||||
queryData={queryData}
|
||||
{...slotProps}
|
||||
inline
|
||||
/>
|
||||
|
||||
<span className={`${styles.bylineSecondary} talk-stream-comment-user-byline`} >
|
||||
<div className={cn(styles.headerContainer, 'talk-stream-comment-header-container')}>
|
||||
<Slot
|
||||
fill="commentTimestamp"
|
||||
defaultComponent={CommentTimestamp}
|
||||
className={'talk-stream-comment-published-date'}
|
||||
created_at={comment.created_at}
|
||||
className={cn(styles.username, 'talk-stream-comment-user-name')}
|
||||
fill="commentAuthorName"
|
||||
defaultComponent={CommentAuthorName}
|
||||
queryData={queryData}
|
||||
{...slotProps}
|
||||
/>
|
||||
{
|
||||
(comment.editing && comment.editing.edited)
|
||||
? <span> <span className={styles.editedMarker}>({t('comment.edited')})</span></span>
|
||||
: null
|
||||
}
|
||||
</span>
|
||||
|
||||
<div className={cn(styles.tagsContainer, 'talk-stream-comment-header-tags-container')}>
|
||||
{isStaff(comment.tags) ? <TagLabel>Staff</TagLabel> : null}
|
||||
|
||||
<Slot
|
||||
className={cn(styles.commentAuthorTagsSlot, 'talk-stream-comment-author-tags')}
|
||||
fill="commentAuthorTags"
|
||||
queryData={queryData}
|
||||
{...slotProps}
|
||||
inline
|
||||
/>
|
||||
</div>
|
||||
|
||||
<span className={`${styles.bylineSecondary} talk-stream-comment-user-byline`} >
|
||||
<Slot
|
||||
fill="commentTimestamp"
|
||||
defaultComponent={CommentTimestamp}
|
||||
className={'talk-stream-comment-published-date'}
|
||||
created_at={comment.created_at}
|
||||
queryData={queryData}
|
||||
{...slotProps}
|
||||
/>
|
||||
{
|
||||
(comment.editing && comment.editing.edited)
|
||||
? <span> <span className={styles.editedMarker}>({t('comment.edited')})</span></span>
|
||||
: null
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<Slot
|
||||
className={styles.commentInfoBar}
|
||||
|
||||
@@ -215,7 +215,12 @@ class Stream extends React.Component {
|
||||
root,
|
||||
appendItemArray,
|
||||
asset,
|
||||
asset: {comment: highlightedComment},
|
||||
asset: {
|
||||
comment: highlightedComment,
|
||||
settings: {
|
||||
questionBoxEnable,
|
||||
}
|
||||
},
|
||||
postComment,
|
||||
notify,
|
||||
updateItem,
|
||||
@@ -233,8 +238,7 @@ class Stream extends React.Component {
|
||||
suspensionUntil &&
|
||||
new Date(suspensionUntil) > new Date();
|
||||
|
||||
const showCommentBox = loggedIn && ((!banned & !temporarilySuspended && !highlightedComment) || keepCommentBox);
|
||||
|
||||
const showCommentBox = loggedIn && ((!banned && !temporarilySuspended && !highlightedComment) || keepCommentBox);
|
||||
const slotProps = {data};
|
||||
const slotQueryData = {root, asset};
|
||||
|
||||
@@ -250,18 +254,17 @@ class Stream extends React.Component {
|
||||
content={asset.settings.infoBoxContent}
|
||||
enable={asset.settings.infoBoxEnable}
|
||||
/>
|
||||
<QuestionBox
|
||||
content={asset.settings.questionBoxContent}
|
||||
enable={asset.settings.questionBoxEnable}
|
||||
icon={asset.settings.questionBoxIcon}
|
||||
>
|
||||
<Slot
|
||||
fill="streamQuestionArea"
|
||||
queryData={slotQueryData}
|
||||
{...slotProps}
|
||||
/>
|
||||
</QuestionBox>
|
||||
|
||||
{questionBoxEnable && (
|
||||
<QuestionBox
|
||||
content={asset.settings.questionBoxContent}
|
||||
icon={asset.settings.questionBoxIcon}>
|
||||
<Slot
|
||||
fill="streamQuestionArea"
|
||||
queryData={slotQueryData}
|
||||
{...slotProps}
|
||||
/>
|
||||
</QuestionBox>
|
||||
)}
|
||||
{!banned &&
|
||||
temporarilySuspended &&
|
||||
<RestrictedMessageBox>
|
||||
|
||||
@@ -190,11 +190,9 @@ body {
|
||||
background-color: #4C1066;
|
||||
color: white;
|
||||
display: inline-block;
|
||||
margin: 0px 5px;
|
||||
padding: 5px 5px;
|
||||
border-radius: 2px;
|
||||
font-size: 12px;
|
||||
font-weight: bold;
|
||||
padding: 5px 6px;
|
||||
}
|
||||
|
||||
/* Comment Action Styles */
|
||||
|
||||
@@ -2,5 +2,6 @@
|
||||
display: inline-block;
|
||||
color: #696969;
|
||||
font-size: 12px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import React, {Children} from 'react';
|
||||
import {connect} from 'react-redux';
|
||||
import PropTypes from 'prop-types';
|
||||
import omit from 'lodash/omit';
|
||||
import {getShallowChanges} from 'coral-framework/utils';
|
||||
|
||||
class IfSlotIsEmpty extends React.Component {
|
||||
@@ -39,7 +38,7 @@ IfSlotIsEmpty.propTypes = {
|
||||
};
|
||||
|
||||
const mapStateToProps = (state) => ({
|
||||
reduxState: omit(state, 'apollo'),
|
||||
reduxState: state,
|
||||
});
|
||||
|
||||
export default connect(mapStateToProps, null)(IfSlotIsEmpty);
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import React, {Children} from 'react';
|
||||
import {connect} from 'react-redux';
|
||||
import PropTypes from 'prop-types';
|
||||
import omit from 'lodash/omit';
|
||||
import {getShallowChanges} from 'coral-framework/utils';
|
||||
|
||||
class IfSlotIsNotEmpty extends React.Component {
|
||||
@@ -39,7 +38,7 @@ IfSlotIsNotEmpty.propTypes = {
|
||||
};
|
||||
|
||||
const mapStateToProps = (state) => ({
|
||||
reduxState: omit(state, 'apollo'),
|
||||
reduxState: state,
|
||||
});
|
||||
|
||||
export default connect(mapStateToProps, null)(IfSlotIsNotEmpty);
|
||||
|
||||
@@ -2,7 +2,6 @@ import React from 'react';
|
||||
import cn from 'classnames';
|
||||
import styles from './Slot.css';
|
||||
import {connect} from 'react-redux';
|
||||
import omit from 'lodash/omit';
|
||||
import kebabCase from 'lodash/kebabCase';
|
||||
import PropTypes from 'prop-types';
|
||||
import isEqual from 'lodash/isEqual';
|
||||
@@ -121,7 +120,7 @@ Slot.propTypes = {
|
||||
};
|
||||
|
||||
const mapStateToProps = (state) => ({
|
||||
reduxState: omit(state, 'apollo'),
|
||||
reduxState: state,
|
||||
});
|
||||
|
||||
export default connect(mapStateToProps, null)(Slot);
|
||||
|
||||
@@ -9,17 +9,18 @@ class TalkProvider extends React.Component {
|
||||
pym: this.props.pym,
|
||||
plugins: this.props.plugins,
|
||||
rest: this.props.rest,
|
||||
graphqlRegistry: this.props.graphqlRegistry,
|
||||
graphql: this.props.graphql,
|
||||
notification: this.props.notification,
|
||||
storage: this.props.storage,
|
||||
history: this.props.history,
|
||||
store: this.props.store,
|
||||
};
|
||||
}
|
||||
|
||||
render() {
|
||||
const {children, client, store} = this.props;
|
||||
const {children, client} = this.props;
|
||||
return (
|
||||
<ApolloProvider client={client} store={store}>
|
||||
<ApolloProvider client={client}>
|
||||
{children}
|
||||
</ApolloProvider>
|
||||
);
|
||||
@@ -31,10 +32,11 @@ TalkProvider.childContextTypes = {
|
||||
eventEmitter: PropTypes.object,
|
||||
plugins: PropTypes.object,
|
||||
rest: PropTypes.func,
|
||||
graphqlRegistry: PropTypes.object,
|
||||
graphql: PropTypes.object,
|
||||
notification: PropTypes.object,
|
||||
storage: PropTypes.object,
|
||||
history: PropTypes.object,
|
||||
store: PropTypes.object,
|
||||
};
|
||||
|
||||
export default TalkProvider;
|
||||
|
||||
@@ -0,0 +1,293 @@
|
||||
import {
|
||||
getMainDefinition,
|
||||
getFragmentDefinitions,
|
||||
createFragmentMap,
|
||||
shouldInclude,
|
||||
getOperationDefinition,
|
||||
} from 'apollo-utilities';
|
||||
|
||||
function getDirectivesID(directives) {
|
||||
let id = '';
|
||||
directives.forEach((directive) => {
|
||||
id += `@${directive.name.value}(`;
|
||||
let first = true;
|
||||
directive.arguments.forEach((arg) => {
|
||||
if (!first) {
|
||||
id += ',';
|
||||
}
|
||||
first = false;
|
||||
const value = arg.value.kind === 'Variable'
|
||||
? `$${arg.value.name.value}`
|
||||
: arg.value.value;
|
||||
id += `${arg.name.value}:${value}`;
|
||||
});
|
||||
id += ')';
|
||||
});
|
||||
return id;
|
||||
}
|
||||
|
||||
// If two definitions have the same id, they can be merged.
|
||||
function getDefinitionID(definition) {
|
||||
|
||||
// Only merge when directives are exactly the same.
|
||||
const trailing = definition.directives.length
|
||||
? `_${getDirectivesID(definition.directives)}`
|
||||
: '';
|
||||
|
||||
switch (definition.kind) {
|
||||
case 'FragmentSpread':
|
||||
return `FragmentSpread_${definition.name.value}`;
|
||||
case 'Field':
|
||||
return `Field_${definition.alias ? definition.alias.value : definition.name.value}${trailing}`;
|
||||
case 'InlineFragment':
|
||||
return `InlineFragment_${definition.typeCondition.name.value}${trailing}`;
|
||||
default:
|
||||
throw new Error(`unknown definition kind ${definition.kind}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge selections of 2 definitions.
|
||||
*/
|
||||
export function mergeDefinitions(a, b) {
|
||||
const name = getDefinitionID(a);
|
||||
|
||||
if (!!a.selectionSet !== !!b.selectionSet) {
|
||||
throw Error(`incompatible field definition for ${name}`);
|
||||
}
|
||||
|
||||
if (!a.selectionSet) {
|
||||
return b;
|
||||
}
|
||||
|
||||
const selectionSet = mergeSelectionSets(a.selectionSet, b.selectionSet);
|
||||
|
||||
return {
|
||||
...b,
|
||||
selectionSet,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge selectionSets
|
||||
*/
|
||||
export function mergeSelectionSets(a, b) {
|
||||
const selectionsMap = [...a.selections, ...b.selections].reduce((o, sel) => {
|
||||
const selName = getDefinitionID(sel);
|
||||
if (!(selName in o)) {
|
||||
o[selName] = sel;
|
||||
return o;
|
||||
}
|
||||
o[selName] = mergeDefinitions(o[selName], sel);
|
||||
return o;
|
||||
}, {});
|
||||
|
||||
const selections = Object.keys(selectionsMap).map((key) => selectionsMap[key]);
|
||||
|
||||
return {
|
||||
...b,
|
||||
selections,
|
||||
};
|
||||
}
|
||||
|
||||
function getFragmentOrDie(name, execContext) {
|
||||
const {
|
||||
rawFragmentMap,
|
||||
fragmentMap,
|
||||
} = execContext;
|
||||
|
||||
if (!(name in fragmentMap)) {
|
||||
const fragment = rawFragmentMap[name];
|
||||
|
||||
if (!fragment) {
|
||||
throw new Error(`fragment ${fragment.name.value} does not exist`);
|
||||
}
|
||||
|
||||
const typeCondition = fragment.typeCondition.name.value;
|
||||
const transformed = transformDefinition(fragment, execContext, `type.${typeCondition}`, typeCondition);
|
||||
fragmentMap[name] = transformed;
|
||||
}
|
||||
|
||||
return fragmentMap[name];
|
||||
}
|
||||
|
||||
/**
|
||||
* Return selections with resolved named fragments and directives.
|
||||
*/
|
||||
function getTransformedSelections(definition, path, gqlType, execContext) {
|
||||
const {
|
||||
variables,
|
||||
} = execContext;
|
||||
|
||||
const selectionsMap = definition.selectionSet.selections.reduce((o, sel) => {
|
||||
if (variables && !shouldInclude(sel, variables)) {
|
||||
|
||||
// Skip this entirely
|
||||
return o;
|
||||
}
|
||||
if (sel.kind !== 'FragmentSpread') {
|
||||
const transformed = transformDefinition(sel, execContext, path, gqlType);
|
||||
const name = getDefinitionID(sel);
|
||||
|
||||
// Merge existing value.
|
||||
if (name in o) {
|
||||
o[name] = mergeDefinitions(o[name], transformed);
|
||||
return o;
|
||||
}
|
||||
|
||||
o[name] = transformed;
|
||||
return o;
|
||||
}
|
||||
|
||||
const fragment = getFragmentOrDie(sel.name.value, execContext);
|
||||
const typeCondition = fragment.typeCondition.name.value;
|
||||
|
||||
// Turn NamedFragment into an InlineFragment.
|
||||
if (gqlType !== typeCondition || fragment.directives.length) {
|
||||
const node = {
|
||||
...fragment,
|
||||
kind: 'InlineFragment',
|
||||
};
|
||||
const name = getDefinitionID(node);
|
||||
|
||||
// Merge existing value.
|
||||
if (name in o) {
|
||||
o[name] = mergeDefinitions(o[name], node);
|
||||
return o;
|
||||
}
|
||||
|
||||
o[name] = node;
|
||||
return o;
|
||||
}
|
||||
|
||||
// Merge NamedFragment directly into selections.
|
||||
const fragmentSelections = fragment.selectionSet.selections;
|
||||
fragmentSelections.forEach((s) => {
|
||||
|
||||
if (variables && !shouldInclude(s, variables)) {
|
||||
|
||||
// Skip this entirely
|
||||
return;
|
||||
}
|
||||
|
||||
const selName = getDefinitionID(s);
|
||||
if (!(selName in o)) {
|
||||
o[selName] = s;
|
||||
return;
|
||||
}
|
||||
|
||||
o[selName] = mergeDefinitions(o[selName], s);
|
||||
});
|
||||
return o;
|
||||
}, {});
|
||||
|
||||
const selections = Object.keys(selectionsMap).map((key) => selectionsMap[key]);
|
||||
return selections;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve named fragments and directives in a definition.
|
||||
*/
|
||||
function transformDefinition(definition, execContext, path = '', type = null) {
|
||||
if (!definition.selectionSet) {
|
||||
return definition;
|
||||
}
|
||||
|
||||
const {typeGetter} = execContext;
|
||||
|
||||
if (definition.kind === 'Field') {
|
||||
const fieldName = definition.name.value;
|
||||
path = `${path}.${fieldName}`;
|
||||
|
||||
if (typeGetter) {
|
||||
type = typeGetter(path);
|
||||
}
|
||||
}
|
||||
|
||||
// InlineFragments
|
||||
else if(!type && typeGetter) {
|
||||
type = typeGetter(path);
|
||||
}
|
||||
|
||||
return {
|
||||
...definition,
|
||||
selectionSet: {
|
||||
...definition.selectionSet,
|
||||
selections: getTransformedSelections(definition, path, type, execContext),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export default function reduceDocument(document, options = {}) {
|
||||
const mainDefinition = getMainDefinition(document);
|
||||
const fragments = getFragmentDefinitions(document);
|
||||
const operationDefinition = getOperationDefinition(document);
|
||||
const path = operationDefinition
|
||||
? operationDefinition.operation
|
||||
: `type.${mainDefinition.typeCondition.name.value}`;
|
||||
|
||||
const execContext = {
|
||||
rawFragmentMap: createFragmentMap(fragments),
|
||||
fragmentMap: options.fragmentMap || {},
|
||||
variables: options.variables,
|
||||
typeGetter: options.typeGetter || (() => null),
|
||||
};
|
||||
|
||||
return {
|
||||
kind: 'Document',
|
||||
definitions: [transformDefinition(mainDefinition, execContext, path)],
|
||||
};
|
||||
}
|
||||
|
||||
function getObjectType(fieldType) {
|
||||
if (['NON_NULL', 'LIST'].indexOf(fieldType.kind) > -1) {
|
||||
return getObjectType(fieldType.ofType);
|
||||
}
|
||||
return fieldType.name;
|
||||
}
|
||||
|
||||
function getFieldType(parentType, fieldName) {
|
||||
const field = parentType.fields.find((f) => f.name === fieldName);
|
||||
return getObjectType(field.type);
|
||||
}
|
||||
|
||||
export function createTypeGetter(introspectionData) {
|
||||
const types = {};
|
||||
introspectionData.__schema.types.forEach((type) => types[type.name] = type);
|
||||
|
||||
const result = {
|
||||
'query': introspectionData.__schema.queryType.name,
|
||||
'mutation': introspectionData.__schema.mutationType.name,
|
||||
'subscription': introspectionData.__schema.subscriptionType.name,
|
||||
};
|
||||
|
||||
return (path) => {
|
||||
if (result[path]) {
|
||||
return result[path];
|
||||
}
|
||||
let currentPath = '';
|
||||
const parts = path.split('.');
|
||||
for (let i = 0; i < parts.length; i++) {
|
||||
const part = parts[i];
|
||||
|
||||
// Handle special path e.g. 'type.ROOT_QUERY.fieldName'
|
||||
if (part === 'type') {
|
||||
const type = parts[i + 1];
|
||||
const nextPath = `type.${type}`;
|
||||
result[nextPath] = type;
|
||||
currentPath = nextPath;
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const nextPath = currentPath ? `${currentPath}.${part}` : part;
|
||||
if (nextPath in result) {
|
||||
currentPath = nextPath;
|
||||
continue;
|
||||
}
|
||||
result[nextPath] = getFieldType(types[result[currentPath]], part);
|
||||
currentPath = nextPath;
|
||||
}
|
||||
return result[path];
|
||||
};
|
||||
}
|
||||
@@ -64,18 +64,15 @@ function hasEqualLeaves(a, b, path = '') {
|
||||
export default (fragments) => hoistStatics((BaseComponent) => {
|
||||
class WithFragments extends React.Component {
|
||||
static contextTypes = {
|
||||
graphqlRegistry: PropTypes.object,
|
||||
graphql: PropTypes.object,
|
||||
};
|
||||
|
||||
get graphqlRegistry() {
|
||||
return this.context.graphqlRegistry;
|
||||
return this.context.graphql.registry;
|
||||
}
|
||||
|
||||
resolveDocument(documentOrCallback) {
|
||||
const document = typeof documentOrCallback === 'function'
|
||||
? documentOrCallback(this.props, this.context)
|
||||
: documentOrCallback;
|
||||
return this.graphqlRegistry.resolveFragments(document);
|
||||
return this.context.graphql.resolveDocument(documentOrCallback, this.props, this.context);
|
||||
}
|
||||
|
||||
fragments = mapValues(fragments, (val) => this.resolveDocument(val));
|
||||
|
||||
@@ -42,18 +42,15 @@ export default (document, config = {}) => hoistStatics((WrappedComponent) => {
|
||||
static contextTypes = {
|
||||
eventEmitter: PropTypes.object,
|
||||
store: PropTypes.object,
|
||||
graphqlRegistry: PropTypes.object,
|
||||
graphql: PropTypes.object,
|
||||
};
|
||||
|
||||
get graphqlRegistry() {
|
||||
return this.context.graphqlRegistry;
|
||||
return this.context.graphql.registry;
|
||||
}
|
||||
|
||||
resolveDocument(documentOrCallback) {
|
||||
const document = typeof documentOrCallback === 'function'
|
||||
? documentOrCallback(this.props, this.context)
|
||||
: documentOrCallback;
|
||||
return this.graphqlRegistry.resolveFragments(document);
|
||||
return this.context.graphql.resolveDocument(documentOrCallback, this.props, this.context);
|
||||
}
|
||||
|
||||
// Lazily resolve fragments from graphRegistry to support circular dependencies.
|
||||
|
||||
@@ -3,6 +3,8 @@ import {graphql} from 'react-apollo';
|
||||
import {getDefinitionName, separateDataAndRoot, getResponseErrors} from '../utils';
|
||||
import PropTypes from 'prop-types';
|
||||
import hoistStatics from 'recompose/hoistStatics';
|
||||
import {getOperationName} from 'apollo-client/queries/getFromAST';
|
||||
import throttle from 'lodash/throttle';
|
||||
|
||||
const withSkipOnErrors = (reducer) => (prev, action, ...rest) => {
|
||||
if (action.type === 'APOLLO_MUTATION_RESULT' && getResponseErrors(action.result)) {
|
||||
@@ -39,24 +41,30 @@ export default (document, config = {}) => hoistStatics((WrappedComponent) => {
|
||||
return class WithQuery extends React.Component {
|
||||
static contextTypes = {
|
||||
eventEmitter: PropTypes.object,
|
||||
graphqlRegistry: PropTypes.object,
|
||||
graphql: PropTypes.object,
|
||||
client: PropTypes.object,
|
||||
};
|
||||
|
||||
// Lazily resolve fragments from graphRegistry to support circular dependencies.
|
||||
memoized = null;
|
||||
resolvedDocument = null;
|
||||
lastNetworkStatus = null;
|
||||
data = null;
|
||||
name = '';
|
||||
|
||||
// Pending subscription data.
|
||||
subscriptionQueue = [];
|
||||
|
||||
get graphqlRegistry() {
|
||||
return this.context.graphqlRegistry;
|
||||
return this.context.graphql.registry;
|
||||
}
|
||||
|
||||
get client() {
|
||||
return this.context.client;
|
||||
}
|
||||
|
||||
resolveDocument(documentOrCallback) {
|
||||
const document = typeof documentOrCallback === 'function'
|
||||
? documentOrCallback(this.props, this.context)
|
||||
: documentOrCallback;
|
||||
return this.graphqlRegistry.resolveFragments(document);
|
||||
return this.context.graphql.resolveDocument(documentOrCallback, this.props, this.context);
|
||||
}
|
||||
|
||||
emitWhenNeeded(data) {
|
||||
@@ -72,6 +80,66 @@ export default (document, config = {}) => hoistStatics((WrappedComponent) => {
|
||||
this.context.eventEmitter.emit(`query.${this.name}.${status}`, {variables, data: root});
|
||||
}
|
||||
|
||||
// Handle any pending susbcription data in the subscription queue at max once every second.
|
||||
// Updates are batched in written into apollo in one go.
|
||||
processSubscriptionQueue = throttle(() => {
|
||||
const variables = typeof this.wrappedOptions === 'function'
|
||||
? this.wrappedOptions(this.props).variables
|
||||
: this.wrappedOptions.variables;
|
||||
|
||||
const previousResult = this.client.readQuery({
|
||||
query: this.resolvedDocument,
|
||||
variables,
|
||||
});
|
||||
|
||||
let result = previousResult;
|
||||
|
||||
this.subscriptionQueue.forEach(([updateQuery, data]) => {
|
||||
result = updateQuery(result, {subscriptionData: {data}});
|
||||
});
|
||||
|
||||
if (result !== previousResult) {
|
||||
this.client.writeQuery({
|
||||
query: this.resolvedDocument,
|
||||
variables,
|
||||
data: result,
|
||||
});
|
||||
}
|
||||
this.subscriptionQueue = [];
|
||||
}, 1000);
|
||||
|
||||
subscribeToMoreThrottled = ({document, variables, updateQuery}) => {
|
||||
|
||||
// We need to add the typenames and resolve fragments.
|
||||
const query = this.resolveDocument(document);
|
||||
const handler = (error, data) => {
|
||||
if (error) {
|
||||
|
||||
// TODO: shuld this show a notification?
|
||||
console.error(error);
|
||||
return;
|
||||
}
|
||||
if (data) {
|
||||
this.subscriptionQueue.push([updateQuery, data]);
|
||||
|
||||
// Triggers the throttled subscription queue processor.
|
||||
this.processSubscriptionQueue();
|
||||
}
|
||||
};
|
||||
|
||||
// Start subscription.
|
||||
const request = {
|
||||
query,
|
||||
variables,
|
||||
operationName: getOperationName(query),
|
||||
};
|
||||
|
||||
const id = this.client.networkInterface.subscribe(request, handler);
|
||||
|
||||
// Return unsubscribe callback.
|
||||
return () => this.client.networkInterface.unsubscribe(id);
|
||||
};
|
||||
|
||||
nextData(data) {
|
||||
this.emitWhenNeeded(data);
|
||||
|
||||
@@ -102,6 +170,7 @@ export default (document, config = {}) => hoistStatics((WrappedComponent) => {
|
||||
stopPolling: data.stopPolling,
|
||||
refetch: data.refetch,
|
||||
updateQuery: data.updateQuery,
|
||||
subscribeToMoreThrottled: this.subscribeToMoreThrottled,
|
||||
subscribeToMore: (stmArgs) => {
|
||||
const resolvedDocument = this.resolveDocument(stmArgs.document);
|
||||
|
||||
@@ -190,10 +259,10 @@ export default (document, config = {}) => hoistStatics((WrappedComponent) => {
|
||||
|
||||
getWrapped = () => {
|
||||
if (!this.memoized) {
|
||||
const resolvedDocument = this.resolveDocument(document);
|
||||
this.name = getDefinitionName(resolvedDocument);
|
||||
this.resolvedDocument = this.resolveDocument(document);
|
||||
this.name = getDefinitionName(this.resolvedDocument);
|
||||
this.memoized = graphql(
|
||||
resolvedDocument,
|
||||
this.resolvedDocument,
|
||||
{...this.wrappedConfig, options: this.wrappedOptions},
|
||||
)(WrappedComponent);
|
||||
}
|
||||
|
||||
+22
-8
@@ -12,6 +12,7 @@ import {BASE_PATH} from 'coral-framework/constants/url';
|
||||
import {createPluginsService} from './plugins';
|
||||
import {createNotificationService} from './notification';
|
||||
import {createGraphQLRegistry} from './graphqlRegistry';
|
||||
import {createGraphQLService} from './graphql';
|
||||
import globalFragments from 'coral-framework/graphql/fragments';
|
||||
import {createStorage, createPymStorage} from 'coral-framework/services/storage';
|
||||
import {createHistory} from 'coral-framework/services/history';
|
||||
@@ -121,7 +122,13 @@ export async function createContext({
|
||||
introspectionData,
|
||||
});
|
||||
const plugins = createPluginsService(pluginsConfig);
|
||||
const graphqlRegistry = createGraphQLRegistry(plugins.getSlotFragments.bind(plugins));
|
||||
const graphql = createGraphQLService(
|
||||
createGraphQLRegistry(plugins.getSlotFragments.bind(plugins)),
|
||||
{
|
||||
introspectionData,
|
||||
optimize: process.env.NODE_ENV === 'production',
|
||||
},
|
||||
);
|
||||
if (!notification) {
|
||||
|
||||
// Use default notification service (pym based)
|
||||
@@ -134,7 +141,7 @@ export async function createContext({
|
||||
plugins,
|
||||
eventEmitter,
|
||||
rest,
|
||||
graphqlRegistry,
|
||||
graphql,
|
||||
notification,
|
||||
storage,
|
||||
history,
|
||||
@@ -143,13 +150,13 @@ export async function createContext({
|
||||
};
|
||||
|
||||
// Load framework fragments.
|
||||
Object.keys(globalFragments).forEach((key) => graphqlRegistry.addFragment(key, globalFragments[key]));
|
||||
Object.keys(globalFragments).forEach((key) => graphql.registry.addFragment(key, globalFragments[key]));
|
||||
|
||||
// Register graphql extension
|
||||
graphqlRegistry.add(graphqlExtension);
|
||||
graphql.registry.add(graphqlExtension);
|
||||
|
||||
// Register plugin graphql extensions.
|
||||
plugins.getGraphQLExtensions().forEach((ext) => graphqlRegistry.add(ext));
|
||||
plugins.getGraphQLExtensions().forEach((ext) => graphql.registry.add(ext));
|
||||
|
||||
// Load plugin translations.
|
||||
plugins.getTranslations().forEach((t) => loadTranslations(t));
|
||||
@@ -159,21 +166,28 @@ export async function createContext({
|
||||
pym.sendMessage('event', JSON.stringify({eventName, value}));
|
||||
});
|
||||
|
||||
// Create our redux store.
|
||||
const finalReducers = {
|
||||
...reducers,
|
||||
...plugins.getReducers(),
|
||||
apollo: client.reducer(),
|
||||
};
|
||||
|
||||
store = createStore(finalReducers, [
|
||||
client.middleware(),
|
||||
thunk.withExtraArgument(context),
|
||||
apolloErrorReporter,
|
||||
createReduxEmitter(eventEmitter),
|
||||
]);
|
||||
|
||||
context.store = store;
|
||||
|
||||
// Create apollo redux store.
|
||||
context.apolloStore = createStore({
|
||||
apollo: client.reducer(),
|
||||
}, [
|
||||
client.middleware(),
|
||||
apolloErrorReporter,
|
||||
createReduxEmitter(eventEmitter),
|
||||
]);
|
||||
|
||||
// Run pre initialization.
|
||||
if (preInit) {
|
||||
await preInit(context);
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import reduceDocument, {createTypeGetter} from '../graphql/reduceDocument';
|
||||
import {addTypenameToDocument} from 'apollo-client/queries/queryTransform';
|
||||
|
||||
/**
|
||||
* createGraphQLService
|
||||
* @param {string} basename base path of the url
|
||||
* @return {Object} histor service
|
||||
*/
|
||||
export function createGraphQLService(registry, {
|
||||
introspectionData,
|
||||
optimize = false,
|
||||
}) {
|
||||
const reduceOptions = {
|
||||
typeGetter: optimize && introspectionData ? createTypeGetter(introspectionData) : null,
|
||||
|
||||
// Use shared fragment map.
|
||||
// Attention: Fragment names must be unique otherwise weird things will happen.
|
||||
fragmentMap: {},
|
||||
};
|
||||
|
||||
return {
|
||||
registry,
|
||||
resolveDocument(documentOrCallback, props, context) {
|
||||
let document = typeof documentOrCallback === 'function'
|
||||
? documentOrCallback(props, context)
|
||||
: documentOrCallback;
|
||||
document = registry.resolveFragments(document);
|
||||
|
||||
if (optimize) {
|
||||
document = reduceDocument(document, reduceOptions);
|
||||
}
|
||||
|
||||
// We also add typenames to the document which apollo would usually do,
|
||||
// but we also use the network interface in subscriptions directly
|
||||
// which require the resolved typenames.
|
||||
return addTypenameToDocument(document);
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user