From 42285a859c21a456bb3674deb8d2aa1b0d029650 Mon Sep 17 00:00:00 2001 From: xavcz Date: Tue, 7 Feb 2017 12:51:11 +0100 Subject: [PATCH] re-enable nova:subscribe --- packages/nova-subscribe/README.md | 20 +-- packages/nova-subscribe/lib/callbacks.js | 2 +- .../lib/components/SubscribeTo.jsx | 123 +++++++++++------- packages/nova-subscribe/lib/custom_fields.js | 6 +- packages/nova-subscribe/lib/helpers.js | 18 +++ packages/nova-subscribe/lib/modules.js | 5 +- .../lib/{methods.js => mutations.js} | 77 ++++++----- packages/nova-subscribe/lib/views.js | 38 +++--- 8 files changed, 174 insertions(+), 115 deletions(-) create mode 100644 packages/nova-subscribe/lib/helpers.js rename packages/nova-subscribe/lib/{methods.js => mutations.js} (67%) diff --git a/packages/nova-subscribe/README.md b/packages/nova-subscribe/README.md index 60dd50386a..5bcaef9a5f 100644 --- a/packages/nova-subscribe/README.md +++ b/packages/nova-subscribe/README.md @@ -25,35 +25,35 @@ categories.unsubscribe This package also provides a reusable component called `SubscribeTo` to subscribe to an document of collection. -This component takes two props, `document` & `documentType`. It can trigger any method described below: +This component takes the `document` as a props. It can trigger any method described below: ```jsx // for example, in PostItem.jsx - + // for example, in UsersProfile.jsx - + // for example, in Category.jsx - + ``` ### Extend to other collections than Users, Posts, Categories -This package export a function called `subscribeMethodsGenerator` that takes a collection as an argument and create the associated methods code : +This package export a function called `subscribMutationsGenerator` that takes a collection as an argument and create the associated methods code : ```js // in my custom package -import subscribeMethodsGenerator from 'meteor/nova:subscribe'; +import subscribMutationsGenerator from 'meteor/nova:subscribe'; import Movies from './collection.js'; -// the function creates the code, then you have to associate it to the Meteor namespace: -Meteor.methods(subscribeMethodsGenerator(Movies)); +// the function creates the code and give it to the graphql server +subscribMutationsGenerator(Movies); ``` -This will creates for you the methods `movies.subscribe` & `movies.unsubscribe` than can be used in the `SubscribeTo` component: +This will creates for you the mutations `moviesSubscribe` & `moviesUnsubscribe` than can be used in the `SubscribeTo` component: ```jsx // in my custom component - + ``` You'll also need to write the relevant callbacks, custom fields & permissions to run whenever a user is subscribed to your custom collection's item. See these files for inspiration. diff --git a/packages/nova-subscribe/lib/callbacks.js b/packages/nova-subscribe/lib/callbacks.js index b399c64ce6..eabb08e08c 100644 --- a/packages/nova-subscribe/lib/callbacks.js +++ b/packages/nova-subscribe/lib/callbacks.js @@ -1,4 +1,4 @@ -import Telescope from 'meteor/nova:lib'; +import Telescope from 'meteor/nova:lib'; // note: Telescope.notifications import Users from 'meteor/nova:users'; import { addCallback } from 'meteor/nova:core'; diff --git a/packages/nova-subscribe/lib/components/SubscribeTo.jsx b/packages/nova-subscribe/lib/components/SubscribeTo.jsx index 98c2032f9b..66712c5e3f 100644 --- a/packages/nova-subscribe/lib/components/SubscribeTo.jsx +++ b/packages/nova-subscribe/lib/components/SubscribeTo.jsx @@ -1,75 +1,106 @@ import React, { PropTypes, Component } from 'react'; -import { intlShape } from 'react-intl'; -import { withCurrentUser, withMessages, registerComponent } from 'meteor/nova:core'; +import { intlShape, FormattedMessage } from 'react-intl'; +import { compose, graphql } from 'react-apollo'; +import gql from 'graphql-tag'; +import Users from 'meteor/nova:users'; +import { withCurrentUser, withMessages, registerComponent, Utils } from 'meteor/nova:core'; -class SubscribeTo extends Component { +// boolean -> unsubscribe || subscribe +const getSubscribeAction = subscribed => subscribed ? 'unsubscribe' : 'subscribe' + +class SubscribeToActionHandler extends Component { constructor(props, context) { super(props, context); this.onSubscribe = this.onSubscribe.bind(this); - this.isSubscribed = this.isSubscribed.bind(this); - } - - onSubscribe(e) { - e.preventDefault(); - - const {document, documentType} = this.props; - - const action = this.isSubscribed() ? `unsubscribe` : `subscribe`; - - // method name will be for example posts.subscribe - this.context.actions.call(`${documentType}.${action}`, document._id, (error, result) => { - if (error) { - this.props.flash(error.message, "error"); - } - - if (result) { - // success message will be for example posts.subscribed - this.props.flash(this.context.intl.formatMessage( - {id: `${documentType}.${action}d`}, - // handle usual name properties - {name: document.name || document.title || document.displayName} - ), "success"); - this.context.events.track(action, {'_id': this.props.document._id}); - } - }); + + this.state = { + subscribed: !!Users.isSubscribedTo(props.currentUser, props.document, props.documentType), + }; } - isSubscribed() { - const documentCheck = this.props.document; - - return documentCheck && documentCheck.subscribers && documentCheck.subscribers.indexOf(this.context.currentUser._id) !== -1; + async onSubscribe(e) { + try { + e.preventDefault(); + + const { document, documentType } = this.props; + const action = getSubscribeAction(this.state.subscribed); + + // todo: change the mutation to auto-update the user in the store + await this.setState(prevState => ({subscribed: !prevState.subscribed})); + + // mutation name will be for example postsSubscribe + await this.props[`${documentType + Utils.capitalize(action)}`]({documentId: document._id}); + + // success message will be for example posts.subscribed + this.props.flash(this.context.intl.formatMessage( + {id: `${documentType}.${action}d`}, + // handle usual name properties + {name: document.name || document.title || document.displayName} + ), "success"); + + + } catch(error) { + this.props.flash(error.message, "error"); + } } render() { - const {currentUser, document, documentType} = this.props; - + const { currentUser, document, documentType } = this.props; + const { subscribed } = this.state; + + const action = `${documentType}.${getSubscribeAction(subscribed)}`; + // can't subscribe to yourself or to own post (also validated on server side) - if (!currentUser || !document || (documentType === 'posts' && document && document.author === currentUser.username) || (documentType === 'users' && document === currentUser)) { + if (!currentUser || !document || (documentType === 'posts' && document.userId === currentUser._id) || (documentType === 'users' && document._id === currentUser._id)) { return null; } - const action = this.isSubscribed() ? `${documentType}.unsubscribe` : `${documentType}.subscribe`; - const className = this.props.className ? this.props.className : ""; - - return Users.canDo(currentUser, action) ? {this.context.intl.formatMessage({id: action})} : null; + + return Users.canDo(currentUser, action) ? : null; } } -SubscribeTo.propTypes = { +SubscribeToActionHandler.propTypes = { document: React.PropTypes.object.isRequired, - documentType: React.PropTypes.string.isRequired, className: React.PropTypes.string, currentUser: React.PropTypes.object, } -SubscribeTo.contextTypes = { - actions: React.PropTypes.object, - events: React.PropTypes.object, +SubscribeToActionHandler.contextTypes = { intl: intlShape }; -registerComponent('SubscribeTo', SubscribeTo, withCurrentUser, withMessages); \ No newline at end of file +const subscribeMutationContainer = ({documentType, actionName}) => graphql(gql` + mutation ${documentType + actionName}($documentId: String) { + ${documentType + actionName}(documentId: $documentId) { + _id + subscribedItems + } + } +`, { + props: ({ownProps, mutate}) => ({ + [documentType + actionName]: vars => { + return mutate({ + variables: vars, + }); + }, + }), +}); + +const SubscribeTo = props => { + + const documentType = `${props.document.__typename.toLowerCase()}s`; + + const withSubscribeMutations = ['Subscribe', 'Unsubscribe'].map(actionName => subscribeMutationContainer({documentType, actionName})); + + const EnhancedHandler = compose(...withSubscribeMutations)(SubscribeToActionHandler); + + return ; +} + + +registerComponent('SubscribeTo', SubscribeTo, withCurrentUser, withMessages); diff --git a/packages/nova-subscribe/lib/custom_fields.js b/packages/nova-subscribe/lib/custom_fields.js index 21bc96f6d9..3c58a8e173 100644 --- a/packages/nova-subscribe/lib/custom_fields.js +++ b/packages/nova-subscribe/lib/custom_fields.js @@ -8,6 +8,7 @@ Users.addField([ optional: true, blackbox: true, hidden: true, // never show this + preload: true, } }, { @@ -16,11 +17,6 @@ Users.addField([ type: [String], optional: true, hidden: true, // never show this, - // publish: true, - // join: { - // joinAs: "subscribersArray", - // collection: () => Users - // } } }, { diff --git a/packages/nova-subscribe/lib/helpers.js b/packages/nova-subscribe/lib/helpers.js new file mode 100644 index 0000000000..e1c4de0469 --- /dev/null +++ b/packages/nova-subscribe/lib/helpers.js @@ -0,0 +1,18 @@ +import Users from 'meteor/nova:users'; + +Users.isSubscribedTo = (user, document) => { + // return user && document && document.subscribers && document.subscribers.indexOf(user._id) !== -1; + if (!user || !document) { + // should return an error + return false; + } + + const { __typename, _id: itemId } = document; + const documentType = __typename + 's'; + + if (user.subscribedItems && user.subscribedItems[documentType]) { + return !!user.subscribedItems[documentType].find(subscribedItems => subscribedItems.itemId === itemId); + } else { + return false; + } +}; diff --git a/packages/nova-subscribe/lib/modules.js b/packages/nova-subscribe/lib/modules.js index bb6d992f1b..6f2dfb4842 100644 --- a/packages/nova-subscribe/lib/modules.js +++ b/packages/nova-subscribe/lib/modules.js @@ -1,9 +1,10 @@ import './callbacks.js'; import './custom_fields.js'; -import subscribeMethodsGenerator from './methods.js'; +import './helpers.js'; +import subscribeMutationsGenerator from './mutations.js'; import './views.js'; import './permissions.js'; import './components/SubscribeTo.jsx'; -export default subscribeMethodsGenerator; \ No newline at end of file +export default subscribeMutationsGenerator; diff --git a/packages/nova-subscribe/lib/methods.js b/packages/nova-subscribe/lib/mutations.js similarity index 67% rename from packages/nova-subscribe/lib/methods.js rename to packages/nova-subscribe/lib/mutations.js index 8dd90ce351..a7b159f968 100644 --- a/packages/nova-subscribe/lib/methods.js +++ b/packages/nova-subscribe/lib/mutations.js @@ -1,4 +1,5 @@ import Users from 'meteor/nova:users'; +import { Utils, GraphQLSchema } from 'meteor/nova:core'; /** * @summary Verify that the un/subscription can be performed @@ -36,8 +37,8 @@ const prepareSubscription = (action, collection, itemId, user) => { // assign the right fields depending on the collection const fields = { - subscribers: collectionName === 'Users' ? 'subscribers' : 'subscribers', - subscriberCount: collectionName === 'Users' ? 'subscriberCount' : 'subscriberCount', + subscribers: 'subscribers', + subscriberCount: 'subscriberCount', }; // return true if the item has the subscriber's id in its fields @@ -82,7 +83,7 @@ const performSubscriptionAction = (action, collection, itemId, user) => { // - the action is subscribe but the user has already subscribed to this item // - the action is unsubscribe but the user hasn't subscribed to this item if (!subscription || (action === 'subscribe' && subscription.hasSubscribedItem) || (action === 'unsubscribe' && !subscription.hasSubscribedItem)) { - return false; // xxx: should return exploitable error + throw Error({id: 'app.mutation_not_allowed', value: 'Already subscribed'}) } // shorthand for useful variables @@ -122,58 +123,70 @@ const performSubscriptionAction = (action, collection, itemId, user) => { [updateOperator]: { [`subscribedItems.${collectionName}`]: loggedItem } }); - return true; // action completed! ✅ + const updatedUser = Users.findOne({_id: user._id}, {fields: {_id:1, subscribedItems: 1}}); + + return updatedUser; } else { - return false; // xxx: should return exploitable error + throw Error(Utils.encodeIntlError({id: 'app.something_bad_happened'})) } }; /** - * @summary Generate methods 'collection.subscribe' & 'collection.unsubscribe' automatically + * @summary Generate mutations 'collection.subscribe' & 'collection.unsubscribe' automatically * @params {Array[Collections]} collections */ - let subscribeMethodsGenerator; - export default subscribeMethodsGenerator = (collection) => { + const subscribeMutationsGenerator = (collection) => { - // generic method function calling the performSubscriptionAction - const genericMethodFunction = (col, action) => { + // generic mutation function calling the performSubscriptionAction + const genericMutationFunction = (collectionName, action) => { // return the method code - return function(docId, userId) { - check(docId, String); - check(userId, Match.Maybe(String)); - - const currentUser = Users.findOne({_id: this.userId}); // this refers to Meteor thanks to previous fat arrows when this function-builder is used - const user = typeof userId !== "undefined" ? Users.findOne({_id: userId }) : currentUser; - - if (!Users.canDo(currentUser, `${col._name}.${action}`) || typeof userId !== "undefined" && !Users.canDo(currentUser, `${col._name}.${action}.all`)) { - throw new Error(601, "You don't have the permission to do this"); + return function(root, { documentId }, context) { + + // extract the current user & the relevant collection from the graphql server context + const { currentUser, [Utils.capitalize(collectionName)]: collection } = context; + + // permission check + if (!Users.canDo(context.currentUser, `${collectionName}.${action}`) || !Users.canDo(currentUser, `${collectionName}.${action}.all`)) { + throw new Error(Utils.encodeIntlError({id: "app.noPermission"})); } - - return performSubscriptionAction(action, col, docId, user); + + // do the actual subscription action + return performSubscriptionAction(action, collection, documentId, currentUser); }; }; const collectionName = collection._name; - // return an object of the shape expected by Meteor.methods - return { - [`${collectionName}.subscribe`]: genericMethodFunction(collection, 'subscribe'), - [`${collectionName}.unsubscribe`]: genericMethodFunction(collection, 'unsubscribe') - }; + + // add mutations to the schema + GraphQLSchema.addMutation(`${collectionName}Subscribe(documentId: String): User`), + GraphQLSchema.addMutation(`${collectionName}Unsubscribe(documentId: String): User`); + + // create an object of the shape expected by mutations resolvers + GraphQLSchema.addResolvers({ + Mutation: { + [`${collectionName}Subscribe`]: genericMutationFunction(collectionName, 'subscribe'), + [`${collectionName}Unsubscribe`]: genericMutationFunction(collectionName, 'unsubscribe'), + }, + }); + + }; -// Finally. Add the methods to the Meteor namespace 🖖 +// Finally. Add the mutations to the Meteor namespace 🖖 // nova:users is a dependency of this package, it is alreay imported -Meteor.methods(subscribeMethodsGenerator(Users)); +subscribeMutationsGenerator(Users); -// check if nova:posts exists, if yes, add the methods to Posts +// check if nova:posts exists, if yes, add the mutations to Posts if (typeof Package['nova:posts'] !== 'undefined') { import Posts from 'meteor/nova:posts'; - Meteor.methods(subscribeMethodsGenerator(Posts)); + subscribeMutationsGenerator(Posts); } -// check if nova:categories exists, if yes, add the methods to Categories +// check if nova:categories exists, if yes, add the mutations to Categories if (typeof Package['nova:categories'] !== "undefined") { import Categories from 'meteor/nova:categories'; - Meteor.methods(subscribeMethodsGenerator(Categories)); + subscribeMutationsGenerator(Categories); } + +export default subscribeMutationsGenerator; diff --git a/packages/nova-subscribe/lib/views.js b/packages/nova-subscribe/lib/views.js index 7e4cd1747c..e8198d8ba2 100644 --- a/packages/nova-subscribe/lib/views.js +++ b/packages/nova-subscribe/lib/views.js @@ -1,19 +1,19 @@ -import Users from 'meteor/nova:users'; - -if (typeof Package['nova:posts'] !== "undefined") { - import Posts from "meteor/nova:posts"; - - Posts.views.add("userSubscribedPosts", function (terms) { - var user = Users.findOne(terms.userId), - postsIds = []; - - if (user && user.subscribedItems && user.subscribedItems.Posts) { - postsIds = _.pluck(user.subscribedItems.Posts, "itemId"); - } - - return { - selector: {_id: {$in: postsIds}}, - options: {limit: 5, sort: {postedAt: -1}} - }; - }); -} +// import Users from 'meteor/nova:users'; +// +// if (typeof Package['nova:posts'] !== "undefined") { +// import Posts from "meteor/nova:posts"; +// +// Posts.views.add("userSubscribedPosts", function (terms) { +// var user = Users.findOne(terms.userId), +// postsIds = []; +// +// if (user && user.subscribedItems && user.subscribedItems.Posts) { +// postsIds = _.pluck(user.subscribedItems.Posts, "itemId"); +// } +// +// return { +// selector: {_id: {$in: postsIds}}, +// options: {limit: 5, sort: {postedAt: -1}} +// }; +// }); +// }