Big voting refactor

This commit is contained in:
SachaG 2017-09-25 22:09:09 +02:00
parent 7bfa4afba3
commit 071a0fd720
14 changed files with 320 additions and 206 deletions

View file

@ -1,6 +1,6 @@
accounts-base@1.3.1
accounts-password@1.4.0
allow-deny@1.0.6
allow-deny@1.0.9
autoupdate@1.3.12
babel-compiler@6.19.4
babel-runtime@1.0.1

View file

@ -47,8 +47,8 @@ class Vote extends PureComponent {
this.props.flash(this.context.intl.formatMessage({id: 'users.please_log_in'}));
// this.stopLoading();
} else {
const voteType = hasUpvoted(user, document) ? 'cancelUpvote' : 'upvote';
this.props.vote({document, voteType, collection, currentUser: this.props.currentUser}).then(result => {
const operationType = this.props.document.currentUserVotes.length ? 'cancelVote' : 'upvote';
this.props.vote({document, operationType, collection, currentUser: this.props.currentUser}).then(result => {
// this.stopLoading();
});
}
@ -77,6 +77,9 @@ class Vote extends PureComponent {
{this.state.loading ? <Components.Icon name="spinner" /> : <Components.Icon name="upvote" /> }
<div className="sr-only">Upvote</div>
<div className="vote-count">{this.props.document.baseScore || 0}</div>
<div>{this.props.document.currentUserVotes ? this.props.document.currentUserVotes.map(vote =>
<p key={vote._id}>{vote.votedAt.toString()}, {vote.power}</p>
) : null}</div>
</a>
</div>
)

View file

@ -6,19 +6,20 @@ Categories parameter
import { addCallback, getSetting, registerSetting, getFragment, runQuery } from 'meteor/vulcan:core';
import gql from 'graphql-tag';
import Categories from './collection.js';
registerSetting('forum.categoriesFilter', 'union', 'Display posts belonging to all (“intersection”) or at least one of (“union”) the selected categories');
// Category Posts Parameters
// Add a 'categories' property to terms which can be used to filter *all* existing Posts views.
async function PostsCategoryParameter(parameters, terms, apolloClient) {
function PostsCategoryParameter(parameters, terms, apolloClient) {
// get category slugs
const cat = terms.cat || terms['cat[]'];
const categoriesSlugs = Array.isArray(cat) ? cat : [cat];
let allCategories = [];
if (cat.length) {
if (cat && cat.length) {
// get all categories
// note: specify all arguments, see https://github.com/apollographql/apollo-client/issues/2051
@ -38,9 +39,11 @@ async function PostsCategoryParameter(parameters, terms, apolloClient) {
variables: {terms: {limit: 0, itemsPerPage: 0}}
}).CategoriesList;
} else {
// TODO: figure out how to make this async without messing up withList on the client
// get categories through GraphQL API using runQuery
const results = await runQuery(query);
allCategories = results.data.CategoriesList;
// const results = await runQuery(query);
// allCategories = results.data.CategoriesList;
allCategories = Categories.find().fetch();
}
// get corresponding category ids

View file

@ -26,15 +26,5 @@ registerFragment(`
}
}
# vulcan:voting
upvoters {
_id
}
downvoters {
_id
}
#upvotes
#downvotes
#baseScore
#score
}
`);

View file

@ -31,14 +31,9 @@ registerFragment(`
...UsersMinimumInfo
}
# voting
upvoters {
_id
currentUserVotes{
...VoteFragment
}
downvoters {
_id
}
upvotes
downvotes
baseScore
score
}

View file

@ -25,6 +25,7 @@ Package.onUse(function (api) {
'check',
'http',
'email',
'random',
'ecmascript@0.8.2',
'service-configuration',
'shell-server@0.2.4',

View file

@ -1,25 +1,22 @@
import React, { PropTypes, Component } from 'react';
import { graphql } from 'react-apollo';
import gql from 'graphql-tag';
import { operateOnItem } from '../modules/vote.js';
import { voteOnItem } from '../modules/vote.js';
import { VoteableCollections } from '../modules/make_voteable.js';
const withVote = component => {
return graphql(gql`
mutation vote($documentId: String, $voteType: String, $collectionName: String) {
vote(documentId: $documentId, voteType: $voteType, collectionName: $collectionName) {
mutation vote($documentId: String, $operationType: String, $collectionName: String) {
vote(documentId: $documentId, operationType: $operationType, collectionName: $collectionName) {
${VoteableCollections.map(collection => `
... on ${collection.typeName} {
__typename
_id
upvotes
upvoters {
_id
}
downvotes
downvoters {
currentUserVotes{
_id
voteType
power
}
baseScore
}
@ -28,19 +25,19 @@ const withVote = component => {
}
`, {
props: ({ownProps, mutate}) => ({
vote: ({document, voteType, collection, currentUser}) => {
const voteResult = operateOnItem(collection, document, currentUser, voteType, true);
vote: ({document, operationType, collection, currentUser}) => {
const voteResult = voteOnItem(collection, document, currentUser, operationType, true);
return mutate({
variables: {
documentId: document._id,
voteType,
operationType,
collectionName: collection._name,
},
optimisticResponse: {
__typename: 'Mutation',
vote: {
...voteResult,
},
vote: voteResult.document,
}
})
}

View file

@ -0,0 +1,9 @@
import { registerFragment } from 'meteor/vulcan:core';
registerFragment(`
fragment VoteFragment on Vote {
_id
voteType
power
}
`);

View file

@ -1,7 +1,9 @@
import './custom_fields.js';
import './permissions.js';
import './fragments.js';
export { default as Votes } from './votes/collection.js';
export * from './make_voteable.js';
export {default as withVote} from '../containers/withVote.js';
export { default as withVote } from '../containers/withVote.js';
export * from './helpers.js';
export * from './vote.js';

View file

@ -6,83 +6,95 @@ export const makeVoteable = collection => {
collection.addField([
/**
How many upvotes the document has received
The current user's votes on the document, if they exists
*/
{
fieldName: 'upvotes',
fieldName: 'currentUserVotes',
fieldSchema: {
type: Number,
type: Array,
optional: true,
defaultValue: 0,
viewableBy: ['guests'],
resolveAs: {
type: '[Vote]',
resolver: async (document, args, { Users, Votes, currentUser }) => {
const votes = Votes.find({userId: currentUser._id, itemId: document._id}).fetch();
console.log('// currentUserVotes')
console.log('// currentUser._id', currentUser._id)
console.log('// document._id', document._id)
console.log('// votes', votes)
if (!votes.length) return null;
return votes;
// return Users.restrictViewableFields(currentUser, Votes, votes);
},
}
}
},
{
fieldName: 'currentUserVotes.$',
fieldSchema: {
type: Object,
optional: true,
}
},
/**
All votes on the document
*/
{
fieldName: 'allVotes',
fieldSchema: {
type: Array,
optional: true,
viewableBy: ['guests'],
resolveAs: {
type: 'Vote',
resolver: async (document, args, { Users, Votes, currentUser }) => {
const votes = Votes.find({itemId: document._id}).fetch();
if (!votes.length) return null;
return votes;
// return Users.restrictViewableFields(currentUser, Votes, votes);
},
}
}
},
{
fieldName: 'allVotes.$',
fieldSchema: {
type: Object,
optional: true,
}
},
/**
An array containing the `_id`s of the document's upvoters
*/
{
fieldName: 'upvoters',
fieldName: 'voters',
fieldSchema: {
type: Array,
optional: true,
viewableBy: ['guests'],
resolveAs: {
fieldName: 'upvoters',
type: '[User]',
resolver: async (document, args, {currentUser, Users}) => {
if (!document.upvoters) return [];
const upvoters = await Users.loader.loadMany(document.upvoters);
return Users.restrictViewableFields(currentUser, Users, upvoters);
const votes = Votes.find({itemId: document._id}).fetch();
const votersIds = _.pluck(votes, 'userId');
const voters = Users.find({_id: {$in: votersIds}});
return voters;
// if (!document.upvoters) return [];
// const upvoters = await Users.loader.loadMany(document.upvoters);
// return Users.restrictViewableFields(currentUser, Users, upvoters);
},
},
}
},
{
fieldName: 'upvoters.$',
fieldName: 'voters.$',
fieldSchema: {
type: String,
optional: true
}
},
/**
How many downvotes the document has received
*/
{
fieldName: 'downvotes',
fieldSchema: {
type: Number,
optional: true,
defaultValue: 0,
viewableBy: ['guests'],
}
},
/**
An array containing the `_id`s of the document's downvoters
*/
{
fieldName: 'downvoters',
fieldSchema: {
type: Array,
optional: true,
viewableBy: ['guests'],
resolveAs: {
fieldName: 'downvoters',
type: '[User]',
resolver: async (document, args, {currentUser, Users}) => {
if (!document.downvoters) return [];
const downvoters = await Users.loader.loadMany(document.downvoters);
return Users.restrictViewableFields(currentUser, Users, downvoters);
},
},
}
},
{
fieldName: 'downvoters.$',
fieldSchema: {
type: String,
optional: true,
}
},
/**
The document's base score (not factoring in the document's age)
*/

View file

@ -1,11 +1,14 @@
import Users from 'meteor/vulcan:users';
import { hasUpvoted, hasDownvoted } from './helpers.js';
import { runCallbacks, runCallbacksAsync } from 'meteor/vulcan:core';
import { runCallbacks, runCallbacksAsync, registerSetting, getSetting } from 'meteor/vulcan:core';
import update from 'immutability-helper';
import Votes from './votes/collection.js';
registerSetting('voting.maxVotes', 1, 'How many times a user can vote on the same document');
// The equation to determine voting power. Defaults to returning 1 for everybody
export const getVotePower = function (user) {
return 1;
export const getVotePower = (user, operationType) => {
return operationType === 'upvote' ? 1 : -1;
};
const keepVoteProperties = item => _.pick(item, '__typename', '_id', 'upvoters', 'downvoters', 'upvotes', 'downvotes', 'baseScore');
@ -15,114 +18,111 @@ const keepVoteProperties = item => _.pick(item, '__typename', '_id', 'upvoters',
Runs all the operation and returns an objects without affecting the db.
*/
export const operateOnItem = function (collection, originalItem, user, operation, isClient = false) {
export const voteOnItem = function (collection, document, user, operationType = 'upvote') {
user = typeof user === "undefined" ? Meteor.user() : user;
const collectionName = collection.options.collectionName;
let result = {};
let item = {
upvotes: 0,
downvotes: 0,
upvoters: [],
downvoters: [],
baseScore: 0,
...originalItem,
}; // we do not want to affect the original item directly
const votePower = getVotePower(user);
const hasUpvotedItem = hasUpvoted(user, item);
const hasDownvotedItem = hasDownvoted(user, item);
const collectionName = collection._name;
const canDo = Users.canDo(user, `${collectionName}.${operation}`);
// console.log('// operateOnItem')
// console.log('isClient: ', isClient)
// console.log('collection: ', collectionName)
// console.log('operation: ', operation)
// console.log('item: ', item)
// console.log('user: ', user)
// console.log('hasUpvotedItem: ', hasUpvotedItem)
// console.log('hasDownvotedItem: ', hasDownvotedItem)
// console.log('canDo: ', canDo)
// make sure item and user are defined, and user can perform the operation
if (
!item ||
!user ||
!canDo ||
operation === "upvote" && hasUpvotedItem ||
operation === "downvote" && hasDownvotedItem ||
operation === "cancelUpvote" && !hasUpvotedItem ||
operation === "cancelDownvote" && !hasDownvotedItem
) {
throw new Error(`Cannot perform operation "${collectionName}.${operation}"`);
// make sure item and user are defined
if (!document || !user) {
throw new Error(`Cannot perform operation '${collectionName}.${operationType}'`);
}
// ------------------------------ Sync Callbacks ------------------------------ //
item = runCallbacks(operation, item, user, operation, isClient);
/*
voters arrays have different structures on client and server:
- client: [{__typename: "User", _id: 'foo123'}]
- server: ['foo123']
First, handle vote cancellation.
Just remove last vote and subtract its power from the base score
*/
if (operationType === 'cancelVote') {
const voter = isClient ? {__typename: "User", _id: user._id} : user._id;
const filterFunction = isClient ? u => u._id !== user._id : u => u !== user._id;
// create a "lite" version of the document that only contains relevant fields
const newDocument = {
_id: document._id,
currentUserVotes: document.currentUserVotes || [],
// voters: document.voters || [],
baseScore: document.baseScore || 0,
__typename: collection.options.typeName,
}; // we do not want to affect the original item directly
switch (operation) {
// if document has votes
if (newDocument.currentUserVotes.length) {
// remove one vote
const cancelledVote = _.last(newDocument.currentUserVotes);
newDocument.currentUserVotes = _.initial(newDocument.currentUserVotes);
result.vote = cancelledVote;
case "upvote":
if (hasDownvotedItem) {
item = operateOnItem(collection, item, user, "cancelDownvote", isClient);
}
// update base score
newDocument.baseScore -= cancelledVote.power;
}
item = update(item, {
upvoters: {$push: [voter]},
upvotes: {$set: item.upvotes + 1},
baseScore: {$set: item.baseScore + votePower},
});
// console.log('// voteOnItem')
// console.log('collection: ', collectionName)
// console.log('document:', document)
// console.log('newDocument:', newDocument)
break;
result.document = newDocument;
case "downvote":
if (hasUpvotedItem) {
item = operateOnItem(collection, item, user, "cancelUpvote", isClient);
}
} else {
/*
item = update(item, {
downvoters: {$push: [voter]},
downvotes: {$set: item.downvotes + 1},
baseScore: {$set: item.baseScore - votePower},
});
Next, handle all other vote types (upvote, downvote, etc.)
*/
break;
const power = getVotePower(user, operationType);
case "cancelUpvote":
item = update(item, {
upvoters: {$set: item.upvoters.filter(filterFunction)},
upvotes: {$set: item.upvotes - 1},
baseScore: {$set: item.baseScore - votePower},
});
break;
// create vote object
const vote = {
_id: Random.id(),
itemId: document._id,
collectionName,
userId: user._id,
voteType: operationType,
power,
votedAt: new Date(),
__typename: 'Vote'
};
case "cancelDownvote":
// create a "lite" version of the document that only contains relevant fields
const currentUserVotes = document.currentUserVotes || [];
const newDocument = {
_id: document._id,
currentUserVotes: [...currentUserVotes, vote],
// voters: document.voters || [],
baseScore: document.baseScore || 0,
__typename: collection.options.typeName,
}; // we do not want to affect the original item directly
item = update(item, {
downvoters: {$set: item.downvoters.filter(filterFunction)},
downvotes: {$set: item.downvotes - 1},
baseScore: {$set: item.baseScore + votePower},
});
// update score
newDocument.baseScore += power;
break;
// console.log('// voteOnItem')
// console.log('collection: ', collectionName)
// console.log('document:', document)
// console.log('newDocument:', newDocument)
// make sure item and user are defined, and user can perform the operation
if (newDocument.currentUserVotes.length > getSetting('voting.maxVotes')) {
throw new Error(`Cannot perform operation '${collectionName}.${operationType}'`);
}
// ------------------------------ Sync Callbacks ------------------------------ //
// item = runCallbacks(operation, item, user, operation, isClient);
result = {
document: newDocument,
vote
};
}
// console.log('new item', item);
return result;
};
export const cancelVote = function (collection, document, user, voteType = 'vote') {
return item;
};
/*
@ -130,14 +130,14 @@ export const operateOnItem = function (collection, originalItem, user, operation
Call operateOnItem, update the db with the result, run callbacks.
*/
export const mutateItem = function (collection, originalItem, user, operation) {
const newItem = operateOnItem(collection, originalItem, user, operation, false);
newItem.inactive = false;
// export const mutateItem = function (collection, originalItem, user, operation) {
// const newItem = operateOnItem(collection, originalItem, user, operation, false);
// newItem.inactive = false;
collection.update({_id: newItem._id}, newItem, {bypassCollection2:true});
// collection.update({_id: newItem._id}, newItem, {bypassCollection2:true});
// --------------------- Server-Side Async Callbacks --------------------- //
runCallbacksAsync(operation+".async", newItem, user, collection, operation);
// // --------------------- Server-Side Async Callbacks --------------------- //
// runCallbacksAsync(operation+'.async', newItem, user, collection, operation);
return newItem;
}
// return newItem;
// }

View file

@ -0,0 +1,19 @@
import { createCollection, getDefaultResolvers, getDefaultMutations } from 'meteor/vulcan:core';
import schema from './schema.js';
const Votes = createCollection({
collectionName: 'Votes',
typeName: 'Vote',
schema,
// resolvers: getDefaultResolvers('Votes'),
// mutations: getDefaultMutations('Votes'),
});
export default Votes;

View file

@ -0,0 +1,55 @@
const schema = {
_id: {
type: String,
viewableBy: ['guests'],
},
/**
The id of the document that was voted on
*/
itemId: {
type: String
},
/**
The id of the document that was voted on
*/
collectionName: {
type: String
},
/**
The id of the user that voted
*/
userId: {
type: String
},
/**
An optional vote type (for Facebook-style reactions)
*/
voteType: {
type: String,
optional: true
},
/**
The vote power (e.g. 1 = upvote, -1 = downvote, or any other value)
*/
power: {
type: Number,
optional: true
},
/**
The vote timestamp
*/
votedAt: {
type: Date,
optional: true
}
};
export default schema;

View file

@ -1,20 +1,13 @@
import { addCallback, addGraphQLSchema, addGraphQLResolvers, addGraphQLMutation, Utils } from 'meteor/vulcan:core';
import { mutateItem } from '../modules/vote.js';
import { addCallback, addGraphQLSchema, addGraphQLResolvers, addGraphQLMutation, Utils, registerSetting, getSetting } from 'meteor/vulcan:core';
import { voteOnItem } from '../modules/vote.js';
import { VoteableCollections } from '../modules/make_voteable.js';
import { createError } from 'apollo-errors';
import Votes from '../modules/votes/collection.js';
function CreateVoteableUnionType() {
const voteSchema = `
type Vote {
itemId: String
power: Float
votedAt: String
}
${VoteableCollections.length ? `union Voteable = ${VoteableCollections.map(collection => collection.typeName).join(' | ')}` : ''}
`;
addGraphQLSchema(voteSchema);
const voteableSchema = VoteableCollections.length ? `union Voteable = ${VoteableCollections.map(collection => collection.typeName).join(' | ')}` : '';
addGraphQLSchema(voteableSchema);
return {}
}
addCallback('graphql.init.before', CreateVoteableUnionType);
@ -30,20 +23,55 @@ const resolverMap = {
addGraphQLResolvers(resolverMap);
addGraphQLMutation('vote(documentId: String, voteType: String, collectionName: String) : Voteable');
addGraphQLMutation('vote(documentId: String, operationType: String, collectionName: String) : Voteable');
const voteResolver = {
Mutation: {
vote(root, {documentId, voteType, collectionName}, context) {
async vote(root, {documentId, operationType, collectionName}, context) {
const { currentUser } = context;
const collection = context[Utils.capitalize(collectionName)];
const document = collection.findOne(documentId);
if (context.Users.canDo(context.currentUser, `${collectionName.toLowerCase()}.${voteType}`)) {
// query for document being voted on
const document = await collection.queryOne(documentId, {
fragmentText: `
fragment DocumentVoteFragment on ${collection.typeName} {
__typename
_id
currentUserVotes{
_id
voteType
power
}
baseScore
}
`,
context
});
if (context.Users.canDo(currentUser, `${collectionName.toLowerCase()}.${operationType}`)) {
// put document through voteOnItem and get result
const voteResult = voteOnItem(collection, document, currentUser, operationType);
const mutatedDocument = mutateItem(collection, document, context.currentUser, voteType, false);
mutatedDocument.__typename = collection.typeName;
return mutatedDocument;
// get new version of document
const newDocument = voteResult.document;
newDocument.__typename = collection.typeName;
// get created or cancelled vote
const vote = voteResult.vote;
if (operationType === 'cancelVote' && vote) {
// if a vote has been cancelled, delete it
Votes.remove(vote._id);
} else {
// if a vote has been created, insert it
delete vote.__typename;
Votes.insert(vote);
}
// in any case, return the document that was voted on
return newDocument;
} else {