+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/comments.js b/graph/loaders/comments.js
index 23f7eb29d..24b66f4bd 100644
--- a/graph/loaders/comments.js
+++ b/graph/loaders/comments.js
@@ -18,7 +18,7 @@ const getCountsByAssetID = (context, asset_ids) => {
$in: asset_ids
},
status: {
- $in: [null, 'ACCEPTED']
+ $in: ['NONE', 'ACCEPTED']
},
parent_id: null
}
@@ -51,7 +51,7 @@ const getCountsByParentID = (context, parent_ids) => {
$in: parent_ids
},
status: {
- $in: [null, 'ACCEPTED']
+ $in: ['NONE', 'ACCEPTED']
}
}
},
@@ -88,7 +88,7 @@ const getCommentsByQuery = ({user}, {ids, statuses, asset_id, parent_id, author_
} else {
comments = comments.where({
status: {
- $in: [null, 'ACCEPTED']
+ $in: ['NONE', 'ACCEPTED']
}
});
}
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/mutators/action.js b/graph/mutators/action.js
index 3499719e1..a572a641c 100644
--- a/graph/mutators/action.js
+++ b/graph/mutators/action.js
@@ -45,10 +45,6 @@ const deleteAction = ({user}, {id}) => {
};
module.exports = (context) => {
-
- // TODO: refactor to something that'll return an error in the event an attempt
- // is made to mutate state while not logged in. There's got to be a better way
- // to do this.
if (context.user && context.user.can('mutation:createAction', 'mutation:deleteAction')) {
return {
Action: {
diff --git a/graph/mutators/comment.js b/graph/mutators/comment.js
index 98891953c..5007a3039 100644
--- a/graph/mutators/comment.js
+++ b/graph/mutators/comment.js
@@ -11,10 +11,10 @@ const Wordlist = require('../../services/wordlist');
* @param {String} body body of the comment
* @param {String} asset_id asset for the comment
* @param {String} parent_id optional parent of the comment
- * @param {String} [status=null] the status of the new comment
+ * @param {String} [status='NONE'] the status of the new comment
* @return {Promise} resolves to the created comment
*/
-const createComment = ({user, loaders: {Comments}}, {body, asset_id, parent_id = null}, status = null) => {
+const createComment = ({user, loaders: {Comments}}, {body, asset_id, parent_id = null}, status = 'NONE') => {
return CommentsService.publicCreate({
body,
asset_id,
@@ -105,7 +105,7 @@ const resolveNewCommentStatus = (context, {asset_id, body}, wordlist = {}) => {
if (charCountEnable && body.length > charCount) {
return 'REJECTED';
}
- return moderation === 'PRE' ? 'PREMOD' : null;
+ return moderation === 'PRE' ? 'PREMOD' : 'NONE';
});
}
@@ -169,16 +169,26 @@ const createPublicComment = (context, commentInput) => {
* @param {String} status the new status of the comment
*/
-const setCommentStatus = ({comment}, {id, status}) => {
- return CommentsService.setStatus(id, status)
- .then(res => res);
+const setCommentStatus = ({loaders: {Comments}}, {id, status}) => {
+ return CommentsService
+ .setStatus(id, status)
+ .then((comment) => {
+
+ // If the loaders are present, clear the caches for these values because we
+ // just added a new comment, hence the counts should be updated.
+ if (Comments && Comments.countByAssetID && Comments.countByParentID) {
+ if (comment.parent_id != null) {
+ Comments.countByParentID.clear(comment.parent_id);
+ } else {
+ Comments.countByAssetID.clear(comment.asset_id);
+ }
+ }
+
+ return comment;
+ });
};
module.exports = (context) => {
-
- // TODO: refactor to something that'll return an error in the event an attempt
- // is made to mutate state while not logged in. There's got to be a better way
- // to do this.
let mutators = {
Comment: {
create: () => Promise.reject(errors.ErrNotAuthorized),
diff --git a/graph/mutators/user.js b/graph/mutators/user.js
index 2c43f11be..3f87c1fb5 100644
--- a/graph/mutators/user.js
+++ b/graph/mutators/user.js
@@ -7,10 +7,6 @@ const setUserStatus = ({user}, {id, status}) => {
};
module.exports = (context) => {
-
- // TODO: refactor to something that'll return an error in the event an attempt
- // is made to mutate state while not logged in. There's got to be a better way
- // to do this.
if (context.user && context.user.can('mutation:setUserStatus')) {
return {
User: {
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 b1c4824e8..cabb50985 100644
--- a/graph/typeDefs.graphql
+++ b/graph/typeDefs.graphql
@@ -67,6 +67,10 @@ type Tag {
# The statuses that a comment may have.
enum COMMENT_STATUS {
+ # The comment is not PREMOD, but was not applied a moderation status by a
+ # moderator.
+ NONE
+
# The comment has been accepted by a moderator.
ACCEPTED
@@ -93,7 +97,7 @@ enum ACTION_TYPE {
input CommentsQuery {
# current status of a comment.
- statuses: [COMMENT_STATUS]
+ statuses: [COMMENT_STATUS!]
# asset that a comment is on.
asset_id: ID
@@ -152,7 +156,7 @@ type Comment {
asset: Asset
# The current status of a comment.
- status: COMMENT_STATUS
+ status: COMMENT_STATUS!
# The time when the comment was created
created_at: Date!
@@ -188,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 {
@@ -274,6 +308,8 @@ type Settings {
infoBoxEnable: Boolean
infoBoxContent: String
+ questionBoxEnable: Boolean
+ questionBoxContent: String
closeTimeout: Int
closedMessage: String
charCountEnable: Boolean
@@ -313,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
}
################################################################################
@@ -349,7 +392,7 @@ type ValidationUserError implements UserError {
}
################################################################################
-## Queries
+## Queries;
################################################################################
# Establishes the ordering of the content by their created_at time stamp.
@@ -386,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/comment.js b/models/comment.js
index ac44d904e..c88041c8a 100644
--- a/models/comment.js
+++ b/models/comment.js
@@ -6,7 +6,7 @@ const STATUSES = [
'ACCEPTED',
'REJECTED',
'PREMOD',
- null
+ 'NONE'
];
/**
@@ -66,7 +66,11 @@ const CommentSchema = new Schema({
asset_id: String,
author_id: String,
status_history: [StatusSchema],
- status: {type: String, default: null},
+ status: {
+ type: String,
+ enum: STATUSES,
+ default: 'NONE'
+ },
tags: [TagSchema],
parent_id: String
}, {
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
},
diff --git a/routes/api/comments/index.js b/routes/api/comments/index.js
index 96f3911d4..e77ca0c30 100644
--- a/routes/api/comments/index.js
+++ b/routes/api/comments/index.js
@@ -52,7 +52,7 @@ router.get('/', (req, res, next) => {
if (user_id) {
query = CommentsService.findByUserId(user_id, authorization.has(req.user, 'ADMIN'));
} else if (status) {
- query = assetIDWrap(CommentsService.findByStatus(status === 'NEW' ? null : status));
+ query = assetIDWrap(CommentsService.findByStatus(status === 'NEW' ? 'NONE' : status));
} else if (action_type) {
query = CommentsService
.findIdsByActionType(action_type)
diff --git a/services/comments.js b/services/comments.js
index bcc534c51..ad9ce18db 100644
--- a/services/comments.js
+++ b/services/comments.js
@@ -11,6 +11,7 @@ const STATUSES = [
'ACCEPTED',
'REJECTED',
'PREMOD',
+ 'NONE',
];
module.exports = class CommentsService {
@@ -31,7 +32,7 @@ module.exports = class CommentsService {
body,
asset_id,
parent_id,
- status = null,
+ status = 'NONE',
author_id
} = comment;
@@ -146,7 +147,7 @@ module.exports = class CommentsService {
* @param {String} status status of the comment to search for
* @return {Promise} resovles to comment array
*/
- static findByStatus(status = null) {
+ static findByStatus(status = 'NONE') {
return CommentModel.find({status});
}
@@ -155,7 +156,7 @@ module.exports = class CommentsService {
* @param {String} asset_id
* @return {Promise}
*/
- static moderationQueue(status = null, asset_id = null) {
+ static moderationQueue(status = 'NONE', asset_id = null) {
// Fetch the comments with statuses.
let comments = CommentModel.find({status});
@@ -272,7 +273,7 @@ module.exports = class CommentsService {
return Promise.reject(new Error(`status ${status} is not supported`));
}
- return CommentModel.update({id}, {
+ return CommentModel.findOneAndUpdate({id}, {
$set: {status}
});
}
diff --git a/test/services/comments.js b/test/services/comments.js
index 4c3e0eab9..0bab40bfa 100644
--- a/test/services/comments.js
+++ b/test/services/comments.js
@@ -131,7 +131,7 @@ describe('services.CommentsService', () => {
expect(c2).to.not.be.null;
expect(c2.id).to.be.uuid;
- expect(c2.status).to.be.null;
+ expect(c2.status).to.be.equal('NONE');
expect(c3).to.not.be.null;
expect(c3.id).to.be.uuid;
@@ -225,7 +225,7 @@ describe('services.CommentsService', () => {
return CommentsService.findById(comment_id)
.then((c) => {
- expect(c.status).to.be.null;
+ expect(c.status).to.be.equal('NONE');
return CommentsService.pushStatus(comment_id, 'REJECTED', '123');
})