mirror of
https://github.com/wassname/talk.git
synced 2026-07-02 05:34:11 +08:00
Merged branch master into admin-login-upgrade
This commit is contained in:
@@ -18,6 +18,7 @@
|
||||
],
|
||||
"rules": {
|
||||
"react/jsx-uses-react": "error",
|
||||
"react/jsx-uses-vars": "error"
|
||||
"react/jsx-uses-vars": "error",
|
||||
"no-console": ["warn", { "allow": ["warn", "error"] }]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ const routes = (
|
||||
<Route path='/admin/login' component={LayoutContainer} />
|
||||
<Route path='/admin/logon' component={LayoutContainer} />
|
||||
<Route path='/admin' component={LayoutContainer}>
|
||||
<IndexRoute component={ModerationContainer} />
|
||||
<IndexRoute component={Dashboard} />
|
||||
<Route path='community' component={CommunityContainer} />
|
||||
<Route path='configure' component={Configure} />
|
||||
<Route path='streams' component={Streams} />
|
||||
|
||||
@@ -75,8 +75,7 @@ export const submitUser = () => (dispatch, getState) => {
|
||||
dispatch(installRequest());
|
||||
coralApi('/setup', {method: 'POST', body: data})
|
||||
.then(result => {
|
||||
console.log(result);
|
||||
dispatch(installSuccess());
|
||||
dispatch(installSuccess(result));
|
||||
dispatch(nextStep());
|
||||
})
|
||||
.catch(error => {
|
||||
|
||||
@@ -1,66 +0,0 @@
|
||||
import React, {PropTypes} from 'react';
|
||||
import {Link} from 'react-router';
|
||||
import styles from './FlagWidget.css';
|
||||
import I18n from 'coral-framework/modules/i18n/i18n';
|
||||
import translations from 'coral-admin/src/translations';
|
||||
|
||||
const lang = new I18n(translations);
|
||||
|
||||
const FlagWidget = ({assets}) => {
|
||||
|
||||
return (
|
||||
<table className={styles.widgetTable}>
|
||||
<thead className={styles.widgetHead}>
|
||||
<tr>
|
||||
<th></th>{/* empty on purpose */}
|
||||
<th>{lang.t('streams.article')}</th>
|
||||
<th>{lang.t('modqueue.flagged')}</th>
|
||||
<th>{lang.t('modqueue.likes')}</th>
|
||||
<th>{lang.t('dashboard.comment_count')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{
|
||||
assets.length
|
||||
? assets.map((asset, index) => {
|
||||
const flagSummary = asset.action_summaries.find(s => s.__typename === 'FlagAssetActionSummary');
|
||||
const likeSummary = asset.action_summaries.find(s => s.__typename === 'LikeAssetActionSummary');
|
||||
return (
|
||||
<tr key={asset.id}>
|
||||
<td>{index + 1}.</td>
|
||||
<td>
|
||||
<Link to={`/admin/moderate/flagged/${asset.id}`}>{asset.title}</Link>
|
||||
<p className={styles.lede}>{asset.author} - Published: {new Date(asset.created_at).toLocaleDateString()}</p>
|
||||
</td>
|
||||
<td>{flagSummary ? flagSummary.actionCount : 0}</td>
|
||||
<td>{likeSummary ? likeSummary.actionCount : 0}</td>
|
||||
<td>{asset.commentCount}</td>
|
||||
</tr>
|
||||
);
|
||||
})
|
||||
: <tr><td colSpan="3">{lang.t('dashboard.no_flags')}</td></tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
};
|
||||
|
||||
FlagWidget.propTypes = {
|
||||
assets: PropTypes.arrayOf(
|
||||
PropTypes.shape({
|
||||
id: PropTypes.string,
|
||||
title: PropTypes.string,
|
||||
url: PropTypes.string,
|
||||
commentCount: PropTypes.number,
|
||||
action_summaries: PropTypes.arrayOf(
|
||||
PropTypes.shape({
|
||||
__typename: PropTypes.string.isRequired,
|
||||
actionCount: PropTypes.number.isRequired,
|
||||
actionableItemCount: PropTypes.number.isRequired
|
||||
})
|
||||
)
|
||||
})
|
||||
).isRequired
|
||||
};
|
||||
|
||||
export default FlagWidget;
|
||||
@@ -1,19 +1,5 @@
|
||||
.Dashboard {
|
||||
display: flex;
|
||||
padding: 5px;
|
||||
}
|
||||
|
||||
.widget {
|
||||
margin-top: 10px;
|
||||
flex: 1;
|
||||
box-shadow: 0px 0px 5px 0px rgba(0,0,0,0.2);
|
||||
margin-right: 10px;
|
||||
padding: 15px;
|
||||
|
||||
}
|
||||
|
||||
.widget:last-child {
|
||||
margin-right: 0;
|
||||
}
|
||||
|
||||
.heading {
|
||||
|
||||
@@ -1,38 +1,46 @@
|
||||
import React from 'react';
|
||||
import {compose} from 'react-apollo';
|
||||
import {mostFlags} from 'coral-admin/src/graphql/queries';
|
||||
import {Spinner} from 'coral-ui';
|
||||
import styles from './Dashboard.css';
|
||||
import FlagWidget from '../../components/FlagWidget';
|
||||
import {compose} from 'react-apollo';
|
||||
import {connect} from 'react-redux';
|
||||
import {getMetrics} from 'coral-admin/src/graphql/queries';
|
||||
import FlagWidget from './FlagWidget';
|
||||
import LikeWidget from './LikeWidget';
|
||||
import {showBanUserDialog, hideBanUserDialog} from 'coral-admin/src/actions/moderation';
|
||||
|
||||
import {Spinner} from 'coral-ui';
|
||||
|
||||
class Dashboard extends React.Component {
|
||||
|
||||
render () {
|
||||
|
||||
const {data} = this.props;
|
||||
const {metrics: assets} = data;
|
||||
|
||||
if (data.loading) {
|
||||
if (this.props.data && this.props.data.loading) {
|
||||
return <Spinner />;
|
||||
}
|
||||
|
||||
if (data.error) {
|
||||
return <code><pre>{data.error}</pre></code>;
|
||||
}
|
||||
const {data: {assetsByLike, assetsByFlag}} = this.props;
|
||||
|
||||
return (
|
||||
<div className={styles.Dashboard}>
|
||||
<div className={styles.widget}>
|
||||
<h2 className={styles.heading}>Top Ten Articles with the most flagged comments</h2>
|
||||
<FlagWidget assets={assets} />
|
||||
</div>
|
||||
<div className={styles.widget}>
|
||||
<h2 className={styles.heading}>Top ten comments with the most likes</h2>
|
||||
</div>
|
||||
<FlagWidget assets={assetsByFlag} />
|
||||
<LikeWidget assets={assetsByLike} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const mapStateToProps = state => {
|
||||
return {
|
||||
settings: state.settings.toJS(),
|
||||
moderation: state.moderation.toJS()
|
||||
};
|
||||
};
|
||||
|
||||
const mapDispatchToProps = dispatch => ({
|
||||
showBanUserDialog: (user, commentId) => dispatch(showBanUserDialog(user, commentId)),
|
||||
hideBanUserDialog: () => dispatch(hideBanUserDialog(false))
|
||||
});
|
||||
|
||||
export default compose(
|
||||
mostFlags
|
||||
connect(mapStateToProps, mapDispatchToProps),
|
||||
getMetrics
|
||||
)(Dashboard);
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import React from 'react';
|
||||
import {Link} from 'react-router';
|
||||
import styles from './Widget.css';
|
||||
import I18n from 'coral-framework/modules/i18n/i18n';
|
||||
import translations from 'coral-admin/src/translations';
|
||||
|
||||
const lang = new I18n(translations);
|
||||
|
||||
const FlagWidget = (props) => {
|
||||
const {assets} = props;
|
||||
|
||||
return (
|
||||
<div className={styles.widget}>
|
||||
<h2 className={styles.heading}>Articles with the most flags</h2>
|
||||
<table className={styles.widgetTable}>
|
||||
<thead className={styles.widgetHead}>
|
||||
<tr>
|
||||
<th></th>{/* empty on purpose */}
|
||||
<th>{lang.t('streams.article')}</th>
|
||||
<th>{lang.t('modqueue.flagged')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{
|
||||
assets.length
|
||||
? assets.map((asset, index) => {
|
||||
const flagSummary = asset.action_summaries.find(s => s.type === 'FlagAssetActionSummary');
|
||||
return (
|
||||
<tr key={asset.id}>
|
||||
<td>{index + 1}.</td>
|
||||
<td>
|
||||
<Link to={`/admin/moderate/flagged/${asset.id}`}>{asset.title}</Link>
|
||||
<p className={styles.lede}>{asset.author} - Published: {new Date(asset.created_at).toLocaleDateString()}</p>
|
||||
</td>
|
||||
<td>{flagSummary ? flagSummary.actionCount : 0}</td>
|
||||
</tr>
|
||||
);
|
||||
})
|
||||
: <tr><td colSpan="3">{lang.t('dashboard.no_flags')}</td></tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default FlagWidget;
|
||||
@@ -0,0 +1,48 @@
|
||||
import React from 'react';
|
||||
import {Link} from 'react-router';
|
||||
import styles from './Widget.css';
|
||||
import I18n from 'coral-framework/modules/i18n/i18n';
|
||||
import translations from 'coral-admin/src/translations';
|
||||
|
||||
const lang = new I18n(translations);
|
||||
|
||||
const LikeWidget = (props) => {
|
||||
|
||||
const {assets} = props;
|
||||
|
||||
return (
|
||||
<div className={styles.widget}>
|
||||
<h2 className={styles.heading}>Articles with the most likes</h2>
|
||||
<table className={styles.widgetTable}>
|
||||
<thead className={styles.widgetHead}>
|
||||
<tr>
|
||||
<th></th>{/* empty on purpose */}
|
||||
<th>{lang.t('streams.article')}</th>
|
||||
<th>{lang.t('modqueue.likes')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{
|
||||
assets.length
|
||||
? assets.map((asset, index) => {
|
||||
const likeSummary = asset.action_summaries.find(s => s.type === 'LikeAssetActionSummary');
|
||||
return (
|
||||
<tr key={asset.id}>
|
||||
<td>{index + 1}.</td>
|
||||
<td>
|
||||
<Link to={`/admin/moderate/flagged/${asset.id}`}>{asset.title}</Link>
|
||||
<p className={styles.lede}>{asset.author} - Published: {new Date(asset.created_at).toLocaleDateString()}</p>
|
||||
</td>
|
||||
<td>{likeSummary ? likeSummary.actionCount : 0}</td>
|
||||
</tr>
|
||||
);
|
||||
})
|
||||
: <tr><td colSpan="3">{lang.t('dashboard.no_likes')}</td></tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default LikeWidget;
|
||||
@@ -0,0 +1,40 @@
|
||||
import React from 'react';
|
||||
import I18n from 'coral-framework/modules/i18n/i18n';
|
||||
import translations from 'coral-admin/src/translations';
|
||||
import ModerationQueue from 'coral-admin/src/containers/ModerationQueue/ModerationQueue';
|
||||
import styles from './Widget.css';
|
||||
import BanUserDialog from 'coral-admin/src/components/BanUserDialog';
|
||||
|
||||
const lang = new I18n(translations);
|
||||
|
||||
const MostLikedCommentsWidget = props => {
|
||||
const {
|
||||
comments,
|
||||
moderation,
|
||||
settings,
|
||||
handleBanUser,
|
||||
showBanUserDialog,
|
||||
hideBanUserDialog,
|
||||
acceptComment,
|
||||
rejectComment
|
||||
} = props;
|
||||
|
||||
return (
|
||||
<div className={styles.widget}>
|
||||
<h2 className={styles.heading}>{lang.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;
|
||||
@@ -0,0 +1,42 @@
|
||||
.widget {
|
||||
box-sizing: border-box;
|
||||
margin: 10px 5px 5px 5px;
|
||||
box-shadow: 0px 0px 5px 0px rgba(0,0,0,0.2);
|
||||
padding: 15px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.heading {
|
||||
margin: 0;
|
||||
font-size: 1.5rem;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.widgetTable {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
box-shadow: 0px 0px 5px 0px rgba(0,0,0,0.2);
|
||||
}
|
||||
|
||||
.widgetTable thead th {
|
||||
border-bottom: 1px solid #f47e6b;
|
||||
padding: 10px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.widgetTable tbody tr {
|
||||
border-bottom: 1px solid lightgrey;
|
||||
}
|
||||
|
||||
.widgetTable tbody tr:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.widgetTable tbody td {
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.lede {
|
||||
font-size: 0.9em;
|
||||
color: grey;
|
||||
}
|
||||
@@ -65,6 +65,8 @@ class ModerationContainer extends Component {
|
||||
}
|
||||
}
|
||||
|
||||
const comments = data[activeTab];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<ModerationHeader asset={asset} />
|
||||
@@ -76,9 +78,8 @@ class ModerationContainer extends Component {
|
||||
modQueueResort={modQueueResort}
|
||||
/>
|
||||
<ModerationQueue
|
||||
data={data}
|
||||
currentAsset={asset}
|
||||
activeTab={activeTab}
|
||||
comments={comments}
|
||||
suspectWords={settings.wordlist.suspect}
|
||||
showBanUserDialog={props.showBanUserDialog}
|
||||
acceptComment={props.acceptComment}
|
||||
|
||||
@@ -7,15 +7,13 @@ import I18n from 'coral-framework/modules/i18n/i18n';
|
||||
import translations from 'coral-admin/src/translations';
|
||||
|
||||
const lang = new I18n(translations);
|
||||
|
||||
const ModerationQueue = ({activeTab = 'premod', ...props}) => {
|
||||
const areComments = props.data[activeTab].length;
|
||||
const ModerationQueue = ({comments, ...props}) => {
|
||||
return (
|
||||
<div id="moderationList">
|
||||
<ul style={{paddingLeft: 0}}>
|
||||
{
|
||||
areComments
|
||||
? props.data[activeTab].map((comment, i) => {
|
||||
comments.length
|
||||
? comments.map((comment, i) => {
|
||||
const status = comment.action_summaries ? 'FLAGGED' : comment.status;
|
||||
return <Comment
|
||||
key={i}
|
||||
@@ -37,12 +35,12 @@ const ModerationQueue = ({activeTab = 'premod', ...props}) => {
|
||||
};
|
||||
|
||||
ModerationQueue.propTypes = {
|
||||
data: PropTypes.object.isRequired,
|
||||
acceptComment: PropTypes.func.isRequired,
|
||||
rejectComment: PropTypes.func.isRequired,
|
||||
showBanUserDialog: PropTypes.func.isRequired,
|
||||
suspectWords: PropTypes.arrayOf(PropTypes.string).isRequired,
|
||||
currentAsset: PropTypes.object,
|
||||
suspectWords: PropTypes.arrayOf(PropTypes.string).isRequired
|
||||
showBanUserDialog: PropTypes.func.isRequired,
|
||||
rejectComment: PropTypes.func.isRequired,
|
||||
acceptComment: PropTypes.func.isRequired,
|
||||
comments: PropTypes.array.isRequired
|
||||
};
|
||||
|
||||
export default ModerationQueue;
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
fragment metrics on Asset {
|
||||
id
|
||||
title
|
||||
url
|
||||
author
|
||||
created_at
|
||||
action_summaries {
|
||||
type: __typename
|
||||
actionCount
|
||||
actionableItemCount
|
||||
}
|
||||
}
|
||||
@@ -1,23 +1,7 @@
|
||||
import {graphql} from 'react-apollo';
|
||||
|
||||
import MOST_FLAGS from './mostFlags.graphql';
|
||||
import MOD_QUEUE_QUERY from './modQueueQuery.graphql';
|
||||
|
||||
export const mostFlags = graphql(MOST_FLAGS, {
|
||||
options: () => {
|
||||
|
||||
// currently hard-coded per Greg's advice
|
||||
const fiveMinutesAgo = new Date();
|
||||
fiveMinutesAgo.setMinutes(fiveMinutesAgo.getMinutes() - 305);
|
||||
return {
|
||||
variables: {
|
||||
sort: 'FLAG',
|
||||
from: fiveMinutesAgo.toISOString(),
|
||||
to: new Date().toISOString()
|
||||
}
|
||||
};
|
||||
}
|
||||
});
|
||||
import METRICS from './metricsQuery.graphql';
|
||||
|
||||
export const modQueueQuery = graphql(MOD_QUEUE_QUERY, {
|
||||
options: ({params: {id = null}}) => {
|
||||
@@ -34,6 +18,18 @@ export const modQueueQuery = graphql(MOD_QUEUE_QUERY, {
|
||||
})
|
||||
});
|
||||
|
||||
export const getMetrics = graphql(METRICS, {
|
||||
options: ({settings: {dashboardWindowStart, dashboardWindowEnd}}) => {
|
||||
|
||||
return {
|
||||
variables: {
|
||||
from: dashboardWindowStart,
|
||||
to: dashboardWindowEnd
|
||||
}
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
export const modQueueResort = (id, fetchMore) => (sort) => {
|
||||
return fetchMore({
|
||||
query: MOD_QUEUE_QUERY,
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
#import "../fragments/assetMetricsView.graphql"
|
||||
|
||||
query Metrics ($from: Date!, $to: Date!) {
|
||||
assetsByFlag: assetMetrics(from: $from, to: $to, sort: FLAG) {
|
||||
...metrics
|
||||
}
|
||||
assetsByLike: assetMetrics(from: $from, to: $to, sort: LIKE) {
|
||||
...metrics
|
||||
}
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
query Metrics ($from: Date!, $to: Date!, $sort: ACTION_TYPE!) {
|
||||
metrics(from: $from, to: $to, sort: $sort) {
|
||||
id
|
||||
title
|
||||
url
|
||||
commentCount
|
||||
author
|
||||
created_at
|
||||
action_summaries {
|
||||
actionCount
|
||||
actionableItemCount
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,22 @@
|
||||
import {Map, List} from 'immutable';
|
||||
import * as actions from '../actions/settings';
|
||||
|
||||
// this is initialized here because
|
||||
// currently you have to reload the dashboard to get new stats
|
||||
// cleaner updates are planned in the future.
|
||||
// TODO: if there are more than two fields for the dashboard being created here,
|
||||
// please create a new reducer specifically for the Dashboard.
|
||||
const DASHBOARD_WINDOW_MINUTES = 5;
|
||||
let then = new Date();
|
||||
then.setMinutes(then.getMinutes() - DASHBOARD_WINDOW_MINUTES);
|
||||
|
||||
const initialState = Map({
|
||||
wordlist: Map({
|
||||
banned: List(),
|
||||
suspect: List()
|
||||
}),
|
||||
dashboardWindowStart: then.toISOString(),
|
||||
dashboardWindowEnd: new Date().toISOString(),
|
||||
domains: Map({
|
||||
whitelist: List()
|
||||
}),
|
||||
|
||||
@@ -114,7 +114,8 @@
|
||||
},
|
||||
"dashboard": {
|
||||
"no_flags": "There have been no flags in the last 5 minutes! Hooray!",
|
||||
"comment_count": "Comments"
|
||||
"no_likes": "There have been no likes in the last 5 minutes. All quiet.",
|
||||
"comment_count": "comments"
|
||||
},
|
||||
"streams": {
|
||||
"empty_result": "No assets match this search. Maybe try widening your search?",
|
||||
@@ -226,7 +227,8 @@
|
||||
},
|
||||
"dashbord": {
|
||||
"no_flags": "¡Nadie ha marcado nada en los últimos 5 minutos! ¡Bravo!",
|
||||
"comment_count": "Comentarios"
|
||||
"no_likes": "A nadie le ha gustado algún comentario en los últimos 5 minutos. Todo tranquilo.",
|
||||
"comment_count": "comentarios"
|
||||
},
|
||||
"streams": {
|
||||
"empty_result": "No se encuentro articulo con esta busqueda. Tal vez extender la busqueda?",
|
||||
|
||||
@@ -137,12 +137,9 @@ class SignUpContent extends React.Component {
|
||||
</div>
|
||||
}
|
||||
<div className={styles.footer}>
|
||||
<span>
|
||||
{lang.t('signIn.alreadyHaveAnAccount')}
|
||||
<a id="coralSignInViewTrigger" onClick={() => changeView('SIGNIN')}>
|
||||
{lang.t('signIn.signIn')}
|
||||
</a>
|
||||
</span>
|
||||
{lang.t('signIn.alreadyHaveAnAccount')} <a id="coralSignInViewTrigger" onClick={() => changeView('SIGNIN')}>
|
||||
{lang.t('signIn.signIn')}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -270,6 +270,21 @@ const genRecentComments = (_, ids) => {
|
||||
.then(util.arrayJoinBy(ids, 'asset_id'));
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns the comment's by their id.
|
||||
* @param {Object} context graph context
|
||||
* @param {Array<String>} ids the comment id's to fetch
|
||||
* @return {Promise} resolves to the comments
|
||||
*/
|
||||
const genCommentsByID = (context, ids) => {
|
||||
return CommentModel.find({
|
||||
id: {
|
||||
$in: ids
|
||||
}
|
||||
})
|
||||
.then(util.singleJoinBy(ids, 'id'));
|
||||
};
|
||||
|
||||
/**
|
||||
* Creates a set of loaders based on a GraphQL context.
|
||||
* @param {Object} context the context of the GraphQL request
|
||||
@@ -277,6 +292,7 @@ const genRecentComments = (_, ids) => {
|
||||
*/
|
||||
module.exports = (context) => ({
|
||||
Comments: {
|
||||
get: new DataLoader((ids) => genCommentsByID(context, ids)),
|
||||
getByQuery: (query) => getCommentsByQuery(context, query),
|
||||
getCountByQuery: (query) => getCommentCountByQuery(context, query),
|
||||
countByAssetID: new util.SharedCacheDataLoader('Comments.countByAssetID', 3600, (ids) => getCountsByAssetID(context, ids)),
|
||||
|
||||
+96
-34
@@ -2,10 +2,12 @@ const _ = require('lodash');
|
||||
const DataLoader = require('dataloader');
|
||||
const {objectCacheKeyFn} = require('./util');
|
||||
|
||||
const CommentModel = require('../../models/comment');
|
||||
const ActionModel = require('../../models/action');
|
||||
|
||||
const getMetrics = ({loaders: {Metrics, Assets}}, {from, to, sort, limit}) => {
|
||||
/**
|
||||
* Returns a list of assets with action metadata included on the models.
|
||||
*/
|
||||
const getAssetMetrics = ({loaders: {Metrics, Assets, Comments}}, {from, to, sort, limit}) => {
|
||||
|
||||
let commentMetrics = {};
|
||||
let assetMetrics = [];
|
||||
@@ -14,6 +16,10 @@ const getMetrics = ({loaders: {Metrics, Assets}}, {from, to, sort, limit}) => {
|
||||
.then((actionSummaries) => {
|
||||
|
||||
commentMetrics = actionSummaries.reduce((acc, {item_id, action_type, count}) => {
|
||||
if (action_type !== sort) {
|
||||
return acc;
|
||||
}
|
||||
|
||||
if (!(item_id in acc)) {
|
||||
acc[item_id] = [];
|
||||
}
|
||||
@@ -24,10 +30,10 @@ const getMetrics = ({loaders: {Metrics, Assets}}, {from, to, sort, limit}) => {
|
||||
}, {});
|
||||
|
||||
// Collect just the comment id's.
|
||||
let commentIDs = _.uniq(actionSummaries.map((as) => as.item_id));
|
||||
let commentIDs = Object.keys(commentMetrics);
|
||||
|
||||
// Find those comments.
|
||||
return Metrics.getSpecificComments.loadMany(commentIDs);
|
||||
return Comments.get.loadMany(commentIDs);
|
||||
})
|
||||
.then((comments) => {
|
||||
|
||||
@@ -44,29 +50,24 @@ const getMetrics = ({loaders: {Metrics, Assets}}, {from, to, sort, limit}) => {
|
||||
}));
|
||||
|
||||
return {action_summaries, id: asset_id};
|
||||
});
|
||||
})
|
||||
|
||||
.filter((asset) => {
|
||||
let contextActionSummary = asset.action_summaries.find((({action_type}) => action_type === sort));
|
||||
if (contextActionSummary === null) {
|
||||
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.
|
||||
assetMetrics.sort((a, b) => {
|
||||
.sort((a, b) => {
|
||||
let aActionSummary = a.action_summaries.find((({action_type}) => action_type === sort));
|
||||
let bActionSummary = b.action_summaries.find((({action_type}) => action_type === sort));
|
||||
|
||||
// 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.actionCount - aActionSummary.actionCount;
|
||||
@@ -99,6 +100,75 @@ const getMetrics = ({loaders: {Metrics, Assets}}, {from, to, sort, limit}) => {
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns a list of comments that are retrieved based on most activity within
|
||||
* the indicated time range.
|
||||
*/
|
||||
const getCommentMetrics = ({loaders: {Metrics, Comments}}, {from, to, sort, limit}) => {
|
||||
|
||||
let commentActionSummaries = {};
|
||||
|
||||
return Metrics.getRecentActions.load({from, to})
|
||||
.then((actionSummaries) => {
|
||||
|
||||
actionSummaries.sort((a, b) => {
|
||||
let aActionSummary = a.action_type === sort ? a : null;
|
||||
let bActionSummary = b.action_type === sort ? 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 === sort);
|
||||
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.
|
||||
return Comments.get.loadMany(commentIDs);
|
||||
})
|
||||
.then((comments) => 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([
|
||||
|
||||
@@ -131,25 +201,17 @@ const getRecentActions = (context, {from, to}) => {
|
||||
]);
|
||||
};
|
||||
|
||||
const getSpecificComments = (context, ids) => {
|
||||
return CommentModel.find({
|
||||
id: {
|
||||
$in: ids
|
||||
}
|
||||
})
|
||||
.select({
|
||||
id: 1,
|
||||
asset_id: 1
|
||||
});
|
||||
};
|
||||
|
||||
module.exports = (context) => ({
|
||||
Metrics: {
|
||||
getSpecificComments: new DataLoader((ids) => getSpecificComments(context, ids)),
|
||||
getRecentActions: new DataLoader(([{from, to}]) => getRecentActions(context, {from, to}).then((as) => [as]), {
|
||||
batch: false,
|
||||
cacheKeyFn: objectCacheKeyFn('from', 'to')
|
||||
}),
|
||||
get: ({from, to, sort, limit}) => getMetrics(context, {from, to, sort, limit})
|
||||
Assets: {
|
||||
get: ({from, to, sort, limit}) => getAssetMetrics(context, {from, to, sort, limit})
|
||||
},
|
||||
Comments: {
|
||||
get: ({from, to, sort, limit}) => getCommentMetrics(context, {from, to, sort, limit})
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -25,7 +25,11 @@ const Comment = {
|
||||
|
||||
return null;
|
||||
},
|
||||
action_summaries({id}, _, {loaders: {Actions}}) {
|
||||
action_summaries({id, action_summaries}, _, {loaders: {Actions}}) {
|
||||
if (action_summaries) {
|
||||
return action_summaries;
|
||||
}
|
||||
|
||||
return Actions.getSummariesByItemID.load(id);
|
||||
},
|
||||
asset({asset_id}, _, {loaders: {Assets}}) {
|
||||
|
||||
@@ -56,12 +56,20 @@ const RootQuery = {
|
||||
return Comments.getCountByQuery({statuses, asset_id, parent_id});
|
||||
},
|
||||
|
||||
metrics(_, {from, to, sort, limit = 10}, {user, loaders: {Metrics}}) {
|
||||
assetMetrics(_, {from, to, sort, limit = 10}, {user, loaders: {Metrics: {Assets}}}) {
|
||||
if (user == null || !user.hasRoles('ADMIN')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return Metrics.get({from, to, sort, limit});
|
||||
return Assets.get({from, to, sort, limit});
|
||||
},
|
||||
|
||||
commentMetrics(_, {from, to, sort, limit = 10}, {user, loaders: {Metrics: {Comments}}}) {
|
||||
if (user == null || !user.hasRoles('ADMIN')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return Comments.get({from, to, sort, limit});
|
||||
},
|
||||
|
||||
// This returns the current user, ensure that if we aren't logged in, we
|
||||
|
||||
@@ -494,9 +494,13 @@ type RootQuery {
|
||||
# role.
|
||||
me: User
|
||||
|
||||
# Metrics related to user actions are saturated into the assets returned. The
|
||||
# sort will affect if it will allow
|
||||
metrics(from: Date!, to: Date!, sort: ACTION_TYPE!, limit: Int = 10): [Asset]
|
||||
# Asset metrics related to user actions are saturated into the assets
|
||||
# returned.
|
||||
assetMetrics(from: Date!, to: Date!, sort: ACTION_TYPE!, limit: Int = 10): [Asset!]
|
||||
|
||||
# Comment metrics related to user actions are saturated into the comments
|
||||
# returned.
|
||||
commentMetrics(from: Date!, to: Date!, sort: ACTION_TYPE!, limit: Int = 10): [Comment!]
|
||||
}
|
||||
|
||||
################################################################################
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
const {expect} = require('chai');
|
||||
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');
|
||||
|
||||
describe('graph.loaders.Metrics', () => {
|
||||
beforeEach(() => SettingsService.init());
|
||||
|
||||
describe('#Comments', () => {
|
||||
const query = `
|
||||
query CommentMetrics($from: Date!, $to: Date!) {
|
||||
liked: commentMetrics(from: $from, to: $to, sort: LIKE) {
|
||||
id
|
||||
}
|
||||
flagged: commentMetrics(from: $from, to: $to, sort: 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!'}
|
||||
]));
|
||||
|
||||
[
|
||||
{liked: 0, flagged: 0, actions: []},
|
||||
{liked: 1, flagged: 0, actions: [{action_type: 'LIKE', item_id: '1', item_type: 'COMMENTS'}]},
|
||||
{liked: 0, flagged: 1, actions: [{action_type: 'FLAG', item_id: '1', item_type: 'COMMENTS'}]},
|
||||
{liked: 1, flagged: 1, actions: [
|
||||
{action_type: 'FLAG', item_id: '1', item_type: 'COMMENTS'},
|
||||
{action_type: 'LIKE', item_id: '1', item_type: 'COMMENTS'}
|
||||
]},
|
||||
{liked: 3, flagged: 1, actions: [
|
||||
{action_type: 'LIKE', item_id: '1', item_type: 'COMMENTS'},
|
||||
{action_type: 'LIKE', item_id: '2', item_type: 'COMMENTS'},
|
||||
{action_type: 'LIKE', item_id: '3', item_type: 'COMMENTS'},
|
||||
{action_type: 'FLAG', item_id: '3', item_type: 'COMMENTS'}
|
||||
]}
|
||||
].forEach(({liked, flagged, actions}) => {
|
||||
|
||||
describe(`with actions=${actions.length}`, () => {
|
||||
|
||||
beforeEach(() => ActionModel.create(actions));
|
||||
|
||||
it(`returns the correct amount of metrics liked=${liked} flagged=${flagged}`, () => {
|
||||
const context = new Context({user: new UserModel({roles: ['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.liked).to.have.length(liked);
|
||||
expect(data.flagged).to.have.length(flagged);
|
||||
});
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
});
|
||||
});
|
||||
|
||||
describe('#Assets', () => {
|
||||
const query = `
|
||||
fragment metrics on Asset {
|
||||
id
|
||||
action_summaries {
|
||||
type: __typename
|
||||
actionCount
|
||||
actionableItemCount
|
||||
}
|
||||
}
|
||||
|
||||
query Metrics($from: Date!, $to: Date!) {
|
||||
assetsByFlag: assetMetrics(from: $from, to: $to, sort: FLAG) {
|
||||
...metrics
|
||||
}
|
||||
assetsByLike: assetMetrics(from: $from, to: $to, sort: LIKE) {
|
||||
...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!'}
|
||||
])
|
||||
]));
|
||||
|
||||
[
|
||||
{liked: 0, flagged: 0, actions: []},
|
||||
{liked: 1, flagged: 0, actions: [{action_type: 'LIKE', item_id: 'c1', item_type: 'COMMENTS'}]},
|
||||
{liked: 0, flagged: 1, actions: [{action_type: 'FLAG', item_id: 'c1', item_type: 'COMMENTS'}]},
|
||||
{liked: 1, flagged: 1, actions: [
|
||||
{action_type: 'FLAG', item_id: 'c1', item_type: 'COMMENTS'},
|
||||
{action_type: 'LIKE', item_id: 'c1', item_type: 'COMMENTS'}
|
||||
]},
|
||||
{liked: 1, flagged: 1, actions: [
|
||||
{action_type: 'LIKE', item_id: 'c1', item_type: 'COMMENTS'},
|
||||
{action_type: 'LIKE', item_id: 'c2', item_type: 'COMMENTS'},
|
||||
{action_type: 'LIKE', item_id: 'c3', item_type: 'COMMENTS'},
|
||||
{action_type: 'FLAG', item_id: 'c3', item_type: 'COMMENTS'}
|
||||
]}
|
||||
].forEach(({liked, flagged, actions}) => {
|
||||
|
||||
describe(`with actions=${actions.length}`, () => {
|
||||
|
||||
beforeEach(() => ActionModel.create(actions));
|
||||
|
||||
it(`returns the correct amount of metrics liked=${liked} flagged=${flagged}`, () => {
|
||||
const context = new Context({user: new UserModel({roles: ['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.assetsByLike).to.have.length(liked);
|
||||
expect(data.assetsByFlag).to.have.length(flagged);
|
||||
});
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user