+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
},