Merge branch 'next' into user-status-refactor

This commit is contained in:
Wyatt Johnson
2017-12-04 15:42:57 -07:00
62 changed files with 710 additions and 1222 deletions
+44 -23
View File
@@ -12,7 +12,7 @@ const mongoose = require('../services/mongoose');
const kue = require('../services/kue');
util.onshutdown([
() => mongoose.disconnect()
() => mongoose.disconnect(),
]);
/**
@@ -20,17 +20,17 @@ util.onshutdown([
*/
function processJobs() {
// Start the scraper processor.
scraper.process();
// Start the mail processor.
mailer.process();
// The scraper only needs to shutdown when the scraper has actually been
// started.
util.onshutdown([
() => kue.Task.shutdown()
]);
// Start the scraper processor.
scraper.process();
// Start the mail processor.
mailer.process();
}
/**
@@ -48,22 +48,13 @@ function removeJob(job) {
}));
}
/**
* Removes the jobs passed in and returns a promise.
* @param {Array} jobs array of jobs
* @return {Promise}
*/
function removeJobs(jobs) {
return Promise.all(jobs.map(removeJob));
}
/**
* Get the top n jobs with a specific state.
* @param {String} [state='complete'] state to list jobs by
* @param {Number} limit limit of jobs to load
* @return {Promise}
*/
function rangeJobsByState(state = 'complete', limit) {
function rangeJobsByState(state, limit) {
return new Promise((resolve, reject) => {
kue.Job.rangeByState(state, 0, limit, 'asc', (err, jobs) => {
if (err) {
@@ -75,22 +66,52 @@ function rangeJobsByState(state = 'complete', limit) {
});
}
async function getJobBatch(n, includeStuck) {
let jobs = [];
jobs = await rangeJobsByState('complete', n);
if (includeStuck) {
jobs = jobs.concat(await rangeJobsByState('failed', n));
}
return jobs;
}
/**
* Cleans up the jobs that are in the queue.
*/
async function cleanupJobs(options) {
// The scraper only needs to shutdown when the scraper has actually been
// started.
util.onshutdown([
() => kue.Task.shutdown()
]);
const n = 100;
try {
const joblists = await Promise.all([
rangeJobsByState('complete', n),
options.stuck ? rangeJobsByState('failed', n) : false
]);
await joblists.filter((jobs) => jobs).map(removeJobs);
// Connect to redis by establishing a queue.
kue.Task.connect();
let jobCount = 0;
let jobs = await getJobBatch(n, options.stuck);
while (jobs.length > 0) {
// Remove all the jobs.
await Promise.all(jobs.map((job) => removeJob(job)));
jobCount += jobs.length;
// Get the next batch of jobs.
jobs = await getJobBatch(n, options.stuck);
}
util.shutdown();
console.log('Removed old jobs');
console.log(`Removed ${jobCount} jobs`);
} catch (err) {
console.error(err);
util.shutdown(1);
-2
View File
@@ -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 */}
+2 -8
View File
@@ -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
+1 -1
View File
@@ -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;
}
-2
View File
@@ -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>&nbsp;<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>&nbsp;<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>
+1 -3
View File
@@ -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);
+1 -2
View File
@@ -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];
};
}
+3 -6
View File
@@ -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));
+3 -6
View File
@@ -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.
+78 -9
View File
@@ -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
View File
@@ -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);
},
};
}
+6 -1
View File
@@ -182,7 +182,12 @@ const CONFIG = {
DISABLE_AUTOFLAG_SUSPECT_WORDS: process.env.TALK_DISABLE_AUTOFLAG_SUSPECT_WORDS === 'TRUE',
// TRUST_THRESHOLDS defines the thresholds used for automoderation.
TRUST_THRESHOLDS: process.env.TRUST_THRESHOLDS || 'comment:2,-1;flag:2,-1'
TRUST_THRESHOLDS: process.env.TRUST_THRESHOLDS || 'comment:2,-1;flag:2,-1',
// IGNORE_FLAGS_AGAINST_STAFF disables staff members from entering the
// reported queue from comments after this was enabled and from reports
// against the staff members user account.
IGNORE_FLAGS_AGAINST_STAFF: process.env.TALK_DISABLE_IGNORE_FLAGS_AGAINST_STAFF === 'TRUE',
};
//==============================================================================
@@ -456,3 +456,8 @@ Could be read as:
added back to the queue.
- At the moment of writing, behavior is not attached to the flagging
reliability, but it is recorded.
## TALK_DISABLE_IGNORE_FLAGS_AGAINST_STAFF
When `TRUE`, staff members will have their accounts and comments moderated the
same as any other user in the system. (Default `FALSE`)
@@ -8,13 +8,6 @@ permalink: /moderator-features/
The Admin is your moderators will moderate your comments, and your Admins will
configure and manage the different parts of Talk.
### Dashboard
The Dashboard provides real-time information to moderators so they know at a
glance where they should direct their attention. It shows what articles are
receiving the most reports and where the most active conversations are
happening.
### Moderate
This is the tab where Moderators will spend the majority of their time. They can
-6
View File
@@ -79,11 +79,6 @@ const findOrCreateAssetByURL = async (context, asset_url) => {
return asset;
};
const getAssetsForMetrics = async ({loaders: {Comments}}) => {
return Comments.getByQuery({action_type: 'FLAG'})
.then((connection) => connection.nodes);
};
const findByUrl = async (context, asset_url) => {
// Verify that the asset_url is parsable.
@@ -111,7 +106,6 @@ module.exports = (context) => ({
findByUrl: (url) => findByUrl(context, url),
getByQuery: (query) => getAssetsByQuery(context, query),
getByID: new DataLoader((ids) => genAssetsByID(context, ids)),
getForMetrics: () => getAssetsForMetrics(context),
getAll: new util.SingletonResolver(() => AssetModel.find({}))
}
});
-2
View File
@@ -4,7 +4,6 @@ const debug = require('debug')('talk:graph:loaders');
const Actions = require('./actions');
const Assets = require('./assets');
const Comments = require('./comments');
const Metrics = require('./metrics');
const Settings = require('./settings');
const Tags = require('./tags');
const Users = require('./users');
@@ -17,7 +16,6 @@ let loaders = [
Actions,
Assets,
Comments,
Metrics,
Settings,
Tags,
Users,
-257
View File
@@ -1,257 +0,0 @@
const _ = require('lodash');
const DataLoader = require('dataloader');
const {objectCacheKeyFn} = require('./util');
const ActionModel = require('../../models/action');
const CommentModel = require('../../models/comment');
/**
* Returns the assets which have had comments made within the last time period.
*/
const getAssetActivityMetrics = ({loaders: {Assets}}, {from, to, limit}) => {
let assetMetrics = [];
return CommentModel.aggregate([
{$match: {
parent_id: null,
created_at: {
$gt: from,
$lt: to
}
}},
{$group: {
_id: '$asset_id',
commentCount: {
$sum: 1
}
}},
{$project: {
_id: false,
asset_id: '$_id',
commentCount: '$commentCount'
}},
{$sort: {
commentCount: -1
}},
{$limit: limit}
])
.then((results) => {
assetMetrics = results;
return Assets.getByID.loadMany(results.map((result) => result.asset_id));
})
.then((assets) => assets.map((asset, i) => {
// We're leveraging the fact that the comments returned by the aggregation
// query are in the request order that we just made, it's what the
// Assets.getByID loader does.
asset.commentCount = assetMetrics[i].commentCount;
return asset;
}));
};
/**
* Returns a list of assets with action metadata included on the models.
*/
const getAssetMetrics = async ({loaders: {Metrics, Assets, Comments}}, {from, to, sortBy, limit}) => {
// Get the recent actions.
let actionSummaries = await Metrics.getRecentActions.load({from, to});
let commentMetrics = actionSummaries.reduce((acc, {item_id, action_type, count}) => {
if (action_type !== sortBy) {
return acc;
}
if (!(item_id in acc)) {
acc[item_id] = [];
}
acc[item_id].push({action_type, count});
return acc;
}, {});
// Collect just the comment id's.
let commentIDs = Object.keys(commentMetrics);
// Find those comments.
let comments = await Comments.get.loadMany(commentIDs);
let commentResults = _.groupBy(comments, 'asset_id');
let assetMetrics = Object.keys(commentResults)
.map((asset_id) => {
let ids = commentResults[asset_id].map((comment) => comment.id);
let summaries = _.groupBy(_.flatten(ids.map((id) => commentMetrics[id])), 'action_type');
let action_summaries = Object.keys(summaries).map((action_type) => ({
action_type,
actionCount: summaries[action_type].reduce((acc, {count}) => acc + count, 0),
actionableItemCount: summaries[action_type].length
}));
return {action_summaries, id: asset_id};
})
.filter((asset) => {
let contextActionSummary = asset.action_summaries.find((({action_type}) => action_type === sortBy));
if (contextActionSummary === null || contextActionSummary.actionCount === 0) {
return false;
}
return true;
})
// Sort these metrics by the predefined sort order. This will ensure that
// if the action summary does not exist on the object, that it is less
// prefered over the one that does have it.
.sort((a, b) => {
let aActionSummary = a.action_summaries.find((({action_type}) => action_type === sortBy));
let bActionSummary = b.action_summaries.find((({action_type}) => action_type === sortBy));
// Both of them had an actionCount, hence we can determine that we could
// compare the actual values directly.
return bActionSummary.actionCount - aActionSummary.actionCount;
});
// Only keep the top `limit`.
assetMetrics = assetMetrics.slice(0, limit);
// Determine the assets that we need to return.
let assets = await Assets.getByID.loadMany(assetMetrics.map((asset) => asset.id));
// Join up the assets that are returned by their id.
let groupedAssets = _.groupBy(assets, 'id');
// Return from the sorted asset metrics and return their assetes.
return assetMetrics.map(({id, action_summaries}) => {
if (id in groupedAssets) {
let asset = groupedAssets[id][0];
// Add the action summaries to the asset.
asset.action_summaries = action_summaries;
return asset;
}
return null;
}).filter((asset) => asset != null);
};
/**
* Returns a list of comments that are retrieved based on most activity within
* the indicated time range.
*/
const getCommentMetrics = async ({loaders: {Metrics, Comments}}, {from, to, sortBy, limit}) => {
let commentActionSummaries = {};
let actionSummaries = await Metrics.getRecentActions.load({from, to});
actionSummaries.sort((a, b) => {
let aActionSummary = a.action_type === sortBy ? a : null;
let bActionSummary = b.action_type === sortBy ? b : null;
// If either a or b don't have this action type, then one of them will
// automatically win.
if (aActionSummary == null || bActionSummary == null) {
if (bActionSummary != null) {
return 1;
}
if (aActionSummary != null) {
return -1;
}
return 0;
}
// Both of them had an actionCount, hence we can determine that we could
// compare the actual values directly.
return bActionSummary.count - aActionSummary.count;
});
commentActionSummaries = _.groupBy(actionSummaries, 'item_id');
// Grab the comment id's for comment where they have at least one of the
// actions being sorted by.
let commentIDs = Object.keys(commentActionSummaries).filter((item_id) => {
let contextActionSummary = commentActionSummaries[item_id].find(({action_type}) => action_type === sortBy);
if (contextActionSummary == null) {
return false;
}
return true;
});
// Only keep the top `limit`.
commentIDs = commentIDs.slice(0, limit);
// If there are no comment's to get, then just continue with an empty
// array.
if (commentIDs.length === 0) {
return [];
}
// Find those comments, this is the final stage, so let's get all the
// fields.
let comments = await Comments.get.loadMany(commentIDs);
return comments.map((comment) => {
// Add in the action summaries genrerated.
comment.action_summaries = commentActionSummaries[comment.id];
return comment;
});
};
const getRecentActions = (context, {from, to}) => {
return ActionModel.aggregate([
// Find all actions that were created in the time range.
{$match: {
item_type: 'COMMENTS',
created_at: {
$gt: from,
$lt: to
}
}},
// Count all those items.
{$group: {
_id: {
item_id: '$item_id',
action_type: '$action_type'
},
count: {
$sum: 1
}
}},
// Project the count to a better field.
{$project: {
item_id: '$_id.item_id',
action_type: '$_id.action_type',
count: '$count'
}}
]);
};
module.exports = (context) => ({
Metrics: {
getRecentActions: new DataLoader(([{from, to}]) => getRecentActions(context, {from, to}).then((as) => [as]), {
batch: false,
cacheKeyFn: objectCacheKeyFn('from', 'to')
}),
Assets: {
get: ({from, to, sortBy, limit}) => getAssetMetrics(context, {from, to, sortBy, limit}),
getActivity: ({from, to, limit}) => getAssetActivityMetrics(context, {from, to, limit}),
},
Comments: {
get: ({from, to, sortBy, limit}) => getCommentMetrics(context, {from, to, sortBy, limit}),
}
}
});
+56 -19
View File
@@ -1,6 +1,8 @@
const ActionsService = require('../../services/actions');
const errors = require('../../errors');
const {CREATE_ACTION, DELETE_ACTION} = require('../../perms/constants');
const {
IGNORE_FLAGS_AGAINST_STAFF,
} = require('../../config');
/**
* getActionItem will return the item that is associated with the given action.
@@ -10,21 +12,21 @@ const {CREATE_ACTION, DELETE_ACTION} = require('../../perms/constants');
* @param {Object} action the action being performed
* @return {Promise} resolves to the referenced item
*/
const getActionItem = async ({loaders: {Comments, Users}}, {item_id, item_type}) => {
if (item_type === 'COMMENTS') {
const comment = await Comments.get.load(item_id);
if (!comment) {
throw errors.ErrNotFound;
}
const getActionItem = async (ctx, {item_id, item_type}) => {
const {
loaders: {
Comments,
Users,
},
} = ctx;
return comment;
} else if (item_type === 'USERS') {
const user = await Users.getByID.load(item_id);
if (!user) {
throw errors.ErrNotFound;
}
return user;
switch (item_type) {
case 'COMMENTS':
return Comments.get.load(item_id);
case 'USERS':
return Users.getByID.load(item_id);
default:
return null;
}
};
@@ -37,10 +39,34 @@ const getActionItem = async ({loaders: {Comments, Users}}, {item_id, item_type})
* @return {Promise} resolves to the action created
*/
const createAction = async (ctx, {item_id, item_type, action_type, group_id, metadata = {}}) => {
const {user = {}, pubsub} = ctx;
const {
user = {},
pubsub,
connectors: {
services: {
Actions,
},
},
} = ctx;
// Gets the item referenced by the action.
const item = await getActionItem(ctx, {item_id, item_type});
if (!item || item === null) {
throw errors.ErrNotFound;
}
// If we are ignoring flags against staff, ensure that the target isn't a
// staff member.
if (IGNORE_FLAGS_AGAINST_STAFF) {
if (action_type === 'FLAG') {
// If the item is a user, and this is a flag. Check to see if they are
// staff, if they are, don't permit the flag.
if (item_type === 'USERS' && item.isStaff()) {
return null;
}
}
}
if (action_type === 'FLAG' && item_type === 'USERS') {
@@ -52,7 +78,7 @@ const createAction = async (ctx, {item_id, item_type, action_type, group_id, met
}
// Create the action itself.
let action = await ActionsService.create({
let action = await Actions.create({
item_id,
item_type,
user_id: user.id,
@@ -78,10 +104,21 @@ const createAction = async (ctx, {item_id, item_type, action_type, group_id, met
* @param {String} id the id of the action to delete
* @return {Promise} resolves to the deleted action, or null if not found.
*/
const deleteAction = ({user}, {id}) => ActionsService.delete({id, user_id: user.id});
const deleteAction = (ctx, {id}) => {
const {
user,
connectors: {
services: {
Actions,
},
},
} = ctx;
return Actions.delete({id, user_id: user.id});
};
module.exports = (ctx) => {
const mutators = {
let mutators = {
Action: {
create: () => Promise.reject(errors.ErrNotAuthorized),
delete: () => Promise.reject(errors.ErrNotAuthorized)
+13 -1
View File
@@ -15,7 +15,10 @@ const {
EDIT_COMMENT
} = require('../../perms/constants');
const debug = require('debug')('talk:graph:mutators:comment');
const {DISABLE_AUTOFLAG_SUSPECT_WORDS} = require('../../config');
const {
DISABLE_AUTOFLAG_SUSPECT_WORDS,
IGNORE_FLAGS_AGAINST_STAFF,
} = require('../../config');
const resolveTagsForComment = async ({user, loaders: {Tags}}, {asset_id, tags = []}) => {
const item_type = 'COMMENTS';
@@ -295,6 +298,15 @@ const moderationPhases = [
}
},
// If a given user is a staff member, always approve their comment.
(context) => {
if (IGNORE_FLAGS_AGAINST_STAFF && context.user && context.user.isStaff()) {
return {
status: 'ACCEPTED',
};
}
},
// This phase checks the comment if it has any links in it if the check is
// enabled.
(context, comment, {assetSettings: {premodLinksEnable}}) => {
-22
View File
@@ -1,7 +1,6 @@
const {
SEARCH_ASSETS,
SEARCH_OTHERS_COMMENTS,
SEARCH_COMMENT_METRICS,
SEARCH_OTHER_USERS
} = require('../../perms/constants');
@@ -58,27 +57,6 @@ const RootQuery = {
return Users.getCountByQuery(query);
},
assetMetrics(_, query, {user, loaders: {Metrics: {Assets}}}) {
if (user == null || !user.can(SEARCH_ASSETS)) {
return null;
}
const {sortBy} = query;
if (sortBy === 'ACTIVITY') {
return Assets.getActivity(query);
}
return Assets.get(query);
},
commentMetrics(_, query, {user, loaders: {Metrics: {Comments}}}) {
if (user == null || !user.can(SEARCH_COMMENT_METRICS)) {
return null;
}
return Comments.get(query);
},
// This returns the current user, ensure that if we aren't logged in, we
// return null.
me(_, args, {user}) {
+2 -3
View File
@@ -5,9 +5,8 @@ const {
SEARCH_OTHER_USERS,
SEARCH_OTHERS_COMMENTS,
UPDATE_USER_ROLES,
SEARCH_COMMENT_METRICS,
LIST_OWN_TOKENS,
VIEW_USER_STATUS
VIEW_USER_STATUS,
} = require('../../perms/constants');
const User = {
@@ -79,7 +78,7 @@ const User = {
// Extract the reliability from the user metadata if they have permission.
reliable(user, _, {user: requestingUser}) {
if (requestingUser && requestingUser.can(SEARCH_COMMENT_METRICS)) {
if (requestingUser && requestingUser.can(SEARCH_ACTIONS)) {
return KarmaService.model(user);
}
},
-21
View File
@@ -923,19 +923,6 @@ enum SORT_COMMENTS_BY {
REPLIES
}
# Metrics for the assets.
enum ASSET_METRICS_SORT {
# Represents a FlagAction.
FLAG
# Represents a don't agree action.
DONTAGREE
# Represents activity.
ACTIVITY
}
type RootQuery {
# Site wide settings and defaults.
@@ -970,14 +957,6 @@ type RootQuery {
# a single User by id
user(id: ID!): User
# Asset metrics related to user actions are saturated into the assets
# returned. Parameters `from` and `to` are related to the action created_at field.
assetMetrics(from: Date!, to: Date!, sortBy: ASSET_METRICS_SORT!, limit: Int = 10): [Asset!]
# Comment metrics related to user actions are saturated into the comments
# returned. Parameters `from` and `to` are related to the action created_at field.
commentMetrics(from: Date!, to: Date!, sortBy: ACTION_TYPE!, limit: Int = 10): [Comment!]
}
################################################################################
-11
View File
@@ -98,7 +98,6 @@ da:
copy_and_paste: "Kopier og indsæt kode nedenfor i dit CMS system for at indlejre din kommentarboks i dine artiker."
custom_css_url: "Brugerdefineret CSS URL"
custom_css_url_desc: "URL til et CSS stylesheet der tilsidesætter den alimdelige strøms stilart. Kan være internt eller externt"
dashboard: "Instrumentbræt"
days: "Dage"
description: "Som administrator kan du tilpasse indstillingerne til kommentarstrømmen for denne historie:"
domain_list_text: "Indtast de domæner du gerne vil tillade til Talk. f.eks. Dit lokale udviklings miljø (fx. localhost:3000 staging.domain.com domain.com)."
@@ -153,16 +152,6 @@ da:
username: "Brugernavn"
write_your_username: "Rediger dit brugernavn"
your_username: "Dit brugernavn vises på hver kommentar du sender."
dashboard:
auto_update: "Data opdateres automatisk hvert femte minut, eller når du genindlæser."
comment_count: "kommentarer"
flags: "Flags"
most_flags: "Historier med de fleste flags"
most_conversations: "Historier med de fleste samtaler"
next_update: "{0} minutter indtil næste opdatering."
no_activity: "Der har ikke været nogen kommentarer i løbet af de sidste fem minutter."
no_flags: "Der har ikke været nogle flag i de sidste 5 minutter! Hurra!"
no_likes: "Der har ikke været nogen der syntes godt om noget i de sidste 5 minutter. Helt stille"
done: "Færdig"
edit_comment:
body_input_label: "Rediger denne kommentar"
-11
View File
@@ -105,7 +105,6 @@ en:
copy_and_paste: "Copy and paste code below into your CMS to embed your comment box in your articles"
custom_css_url: "Custom CSS URL"
custom_css_url_desc: "URL of a CSS stylesheet that will override default Embed Stream styles. Can be internal or external."
dashboard: Dashboard
days: Days
description: "As an admin, you can customize the settings for the comment stream for this story:"
domain_list_text: "Enter the domains you would like to permit for Talk e.g. your local staging and production environments (ex. localhost:3000 staging.domain.com domain.com)."
@@ -160,16 +159,6 @@ en:
username: Username
write_your_username: "Edit your username"
your_username: "Your username appears on every comment you post."
dashboard:
auto_update: "Data automatically updates every five minutes or when you Reload."
comment_count: comments
flags: Flags
most_flags: "Stories with the most flags"
most_conversations: "Stories with the most conversations"
next_update: "{0} minutes until next update."
no_activity: "There haven't been any comments anywhere in the last five minutes."
no_flags: "There have been no flags in the last 5 minutes! Hooray!"
no_likes: "There have been no likes in the last 5 minutes. All quiet."
done: Done
edit_comment:
body_input_label: "Edit this comment"
-11
View File
@@ -103,7 +103,6 @@ es:
copy_and_paste: "Copiar y pegar el código de más abajo en tu CMS para colocar la caja de comentarios en tus artículos"
custom_css_url: "URL CSS a medida"
custom_css_url_desc: "URL de una hoja de estilo que va a sobrescribir los estilos por defecto del hilo de comentarios. Puede ser interna o externa."
dashboard: Panel
days: Días
description: "Como Administrador/a puedes modificar la configuración de los comentarios en este artículo"
domain_list_text: "Agrega dominios permitidos a Talk, por ejemplo tu localhost, staging y ambientes de producción (ej. localhost:3000, staging.domain.com, domain.com)."
@@ -158,16 +157,6 @@ es:
username: "Nombre"
write_your_username: "Edita tu nombre"
your_username: "Tu nombre aparece en cada comentario que publiques."
dashboard:
auto_update: "Los datos se actualizan automáticamente cada cinco minutos o cuando refresca el navegador."
comment_count: "comentarios"
flags: "Reportes"
most_flags: "Artículos con la mayor cantidad de reportes"
most_conversations: "Artículos con las mayores conversaciones"
next_update: "{0} minutos hasta la próxima actualización."
no_activity: "¡No han habido comentarios en ningún lado en los últimos 5 minutos."
no_flags: "¡No ha habido ningún reporte en los últimos 5 minutos! Bravo!"
no_likes: "¡No ha habido ningún 'me gusta' en los últimos 5 minutos. Todo tranquilo."
done: "Hecho"
edit_comment:
body_input_label: "Editar este comentario"
-11
View File
@@ -79,7 +79,6 @@ fr:
copy_and_paste: "Copiez et collez le code ci-dessous dans votre CMS pour intégrer votre boîte de commentaires dans vos articles."
custom_css_url: "URL CSS personnalisée"
custom_css_url_desc: "URL d'une feuille de style CSS qui remplacera les styles par défaut d'intégration des commentaires. Peut être interne ou externe."
dashboard: Tableau de bord
days: Journées
description: "En tant qu'administrateur, vous pouvez personnaliser les paramètres du fil de commentaires pour cet élément."
domain_list_text: "Entrez les domaines que vous souhaitez autoriser pour Talk, par exemple vos environnements locaux de production et de production (ex : localhost: 3000 staging.domain.com domain.com)."
@@ -121,16 +120,6 @@ fr:
weeks: Semaines
wordlist: "Mots interdits"
continue: Continuer
dashboard:
auto_update: "Les données sont automatiquement mises à jour toutes les cinq minutes ou lorsque vous rechargez."
comment_count: commentaires
flags: signalements
most_flags: "Articles avec le plus de signalements"
most_conversations: "Articles avec le plus de conversations"
next_update: "{0} minutes jusqu'à la prochaine mise à jour."
no_activity: "Il n'y a eu aucun commentaire nulle part dans les cinq dernières minutes."
no_flags: "Il n'y a pas eu aucun signalement au cours des 5 dernières minutes! Hooray !"
no_likes: "Il n'y a pas eu de \"J'aime\" au cours des 5 dernières minutes. Tout est calme."
done: Terminé
edit_comment:
body_input_label: "Modifier ce commentaire"
-11
View File
@@ -98,7 +98,6 @@ pt_BR:
copy_and_paste: "Copie e cole o código abaixo em seu CMS para incorporar sua caixa de comentários em seus artigos."
custom_css_url: "URL para CSS customizado"
custom_css_url_desc: "URL de uma folha de estilo CSS para substituir os estilos padrão dos comentários embutidos. Pode ser interno ou externo."
dashboard: Painel de controle
days: Dias
description: "Como administrador, você pode personalizar as configurações da lista de comentários para esta história:"
domain_list_text: "Insira os domínios que você gostaria de permitir para o Talk, como seus ambientes de desenvolvimento, teste ou produção (ej. localhost:3000 staging.domain.com domain.com)."
@@ -153,16 +152,6 @@ pt_BR:
username: Nome de usuário
write_your_username: "Edite seu nome de usuário"
your_username: "Nosso nome de usuário aparece em todos os comentários que você publica."
dashboard:
auto_update: "Atualizado autimaticamente a cada minutos ou após recarregamento."
comment_count: comentários
flags: Marcações
most_flags: "Conversas com mais comentários marcados"
most_conversations: "Conversas com mais comentários"
next_update: "{0} minutos até a próxima atualização."
no_activity: "Não houve nenhum comentário nos últimos cinco minutos."
no_flags: "Não houve nenhum comentário marcado em nenhum lugar nos últimos 5 minutos. Hip Hip Uha!"
no_likes: "Não houve reações nos últimos 5 minutos."
done: Feito
edit_comment:
body_input_label: "Edite este comentário"
+2 -1
View File
@@ -49,9 +49,11 @@
},
"homepage": "https://github.com/coralproject/talk#readme",
"dependencies": {
"@coralproject/graphql-anywhere-optimized": "^0.1.0",
"accepts": "^1.3.4",
"apollo-client": "^1.9.1",
"apollo-server-express": "^1.2.0",
"apollo-utilities": "^1.0.3",
"app-module-path": "^2.2.0",
"autoprefixer": "^6.5.2",
"babel-cli": "6.26.0",
@@ -99,7 +101,6 @@
"fs-extra": "^4.0.1",
"gql-merge": "^0.0.4",
"graphql": "^0.9.1",
"graphql-anywhere": "^3.1.0",
"graphql-docs": "0.2.0",
"graphql-errors": "^2.1.0",
"graphql-redis-subscriptions": "1.3.0",
+2 -3
View File
@@ -4,9 +4,8 @@ module.exports = {
SEARCH_ACTIONS: 'SEARCH_ACTIONS',
SEARCH_NON_NULL_OR_ACCEPTED_COMMENTS: 'SEARCH_NON_NULL_OR_ACCEPTED_COMMENTS',
SEARCH_OTHERS_COMMENTS: 'SEARCH_OTHERS_COMMENTS',
SEARCH_COMMENT_METRICS: 'SEARCH_COMMENT_METRICS',
LIST_OWN_TOKENS: 'LIST_OWN_TOKENS',
SEARCH_COMMENT_STATUS_HISTORY: 'SEARCH_COMMENT_STATUS_HISTORY',
VIEW_USER_STATUS: 'VIEW_USER_STATUS',
VIEW_PROTECTED_SETTINGS: 'VIEW_PROTECTED_SETTINGS',
VIEW_USER_STATUS: 'VIEW_USER_STATUS'
LIST_OWN_TOKENS: 'LIST_OWN_TOKENS',
};
+1 -3
View File
@@ -8,15 +8,13 @@ module.exports = (user, perm) => {
case types.SEARCH_ACTIONS:
case types.SEARCH_NON_NULL_OR_ACCEPTED_COMMENTS:
case types.SEARCH_OTHERS_COMMENTS:
case types.SEARCH_COMMENT_METRICS:
return check(user, ['ADMIN', 'MODERATOR']);
case types.SEARCH_COMMENT_STATUS_HISTORY:
case types.VIEW_USER_STATUS:
case types.VIEW_PROTECTED_SETTINGS:
return check(user, ['ADMIN', 'MODERATOR']);
case types.LIST_OWN_TOKENS:
return check(user, ['ADMIN']);
default:
break;
}
-2
View File
@@ -7,7 +7,6 @@ module.exports = (user, perm) => {
case types.SUBSCRIBE_COMMENT_ACCEPTED:
case types.SUBSCRIBE_COMMENT_REJECTED:
case types.SUBSCRIBE_COMMENT_RESET:
return check(user, ['ADMIN', 'MODERATOR']);
case types.SUBSCRIBE_ALL_COMMENT_EDITED:
case types.SUBSCRIBE_ALL_COMMENT_ADDED:
case types.SUBSCRIBE_ALL_USER_SUSPENDED:
@@ -15,7 +14,6 @@ module.exports = (user, perm) => {
case types.SUBSCRIBE_ALL_USERNAME_REJECTED:
case types.SUBSCRIBE_ALL_USERNAME_APPROVED:
return check(user, ['ADMIN', 'MODERATOR']);
default:
break;
}
+14 -12
View File
@@ -179,10 +179,22 @@ function getReactionConfig(reaction) {
RootMutation: {
[`create${Reaction}Action`]: async (_, {input: {item_id}}, {mutators: {Action}, pubsub, loaders: {Comments}}) => {
const comment = await Comments.get.load(item_id);
if (!comment) {
throw errors.ErrNotFound;
}
let action;
try {
action = await Action.create({item_id, item_type: 'COMMENTS', action_type: REACTION});
const action = await Action.create({item_id, item_type: 'COMMENTS', action_type: REACTION});
if (pubsub) {
// The comment is needed to allow better filtering e.g. by asset_id.
pubsub.publish(`${reaction}ActionCreated`, {action, comment});
}
return {
[reaction]: action,
};
} catch (err) {
if (err instanceof errors.ErrAlreadyExists) {
return err.metadata.existing;
@@ -190,16 +202,6 @@ function getReactionConfig(reaction) {
throw err;
}
if (pubsub) {
// The comment is needed to allow better filtering e.g. by asset_id.
pubsub.publish(`${reaction}ActionCreated`, {action, comment});
}
return {
[reaction]: action,
};
},
[`delete${Reaction}Action`]: async (_, {input: {id}}, {mutators: {Action}, pubsub, loaders: {Comments}}) => {
const action = await Action.delete({id});
@@ -2,7 +2,6 @@
background-color: #616161;
color: white;
display: inline-block;
margin: 0px 5px;
padding: 5px 5px;
border-radius: 2px;
font-size: 12px;
+4 -4
View File
@@ -1,7 +1,7 @@
module.exports = {
plugins: [
require('postcss-smart-import')({ /* ...options */ }),
require('precss')({ /* ...options */ }),
require('autoprefixer')({ /* ...options */ })
]
require('postcss-smart-import'),
require('precss'),
require('autoprefixer'),
],
};
+15
View File
@@ -21,6 +21,9 @@ const getQueue = () => {
}
});
// Watch for stuck jobs to manage.
queue.watchStuckJobs(1000);
return queue;
};
@@ -47,6 +50,7 @@ class Task {
.attempts(this.attempts)
.delay(this.delay)
.backoff({type: 'exponential'})
.removeOnComplete(true)
.save((err) => {
if (err) {
return reject(err);
@@ -66,6 +70,15 @@ class Task {
return getQueue().process(this.name, callback);
}
/**
* Connect to redis now by getting the queue.
*/
static connect() {
// Force setup the redis connection for kue.
getQueue();
}
/**
* Shutdown running jobs.
*/
@@ -145,3 +158,5 @@ if (process.env.NODE_ENV === 'test') {
module.exports.Task = Task;
}
// Add the job reference to the exported params.
module.exports.Job = kue.Job;
-133
View File
@@ -1,133 +0,0 @@
const {graphql} = require('graphql');
const schema = require('../../../../graph/schema');
const Context = require('../../../../graph/context');
const UserModel = require('../../../../models/user');
const AssetModel = require('../../../../models/asset');
const SettingsService = require('../../../../services/settings');
const ActionModel = require('../../../../models/action');
const CommentModel = require('../../../../models/comment');
const {expect} = require('chai');
describe('graph.loaders.Metrics', () => {
beforeEach(() => SettingsService.init());
describe('#Comments', () => {
const query = `
query CommentMetrics($from: Date!, $to: Date!) {
flagged: commentMetrics(from: $from, to: $to, sortBy: FLAG) {
id
}
}
`;
describe('different comment states', () => {
beforeEach(() => CommentModel.create([
{id: '1', body: 'a new comment!'},
{id: '2', body: 'a new comment!'},
{id: '3', body: 'a new comment!'}
]));
[
{flagged: 0, actions: []},
{flagged: 1, actions: [{action_type: 'FLAG', item_id: '1', item_type: 'COMMENTS'}]},
{flagged: 1, actions: [
{action_type: 'FLAG', item_id: '1', item_type: 'COMMENTS'},
]},
{flagged: 1, actions: [
{action_type: 'FLAG', item_id: '3', item_type: 'COMMENTS'}
]}
].forEach(({flagged, actions}) => {
describe(`with actions=${actions.length}`, () => {
beforeEach(() => ActionModel.create(actions));
it(`returns the correct amount of metrics flagged=${flagged}`, () => {
const context = new Context({user: new UserModel({role: 'ADMIN'})});
return graphql(schema, query, {}, context, {
from: (new Date()).setMinutes((new Date()).getMinutes() - 5),
to: (new Date()).setMinutes((new Date()).getMinutes() + 5)
})
.then(({data, errors}) => {
expect(errors).to.be.undefined;
expect(data.flagged).to.have.length(flagged);
});
});
});
});
});
});
describe('#Assets', () => {
const query = `
fragment metrics on Asset {
id
action_summaries {
actionCount
actionableItemCount
}
}
query Metrics($from: Date!, $to: Date!) {
assetsByFlag: assetMetrics(from: $from, to: $to, sortBy: FLAG) {
...metrics
}
}
`;
describe('different comment states', () => {
beforeEach(() => Promise.all([
AssetModel.create([
{id: 'a1', url: 'http://localhost:3030/article/1'},
{id: 'a2', url: 'http://localhost:3030/article/2'}
]),
CommentModel.create([
{id: 'c1', asset_id: 'a1', body: 'a new comment!'},
{id: 'c2', asset_id: 'a1', body: 'a new comment!'},
{id: 'c3', asset_id: 'a1', body: 'a new comment!'}
])
]));
[
{flagged: 0, actions: []},
{flagged: 1, actions: [{action_type: 'FLAG', item_id: 'c1', item_type: 'COMMENTS'}]},
{flagged: 1, actions: [
{action_type: 'FLAG', item_id: 'c1', item_type: 'COMMENTS'},
]},
{flagged: 1, actions: [
{action_type: 'FLAG', item_id: 'c3', item_type: 'COMMENTS'}
]}
].forEach(({flagged, actions}) => {
describe(`with actions=${actions.length}`, () => {
beforeEach(() => ActionModel.create(actions));
it(`returns the correct amount of metrics flagged=${flagged}`, () => {
const context = new Context({user: new UserModel({role: 'ADMIN'})});
return graphql(schema, query, {}, context, {
from: (new Date()).setMinutes((new Date()).getMinutes() - 5),
to: (new Date()).setMinutes((new Date()).getMinutes() + 5)
})
.then(({data, errors}) => {
expect(errors).to.be.undefined;
expect(data.assetsByFlag).to.have.length(flagged);
});
});
});
});
});
});
});
+1
View File
@@ -118,6 +118,7 @@ const config = {
},
resolve: {
alias: {
'graphql-anywhere': '@coralproject/graphql-anywhere-optimized',
'plugin-api': path.resolve(__dirname, 'plugin-api/'),
plugins: path.resolve(__dirname, 'plugins/'),
pluginsConfig: pluginsPath
+9 -1
View File
@@ -11,6 +11,10 @@
eslint-plugin-promise "^3.3.1"
eslint-plugin-react "^7.3.0"
"@coralproject/graphql-anywhere-optimized@^0.1.0":
version "0.1.0"
resolved "https://registry.yarnpkg.com/@coralproject/graphql-anywhere-optimized/-/graphql-anywhere-optimized-0.1.0.tgz#3456f16f790d8593b0ca4355910578ab1c6edab5"
"@kadira/storybook-deployer@^1.1.0":
version "1.2.0"
resolved "https://registry.yarnpkg.com/@kadira/storybook-deployer/-/storybook-deployer-1.2.0.tgz#1708f5cb37fa08ab4173b1bd99df6f4717dfae12"
@@ -285,6 +289,10 @@ apollo-tracing@^0.1.0:
dependencies:
graphql-extensions "^0.0.x"
apollo-utilities@^1.0.3:
version "1.0.3"
resolved "https://registry.yarnpkg.com/apollo-utilities/-/apollo-utilities-1.0.3.tgz#bf435277609850dd442cf1d5c2e8bc6655eaa943"
app-module-path@^2.2.0:
version "2.2.0"
resolved "https://registry.yarnpkg.com/app-module-path/-/app-module-path-2.2.0.tgz#641aa55dfb7d6a6f0a8141c4b9c0aa50b6c24dd5"
@@ -3595,7 +3603,7 @@ graceful-fs@^4.1.11, graceful-fs@^4.1.2, graceful-fs@^4.1.4, graceful-fs@^4.1.6:
version "1.0.1"
resolved "https://registry.yarnpkg.com/graceful-readlink/-/graceful-readlink-1.0.1.tgz#4cafad76bc62f02fa039b2f94e9a3dd3a391a725"
graphql-anywhere@^3.0.1, graphql-anywhere@^3.1.0:
graphql-anywhere@^3.0.1:
version "3.1.0"
resolved "https://registry.yarnpkg.com/graphql-anywhere/-/graphql-anywhere-3.1.0.tgz#3ea0d8e8646b5cee68035016a9a7557c15c21e96"