mirror of
https://github.com/wassname/talk.git
synced 2026-07-07 05:36:31 +08:00
Merge branch 'next' into user-status-refactor
This commit is contained in:
+44
-23
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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({}))
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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}}) => {
|
||||
|
||||
@@ -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}) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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!]
|
||||
}
|
||||
|
||||
################################################################################
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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',
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
@@ -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'),
|
||||
],
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
Reference in New Issue
Block a user