More work on voting

This commit is contained in:
SachaG 2017-09-27 17:15:49 +02:00
parent 683da40216
commit 15ed3c3923
13 changed files with 299 additions and 200 deletions

View file

@ -37,7 +37,7 @@ class PostsItem extends PureComponent {
<div className={postClass}>
<div className="posts-item-vote">
<Components.Vote collection={Posts} document={post} currentUser={this.props.currentUser}/>
<Components.Vote collection={Posts} document={post} currentUser={this.props.currentUser} showDownvote={false}/>
</div>
{post.thumbnailUrl ? <Components.PostsThumbnail post={post}/> : null}

View file

@ -10,7 +10,6 @@ import '../components/common/FlashMessages.jsx';
import '../components/common/Newsletter.jsx';
import '../components/common/NewsletterButton.jsx';
import '../components/common/SearchForm.jsx';
import '../components/common/Vote.jsx';
// posts

View file

@ -109,7 +109,7 @@
}
}
.upvote-button{
.vote-button{
@include border;
@include border-radius;
@include flex-center;
@ -127,7 +127,16 @@
.voted &{
color: $light-text;
}
&.show-downvote{
.icon{
&:hover{
color: $active-color;
}
}
}
&.hide-downvote{
@include activeHover;
}
&, &:active, &:hover{
text-decoration: none;
}

View file

@ -0,0 +1,110 @@
import { Components, registerComponent, withMessages } from 'meteor/vulcan:core';
import React, { PureComponent } from 'react';
import PropTypes from 'prop-types';
import classNames from 'classnames';
import { withVote } from '../containers/withVote.js';
import { FormattedMessage, intlShape } from 'meteor/vulcan:i18n';
class Vote extends PureComponent {
constructor() {
super();
this.vote = this.vote.bind(this);
this.upvote = this.upvote.bind(this);
this.downvote = this.downvote.bind(this);
this.hasVoted = this.hasVoted.bind(this);
this.getActionClass = this.getActionClass.bind(this);
// this.startLoading = this.startLoading.bind(this);
// this.stopLoading = this.stopLoading.bind(this);
this.state = {
loading: false
}
}
hasVoted() {
return this.props.document.currentUserVotes && this.props.document.currentUserVotes.length;
}
vote(voteType) {
const document = this.props.document;
const collection = this.props.collection;
const user = this.props.currentUser;
if(!user){
this.props.flash(this.context.intl.formatMessage({id: 'users.please_log_in'}));
} else {
const operationType = this.hasVoted() ? 'cancelVote' : voteType;
this.props.vote({document, operationType, collection, currentUser: this.props.currentUser});
}
}
upvote(e) {
e.preventDefault();
this.vote('upvote');
}
downvote(e) {
e.preventDefault();
this.vote('downvote');
}
getActionClass() {
const actionsClass = classNames(
'vote-button',
{'show-downvote': this.props.showDownvote},
{'hide-downvote': !this.props.showDownvote},
{voted: this.hasVoted()},
);
return actionsClass;
}
render() {
if (this.props.showDownvote) {
return (
<div className={this.getActionClass()}>
<a className="upvote-button" onClick={this.upvote}>
<Components.Icon name="upvote" />
<div className="sr-only"><FormattedMessage id="voting.upvote"/></div>
</a>
<div className="vote-count">{this.props.document.baseScore || 0}</div>
<a className="downvote-button" onClick={this.downvote}>
<Components.Icon name="downvote" />
<div className="sr-only"><FormattedMessage id="voting.downvote"/></div>
</a>
</div>
)
} else {
return (
<div className={this.getActionClass()}>
<a className="upvote-button" onClick={this.upvote}>
<Components.Icon name="upvote" />
<div className="sr-only"><FormattedMessage id="voting.upvote"/></div>
<div className="vote-count">{this.props.document.baseScore || 0}</div>
</a>
</div>
)
}
}
}
Vote.propTypes = {
document: PropTypes.object.isRequired, // the document to upvote
collection: PropTypes.object.isRequired, // the collection containing the document
vote: PropTypes.func.isRequired, // mutate function with callback inside
currentUser: PropTypes.object, // user might not be logged in, so don't make it required
showDownvote: PropTypes.bool,
};
Vote.defaultProps = {
showDownvote: false
};
Vote.contextTypes = {
intl: intlShape
};
registerComponent('Vote', Vote, withMessages, withVote);

View file

@ -1,70 +1,65 @@
/*
This variant of the Vote.jsx component implements a loading spinner instead of
optimistic response
*/
import { Components, registerComponent, withMessages } from 'meteor/vulcan:core';
import React, { PureComponent } from 'react';
import PropTypes from 'prop-types';
import classNames from 'classnames';
import { withVote, hasUpvoted, hasDownvoted } from 'meteor/vulcan:voting';
import { /*FormattedMessage,*/ intlShape } from 'meteor/vulcan:i18n';
import { withVote } from '../containers/withVote.js';
class Vote extends PureComponent {
constructor() {
super();
this.upvote = this.upvote.bind(this);
this.hasVoted = this.hasVoted.bind(this);
this.getActionClass = this.getActionClass.bind(this);
// this.startLoading = this.startLoading.bind(this);
// this.stopLoading = this.stopLoading.bind(this);
this.startLoading = this.startLoading.bind(this);
this.stopLoading = this.stopLoading.bind(this);
this.state = {
loading: false
}
}
/*
startLoading() {
this.setState({ loading: true });
}
note: with optimisitc UI, loading functions are not needed
also, setState triggers issues when the component is unmounted
before the vote mutation returns.
stopLoading() {
this.setState({ loading: false });
}
*/
// startLoading() {
// this.setState({ loading: true });
// }
// stopLoading() {
// this.setState({ loading: false });
// }
hasVoted() {
return this.props.document.currentUserVotes && this.props.document.currentUserVotes.length;
}
upvote(e) {
e.preventDefault();
// this.startLoading();
this.startLoading();
const document = this.props.document;
const collection = this.props.collection;
const user = this.props.currentUser;
if(!user){
this.props.flash(this.context.intl.formatMessage({id: 'users.please_log_in'}));
// this.stopLoading();
this.stopLoading();
} else {
const operationType = this.props.document.currentUserVotes && this.props.document.currentUserVotes.length ? 'cancelVote' : 'upvote';
const operationType = this.hasVoted() ? 'cancelVote' : 'upvote';
this.props.vote({document, operationType, collection, currentUser: this.props.currentUser}).then(result => {
// this.stopLoading();
this.stopLoading();
});
}
}
getActionClass() {
const document = this.props.document;
const user = this.props.currentUser;
const isUpvoted = hasUpvoted(user, document);
const isDownvoted = hasDownvoted(user, document);
const actionsClass = classNames(
'vote',
{voted: isUpvoted || isDownvoted},
{upvoted: isUpvoted},
{downvoted: isDownvoted}
{voted: this.hasVoted()},
);
return actionsClass;
@ -91,8 +86,4 @@ Vote.propTypes = {
currentUser: PropTypes.object, // user might not be logged in, so don't make it required
};
Vote.contextTypes = {
intl: intlShape
};
registerComponent('Vote', Vote, withMessages, withVote);
registerComponent('Upvote', Vote, withMessages, withVote);

View file

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

View file

@ -0,0 +1 @@
import '../components/Vote.jsx';

View file

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

View file

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

View file

@ -1,143 +1,163 @@
import Users from 'meteor/vulcan:users';
import { hasUpvoted, hasDownvoted } from './helpers.js';
import { runCallbacks, runCallbacksAsync, registerSetting, getSetting } from 'meteor/vulcan:core';
import update from 'immutability-helper';
import { registerSetting, getSetting } 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');
// The equation to determine voting power. Defaults to returning 1 for everybody
export const getVotePower = (user, operationType) => {
return operationType === 'upvote' ? 1 : -1;
};
/*
const keepVoteProperties = item => _.pick(item, '__typename', '_id', 'upvoters', 'downvoters', 'upvotes', 'downvotes', 'baseScore');
Define voting operations
*/
export const voteOperations = {
'upvote': {
power: 1,
// TODO: refactor voteOptimisticResponse and performVoteOperation code
// into extensible, action-specific objects
// clientOperation: () => {
// },
// serverOperation: () => {
// }
},
'downvote': {
power: -1
},
'adminUpvote': {
power: user => Users.isAdmin(user) ? 5 : 1
},
'cancelVote': {
}
}
/*
Runs all the operation and returns an objects without affecting the db.
Determine a user's voting power for a given operation.
If power is a function, call it on user
*/
export const voteOnItem = function (collection, document, user, operationType = 'upvote') {
export const getVotePower = (user, operationType) => {
const power = voteOperations[operationType].power;
return typeof power === 'function' ? power(user) : 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 }) => ({
_id: voteId,
itemId: documentId,
collectionName,
userId: user._id,
voteType: operationType,
power: getVotePower(user, operationType),
votedAt: new Date(),
__typename: 'Vote'
});
/*
Optimistic response for votes
*/
export const voteOptimisticResponse = ({collection, document, user, operationType = 'upvote', voteId}) => {
const collectionName = collection.options.collectionName;
let result = {};
// make sure item and user are defined
if (!document || !user) {
throw new Error(`Cannot perform operation '${collectionName}.${operationType}'`);
}
/*
// console.log('// voteOptimisticResponse')
// console.log('collectionName: ', collectionName)
// console.log('document:', document)
// console.log('operationType:', operationType)
First, handle vote cancellation.
Just remove last vote and subtract its power from the base score
// 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 (operationType === 'cancelVote') {
// 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
// subtract vote scores
newDocument.baseScore -= calculateTotalPower(document.currentUserVotes);
// if document has votes
if (newDocument.currentUserVotes.length) {
// remove one vote
const cancelledVote = _.last(newDocument.currentUserVotes);
newDocument.currentUserVotes = _.initial(newDocument.currentUserVotes);
result.vote = cancelledVote;
// update base score
newDocument.baseScore -= cancelledVote.power;
}
// console.log('// voteOnItem')
// console.log('collection: ', collectionName)
// console.log('document:', document)
// console.log('newDocument:', newDocument)
result.document = newDocument;
// clear out all votes
newDocument.currentUserVotes = [];
} else {
/*
Next, handle all other vote types (upvote, downvote, etc.)
*/
// create new vote and add it to currentUserVotes array
const vote = createVote({ documentId: document._id, collectionName, operationType, user, voteId });
newDocument.currentUserVotes = [...document.currentUserVotes, vote];
// increment baseScore
const power = getVotePower(user, operationType);
// create vote object
const vote = {
_id: Random.id(),
itemId: document._id,
collectionName,
userId: user._id,
voteType: operationType,
power,
votedAt: new Date(),
__typename: 'Vote'
};
// 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
// update score
newDocument.baseScore += power;
// 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
};
return newDocument;
}
return result;
};
export const cancelVote = function (collection, document, user, voteType = 'vote') {
};
/*
Call operateOnItem, update the db with the result, run callbacks.
Server-side database operation
*/
// export const mutateItem = function (collection, originalItem, user, operation) {
// const newItem = operateOnItem(collection, originalItem, user, operation, false);
// newItem.inactive = false;
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))
// collection.update({_id: newItem._id}, newItem, {bypassCollection2:true});
const power = getVotePower(currentUser, operationType);
const userVotes = Votes.find({itemId: documentId, userId: currentUser._id}).fetch();
// // --------------------- Server-Side Async Callbacks --------------------- //
// runCallbacksAsync(operation+'.async', newItem, user, collection, operation);
if (operationType === 'cancelVote') {
// return newItem;
// }
// if a vote has been cancelled, delete all votes and subtract their power from base score
const scoreTotal = calculateTotalPower(userVotes);
// remove vote object
Votes.remove({itemId: documentId, userId: currentUser._id});
// update document score
collection.update({_id: documentId}, {$inc: {baseScore: -scoreTotal }});
} else {
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();
}
}
}

