From cf4f63b0dec6adeb47a83c88f24e546d228bbd1b Mon Sep 17 00:00:00 2001 From: xavcz Date: Wed, 1 Feb 2017 13:53:52 +0100 Subject: [PATCH 1/4] export categories schema functions outside of nova:categories (can be used in custom packages for instance) --- packages/nova-categories/lib/client.js | 5 +++-- packages/nova-categories/lib/modules.js | 4 ++-- packages/nova-categories/lib/server.js | 5 +++-- 3 files changed, 8 insertions(+), 6 deletions(-) diff --git a/packages/nova-categories/lib/client.js b/packages/nova-categories/lib/client.js index 9c0ae1243..1c346b5f8 100644 --- a/packages/nova-categories/lib/client.js +++ b/packages/nova-categories/lib/client.js @@ -1,3 +1,4 @@ -import Categories from './modules.js'; +import Categories, { getCategories, getCategoriesAsOptions } from './modules.js'; -export default Categories; \ No newline at end of file +export { getCategories, getCategoriesAsOptions }; +export default Categories; diff --git a/packages/nova-categories/lib/modules.js b/packages/nova-categories/lib/modules.js index 285ae1fa5..bf66d63a0 100644 --- a/packages/nova-categories/lib/modules.js +++ b/packages/nova-categories/lib/modules.js @@ -1,6 +1,6 @@ import Categories from './collection.js'; -import './schema.js'; +export { getCategories, getCategoriesAsOptions } from './schema.js'; import './helpers.js'; import './callbacks.js'; import './parameters.js'; @@ -9,4 +9,4 @@ import './permissions.js'; import './resolvers.js'; import './mutations.js'; -export default Categories; \ No newline at end of file +export default Categories; diff --git a/packages/nova-categories/lib/server.js b/packages/nova-categories/lib/server.js index b556e2780..274bc143e 100644 --- a/packages/nova-categories/lib/server.js +++ b/packages/nova-categories/lib/server.js @@ -1,5 +1,6 @@ -import Categories from './modules.js'; +import Categories, { getCategories, getCategoriesAsOptions } from './modules.js'; import './server/load_categories.js'; -export default Categories; \ No newline at end of file +export { getCategories, getCategoriesAsOptions }; +export default Categories; From 4810947eea1b95ac9862f510970c3a35850472be Mon Sep 17 00:00:00 2001 From: xavcz Date: Wed, 1 Feb 2017 14:52:56 +0100 Subject: [PATCH 2/4] FormComponent: extract props beforeComponent & afterComponent used to wrap the form component --- packages/nova-forms/lib/FormComponent.jsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/nova-forms/lib/FormComponent.jsx b/packages/nova-forms/lib/FormComponent.jsx index a4a3c2b55..626447279 100644 --- a/packages/nova-forms/lib/FormComponent.jsx +++ b/packages/nova-forms/lib/FormComponent.jsx @@ -30,7 +30,7 @@ class FormComponent extends Component { renderComponent() { // see https://facebook.github.io/react/warnings/unknown-prop.html - const { control, group, updateCurrentValues, document, ...rest } = this.props; // eslint-disable-line + const { control, group, updateCurrentValues, document, beforeComponent, afterComponent, ...rest } = this.props; // eslint-disable-line const base = this.props.control === "function" ? this.props : rest; From 08c171ed64532367baa5297ca2b3ac4dd42dce9b Mon Sep 17 00:00:00 2001 From: xavcz Date: Wed, 1 Feb 2017 16:37:06 +0100 Subject: [PATCH 3/4] fix #1541: increasePostViewCount mutation + associated resolver; store posts viewed on the client session on postsViewed in the redux store; document PostsPage HOC & lifecycle hook --- .../lib/posts/PostsPage.jsx | 110 ++++++++++++++---- packages/nova-posts/lib/modules.js | 3 +- packages/nova-posts/lib/mutations.js | 6 +- packages/nova-posts/lib/redux.js | 23 ++++ packages/nova-posts/lib/resolvers.js | 5 + 5 files changed, 119 insertions(+), 28 deletions(-) create mode 100644 packages/nova-posts/lib/redux.js diff --git a/packages/nova-base-components/lib/posts/PostsPage.jsx b/packages/nova-base-components/lib/posts/PostsPage.jsx index af2c850ce..fc1888697 100644 --- a/packages/nova-base-components/lib/posts/PostsPage.jsx +++ b/packages/nova-base-components/lib/posts/PostsPage.jsx @@ -1,44 +1,104 @@ -import { Components, registerComponent, withDocument, withCurrentUser } from 'meteor/nova:core'; -import React from 'react'; +import { Components, registerComponent, withDocument, withCurrentUser, getActions, withMutation } from 'meteor/nova:core'; import Posts from 'meteor/nova:posts'; +import React, { Component, PropTypes } from 'react'; +import { connect } from 'react-redux'; +import { bindActionCreators } from 'redux'; -const PostsPage = (props) => { +class PostsPage extends Component { + + render() { + if (this.props.loading) { + + return
+ + } else { + + const post = this.props.document; - if (props.loading) { + const htmlBody = {__html: post.htmlBody}; - return
+ return ( +
+ + + - } else { + {post.htmlBody ?
: null} - const post = props.document; + - const htmlBody = {__html: post.htmlBody}; - - return ( -
- - - - - {post.htmlBody ?
: null} - - - -
- ) +
+ ); + + } } -}; + + // triggered after the component did mount on the client + async componentDidMount() { + try { + + // destructure the relevant props + const { + // from the parent component, used in withDocument, GraphQL HOC + documentId, + // from connect, Redux HOC + setViewed, + postsViewed, + // from withMutation, GraphQL HOC + increasePostViewCount, + } = this.props; + + // a post id has been found & it's has not been seen yet on this client session + if (documentId && !postsViewed.includes(documentId)) { + + // trigger the asynchronous mutation with postId as an argument + await increasePostViewCount({postId: documentId}); + + // once the mutation is done, update the redux store + setViewed(documentId); + } + + } catch(error) { + console.log(error); // eslint-disable-line + } + } +} PostsPage.displayName = "PostsPage"; PostsPage.propTypes = { - document: React.PropTypes.object + documentId: PropTypes.string, + document: PropTypes.object, + postsViewed: PropTypes.array, + setViewed: PropTypes.func, + increasePostViewCount: PropTypes.func, } -const options = { +const queryOptions = { collection: Posts, queryName: 'postsSingleQuery', fragmentName: 'PostsPage', }; -registerComponent('PostsPage', PostsPage, withCurrentUser, [withDocument, options]); +const mutationOptions = { + name: 'increasePostViewCount', + args: {postId: 'String'}, +}; + +const mapStateToProps = state => ({ postsViewed: state.postsViewed }); +const mapDispatchToProps = dispatch => bindActionCreators(getActions().postsViewed, dispatch); + +registerComponent( + // component name used by Nova + 'PostsPage', + // React component + PostsPage, + // HOC to give access to the current user + withCurrentUser, + // HOC to load the data of the document, based on queryOptions & a documentId props + [withDocument, queryOptions], + // HOC to provide a single mutation, based on mutationOptions + withMutation(mutationOptions), + // HOC to give access to the redux store & related actions + connect(mapStateToProps, mapDispatchToProps) +); diff --git a/packages/nova-posts/lib/modules.js b/packages/nova-posts/lib/modules.js index 1858e1eac..158b584c4 100644 --- a/packages/nova-posts/lib/modules.js +++ b/packages/nova-posts/lib/modules.js @@ -13,5 +13,6 @@ import './emails.js'; import './permissions.js'; import './resolvers.js'; import './mutations.js'; +import './redux.js'; -export default Posts; \ No newline at end of file +export default Posts; diff --git a/packages/nova-posts/lib/mutations.js b/packages/nova-posts/lib/mutations.js index 16d8e421d..c325cbf35 100644 --- a/packages/nova-posts/lib/mutations.js +++ b/packages/nova-posts/lib/mutations.js @@ -1,4 +1,4 @@ -import { newMutation, editMutation, removeMutation } from 'meteor/nova:core'; +import { newMutation, editMutation, removeMutation, GraphQLSchema } from 'meteor/nova:core'; import Users from 'meteor/nova:users'; const performCheck = (mutation, user, document) => { @@ -85,4 +85,6 @@ const mutations = { }; -export default mutations; \ No newline at end of file +GraphQLSchema.addMutation('increasePostViewCount(postId: String): Float'); + +export default mutations; diff --git a/packages/nova-posts/lib/redux.js b/packages/nova-posts/lib/redux.js new file mode 100644 index 000000000..ac2dec643 --- /dev/null +++ b/packages/nova-posts/lib/redux.js @@ -0,0 +1,23 @@ +import { addAction, addReducer } from 'meteor/nova:core'; + +addAction({ + postsViewed: { + setViewed: (postId) => ({ + type: 'SET_VIEWED', + postId, + }), + }, +}); + +addReducer({ + postsViewed: (state = [], action) => { + if (action.type === 'SET_VIEWED') { + return [ + ...state, + action.postId, + ]; + } + + return state; + }, +}); diff --git a/packages/nova-posts/lib/resolvers.js b/packages/nova-posts/lib/resolvers.js index d2cb827b5..51ba14a24 100644 --- a/packages/nova-posts/lib/resolvers.js +++ b/packages/nova-posts/lib/resolvers.js @@ -6,6 +6,11 @@ const specificResolvers = { return context.Users.findOne({ _id: post.userId }, { fields: context.getViewableFields(context.currentUser, context.Users) }); }, }, + Mutation: { + increasePostViewCount(root, { postId }, context) { + return context.Posts.update({_id: postId}, { $inc: { viewCount: 1 }}); + } + } }; GraphQLSchema.addResolvers(specificResolvers); From 4d024536b17ec72423ccf9e8f26c4ba54c240a37 Mon Sep 17 00:00:00 2001 From: xavcz Date: Wed, 1 Feb 2017 23:11:41 +0100 Subject: [PATCH 4/4] nova:events: auto-generated hooks on `collection.mutation.async`, package-structure (first pass) --- package.json | 1 + packages/nova-events/lib/callbacks.js | 47 +++++++++++ packages/nova-events/lib/client.js | 7 +- packages/nova-events/lib/client/analytics.js | 40 --------- packages/nova-events/lib/collection.js | 35 -------- packages/nova-events/lib/helpers.js | 86 ++++++++++++++++++++ packages/nova-events/lib/server.js | 18 +++- packages/nova-events/package.js | 2 +- sample_settings.json | 4 +- 9 files changed, 160 insertions(+), 80 deletions(-) create mode 100644 packages/nova-events/lib/callbacks.js delete mode 100644 packages/nova-events/lib/client/analytics.js create mode 100644 packages/nova-events/lib/helpers.js diff --git a/package.json b/package.json index 678eab1fd..4b5a1bd76 100644 --- a/package.json +++ b/package.json @@ -8,6 +8,7 @@ "lint": "eslint --cache --ext .jsx,js packages" }, "dependencies": { + "analytics-node": "^2.1.1", "apollo-client": "^0.8.1", "babel-runtime": "^6.18.0", "bcrypt": "^0.8.7", diff --git a/packages/nova-events/lib/callbacks.js b/packages/nova-events/lib/callbacks.js new file mode 100644 index 000000000..01be44db0 --- /dev/null +++ b/packages/nova-events/lib/callbacks.js @@ -0,0 +1,47 @@ +import { addCallback, getSetting } from 'meteor/nova:core'; +import { sendGoogleAnalyticsRequest, mutationAnalyticsAsync } from './helpers'; +import Analytics from 'analytics-node'; + +// add client-side callback: log a ga request on page view +addCallback('router.onUpdate', sendGoogleAnalyticsRequest); + + +// get the segment write key from the settings +const useSegment = getSetting('useSegment'); +const writeKey = getSetting('segmentWriteKey'); + +// the settings obviously tells to use segment +// and segment write key is defined & isn't the placeholder from sample_settings.json +if (useSegment && writeKey && writeKey !== '456bar') { + const analyticsInstance = new Analytics(writeKey); + + // generate callbacks on collection ... + ['users', 'posts', 'comments', 'categories'].map(collection => { + // ... for each common mutation + return ['new', 'edit', 'remove'].map(mutation => { + + const hook = `${collection}.${mutation}`; + + addCallback(`${hook}.async`, function AnalyticsTracking(...args) { + + // a note on what's happenning below: + // the first argument is always the document we are interested in + // the second to last argument is always the current user + // on edit.async, the argument on index 1 is always the previous document + // see nova:lib/mutations.js for more informations + + // remove unnecessary 'previousDocument' if operating on a collection.edit hook + if (hook.includes('edit')) { + args.splice(1,1); + } + + const [document, currentUser, ...rest] = args; // eslint-disable-line no-unused-vars + + return mutationAnalyticsAsync(analyticsInstance, hook, document, currentUser); + }); + + // return the hook name, used for debug + return hook; + }); + }); +} diff --git a/packages/nova-events/lib/client.js b/packages/nova-events/lib/client.js index 3b4757489..5f5e7d985 100644 --- a/packages/nova-events/lib/client.js +++ b/packages/nova-events/lib/client.js @@ -1,5 +1,8 @@ import Events from './collection.js'; +import { initGoogleAnalytics } from './helpers.js'; +import './callbacks.js'; -import './client/analytics.js'; +// init google analytics on the client module +initGoogleAnalytics(); -export default Events; \ No newline at end of file +export default Events; diff --git a/packages/nova-events/lib/client/analytics.js b/packages/nova-events/lib/client/analytics.js deleted file mode 100644 index 875529d1f..000000000 --- a/packages/nova-events/lib/client/analytics.js +++ /dev/null @@ -1,40 +0,0 @@ -import Events from '../collection.js'; -import { addCallback, getSetting } from 'meteor/nova:core'; - -Events.analyticsRequest = function() { - // Google Analytics - if (typeof window.ga !== 'undefined'){ - window.ga('send', 'pageview', { - 'page': window.location.pathname - }); - } -}; - -Events.analyticsInit = function() { - - // Google Analytics - const googleAnalyticsId = getSetting("googleAnalyticsId"); - if (googleAnalyticsId) { - - (function(i,s,o,g,r,a,m){i['GoogleAnalyticsObject']=r;i[r]=i[r]||function(){ - (i[r].q=i[r].q||[]).push(arguments)},i[r].l=1*new Date();a=s.createElement(o), - m=s.getElementsByTagName(o)[0];a.async=1;a.src=g;m.parentNode.insertBefore(a,m) - })(window,document,'script','//www.google-analytics.com/analytics.js','ga'); - - var cookieDomain = document.domain === "localhost" ? "none" : "auto"; - - window.ga('create', googleAnalyticsId, cookieDomain); - - } - - // trigger first request once analytics are initialized - Events.analyticsRequest(); - -}; - -Events.analyticsInit(); - -function analyticsRequest () { - Events.analyticsRequest(); -} -addCallback('router.onUpdate', analyticsRequest); diff --git a/packages/nova-events/lib/collection.js b/packages/nova-events/lib/collection.js index 40b2d739f..7202fd860 100644 --- a/packages/nova-events/lib/collection.js +++ b/packages/nova-events/lib/collection.js @@ -28,41 +28,6 @@ Events.schema = new SimpleSchema({ } }); -// Meteor.startup(function(){ -// // needs to happen after every fields are added -// Events.internationalize(); -// }); - Events.attachSchema(Events.schema); -if (Meteor.isServer) { - Events.log = function (event) { - - // if event is supposed to be unique, check if it has already been logged - if (!!event.unique && !!Events.findOne({name: event.name})) { - return; - } - - event.createdAt = new Date(); - - Events.insert(event); - - }; -} - -Events.track = function(event, properties){ - // console.log('trackevent: ', event, properties); - properties = properties || {}; - //TODO - // add event to an Events collection for logging and buffering purposes - // if(Meteor.isClient){ - // if(typeof mixpanel !== 'undefined' && typeof mixpanel.track !== 'undefined'){ - // mixpanel.track(event, properties); - // } - // if(typeof GoSquared !== 'undefined' && typeof GoSquared.DefaultTracker !== 'undefined'){ - // GoSquared.DefaultTracker.TrackEvent(event, JSON.stringify(properties)); - // } - // } -}; - export default Events; diff --git a/packages/nova-events/lib/helpers.js b/packages/nova-events/lib/helpers.js new file mode 100644 index 000000000..97f3c2793 --- /dev/null +++ b/packages/nova-events/lib/helpers.js @@ -0,0 +1,86 @@ +import { getSetting } from 'meteor/nova:core'; + +/* + + We provide a special support for Google Analytics. + + If you want to enable GA page viewing / tracking, go to + your settings file and update the "public > googleAnalyticsId" + field with your GA unique identifier (UA-xxx...). + +*/ + +export const sendGoogleAnalyticsRequest = () => { + if (window && window.ga) { + window.ga('send', 'pageview', { + 'page': window.location.pathname + }); + } +}; + +export const initGoogleAnalytics = () => { + + // get the google analytics id from the settings + const googleAnalyticsId = getSetting("googleAnalyticsId"); + + // the google analytics id exists & isn't the placeholder from sample_settings.json + if (googleAnalyticsId && googleAnalyticsId !== "foo123") { + + (function(i,s,o,g,r,a,m){i['GoogleAnalyticsObject']=r;i[r]=i[r]||function(){ + (i[r].q=i[r].q||[]).push(arguments)},i[r].l=1*new Date();a=s.createElement(o), + m=s.getElementsByTagName(o)[0];a.async=1;a.src=g;m.parentNode.insertBefore(a,m) + })(window,document,'script','//www.google-analytics.com/analytics.js','ga'); + + const cookieDomain = document.domain === "localhost" ? "none" : "auto"; + + window.ga('create', googleAnalyticsId, cookieDomain); + + // trigger first request once analytics are initialized + sendGoogleAnalyticsRequest(); + } +}; + +/* + + We provide a special support for Segment, using analytics-node + See https://segment.com/docs/sources/server/node/ + +*/ + +export const mutationAnalyticsAsync = (analytics, hook, document, user) => { + + if (hook.includes('users')) { + // if the mutation is related to users, use analytics.identify + // see https://segment.com/docs/sources/server/node/#identify + + // note: on users.new.async, user is undefined + const userId = user ? user._id : document._id; + + const data = { + userId, + traits: document, + }; + + // uncomment for debug + // console.log(`// dispatching identify on "${hook}" (user ${userId})`); + // console.log(data); + + analytics.identify(data); + + } else { + // else use analytics.track + // see https://segment.com/docs/sources/server/node/#track + + const data = { + userId: user._id, + event: hook, + properties: document, + }; + + // uncomment for debug + // console.log(`// dispatching track on "${hook}"`); + // console.log(data); + + analytics.track(data); + } +} diff --git a/packages/nova-events/lib/server.js b/packages/nova-events/lib/server.js index 29c8986e5..245bb6040 100644 --- a/packages/nova-events/lib/server.js +++ b/packages/nova-events/lib/server.js @@ -1,3 +1,19 @@ import Events from './collection.js'; +import './helpers'; +import './callbacks.js'; -export default Events; \ No newline at end of file +// note: do we still need that? +Events.log = function (event) { + + // if event is supposed to be unique, check if it has already been logged + if (!!event.unique && !!Events.findOne({name: event.name})) { + return; + } + + event.createdAt = new Date(); + + Events.insert(event); + +}; + +export default Events; diff --git a/packages/nova-events/package.js b/packages/nova-events/package.js index ef7b72a6f..5792a175f 100644 --- a/packages/nova-events/package.js +++ b/packages/nova-events/package.js @@ -10,7 +10,7 @@ Package.onUse(function(api) { api.versionsFrom("METEOR@1.0"); api.use([ - 'nova:lib@1.0.0' + 'nova:core@1.0.0', ]); api.mainModule("lib/server.js", "server"); diff --git a/sample_settings.json b/sample_settings.json index 9bb06970b..f9f7aa5c6 100644 --- a/sample_settings.json +++ b/sample_settings.json @@ -27,7 +27,9 @@ "twitterAccount": "foo", "facebookPage": "http://facebook.com/foo", - "googleAnalyticsId":"123foo" + "googleAnalyticsId":"123foo", + "useSegment": false, + "segmentWriteKey": "456bar" }, "defaultEmail": "hello@world.com",