Another voting refactor (hopefully the last one…)

This commit is contained in:
SachaG 2017-09-28 18:15:04 +09:00
parent 15ed3c3923
commit 4640dad0b7
6 changed files with 227 additions and 129 deletions

View file

@ -1,14 +1,14 @@
import React, { PropTypes, Component } from 'react';
import { graphql } from 'react-apollo';
import gql from 'graphql-tag';
import { voteOptimisticResponse } from '../modules/vote.js';
import { performVoteClient } from '../modules/vote.js';
import { VoteableCollections } from '../modules/make_voteable.js';
export const withVote = component => {
return graphql(gql`
mutation vote($documentId: String, $operationType: String, $collectionName: String, $voteId: String) {
vote(documentId: $documentId, operationType: $operationType, collectionName: $collectionName, voteId: $voteId) {
mutation vote($documentId: String, $voteType: String, $collectionName: String, $voteId: String) {
vote(documentId: $documentId, voteType: $voteType, collectionName: $collectionName, voteId: $voteId) {
${VoteableCollections.map(collection => `
... on ${collection.typeName} {
__typename
@ -25,15 +25,14 @@ export const withVote = component => {
}
`, {
props: ({ownProps, mutate}) => ({
vote: ({document, operationType, collection, currentUser}) => {
vote: ({document, voteType, collection, currentUser, voteId = Random.id()}) => {
const voteId = Random.id();
const newDocument = voteOptimisticResponse({collection, document, user: currentUser, operationType, voteId});
const newDocument = performVoteClient({collection, document, user: currentUser, voteType, voteId});
return mutate({
variables: {
documentId: document._id,
operationType,
voteType,
collectionName: collection.options.collectionName,
voteId,
},

View file

@ -18,7 +18,7 @@ export const makeVoteable = collection => {
type: '[Vote]',
resolver: async (document, args, { Users, Votes, currentUser }) => {
if (!currentUser) return [];
const votes = Votes.find({userId: currentUser._id, itemId: document._id}).fetch();
const votes = Votes.find({userId: currentUser._id, documentId: document._id}).fetch();
if (!votes.length) return [];
return votes;
// return Users.restrictViewableFields(currentUser, Votes, votes);

View file

@ -1,13 +0,0 @@
import Users from 'meteor/vulcan:users';
const membersActions = [
'posts.upvote',
'posts.cancelUpvote',
'posts.downvote',
'posts.cancelDownvote',
'comments.upvote',
'comments.cancelUpvote',
'comments.downvote',
'comments.cancelDownvote'
];
Users.groups.members.can(membersActions);

View file

@ -1,68 +1,191 @@
import { registerSetting, getSetting } from 'meteor/vulcan:core';
import { runCallbacksAsync, runCallbacks, addCallback } from 'meteor/vulcan:core';
import { createError } from 'apollo-errors';
import Votes from './votes/collection.js';
import Users from 'meteor/vulcan:users';
registerSetting('voting.maxVotes', 1, 'How many times a user can vote on the same document');
/*
Define voting operations
*/
export const voteOperations = {
'upvote': {
power: 1,
// TODO: refactor voteOptimisticResponse and performVoteOperation code
// into extensible, action-specific objects
// clientOperation: () => {
const voteTypes = {}
// },
// serverOperation: () => {
/*
// }
},
'downvote': {
power: -1
},
'adminUpvote': {
power: user => Users.isAdmin(user) ? 5 : 1
},
'cancelVote': {
Add new vote types
*/
export const addVoteType = (voteType, voteTypeOptions) => {
voteTypes[voteType] = voteTypeOptions;
}
addVoteType('upvote', {power: 1, exclusive: true});
addVoteType('downvote', {power: -1, exclusive: true});
addVoteType('angry', {power: -1, exclusive: true});
addVoteType('sad', {power: -1, exclusive: true});
addVoteType('happy', {power: 1, exclusive: true});
addVoteType('laughing', {power: 1, exclusive: true});
/*
Test if a user has voted on the client
*/
export const hasVotedClient = ({ document, voteType }) => {
const userVotes = document.currentUserVotes;
if (voteType) {
return _.where(userVotes, { voteType }).length
} else {
return userVotes && userVotes.length
}
}
/*
Calculate total power of all a user's votes on a document
*/
const calculateTotalPower = votes => _.pluck(votes, 'power').reduce((a, b) => a + b, 0);
/*
Test if a user has voted on the server
*/
const hasVotedServer = ({ document, voteType, user }) => {
const vote = Votes.findOne({documentId: document._id, userId: user._id, voteType});
return vote;
}
/*
Add a vote of a specific type on the client
*/
const addVoteClient = ({ document, collection, voteType, user, voteId }) => {
const newDocument = {
_id: document._id,
baseScore: document.baseScore || 0,
__typename: collection.options.typeName,
};
// create new vote and add it to currentUserVotes array
const vote = createVote({ document, collectionName: collection.options.collectionName, voteType, user, voteId });
newDocument.currentUserVotes = [...document.currentUserVotes, vote];
// increment baseScore
newDocument.baseScore += vote.power;
return newDocument;
}
/*
Add a vote of a specific type on the server
*/
const addVoteServer = ({ document, collection, voteType, user, voteId }) => {
// create vote and insert it
const vote = createVote({ document, collectionName: collection.options.collectionName, voteType, user, voteId });
delete vote.__typename;
Votes.insert(vote);
// update document score
collection.update({_id: document._id}, {$inc: {baseScore: vote.power }});
return vote;
}
/*
Cancel votes of a specific type on a given document (client)
*/
const cancelVoteClient = ({ document, voteType }) => {
const vote = _.findWhere(document.currentUserVotes, { voteType });
const newDocument = _.clone(document);
if (vote) {
// subtract vote scores
newDocument.baseScore -= vote.power;
const newVotes = _.reject(document.currentUserVotes, vote => vote.voteType === voteType);
// clear out vote of this type
newDocument.currentUserVotes = newVotes;
}
return newDocument;
}
/*
Clear *all* votes for a given document and user (client)
*/
const clearVotesClient = ({ document }) => {
const newDocument = _.clone(document);
newDocument.baseScore -= calculateTotalPower(document.currentUserVotes);
newDocument.currentUserVotes = [];
return newDocument
}
/*
Clear all votes for a given document and user (server)
*/
const clearVotesServer = ({ document, user, collection }) => {
const votes = Votes.find({ documentId: document._id, userId: user._id}).fetch();
if (votes.length) {
Votes.remove({documentId: document._id});
collection.update({_id: document._id}, {$inc: {baseScore: -calculateTotalPower(votes) }});
}
}
/*
Cancel votes of a specific type on a given document (server)
*/
const cancelVoteServer = ({ document, voteType, collection, user }) => {
const vote = Votes.findOne({documentId: document._id, userId: user._id, voteType})
// remove vote object
Votes.remove({_id: vote._id});
// update document score
collection.update({_id: document._id}, {$inc: {baseScore: -vote.power }});
return vote;
}
/*
Determine a user's voting power for a given operation.
If power is a function, call it on user
*/
export const getVotePower = (user, operationType) => {
const power = voteOperations[operationType].power;
return typeof power === 'function' ? power(user) : power;
const getVotePower = ({ user, voteType, document }) => {
const power = voteTypes[voteType] && voteTypes[voteType].power || 1;
return typeof power === 'function' ? power(user, document) : power;
};
/*
Calculate total power of all a user's votes on a document
*/
export const calculateTotalPower = userVotes => _.pluck(userVotes, 'power').reduce((a, b) => a + b, 0);
/*
Create new vote object
*/
export const createVote = ({ documentId, collectionName, operationType, user, voteId }) => ({
const createVote = ({ document, collectionName, voteType, user, voteId }) => ({
_id: voteId,
itemId: documentId,
documentId: document._id,
collectionName,
userId: user._id,
voteType: operationType,
power: getVotePower(user, operationType),
voteType: voteType,
power: getVotePower({user, voteType, document}),
votedAt: new Date(),
__typename: 'Vote'
});
@ -72,49 +195,45 @@ export const createVote = ({ documentId, collectionName, operationType, user, vo
Optimistic response for votes
*/
export const voteOptimisticResponse = ({collection, document, user, operationType = 'upvote', voteId}) => {
export const performVoteClient = ({ document, collection, voteType = 'upvote', user, voteId }) => {
const collectionName = collection.options.collectionName;
let returnedDocument;
console.log('// voteOptimisticResponse')
console.log('collectionName: ', collectionName)
console.log('document:', document)
console.log('voteType:', voteType)
// make sure item and user are defined
if (!document || !user) {
throw new Error(`Cannot perform operation '${collectionName}.${operationType}'`);
if (!document || !user || !Users.canDo(user, `${collectionName.toLowerCase()}.${voteType}`)) {
throw new Error(`Cannot perform operation '${collectionName.toLowerCase()}.${voteType}'`);
}
// console.log('// voteOptimisticResponse')
// console.log('collectionName: ', collectionName)
// console.log('document:', document)
// console.log('operationType:', operationType)
const voteOptions = {document, collection, voteType, user, voteId};
// create a "lite" version of the document that only contains relevant fields
// we do not want to affect the original item directly
const newDocument = {
_id: document._id,
baseScore: document.baseScore || 0,
__typename: collection.options.typeName,
};
if (hasVotedClient({document, voteType})) {
if (operationType === 'cancelVote') {
// subtract vote scores
newDocument.baseScore -= calculateTotalPower(document.currentUserVotes);
// clear out all votes
newDocument.currentUserVotes = [];
console.log('action: cancel')
returnedDocument = cancelVoteClient(voteOptions);
// returnedDocument = runCallbacks(`votes.cancel.client`, returnedDocument, collection, user);
} else {
// create new vote and add it to currentUserVotes array
const vote = createVote({ documentId: document._id, collectionName, operationType, user, voteId });
newDocument.currentUserVotes = [...document.currentUserVotes, vote];
console.log('action: vote')
// increment baseScore
const power = getVotePower(user, operationType);
newDocument.baseScore += power;
if (voteTypes[voteType].exclusive) {
clearVotesClient({document, collection, voteType, user, voteId})
}
returnedDocument = addVoteClient(voteOptions);
// returnedDocument = runCallbacks(`votes.${voteType}.client`, returnedDocument, collection, user);
}
return newDocument;
console.log('returnedDocument:', returnedDocument)
return returnedDocument;
}
/*
@ -122,42 +241,47 @@ export const voteOptimisticResponse = ({collection, document, user, operationTyp
Server-side database operation
*/
export const performVoteOperation = ({documentId, operationType, collection, voteId, currentUser}) => {
// console.log('// performVoteMutation')
// console.log('operationType: ', operationType)
// console.log('collectionName: ', collectionName)
// console.log('// document: ', collection.findOne(documentId))
export const performVoteServer = ({ documentId, voteType = 'upvote', collection, voteId, user }) => {
const power = getVotePower(currentUser, operationType);
const userVotes = Votes.find({itemId: documentId, userId: currentUser._id}).fetch();
const collectionName = collection.options.collectionName;
const document = collection.findOne(documentId);
if (operationType === 'cancelVote') {
// if a vote has been cancelled, delete all votes and subtract their power from base score
const scoreTotal = calculateTotalPower(userVotes);
console.log('// performVoteMutation')
console.log('collectionName: ', collectionName)
console.log('document: ', collection.findOne(documentId))
console.log('voteType: ', voteType)
const voteOptions = {document, collection, voteType, user, voteId};
// remove vote object
Votes.remove({itemId: documentId, userId: currentUser._id});
if (!document || !user || !Users.canDo(user, `${collectionName.toLowerCase()}.${voteType}`)) {
const VoteError = createError('voting.no_permission', {message: 'voting.no_permission'});
throw new VoteError();
}
// update document score
collection.update({_id: documentId}, {$inc: {baseScore: -scoreTotal }});
if (hasVotedServer({document, voteType, user})) {
console.log('action: cancel')
// runCallbacks(`votes.cancel.sync`, document, collection, user);
cancelVoteServer(voteOptions);
// runCallbacksAsync(`votes.cancel.async`, vote, document, collection, user);
} else {
console.log('action: vote')
if (userVotes.length < getSetting('voting.maxVotes')) {
// create vote and insert it
const vote = createVote({ documentId, collectionName: collection.options.collectionName, operationType, user: currentUser, voteId });
delete vote.__typename;
Votes.insert(vote);
// update document score
collection.update({_id: documentId}, {$inc: {baseScore: power }});
} else {
const VoteError = createError('voting.maximum_votes_reached', {message: 'voting.maximum_votes_reached'});
throw new VoteError();
if (voteTypes[voteType].exclusive) {
clearVotesServer(voteOptions)
}
// runCallbacks(`votes.${voteType}.sync`, document, collection, user);
addVoteServer(voteOptions);
// runCallbacksAsync(`votes.${voteType}.async`, vote, document, collection, user);
}
const newDocument = collection.findOne(documentId);
newDocument.__typename = collection.options.typeName;
return newDocument;
}

View file

@ -8,7 +8,7 @@ const schema = {
/**
The id of the document that was voted on
*/
itemId: {
documentId: {
type: String
},

View file

@ -1,7 +1,6 @@
import { addCallback, addGraphQLSchema, addGraphQLResolvers, addGraphQLMutation, Utils, registerSetting, getSetting } from 'meteor/vulcan:core';
import { performVoteOperation } from '../modules/vote.js';
import { performVoteServer } from '../modules/vote.js';
import { VoteableCollections } from '../modules/make_voteable.js';
import { createError } from 'apollo-errors';
function CreateVoteableUnionType() {
const voteableSchema = VoteableCollections.length ? `union Voteable = ${VoteableCollections.map(collection => collection.typeName).join(' | ')}` : '';
@ -20,29 +19,18 @@ const resolverMap = {
addGraphQLResolvers(resolverMap);
addGraphQLMutation('vote(documentId: String, operationType: String, collectionName: String, voteId: String) : Voteable');
addGraphQLMutation('vote(documentId: String, voteType: String, collectionName: String, voteId: String) : Voteable');
const voteResolver = {
Mutation: {
async vote(root, {documentId, operationType, collectionName, voteId}, context) {
async vote(root, {documentId, voteType, collectionName, voteId}, context) {
const { currentUser } = context;
const collection = context[collectionName];
if (context.Users.canDo(currentUser, `${collectionName.toLowerCase()}.${operationType}`)) {
performVoteOperation({documentId, operationType, collection, voteId, currentUser});
const document = performVoteServer({documentId, voteType, collection, voteId, user: currentUser});
return document;
const document = collection.findOne(documentId);
document.__typename = collection.options.typeName;
return document;
} else {
const VoteError = createError('voting.cannot_vote', {message: 'voting.cannot_vote'});
throw new VoteError();
}
},
},
};