View file

@ -1,9 +1,7 @@
import { addCallback, addGraphQLSchema, addGraphQLResolvers, addGraphQLMutation, Utils, registerSetting, getSetting } from 'meteor/vulcan:core';
import { voteOnItem } from '../modules/vote.js';
import { performVoteOperation } 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 voteableSchema = VoteableCollections.length ? `union Voteable = ${VoteableCollections.map(collection => collection.typeName).join(' | ')}` : '';
@ -12,7 +10,6 @@ function CreateVoteableUnionType() {
}
addCallback('graphql.init.before', CreateVoteableUnionType);
const resolverMap = {
Voteable: {
__resolveType(obj, context, info){
@ -23,59 +20,26 @@ const resolverMap = {
addGraphQLResolvers(resolverMap);
addGraphQLMutation('vote(documentId: String, operationType: String, collectionName: String) : Voteable');
addGraphQLMutation('vote(documentId: String, operationType: String, collectionName: String, voteId: String) : Voteable');
const voteResolver = {
Mutation: {
async vote(root, {documentId, operationType, collectionName}, context) {
async vote(root, {documentId, operationType, collectionName, voteId}, context) {
const { currentUser } = context;
const collection = context[Utils.capitalize(collectionName)];
// 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
});
const collection = context[collectionName];
if (context.Users.canDo(currentUser, `${collectionName.toLowerCase()}.${operationType}`)) {
// put document through voteOnItem and get result
const voteResult = voteOnItem(collection, document, currentUser, operationType);
performVoteOperation({documentId, operationType, collection, voteId, currentUser});
// 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;
const document = collection.findOne(documentId);
document.__typename = collection.options.typeName;
return document;
} else {
const VoteError = createError('cannot_vote');
const VoteError = createError('voting.cannot_vote', {message: 'voting.cannot_vote'});
throw new VoteError();
}

View file

@ -10,10 +10,14 @@ Package.onUse(function (api) {
api.versionsFrom("METEOR@1.0");
api.use([
'fourseven:scss',
'vulcan:core@1.7.0',
'vulcan:i18n@1.7.0',
], ['client', 'server']);
api.mainModule("lib/server/main.js", "server");
api.mainModule("lib/client/main.js", "client");
api.addFiles(['lib/stylesheets/vote.scss'], ['client']);
});