+
+
- { this.props.currentUserLoading ? : (this.props.children ? this.props.children : ) }
+ {this.props.currentUserLoading ? (
+
+ ) : this.props.children ? (
+ this.props.children
+ ) : (
+
+ )}
@@ -40,11 +72,11 @@ class App extends PureComponent {
App.propTypes = {
currentUserLoading: PropTypes.bool,
-}
+};
App.childContextTypes = {
intl: intlShape,
-}
+};
App.displayName = 'App';
diff --git a/packages/vulcan-core/lib/modules/components/Datatable.jsx b/packages/vulcan-core/lib/modules/components/Datatable.jsx
index 664d016d9..f9481b289 100644
--- a/packages/vulcan-core/lib/modules/components/Datatable.jsx
+++ b/packages/vulcan-core/lib/modules/components/Datatable.jsx
@@ -112,10 +112,12 @@ DatatableContents Component
*/
const DatatableContents = (props) => {
- const {collection, columns, results, loading, loadMore, count, totalCount, networkStatus, showEdit, currentUser} = props;
+ const {collection, columns, results, loading, loadMore, count, totalCount, networkStatus, showEdit, currentUser, emptyState} = props;
if (loading) {
return ;
+ } else if (!results.length) {
+ return emptyState || null;
}
const isLoadingMore = networkStatus === 2;
@@ -123,26 +125,26 @@ const DatatableContents = (props) => {
return (
-
-
-
- {_.sortBy(columns, column => column.order).map((column, index) => )}
- {showEdit ? | : null}
-
-
-
- {results.map((document, index) => )}
-
-
-
- {hasMore ?
- isLoadingMore ?
-
- :
- : null
- }
+
+
+
+ {_.sortBy(columns, column => column.order).map((column, index) => )}
+ {showEdit ? | : null}
+
+
+
+ {results.map((document, index) => )}
+
+
+
+ {hasMore ?
+ isLoadingMore ?
+
+ :
+ : null
+ }
+
-
)
}
registerComponent('DatatableContents', DatatableContents);
diff --git a/packages/vulcan-core/lib/modules/components/EditButton.jsx.js b/packages/vulcan-core/lib/modules/components/EditButton.jsx.js
new file mode 100644
index 000000000..143a635da
--- /dev/null
+++ b/packages/vulcan-core/lib/modules/components/EditButton.jsx.js
@@ -0,0 +1,20 @@
+import { Components, registerComponent } from 'meteor/vulcan:lib';
+import React from 'react';
+import Button from 'react-bootstrap/lib/Button';
+import { FormattedMessage, intlShape } from 'meteor/vulcan:i18n';
+
+const EditButton = ({ collection, document, bsStyle = 'primary' }, {intl}) =>
+ }
+ >
+
+
+
+EditButton.contextTypes = {
+ intl: intlShape
+};
+
+EditButton.displayName = 'EditButton';
+
+registerComponent('EditButton', EditButton);
\ No newline at end of file
diff --git a/packages/vulcan-core/lib/modules/components/HeadTags.jsx b/packages/vulcan-core/lib/modules/components/HeadTags.jsx
index 2cb5764cc..da9b49a4b 100644
--- a/packages/vulcan-core/lib/modules/components/HeadTags.jsx
+++ b/packages/vulcan-core/lib/modules/components/HeadTags.jsx
@@ -2,6 +2,7 @@ import React, { PureComponent } from 'react';
import PropTypes from 'prop-types';
import Helmet from 'react-helmet';
import { registerComponent, Utils, getSetting, registerSetting, Head } from 'meteor/vulcan:lib';
+import { compose } from 'react-apollo';
registerSetting('logoUrl', null, 'Absolute URL for the logo image');
registerSetting('title', 'My App', 'App title');
@@ -13,9 +14,9 @@ registerSetting('faviconUrl', '/img/favicon.ico', 'Favicon absolute URL');
class HeadTags extends PureComponent {
render() {
- const url = !!this.props.url ? this.props.url : Utils.getSiteUrl();
- const title = !!this.props.title ? this.props.title : getSetting('title', 'My App');
- const description = !!this.props.description ? this.props.description : getSetting('tagline') || getSetting('description');
+ const url = this.props.url || Utils.getSiteUrl();
+ const title = this.props.title || getSetting('title', 'My App');
+ const description = this.props.description || getSetting('tagline') || getSetting('description');
// default image meta: logo url, else site image defined in settings
let image = !!getSetting('siteImage') ? getSetting('siteImage'): getSetting('logoUrl');
@@ -27,6 +28,10 @@ class HeadTags extends PureComponent {
// add site url base if the image is stored locally
if (!!image && image.indexOf('//') === -1) {
+ // remove starting slash from image path if needed
+ if (image.charAt(0) === '/') {
+ image = image.slice(1);
+ }
image = Utils.getSiteUrl() + image;
}
@@ -58,9 +63,21 @@ class HeadTags extends PureComponent {
{Head.meta.map((tag, index) => )}
{Head.link.map((tag, index) => )}
- {Head.script.map((tag, index) => )}
+ {Head.script.map((tag, index) => )}
+
+ {Head.components.map((componentOrArray, index) => {
+ let HeadComponent;
+ if (Array.isArray(componentOrArray)) {
+ const [component, ...hocs] = componentOrArray;
+ HeadComponent = compose(...hocs)(component);
+ } else {
+ HeadComponent = componentOrArray;
+ }
+ return
+ })}
+
);
}
diff --git a/packages/vulcan-core/lib/modules/components/RouterHook.jsx b/packages/vulcan-core/lib/modules/components/RouterHook.jsx
new file mode 100644
index 000000000..9b9254043
--- /dev/null
+++ b/packages/vulcan-core/lib/modules/components/RouterHook.jsx
@@ -0,0 +1,26 @@
+import React, { PureComponent } from 'react';
+import { registerComponent, runCallbacks } from 'meteor/vulcan:lib';
+import { withApollo } from 'react-apollo';
+
+class RouterHook extends PureComponent {
+ constructor(props) {
+ super(props);
+ this.runOnUpdateCallback(props);
+ }
+
+ componentWillReceiveProps(nextProps) {
+ this.runOnUpdateCallback(nextProps);
+ }
+
+ runOnUpdateCallback = props => {
+ const { currentRoute, client } = props;
+ // the first argument is an item to iterate on, needed by vulcan:lib/callbacks
+ // note: this item is not used in this specific callback: router.onUpdate
+ runCallbacks('router.onUpdate', {}, currentRoute, client.store, client);
+ };
+
+ render() {
+ return null;
+ }
+}
+registerComponent('RouterHook', RouterHook, withApollo);
diff --git a/packages/vulcan-core/lib/modules/containers/withDocument.js b/packages/vulcan-core/lib/modules/containers/withDocument.js
index 96f9c645a..3d4ac3969 100644
--- a/packages/vulcan-core/lib/modules/containers/withDocument.js
+++ b/packages/vulcan-core/lib/modules/containers/withDocument.js
@@ -1,11 +1,11 @@
import React, { PropTypes, Component } from 'react';
import { graphql } from 'react-apollo';
import gql from 'graphql-tag';
-import { getFragment, getFragmentName } from 'meteor/vulcan:core';
+import { getSetting, getFragment, getFragmentName } from 'meteor/vulcan:core';
export default function withDocument (options) {
- const { collection, pollInterval = 20000 } = options,
+ const { collection, pollInterval = getSetting('pollInterval', 20000), enableCache = false } = options,
queryName = options.queryName || `${collection.options.collectionName}SingleQuery`,
singleResolverName = collection.options.resolvers.single && collection.options.resolvers.single.name;
@@ -22,8 +22,8 @@ export default function withDocument (options) {
const fragmentName = getFragmentName(fragment);
return graphql(gql`
- query ${queryName}($documentId: String, $slug: String) {
- ${singleResolverName}(documentId: $documentId, slug: $slug) {
+ query ${queryName}($documentId: String, $slug: String, $enableCache: Boolean) {
+ ${singleResolverName}(documentId: $documentId, slug: $slug, enableCache: $enableCache) {
__typename
...${fragmentName}
}
@@ -33,17 +33,24 @@ export default function withDocument (options) {
alias: 'withDocument',
options(ownProps) {
- return {
- variables: { documentId: ownProps.documentId, slug: ownProps.slug },
+ const graphQLOptions = {
+ variables: { documentId: ownProps.documentId, slug: ownProps.slug, enableCache },
pollInterval, // note: pollInterval can be set to 0 to disable polling (20s by default)
};
+
+ if (options.fetchPolicy) {
+ graphQLOptions.fetchPolicy = options.fetchPolicy;
+ }
+
+ return graphQLOptions;
},
props: returnedProps => {
const { ownProps, data } = returnedProps;
+ const propertyName = options.propertyName || 'document';
return {
loading: data.loading,
// document: Utils.convertDates(collection, data[singleResolverName]),
- document: data[singleResolverName],
+ [ propertyName ]: data[singleResolverName],
fragmentName,
fragment,
};
diff --git a/packages/vulcan-core/lib/modules/containers/withList.js b/packages/vulcan-core/lib/modules/containers/withList.js
index 3e683cbae..98abeceb8 100644
--- a/packages/vulcan-core/lib/modules/containers/withList.js
+++ b/packages/vulcan-core/lib/modules/containers/withList.js
@@ -38,7 +38,7 @@ import React, { PropTypes, Component } from 'react';
import { withApollo, graphql } from 'react-apollo';
import gql from 'graphql-tag';
import update from 'immutability-helper';
-import { getFragment, getFragmentName } from 'meteor/vulcan:core';
+import { getSetting, getFragment, getFragmentName } from 'meteor/vulcan:core';
import Mingo from 'mingo';
import compose from 'recompose/compose';
import withState from 'recompose/withState';
@@ -47,7 +47,7 @@ const withList = (options) => {
// console.log(options)
- const { collection, limit = 10, pollInterval = 20000, totalResolver = true } = options,
+ const { collection, limit = 10, pollInterval = getSetting('pollInterval', 20000), totalResolver = true, enableCache = false } = options,
queryName = options.queryName || `${collection.options.collectionName}ListQuery`,
listResolverName = collection.options.resolvers.list && collection.options.resolvers.list.name,
totalResolverName = collection.options.resolvers.total && collection.options.resolvers.total.name;
@@ -66,9 +66,9 @@ const withList = (options) => {
// build graphql query from options
const query = gql`
- query ${queryName}($terms: JSON) {
- ${totalResolver ? `${totalResolverName}(terms: $terms)` : ``}
- ${listResolverName}(terms: $terms) {
+ query ${queryName}($terms: JSON, $enableCache: Boolean) {
+ ${totalResolver ? `${totalResolverName}(terms: $terms, enableCache: $enableCache)` : ``}
+ ${listResolverName}(terms: $terms, enableCache: $enableCache) {
__typename
...${fragmentName}
}
@@ -106,9 +106,11 @@ const withList = (options) => {
options({terms, paginationTerms, client: apolloClient}) {
// get terms from options, then props, then pagination
const mergedTerms = {...options.terms, ...terms, ...paginationTerms};
- return {
+
+ const graphQLOptions = {
variables: {
terms: mergedTerms,
+ enableCache,
},
// note: pollInterval can be set to 0 to disable polling (20s by default)
pollInterval,
@@ -119,18 +121,27 @@ const withList = (options) => {
},
};
+
+ if (options.fetchPolicy) {
+ graphQLOptions.fetchPolicy = options.fetchPolicy
+ }
+
+ return graphQLOptions;
},
// define props returned by graphql HoC
props(props) {
+ // see https://github.com/apollographql/apollo-client/blob/master/packages/apollo-client/src/core/networkStatus.ts
const refetch = props.data.refetch,
// results = Utils.convertDates(collection, props.data[listResolverName]),
results = props.data[listResolverName],
totalCount = props.data[totalResolverName],
networkStatus = props.data.networkStatus,
- loading = props.data.loading,
- error = props.data.error;
+ loading = props.data.networkStatus === 1,
+ loadingMore = props.data.networkStatus === 2,
+ error = props.data.error,
+ propertyName = options.propertyName || 'results';
if (error) {
console.log(error);
@@ -139,8 +150,9 @@ const withList = (options) => {
return {
// see https://github.com/apollostack/apollo-client/blob/master/src/queries/store.ts#L28-L36
// note: loading will propably change soon https://github.com/apollostack/apollo-client/issues/831
- loading: networkStatus === 1,
- results,
+ loading,
+ loadingMore,
+ [ propertyName ]: results,
totalCount,
refetch,
networkStatus,
diff --git a/packages/vulcan-core/lib/modules/default_mutations.js b/packages/vulcan-core/lib/modules/default_mutations.js
index 2f4d3a178..76bde3cfc 100644
--- a/packages/vulcan-core/lib/modules/default_mutations.js
+++ b/packages/vulcan-core/lib/modules/default_mutations.js
@@ -4,121 +4,213 @@ Default mutations
*/
-import { newMutation, editMutation, removeMutation, Utils } from 'meteor/vulcan:lib';
+import { registerCallback, newMutation, editMutation, removeMutation, Utils } from 'meteor/vulcan:lib';
import Users from 'meteor/vulcan:users';
-export const getDefaultMutations = (collectionName, options = {}) => ({
+export const getDefaultMutations = (collectionName, options = {}) => {
- // mutation for inserting a new document
+ // register callbacks for documentation purposes
+ registerCollectionCallbacks(collectionName);
- new: {
-
- name: `${collectionName}New`,
-
- // check function called on a user to see if they can perform the operation
- check(user, document) {
- if (options.newCheck) {
- return options.newCheck(user, document);
- }
- // if user is not logged in, disallow operation
- if (!user) return false;
- // else, check if they can perform "foo.new" operation (e.g. "movies.new")
- return Users.canDo(user, `${collectionName.toLowerCase()}.new`);
- },
-
- async mutation(root, {document}, context) {
+ return {
+
+ // mutation for inserting a new document
+
+ new: {
- const collection = context[collectionName];
-
- // check if current user can pass check function; else throw error
- Utils.performCheck(this.check, context.currentUser, document);
-
- // pass document to boilerplate newMutation function
- return await newMutation({
- collection,
- document: document,
- currentUser: context.currentUser,
- validate: true,
- context,
- });
- },
-
- },
-
- // mutation for editing a specific document
-
- edit: {
-
- name: `${collectionName}Edit`,
-
- // check function called on a user and document to see if they can perform the operation
- check(user, document) {
- if (options.editCheck) {
- return options.editCheck(user, document);
- }
-
- if (!user || !document) return false;
- // check if user owns the document being edited.
- // if they do, check if they can perform "foo.edit.own" action
- // if they don't, check if they can perform "foo.edit.all" action
- return Users.owns(user, document) ? Users.canDo(user, `${collectionName.toLowerCase()}.edit.own`) : Users.canDo(user, `${collectionName.toLowerCase()}.edit.all`);
- },
-
- async mutation(root, {documentId, set, unset}, context) {
-
- const collection = context[collectionName];
-
- // get entire unmodified document from database
- const document = collection.findOne(documentId);
-
- // check if user can perform operation; if not throw error
- Utils.performCheck(this.check, context.currentUser, document);
-
- // call editMutation boilerplate function
- return await editMutation({
- collection,
- documentId: documentId,
- set: set,
- unset: unset,
- currentUser: context.currentUser,
- validate: true,
- context,
- });
- },
-
- },
-
- // mutation for removing a specific document (same checks as edit mutation)
-
- remove: {
-
- name: `${collectionName}Remove`,
-
- check(user, document) {
- if (options.removeCheck) {
- return options.removeCheck(user, document);
- }
+ name: `${collectionName}New`,
- if (!user || !document) return false;
- return Users.owns(user, document) ? Users.canDo(user, `${collectionName.toLowerCase()}.remove.own`) : Users.canDo(user, `${collectionName.toLowerCase()}.remove.all`);
+ // check function called on a user to see if they can perform the operation
+ check(user, document) {
+ if (options.newCheck) {
+ return options.newCheck(user, document);
+ }
+ // if user is not logged in, disallow operation
+ if (!user) return false;
+ // else, check if they can perform "foo.new" operation (e.g. "movies.new")
+ return Users.canDo(user, `${collectionName.toLowerCase()}.new`);
+ },
+
+ async mutation(root, {document}, context) {
+
+ const collection = context[collectionName];
+
+ // check if current user can pass check function; else throw error
+ Utils.performCheck(this.check, context.currentUser, document);
+
+ // pass document to boilerplate newMutation function
+ return await newMutation({
+ collection,
+ document: document,
+ currentUser: context.currentUser,
+ validate: true,
+ context,
+ });
+ },
+
+ },
+
+ // mutation for editing a specific document
+
+ edit: {
+
+ name: `${collectionName}Edit`,
+
+ // check function called on a user and document to see if they can perform the operation
+ check(user, document) {
+ if (options.editCheck) {
+ return options.editCheck(user, document);
+ }
+
+ if (!user || !document) return false;
+ // check if user owns the document being edited.
+ // if they do, check if they can perform "foo.edit.own" action
+ // if they don't, check if they can perform "foo.edit.all" action
+ return Users.owns(user, document) ? Users.canDo(user, `${collectionName.toLowerCase()}.edit.own`) : Users.canDo(user, `${collectionName.toLowerCase()}.edit.all`);
+ },
+
+ async mutation(root, {documentId, set, unset}, context) {
+
+ const collection = context[collectionName];
+
+ // get entire unmodified document from database
+ const document = collection.findOne(documentId);
+
+ // check if user can perform operation; if not throw error
+ Utils.performCheck(this.check, context.currentUser, document);
+
+ // call editMutation boilerplate function
+ return await editMutation({
+ collection,
+ documentId: documentId,
+ set: set,
+ unset: unset,
+ currentUser: context.currentUser,
+ validate: true,
+ context,
+ });
+ },
+
},
- async mutation(root, {documentId}, context) {
+ // mutation for removing a specific document (same checks as edit mutation)
- const collection = context[collectionName];
+ remove: {
- const document = collection.findOne(documentId);
- Utils.performCheck(this.check, context.currentUser, document, context);
+ name: `${collectionName}Remove`,
+
+ check(user, document) {
+ if (options.removeCheck) {
+ return options.removeCheck(user, document);
+ }
+
+ if (!user || !document) return false;
+ return Users.owns(user, document) ? Users.canDo(user, `${collectionName.toLowerCase()}.remove.own`) : Users.canDo(user, `${collectionName.toLowerCase()}.remove.all`);
+ },
+
+ async mutation(root, {documentId}, context) {
+
+ const collection = context[collectionName];
+
+ const document = collection.findOne(documentId);
+ Utils.performCheck(this.check, context.currentUser, document, context);
+
+ return await removeMutation({
+ collection,
+ documentId: documentId,
+ currentUser: context.currentUser,
+ validate: true,
+ context,
+ });
+ },
- return await removeMutation({
- collection,
- documentId: documentId,
- currentUser: context.currentUser,
- validate: true,
- context,
- });
},
+ }
- },
+};
-});
+
+const registerCollectionCallbacks = collectionName => {
+
+ collectionName = collectionName.toLowerCase();
+
+ registerCallback({
+ name: `${collectionName}.new.validate`,
+ arguments: [{document: 'The document being inserted'}, {currentUser: 'The current user'}, {validationErrors: 'An object that can be used to accumulate validation errors'}],
+ runs: 'sync',
+ returns: 'document',
+ description: `Validate a document before insertion (can be skipped when inserting directly on server).`
+ });
+ registerCallback({
+ name: `${collectionName}.new.before`,
+ arguments: [{document: 'The document being inserted'}, {currentUser: 'The current user'}],
+ runs: 'sync',
+ returns: 'document',
+ description: `Perform operations on a new document before it's inserted in the database.`
+ });
+ registerCallback({
+ name: `${collectionName}.new.after`,
+ arguments: [{document: 'The document being inserted'}, {currentUser: 'The current user'}],
+ runs: 'sync',
+ returns: 'document',
+ description: `Perform operations on a new document after it's inserted in the database but *before* the mutation returns it.`
+ });
+ registerCallback({
+ name: `${collectionName}.new.async`,
+ arguments: [{document: 'The document being inserted'}, {currentUser: 'The current user'}, {collection: 'The collection the document belongs to'}],
+ runs: 'async',
+ returns: null,
+ description: `Perform operations on a new document after it's inserted in the database asynchronously.`
+ });
+
+ registerCallback({
+ name: `${collectionName}.edit.validate`,
+ arguments: [{modifier: 'The MongoDB modifier'}, {document: 'The document being edited'}, {currentUser: 'The current user'}, {validationErrors: 'An object that can be used to accumulate validation errors'}],
+ runs: 'sync',
+ returns: 'modifier',
+ description: `Validate a document before update (can be skipped when updating directly on server).`
+ });
+ registerCallback({
+ name: `${collectionName}.edit.before`,
+ arguments: [{modifier: 'The MongoDB modifier'}, {document: 'The document being edited'}, {currentUser: 'The current user'}],
+ runs: 'sync',
+ returns: 'modifier',
+ description: `Perform operations on a document before it's updated in the database.`
+ });
+ registerCallback({
+ name: `${collectionName}.edit.after`,
+ arguments: [{modifier: 'The MongoDB modifier'}, {document: 'The document being edited'}, {currentUser: 'The current user'}],
+ runs: 'sync',
+ returns: 'document',
+ description: `Perform operations on a document after it's updated in the database but *before* the mutation returns it.`
+ });
+ registerCallback({
+ name: `${collectionName}.edit.async`,
+ arguments: [{newDocument: 'The document after the edit'}, {document: 'The document before the edit'}, {currentUser: 'The current user'}, {collection: 'The collection the document belongs to'}],
+ runs: 'async',
+ returns: null,
+ description: `Perform operations on a document after it's updated in the database asynchronously.`
+ });
+
+ registerCallback({
+ name: `${collectionName}.remove.validate`,
+ arguments: [{document: 'The document being removed'}, {currentUser: 'The current user'}, {validationErrors: 'An object that can be used to accumulate validation errors'}],
+ runs: 'sync',
+ returns: 'document',
+ description: `Validate a document before removal (can be skipped when removing directly on server).`
+ });
+ registerCallback({
+ name: `${collectionName}.remove.before`,
+ arguments: [{document: 'The document being removed'}, {currentUser: 'The current user'}],
+ runs: 'sync',
+ returns: null,
+ description: `Perform operations on a document before it's removed from the database.`
+ });
+ registerCallback({
+ name: `${collectionName}.remove.async`,
+ arguments: [{document: 'The document being removed'}, {currentUser: 'The current user'}, {collection: 'The collection the document belongs to'}],
+ runs: 'async',
+ returns: null,
+ description: `Perform operations on a document after it's removed from the database asynchronously.`
+ });
+}
\ No newline at end of file
diff --git a/packages/vulcan-core/lib/modules/default_resolvers.js b/packages/vulcan-core/lib/modules/default_resolvers.js
index 3e2232b9f..edea44e80 100644
--- a/packages/vulcan-core/lib/modules/default_resolvers.js
+++ b/packages/vulcan-core/lib/modules/default_resolvers.js
@@ -6,97 +6,123 @@ Default list, single, and total resolvers
import { Utils, debug } from 'meteor/vulcan:core';
-export const getDefaultResolvers = collectionName => ({
+const defaultOptions = {
+ cacheMaxAge: 300
+}
- // resolver for returning a list of documents based on a set of query terms
+export const getDefaultResolvers = (collectionName, resolverOptions = defaultOptions) => {
- list: {
+ return {
- name: `${collectionName}List`,
+ // resolver for returning a list of documents based on a set of query terms
- async resolver(root, {terms = {}}, context, info) {
+ list: {
- debug(`//--------------- start ${collectionName} list resolver ---------------//`);
- debug(terms);
+ name: `${collectionName}List`,
- // get currentUser and Users collection from context
- const { currentUser, Users } = context;
+ async resolver(root, {terms = {}, enableCache = false}, context, { cacheControl }) {
- // get collection based on collectionName argument
- const collection = context[collectionName];
+ debug(`//--------------- start ${collectionName} list resolver ---------------//`);
+ debug(resolverOptions);
+ debug(terms);
- // get selector and options from terms and perform Mongo query
- let {selector, options} = await collection.getParameters(terms, {}, context);
- options.skip = terms.offset;
+ if (cacheControl && enableCache) {
+ const maxAge = resolverOptions.cacheMaxAge || defaultOptions.cacheMaxAge;
+ cacheControl.setCacheHint({ maxAge });
+ }
- debug({ selector, options });
+ // get currentUser and Users collection from context
+ const { currentUser, Users } = context;
- const docs = collection.find(selector, options).fetch();
+ // get collection based on collectionName argument
+ const collection = context[collectionName];
- // if collection has a checkAccess function defined, remove any documents that doesn't pass the check
- const viewableDocs = collection.checkAccess ? _.filter(docs, doc => collection.checkAccess(currentUser, doc)) : docs;
+ // get selector and options from terms and perform Mongo query
+ let {selector, options} = await collection.getParameters(terms, {}, context);
+ options.skip = terms.offset;
+
+ debug({ selector, options });
+
+ const docs = collection.find(selector, options).fetch();
+
+ // if collection has a checkAccess function defined, remove any documents that doesn't pass the check
+ const viewableDocs = collection.checkAccess ? _.filter(docs, doc => collection.checkAccess(currentUser, doc)) : docs;
+
+ // take the remaining documents and remove any fields that shouldn't be accessible
+ const restrictedDocs = Users.restrictViewableFields(currentUser, collection, viewableDocs);
+
+ // prime the cache
+ restrictedDocs.forEach(doc => collection.loader.prime(doc._id, doc));
+
+ debug(`// ${restrictedDocs.length} documents returned`);
+ debug(`//--------------- end ${collectionName} list resolver ---------------//`);
+
+ // return results
+ return restrictedDocs;
+ },
+
+ },
+
+ // resolver for returning a single document queried based on id or slug
+
+ single: {
- // take the remaining documents and remove any fields that shouldn't be accessible
- const restrictedDocs = Users.restrictViewableFields(currentUser, collection, viewableDocs);
+ name: `${collectionName}Single`,
- // prime the cache
- restrictedDocs.forEach(doc => collection.loader.prime(doc._id, doc));
+ async resolver(root, {documentId, slug, enableCache = false}, context, { cacheControl }) {
- debug(`// ${restrictedDocs.length} documents returned`);
- debug(`//--------------- end ${collectionName} list resolver ---------------//`);
+ debug(`//--------------- start ${collectionName} single resolver ---------------//`);
+ debug(resolverOptions);
+ debug(documentId);
- // return results
- return restrictedDocs;
+ if (cacheControl && enableCache) {
+ const maxAge = resolverOptions.cacheMaxAge || defaultOptions.cacheMaxAge;
+ cacheControl.setCacheHint({ maxAge });
+ }
+
+ const { currentUser, Users } = context;
+ const collection = context[collectionName];
+
+ // don't use Dataloader if doc is selected by slug
+ const doc = documentId ? await collection.loader.load(documentId) : (slug ? collection.findOne({slug}) : collection.findOne());
+
+ // if collection has a checkAccess function defined, use it to perform a check on the current document
+ // (will throw an error if check doesn't pass)
+ if (collection.checkAccess) {
+ Utils.performCheck(collection.checkAccess, currentUser, doc, collection, documentId);
+ }
+
+ const restrictedDoc = Users.restrictViewableFields(currentUser, collection, doc);
+
+ debug(`//--------------- end ${collectionName} single resolver ---------------//`);
+
+ // filter out disallowed properties and return resulting document
+ return restrictedDoc;
+ },
+
},
- },
+ // resolver for returning the total number of documents matching a set of query terms
- // resolver for returning a single document queried based on id or slug
-
- single: {
-
- name: `${collectionName}Single`,
-
- async resolver(root, {documentId, slug}, context) {
-
- debug(`//--------------- start ${collectionName} single resolver ---------------//`);
- debug(documentId);
-
- const { currentUser, Users } = context;
- const collection = context[collectionName];
-
- // don't use Dataloader if doc is selected by slug
- const doc = documentId ? await collection.loader.load(documentId) : (slug ? collection.findOne({slug}) : collection.findOne());
-
- // if collection has a checkAccess function defined, use it to perform a check on the current document
- // (will throw an error if check doesn't pass)
- if (collection.checkAccess) {
- Utils.performCheck(collection.checkAccess, currentUser, doc, collection, documentId);
- }
-
- debug(`//--------------- end ${collectionName} single resolver ---------------//`);
-
-
- // filter out disallowed properties and return resulting document
- return Users.restrictViewableFields(currentUser, collection, doc);
- },
-
- },
-
- // resolver for returning the total number of documents matching a set of query terms
-
- total: {
-
- name: `${collectionName}Total`,
-
- async resolver(root, {terms}, context) {
+ total: {
- const collection = context[collectionName];
+ name: `${collectionName}Total`,
+
+ async resolver(root, {terms, enableCache}, context, { cacheControl }) {
+
+ if (cacheControl && enableCache) {
+ const maxAge = resolverOptions.cacheMaxAge || defaultOptions.cacheMaxAge;
+ cacheControl.setCacheHint({ maxAge });
+ }
- const {selector} = await collection.getParameters(terms, {}, context);
+ const collection = context[collectionName];
- return collection.find(selector).count();
- },
-
+ const {selector} = await collection.getParameters(terms, {}, context);
+
+ return collection.find(selector).count();
+ },
+
+ }
}
-});
+
+};
diff --git a/packages/vulcan-core/lib/modules/index.js b/packages/vulcan-core/lib/modules/index.js
index 9c4f9dac3..e98cb2d3c 100644
--- a/packages/vulcan-core/lib/modules/index.js
+++ b/packages/vulcan-core/lib/modules/index.js
@@ -12,6 +12,7 @@ export { default as Icon } from "./components/Icon.jsx";
export { default as Loading } from "./components/Loading.jsx";
export { default as ShowIf } from "./components/ShowIf.jsx";
export { default as ModalTrigger } from './components/ModalTrigger.jsx';
+export { default as EditButton } from './components/EditButton.jsx';
export { default as Error404 } from './components/Error404.jsx';
export { default as DynamicLoading } from './components/DynamicLoading.jsx';
export { default as HeadTags } from './components/HeadTags.jsx';
@@ -21,6 +22,7 @@ export { default as Datatable } from './components/Datatable.jsx';
export { default as Flash } from './components/Flash.jsx';
export { default as HelloWorld } from './components/HelloWorld.jsx';
export { default as Welcome } from './components/Welcome.jsx';
+export { default as RouterHook } from './components/RouterHook.jsx';
export { default as withMessages } from "./containers/withMessages.js";
export { default as withList } from './containers/withList.js';
diff --git a/packages/vulcan-core/package.js b/packages/vulcan-core/package.js
index 6d03391ae..df0552f42 100644
--- a/packages/vulcan-core/package.js
+++ b/packages/vulcan-core/package.js
@@ -1,7 +1,7 @@
Package.describe({
name: "vulcan:core",
summary: "Vulcan core package",
- version: '1.8.0',
+ version: '1.8.1',
git: "https://github.com/VulcanJS/Vulcan.git"
});
@@ -10,15 +10,15 @@ Package.onUse(function(api) {
api.versionsFrom('METEOR@1.5.2');
api.use([
- 'vulcan:lib@1.8.0',
- 'vulcan:i18n@1.8.0',
- 'vulcan:users@1.8.0',
- 'vulcan:routing@1.8.0',
- 'vulcan:debug@1.8.0',
+ 'vulcan:lib@1.8.1',
+ 'vulcan:i18n@1.8.1',
+ 'vulcan:users@1.8.1',
+ 'vulcan:routing@1.8.1',
+ 'vulcan:debug@1.8.1',
]);
api.imply([
- 'vulcan:lib@1.8.0'
+ 'vulcan:lib@1.8.1'
]);
api.mainModule('lib/server/main.js', 'server');
diff --git a/packages/vulcan-debug/lib/components/Callbacks.jsx b/packages/vulcan-debug/lib/components/Callbacks.jsx
new file mode 100644
index 000000000..b158d1f8f
--- /dev/null
+++ b/packages/vulcan-debug/lib/components/Callbacks.jsx
@@ -0,0 +1,31 @@
+import React from 'react';
+import { FormattedMessage } from 'meteor/vulcan:i18n';
+import { registerComponent, Components } from 'meteor/vulcan:lib';
+import Callbacks from '../modules/callbacks/collection.js';
+
+const CallbacksName = ({ document }) =>
+ {document.name}
+
+const CallbacksDashboard = props =>
+
+
+
+
+registerComponent('Callbacks', CallbacksDashboard);
+
+export default Callbacks;
\ No newline at end of file
diff --git a/packages/vulcan-debug/lib/components/Emails.jsx b/packages/vulcan-debug/lib/components/Emails.jsx
index 395ce4c6a..8747c09d1 100644
--- a/packages/vulcan-debug/lib/components/Emails.jsx
+++ b/packages/vulcan-debug/lib/components/Emails.jsx
@@ -37,7 +37,7 @@ class Email extends PureComponent {
{name} |
{email.template} |
{typeof email.subject === 'function' ? email.subject({}) : email.subject} |
- {email.path} |
+ {email.path} |
diff --git a/packages/vulcan-debug/lib/modules/callbacks/collection.js b/packages/vulcan-debug/lib/modules/callbacks/collection.js
new file mode 100644
index 000000000..ca30d4a7a
--- /dev/null
+++ b/packages/vulcan-debug/lib/modules/callbacks/collection.js
@@ -0,0 +1,19 @@
+import { createCollection } from 'meteor/vulcan:lib';
+import schema from './schema.js';
+import resolvers from './resolvers.js';
+import './fragments.js';
+
+const Callbacks = createCollection({
+
+ collectionName: 'Callbacks',
+
+ typeName: 'Callback',
+
+ schema,
+
+ resolvers,
+
+});
+
+
+export default Callbacks;
diff --git a/packages/vulcan-debug/lib/modules/callbacks/fragments.js b/packages/vulcan-debug/lib/modules/callbacks/fragments.js
new file mode 100644
index 000000000..b0f706b5e
--- /dev/null
+++ b/packages/vulcan-debug/lib/modules/callbacks/fragments.js
@@ -0,0 +1,12 @@
+import { registerFragment } from 'meteor/vulcan:lib';
+
+registerFragment(`
+ fragment CallbacksFragment on Callback {
+ name
+ arguments
+ runs
+ returns
+ description
+ hooks
+ }
+`);
diff --git a/packages/vulcan-debug/lib/modules/callbacks/resolvers.js b/packages/vulcan-debug/lib/modules/callbacks/resolvers.js
new file mode 100644
index 000000000..4d2326a9e
--- /dev/null
+++ b/packages/vulcan-debug/lib/modules/callbacks/resolvers.js
@@ -0,0 +1,26 @@
+import { CallbackHooks } from 'meteor/vulcan:lib';
+
+const resolvers = {
+
+ list: {
+
+ name: 'CallbacksList',
+
+ resolver(root, {terms = {}}, context, info) {
+ return CallbackHooks;
+ },
+
+ },
+
+ total: {
+
+ name: 'CallbacksTotal',
+
+ resolver(root, {terms = {}}, context) {
+ return CallbackHooks.length;
+ },
+
+ }
+};
+
+export default resolvers;
\ No newline at end of file
diff --git a/packages/vulcan-debug/lib/modules/callbacks/schema.js b/packages/vulcan-debug/lib/modules/callbacks/schema.js
new file mode 100644
index 000000000..52643074c
--- /dev/null
+++ b/packages/vulcan-debug/lib/modules/callbacks/schema.js
@@ -0,0 +1,57 @@
+import { Callbacks } from 'meteor/vulcan:lib';
+
+const schema = {
+ name: {
+ label: 'Name',
+ type: String,
+ viewableBy: ['admins'],
+ },
+
+ arguments: {
+ label: 'Arguments',
+ type: Array,
+ viewableBy: ['admins'],
+ },
+
+ 'arguments.$': {
+ type: Object,
+ viewableBy: ['admins'],
+ },
+
+ runs: {
+ label: 'Runs',
+ type: String,
+ viewableBy: ['admins'],
+ },
+
+ returns: {
+ label: 'Should Return',
+ type: String,
+ viewableBy: ['admins'],
+ },
+
+ description: {
+ label: 'Description',
+ type: String,
+ viewableBy: ['admins'],
+ },
+
+ hooks: {
+ label: 'Hooks',
+ type: Array,
+ viewableBy: ['admins'],
+ resolveAs: {
+ type: '[String]',
+ resolver: callback => {
+ if (Callbacks[callback.name]) {
+ const callbacks = Callbacks[callback.name].map(f => f.name);
+ return callbacks;
+ } else {
+ return [];
+ }
+ },
+ },
+ },
+};
+
+export default schema;
diff --git a/packages/vulcan-debug/lib/modules/components.js b/packages/vulcan-debug/lib/modules/components.js
index 4e61994fe..017a9ea69 100644
--- a/packages/vulcan-debug/lib/modules/components.js
+++ b/packages/vulcan-debug/lib/modules/components.js
@@ -2,3 +2,4 @@
import '../components/Emails.jsx';
import '../components/Groups.jsx';
import '../components/Settings.jsx';
+import '../components/Callbacks.jsx';
diff --git a/packages/vulcan-debug/lib/modules/routes.js b/packages/vulcan-debug/lib/modules/routes.js
index f1b85b835..a557289eb 100644
--- a/packages/vulcan-debug/lib/modules/routes.js
+++ b/packages/vulcan-debug/lib/modules/routes.js
@@ -4,6 +4,7 @@ addRoute([
// {name: 'cheatsheet', path: '/cheatsheet', component: import('./components/Cheatsheet.jsx')},
{name: 'groups', path: '/groups', component: () => getDynamicComponent(import('../components/Groups.jsx'))},
{name: 'settings', path: '/settings', componentName: 'Settings'},
+ {name: 'callbacks', path: '/callbacks', componentName: 'Callbacks'},
// {name: 'emails', path: '/emails', component: () => getDynamicComponent(import('./components/Emails.jsx'))},
{name: 'emails', path: '/emails', componentName: 'Emails'},
]);
\ No newline at end of file
diff --git a/packages/vulcan-debug/package.js b/packages/vulcan-debug/package.js
index c14b96fc9..46b2b2abe 100644
--- a/packages/vulcan-debug/package.js
+++ b/packages/vulcan-debug/package.js
@@ -1,7 +1,7 @@
Package.describe({
name: "vulcan:debug",
summary: "Vulcan debug package",
- version: '1.8.0',
+ version: '1.8.1',
git: "https://github.com/VulcanJS/Vulcan.git",
debugOnly: true
});
@@ -17,8 +17,8 @@ Package.onUse(function (api) {
// Vulcan packages
- 'vulcan:lib@1.8.0',
- 'vulcan:email@1.8.0',
+ 'vulcan:lib@1.8.1',
+ 'vulcan:email@1.8.1',
]);
diff --git a/packages/vulcan-email/lib/server/email.js b/packages/vulcan-email/lib/server/email.js
index 3d3d90d99..8161799c0 100644
--- a/packages/vulcan-email/lib/server/email.js
+++ b/packages/vulcan-email/lib/server/email.js
@@ -24,7 +24,7 @@ VulcanEmail.addTemplates = templates => {
VulcanEmail.getTemplate = templateName => Handlebars.compile(
VulcanEmail.templates[templateName],
- { noEscape: true}
+ { noEscape: true, strict: true}
);
VulcanEmail.buildTemplate = (htmlContent, optionalProperties = {}) => {
@@ -46,9 +46,7 @@ VulcanEmail.buildTemplate = (htmlContent, optionalProperties = {}) => {
};
const emailHTML = VulcanEmail.getTemplate("wrapper")(emailProperties);
-
const inlinedHTML = Juice(emailHTML, {preserveMediaQueries: true});
-
const doctype = ''
return doctype+inlinedHTML;
@@ -122,7 +120,7 @@ VulcanEmail.build = async ({ emailName, variables }) => {
const subject = typeof email.subject === 'function' ? email.subject(data) : email.subject;
- const html = VulcanEmail.buildTemplate(VulcanEmail.getTemplate(email.template)(data));
+ const html = VulcanEmail.buildTemplate(VulcanEmail.getTemplate(email.template)(data), data);
return { data, subject, html };
}
diff --git a/packages/vulcan-email/lib/server/routes.js b/packages/vulcan-email/lib/server/routes.js
index 5f3db6158..844906160 100644
--- a/packages/vulcan-email/lib/server/routes.js
+++ b/packages/vulcan-email/lib/server/routes.js
@@ -10,7 +10,6 @@ Meteor.startup(function () {
Picker.route(email.path, async (params, req, res) => {
let html;
-
// if email has a custom way of generating test HTML, use it
if (typeof email.getTestHTML !== "undefined") {
@@ -20,14 +19,20 @@ Meteor.startup(function () {
// else get test object (sample post, comment, user, etc.)
const testVariables = (typeof email.testVariables === 'function' ? email.testVariables() : email.testVariables) || {};
- const result = email.query ? await runQuery(email.query, testVariables) : {data: {}};
+ // merge test variables with params from URL
+ const variables = {...testVariables, ...params};
+
+ const result = email.query ? await runQuery(email.query, variables) : {data: {}};
// if email has a data() function, merge it with results of query
- const emailTestData = email.data ? {...result.data, ...email.data(testVariables)} : result.data;
+ const emailTestData = email.data ? {...result.data, ...email.data(variables)} : result.data;
const subject = typeof email.subject === 'function' ? email.subject(emailTestData) : email.subject;
+ const template = VulcanEmail.getTemplate(email.template);
+ const htmlContent = template(emailTestData)
+
// then apply email template to properties, and wrap it with buildTemplate
- html = VulcanEmail.buildTemplate(VulcanEmail.getTemplate(email.template)(emailTestData));
+ html = VulcanEmail.buildTemplate(htmlContent, emailTestData);
html += `
Subject: ${subject}
diff --git a/packages/vulcan-email/package.js b/packages/vulcan-email/package.js
index 8dad243df..1cca49b40 100644
--- a/packages/vulcan-email/package.js
+++ b/packages/vulcan-email/package.js
@@ -1,7 +1,7 @@
Package.describe({
name: "vulcan:email",
summary: "Vulcan email package",
- version: '1.8.0',
+ version: '1.8.1',
git: "https://github.com/VulcanJS/Vulcan.git"
});
@@ -10,7 +10,7 @@ Package.onUse(function (api) {
api.versionsFrom('METEOR@1.5.2');
api.use([
- 'vulcan:lib@1.8.0'
+ 'vulcan:lib@1.8.1'
]);
api.mainModule("lib/server.js", "server");
diff --git a/packages/vulcan-embed/package.js b/packages/vulcan-embed/package.js
index 9b881c415..3b0c2f4a7 100644
--- a/packages/vulcan-embed/package.js
+++ b/packages/vulcan-embed/package.js
@@ -1,7 +1,7 @@
Package.describe({
name: "vulcan:embed",
summary: "Vulcan Embed package",
- version: '1.8.0',
+ version: '1.8.1',
git: 'https://github.com/VulcanJS/Vulcan.git'
});
@@ -10,7 +10,7 @@ Package.onUse( function(api) {
api.versionsFrom('METEOR@1.5.2');
api.use([
- 'vulcan:core@1.8.0',
+ 'vulcan:core@1.8.1',
'fourseven:scss@4.5.0'
]);
diff --git a/packages/vulcan-events-ga/README.md b/packages/vulcan-events-ga/README.md
new file mode 100644
index 000000000..70247b7e5
--- /dev/null
+++ b/packages/vulcan-events-ga/README.md
@@ -0,0 +1 @@
+Vulcan events package, used internally.
\ No newline at end of file
diff --git a/packages/vulcan-events-ga/lib/client/ga.js b/packages/vulcan-events-ga/lib/client/ga.js
new file mode 100644
index 000000000..6eb126937
--- /dev/null
+++ b/packages/vulcan-events-ga/lib/client/ga.js
@@ -0,0 +1,62 @@
+import { getSetting } from 'meteor/vulcan:core';
+import { addPageFunction, addInitFunction } from 'meteor/vulcan:events';
+
+/*
+
+ 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 > googleAnalytics > apiKey'
+ field with your GA unique identifier (UA-xxx...).
+
+*/
+
+function googleAnaticsTrackPage() {
+ if (window && window.ga) {
+ window.ga('send', 'pageview', {
+ page: window.location.pathname,
+ });
+ }
+ return {};
+}
+
+// add client-side callback: log a ga request on page view
+addPageFunction(googleAnaticsTrackPage);
+
+function googleAnalyticsInit() {
+ // get the google analytics id from the settings
+ const googleAnalyticsId = getSetting('googleAnalytics.apiKey');
+
+ // 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
+ googleAnaticsTrackPage();
+ }
+}
+
+// init google analytics on the client module
+addInitFunction(googleAnalyticsInit);
diff --git a/packages/vulcan-events-ga/lib/client/main.js b/packages/vulcan-events-ga/lib/client/main.js
new file mode 100644
index 000000000..bd90865fc
--- /dev/null
+++ b/packages/vulcan-events-ga/lib/client/main.js
@@ -0,0 +1,2 @@
+import './ga.js';
+export * from '../modules/index.js';
\ No newline at end of file
diff --git a/packages/vulcan-events-ga/lib/modules/index.js b/packages/vulcan-events-ga/lib/modules/index.js
new file mode 100644
index 000000000..29ed8aee7
--- /dev/null
+++ b/packages/vulcan-events-ga/lib/modules/index.js
@@ -0,0 +1,3 @@
+import { registerSetting } from 'meteor/vulcan:core';
+
+registerSetting('googleAnalytics.apiKey', null, 'Google Analytics ID');
diff --git a/packages/vulcan-events-ga/lib/server/main.js b/packages/vulcan-events-ga/lib/server/main.js
new file mode 100644
index 000000000..094a9a675
--- /dev/null
+++ b/packages/vulcan-events-ga/lib/server/main.js
@@ -0,0 +1 @@
+export * from '../modules/index.js';
diff --git a/packages/vulcan-events-ga/package.js b/packages/vulcan-events-ga/package.js
new file mode 100644
index 000000000..d98194730
--- /dev/null
+++ b/packages/vulcan-events-ga/package.js
@@ -0,0 +1,20 @@
+Package.describe({
+ name: "vulcan:events-ga",
+ summary: "Vulcan Google Analytics event tracking package",
+ version: '1.8.1',
+ git: "https://github.com/VulcanJS/Vulcan.git"
+});
+
+Package.onUse(function(api) {
+
+ api.versionsFrom('METEOR@1.5.2');
+
+ api.use([
+ 'vulcan:core@1.8.1',
+ 'vulcan:events@1.8.1',
+ ]);
+
+ api.mainModule("lib/server/main.js", "server");
+ api.mainModule('lib/client/main.js', 'client');
+
+});
diff --git a/packages/vulcan-events-intercom/README.md b/packages/vulcan-events-intercom/README.md
new file mode 100644
index 000000000..128e4c4dc
--- /dev/null
+++ b/packages/vulcan-events-intercom/README.md
@@ -0,0 +1,21 @@
+Intercom package.
+
+### Settings
+
+```
+{
+ "public": {
+
+ "intercom": {
+ "appId": "123foo"
+ }
+
+ },
+
+ "intercom": {
+ "accessToken": "456bar"
+ }
+}
+```
+
+Requires installing the [react-intercom](https://github.com/nhagen/react-intercom) package (`npm install --save react-intercom`).
\ No newline at end of file
diff --git a/packages/vulcan-events-intercom/lib/client/intercom-client.js b/packages/vulcan-events-intercom/lib/client/intercom-client.js
new file mode 100644
index 000000000..5767b30f1
--- /dev/null
+++ b/packages/vulcan-events-intercom/lib/client/intercom-client.js
@@ -0,0 +1,103 @@
+import { getSetting, addCallback, Utils } from 'meteor/vulcan:core';
+import { addPageFunction, addInitFunction, addIdentifyFunction, addTrackFunction } from 'meteor/vulcan:events';
+
+/*
+
+Identify User
+
+*/
+function intercomIdentify(currentUser) {
+ intercomSettings = {
+ app_id: getSetting('intercom.appId'),
+ name: currentUser.displayName,
+ email: currentUser.email,
+ created_at: currentUser.createdAt,
+ _id: currentUser._id,
+ pageUrl: currentUser.pageUrl,
+ };
+ (function() {
+ var w = window;
+ var ic = w.Intercom;
+ if (typeof ic === 'function') {
+ ic('reattach_activator');
+ ic('update', intercomSettings);
+ } else {
+ var d = document;
+ var i = function() {
+ i.c(arguments);
+ };
+ i.q = [];
+ i.c = function(args) {
+ i.q.push(args);
+ };
+ w.Intercom = i;
+ function l() {
+ var s = d.createElement('script');
+ s.type = 'text/javascript';
+ s.async = true;
+ s.src = 'https://widget.intercom.io/widget/icygo7se';
+ var x = d.getElementsByTagName('script')[0];
+ x.parentNode.insertBefore(s, x);
+ }
+ if (w.attachEvent) {
+ w.attachEvent('onload', l);
+ } else {
+ w.addEventListener('load', l, false);
+ }
+ }
+ })();
+}
+addIdentifyFunction(intercomIdentify);
+
+/*
+
+Track Event
+
+*/
+// function segmentTrack(eventName, eventProperties) {
+// analytics.track(eventName, eventProperties);
+// }
+// addTrackFunction(segmentTrack);
+
+/*
+
+Init Snippet
+
+*/
+function intercomInit() {
+ window.intercomSettings = {
+ app_id: getSetting('intercom.appId'),
+ };
+ (function() {
+ var w = window;
+ var ic = w.Intercom;
+ if (typeof ic === 'function') {
+ ic('reattach_activator');
+ ic('update', intercomSettings);
+ } else {
+ var d = document;
+ var i = function() {
+ i.c(arguments);
+ };
+ i.q = [];
+ i.c = function(args) {
+ i.q.push(args);
+ };
+ w.Intercom = i;
+ function l() {
+ var s = d.createElement('script');
+ s.type = 'text/javascript';
+ s.async = true;
+ s.src = 'https://widget.intercom.io/widget/icygo7se';
+ var x = d.getElementsByTagName('script')[0];
+ x.parentNode.insertBefore(s, x);
+ }
+ if (w.attachEvent) {
+ w.attachEvent('onload', l);
+ } else {
+ w.addEventListener('load', l, false);
+ }
+ }
+ })();
+}
+addInitFunction(intercomInit);
diff --git a/packages/vulcan-events-intercom/lib/client/main.js b/packages/vulcan-events-intercom/lib/client/main.js
new file mode 100644
index 000000000..e3113bcb6
--- /dev/null
+++ b/packages/vulcan-events-intercom/lib/client/main.js
@@ -0,0 +1,3 @@
+import './intercom-client.js';
+
+export * from '../modules/index.js';
\ No newline at end of file
diff --git a/packages/vulcan-events-intercom/lib/modules/index.js b/packages/vulcan-events-intercom/lib/modules/index.js
new file mode 100644
index 000000000..e69de29bb
diff --git a/packages/vulcan-events-intercom/lib/server/intercom-server.js b/packages/vulcan-events-intercom/lib/server/intercom-server.js
new file mode 100644
index 000000000..cc15444f9
--- /dev/null
+++ b/packages/vulcan-events-intercom/lib/server/intercom-server.js
@@ -0,0 +1,49 @@
+import Intercom from 'intercom-client';
+import { getSetting, addCallback, Utils } from 'meteor/vulcan:core';
+import { addPageFunction, addUserFunction, addInitFunction, addIdentifyFunction, addTrackFunction } from 'meteor/vulcan:events';
+
+const token = getSetting('intercom.accessToken');
+
+if (!token) {
+ throw new Error('Please add your Intercom access token in settings.json');
+} else {
+
+ const intercomClient = new Intercom.Client({ token });
+
+ const getDate = () => new Date().valueOf().toString().substr(0,10);
+
+ /*
+
+ New User
+
+ */
+ function intercomNewUser(user) {
+ intercomClient.users.create({
+ email: user.email,
+ custom_attributes: {
+ name: user.displayName,
+ profileUrl: Users.getProfileUrl(user, true),
+ _id: user._id,
+ }
+ });
+ }
+ addUserFunction(intercomNewUser);
+
+ /*
+
+ Track Event
+
+ */
+ function intercomTrackServer(eventName, eventProperties, currentUser) {
+ intercomClient.events.create({
+ event_name: eventName,
+ created_at: getDate(),
+ email: currentUser.email,
+ metadata: {
+ ...eventProperties
+ }
+ });
+ }
+ addTrackFunction(intercomTrackServer);
+
+}
diff --git a/packages/vulcan-events-intercom/lib/server/main.js b/packages/vulcan-events-intercom/lib/server/main.js
new file mode 100644
index 000000000..094a9a675
--- /dev/null
+++ b/packages/vulcan-events-intercom/lib/server/main.js
@@ -0,0 +1 @@
+export * from '../modules/index.js';
diff --git a/packages/vulcan-events-intercom/package.js b/packages/vulcan-events-intercom/package.js
new file mode 100644
index 000000000..d9bcd25f4
--- /dev/null
+++ b/packages/vulcan-events-intercom/package.js
@@ -0,0 +1,20 @@
+Package.describe({
+ name: 'vulcan:events-intercom',
+ summary: 'Vulcan Intercom integration package.',
+ version: '1.8.1',
+ git: "https://github.com/VulcanJS/Vulcan.git"
+});
+
+Package.onUse(function (api) {
+
+ api.versionsFrom('METEOR@1.5.2');
+
+ api.use([
+ 'vulcan:core@1.8.1',
+ 'vulcan:events@1.8.1'
+ ]);
+
+ api.mainModule("lib/client/main.js", "client");
+ api.mainModule("lib/server/main.js", "server");
+
+});
diff --git a/packages/vulcan-events-internal/README.md b/packages/vulcan-events-internal/README.md
new file mode 100644
index 000000000..70247b7e5
--- /dev/null
+++ b/packages/vulcan-events-internal/README.md
@@ -0,0 +1 @@
+Vulcan events package, used internally.
\ No newline at end of file
diff --git a/packages/vulcan-events-internal/lib/client/internal-client.js b/packages/vulcan-events-internal/lib/client/internal-client.js
new file mode 100644
index 000000000..3651371b9
--- /dev/null
+++ b/packages/vulcan-events-internal/lib/client/internal-client.js
@@ -0,0 +1,25 @@
+import { addTrackFunction } from 'meteor/vulcan:events';
+import { ApolloClient } from 'apollo-client';
+import { getRenderContext } from 'meteor/vulcan:lib';
+import gql from 'graphql-tag';
+
+function trackInternal(eventName, eventProperties) {
+ const { apolloClient, store } = getRenderContext();
+ const mutation = gql`
+ mutation EventsNew($document: EventsInput) {
+ EventsNew(document: $document) {
+ name
+ createdAt
+ }
+ }
+ `;
+ const variables = {
+ document: {
+ name: eventName,
+ properties: eventProperties,
+ },
+ };
+ apolloClient.mutate({ mutation, variables });
+}
+
+addTrackFunction(trackInternal);
diff --git a/packages/vulcan-events-internal/lib/client/main.js b/packages/vulcan-events-internal/lib/client/main.js
new file mode 100644
index 000000000..c24f47e84
--- /dev/null
+++ b/packages/vulcan-events-internal/lib/client/main.js
@@ -0,0 +1,3 @@
+export * from '../modules/index.js';
+
+import './internal-client.js';
\ No newline at end of file
diff --git a/packages/vulcan-events-internal/lib/modules/collection.js b/packages/vulcan-events-internal/lib/modules/collection.js
new file mode 100644
index 000000000..b8ba51bd6
--- /dev/null
+++ b/packages/vulcan-events-internal/lib/modules/collection.js
@@ -0,0 +1,27 @@
+import { createCollection, getDefaultResolvers, getDefaultMutations } from 'meteor/vulcan:core';
+import schema from './schema.js';
+import Users from 'meteor/vulcan:users';
+
+const Events = createCollection({
+
+ collectionName: 'Events',
+
+ typeName: 'Event',
+
+ schema,
+
+ resolvers: getDefaultResolvers('Events'),
+
+ mutations: getDefaultMutations('Events', {
+ newCheck: () => true,
+ editCheck: () => false,
+ removeCheck: () => false
+ })
+
+});
+
+Events.checkAccess = (currentUser, doc) => {
+ return Users.isAdmin(currentUser);
+}
+
+export default Events;
diff --git a/packages/vulcan-events-internal/lib/modules/index.js b/packages/vulcan-events-internal/lib/modules/index.js
new file mode 100644
index 000000000..d193f1a60
--- /dev/null
+++ b/packages/vulcan-events-internal/lib/modules/index.js
@@ -0,0 +1 @@
+export * from './collection.js';
\ No newline at end of file
diff --git a/packages/vulcan-events-internal/lib/modules/schema.js b/packages/vulcan-events-internal/lib/modules/schema.js
new file mode 100644
index 000000000..6f216bb50
--- /dev/null
+++ b/packages/vulcan-events-internal/lib/modules/schema.js
@@ -0,0 +1,38 @@
+const schema = {
+ createdAt: {
+ type: Date,
+ optional: true,
+ onInsert: () => {
+ return new Date()
+ }
+ },
+ name: {
+ type: String,
+ insertableBy: ['guests'],
+ },
+ userId: {
+ type: String,
+ optional: true,
+ },
+ description: {
+ type: String,
+ optional: true,
+ },
+ unique: {
+ type: Boolean,
+ optional: true,
+ },
+ important: {
+ // marking an event as important means it should never be erased
+ type: Boolean,
+ optional: true,
+ },
+ properties: {
+ type: Object,
+ optional: true,
+ blackbox: true,
+ insertableBy: ['guests'],
+ },
+};
+
+export default schema;
\ No newline at end of file
diff --git a/packages/vulcan-events-internal/lib/server/internal-server.js b/packages/vulcan-events-internal/lib/server/internal-server.js
new file mode 100644
index 000000000..b966fe0b1
--- /dev/null
+++ b/packages/vulcan-events-internal/lib/server/internal-server.js
@@ -0,0 +1,19 @@
+import { addTrackFunction } from 'meteor/vulcan:events';
+import { newMutation } from 'meteor/vulcan:lib';
+import Events from '../modules/collection';
+
+async function trackInternalServer(eventName, eventProperties, currentUser) {
+ const document = {
+ name: eventName,
+ properties: eventProperties,
+ };
+ return await newMutation({
+ collection: Events,
+ document,
+ currentUser,
+ validate: false,
+ context: {},
+ });
+}
+
+addTrackFunction(trackInternalServer);
diff --git a/packages/vulcan-events-internal/lib/server/main.js b/packages/vulcan-events-internal/lib/server/main.js
new file mode 100644
index 000000000..e9da5ae82
--- /dev/null
+++ b/packages/vulcan-events-internal/lib/server/main.js
@@ -0,0 +1,3 @@
+export * from '../modules/index.js';
+
+import './internal-server';
diff --git a/packages/vulcan-events-internal/package.js b/packages/vulcan-events-internal/package.js
new file mode 100644
index 000000000..18231cc12
--- /dev/null
+++ b/packages/vulcan-events-internal/package.js
@@ -0,0 +1,20 @@
+Package.describe({
+ name: "vulcan:events-internal",
+ summary: "Vulcan internal event tracking package",
+ version: '1.8.1',
+ git: "https://github.com/VulcanJS/Vulcan.git"
+});
+
+Package.onUse(function(api) {
+
+ api.versionsFrom('METEOR@1.5.2');
+
+ api.use([
+ 'vulcan:core@1.8.1',
+ 'vulcan:events@1.8.1',
+ ]);
+
+ api.mainModule("lib/server/main.js", "server");
+ api.mainModule('lib/client/main.js', 'client');
+
+});
diff --git a/packages/vulcan-events-segment/lib/client/main.js b/packages/vulcan-events-segment/lib/client/main.js
new file mode 100644
index 000000000..288be2607
--- /dev/null
+++ b/packages/vulcan-events-segment/lib/client/main.js
@@ -0,0 +1,3 @@
+export * from '../modules/index';
+
+import './segment-client.js';
\ No newline at end of file
diff --git a/packages/vulcan-events-segment/lib/client/segment-client.js b/packages/vulcan-events-segment/lib/client/segment-client.js
new file mode 100644
index 000000000..b733d1e52
--- /dev/null
+++ b/packages/vulcan-events-segment/lib/client/segment-client.js
@@ -0,0 +1,110 @@
+import { getSetting, addCallback, Utils } from 'meteor/vulcan:core';
+import {
+ addPageFunction,
+ addInitFunction,
+ addIdentifyFunction,
+ addTrackFunction,
+} from 'meteor/vulcan:events';
+
+/*
+
+Track Page
+
+*/
+function segmentTrackPage(route) {
+ const { name, path } = route;
+ const properties = {
+ url: Utils.getSiteUrl().slice(0, -1) + path,
+ path,
+ };
+ window.analytics.page(null, name, properties);
+ return {};
+}
+addPageFunction(segmentTrackPage);
+
+/*
+
+Identify User
+
+*/
+function segmentIdentify(currentUser) {
+ window.analytics.identify(currentUser._id, {
+ email: currentUser.email,
+ pageUrl: currentUser.pageUrl,
+ });
+}
+addIdentifyFunction(segmentIdentify);
+
+/*
+
+Track Event
+
+*/
+function segmentTrack(eventName, eventProperties) {
+ analytics.track(eventName, eventProperties);
+}
+addTrackFunction(segmentTrack);
+
+/*
+
+Init Snippet
+
+*/
+function segmentInit() {
+ !(function() {
+ var analytics = (window.analytics = window.analytics || []);
+ if (!analytics.initialize)
+ if (analytics.invoked)
+ window.console &&
+ console.error &&
+ console.error('Segment snippet included twice.');
+ else {
+ analytics.invoked = !0;
+ analytics.methods = [
+ 'trackSubmit',
+ 'trackClick',
+ 'trackLink',
+ 'trackForm',
+ 'pageview',
+ 'identify',
+ 'reset',
+ 'group',
+ 'track',
+ 'ready',
+ 'alias',
+ 'debug',
+ 'page',
+ 'once',
+ 'off',
+ 'on',
+ ];
+ analytics.factory = function(t) {
+ return function() {
+ var e = Array.prototype.slice.call(arguments);
+ e.unshift(t);
+ analytics.push(e);
+ return analytics;
+ };
+ };
+ for (var t = 0; t < analytics.methods.length; t++) {
+ var e = analytics.methods[t];
+ analytics[e] = analytics.factory(e);
+ }
+ analytics.load = function(t) {
+ var e = document.createElement('script');
+ e.type = 'text/javascript';
+ e.async = !0;
+ e.src =
+ ('https:' === document.location.protocol ? 'https://' : 'http://') +
+ 'cdn.segment.com/analytics.js/v1/' +
+ t +
+ '/analytics.min.js';
+ var n = document.getElementsByTagName('script')[0];
+ n.parentNode.insertBefore(e, n);
+ };
+ analytics.SNIPPET_VERSION = '4.0.0';
+ analytics.load(getSetting('segment.clientKey'));
+ }
+ })();
+}
+addInitFunction(segmentInit);
\ No newline at end of file
diff --git a/packages/vulcan-events-segment/lib/modules/index.js b/packages/vulcan-events-segment/lib/modules/index.js
new file mode 100644
index 000000000..f04e7c61d
--- /dev/null
+++ b/packages/vulcan-events-segment/lib/modules/index.js
@@ -0,0 +1,4 @@
+import { registerSetting } from 'meteor/vulcan:core';
+
+registerSetting('segment.clientKey', null, 'Segment client-side API key');
+registerSetting('segment.serverKey', null, 'Segment server-side API key');
diff --git a/packages/vulcan-events-segment/lib/server/main.js b/packages/vulcan-events-segment/lib/server/main.js
new file mode 100644
index 000000000..132be3f68
--- /dev/null
+++ b/packages/vulcan-events-segment/lib/server/main.js
@@ -0,0 +1,3 @@
+// export * from '../modules/index';
+
+export * from './segment-server.js';
\ No newline at end of file
diff --git a/packages/vulcan-events-segment/lib/server/segment-server.js b/packages/vulcan-events-segment/lib/server/segment-server.js
new file mode 100644
index 000000000..6d19e9a6b
--- /dev/null
+++ b/packages/vulcan-events-segment/lib/server/segment-server.js
@@ -0,0 +1,40 @@
+import Analytics from 'analytics-node';
+import { getSetting, addCallback, Utils } from 'meteor/vulcan:core';
+import { addPageFunction, addInitFunction, addIdentifyFunction, addTrackFunction } from 'meteor/vulcan:events';
+
+const segmentWriteKey = getSetting('segment.serverKey');
+
+if (segmentWriteKey) {
+
+ const analytics = new Analytics(segmentWriteKey);
+
+ /*
+
+ Identify User
+
+ */
+ function segmentIdentifyServer(currentUser) {
+ analytics.identify({
+ userId: currentUser._id,
+ traits: {
+ email: currentUser.email,
+ pageUrl: currentUser.pageUrl,
+ },
+ });
+ }
+ addIdentifyFunction(segmentIdentifyServer);
+
+ /*
+
+ Track Event
+
+ */
+ function segmentTrackServer(eventName, eventProperties, currentUser) {
+ analytics.track({
+ event: eventName,
+ properties: eventProperties,
+ userId: currentUser && currentUser._id,
+ });
+ }
+ addTrackFunction(segmentTrackServer);
+}
diff --git a/packages/vulcan-events-segment/package.js b/packages/vulcan-events-segment/package.js
new file mode 100644
index 000000000..d5544bd57
--- /dev/null
+++ b/packages/vulcan-events-segment/package.js
@@ -0,0 +1,20 @@
+Package.describe({
+ name: "vulcan:events-segment",
+ summary: "Vulcan Segment",
+ version: '1.8.1',
+ git: "https://github.com/VulcanJS/Vulcan.git"
+});
+
+Package.onUse(function (api) {
+
+ api.versionsFrom('METEOR@1.5.2');
+
+ api.use([
+ 'vulcan:core@1.8.1',
+ 'vulcan:events@1.8.1',
+ ]);
+
+ api.mainModule('lib/server/main.js', 'server');
+ api.mainModule('lib/client/main.js', 'client');
+
+});
diff --git a/packages/vulcan-events/lib/callbacks.js b/packages/vulcan-events/lib/callbacks.js
deleted file mode 100644
index 75d594d60..000000000
--- a/packages/vulcan-events/lib/callbacks.js
+++ /dev/null
@@ -1,5 +0,0 @@
-import { addCallback } from 'meteor/vulcan:core';
-import { sendGoogleAnalyticsRequest } from './helpers';
-
-// add client-side callback: log a ga request on page view
-addCallback('router.onUpdate', sendGoogleAnalyticsRequest);
\ No newline at end of file
diff --git a/packages/vulcan-events/lib/client.js b/packages/vulcan-events/lib/client.js
deleted file mode 100644
index 5f5e7d985..000000000
--- a/packages/vulcan-events/lib/client.js
+++ /dev/null
@@ -1,8 +0,0 @@
-import Events from './collection.js';
-import { initGoogleAnalytics } from './helpers.js';
-import './callbacks.js';
-
-// init google analytics on the client module
-initGoogleAnalytics();
-
-export default Events;
diff --git a/packages/vulcan-events/lib/client/main.js b/packages/vulcan-events/lib/client/main.js
new file mode 100644
index 000000000..67d11275b
--- /dev/null
+++ b/packages/vulcan-events/lib/client/main.js
@@ -0,0 +1 @@
+export * from '../modules/index.js';
\ No newline at end of file
diff --git a/packages/vulcan-events/lib/collection.js b/packages/vulcan-events/lib/collection.js
deleted file mode 100644
index 031492f08..000000000
--- a/packages/vulcan-events/lib/collection.js
+++ /dev/null
@@ -1,33 +0,0 @@
-import SimpleSchema from 'simpl-schema';
-
-const Events = new Mongo.Collection('events');
-
-Events.schema = new SimpleSchema({
- createdAt: {
- type: Date
- },
- name: {
- type: String
- },
- description: {
- type: String,
- optional: true
- },
- unique: {
- type: Boolean,
- optional: true
- },
- important: { // marking an event as important means it should never be erased
- type: Boolean,
- optional: true
- },
- properties: {
- type: Object,
- optional: true,
- blackbox: true
- }
-});
-
-Events.attachSchema(Events.schema);
-
-export default Events;
diff --git a/packages/vulcan-events/lib/helpers.js b/packages/vulcan-events/lib/helpers.js
deleted file mode 100644
index b3355c05c..000000000
--- a/packages/vulcan-events/lib/helpers.js
+++ /dev/null
@@ -1,60 +0,0 @@
-import { getSetting, registerSetting } from 'meteor/vulcan:core';
-import Events from './collection.js';
-
-registerSetting('googleAnalyticsId', null, 'Google Analytics ID');
-
-/*
-
- 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 function sendGoogleAnalyticsRequest () {
- if (window && window.ga) {
- window.ga('send', 'pageview', {
- 'page': window.location.pathname
- });
- }
- return {}
-}
-
-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();
- }
-};
-
-
-// collection based logging
-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);
-
-};
diff --git a/packages/vulcan-events/lib/modules/events.js b/packages/vulcan-events/lib/modules/events.js
new file mode 100644
index 000000000..c04a37279
--- /dev/null
+++ b/packages/vulcan-events/lib/modules/events.js
@@ -0,0 +1,41 @@
+import { addCallback } from 'meteor/vulcan:core';
+
+export const initFunctions = [];
+
+export const trackFunctions = [];
+
+export const addInitFunction = f => {
+ initFunctions.push(f);
+ // execute init function as soon as possible
+ f();
+};
+
+export const addTrackFunction = f => {
+ trackFunctions.push(f);
+};
+
+export const track = async (eventName, eventProperties, currentUser) => {
+ for (let f of trackFunctions) {
+ await f(eventName, eventProperties, currentUser);
+ }
+};
+
+export const addUserFunction = f => {
+ addCallback('users.new.async', f);
+};
+
+export const addIdentifyFunction = f => {
+ addCallback('events.identify', f);
+};
+
+export const addPageFunction = f => {
+ const f2 = (empty, route) => f(route);
+
+ // rename f2 to same name as f
+ // see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Object/defineProperty
+ const descriptor = Object.create(null); // no inherited properties
+ descriptor.value = f.name;
+ Object.defineProperty(f2, 'name', descriptor);
+
+ addCallback('router.onUpdate', f2);
+};
diff --git a/packages/vulcan-events/lib/modules/index.js b/packages/vulcan-events/lib/modules/index.js
new file mode 100644
index 000000000..099399ac2
--- /dev/null
+++ b/packages/vulcan-events/lib/modules/index.js
@@ -0,0 +1 @@
+export * from './events';
\ No newline at end of file
diff --git a/packages/vulcan-events/lib/mutations.js b/packages/vulcan-events/lib/mutations.js
deleted file mode 100644
index 17a92a1aa..000000000
--- a/packages/vulcan-events/lib/mutations.js
+++ /dev/null
@@ -1,18 +0,0 @@
-// import { GraphQLSchema } from 'meteor/vulcan:core';
-// // import Events from './collection.js';
-// import { requestAnalyticsAsync } from './helpers.js';
-
-// GraphQLSchema.addMutation('eventTrack(eventName: String, properties: JSON): JSON');
-
-// const resolvers = {
-// Mutation: {
-// eventTrack: (root, { eventName, properties }, context) => {
-// const user = context.currentUser || {_id: 'anonymous'};
-
-
-// return properties;
-// },
-// },
-// };
-
-// GraphQLSchema.addResolvers(resolvers);
diff --git a/packages/vulcan-events/lib/server.js b/packages/vulcan-events/lib/server.js
deleted file mode 100644
index 523fea258..000000000
--- a/packages/vulcan-events/lib/server.js
+++ /dev/null
@@ -1,5 +0,0 @@
-import Events from './collection.js';
-import './callbacks.js';
-// import './mutations.js';
-
-export default Events;
diff --git a/packages/vulcan-events/lib/server/main.js b/packages/vulcan-events/lib/server/main.js
new file mode 100644
index 000000000..67d11275b
--- /dev/null
+++ b/packages/vulcan-events/lib/server/main.js
@@ -0,0 +1 @@
+export * from '../modules/index.js';
\ No newline at end of file
diff --git a/packages/vulcan-events/package.js b/packages/vulcan-events/package.js
index 2d14c24b3..96748fbf1 100644
--- a/packages/vulcan-events/package.js
+++ b/packages/vulcan-events/package.js
@@ -1,7 +1,7 @@
Package.describe({
name: "vulcan:events",
summary: "Vulcan event tracking package",
- version: '1.8.0',
+ version: '1.8.1',
git: "https://github.com/VulcanJS/Vulcan.git"
});
@@ -10,10 +10,10 @@ Package.onUse(function(api) {
api.versionsFrom('METEOR@1.5.2');
api.use([
- 'vulcan:core@1.8.0',
+ 'vulcan:core@1.8.1',
]);
- api.mainModule("lib/server.js", "server");
- api.mainModule("lib/client.js", "client");
+ api.mainModule("lib/server/main.js", "server");
+ api.mainModule('lib/client/main.js', 'client');
});
diff --git a/packages/vulcan-forms-tags/package.js b/packages/vulcan-forms-tags/package.js
index e5a33244f..9872f60b4 100644
--- a/packages/vulcan-forms-tags/package.js
+++ b/packages/vulcan-forms-tags/package.js
@@ -1,7 +1,7 @@
Package.describe({
name: "vulcan:forms-tags",
summary: "Vulcan tag input package",
- version: '1.8.0',
+ version: '1.8.1',
git: 'https://github.com/VulcanJS/Vulcan.git'
});
@@ -10,8 +10,8 @@ Package.onUse( function(api) {
api.versionsFrom('METEOR@1.5.2');
api.use([
- 'vulcan:core@1.8.0',
- 'vulcan:forms@1.8.0'
+ 'vulcan:core@1.8.1',
+ 'vulcan:forms@1.8.1'
]);
api.mainModule("lib/export.js", ["client", "server"]);
diff --git a/packages/vulcan-forms-upload/lib/Upload.jsx b/packages/vulcan-forms-upload/lib/Upload.jsx
index 14f17c7e1..bc0030af2 100755
--- a/packages/vulcan-forms-upload/lib/Upload.jsx
+++ b/packages/vulcan-forms-upload/lib/Upload.jsx
@@ -169,15 +169,15 @@ class Upload extends PureComponent {
const newValue = this.enableMultiple() ? removeNthItem(this.state.value, index): '';
this.context.addToAutofilledValues({[this.props.name]: newValue});
this.setState({
- preview: newValue,
+ preview: null,
value: newValue,
});
}
render() {
const { uploading, preview, value } = this.state;
+
// show the actual uploaded image or the preview
-
const imageData = this.enableMultiple() ? (preview ? value.concat(preview) : value) : value || preview;
return (
diff --git a/packages/vulcan-forms-upload/package.js b/packages/vulcan-forms-upload/package.js
index 734df83c4..62c0d2b7b 100755
--- a/packages/vulcan-forms-upload/package.js
+++ b/packages/vulcan-forms-upload/package.js
@@ -1,7 +1,7 @@
Package.describe({
name: "vulcan:forms-upload",
summary: "Vulcan package extending vulcan:forms to upload images to Cloudinary from a drop zone.",
- version: "1.8.0",
+ version: "1.8.1",
git: 'https://github.com/xavcz/nova-forms-upload.git'
});
@@ -10,8 +10,8 @@ Package.onUse( function(api) {
api.versionsFrom('METEOR@1.5.2');
api.use([
- 'vulcan:core@1.8.0',
- 'vulcan:forms@1.8.0',
+ 'vulcan:core@1.8.1',
+ 'vulcan:forms@1.8.1',
'fourseven:scss@4.5.0'
]);
diff --git a/packages/vulcan-forms/lib/components/Flash.jsx b/packages/vulcan-forms/lib/components/Flash.jsx
index 9ce77b4ff..927fdb821 100644
--- a/packages/vulcan-forms/lib/components/Flash.jsx
+++ b/packages/vulcan-forms/lib/components/Flash.jsx
@@ -1,6 +1,7 @@
import React from 'react';
import PropTypes from 'prop-types';
import Alert from 'react-bootstrap/lib/Alert'
+import { registerComponent } from 'meteor/vulcan:core';
const Flash = ({message, type}) => {
@@ -24,4 +25,4 @@ Flash.propTypes = {
message: PropTypes.oneOfType([PropTypes.object.isRequired, PropTypes.array.isRequired])
}
-export default Flash;
\ No newline at end of file
+registerComponent('FormFlash', Flash);
\ No newline at end of file
diff --git a/packages/vulcan-forms/lib/components/Form.jsx b/packages/vulcan-forms/lib/components/Form.jsx
index 8ca04813a..f8527995c 100644
--- a/packages/vulcan-forms/lib/components/Form.jsx
+++ b/packages/vulcan-forms/lib/components/Form.jsx
@@ -25,12 +25,9 @@ This component expects:
import { Components, Utils, runCallbacks } from 'meteor/vulcan:core';
import React, { Component } from 'react';
import PropTypes from 'prop-types';
-import { FormattedMessage, intlShape } from 'meteor/vulcan:i18n';
+import { intlShape } from 'meteor/vulcan:i18n';
import Formsy from 'formsy-react';
-import Button from 'react-bootstrap/lib/Button';
-import Flash from "./Flash.jsx";
-import FormGroup from "./FormGroup.jsx";
-import { flatten, deepValue, getEditableFields, getInsertableFields } from '../modules/utils.js';
+import { getEditableFields, getInsertableFields } from '../modules/utils.js';
/*
@@ -156,21 +153,32 @@ class Form extends Component {
}
// replace empty value, which has not been prefilled, by the default value from the schema
- if (fieldSchema.defaultValue && field.value === "") {
+ // keep defaultValue for backwards compatibility even though it doesn't actually work
+ if (fieldSchema.defaultValue && (typeof field.value === 'undefined' || field.value === '')) {
field.value = fieldSchema.defaultValue;
}
+ if (fieldSchema.default && (typeof field.value === 'undefined' || field.value === '')) {
+ field.value = fieldSchema.default;
+ }
// add options if they exist
if (fieldSchema.form && fieldSchema.form.options) {
field.options = typeof fieldSchema.form.options === "function" ? fieldSchema.form.options.call(fieldSchema, this.props) : fieldSchema.form.options;
+
+ // in case of checkbox groups, check "checked" option to populate value
+ if (!field.value) {
+ field.value = _.where(field.options, {checked: true}).map(option => option.value);
+ }
}
-
- if (fieldSchema.form && fieldSchema.form.disabled) {
- field.disabled = typeof fieldSchema.form.disabled === "function" ? fieldSchema.form.disabled.call(fieldSchema) : fieldSchema.form.disabled;
- }
-
- if (fieldSchema.form && fieldSchema.form.help) {
- field.help = typeof fieldSchema.form.help === "function" ? fieldSchema.form.help.call(fieldSchema) : fieldSchema.form.help;
+
+ if (fieldSchema.form) {
+ for (const prop in fieldSchema.form) {
+ if (prop !== 'prefill' && prop !== 'options' && fieldSchema.form.hasOwnProperty(prop)) {
+ field[prop] = typeof fieldSchema.form[prop] === "function" ?
+ fieldSchema.form[prop].call(fieldSchema) :
+ fieldSchema.form[prop];
+ }
+ }
}
// add limit
@@ -353,7 +361,8 @@ class Form extends Component {
message = error.data.errors.map(error => {
return {
- content: this.getErrorMessage(error)
+ content: this.getErrorMessage(error),
+ data: error.data,
}
});
@@ -362,8 +371,8 @@ class Form extends Component {
message = {content: error.message || this.context.intl.formatMessage({id: error.id, defaultMessage: error.id}, error.data)}
}
-
- return
+
+ return ;
})}
)
@@ -613,26 +622,25 @@ class Form extends Component {
disabled={this.state.disabled}
ref="form"
>
- {this.renderErrors()}
- {fieldGroups.map(group => )}
-
+ {this.renderErrors()}
+
+ {fieldGroups.map(group => )}
+
+ {this.props.repeatErrors && this.renderErrors()}
+
+
-
- {
- this.props.formType === 'edit' && this.props.showRemove
- ?
- : null
- }
)
}
@@ -660,6 +668,7 @@ Form.propTypes = {
showRemove: PropTypes.bool,
submitLabel: PropTypes.string,
cancelLabel: PropTypes.string,
+ repeatErrors: PropTypes.bool,
// callbacks
submitCallback: PropTypes.func,
@@ -673,7 +682,8 @@ Form.propTypes = {
}
Form.defaultProps = {
- layout: "horizontal",
+ layout: 'horizontal',
+ repeatErrors: false,
}
Form.contextTypes = {
diff --git a/packages/vulcan-forms/lib/components/FormComponent.jsx b/packages/vulcan-forms/lib/components/FormComponent.jsx
index 58e1f86fb..2e1cc468a 100644
--- a/packages/vulcan-forms/lib/components/FormComponent.jsx
+++ b/packages/vulcan-forms/lib/components/FormComponent.jsx
@@ -3,6 +3,7 @@ import PropTypes from 'prop-types';
import { intlShape } from 'meteor/vulcan:i18n';
import classNames from 'classnames';
import { Components } from 'meteor/vulcan:core';
+import { registerComponent } from 'meteor/vulcan:core';
class FormComponent extends PureComponent {
@@ -97,6 +98,9 @@ class FormComponent extends PureComponent {
case 'datetime':
return ;
+ case 'time':
+ return ;
+
case 'text':
return ;
@@ -120,16 +124,37 @@ class FormComponent extends PureComponent {
)
}
+ showClear = () => {
+ return ['datetime', 'select', 'radiogroup'].includes(this.props.control);
+ }
+
+ clearField = (e) => {
+ e.preventDefault();
+ console.log(this.props)
+ const fieldName = this.props.name;
+ // clear value
+ this.props.updateCurrentValues({[fieldName]: null});
+ // add it to unset
+ this.context.addToDeletedValues(fieldName);
+ }
+
+ renderClear() {
+ return (
+ ✕
+ )
+ }
+
render() {
const hasErrors = this.props.errors && this.props.errors.length;
- const inputClass = classNames('form-input', `input-${this.props.name}`, {'input-error': hasErrors});
+ const inputClass = classNames('form-input', `input-${this.props.name}`, `form-component-${this.props.control || 'default'}`,{'input-error': hasErrors});
return (
{this.props.beforeComponent ? this.props.beforeComponent : null}
{this.renderComponent()}
{hasErrors ? this.renderErrors() : null}
+ {this.showClear() ? this.renderClear() : null}
{this.props.limit ? {this.state.limit} : null}
{this.props.afterComponent ? this.props.afterComponent : null}
@@ -153,7 +178,8 @@ FormComponent.propTypes = {
}
FormComponent.contextTypes = {
- intl: intlShape
+ intl: intlShape,
+ addToDeletedValues: PropTypes.func,
};
-export default FormComponent;
+registerComponent('FormComponent', FormComponent);
diff --git a/packages/vulcan-forms/lib/components/FormGroup.jsx b/packages/vulcan-forms/lib/components/FormGroup.jsx
index 05de64f3e..79f168a9b 100644
--- a/packages/vulcan-forms/lib/components/FormGroup.jsx
+++ b/packages/vulcan-forms/lib/components/FormGroup.jsx
@@ -1,8 +1,8 @@
import React, { PureComponent } from 'react';
import PropTypes from 'prop-types';
-import FormComponent from './FormComponent.jsx';
import { Components } from 'meteor/vulcan:core';
import classNames from 'classnames';
+import { registerComponent } from 'meteor/vulcan:core';
class FormGroup extends PureComponent {
@@ -40,7 +40,7 @@ class FormGroup extends PureComponent {
{this.props.name === 'default' ? null : this.renderHeading()}
- {this.props.fields.map(field => )}
+ {this.props.fields.map(field => )}
)
@@ -55,4 +55,4 @@ FormGroup.propTypes = {
updateCurrentValues: PropTypes.func
}
-export default FormGroup;
\ No newline at end of file
+registerComponent('FormGroup', FormGroup);
\ No newline at end of file
diff --git a/packages/vulcan-forms/lib/components/FormSubmit.jsx b/packages/vulcan-forms/lib/components/FormSubmit.jsx
new file mode 100644
index 000000000..ac0ae3e5f
--- /dev/null
+++ b/packages/vulcan-forms/lib/components/FormSubmit.jsx
@@ -0,0 +1,65 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { Components } from 'meteor/vulcan:core';
+import { registerComponent } from 'meteor/vulcan:core';
+import Button from 'react-bootstrap/lib/Button';
+import { FormattedMessage } from 'meteor/vulcan:i18n';
+
+
+const FormSubmit = ({
+ submitLabel,
+ cancelLabel,
+ cancelCallback,
+ document,
+ deleteDocument,
+ collectionName,
+ classes
+ }) => (
+
+);
+
+
+FormSubmit.propTypes = {
+ submitLabel: PropTypes.string,
+ cancelLabel: PropTypes.string,
+ cancelCallback: PropTypes.func,
+ document: PropTypes.object,
+ deleteDocument: PropTypes.func,
+ collectionName: PropTypes.string,
+ classes: PropTypes.object,
+};
+
+
+registerComponent('FormSubmit', FormSubmit);
diff --git a/packages/vulcan-forms/lib/components/FormWrapper.jsx b/packages/vulcan-forms/lib/components/FormWrapper.jsx
index 3ab78f520..09ff6b348 100644
--- a/packages/vulcan-forms/lib/components/FormWrapper.jsx
+++ b/packages/vulcan-forms/lib/components/FormWrapper.jsx
@@ -35,6 +35,13 @@ import { withDocument } from 'meteor/vulcan:core';
class FormWrapper extends PureComponent {
+ constructor(props) {
+ super(props);
+ // instantiate the wrapped component in constructor, not in render
+ // see https://reactjs.org/docs/higher-order-components.html#dont-use-hocs-inside-the-render-method
+ this.FormComponent = this.getComponent();
+ }
+
// return the current schema based on either the schema or collection prop
getSchema() {
return this.props.schema ? this.props.schema : Utils.stripTelescopeNamespace(this.props.collection.simpleSchema()._schema);
@@ -69,7 +76,7 @@ class FormWrapper extends PureComponent {
mutationFields = _.intersection(mutationFields, fields);
}
- // resolve any array field with resolveAs as fieldName{_id}
+ // resolve any array field with resolveAs as fieldName{_id} -> why?
/*
- string field with no resolver -> fieldName
- string field with a resolver -> fieldName
@@ -77,9 +84,9 @@ class FormWrapper extends PureComponent {
- array field with a resolver -> fieldName{_id}
*/
const mapFieldNameToField = fieldName => {
- const field = this.getSchema()[fieldName]
+ const field = this.getSchema()[fieldName];
return field.resolveAs && field.type.definitions[0].type === Array
- ? `${fieldName}{_id}` // if it's a custom resolver, add a basic query to its _id
+ ? `${fieldName}` // if it's a custom resolver, add a basic query to its _id
: fieldName; // else just ask for the field name
}
queryFields = queryFields.map(mapFieldNameToField);
@@ -108,13 +115,7 @@ class FormWrapper extends PureComponent {
};
}
- shouldComponentUpdate(nextProps) {
- // prevent extra re-renderings for unknown reasons
- // re-render only if the document selector changes
- return nextProps.slug !== this.props.slug || nextProps.documentId !== this.props.documentId;
- }
-
- render() {
+ getComponent() {
// console.log(this)
@@ -136,6 +137,8 @@ class FormWrapper extends PureComponent {
queryName: `${prefix}FormQuery`,
collection: this.props.collection,
fragment: this.getFragments().queryFragment,
+ fetchPolicy: 'network-only', // we always want to load a fresh copy of the document
+ enableCache: false,
};
// options for withNew, withEdit, and withRemove HoCs
@@ -180,6 +183,16 @@ class FormWrapper extends PureComponent {
}
}
+
+ shouldComponentUpdate(nextProps) {
+ // prevent extra re-renderings for unknown reasons
+ // re-render only if the document selector changes
+ return nextProps.slug !== this.props.slug || nextProps.documentId !== this.props.documentId;
+ }
+
+ render() {
+ return this.FormComponent;
+ }
}
FormWrapper.propTypes = {
diff --git a/packages/vulcan-forms/lib/components/bootstrap/Time.jsx b/packages/vulcan-forms/lib/components/bootstrap/Time.jsx
new file mode 100644
index 000000000..b06ca6e89
--- /dev/null
+++ b/packages/vulcan-forms/lib/components/bootstrap/Time.jsx
@@ -0,0 +1,73 @@
+import React, { PureComponent } from 'react';
+import PropTypes from 'prop-types';
+import DateTimePicker from 'react-datetime';
+import { registerComponent } from 'meteor/vulcan:core';
+
+class Time extends PureComponent {
+
+ constructor(props) {
+ super(props);
+ this.updateDate = this.updateDate.bind(this);
+ }
+
+ // when the datetime picker has mounted, SmartForm will catch the date value (no formsy mixin in this component)
+ componentDidMount() {
+ if (this.props.value) {
+ this.context.updateCurrentValues({[this.props.name]: this.props.value});
+ }
+ }
+
+ updateDate(mDate) {
+ // if this is a properly formatted moment date, update time
+ if (typeof mDate === 'object') {
+ this.context.updateCurrentValues({[this.props.name]: mDate.format('HH:mm')});
+ }
+ }
+
+ render() {
+
+ const date = new Date();
+
+ // transform time string into date object to work inside datetimepicker
+ const time = this.props.value;
+ if (time) {
+ date.setHours(parseInt(time.substr(0,2)), parseInt(time.substr(3,5)));
+ } else {
+ date.setHours(0,0);
+ }
+
+ return (
+
+
+
+ this.updateDate(newDate)}
+ inputProps={{name: this.props.name}}
+ />
+
+
+ );
+ }
+}
+
+Time.propTypes = {
+ control: PropTypes.any,
+ datatype: PropTypes.any,
+ group: PropTypes.any,
+ label: PropTypes.string,
+ name: PropTypes.string,
+ value: PropTypes.any,
+};
+
+Time.contextTypes = {
+ updateCurrentValues: PropTypes.func,
+};
+
+export default Time;
+
+registerComponent('FormComponentTime', Time);
\ No newline at end of file
diff --git a/packages/vulcan-forms/lib/modules/components.js b/packages/vulcan-forms/lib/modules/components.js
index 390b5768f..df9b3e833 100644
--- a/packages/vulcan-forms/lib/modules/components.js
+++ b/packages/vulcan-forms/lib/modules/components.js
@@ -7,6 +7,11 @@ import '../components/bootstrap/Number.jsx';
import '../components/bootstrap/Radiogroup.jsx';
import '../components/bootstrap/Select.jsx';
import '../components/bootstrap/Textarea.jsx';
+import '../components/bootstrap/Time.jsx';
import '../components/bootstrap/Url.jsx';
+import '../components/Flash.jsx';
+import '../components/FormComponent.jsx';
+import '../components/FormGroup.jsx';
+import '../components/FormSubmit.jsx';
import '../components/FormWrapper.jsx';
diff --git a/packages/vulcan-forms/lib/stylesheets/style.scss b/packages/vulcan-forms/lib/stylesheets/style.scss
index c0789f50c..3ff8eaf9e 100644
--- a/packages/vulcan-forms/lib/stylesheets/style.scss
+++ b/packages/vulcan-forms/lib/stylesheets/style.scss
@@ -1,4 +1,5 @@
$light-grey: #ddd;
+$medium-grey: #bbb;
$vmargin: 15px;
$light-border: $light-grey;
@@ -181,4 +182,35 @@ div.ReactTags__suggestions mark{
li{
margin: 0;
}
+}
+
+.form-component-select, .form-component-datetime{
+ .col-sm-9{
+ padding-right: 40px;
+ }
+}
+
+.form-component-clear{
+ position: absolute;
+ top: 11px;
+ right: 0px;
+ background: $light-grey;
+ color: #fff;
+ border-radius: 100%;
+ height: 16px;
+ width: 16px;
+ border: 0;
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ span{
+ font-size: 8px;
+ display: block;
+ line-height: 1;
+ }
+ &:hover{
+ text-decoration: none;
+ background: $medium-grey;
+ color: #fff;
+ }
}
\ No newline at end of file
diff --git a/packages/vulcan-forms/package.js b/packages/vulcan-forms/package.js
index 16b66720f..fc1480b8d 100644
--- a/packages/vulcan-forms/package.js
+++ b/packages/vulcan-forms/package.js
@@ -1,7 +1,7 @@
Package.describe({
name: "vulcan:forms",
summary: "Form containers for React",
- version: '1.8.0',
+ version: '1.8.1',
git: "https://github.com/meteor-utilities/react-form-containers.git"
});
@@ -10,7 +10,7 @@ Package.onUse(function(api) {
api.versionsFrom("METEOR@1.3");
api.use([
- 'vulcan:core@1.8.0',
+ 'vulcan:core@1.8.1',
'fourseven:scss@4.5.0'
]);
diff --git a/packages/vulcan-i18n-en-us/package.js b/packages/vulcan-i18n-en-us/package.js
index 73f0b25a8..7088320a4 100644
--- a/packages/vulcan-i18n-en-us/package.js
+++ b/packages/vulcan-i18n-en-us/package.js
@@ -1,7 +1,7 @@
Package.describe({
name: "vulcan:i18n-en-us",
summary: "Vulcan i18n package (en_US)",
- version: '1.8.0',
+ version: '1.8.1',
git: "https://github.com/VulcanJS/Vulcan.git"
});
@@ -10,7 +10,7 @@ Package.onUse(function (api) {
api.versionsFrom('METEOR@1.5.2');
api.use([
- 'vulcan:core@1.8.0'
+ 'vulcan:core@1.8.1'
]);
api.addFiles([
diff --git a/packages/vulcan-i18n/package.js b/packages/vulcan-i18n/package.js
index 74ed019af..f93ba0a78 100644
--- a/packages/vulcan-i18n/package.js
+++ b/packages/vulcan-i18n/package.js
@@ -1,14 +1,14 @@
Package.describe({
name: 'vulcan:i18n',
summary: "i18n client polyfill",
- version: '1.8.0',
+ version: '1.8.1',
git: "https://github.com/VulcanJS/Vulcan"
});
Package.onUse(function (api) {
api.use([
- 'vulcan:lib@1.8.0',
+ 'vulcan:lib@1.8.1',
]);
api.mainModule('lib/server/main.js', 'server');
diff --git a/packages/vulcan-lib/lib/modules/callbacks.js b/packages/vulcan-lib/lib/modules/callbacks.js
index a401bc292..b390dd08a 100644
--- a/packages/vulcan-lib/lib/modules/callbacks.js
+++ b/packages/vulcan-lib/lib/modules/callbacks.js
@@ -1,11 +1,26 @@
import { debug } from './debug.js';
+/**
+ * @summary A list of all registered callback hooks
+ */
+export const CallbackHooks = [];
+
/**
* @summary Callback hooks provide an easy way to add extra steps to common operations.
* @namespace Callbacks
*/
export const Callbacks = {};
+
+/**
+ * @summary Register a callback
+ * @param {String} hook - The name of the hook
+ * @param {Function} callback - The callback function
+ */
+export const registerCallback = function (callback) {
+ CallbackHooks.push(callback);
+};
+
/**
* @summary Add a callback function to a hook
* @param {String} hook - The name of the hook
@@ -72,7 +87,7 @@ export const runCallbacks = function () {
if (typeof result === 'undefined') {
// if result of current iteration is undefined, don't pass it on
- console.log(`// Warning: Sync callback [${callback.name}] in hook [${hook}] didn't return a result!`)
+ // debug(`// Warning: Sync callback [${callback.name}] in hook [${hook}] didn't return a result!`)
return accumulator
} else {
return result;
@@ -114,7 +129,7 @@ export const runCallbacksAsync = function () {
Meteor.defer(function () {
// run all post submit server callbacks on post object successively
callbacks.forEach(function(callback) {
- // console.log("// "+hook+": running callback ["+callback.name+"] at "+moment().format("hh:mm:ss"))
+ debug(`// Running async callback [${callback.name}] on hook [${hook}]`);
callback.apply(this, args);
});
});
diff --git a/packages/vulcan-lib/lib/modules/collections.js b/packages/vulcan-lib/lib/modules/collections.js
index 38df3817c..5c079f7c8 100644
--- a/packages/vulcan-lib/lib/modules/collections.js
+++ b/packages/vulcan-lib/lib/modules/collections.js
@@ -135,17 +135,17 @@ export const createCollection = options => {
const queryResolvers = {};
// list
if (resolvers.list) { // e.g. ""
- addGraphQLQuery(`${resolvers.list.name}(terms: JSON, offset: Int, limit: Int): [${typeName}]`);
+ addGraphQLQuery(`${resolvers.list.name}(terms: JSON, offset: Int, limit: Int, enableCache: Boolean): [${typeName}]`);
queryResolvers[resolvers.list.name] = resolvers.list.resolver.bind(resolvers.list);
}
// single
if (resolvers.single) {
- addGraphQLQuery(`${resolvers.single.name}(documentId: String, slug: String): ${typeName}`);
+ addGraphQLQuery(`${resolvers.single.name}(documentId: String, slug: String, enableCache: Boolean): ${typeName}`);
queryResolvers[resolvers.single.name] = resolvers.single.resolver.bind(resolvers.single);
}
// total
if (resolvers.total) {
- addGraphQLQuery(`${resolvers.total.name}(terms: JSON): Int`);
+ addGraphQLQuery(`${resolvers.total.name}(terms: JSON, enableCache: Boolean): Int`);
queryResolvers[resolvers.total.name] = resolvers.total.resolver;
}
addGraphQLResolvers({ Query: { ...queryResolvers } });
diff --git a/packages/vulcan-lib/lib/modules/config.js b/packages/vulcan-lib/lib/modules/config.js
index b03c736a6..a4dd21c80 100644
--- a/packages/vulcan-lib/lib/modules/config.js
+++ b/packages/vulcan-lib/lib/modules/config.js
@@ -7,7 +7,7 @@ import SimpleSchema from 'simpl-schema';
Vulcan = {};
-Vulcan.VERSION = '1.8.0';
+Vulcan.VERSION = '1.8.1';
// ------------------------------------- Schemas -------------------------------- //
@@ -32,6 +32,7 @@ SimpleSchema.extendOptions([
'resolveAs',
'limit',
'searchable',
+ 'default',
]);
export default Vulcan;
diff --git a/packages/vulcan-lib/lib/modules/headtags.js b/packages/vulcan-lib/lib/modules/headtags.js
index 9a2622359..18348d49c 100644
--- a/packages/vulcan-lib/lib/modules/headtags.js
+++ b/packages/vulcan-lib/lib/modules/headtags.js
@@ -2,6 +2,7 @@ export const Head = {
meta: [],
link: [],
script: [],
+ components: [],
}
export const removeFromHeadTags = (type, name)=>{
diff --git a/packages/vulcan-lib/lib/modules/utils.js b/packages/vulcan-lib/lib/modules/utils.js
index 7e8ad604b..1d7e379fa 100644
--- a/packages/vulcan-lib/lib/modules/utils.js
+++ b/packages/vulcan-lib/lib/modules/utils.js
@@ -11,6 +11,7 @@ import sanitizeHtml from 'sanitize-html';
import getSlug from 'speakingurl';
import { getSetting, registerSetting } from './settings.js';
import { Routes } from './routes.js';
+import { isAbsolute } from 'path';
registerSetting('debug', false, 'Enable debug mode (more verbose logging)');
@@ -126,10 +127,14 @@ Utils.getDateRange = function(pageNumber) {
//////////////////////////
/**
- * @summary Returns the user defined site URL or Meteor.absoluteUrl
+ * @summary Returns the user defined site URL or Meteor.absoluteUrl. Add trailing '/' if missing
*/
Utils.getSiteUrl = function () {
- return getSetting('siteUrl', Meteor.absoluteUrl());
+ const url = getSetting('siteUrl', Meteor.absoluteUrl());
+ if (url.slice(-1) !== '/') {
+ url += '/';
+ }
+ return url;
};
/**
@@ -288,7 +293,7 @@ Utils.getFieldLabel = (fieldName, collection) => {
Utils.getLogoUrl = () => {
const logoUrl = getSetting('logoUrl');
- if (!!logoUrl) {
+ if (logoUrl) {
const prefix = Utils.getSiteUrl().slice(0,-1);
// the logo may be hosted on another website
return logoUrl.indexOf('://') > -1 ? logoUrl : prefix + logoUrl;
diff --git a/packages/vulcan-lib/lib/modules/validation.js b/packages/vulcan-lib/lib/modules/validation.js
index d516b6ee0..09dd3e175 100644
--- a/packages/vulcan-lib/lib/modules/validation.js
+++ b/packages/vulcan-lib/lib/modules/validation.js
@@ -47,7 +47,6 @@ export const validateDocument = (document, collection, context) => {
const fieldSchema = schema[fieldName];
-
if ((fieldSchema.required || !fieldSchema.optional) && typeof document[fieldName] === 'undefined') {
validationErrors.push({
id: 'app.required_field_missing',
@@ -56,7 +55,7 @@ export const validateDocument = (document, collection, context) => {
}
});
-
+
// 5. still run SS validation for now for backwards compatibility
try {
collection.simpleSchema().validate(document);
@@ -123,19 +122,20 @@ export const validateModifier = (modifier, document, collection, context) => {
});
// 4. check that required fields have a value
- // note: maybe required fields don't make sense for edit operation?
- // _.keys(schema).forEach(fieldName => {
+ // when editing, we only want to require fields that are actually part of the form
+ // so we make sure required keys are present in the $unset object
+ _.keys(schema).forEach(fieldName => {
- // const fieldSchema = schema[fieldName];
+ const fieldSchema = schema[fieldName];
- // if ((fieldSchema.required || !fieldSchema.optional) && typeof set[fieldName] === 'undefined') {
- // validationErrors.push({
- // id: 'app.required_field_missing',
- // data: {fieldName}
- // });
- // }
+ if (unset[fieldName] && (fieldSchema.required || !fieldSchema.optional) && typeof set[fieldName] === 'undefined') {
+ validationErrors.push({
+ id: 'app.required_field_missing',
+ data: {fieldName}
+ });
+ }
- // });
+ });
// 5. still run SS validation for now for backwards compatibility
try {
diff --git a/packages/vulcan-lib/lib/server/apollo_server.js b/packages/vulcan-lib/lib/server/apollo_server.js
index adca0336d..ee29eb4b2 100644
--- a/packages/vulcan-lib/lib/server/apollo_server.js
+++ b/packages/vulcan-lib/lib/server/apollo_server.js
@@ -1,26 +1,79 @@
-import { graphqlExpress, graphiqlExpress } from 'graphql-server-express';
+import { graphqlExpress, graphiqlExpress } from 'apollo-server-express';
import bodyParser from 'body-parser';
import express from 'express';
import { makeExecutableSchema } from 'graphql-tools';
import deepmerge from 'deepmerge';
-import OpticsAgent from 'optics-agent'
import DataLoader from 'dataloader';
import { formatError } from 'apollo-errors';
-
+import compression from 'compression';
import { Meteor } from 'meteor/meteor';
import { check } from 'meteor/check';
import { Accounts } from 'meteor/accounts-base';
+import { Engine } from 'apollo-engine';
import { GraphQLSchema } from '../modules/graphql.js';
import { Utils } from '../modules/utils.js';
import { webAppConnectHandlersUse } from './meteor_patch.js';
+import { getSetting } from '../modules/settings.js';
import { Collections } from '../modules/collections.js';
import findByIds from '../modules/findbyids.js';
import { runCallbacks } from '../modules/callbacks.js';
export let executableSchema;
+// see https://github.com/apollographql/apollo-cache-control
+
+const engineApiKey = getSetting('apolloEngine.apiKey');
+const engineConfig = {
+ apiKey: engineApiKey,
+ // "origins": [
+ // {
+ // "http": {
+ // "url": "http://localhost:3000/graphql"
+ // }
+ // }
+ // ],
+ "stores": [
+ {
+ "name": "vulcanCache",
+ "inMemory": {
+ "cacheSize": 20000000
+ }
+ }
+ ],
+ // "sessionAuth": {
+ // "store": "embeddedCache",
+ // "header": "Authorization"
+ // },
+ // "frontends": [
+ // {
+ // "host": "127.0.0.1",
+ // "port": 3000,
+ // "endpoint": "/graphql",
+ // "extensions": {
+ // "strip": []
+ // }
+ // }
+ // ],
+ "queryCache": {
+ "publicFullQueryStore": "vulcanCache",
+ "privateFullQueryStore": "vulcanCache"
+ },
+ // "reporting": {
+ // "endpointUrl": "https://engine-report.apollographql.com",
+ // "debugReports": true
+ // },
+ // "logging": {
+ // "level": "DEBUG"
+ // }
+};
+let engine;
+if (engineApiKey) {
+ engine = new Engine({ engineConfig });
+ engine.start();
+}
+
// defaults
const defaultConfig = {
path: '/graphql',
@@ -55,11 +108,14 @@ const createApolloServer = (givenOptions = {}, givenConfig = {}) => {
config.configServer(graphQLServer);
- // Use Optics middleware
- if (process.env.OPTICS_API_KEY) {
- graphQLServer.use(OpticsAgent.middleware());
+ // Use Engine middleware
+ if (engineApiKey) {
+ graphQLServer.use(engine.expressMiddleware());
}
+ // compression
+ graphQLServer.use(compression());
+
// GraphQL endpoint
graphQLServer.use(config.path, bodyParser.json(), graphqlExpress(async (req) => {
let options;
@@ -80,10 +136,9 @@ const createApolloServer = (givenOptions = {}, givenConfig = {}) => {
options.context = {};
}
- // Add Optics to GraphQL context object
- if (process.env.OPTICS_API_KEY) {
- options.context.opticsContext = OpticsAgent.context(req);
- }
+ // enable tracing and caching
+ options.tracing = true;
+ options.cacheControl = true;
// Get the token from the header
if (req.headers.authorization) {
@@ -97,6 +152,10 @@ const createApolloServer = (givenOptions = {}, givenConfig = {}) => {
);
if (user) {
+
+ // identify user to any server-side analytics providers
+ runCallbacks('events.identify', user);
+
const loginToken = Utils.findWhere(user.services.resume.loginTokens, { hashedToken });
const expiresAt = Accounts._tokenExpiration(loginToken.when);
const isExpired = expiresAt < new Date();
@@ -167,10 +226,6 @@ Meteor.startup(() => {
resolvers: GraphQLSchema.resolvers,
});
- if (process.env.OPTICS_API_KEY) {
- OpticsAgent.instrumentSchema(executableSchema)
- }
-
createApolloServer({
schema: executableSchema,
});
diff --git a/packages/vulcan-lib/lib/server/mutations.js b/packages/vulcan-lib/lib/server/mutations.js
index 71acb9b00..27de03a87 100644
--- a/packages/vulcan-lib/lib/server/mutations.js
+++ b/packages/vulcan-lib/lib/server/mutations.js
@@ -60,10 +60,12 @@ export const newMutation = async ({ collection, document, currentUser, validate,
}
- // check if userId field is in the schema and add it to document if needed
- const userIdInSchema = Object.keys(schema).find(key => key === 'userId');
- if (!!userIdInSchema && !newDocument.userId) newDocument.userId = currentUser._id;
-
+ // if user is logged in, check if userId field is in the schema and add it to document if needed
+ if (currentUser) {
+ const userIdInSchema = Object.keys(schema).find(key => key === 'userId');
+ if (!!userIdInSchema && !newDocument.userId) newDocument.userId = currentUser._id;
+ }
+
// run onInsert step
// note: cannot use forEach with async/await.
// See https://stackoverflow.com/a/37576787/649299
@@ -83,17 +85,22 @@ export const newMutation = async ({ collection, document, currentUser, validate,
// }
// run sync callbacks
+ newDocument = await runCallbacks(`${collectionName}.new.before`, newDocument, currentUser);
newDocument = await runCallbacks(`${collectionName}.new.sync`, newDocument, currentUser);
// add _id to document
newDocument._id = collection.insert(newDocument);
+ // run any post-operation sync callbacks
+ newDocument = await runCallbacks(`${collectionName}.new.after`, newDocument, currentUser);
+
// get fresh copy of document from db
+ // TODO: not needed?
const insertedDocument = collection.findOne(newDocument._id);
// run async callbacks
// note: query for document to get fresh document with collection-hooks effects applied
- runCallbacksAsync(`${collectionName}.new.async`, insertedDocument, currentUser, collection);
+ await runCallbacksAsync(`${collectionName}.new.async`, insertedDocument, currentUser, collection);
debug('// new mutation finished:');
debug(newDocument);
@@ -118,9 +125,9 @@ export const editMutation = async ({ collection, documentId, set = {}, unset = {
debug('// editMutation');
debug('// collectionName: ', collection._name);
debug('// documentId: ', documentId);
- debug('// set: ', set);
- debug('// unset: ', unset);
- debug('// document: ', document);
+ // debug('// set: ', set);
+ // debug('// unset: ', unset);
+ // debug('// document: ', document);
if (validate) {
@@ -129,6 +136,8 @@ export const editMutation = async ({ collection, documentId, set = {}, unset = {
modifier = runCallbacks(`${collectionName}.edit.validate`, modifier, document, currentUser, validationErrors);
if (validationErrors.length) {
+ console.log('// validationErrors')
+ console.log(validationErrors)
const EditDocumentValidationError = createError('app.validation_error', {message: 'app.edit_document_validation_error'});
throw new EditDocumentValidationError({data: {break: true, errors: validationErrors}});
}
@@ -154,6 +163,7 @@ export const editMutation = async ({ collection, documentId, set = {}, unset = {
}
// run sync callbacks (on mongo modifier)
+ modifier = await runCallbacks(`${collectionName}.edit.before`, modifier, document, currentUser);
modifier = await runCallbacks(`${collectionName}.edit.sync`, modifier, document, currentUser);
// remove empty modifiers
@@ -168,19 +178,22 @@ export const editMutation = async ({ collection, documentId, set = {}, unset = {
collection.update(documentId, modifier, {removeEmptyStrings: false});
// get fresh copy of document from db
- const newDocument = collection.findOne(documentId);
+ let newDocument = collection.findOne(documentId);
// clear cache if needed
if (collection.loader) {
collection.loader.clear(documentId);
}
+ // run any post-operation sync callbacks
+ newDocument = await runCallbacks(`${collectionName}.edit.after`, newDocument, document, currentUser);
+
// run async callbacks
- runCallbacksAsync(`${collectionName}.edit.async`, newDocument, document, currentUser, collection);
+ await runCallbacksAsync(`${collectionName}.edit.async`, newDocument, document, currentUser, collection);
debug('// edit mutation finished')
debug('// modifier: ', modifier)
- debug('// newDocument: ', newDocument)
+ debug('// edited document: ', newDocument)
debug('//------------------------------------//');
return newDocument;
@@ -211,6 +224,7 @@ export const removeMutation = async ({ collection, documentId, currentUser, vali
}
}
+ await runCallbacks(`${collectionName}.remove.before`, document, currentUser);
await runCallbacks(`${collectionName}.remove.sync`, document, currentUser);
collection.remove(documentId);
@@ -220,7 +234,11 @@ export const removeMutation = async ({ collection, documentId, currentUser, vali
collection.loader.clear(documentId);
}
- runCallbacksAsync(`${collectionName}.remove.async`, document, currentUser, collection);
+ await runCallbacksAsync(`${collectionName}.remove.async`, document, currentUser, collection);
return document;
}
+
+export const newMutator = newMutation;
+export const editMutator = editMutation;
+export const removeMutator = removeMutation;
\ No newline at end of file
diff --git a/packages/vulcan-lib/lib/server/oauth_config.js b/packages/vulcan-lib/lib/server/oauth_config.js
index d0693b0e7..cf3c42f54 100644
--- a/packages/vulcan-lib/lib/server/oauth_config.js
+++ b/packages/vulcan-lib/lib/server/oauth_config.js
@@ -1,4 +1,6 @@
-const services = Meteor.settings.oAuth;
+import { getSetting } from '../modules/settings.js';
+
+const services = getSetting('oAuth');
if (services) {
_.keys(services).forEach(serviceName => {
diff --git a/packages/vulcan-lib/lib/server/site.js b/packages/vulcan-lib/lib/server/site.js
index f0465691c..b4eece7b9 100644
--- a/packages/vulcan-lib/lib/server/site.js
+++ b/packages/vulcan-lib/lib/server/site.js
@@ -1,10 +1,12 @@
import { addGraphQLSchema, addGraphQLResolvers, addGraphQLQuery } from '../modules/graphql.js';
+import { Utils } from '../modules/utils';
import { getSetting, registerSetting } from '../modules/settings.js';
const siteSchema = `
type Site {
title: String
url: String
+ logoUrl: String
}
`;
addGraphQLSchema(siteSchema);
@@ -12,9 +14,13 @@ addGraphQLSchema(siteSchema);
const siteResolvers = {
Query: {
SiteData(root, args, context) {
- return {title: getSetting('title'), url: getSetting('siteUrl', Meteor.absoluteUrl())}
- }
- }
+ return {
+ title: getSetting('title'),
+ url: getSetting('siteUrl', Meteor.absoluteUrl()),
+ logoUrl: Utils.getLogoUrl(),
+ };
+ },
+ },
};
addGraphQLResolvers(siteResolvers);
diff --git a/packages/vulcan-lib/package.js b/packages/vulcan-lib/package.js
index 285af9c01..df718f90c 100644
--- a/packages/vulcan-lib/package.js
+++ b/packages/vulcan-lib/package.js
@@ -1,13 +1,13 @@
Package.describe({
name: 'vulcan:lib',
summary: 'Vulcan libraries.',
- version: '1.8.0',
+ version: '1.8.1',
git: "https://github.com/VulcanJS/Vulcan.git"
});
Package.onUse(function (api) {
- api.versionsFrom('METEOR@1.5.2');
+ api.versionsFrom('METEOR@1.6');
var packages = [
@@ -15,20 +15,20 @@ Package.onUse(function (api) {
// Meteor packages
- 'meteor-base@1.1.0',
+ 'meteor-base',
'mongo',
'tracker',
'service-configuration',
- 'standard-minifiers@1.1.0',
- 'modules@0.9.2',
+ 'standard-minifiers',
+ 'modules',
'accounts-base',
'check',
'http',
'email',
'random',
- 'ecmascript@0.8.2',
+ 'ecmascript',
'service-configuration',
- 'shell-server@0.2.4',
+ 'shell-server',
// Third-party packages
diff --git a/packages/vulcan-newsletter/lib/server/cron.js b/packages/vulcan-newsletter/lib/server/cron.js
index 14342c3b3..c6997c5c3 100644
--- a/packages/vulcan-newsletter/lib/server/cron.js
+++ b/packages/vulcan-newsletter/lib/server/cron.js
@@ -76,7 +76,7 @@ var addJob = function () {
};
Meteor.startup(function () {
- if (getSetting('newsletter.enabled', true)) {
+ if (getSetting('newsletter.enabled', false)) {
addJob();
}
});
diff --git a/packages/vulcan-newsletter/package.js b/packages/vulcan-newsletter/package.js
index c722fe1eb..e2b62c916 100644
--- a/packages/vulcan-newsletter/package.js
+++ b/packages/vulcan-newsletter/package.js
@@ -1,7 +1,7 @@
Package.describe({
name: "vulcan:newsletter",
summary: "Vulcan email newsletter package",
- version: '1.8.0',
+ version: '1.8.1',
git: "https://github.com/VulcanJS/Vulcan.git"
});
@@ -10,8 +10,8 @@ Package.onUse(function (api) {
api.versionsFrom('METEOR@1.5.2');
api.use([
- 'vulcan:core@1.8.0',
- 'vulcan:email@1.8.0'
+ 'vulcan:core@1.8.1',
+ 'vulcan:email@1.8.1'
]);
api.mainModule('lib/server/main.js', 'server');
diff --git a/packages/vulcan-payments/lib/components/Checkout.jsx b/packages/vulcan-payments/lib/components/Checkout.jsx
index e1af58357..91566dd27 100644
--- a/packages/vulcan-payments/lib/components/Checkout.jsx
+++ b/packages/vulcan-payments/lib/components/Checkout.jsx
@@ -4,7 +4,7 @@ import { Components, registerComponent, getSetting, registerSetting, withCurrent
import Users from 'meteor/vulcan:users';
import { intlShape } from 'meteor/vulcan:i18n';
import classNames from 'classnames';
-import withCreateCharge from '../containers/withCreateCharge.js';
+import withPaymentAction from '../containers/withPaymentAction.js';
import { Products } from '../modules/products.js';
const stripeSettings = getSetting('stripe');
@@ -22,7 +22,7 @@ class Checkout extends React.Component {
onToken(token) {
- const {createChargeMutation, productKey, associatedCollection, associatedDocument, callback, properties, currentUser, flash, coupon} = this.props;
+ const {paymentActionMutation, productKey, associatedCollection, associatedDocument, callback, properties, currentUser, flash, coupon} = this.props;
this.setState({ loading: true });
@@ -36,7 +36,7 @@ class Checkout extends React.Component {
coupon,
}
- createChargeMutation(args).then(response => {
+ paymentActionMutation(args).then(response => {
// not needed because we just unmount the whole component:
this.setState({ loading: false });
@@ -72,7 +72,8 @@ class Checkout extends React.Component {
const definedProduct = Products[productKey];
const product = typeof definedProduct === 'function' ? definedProduct(this.props.associatedDocument) : definedProduct || sampleProduct;
- let amount = product.amount;
+ // if product has initial amount, add it to amount (for subscription products)
+ let amount = product.initialAmount ? product.initialAmount + product.amount : product.amount;
if (coupon && product.coupons && product.coupons[coupon]) {
amount -= product.coupons[coupon];
@@ -109,7 +110,7 @@ Checkout.contextTypes = {
const WrappedCheckout = (props) => {
const { fragment, fragmentName } = props;
- const WrappedCheckout = withCreateCharge({fragment, fragmentName})(Checkout);
+ const WrappedCheckout = withPaymentAction({fragment, fragmentName})(Checkout);
return ;
}
diff --git a/packages/vulcan-payments/lib/containers/withCreateCharge.js b/packages/vulcan-payments/lib/containers/withPaymentAction.js
similarity index 51%
rename from packages/vulcan-payments/lib/containers/withCreateCharge.js
rename to packages/vulcan-payments/lib/containers/withPaymentAction.js
index 8184844cc..16b770334 100644
--- a/packages/vulcan-payments/lib/containers/withCreateCharge.js
+++ b/packages/vulcan-payments/lib/containers/withPaymentAction.js
@@ -2,14 +2,14 @@ import { graphql } from 'react-apollo';
import gql from 'graphql-tag';
import { getFragment, getFragmentName } from 'meteor/vulcan:core';
-export default function withCreateCharge(options) {
+export default function withPaymentAction(options) {
const fragment = options.fragment || getFragment(options.fragmentName);
const fragmentName = getFragmentName(fragment) || fragmentName;
const mutation = gql`
- mutation createChargeMutation($token: JSON, $userId: String, $productKey: String, $associatedCollection: String, $associatedId: String, $properties: JSON, $coupon: String) {
- createChargeMutation(token: $token, userId: $userId, productKey: $productKey, associatedCollection: $associatedCollection, associatedId: $associatedId, properties: $properties, coupon: $coupon) {
+ mutation paymentActionMutation($token: JSON, $userId: String, $productKey: String, $associatedCollection: String, $associatedId: String, $properties: JSON, $coupon: String) {
+ paymentActionMutation(token: $token, userId: $userId, productKey: $productKey, associatedCollection: $associatedCollection, associatedId: $associatedId, properties: $properties, coupon: $coupon) {
__typename
...${fragmentName}
}
@@ -18,9 +18,9 @@ export default function withCreateCharge(options) {
`;
return graphql(mutation, {
- alias: 'withCreateCharge',
+ alias: 'withPaymentAction',
props: ({ownProps, mutate}) => ({
- createChargeMutation: (vars) => {
+ paymentActionMutation: (vars) => {
return mutate({
variables: vars,
});
diff --git a/packages/vulcan-payments/lib/modules/charges/schema.js b/packages/vulcan-payments/lib/modules/charges/schema.js
index 5498f6582..d7a197fe1 100644
--- a/packages/vulcan-payments/lib/modules/charges/schema.js
+++ b/packages/vulcan-payments/lib/modules/charges/schema.js
@@ -1,3 +1,4 @@
+import moment from 'moment';
const schema = {
@@ -24,6 +25,16 @@ const schema = {
// custom properties
+ associatedCollection: {
+ type: String,
+ optional: true,
+ },
+
+ associatedId: {
+ type: String,
+ optional: true,
+ },
+
tokenId: {
type: String,
optional: false,
@@ -59,6 +70,30 @@ const schema = {
optional: true,
},
+ // GraphQL only
+
+ createdAtFormatted: {
+ type: String,
+ optional: true,
+ resolveAs: {
+ type: 'String',
+ resolver: (charge, args, context) => {
+ return moment(charge.createdAt).format('dddd, MMMM Do YYYY');
+ }
+ }
+ },
+
+ stripeChargeUrl: {
+ type: String,
+ optional: true,
+ resolveAs: {
+ type: 'String',
+ resolver: (charge, args, context) => {
+ return `https://dashboard.stripe.com/payments/${charge.data.id}`;
+ }
+ }
+ },
+
};
export default schema;
diff --git a/packages/vulcan-payments/lib/modules/custom_fields.js b/packages/vulcan-payments/lib/modules/custom_fields.js
new file mode 100644
index 000000000..de9dc5d70
--- /dev/null
+++ b/packages/vulcan-payments/lib/modules/custom_fields.js
@@ -0,0 +1,11 @@
+import Users from 'meteor/vulcan:users';
+
+Users.addField([
+ {
+ fieldName: 'stripeCustomerId',
+ fieldSchema: {
+ type: String,
+ optional: true,
+ }
+ }
+]);
diff --git a/packages/vulcan-payments/lib/modules/index.js b/packages/vulcan-payments/lib/modules/index.js
index e6c277eaf..f2f945723 100644
--- a/packages/vulcan-payments/lib/modules/index.js
+++ b/packages/vulcan-payments/lib/modules/index.js
@@ -4,5 +4,6 @@ import '../components/Checkout.jsx';
import './routes.js';
import './i18n.js';
+import './custom_fields.js';
export * from './products.js'
\ No newline at end of file
diff --git a/packages/vulcan-payments/lib/server/integrations/stripe.js b/packages/vulcan-payments/lib/server/integrations/stripe.js
index 3f671ea7e..40675b905 100644
--- a/packages/vulcan-payments/lib/server/integrations/stripe.js
+++ b/packages/vulcan-payments/lib/server/integrations/stripe.js
@@ -1,16 +1,21 @@
-import { getSetting, registerSetting, newMutation, editMutation, Collections, runCallbacks, runCallbacksAsync } from 'meteor/vulcan:core';
-// import express from 'express';
+import { getSetting, registerSetting, newMutation, editMutation, Collections, registerCallback, runCallbacks, runCallbacksAsync } from 'meteor/vulcan:core';
+import express from 'express';
import Stripe from 'stripe';
-// import { Picker } from 'meteor/meteorhacks:picker';
-// import bodyParser from 'body-parser';
+import { Picker } from 'meteor/meteorhacks:picker';
+import bodyParser from 'body-parser';
import Charges from '../../modules/charges/collection.js';
import Users from 'meteor/vulcan:users';
import { Products } from '../../modules/products.js';
+import { webAppConnectHandlersUse } from 'meteor/vulcan:core';
registerSetting('stripe', null, 'Stripe settings');
const stripeSettings = getSetting('stripe');
+// initialize Stripe
+const keySecret = Meteor.isDevelopment ? stripeSettings.secretKeyTest : stripeSettings.secretKey;
+const stripe = new Stripe(keySecret);
+
const sampleProduct = {
amount: 10000,
name: 'My Cool Product',
@@ -18,20 +23,21 @@ const sampleProduct = {
currency: 'USD',
}
-// returns a promise:
-export const createCharge = async (args) => {
+/*
+
+Create new Stripe charge
+(returns a promise)
+
+*/
+export const performAction = async (args) => {
let collection, document, returnDocument = {};
- const {token, userId, productKey, associatedCollection, associatedId, properties, coupon } = args;
+ const {token, userId, productKey, associatedCollection, associatedId, properties } = args;
if (!stripeSettings) {
throw new Error('Please fill in your Stripe settings');
}
-
- // initialize Stripe
- const keySecret = Meteor.isDevelopment ? stripeSettings.secretKeyTest : stripeSettings.secretKey;
- const stripe = new Stripe(keySecret);
// if an associated collection name and document id have been provided,
// get the associated collection and document
@@ -45,25 +51,82 @@ export const createCharge = async (args) => {
const definedProduct = Products[productKey];
const product = typeof definedProduct === 'function' ? definedProduct(document) : definedProduct || sampleProduct;
- let amount = product.amount;
-
// get the user performing the transaction
const user = Users.findOne(userId);
- // create Stripe customer
- const customer = await stripe.customers.create({
- email: token.email,
- source: token.id
- });
+ const customer = await getCustomer(user, token.id);
// create metadata object
const metadata = {
userId: userId,
userName: Users.getDisplayName(user),
userProfile: Users.getProfileUrl(user, true),
+ productKey,
...properties
}
+ if (associatedCollection && associatedId) {
+ metadata.associatedCollection = associatedCollection;
+ metadata.associatedId = associatedId;
+ }
+
+ if (product.plan) {
+ // if product has a plan, subscribe user to it
+ returnDocument = await subscribeUser({user, customer, product, collection, document, metadata, args});
+ } else {
+ // else, perform charge
+ returnDocument = await createCharge({user, customer, product, collection, document, metadata, args});
+ }
+
+ return returnDocument;
+}
+
+/*
+
+Retrieve or create a Stripe customer
+
+*/
+export const getCustomer = async (user, id) => {
+
+ let customer;
+
+ try {
+
+ // try retrieving customer from Stripe
+ customer = await stripe.customers.retrieve(user.stripeCustomerId);
+
+ } catch (error) {
+
+ // if user doesn't have a stripeCustomerId; or if id doesn't match up with Stripe
+ // create new customer object
+ const customerOptions = { email: user.email };
+ if (id) { customerOptions.source = id; }
+ customer = await stripe.customers.create(customerOptions);
+
+ // add stripe customer id to user object
+ await editMutation({
+ collection: Users,
+ documentId: user._id,
+ set: {stripeCustomerId: customer.id},
+ validate: false
+ });
+
+ }
+
+ return customer;
+}
+
+/*
+
+Create one-time charge.
+
+*/
+export const createCharge = async ({user, customer, product, collection, document, metadata, args}) => {
+
+ const {token, userId, productKey, associatedId, properties, coupon } = args;
+
+ let amount = product.amount;
+
// apply discount coupon and add it to metadata, if there is one
if (coupon && product.coupons && product.coupons[coupon]) {
amount -= product.coupons[coupon];
@@ -83,19 +146,39 @@ export const createCharge = async (args) => {
// create Stripe charge
const charge = await stripe.charges.create(chargeData);
+ return processCharge({collection, document, charge, args, user})
+
+}
+
+/*
+
+Process charge on Vulcan's side
+
+*/
+export const processCharge = async ({collection, document, charge, args, user}) => {
+
+ let returnDocument = {};
+
+ const {token, userId, productKey, associatedCollection, associatedId, properties, livemode } = args;
+
// create charge document for storing in our own Charges collection
const chargeDoc = {
createdAt: new Date(),
userId,
- tokenId: token.id,
type: 'stripe',
- test: !token.livemode,
+ test: !livemode,
data: charge,
- ip: token.client_ip,
+ associatedCollection,
+ associatedId,
properties,
productKey,
}
+ if (token) {
+ chargeDoc.tokenId = token.id;
+ chargeDoc.test = !token.livemode; // get livemode from token if provided
+ chargeDoc.ip = token.client_ip;
+ }
// insert
const chargeSaved = newMutation({
collection: Charges,
@@ -107,14 +190,16 @@ export const createCharge = async (args) => {
// update the associated document
if (collection && document) {
+ // note: assume a single document can have multiple successive charges associated to it
const chargeIds = document.chargeIds ? [...document.chargeIds, chargeSaved._id] : [chargeSaved._id];
let modifier = {
$set: {chargeIds},
$unset: {}
}
+
// run collection.charge.sync callbacks
- modifier = runCallbacks(`${collection._name}.charge.sync`, modifier, document, chargeDoc);
+ modifier = runCallbacks(`${collection._name}.charge.sync`, modifier, document, chargeDoc, user);
returnDocument = await editMutation({
collection,
@@ -128,37 +213,243 @@ export const createCharge = async (args) => {
}
- runCallbacksAsync(`${collection._name}.charge.async`, returnDocument, chargeDoc);
+ runCallbacksAsync(`${collection._name}.charge.async`, returnDocument, chargeDoc, user);
return returnDocument;
}
/*
-POST route with Picker
+Subscribe a user to a Stripe plan
+
+*/
+export const subscribeUser = async ({user, customer, product, collection, document, metadata, args }) => {
+ try {
+ // if product has an initial cost,
+ // create an invoice item and attach it to the customer first
+ // see https://stripe.com/docs/subscriptions/invoices#adding-invoice-items
+ if (product.initialAmount) {
+ const initialInvoiceItem = await stripe.invoiceItems.create({
+ customer: customer.id,
+ amount: product.initialAmount,
+ currency: product.currency,
+ description: product.initialAmountDescription,
+ });
+ }
+
+ const subscription = await stripe.subscriptions.create({
+ customer: customer.id,
+ items: [
+ { plan: product.plan },
+ ],
+ metadata,
+ });
+
+ } catch (error) {
+ console.log('// Stripe subscribeUser error')
+ console.log(error)
+ }
+}
+
+
+/*
+
+Webhooks with Express
*/
-// Picker.middleware(bodyParser.text());
+// see https://github.com/stripe/stripe-node/blob/master/examples/webhook-signing/express.js
-// Picker.route('/charge', function(params, req, res, next) {
+const app = express()
-// const body = JSON.parse(req.body);
+// Add the raw text body of the request to the `request` object
+function addRawBody(req, res, next) {
+ req.setEncoding('utf8');
-// // console.log(body)
+ var data = '';
-// const { token, userId, productKey, associatedCollection, associatedId } = body;
+ req.on('data', function(chunk) {
+ data += chunk;
+ });
-// createCharge({
-// token,
-// userId,
-// productKey,
-// associatedCollection,
-// associatedId,
-// callback: (charge) => {
-// // return Stripe charge
-// res.end(JSON.stringify(charge));
-// }
-// });
+ req.on('end', function() {
+ req.rawBody = data;
+
+ next();
+ });
+}
+
+app.use(addRawBody);
+
+app.post('/stripe', async function(req, res) {
+
+ console.log('////////////// stripe webhook')
+
+ const sig = req.headers['stripe-signature'];
+
+ try {
+
+ const event = stripe.webhooks.constructEvent(req.rawBody, sig, stripeSettings.endpointSecret);
+
+ console.log('event ///////////////////')
+ console.log(event)
+
+ switch (event.type) {
+
+ case 'charge.succeeded':
+
+ console.log('////// charge succeeded')
+
+ const charge = event.data.object;
+
+ console.log(charge)
+
+ try {
+
+ // look up corresponding invoice
+ const invoice = await stripe.invoices.retrieve(charge.invoice);
+ console.log('////// invoice')
+ console.log(invoice)
+
+ // look up corresponding subscription
+ const subscription = await stripe.subscriptions.retrieve(invoice.subscription);
+ console.log('////// subscription')
+ console.log(subscription)
+
+ const { userId, productKey, associatedCollection, associatedId } = subscription.metadata;
+
+ if (associatedCollection && associatedId) {
+ const collection = _.findWhere(Collections, {_name: associatedCollection});
+ const document = collection.findOne(associatedId);
+
+ const args = {
+ userId,
+ productKey,
+ associatedCollection,
+ associatedId,
+ livemode: subscription.livemode,
+ }
+
+ processCharge({ collection, document, charge, args});
+
+ }
+ } catch (error) {
+ console.log('// Stripe webhook error')
+ console.log(error)
+ }
+
+ break;
+
+ }
+
+ } catch (error) {
+ console.log('///// Stripe webhook error')
+ console.log(error)
+ }
+
+ res.sendStatus(200);
+});
+
+webAppConnectHandlersUse(Meteor.bindEnvironment(app), {name: 'stripe_endpoint', order: 100});
+
+// Picker.middleware(bodyParser.json());
+
+// Picker.route('/stripe', async function(params, req, res, next) {
+
+// console.log('////////////// stripe webhook')
+
+// console.log(req)
+// const sig = req.headers['stripe-signature'];
+// const body = req.body;
+
+// console.log('sig ///////////////////')
+// console.log(sig)
+
+// console.log('body ///////////////////')
+// console.log(body)
+
+// console.log('rawBody ///////////////////')
+// console.log(req.rawBody)
+
+// try {
+// const event = stripe.webhooks.constructEvent(req.rawBody, sig, stripeSettings.endpointSecret);
+// console.log('event ///////////////////')
+// console.log(event)
+// } catch (error) {
+// console.log('///// Stripe webhook error')
+// console.log(error)
+// }
+
+// // Retrieve the request's body and parse it as JSON
+// switch (body.type) {
+
+// case 'charge.succeeded':
+
+// console.log('////// charge succeeded')
+// // console.log(body)
+
+// const charge = body.data.object;
+
+// try {
+
+// // look up corresponding invoice
+// const invoice = await stripe.invoices.retrieve(body.data.object.invoice);
+// console.log('////// invoice')
+
+// // look up corresponding subscription
+// const subscription = await stripe.subscriptions.retrieve(invoice.subscription);
+// console.log('////// subscription')
+// console.log(subscription)
+
+// const { userId, productKey, associatedCollection, associatedId } = subscription.metadata;
+
+// if (associatedCollection && associatedId) {
+// const collection = _.findWhere(Collections, {_name: associatedCollection});
+// const document = collection.findOne(associatedId);
+
+// const args = {
+// userId,
+// productKey,
+// associatedCollection,
+// associatedId,
+// livemode: subscription.livemode,
+// }
+
+// processCharge({ collection, document, charge, args});
+
+// }
+// } catch (error) {
+// console.log('// Stripe webhook error')
+// console.log(error)
+// }
+
+// break;
+
+// }
+
+// res.statusCode = 200;
+// res.end();
// });
+
+Meteor.startup(() => {
+ Collections.forEach(c => {
+ collectionName = c._name.toLowerCase();
+
+ registerCallback({
+ name: `${collectionName}.charge.sync`,
+ description: `Modify the modifier used to add charge ids to the charge's associated document.`,
+ arguments: [{modifier: 'The modifier'}, {document: 'The associated document'}, {charge: 'The charge'}, {currentUser: 'The current user'}],
+ runs: 'sync',
+ returns: 'modifier',
+ });
+
+ registerCallback({
+ name: `${collectionName}.charge.sync`,
+ description: `Perform operations after the charge has succeeded.`,
+ arguments: [{document: 'The associated document'}, {charge: 'The charge'}, {currentUser: 'The current user'}],
+ runs: 'async',
+ });
+
+ })
+})
\ No newline at end of file
diff --git a/packages/vulcan-payments/lib/server/main.js b/packages/vulcan-payments/lib/server/main.js
index 890d522d3..64ed7b383 100644
--- a/packages/vulcan-payments/lib/server/main.js
+++ b/packages/vulcan-payments/lib/server/main.js
@@ -1,4 +1,4 @@
export * from '../modules/index.js';
import './mutations.js';
-import './integrations/stripe.js';
+export * from './integrations/stripe.js';
diff --git a/packages/vulcan-payments/lib/server/mutations.js b/packages/vulcan-payments/lib/server/mutations.js
index 564c56ee8..d638119f3 100644
--- a/packages/vulcan-payments/lib/server/mutations.js
+++ b/packages/vulcan-payments/lib/server/mutations.js
@@ -1,16 +1,16 @@
import { addGraphQLSchema, addGraphQLResolvers, addGraphQLMutation, Collections, addCallback } from 'meteor/vulcan:core';
// import Users from 'meteor/vulcan:users';
-import { createCharge } from '../server/integrations/stripe.js';
+import { performAction } from '../server/integrations/stripe.js';
const resolver = {
Mutation: {
- async createChargeMutation(root, args, context) {
- return await createCharge(args);
+ async paymentActionMutation(root, args, context) {
+ return await performAction(args);
},
},
};
addGraphQLResolvers(resolver);
-addGraphQLMutation('createChargeMutation(token: JSON, userId: String, productKey: String, associatedCollection: String, associatedId: String, properties: JSON, coupon: String) : Chargeable');
+addGraphQLMutation('paymentActionMutation(token: JSON, userId: String, productKey: String, associatedCollection: String, associatedId: String, properties: JSON, coupon: String) : Chargeable');
function CreateChargeableUnionType() {
const chargeableSchema = `
diff --git a/packages/vulcan-payments/package.js b/packages/vulcan-payments/package.js
index 5e3997a1f..031bf29a6 100644
--- a/packages/vulcan-payments/package.js
+++ b/packages/vulcan-payments/package.js
@@ -1,14 +1,14 @@
Package.describe({
name: 'vulcan:payments',
summary: "Vulcan payments package",
- version: '1.8.0',
+ version: '1.8.1',
git: "https://github.com/VulcanJS/Vulcan.git"
});
Package.onUse(function (api) {
api.use([
- 'vulcan:core@1.8.0',
+ 'vulcan:core@1.8.1',
'fourseven:scss@4.5.4',
]);
diff --git a/packages/vulcan-routing/lib/client/routing.jsx b/packages/vulcan-routing/lib/client/routing.jsx
index 5d5639d1f..135e07075 100644
--- a/packages/vulcan-routing/lib/client/routing.jsx
+++ b/packages/vulcan-routing/lib/client/routing.jsx
@@ -8,10 +8,8 @@ import { Meteor } from 'meteor/meteor';
import {
Components,
addRoute,
- addReducer, addMiddleware,
Routes, populateComponentsApp, populateRoutesApp, runCallbacks, initializeFragments,
getRenderContext,
- dynamicLoader,
} from 'meteor/vulcan:lib';
import { RouterClient } from './router.jsx';
@@ -53,24 +51,26 @@ Meteor.startup(() => {
context.addReducer({ apollo: apolloClientReducer });
context.store.reload();
context.store.dispatch({ type: '@@nova/INIT' }) // the first dispatch will generate a newDispatch function from middleware
+ runCallbacks('router.client.rehydrate', { initialState, store: context.store});
},
historyHook(newHistory) {
- const { history } = getRenderContext();
+ let { history } = getRenderContext();
+ history = runCallbacks('router.client.history', history, { newHistory });
return history;
},
wrapperHook(appGenerator) {
const { apolloClient, store } = getRenderContext();
- const app = appGenerator({
+ const app = runCallbacks('router.client.wrapper', appGenerator({
onUpdate: () => {
// the first argument is an item to iterate on, needed by vulcan:lib/callbacks
// note: this item is not used in this specific callback: router.onUpdate
- runCallbacks('router.onUpdate', {}, store, apolloClient);
+ // runCallbacks('router.onUpdate', {}, store, apolloClient);
},
render: applyRouterMiddleware(useScroll((prevRouterProps, nextRouterProps) => {
// if the action is REPLACE, return false so that we don't jump back to top of page
return !(nextRouterProps.location.action === 'REPLACE');
}))
- });
+ }));
return {app};
},
};
diff --git a/packages/vulcan-routing/lib/server/routing.jsx b/packages/vulcan-routing/lib/server/routing.jsx
index ef24021de..9b39929aa 100644
--- a/packages/vulcan-routing/lib/server/routing.jsx
+++ b/packages/vulcan-routing/lib/server/routing.jsx
@@ -1,7 +1,6 @@
import React from 'react';
import Helmet from 'react-helmet';
import { getDataFromTree, ApolloProvider } from 'react-apollo';
-// import styleSheet from 'styled-components/lib/models/StyleSheet';
import { Meteor } from 'meteor/meteor';
@@ -10,7 +9,7 @@ import {
addRoute,
Routes, populateComponentsApp, populateRoutesApp, initializeFragments,
getRenderContext,
- dynamicLoader,
+ runCallbacks,
} from 'meteor/vulcan:lib';
import { RouterServer } from './router.jsx';
@@ -42,30 +41,30 @@ Meteor.startup(() => {
const options = {
historyHook(req, res, newHistory) {
- const { history } = getRenderContext();
+ let { history } = getRenderContext();
+ history = runCallbacks('router.server.history', history, { req, res, newHistory });
return history;
},
wrapperHook(req, res, appGenerator) {
const { apolloClient, store } = getRenderContext();
store.reload();
store.dispatch({ type: '@@nova/INIT' }) // the first dispatch will generate a newDispatch function from middleware
- const app = appGenerator();
+ const app = runCallbacks('router.server.wrapper', appGenerator(), { req, res, store, apolloClient });
return {app};
},
preRender(req, res, app) {
+ runCallbacks('router.server.preRender', { req, res, app });
return Promise.await(getDataFromTree(app));
},
dehydrateHook(req, res) {
- const context = getRenderContext();
+ const context = runCallbacks('router.server.dehydrate', getRenderContext(), { req, res });
return context.apolloClient.store.getState();
},
postRender(req, res) {
- // req.css = styleSheet.sheet ? styleSheet.rules().map(rule => rule.cssText).join('\n') : '';
- // const context = renderContext.get();
- // context.css = req.css;
+ runCallbacks('router.server.postRender', { req, res });
},
htmlHook(req, res, dynamicHead, dynamicBody) {
- const head = Helmet.rewind();
+ const head = runCallbacks('router.server.html', Helmet.rewind(), { req, res, dynamicHead, dynamicBody });
return {
dynamicHead: `${head.title}${head.meta}${head.link}${head.script}${dynamicHead}`,
dynamicBody,
diff --git a/packages/vulcan-routing/package.js b/packages/vulcan-routing/package.js
index 423630959..96875ffa9 100644
--- a/packages/vulcan-routing/package.js
+++ b/packages/vulcan-routing/package.js
@@ -1,7 +1,7 @@
Package.describe({
name: "vulcan:routing",
summary: "Vulcan router package",
- version: '1.8.0',
+ version: '1.8.1',
git: "https://github.com/VulcanJS/Vulcan.git"
});
@@ -10,7 +10,7 @@ Package.onUse(function (api) {
api.versionsFrom('METEOR@1.5.2');
api.use([
- 'vulcan:lib@1.8.0',
+ 'vulcan:lib@1.8.1',
]);
api.mainModule('lib/server/main.js', 'server');
diff --git a/packages/vulcan-subscribe/package.js b/packages/vulcan-subscribe/package.js
index 55b84bbac..249d06393 100644
--- a/packages/vulcan-subscribe/package.js
+++ b/packages/vulcan-subscribe/package.js
@@ -1,7 +1,7 @@
Package.describe({
name: "vulcan:subscribe",
summary: "Subscribe to posts, users, etc. to be notified of new activity",
- version: '1.8.0',
+ version: '1.8.1',
git: "https://github.com/VulcanJS/Vulcan.git"
});
@@ -11,15 +11,14 @@ Package.onUse(function (api) {
api.versionsFrom('METEOR@1.5.2');
api.use([
- 'vulcan:core@1.8.0',
- 'vulcan:notifications@1.8.0',
+ 'vulcan:core@1.8.1',
// dependencies on posts, categories are done with nested imports to reduce explicit dependencies
]);
api.use([
- 'vulcan:posts@1.8.0',
- 'vulcan:comments@1.8.0',
- 'vulcan:categories@1.8.0',
+ 'vulcan:posts@1.8.1',
+ 'vulcan:comments@1.8.1',
+ 'vulcan:categories@1.8.1',
], {weak: true});
api.mainModule("lib/modules.js", ["client"]);
diff --git a/packages/vulcan-users/lib/fragments.js b/packages/vulcan-users/lib/fragments.js
index 9026da738..bd6922271 100644
--- a/packages/vulcan-users/lib/fragments.js
+++ b/packages/vulcan-users/lib/fragments.js
@@ -16,5 +16,6 @@ registerFragment(`
groups
services
avatarUrl
+ pageUrl
}
`);
diff --git a/packages/vulcan-users/lib/permissions.js b/packages/vulcan-users/lib/permissions.js
index c76c9e7ff..0022f1a62 100644
--- a/packages/vulcan-users/lib/permissions.js
+++ b/packages/vulcan-users/lib/permissions.js
@@ -251,7 +251,7 @@ Users.restrictViewableFields = function (user, collection, docOrDocs) {
* @param {Object} field - The field being edited or inserted
*/
Users.canInsertField = function (user, field) {
- if (user && field.insertableBy) {
+ if (field.insertableBy) {
return typeof field.insertableBy === 'function' ? field.insertableBy(user) : Users.isMemberOf(user, field.insertableBy)
}
return false;
@@ -263,7 +263,7 @@ Users.canInsertField = function (user, field) {
* @param {Object} field - The field being edited or inserted
*/
Users.canEditField = function (user, field, document) {
- if (user && field.editableBy) {
+ if (field.editableBy) {
return typeof field.editableBy === 'function' ? field.editableBy(user, document) : Users.isMemberOf(user, field.editableBy)
}
return false;
diff --git a/packages/vulcan-users/lib/server/on_create_user.js b/packages/vulcan-users/lib/server/on_create_user.js
index 7de77241d..929ccd957 100644
--- a/packages/vulcan-users/lib/server/on_create_user.js
+++ b/packages/vulcan-users/lib/server/on_create_user.js
@@ -8,6 +8,8 @@ function onCreateUserCallback (options, user) {
delete options.password; // we don't need to store the password digest
delete options.username; // username is already in user object
+ options = runCallbacks(`users.new.validate.before`, options);
+
// validate options since they can't be trusted
Users.simpleSchema().validate(options);
diff --git a/packages/vulcan-users/package.js b/packages/vulcan-users/package.js
index ba8d4ff56..7876b5c07 100644
--- a/packages/vulcan-users/package.js
+++ b/packages/vulcan-users/package.js
@@ -1,7 +1,7 @@
Package.describe({
name: 'vulcan:users',
summary: 'Vulcan permissions.',
- version: '1.8.0',
+ version: '1.8.1',
git: "https://github.com/VulcanJS/Vulcan.git"
});
@@ -10,7 +10,7 @@ Package.onUse(function (api) {
api.versionsFrom('METEOR@1.5.2');
api.use([
- 'vulcan:lib@1.8.0'
+ 'vulcan:lib@1.8.1'
]);
api.mainModule("lib/server.js", "server");
diff --git a/packages/vulcan-voting/lib/modules/make_voteable.js b/packages/vulcan-voting/lib/modules/make_voteable.js
index 735a6de83..1963a71fa 100644
--- a/packages/vulcan-voting/lib/modules/make_voteable.js
+++ b/packages/vulcan-voting/lib/modules/make_voteable.js
@@ -102,6 +102,7 @@ export const makeVoteable = collection => {
optional: true,
defaultValue: 0,
viewableBy: ['guests'],
+ onInsert: () => 0
}
},
/**
@@ -114,6 +115,7 @@ export const makeVoteable = collection => {
optional: true,
defaultValue: 0,
viewableBy: ['guests'],
+ onInsert: () => 0
}
},
/**
diff --git a/packages/vulcan-voting/lib/modules/vote.js b/packages/vulcan-voting/lib/modules/vote.js
index 3fb8deba0..86f2b4945 100644
--- a/packages/vulcan-voting/lib/modules/vote.js
+++ b/packages/vulcan-voting/lib/modules/vote.js
@@ -1,4 +1,4 @@
-import { runCallbacksAsync, runCallbacks, addCallback } from 'meteor/vulcan:core';
+import { debug, runCallbacksAsync, runCallbacks, addCallback } from 'meteor/vulcan:core';
import { createError } from 'apollo-errors';
import Votes from './votes/collection.js';
import Users from 'meteor/vulcan:users';
@@ -268,10 +268,10 @@ export const performVoteServer = ({ documentId, document, voteType = 'upvote', c
const collectionName = collection.options.collectionName;
document = document || collection.findOne(documentId);
- console.log('// performVoteMutation')
- console.log('collectionName: ', collectionName)
- console.log('document: ', document)
- console.log('voteType: ', voteType)
+ debug('// performVoteMutation')
+ debug('collectionName: ', collectionName)
+ debug('document: ', document)
+ debug('voteType: ', voteType)
const voteOptions = {document, collection, voteType, user, voteId};
diff --git a/packages/vulcan-voting/lib/server/cron.js b/packages/vulcan-voting/lib/server/cron.js
index aba95fcb0..e5e376e47 100644
--- a/packages/vulcan-voting/lib/server/cron.js
+++ b/packages/vulcan-voting/lib/server/cron.js
@@ -1,5 +1,5 @@
import { getSetting, registerSetting, debug } from 'meteor/vulcan:core';
-import { updateScore } from './scoring.js';
+import { /*updateScore,*/ batchUpdateScore } from './scoring.js';
import { VoteableCollections } from '../modules/make_voteable.js';
registerSetting('voting.scoreUpdateInterval', 60, 'How often to update scores, in seconds');
@@ -14,28 +14,32 @@ Meteor.startup(function () {
VoteableCollections.forEach(collection => {
// active items get updated every N seconds
- Meteor.setInterval(function () {
+ Meteor.setInterval(async function () {
- let updatedDocuments = 0;
+ // let updatedDocuments = 0;
// console.log('tick ('+scoreInterval+')');
- collection.find({'inactive': {$ne : true}}).forEach(document => {
- updatedDocuments += updateScore({collection, item: document});
- });
+ // collection.find({'inactive': {$ne : true}}).forEach(document => {
+ // updatedDocuments += updateScore({collection, item: document});
+ // });
+
+ const updatedDocuments = await batchUpdateScore(collection, false, false);
debug(`[vulcan:voting] Updated scores for ${updatedDocuments} active documents in collection ${collection.options.collectionName}`)
}, scoreInterval * 1000);
// inactive items get updated every hour
- Meteor.setInterval(function () {
+ Meteor.setInterval(async function () {
- let updatedDocuments = 0;
+ // let updatedDocuments = 0;
+ //
+ // collection.find({'inactive': true}).forEach(document => {
+ // updatedDocuments += updateScore({collection, item: document});
+ // });
- collection.find({'inactive': true}).forEach(document => {
- updatedDocuments += updateScore({collection, item: document});
- });
+ const updatedDocuments = await batchUpdateScore(collection, true, false);
debug(`[vulcan:voting] Updated scores for ${updatedDocuments} inactive documents in collection ${collection.options.collectionName}`)
diff --git a/packages/vulcan-voting/lib/server/indexes.js b/packages/vulcan-voting/lib/server/indexes.js
new file mode 100644
index 000000000..439c7638e
--- /dev/null
+++ b/packages/vulcan-voting/lib/server/indexes.js
@@ -0,0 +1,3 @@
+import Votes from '../modules/votes/collection.js';
+
+Votes._ensureIndex({ "userId": 1, "documentId": 1 });
diff --git a/packages/vulcan-voting/lib/server/main.js b/packages/vulcan-voting/lib/server/main.js
index 4d5033e3f..ac39cbf91 100644
--- a/packages/vulcan-voting/lib/server/main.js
+++ b/packages/vulcan-voting/lib/server/main.js
@@ -1,5 +1,6 @@
import './graphql.js';
import './cron.js';
import './scoring.js';
+import './indexes.js';
export * from '../modules/index.js';
diff --git a/packages/vulcan-voting/lib/server/scoring.js b/packages/vulcan-voting/lib/server/scoring.js
index 1955e6157..d2231b781 100644
--- a/packages/vulcan-voting/lib/server/scoring.js
+++ b/packages/vulcan-voting/lib/server/scoring.js
@@ -10,17 +10,18 @@ Returns how many documents have been updated (1 or 0).
export const updateScore = ({collection, item, forceUpdate}) => {
// Age Check
-
- // If for some reason item doesn't have a "postedAt" property, abort
- // Or, if post has been scheduled in the future, don't update its score
- if (!item.postedAt || postedAt > now)
- return 0;
-
- const postedAt = item.postedAt.valueOf();
+ const postedAt = item && item.postedAt && item.postedAt.valueOf();
const now = new Date().getTime();
const age = now - postedAt;
const ageInHours = age / (60 * 60 * 1000);
+ // If for some reason item doesn't have a "postedAt" property, abort
+ // Or, if post has been scheduled in the future, don't update its score
+ if (postedAt || postedAt > now)
+ return 0;
+
+
+
// For performance reasons, the database is only updated if the difference between the old score and the new score
// is meaningful enough. To find out, we calculate the "power" of a single vote after n days.
// We assume that after n days, a single vote will not be powerful enough to affect posts' ranking order.
@@ -57,3 +58,105 @@ export const updateScore = ({collection, item, forceUpdate}) => {
}
return 0;
};
+
+export const batchUpdateScore = async (collection, inactive = false, forceUpdate = false) => {
+ // n = number of days after which a single vote will not have a big enough effect to trigger a score update
+ // and posts can become inactive
+ const n = 30;
+ // x = score increase amount of a single vote after n days (for n=100, x=0.000040295)
+ const x = 1/Math.pow(n*24+2,1.3);
+ // time decay factor
+ const f = 1.3
+ const itemsPromise = collection.rawCollection().aggregate([
+ {
+ $match: {
+ $and: [
+ {postedAt: {$exists: true}},
+ {postedAt: {$lte: new Date()}},
+ {inactive: inactive ? true : {$ne: true}}
+ ]
+ }
+ },
+ {
+ $project: {
+ postedAt: 1,
+ baseScore: 1,
+ score: 1,
+ newScore: {
+ $divide: [
+ "$baseScore",
+ {
+ $pow: [
+ {
+ $add: [
+ {
+ $divide: [
+ {
+ $subtract: [new Date(), "$postedAt"] // Age in miliseconds
+ },
+ 60 * 60 * 1000
+ ]
+ }, // Age in hours
+ 2
+ ]
+ },
+ f
+ ]
+ }
+ ]
+ }
+ }
+ },
+ {
+ $project: {
+ postedAt: 1,
+ baseScore: 1,
+ score: 1,
+ newScore: 1,
+ scoreDiffSignificant: {
+ $gt: [
+ {$abs: {$subtract: ["$score", "$newScore"]}},
+ x
+ ]
+ },
+ oldEnough: { // Only set a post as inactive if it's older than n days
+ $gt: [
+ {$divide: [
+ {
+ $subtract: [new Date(), "$postedAt"] // Difference in miliseconds
+ },
+ 60 * 60 * 1000 //Difference in hours
+ ]},
+ n*24]
+ }
+ }
+ },
+ ])
+
+ const items = await itemsPromise;
+ const itemsArray = await items.toArray();
+ let updatedDocumentsCounter = 0;
+ const itemUpdates = _.compact(itemsArray.map(i => {
+ if (forceUpdate || i.scoreDiffSignificant) {
+ updatedDocumentsCounter++;
+ return {
+ updateOne: {
+ filter: {_id: i._id},
+ update: {$set: {score: i.newScore, inactive: false}},
+ upsert: false,
+ }
+ }
+ } else if (i.oldEnough) {
+ // only set a post as inactive if it's older than n days
+ return {
+ updateOne: {
+ filter: {_id: i._id},
+ update: {$set: {inactive: true}},
+ upsert: false,
+ }
+ }
+ }
+ }))
+ if (itemUpdates && itemUpdates.length) {await collection.rawCollection().bulkWrite(itemUpdates, {ordered: false});}
+ return updatedDocumentsCounter;
+}
diff --git a/packages/vulcan-voting/package.js b/packages/vulcan-voting/package.js
index ade8efbc6..75b77af32 100644
--- a/packages/vulcan-voting/package.js
+++ b/packages/vulcan-voting/package.js
@@ -1,7 +1,7 @@
Package.describe({
name: "vulcan:voting",
summary: "Vulcan scoring package.",
- version: '1.8.0',
+ version: '1.8.1',
git: "https://github.com/VulcanJS/Vulcan.git"
});
@@ -10,9 +10,9 @@ Package.onUse(function (api) {
api.versionsFrom('METEOR@1.5.2');
api.use([
- 'fourseven:scss',
- 'vulcan:core@1.8.0',
- 'vulcan:i18n@1.8.0',
+ 'fourseven:scss@4.5.0',
+ 'vulcan:core@1.8.1',
+ 'vulcan:i18n@1.8.1',
], ['client', 'server']);
api.mainModule("lib/server/main.js", "server");
diff --git a/prestart_vulcan.js b/prestart_vulcan.js
new file mode 100644
index 000000000..33747d47c
--- /dev/null
+++ b/prestart_vulcan.js
@@ -0,0 +1,70 @@
+#!/usr/bin/env node
+
+//Functions
+var fs = require('fs');
+function existsSync(filePath){
+ try{
+ fs.statSync(filePath);
+ }catch(err){
+ if(err.code == 'ENOENT') return false;
+ }
+ return true;
+}
+
+function copySync(origin,target){
+ try{
+ fs.writeFileSync(target, fs.readFileSync(origin));
+ }catch(err){
+ if(err.code == 'ENOENT') return false;
+ }
+ return true;
+}
+
+//Add Definition Colors
+const chalk = require('chalk');
+
+//Vulkan letters
+console.log(chalk.gray(' ___ ___ '));
+console.log(chalk.gray(' '+String.fromCharCode(92))+chalk.redBright(String.fromCharCode(92))+chalk.dim.yellow(String.fromCharCode(92))+chalk.gray(String.fromCharCode(92)+' /')+chalk.dim.yellow('/')+chalk.yellowBright('/')+chalk.gray('/'));
+console.log(chalk.gray(' '+String.fromCharCode(92))+chalk.redBright(String.fromCharCode(92))+chalk.dim.yellow(String.fromCharCode(92))+chalk.gray(String.fromCharCode(92))+chalk.gray('/')+chalk.dim.yellow('/')+chalk.yellowBright('/')+chalk.gray('/ Vulcan.js'));
+console.log(chalk.gray(' '+String.fromCharCode(92))+chalk.redBright(String.fromCharCode(92))+chalk.dim.yellow(String.fromCharCode(92))+chalk.dim.yellow('/')+chalk.yellowBright('/')+chalk.gray('/ The full-stack React+GraphQL framework'));
+console.log(chalk.gray(' ──── '));
+
+
+var os = require('os');
+var exec = require('child_process').execSync;
+var options = {
+ encoding: 'utf8'
+};
+//Check Meteor and install if not installed
+var checker = exec("meteor --version", options);
+if (!checker.includes("Meteor ")) {
+console.log("Vulcan requires Meteor but it's not installed. Trying to Install...");
+ //Check platform
+ if (os.platform()=='darwin') {
+ //Mac OS platform
+ console.log("🌋 "+chalk.bold.yellow("Good news you have a Mac and we will install it now! }"));
+ console.log(exec("curl https://install.meteor.com/ | bash", options));
+ } else if (os.platform()=='linux') {
+ //GNU/Linux platform
+ console.log("🌋 "+chalk.bold.yellow("Good news you are on GNU/Linux platform and we will install Meteor now!"));
+ console.log(exec("curl https://install.meteor.com/ | bash", options));
+ } else if (os.platform()=='win32') {
+ //Windows NT platform
+ console.log("> "+chalk.bold.yellow("Oh no! you are on a Windows platform and you will need to install Meteor Manually!"));
+ console.log("> "+chalk.dim.yellow("Meteor for Windows is available at: ")+chalk.redBright("https://install.meteor.com/windows"));
+ process.exit(-1)
+ }
+} else {
+//Check exist file settings and create if not exist
+if (!existsSync("settings.json")) {
+ console.log("> "+chalk.bold.yellow("Creating your own settings.json file...\n"));
+ if (!copySync("sample_settings.json","settings.json")) {
+ console.log("> "+chalk.bold.red("Error Creating your own settings.json file...check files and permissions\n"));
+ process.exit(-1);
+ }
+}
+
+ console.log("> "+chalk.bold.yellow("Happy hacking with Vulcan!"));
+ console.log("> "+chalk.dim.yellow("The docs are available at: ")+chalk.redBright("http://docs.vulcanjs.org"));
+}
|