parties: validation + code cleanups

This commit is contained in:
Geoff Schmidt
2012-10-16 04:17:36 -07:00
parent 0ca1386c10
commit 4dee258b8e
5 changed files with 238 additions and 243 deletions

View File

@@ -1,16 +1,8 @@
// XXX I want to collect first/last name when a user signs up by username/password
// All Tomorrow's Parties -- client
Meteor.subscribe("directory");
Meteor.subscribe("parties");
Template.page.showCreateDialog = function () {
return Session.get("showCreateDialog");
};
Template.page.showInviteDialog = function () {
return Session.get("showInviteDialog");
};
// If no party selected, select one.
Meteor.startup(function () {
Meteor.autorun(function () {
@@ -24,7 +16,6 @@ Meteor.startup(function () {
///////////////////////////////////////////////////////////////////////////////
// Party details sidebar
///////////////////////////////////////////////////////////////////////////////
Template.details.party = function () {
return Parties.findOne(Session.get("selected"));
@@ -41,6 +32,44 @@ Template.details.creatorName = function () {
return displayName(owner);
};
Template.details.canRemove = function () {
return this.owner === Meteor.userId() && attending(this) === 0;
};
Template.details.maybeChosen = function (what) {
var myRsvp = _.find(this.rsvps, function (r) {
return r.user === Meteor.userId();
}) || {};
return what == myRsvp.rsvp ? "chosen btn-inverse" : "";
};
Template.details.events({
'click .rsvp_yes': function () {
Meteor.call("rsvp", Session.get("selected"), "yes");
return false;
},
'click .rsvp_maybe': function () {
Meteor.call("rsvp", Session.get("selected"), "maybe");
return false;
},
'click .rsvp_no': function () {
Meteor.call("rsvp", Session.get("selected"), "no");
return false;
},
'click .invite': function () {
openInviteDialog();
return false;
},
'click .remove': function () {
Parties.remove(this._id);
return false;
}
});
///////////////////////////////////////////////////////////////////////////////
// Party attendance widget
Template.attendance.rsvpName = function () {
var user = Meteor.users.findOne(this.user);
return displayName(user);
@@ -48,9 +77,10 @@ Template.attendance.rsvpName = function () {
Template.attendance.outstandingInvitations = function () {
var party = Parties.findOne(this._id);
// take out the people that have already rsvp'd
var people = _.difference(party.invited, _.pluck(party.rsvps, 'user'));
return Meteor.users.find({_id: {$in: people}});
return Meteor.users.find({$and: [
{_id: {$in: party.invited}}, // they're invited
{_id: {$nin: _.pluck(party.rsvps, 'user')}} // but haven't RSVP'd
]});
};
Template.attendance.invitationName = function () {
@@ -69,102 +99,49 @@ Template.attendance.canInvite = function () {
return ! this.public && this.owner === Meteor.userId();
};
Template.details.canRemove = function () {
return this.owner === Meteor.userId() && attending(this) === 0;
};
Template.details.maybeChosen = function (what) {
var myRsvp = _.find(this.rsvps, function (r) {
return r.user === Meteor.userId();
}) || {};
return what == myRsvp.rsvp ? "chosen btn-inverse" : "";
};
// XXX show which button is currently selected
Template.details.events({
// XXX demonstrate error handling?
'click .rsvp_yes': function () {
Meteor.call("rsvp", Session.get("selected"), "yes");
return false;
},
'click .rsvp_maybe': function () {
Meteor.call("rsvp", Session.get("selected"), "maybe");
return false;
},
'click .rsvp_no': function () {
Meteor.call("rsvp", Session.get("selected"), "no");
return false;
},
'click .invite': function () {
Session.set("showInviteDialog", true);
return false;
},
'click .remove': function () {
Parties.remove(this._id);
return false;
}
});
///////////////////////////////////////////////////////////////////////////////
// Map display
///////////////////////////////////////////////////////////////////////////////
// XXX fold into package? domutils?
// http://stackoverflow.com/questions/55677/how-do-i-get-the-coordinates-of-a-mouse-click-on-a-canvas-element
function relMouseCoords (element, event) {
var totalOffsetX = 0;
var totalOffsetY = 0;
var canvasX = 0;
var canvasY = 0;
var currentElement = element;
var coordsRelativeToElement = function (element, event) {
var totalOffsetX = 0, totalOffsetY = 0;
do {
totalOffsetX += currentElement.offsetLeft - currentElement.scrollLeft;
totalOffsetY += currentElement.offsetTop - currentElement.scrollTop;
}
while (currentElement = currentElement.offsetParent)
totalOffsetX += element.offsetLeft - element.scrollLeft;
totalOffsetY += element.offsetTop - element.scrollTop;
} while (element = element.offsetParent);
canvasX = event.pageX - totalOffsetX;
canvasY = event.pageY - totalOffsetY;
return {x: canvasX, y: canvasY};
}
return {
x: event.pageX - totalOffsetX,
y: event.pageY - totalOffsetY
};
};
Template.map.events = {
'mousedown circle, mousedown text': function (event, template) {
Session.set("selected", event.currentTarget.id);
},
'dblclick svg': function (event, template) {
// must be logged in
if (!Meteor.userId())
if (! Meteor.userId()) // must be logged in to create events
return;
var coords = relMouseCoords(event.currentTarget, event);
Session.set("createCoords", {
x: coords.x / 500, // XXX event.currentTarget.width?
y: coords.y / 500
});
Session.set("showCreateDialog", true);
var coords = coordsRelativeToElement(event.currentTarget, event);
openCreateDialog(coords.x / 500, coords.y / 500);
}
};
Template.map.rendered = function () {
var self = this;
self.node = this.find("svg");
self.node = self.find("svg");
if (! self.handle) {
self.handle = Meteor.autorun(function () {
var selected = Session.get('selected');
var selectedParty = selected && Parties.findOne(selected);
var radius = function (party) {
return 10 + Math.sqrt(attending(party)) * 10;
};
// Draw a circle for each party
var circles = d3.select(self.node).select(".circles").selectAll("circle")
.data(Parties.find().fetch(), function (party) { return party._id; });
var updateCircles = function (group) {
group.attr("id", function (party) { return party._id; })
.attr("cx", function (party) { return party.x * 500; })
@@ -178,14 +155,14 @@ Template.map.rendered = function () {
});
};
updateCircles(circles.enter().append("circle"));
updateCircles(circles.transition().duration(250).ease("cubic-out"));
circles.exit().remove();
// Label each with the current attendance count
var labels = d3.select(self.node).select(".labels").selectAll("text")
var circles = d3.select(self.node).select(".circles").selectAll("circle")
.data(Parties.find().fetch(), function (party) { return party._id; });
updateCircles(circles.enter().append("circle"));
updateCircles(circles.transition().duration(250).ease("cubic-out"));
circles.exit().transition().duration(250).attr("r", 0).remove();
// Label each with the current attendance count
var updateLabels = function (group) {
group.attr("id", function (party) { return party._id; })
.text(function (party) {return attending(party) || '';})
@@ -196,6 +173,9 @@ Template.map.rendered = function () {
});
};
var labels = d3.select(self.node).select(".labels").selectAll("text")
.data(Parties.find().fetch(), function (party) { return party._id; });
updateLabels(labels.enter().append("text"));
updateLabels(labels.transition().duration(250).ease("cubic-out"));
labels.exit().remove();
@@ -221,16 +201,25 @@ Template.map.destroyed = function () {
///////////////////////////////////////////////////////////////////////////////
// Create Party dialog
///////////////////////////////////////////////////////////////////////////////
var openCreateDialog = function (x, y) {
Session.set("createCoords", {x: x, y: y});
Session.set("createError", null);
Session.set("showCreateDialog", true);
};
Template.page.showCreateDialog = function () {
return Session.get("showCreateDialog");
};
Template.createDialog.events = {
'click .save': function (event, template) {
var title = template.find(".title").value;
var description = template.find(".description").value;
var public = !template.find(".private").checked;
var public = ! template.find(".private").checked;
var coords = Session.get("createCoords");
if (title.length && description.length) {
var coords = Session.get("createCoords");
Meteor.call('createParty', {
title: title,
description: description,
@@ -241,12 +230,13 @@ Template.createDialog.events = {
if (! error) {
Session.set("selected", party);
if (! public && Meteor.users.find().count() > 1)
Session.set("showInviteDialog", true);
openInviteDialog();
}
});
Session.set("showCreateDialog", false);
} else {
// XXX show validation failure
Session.set("createError",
"It needs a title and a description, or why bother?");
}
},
@@ -255,9 +245,20 @@ Template.createDialog.events = {
}
};
Template.createDialog.error = function () {
return Session.get("createError");
};
///////////////////////////////////////////////////////////////////////////////
// Invite dialog
///////////////////////////////////////////////////////////////////////////////
var openInviteDialog = function () {
Session.set("showInviteDialog", true);
};
Template.page.showInviteDialog = function () {
return Session.get("showInviteDialog");
};
Template.inviteDialog.events = {
'click .invite': function (event, template) {
@@ -272,13 +273,9 @@ Template.inviteDialog.events = {
Template.inviteDialog.uninvited = function () {
var party = Parties.findOne(Session.get("selected"));
if (! party)
// XXX this happens on code push when the invite dialog is
// open. easy enough to add a guard, but what's the big picture?
// do we have to do this everywhere?
return [];
var invited = _.clone(party.invited);
invited.push(party.owner);
return Meteor.users.find({_id: {$nin: invited}});
return []; // party hasn't loaded yet
return Meteor.users.find({$nor: [{_id: {$in: party.invited}},
{_id: party.owner}]});
};
Template.inviteDialog.displayName = function () {

View File

@@ -69,4 +69,4 @@ input.chosen {
stroke-opacity: .8;
fill: none;
stroke: red;
}
}

View File

@@ -1,76 +1,60 @@
// All Tomorrow's Parties -- data model
// Loaded on both the client and the server
///////////////////////////////////////////////////////////////////////////////
// Parties
/*
owner: user id
x, y: Number (screen coordinates)
title, description: String
public: Boolean
invited: list of user id's that it's shared with, not including
the owner (ignored if public)
rsvps: XXX document, fields are 'user' and 'rsvp'
Each party is represented by a document in the Parties collection:
owner: user id
x, y: Number (screen coordinates in the interval [0, 1])
title, description: String
public: Boolean
invited: Array of user id's that are invited (only if !public)
rsvps: Array of objects like {user: userId, rsvp: "yes"} (or "no"/"maybe")
*/
Parties = new Meteor.Collection("parties");
Parties.allow({
insert: function (userId, party) {
return false; // use createParty method instead
return false; // no cowboy inserts -- use createParty method
},
update: function (userId, parties, fields, modifier) {
return _.all(parties, function (party) {
if (userId !== party.owner)
return false; // not the owner
var allowed = ["title", "description", "x", "y"];
if (_.difference(fields, allowed).length)
return false; // tried to write to forbidden field
// XXX validate that they keep the right types, and don't write
// stupidly long strings to the database. since all we have is a
// modifier that take a little logic.
// A good improvement would be to validate the type of the new
// value of the field (and if a string, the length.) In the
// future Meteor will have a schema system to makes that easier.
return true;
});
},
remove: function (userId, parties) {
return ! _.any(parties, function (party) {
// deny if not the owner, or if other people are going
return party.owner !== userId || attending(party) > 0;
});
}
});
var attending = function (party) {
return _.reduce(party.rsvps, function (memo, rsvp) {
if (rsvp.rsvp === 'yes')
return memo + 1;
else
return memo;
}, 0);
};
var displayName = function (user) {
if (user.profile && user.profile.name)
return user.profile.name;
return user.emails[0].address;
};
var contactEmail = function (user) {
if (user.emails && user.emails.length)
return user.emails[0].address;
if (user.services.facebook && user.services.facebook.email)
return user.services.facebook.email;
return null;
return (_.groupBy(party.rsvps, 'rsvp').yes || []).length;
};
Meteor.methods({
// title, description, x, y, public
// XXX limit a user to a certain number of parties
// options should include: title, description, x, y, public
createParty: function (options) {
options = options || {};
if (! (typeof options.title === "string" && options.title.length &&
typeof options.description === "string" &&
options.description.length &&
typeof options.x === "number" &&
options.x >= 0 && options.x <= 1 &&
typeof options.y === "number" &&
options.y >= 0 && options.y <= 1))
// XXX should get rid of the error code
typeof options.x === "number" && options.x >= 0 && options.x <= 1 &&
typeof options.y === "number" && options.y >= 0 && options.y <= 1))
throw new Meteor.Error(400, "Required parameter missing");
if (options.title.length > 100)
throw new Meteor.Error(413, "Title too long");
@@ -104,15 +88,16 @@ Meteor.methods({
var from = contactEmail(Meteor.users.findOne(this.userId));
var to = contactEmail(Meteor.users.findOne(userId));
if (Meteor.isServer && to) {
// This code only runs on the server. If you didn't want clients
// to be able to see it, you could move it to a separate file.
Email.send({
from: "noreply@example.com",
to: to,
replyTo: from || undefined,
subject: "PARTY: " + party.title,
text:
"Hey, I just invited you to '" + party.title + "' on All Tomorrow's Parties.\n" +
"\n" +
"Come check it out at: " + Meteor.absoluteUrl() + "\n"
"Hey, I just invited you to '" + party.title + "' on All Tomorrow's Parties." +
"\n\nCome check it out: " + Meteor.absoluteUrl() + "\n"
});
}
}
@@ -126,8 +111,10 @@ Meteor.methods({
var party = Parties.findOne(partyId);
if (! party)
throw new Meteor.Error(404, "No such party");
if (! party.public && party.owner !== this.userId && !_.contains(party.invited, this.userId))
throw new Meteor.Error(403, "No such party"); // private, but let's not tell this to the user
if (! party.public && party.owner !== this.userId &&
!_.contains(party.invited, this.userId))
// private, but let's not tell this to the user
throw new Meteor.Error(403, "No such party");
var rsvpIndex = _.indexOf(_.pluck(party.rsvps, 'user'), this.userId);
if (rsvpIndex !== -1) {
@@ -139,18 +126,37 @@ Meteor.methods({
{_id: partyId, "rsvps.user": this.userId},
{$set: {"rsvps.$.rsvp": rsvp}});
} else {
// minimongo doesn't yet support $ in modifier. reconstruct
// the modifier to be of the form:
// {$set: {"rsvps.<index>.rsvp"}}
// minimongo doesn't yet support $ in modifier. as a temporary
// workaround, make a modifier that uses an index. this is
// safe on the client since there's only one thread.
var modifier = {$set: {}};
modifier.$set["rsvps." + rsvpIndex + ".rsvp"] = rsvp;
Parties.update(partyId, modifier);
}
// Possible improvement: send email to the other people that are
// coming to the party.
} else {
// add new rsvp entry
Parties.update(
partyId,
{$push: {rsvps: {user: this.userId, rsvp: rsvp}}});
Parties.update(partyId,
{$push: {rsvps: {user: this.userId, rsvp: rsvp}}});
}
}
});
///////////////////////////////////////////////////////////////////////////////
// Users
var displayName = function (user) {
if (user.profile && user.profile.name)
return user.profile.name;
return user.emails[0].address;
};
var contactEmail = function (user) {
if (user.emails && user.emails.length)
return user.emails[0].address;
if (user.services && user.services.facebook && user.services.facebook.email)
return user.services.facebook.email;
return null;
};

View File

@@ -8,7 +8,6 @@
</body>
<template name="page">
{{#if showCreateDialog}}
{{> createDialog}}
{{/if}}
@@ -17,99 +16,38 @@
{{> inviteDialog}}
{{/if}}
<div class="container">
<div class="row">
<div class="span1"> </div>
<div class="span10">
<div class="header row">
<div class="span5">
<h3 style="margin-bottom: 0px">All Tomorrow's Parties</h3>
</div>
<div class="span5">
<div style="float: right">
{{loginButtons align="right"}}
</div>
</div>
</div>
<div class="row">
<div class="span6">
{{> map}}
{{#if currentUser}}
<div class="pagination-centered">
<em><small>Double click the map to post a party!</small></em>
<div class="header row">
<div class="span5">
<h3 style="margin-bottom: 0px">All Tomorrow's Parties</h3>
</div>
<div class="span5">
<div style="float: right">
{{loginButtons align="right"}}
</div>
</div>
{{/if}}
</div>
<div class="span4">
{{> details}}
</div>
</div>
</div>
<div class="span1"> </div>
</div>
</div>
</template>
<template name="createDialog">
<div class="mask"> </div>
<div class="modal">
<div class="modal-header">
<button type="button" class="close cancel">&times;</button>
<h3>Add party</h3>
</div>
<div class="modal-body">
<label>Title</label>
<input type="text" class="title span5">
<label>Description</label>
<textarea class="description span5"></textarea>
<label class="checkbox">
<input type="checkbox" class="private">
Private party &mdash; invitees only
</label>
</div>
<div class="modal-footer">
<a href="#" class="btn cancel">Cancel</a>
<a href="#" class="btn btn-primary save">Add party</a>
</div>
</div>
</div>
</template>
<template name="inviteDialog">
<div class="mask"> </div>
<div class="modal">
<div class="modal-header">
<button type="button" class="close done">&times;</button>
<h3>Invite people</h3>
</div>
<div class="modal-body">
{{#each uninvited}}
<div class="invite-row">
<a href="#" class="btn invite">Invite</a>
{{displayName}}
</div>
{{else}}
Everyone on the site has already been invited.
{{/each}}
</div>
<div class="modal-footer">
<a href="#" class="btn btn-primary done">Done</a>
<div class="row">
<div class="span6">
{{> map}}
{{#if currentUser}}
<div class="pagination-centered">
<em><small>Double click the map to post a party!</small></em>
</div>
{{/if}}
</div>
<div class="span4">
{{> details}}
</div>
</div>
</div>
<div class="span1"> </div>
</div>
</div>
</template>
@@ -117,7 +55,7 @@
<template name="map">
<div class="map">
{{#constant}}
<svg width="480" height="480">
<svg width="500" height="500">
<circle class="callout" cx=-100 cy=-100></circle>
<g class="circles"></g>
<g class="labels"></g>
@@ -157,8 +95,6 @@
<b><a href="#" class="remove">delete this listing</a></b>.
</small></div>
{{/if}}
{{/with}}
{{else}}
<h1 class="muted pagination-centered">
@@ -172,7 +108,6 @@
</div>
</template>
<template name="attendance">
<div class="attendance well well-small">
<div class="muted who"><b>Who</b></div>
@@ -216,6 +151,63 @@
<a href="#" class="btn btn-mini invite">Invite people</a>
</div>
{{/if}}
</div>
</template>
<template name="createDialog">
<div class="mask"> </div>
<div class="modal">
<div class="modal-header">
<button type="button" class="close cancel">&times;</button>
<h3>Add party</h3>
</div>
<div class="modal-body">
{{#if error}}
<div class="alert alert-error">{{error}}</div>
{{/if}}
<label>Title</label>
<input type="text" class="title span5">
<label>Description</label>
<textarea class="description span5"></textarea>
<label class="checkbox">
<input type="checkbox" class="private">
Private party &mdash; invitees only
</label>
</div>
<div class="modal-footer">
<a href="#" class="btn cancel">Cancel</a>
<a href="#" class="btn btn-primary save">Add party</a>
</div>
</div>
</template>
<template name="inviteDialog">
<div class="mask"> </div>
<div class="modal">
<div class="modal-header">
<button type="button" class="close done">&times;</button>
<h3>Invite people</h3>
</div>
<div class="modal-body">
{{#each uninvited}}
<div class="invite-row">
<a href="#" class="btn invite">Invite</a>
{{displayName}}
</div>
{{else}}
Everyone on the site has already been invited.
{{/each}}
</div>
<div class="modal-footer">
<a href="#" class="btn btn-primary done">Done</a>
</div>
</div>
</template>

View File

@@ -1,4 +1,4 @@
// XXX autopublish warning is printed on each restart. super spammy!
// All Tomorrow's Parties -- server
Meteor.publish("directory", function () {
return Meteor.users.find({}, {fields: {emails: 1, profile: 1}});