diff --git a/client/coral-admin/src/AppRouter.js b/client/coral-admin/src/AppRouter.js index bd72bbb8c..d96bacfe0 100644 --- a/client/coral-admin/src/AppRouter.js +++ b/client/coral-admin/src/AppRouter.js @@ -9,6 +9,7 @@ import CommunityContainer from 'containers/Community/CommunityContainer'; import ModerationLayout from 'containers/ModerationQueue/ModerationLayout'; import ModerationContainer from 'containers/ModerationQueue/ModerationContainer'; +import Dashboard from 'containers/Dashboard/Dashboard'; const routes = (
@@ -18,6 +19,7 @@ const routes = ( + {/* Moderation Routes */} diff --git a/client/coral-admin/src/components/BanUserDialog.css b/client/coral-admin/src/components/BanUserDialog.css index 054c343dd..a46b9da32 100644 --- a/client/coral-admin/src/components/BanUserDialog.css +++ b/client/coral-admin/src/components/BanUserDialog.css @@ -22,17 +22,17 @@ } } -.formField { +.textField { margin-top: 15px; } -.formField label { +.textField label { font-size: 1.08em; font-weight: bold; margin-bottom: 5px; } -.formField input { +.textField input { width: 100%; display: block; border: none; diff --git a/client/coral-admin/src/components/FlagWidget.css b/client/coral-admin/src/components/FlagWidget.css new file mode 100644 index 000000000..f40da403d --- /dev/null +++ b/client/coral-admin/src/components/FlagWidget.css @@ -0,0 +1,34 @@ +.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; +} diff --git a/client/coral-admin/src/components/FlagWidget.js b/client/coral-admin/src/components/FlagWidget.js new file mode 100644 index 000000000..8116d4ff9 --- /dev/null +++ b/client/coral-admin/src/components/FlagWidget.js @@ -0,0 +1,66 @@ +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 ( + + + + {/* empty on purpose */} + + + + + + + + { + assets.length + ? assets.map((asset, index) => { + const flagCount = asset.action_summaries.find(s => s.__typename === 'FlagAssetActionSummary').actionCount; + const likeCount = asset.action_summaries.find(s => s.__typename === 'LikeAssetActionSummary').actionCount; + return ( + + + + + + + + ); + }) + : + } + +
{lang.t('streams.article')}{lang.t('modqueue.flagged')}{lang.t('modqueue.likes')}{lang.t('dashboard.comment_count')}
{index + 1}. + {asset.title} +

{asset.author} - Published: {new Date(asset.created_at).toLocaleDateString()}

+
{likeCount}{flagCount}{asset.commentCount}
{lang.t('dashboard.no_flags')}
+ ); +}; + +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; diff --git a/client/coral-admin/src/components/ui/Header.js b/client/coral-admin/src/components/ui/Header.js index e92e3c84f..6eacaba9e 100644 --- a/client/coral-admin/src/components/ui/Header.js +++ b/client/coral-admin/src/components/ui/Header.js @@ -13,21 +13,33 @@ export default ({handleLogout, restricted = false}) => ( !restricted ?
- - {lang.t('configure.moderate')} - - + + {lang.t('configure.moderate')} + + {lang.t('configure.streams')} - - {lang.t('configure.community')} + + {lang.t('configure.community')} - - {lang.t('configure.configure')} + + {lang.t('configure.configure')} + + + {lang.t('configure.dashboard')}
diff --git a/client/coral-admin/src/containers/Dashboard/Dashboard.css b/client/coral-admin/src/containers/Dashboard/Dashboard.css new file mode 100644 index 000000000..be2cd9f7a --- /dev/null +++ b/client/coral-admin/src/containers/Dashboard/Dashboard.css @@ -0,0 +1,23 @@ +.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 { + margin: 0; + font-size: 1.5rem; + font-weight: bold; +} diff --git a/client/coral-admin/src/containers/Dashboard/Dashboard.js b/client/coral-admin/src/containers/Dashboard/Dashboard.js new file mode 100644 index 000000000..6baa16af1 --- /dev/null +++ b/client/coral-admin/src/containers/Dashboard/Dashboard.js @@ -0,0 +1,38 @@ +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'; + +class Dashboard extends React.Component { + render () { + + const {data} = this.props; + const {metrics: assets} = data; + + if (data.loading) { + return ; + } + + if (data.error) { + return
{data.error}
; + } + + return ( +
+
+

Top Ten Articles with the most flagged comments

+ +
+
+

Top ten comments with the most likes

+
+
+ ); + } +} + +export default compose( + mostFlags +)(Dashboard); diff --git a/client/coral-admin/src/containers/Install/components/Steps/AddOrganizationName.js b/client/coral-admin/src/containers/Install/components/Steps/AddOrganizationName.js index 0f85bbad4..ae23d0048 100644 --- a/client/coral-admin/src/containers/Install/components/Steps/AddOrganizationName.js +++ b/client/coral-admin/src/containers/Install/components/Steps/AddOrganizationName.js @@ -1,6 +1,6 @@ import React from 'react'; import styles from './style.css'; -import {FormField, Button} from 'coral-ui'; +import {TextField, Button} from 'coral-ui'; const AddOrganizationName = props => { const {handleSettingsChange, handleSettingsSubmit, install} = props; @@ -12,8 +12,8 @@ const AddOrganizationName = props => {

- { const {handleUserChange, handleUserSubmit, install} = props; @@ -8,8 +8,8 @@ const InitialStep = props => {
- { noValidate /> - { errorMsg={install.errors.username} /> - { errorMsg={install.errors.password} /> - { const {nextStep} = props; @@ -14,19 +14,19 @@ const InviteTeamMembers = props => {
- - -
+
this.emailInput = input} diff --git a/client/coral-sign-in/components/SignInContent.js b/client/coral-sign-in/components/SignInContent.js index eebaf960b..1f339979b 100644 --- a/client/coral-sign-in/components/SignInContent.js +++ b/client/coral-sign-in/components/SignInContent.js @@ -1,6 +1,6 @@ import React, {PropTypes} from 'react'; import Alert from './Alert'; -import {Button, FormField, Spinner, Success} from 'coral-ui'; +import {Button, TextField, Spinner, Success} from 'coral-ui'; import styles from './styles.css'; import I18n from 'coral-framework/modules/i18n/i18n'; import translations from '../translations'; @@ -32,7 +32,7 @@ const SignInContent = ({ auth.emailVerificationFailure ?

{lang.t('signIn.requestNewVerifyEmail')}

-
- -
- - - { errors.password && Password must be at least 8 characters. } - ( -
+const TextField = ({className, showErrors = false, errorMsg, label, ...props}) => ( +
@@ -15,7 +15,7 @@ const FormField = ({className, showErrors = false, errorMsg, label, ...props}) =
); -FormField.propTypes = { +TextField.propTypes = { label: PropTypes.string, value: PropTypes.string, onChange: PropTypes.func, @@ -23,4 +23,4 @@ FormField.propTypes = { type: PropTypes.string }; -export default FormField; +export default TextField; diff --git a/client/coral-ui/index.js b/client/coral-ui/index.js index beb2396f5..255259c32 100644 --- a/client/coral-ui/index.js +++ b/client/coral-ui/index.js @@ -13,7 +13,7 @@ export {default as Icon} from './components/Icon'; export {default as List} from './components/List'; export {default as Item} from './components/Item'; export {default as Card} from './components/Card'; -export {default as FormField} from './components/FormField'; +export {default as TextField} from './components/TextField'; export {default as Success} from './components/Success'; export {default as Pager} from './components/Pager'; export {default as Wizard} from './components/Wizard'; diff --git a/graph/loaders/assets.js b/graph/loaders/assets.js index a07a18a3f..ba8fb3751 100644 --- a/graph/loaders/assets.js +++ b/graph/loaders/assets.js @@ -46,6 +46,15 @@ const findOrCreateAssetByURL = (context, asset_url) => { }); }; +const getAssetsForMetrics = ({loaders: {Actions, Comments}}) => { + return Actions.getByTypes({action_type: 'FLAG', item_type: 'COMMENT'}) + .then((actions) => { // ALL ACTIONS :O + const ids = actions.map(({item_id}) => item_id); + + return Comments.getByQuery({ids}); + }); +}; + /** * Creates a set of loaders based on a GraphQL context. * @param {Object} context the context of the GraphQL request @@ -59,6 +68,7 @@ module.exports = (context) => ({ getByURL: (url) => findOrCreateAssetByURL(context, url), getByID: new DataLoader((ids) => genAssetsByID(context, ids)), + getForMetrics: () => getAssetsForMetrics(context), getAll: new util.SingletonResolver(() => AssetModel.find({})) } }); diff --git a/graph/loaders/index.js b/graph/loaders/index.js index 536e40fa9..5b1894b65 100644 --- a/graph/loaders/index.js +++ b/graph/loaders/index.js @@ -3,6 +3,7 @@ const _ = require('lodash'); const Actions = require('./actions'); const Assets = require('./assets'); const Comments = require('./comments'); +const Metrics = require('./metrics'); const Settings = require('./settings'); const Users = require('./users'); @@ -18,6 +19,7 @@ module.exports = (context) => { Actions, Assets, Comments, + Metrics, Settings, Users ].map((loaders) => { diff --git a/graph/loaders/metrics.js b/graph/loaders/metrics.js new file mode 100644 index 000000000..a842165c0 --- /dev/null +++ b/graph/loaders/metrics.js @@ -0,0 +1,155 @@ +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}) => { + + let commentMetrics = {}; + let assetMetrics = []; + + return Metrics.getRecentActions.load({from, to}) + .then((actionSummaries) => { + + commentMetrics = actionSummaries.reduce((acc, {item_id, action_type, count}) => { + 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 = _.uniq(actionSummaries.map((as) => as.item_id)); + + // Find those comments. + return Metrics.getSpecificComments.loadMany(commentIDs); + }) + .then((comments) => { + + let commentResults = _.groupBy(comments, 'asset_id'); + + 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}; + }); + + // 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) => { + 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; + }); + + // Only keep the top `limit`. + assetMetrics = assetMetrics.slice(0, limit); + + // Determine the assets that we need to return. + return Assets.getByID.loadMany(assetMetrics.map((asset) => asset.id)); + }) + .then((assets) => { + + // 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); + }); +}; + +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' + }} + ]); +}; + +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}) + } +}); diff --git a/graph/loaders/util.js b/graph/loaders/util.js index 4640d8245..ab6f8a1f3 100644 --- a/graph/loaders/util.js +++ b/graph/loaders/util.js @@ -130,10 +130,20 @@ const objectCacheKeyFn = (...paths) => (obj) => { return paths.map((path) => obj[path]).join(':'); }; +/** + * Maps an object's paths to a string that can be used as a cache key. + * @param {Array} paths paths on the object to be used to generate the cache + * key + */ +const arrayCacheKeyFn = (arr) => { + return arr.sort().join(':'); +}; + module.exports = { singleJoinBy, arrayJoinBy, objectCacheKeyFn, + arrayCacheKeyFn, SingletonResolver, SharedCacheDataLoader }; diff --git a/graph/resolvers/asset_action_summary.js b/graph/resolvers/asset_action_summary.js new file mode 100644 index 000000000..c4a3cef01 --- /dev/null +++ b/graph/resolvers/asset_action_summary.js @@ -0,0 +1,12 @@ +const AssetActionSummary = { + __resolveType({action_type}) { + switch (action_type) { + case 'FLAG': + return 'FlagAssetActionSummary'; + case 'LIKE': + return 'LikeAssetActionSummary'; + } + } +}; + +module.exports = AssetActionSummary; diff --git a/graph/resolvers/index.js b/graph/resolvers/index.js index 84dd10fdc..65461dc76 100644 --- a/graph/resolvers/index.js +++ b/graph/resolvers/index.js @@ -1,5 +1,6 @@ const ActionSummary = require('./action_summary'); const Action = require('./action'); +const AssetActionSummary = require('./asset_action_summary'); const Asset = require('./asset'); const Comment = require('./comment'); const Date = require('./date'); @@ -17,6 +18,7 @@ const ValidationUserError = require('./validation_user_error'); module.exports = { ActionSummary, Action, + AssetActionSummary, Asset, Comment, Date, diff --git a/graph/resolvers/root_query.js b/graph/resolvers/root_query.js index ec3ac5b08..eb66274dd 100644 --- a/graph/resolvers/root_query.js +++ b/graph/resolvers/root_query.js @@ -39,6 +39,14 @@ const RootQuery = { return Comments.getByQuery(query); }, + metrics(_, {from, to, sort, limit = 10}, {user, loaders: {Metrics}}) { + if (user == null || !user.hasRoles('ADMIN')) { + return null; + } + + return Metrics.get({from, to, sort, limit}); + }, + // This returns the current user, ensure that if we aren't logged in, we // return null. me(_, args, {user}) { diff --git a/graph/typeDefs.graphql b/graph/typeDefs.graphql index d338c7c71..cabb50985 100644 --- a/graph/typeDefs.graphql +++ b/graph/typeDefs.graphql @@ -192,6 +192,36 @@ interface ActionSummary { current_user: Action } +# A summary of actions for a specific action type on an Asset. +interface AssetActionSummary { + + # Number of actions associated with actionable types on this this Asset. + actionCount: Int + + # Number of unique actionable types that are referenced by the actions. + actionableItemCount: Int +} + +# A summary of counts related to all the Flags on an Asset. +type FlagAssetActionSummary implements AssetActionSummary { + + # Number of flags associated with actionable types on this this Asset. + actionCount: Int + + # Number of unique actionable types that are referenced by the flags. + actionableItemCount: Int +} + +# A summary of counts related to all the Likes on an Asset. +type LikeAssetActionSummary implements AssetActionSummary { + + # Number of likes associated with actionable types on this this Asset. + actionCount: Int + + # Number of unique actionable types that are referenced by the likes. + actionableItemCount: Int +} + # LikeAction is used by users who "like" a specific entity. type LikeAction implements Action { @@ -278,6 +308,8 @@ type Settings { infoBoxEnable: Boolean infoBoxContent: String + questionBoxEnable: Boolean + questionBoxContent: String closeTimeout: Int closedMessage: String charCountEnable: Boolean @@ -317,8 +349,15 @@ type Asset { # The date that the asset was closed at. closedAt: Date + # Summary of all Actions against all entities associated with the Asset. + # (likes, flags, etc.) + action_summaries: [AssetActionSummary] + # The date that the asset was created. created_at: Date + + # The author(s) of the asset. + author: String } ################################################################################ @@ -353,7 +392,7 @@ type ValidationUserError implements UserError { } ################################################################################ -## Queries +## Queries; ################################################################################ # Establishes the ordering of the content by their created_at time stamp. @@ -390,6 +429,10 @@ type RootQuery { # The currently logged in user based on the request. 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] } ################################################################################ diff --git a/models/setting.js b/models/setting.js index 0987b141d..993384ff1 100644 --- a/models/setting.js +++ b/models/setting.js @@ -32,6 +32,14 @@ const SettingSchema = new Schema({ type: String, default: '' }, + questionBoxEnable: { + type: Boolean, + default: false + }, + questionBoxContent: { + type: String, + default: '' + }, organizationName: { type: String },