diff --git a/examples/parties/client/client.js b/examples/parties/client/client.js index b2aebab942..3802b2d0ab 100644 --- a/examples/parties/client/client.js +++ b/examples/parties/client/client.js @@ -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 () { diff --git a/examples/parties/client/parties.css b/examples/parties/client/parties.css index d32fe0efa3..adecec2651 100644 --- a/examples/parties/client/parties.css +++ b/examples/parties/client/parties.css @@ -69,4 +69,4 @@ input.chosen { stroke-opacity: .8; fill: none; stroke: red; -} \ No newline at end of file +} diff --git a/examples/parties/model.js b/examples/parties/model.js index 7d88b3d4ac..968a990440 100644 --- a/examples/parties/model.js +++ b/examples/parties/model.js @@ -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..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; +}; diff --git a/examples/parties/parties.html b/examples/parties/parties.html index 69ecd312fa..9d68cb371d 100644 --- a/examples/parties/parties.html +++ b/examples/parties/parties.html @@ -8,7 +8,6 @@ - - - - @@ -117,7 +55,7 @@ - + + + + + diff --git a/examples/parties/server/server.js b/examples/parties/server/server.js index 6c61f68b78..46ac47b686 100644 --- a/examples/parties/server/server.js +++ b/examples/parties/server/server.js @@ -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}});