diff --git a/.editorconfig b/.editorconfig index f7932b46a..566d8e608 100644 --- a/.editorconfig +++ b/.editorconfig @@ -10,7 +10,7 @@ indent_brace_style = 1TBS indent_size = 2 indent_style = space insert_final_newline = true -max_line_length = 80 +max_line_length = 120 quote_type = auto spaces_around_operators = true trim_trailing_whitespace = true diff --git a/.eslintrc b/.eslintrc index 203d9ea36..07b33cf5a 100644 --- a/.eslintrc +++ b/.eslintrc @@ -50,10 +50,15 @@ "meteor": true, "node": true }, + "ecmaFeatures": { + "modules": true, + "jsx": true + }, "plugins": [ "babel", "meteor", - "react" + "react", + "prettier" ], "settings": { "import/resolver": "meteor" diff --git a/.meteor/.id b/.meteor/.id new file mode 100644 index 000000000..d8a3442db --- /dev/null +++ b/.meteor/.id @@ -0,0 +1,7 @@ +# This file contains a token that is unique to your project. +# Check it into your repository along with the rest of this directory. +# It can be used for purposes such as: +# - ensuring you don't accidentally deploy one app on top of another +# - providing package authors with aggregated statistics + +1txv9r51kxht481ysl8bb diff --git a/.meteor/release b/.meteor/release index 099d5b9c0..0fa8d22dd 100644 --- a/.meteor/release +++ b/.meteor/release @@ -1 +1 @@ -METEOR@1.5.2.2 +METEOR@1.6 diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 000000000..2ba986f6f --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,15 @@ +{ + // Use IntelliSense to learn about possible attributes. + // Hover to view descriptions of existing attributes. + // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + { + "type": "chrome", + "request": "launch", + "name": "Launch Chrome against localhost", + "url": "http://localhost:8080", + "webRoot": "${workspaceFolder}" + } + ] +} \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index e796d2df2..8bfffae00 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,9 +1,269 @@ { "name": "Vulcan", - "version": "1.2.0", + "version": "1.8.0", "lockfileVersion": 1, "requires": true, "dependencies": { + "accepts": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.4.tgz", + "integrity": "sha1-hiRnWMfdbSGmR0/whKR0DsBesh8=", + "requires": { + "mime-types": "2.1.17", + "negotiator": "0.6.1" + } + }, + "ajv": { + "version": "5.4.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-5.4.0.tgz", + "integrity": "sha1-MtHPCNvIDEMvQm8S4QslEfa0ZHQ=", + "requires": { + "co": "4.6.0", + "fast-deep-equal": "1.0.0", + "fast-json-stable-stringify": "2.0.0", + "json-schema-traverse": "0.3.1" + } + }, + "apollo-cache-control": { + "version": "0.0.7", + "resolved": "https://registry.npmjs.org/apollo-cache-control/-/apollo-cache-control-0.0.7.tgz", + "integrity": "sha512-DoMTr3uTC5Cx9ukSO63wlzHD15C37FwZuoOZEu+m/UTzVFKQ4PnlBKzwZ0H2+iIwcdSulV0xte6Z3wBe9lHAOA==", + "requires": { + "graphql-extensions": "0.0.5" + } + }, + "apollo-engine": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/apollo-engine/-/apollo-engine-0.5.4.tgz", + "integrity": "sha512-91yqiM1fB33fjvcsIBICy8BUHh2cG9FAIrteCh9QaL7UwJ6aQMV5DSfjNhgP95DEZcPMggKQGLhbW156A7G0mg==", + "requires": { + "apollo-engine-binary-darwin": "0.2017.11-84-gb299b9188", + "apollo-engine-binary-linux": "0.2017.11-84-gb299b9188", + "apollo-engine-binary-windows": "0.2017.11-84-gb299b9188", + "request": "2.83.0", + "stream-line-wrapper": "0.1.1" + } + }, + "apollo-engine-binary-darwin": { + "version": "0.2017.11-84-gb299b9188", + "resolved": "https://registry.npmjs.org/apollo-engine-binary-darwin/-/apollo-engine-binary-darwin-0.2017.11-84-gb299b9188.tgz", + "integrity": "sha512-8zKIFo6ldSwT1npHU4gjHMDEJQuN/CG3MCnx5xY5MGSSkqlqNZZ8njYgXe4qLEjewLMwRTXapcnCw7E2+H1RYQ==", + "optional": true + }, + "apollo-engine-binary-linux": { + "version": "0.2017.11-84-gb299b9188", + "resolved": "https://registry.npmjs.org/apollo-engine-binary-linux/-/apollo-engine-binary-linux-0.2017.11-84-gb299b9188.tgz", + "integrity": "sha512-Y+DYYoR24yi73+Kt03Nr7IXNoMJw6faEgdUpysMdnkIdmqaFfcKj3KH0auzVBhPyVcJo+iRTKqXdnMzjnQxrsg==", + "optional": true + }, + "apollo-engine-binary-windows": { + "version": "0.2017.11-84-gb299b9188", + "resolved": "https://registry.npmjs.org/apollo-engine-binary-windows/-/apollo-engine-binary-windows-0.2017.11-84-gb299b9188.tgz", + "integrity": "sha512-ecpP1HrlP+eb5mNQuz7ObzMWtGJA78UrPlzGRes1KiKJ/c8e1UrrAWI/wuI0Ry7fIKYA6dUzxJ4fHR5TEnMAVA==", + "optional": true + }, + "apollo-server-core": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/apollo-server-core/-/apollo-server-core-1.2.0.tgz", + "integrity": "sha1-6FHEdESZG2+J+IUpI3B2uD4B6O4=", + "requires": { + "apollo-cache-control": "0.0.7", + "apollo-tracing": "0.1.1", + "graphql-extensions": "0.0.5" + } + }, + "apollo-server-express": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/apollo-server-express/-/apollo-server-express-1.2.0.tgz", + "integrity": "sha1-AmsStFO47KxgRLIFtqhf5Zb7X54=", + "requires": { + "apollo-server-core": "1.2.0", + "apollo-server-module-graphiql": "1.2.0" + } + }, + "apollo-server-module-graphiql": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/apollo-server-module-graphiql/-/apollo-server-module-graphiql-1.2.0.tgz", + "integrity": "sha1-iZ2E87dHeV27/INUqlFiLvA4FRw=" + }, + "apollo-tracing": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/apollo-tracing/-/apollo-tracing-0.1.1.tgz", + "integrity": "sha512-OrL0SYpmwNs6R339y7Is6PppOkyooMB1iLSN+HAp1FdBycQ88SqVV5Dqjxb4Du+TrMyyJLHfR5BAENZSFQyWGQ==", + "requires": { + "graphql-extensions": "0.0.5" + } + }, + "asn1": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/asn1/-/asn1-0.2.3.tgz", + "integrity": "sha1-2sh4dxPJlmhJ/IGAd36+nB3fO4Y=" + }, + "assert-plus": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/assert-plus/-/assert-plus-1.0.0.tgz", + "integrity": "sha1-8S4PPF13sLHN2RRpQuTpbB5N1SU=" + }, + "async": { + "version": "0.2.10", + "resolved": "https://registry.npmjs.org/async/-/async-0.2.10.tgz", + "integrity": "sha1-trvgsGdLnXGXCMo43owjfLUmw9E=" + }, + "asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha1-x57Zf380y48robyXkLzDZkdLS3k=" + }, + "aws-sign2": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/aws-sign2/-/aws-sign2-0.7.0.tgz", + "integrity": "sha1-tG6JCTSpWR8tL2+G1+ap8bP+dqg=" + }, + "aws4": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/aws4/-/aws4-1.6.0.tgz", + "integrity": "sha1-g+9cqGCysy5KDe7e6MdxudtXRx4=" + }, + "bcrypt-pbkdf": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.1.tgz", + "integrity": "sha1-Y7xdy2EzG5K8Bf1SiVPDNGKgb40=", + "optional": true, + "requires": { + "tweetnacl": "0.14.5" + } + }, + "boom": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/boom/-/boom-4.3.1.tgz", + "integrity": "sha1-T4owBctKfjiJ90kDD9JbluAdLjE=", + "requires": { + "hoek": "4.2.0" + } + }, + "bytes": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.0.0.tgz", + "integrity": "sha1-0ygVQE1olpn4Wk6k+odV3ROpYEg=" + }, + "caseless": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/caseless/-/caseless-0.12.0.tgz", + "integrity": "sha1-G2gcIf+EAzyCZUMJBolCDRhxUdw=" + }, + "co": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz", + "integrity": "sha1-bqa989hTrlTMuOR7+gvz+QMfsYQ=" + }, + "combined-stream": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.5.tgz", + "integrity": "sha1-k4NwpXtKUd6ix3wV1cX9+JUWQAk=", + "requires": { + "delayed-stream": "1.0.0" + } + }, + "compressible": { + "version": "2.0.12", + "resolved": "https://registry.npmjs.org/compressible/-/compressible-2.0.12.tgz", + "integrity": "sha1-xZpcmdt2dn6YdlAOJx72OzSTvWY=", + "requires": { + "mime-db": "1.30.0" + } + }, + "compression": { + "version": "1.7.1", + "resolved": "https://registry.npmjs.org/compression/-/compression-1.7.1.tgz", + "integrity": "sha1-7/JgPvwuIs+G810uuTWJ+YdTc9s=", + "requires": { + "accepts": "1.3.4", + "bytes": "3.0.0", + "compressible": "2.0.12", + "debug": "2.6.9", + "on-headers": "1.0.1", + "safe-buffer": "5.1.1", + "vary": "1.1.2" + } + }, + "core-js": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/core-js/-/core-js-2.5.1.tgz", + "integrity": "sha1-rmh03GaTd4m4B1T/VCjfZoGcpQs=" + }, + "core-util-is": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz", + "integrity": "sha1-tf1UIgqivFq1eqtxQMlAdUUDwac=" + }, + "cryptiles": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/cryptiles/-/cryptiles-3.1.2.tgz", + "integrity": "sha1-qJ+7Ig9c4l7FboxKqKT9e1sNKf4=", + "requires": { + "boom": "5.2.0" + }, + "dependencies": { + "boom": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/boom/-/boom-5.2.0.tgz", + "integrity": "sha512-Z5BTk6ZRe4tXXQlkqftmsAUANpXmuwlsF5Oov8ThoMbQRzdGTA1ngYRW160GexgOgjsFOKJz0LYhoNi+2AMBUw==", + "requires": { + "hoek": "4.2.0" + } + } + } + }, + "dashdash": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/dashdash/-/dashdash-1.14.1.tgz", + "integrity": "sha1-hTz6D3y+L+1d4gMmuN1YEDX24vA=", + "requires": { + "assert-plus": "1.0.0" + } + }, + "debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "requires": { + "ms": "2.0.0" + } + }, + "delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha1-3zrhmayt+31ECqrgsp4icrJOxhk=" + }, + "ecc-jsbn": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/ecc-jsbn/-/ecc-jsbn-0.1.1.tgz", + "integrity": "sha1-D8c6ntXw1Tw4GTOYUj735UN3dQU=", + "optional": true, + "requires": { + "jsbn": "0.1.1" + } + }, + "extend": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.1.tgz", + "integrity": "sha1-p1Xqe8Gt/MWjHOfnYtuq3F5jZEQ=" + }, + "extsprintf": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/extsprintf/-/extsprintf-1.3.0.tgz", + "integrity": "sha1-lpGEQOMEGnpBT4xS48V06zw+HgU=" + }, + "fast-deep-equal": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-1.0.0.tgz", + "integrity": "sha1-liVqO8l1WV6zbYLpkp0GDYk0Of8=" + }, + "fast-json-stable-stringify": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.0.0.tgz", + "integrity": "sha1-1RQsDK7msRifh9OnYREGT4bIu/I=" + }, "flat": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/flat/-/flat-4.0.0.tgz", @@ -12,10 +272,297 @@ "is-buffer": "1.1.5" } }, + "forever-agent": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/forever-agent/-/forever-agent-0.6.1.tgz", + "integrity": "sha1-+8cfDEGt6zf5bFd60e1C2P2sypE=" + }, + "form-data": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-2.3.1.tgz", + "integrity": "sha1-b7lPvXGIUwbXPRXMSX/kzE7NRL8=", + "requires": { + "asynckit": "0.4.0", + "combined-stream": "1.0.5", + "mime-types": "2.1.17" + } + }, + "getpass": { + "version": "0.1.7", + "resolved": "https://registry.npmjs.org/getpass/-/getpass-0.1.7.tgz", + "integrity": "sha1-Xv+OPmhNVprkyysSgmBOi6YhSfo=", + "requires": { + "assert-plus": "1.0.0" + } + }, + "graphql-extensions": { + "version": "0.0.5", + "resolved": "https://registry.npmjs.org/graphql-extensions/-/graphql-extensions-0.0.5.tgz", + "integrity": "sha512-IbgYhKIyI60Nio/uJjkkiXaOZ2fI8ynAyzcA/okD0iuKzBdWX4Tn6tidMLgd16Bf2v3TtNnyXnN0F2BJDs6e4A==", + "requires": { + "core-js": "2.5.1", + "source-map-support": "0.5.0" + } + }, + "har-schema": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/har-schema/-/har-schema-2.0.0.tgz", + "integrity": "sha1-qUwiJOvKwEeCoNkDVSHyRzW37JI=" + }, + "har-validator": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/har-validator/-/har-validator-5.0.3.tgz", + "integrity": "sha1-ukAsJmGU8VlW7xXg/PJCmT9qff0=", + "requires": { + "ajv": "5.4.0", + "har-schema": "2.0.0" + } + }, + "hawk": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/hawk/-/hawk-6.0.2.tgz", + "integrity": "sha512-miowhl2+U7Qle4vdLqDdPt9m09K6yZhkLDTWGoUiUzrQCn+mHHSmfJgAyGaLRZbPmTqfFFjRV1QWCW0VWUJBbQ==", + "requires": { + "boom": "4.3.1", + "cryptiles": "3.1.2", + "hoek": "4.2.0", + "sntp": "2.1.0" + } + }, + "hoek": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/hoek/-/hoek-4.2.0.tgz", + "integrity": "sha512-v0XCLxICi9nPfYrS9RL8HbYnXi9obYAeLbSP00BmnZwCK9+Ih9WOjoZ8YoHCoav2csqn4FOz4Orldsy2dmDwmQ==" + }, + "http-signature": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/http-signature/-/http-signature-1.2.0.tgz", + "integrity": "sha1-muzZJRFHcvPZW2WmCruPfBj7rOE=", + "requires": { + "assert-plus": "1.0.0", + "jsprim": "1.4.1", + "sshpk": "1.13.1" + } + }, "is-buffer": { "version": "1.1.5", "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-1.1.5.tgz", "integrity": "sha1-Hzsm72E7IUuIy8ojzGwB2Hlh7sw=" + }, + "is-typedarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-typedarray/-/is-typedarray-1.0.0.tgz", + "integrity": "sha1-5HnICFjfDBsR3dppQPlgEfzaSpo=" + }, + "isstream": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/isstream/-/isstream-0.1.2.tgz", + "integrity": "sha1-R+Y/evVa+m+S4VAOaQ64uFKcCZo=" + }, + "jsbn": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/jsbn/-/jsbn-0.1.1.tgz", + "integrity": "sha1-peZUwuWi3rXyAdls77yoDA7y9RM=", + "optional": true + }, + "json-schema": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/json-schema/-/json-schema-0.2.3.tgz", + "integrity": "sha1-tIDIkuWaLwWVTOcnvT8qTogvnhM=" + }, + "json-schema-traverse": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.3.1.tgz", + "integrity": "sha1-NJptRMU6Ud6JtAgFxdXlm0F9M0A=" + }, + "json-stringify-safe": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz", + "integrity": "sha1-Epai1Y/UXxmg9s4B1lcB4sc1tus=" + }, + "jsprim": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/jsprim/-/jsprim-1.4.1.tgz", + "integrity": "sha1-MT5mvB5cwG5Di8G3SZwuXFastqI=", + "requires": { + "assert-plus": "1.0.0", + "extsprintf": "1.3.0", + "json-schema": "0.2.3", + "verror": "1.10.0" + } + }, + "mime-db": { + "version": "1.30.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.30.0.tgz", + "integrity": "sha1-dMZD2i3Z1qRTmZY0ZbJtXKfXHwE=" + }, + "mime-types": { + "version": "2.1.17", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.17.tgz", + "integrity": "sha1-Cdejk/A+mVp5+K+Fe3Cp4KsWVXo=", + "requires": { + "mime-db": "1.30.0" + } + }, + "ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=" + }, + "negotiator": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.1.tgz", + "integrity": "sha1-KzJxhOiZIQEXeyhWP7XnECrNDKk=" + }, + "oauth-sign": { + "version": "0.8.2", + "resolved": "https://registry.npmjs.org/oauth-sign/-/oauth-sign-0.8.2.tgz", + "integrity": "sha1-Rqarfwrq2N6unsBWV4C31O/rnUM=" + }, + "on-headers": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.0.1.tgz", + "integrity": "sha1-ko9dD0cNSTQmUepnlLCFfBAGk/c=" + }, + "performance-now": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz", + "integrity": "sha1-Ywn04OX6kT7BxpMHrjZLSzd8nns=" + }, + "punycode": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-1.4.1.tgz", + "integrity": "sha1-wNWmOycYgArY4esPpSachN1BhF4=" + }, + "qs": { + "version": "6.5.1", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.5.1.tgz", + "integrity": "sha512-eRzhrN1WSINYCDCbrz796z37LOe3m5tmW7RQf6oBntukAG1nmovJvhnwHHRMAfeoItc1m2Hk02WER2aQ/iqs+A==" + }, + "request": { + "version": "2.83.0", + "resolved": "https://registry.npmjs.org/request/-/request-2.83.0.tgz", + "integrity": "sha512-lR3gD69osqm6EYLk9wB/G1W/laGWjzH90t1vEa2xuxHD5KUrSzp9pUSfTm+YC5Nxt2T8nMPEvKlhbQayU7bgFw==", + "requires": { + "aws-sign2": "0.7.0", + "aws4": "1.6.0", + "caseless": "0.12.0", + "combined-stream": "1.0.5", + "extend": "3.0.1", + "forever-agent": "0.6.1", + "form-data": "2.3.1", + "har-validator": "5.0.3", + "hawk": "6.0.2", + "http-signature": "1.2.0", + "is-typedarray": "1.0.0", + "isstream": "0.1.2", + "json-stringify-safe": "5.0.1", + "mime-types": "2.1.17", + "oauth-sign": "0.8.2", + "performance-now": "2.1.0", + "qs": "6.5.1", + "safe-buffer": "5.1.1", + "stringstream": "0.0.5", + "tough-cookie": "2.3.3", + "tunnel-agent": "0.6.0", + "uuid": "3.1.0" + } + }, + "safe-buffer": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.1.tgz", + "integrity": "sha512-kKvNJn6Mm93gAczWVJg7wH+wGYWNrDHdWvpUmHyEsgCtIwwo3bqPtV4tR5tuPaUhTOo/kvhVwd8XwwOllGYkbg==" + }, + "sntp": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/sntp/-/sntp-2.1.0.tgz", + "integrity": "sha512-FL1b58BDrqS3A11lJ0zEdnJ3UOKqVxawAkF3k7F0CVN7VQ34aZrV+G8BZ1WC9ZL7NyrwsW0oviwsWDgRuVYtJg==", + "requires": { + "hoek": "4.2.0" + } + }, + "source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==" + }, + "source-map-support": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.0.tgz", + "integrity": "sha512-vUoN3I7fHQe0R/SJLKRdKYuEdRGogsviXFkHHo17AWaTGv17VLnxw+CFXvqy+y4ORZ3doWLQcxRYfwKrsd/H7Q==", + "requires": { + "source-map": "0.6.1" + } + }, + "sshpk": { + "version": "1.13.1", + "resolved": "https://registry.npmjs.org/sshpk/-/sshpk-1.13.1.tgz", + "integrity": "sha1-US322mKHFEMW3EwY/hzx2UBzm+M=", + "requires": { + "asn1": "0.2.3", + "assert-plus": "1.0.0", + "bcrypt-pbkdf": "1.0.1", + "dashdash": "1.14.1", + "ecc-jsbn": "0.1.1", + "getpass": "0.1.7", + "jsbn": "0.1.1", + "tweetnacl": "0.14.5" + } + }, + "stream-line-wrapper": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/stream-line-wrapper/-/stream-line-wrapper-0.1.1.tgz", + "integrity": "sha1-Pivh02jGNW+Qru9keGaD8+7j7qc=", + "requires": { + "async": "0.2.10" + } + }, + "stringstream": { + "version": "0.0.5", + "resolved": "https://registry.npmjs.org/stringstream/-/stringstream-0.0.5.tgz", + "integrity": "sha1-TkhM1N5aC7vuGORjB3EKioFiGHg=" + }, + "tough-cookie": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-2.3.3.tgz", + "integrity": "sha1-C2GKVWW23qkL80JdBNVe3EdadWE=", + "requires": { + "punycode": "1.4.1" + } + }, + "tunnel-agent": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", + "integrity": "sha1-J6XeoGs2sEoKmWZ3SykIaPD8QP0=", + "requires": { + "safe-buffer": "5.1.1" + } + }, + "tweetnacl": { + "version": "0.14.5", + "resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-0.14.5.tgz", + "integrity": "sha1-WuaBd/GS1EViadEIr6k/+HQ/T2Q=", + "optional": true + }, + "uuid": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-3.1.0.tgz", + "integrity": "sha512-DIWtzUkw04M4k3bf1IcpS2tngXEL26YUD2M0tMDUpnUrz2hgzUBlD55a4FjdLGPvfHxS6uluGWvaVEqgBcVa+g==" + }, + "vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha1-IpnwLG3tMNSllhsLn3RSShj2NPw=" + }, + "verror": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/verror/-/verror-1.10.0.tgz", + "integrity": "sha1-OhBcoXBTr1XW4nDB+CiGguGNpAA=", + "requires": { + "assert-plus": "1.0.0", + "core-util-is": "1.0.2", + "extsprintf": "1.3.0" + } } } } diff --git a/package.json b/package.json index 5e5c4c948..89b311a70 100644 --- a/package.json +++ b/package.json @@ -1,22 +1,26 @@ { "name": "Vulcan", - "version": "1.8.0", + "version": "1.8.1", "engines": { "npm": "^3.0" }, "scripts": { - "prestart": "sh prestart_vulcan.sh", + "prestart": "node prestart_vulcan.js", "start": "meteor --settings settings.json", "lint": "eslint --cache --ext .jsx,js packages" }, "dependencies": { "analytics-node": "^2.1.1", "apollo-client": "^1.2.2", + "apollo-engine": "^0.5.4", "apollo-errors": "^1.4.0", + "apollo-server-express": "^1.2.0", "babel-runtime": "^6.18.0", "bcrypt": "^0.8.7", - "body-parser": "^1.15.2", + "body-parser": "^1.18.2", + "chalk": "2.2.0", "classnames": "^2.2.3", + "compression": "^1.7.1", "cookie-parser": "^1.4.3", "cross-fetch": "^0.0.8", "crypto-js": "^3.1.9-1", @@ -30,7 +34,6 @@ "graphql": "^0.9.6", "graphql-anywhere": "^3.0.1", "graphql-date": "^1.0.2", - "graphql-server-express": "^0.6.0", "graphql-tag": "^2.0.0", "graphql-tools": "^0.10.1", "graphql-type-json": "^0.1.4", @@ -54,7 +57,7 @@ "react": "^15.6.1", "react-addons-pure-render-mixin": "^15.4.1", "react-apollo": "^1.1.1", - "react-bootstrap": "^0.30.7", + "react-bootstrap": "^0.31.3", "react-bootstrap-datetimepicker": "0.0.22", "react-cookie": "^0.4.6", "react-datetime": "^2.3.2", diff --git a/packages/_boilerplate-generator/package.js b/packages/_boilerplate-generator/package.js index 8b1a607a7..51171b585 100644 --- a/packages/_boilerplate-generator/package.js +++ b/packages/_boilerplate-generator/package.js @@ -1,7 +1,7 @@ Package.describe({ name: "boilerplate-generator", summary: "Generates the boilerplate html from program's manifest", - version: '1.2.0' + version: '1.3.0' }); Package.onUse(api => { @@ -11,4 +11,4 @@ Package.onUse(api => { ], 'server'); api.mainModule('generator.js', 'server'); api.export('Boilerplate', 'server'); -}); +}); \ No newline at end of file diff --git a/packages/example-forum/lib/modules/comments/fragments.js b/packages/example-forum/lib/modules/comments/fragments.js index 7b781295d..ce9764114 100644 --- a/packages/example-forum/lib/modules/comments/fragments.js +++ b/packages/example-forum/lib/modules/comments/fragments.js @@ -25,6 +25,12 @@ registerFragment(` ...UsersMinimumInfo } } - # vulcan:voting + # voting + currentUserVotes{ + ...VoteFragment + } + baseScore + score } `); + diff --git a/packages/example-forum/lib/modules/posts/schema.js b/packages/example-forum/lib/modules/posts/schema.js index 0d31ed1ec..84442e9e1 100644 --- a/packages/example-forum/lib/modules/posts/schema.js +++ b/packages/example-forum/lib/modules/posts/schema.js @@ -198,10 +198,15 @@ const schema = { insertableBy: ['admins'], editableBy: ['admins'], control: 'select', - onInsert: document => { - if (document.userId && !document.status) { - const user = Users.findOne(document.userId); - return Posts.getDefaultStatus(user); + onInsert: (document, currentUser) => { + if (!document.status) { + return Posts.getDefaultStatus(currentUser); + } + }, + onEdit: (modifier, document, currentUser) => { + // if for some reason post status has been removed, give it default status + if (modifier.$unset && modifier.$unset.status) { + return Posts.getDefaultStatus(currentUser); } }, form: { @@ -367,8 +372,8 @@ const schema = { optional: true, resolveAs: { type: 'String', - resolver: (booking, args, context) => { - return moment(booking.endAt).format('dddd, MMMM Do YYYY'); + resolver: (post, args, context) => { + return moment(post.postedAt).format('dddd, MMMM Do YYYY'); } } }, @@ -403,6 +408,39 @@ const schema = { } }, + emailShareUrl: { + type: String, + optional: true, + resolveAs: { + type: 'String', + resolver: (post) => { + return Posts.getEmailShareUrl(post); + } + } + }, + + twitterShareUrl: { + type: String, + optional: true, + resolveAs: { + type: 'String', + resolver: (post) => { + return Posts.getTwitterShareUrl(post); + } + } + }, + + facebookShareUrl: { + type: String, + optional: true, + resolveAs: { + type: 'String', + resolver: (post) => { + return Posts.getFacebookShareUrl(post); + } + } + }, + }; export default schema; diff --git a/packages/example-forum/lib/server/comments/callbacks/voting.js b/packages/example-forum/lib/server/comments/callbacks/voting.js index 11bae5fc4..cbac25c23 100644 --- a/packages/example-forum/lib/server/comments/callbacks/voting.js +++ b/packages/example-forum/lib/server/comments/callbacks/voting.js @@ -9,7 +9,8 @@ import { performVoteServer } from 'meteor/vulcan:voting'; */ function CommentsNewUpvoteOwnComment(comment) { var commentAuthor = Users.findOne(comment.userId); - return {...comment, ...performVoteServer({ document: comment, voteType: 'upvote', collection: Comments, user: commentAuthor })}; + const votedComent = performVoteServer({ document: comment, voteType: 'upvote', collection: Comments, user: commentAuthor }) + return {...comment, ...votedComent}; } -addCallback('comments.new.async', CommentsNewUpvoteOwnComment); \ No newline at end of file +addCallback('comments.new.after', CommentsNewUpvoteOwnComment); \ No newline at end of file diff --git a/packages/example-forum/lib/server/posts/callbacks/other.js b/packages/example-forum/lib/server/posts/callbacks/other.js index 0bcf9aa7e..ce7fdf4b9 100644 --- a/packages/example-forum/lib/server/posts/callbacks/other.js +++ b/packages/example-forum/lib/server/posts/callbacks/other.js @@ -96,7 +96,7 @@ Posts.increaseClicks = (post, ip) => { const existingClickEvent = Events.findOne({name: 'click', 'properties.postId': post._id, 'properties.ip': ip}); if(!existingClickEvent) { - Events.log(clickEvent); + // Events.log(clickEvent); // Sidebar only: don't log event return Posts.update(post._id, { $inc: { clickCount: 1 }}); } } else { @@ -112,4 +112,14 @@ function PostsClickTracking(post, ip) { // note: this event is not sent to segment cause we cannot access the current user // in our server-side route /out -> sending an event would create a new anonymous // user: the free limit of 1,000 unique users per month would be reached quickly -addCallback('posts.click.async', PostsClickTracking); \ No newline at end of file +addCallback('posts.click.async', PostsClickTracking); + +////////////////////////////////////////////////////// +// posts.approve.sync // +////////////////////////////////////////////////////// + +function PostsApprovedSetPostedAt (modifier, post) { + modifier.postedAt = new Date(); + return modifier; +} +addCallback('posts.approve.sync', PostsApprovedSetPostedAt); diff --git a/packages/example-forum/lib/server/posts/callbacks/voting.js b/packages/example-forum/lib/server/posts/callbacks/voting.js index d238241db..0694ac821 100644 --- a/packages/example-forum/lib/server/posts/callbacks/voting.js +++ b/packages/example-forum/lib/server/posts/callbacks/voting.js @@ -17,4 +17,4 @@ function PostsNewUpvoteOwnPost(post) { return {...post, ...performVoteServer({ document: post, voteType: 'upvote', collection: Posts, user: postAuthor })}; } -addCallback('posts.new.async', PostsNewUpvoteOwnPost); +addCallback('posts.new.after', PostsNewUpvoteOwnPost); diff --git a/packages/example-forum/package.js b/packages/example-forum/package.js index cfd5af0ef..1799a0bbd 100644 --- a/packages/example-forum/package.js +++ b/packages/example-forum/package.js @@ -1,7 +1,7 @@ Package.describe({ name: "example-forum", summary: "Vulcan forum package", - version: '1.8.0', + version: '1.8.1', git: "https://github.com/VulcanJS/Vulcan.git" }); @@ -14,11 +14,11 @@ Package.onUse(function (api) { 'fourseven:scss@4.5.0', // vulcan core - 'vulcan:core@1.8.0', + 'vulcan:core@1.8.1', // vulcan packages - 'vulcan:voting@1.8.0', - 'vulcan:accounts@1.8.0', + 'vulcan:voting@1.8.1', + 'vulcan:accounts@1.8.1', 'vulcan:email', 'vulcan:forms', 'vulcan:newsletter', diff --git a/packages/example-instagram/lib/components/common/Header.jsx b/packages/example-instagram/lib/components/common/Header.jsx index 09f4c49ce..da2883252 100644 --- a/packages/example-instagram/lib/components/common/Header.jsx +++ b/packages/example-instagram/lib/components/common/Header.jsx @@ -11,7 +11,7 @@ component (if the "component" prop is specified). import React from 'react'; import { registerComponent, Components, withCurrentUser } from 'meteor/vulcan:core'; import Users from 'meteor/vulcan:users'; -import PicsNewForm from '../pics/PicsNewForm'; +// import PicsNewForm from '../pics/PicsNewForm'; // navigation bar component when the user is logged in @@ -33,7 +33,7 @@ const NavLoggedIn = ({currentUser}) => - + @@ -71,4 +71,4 @@ const Header = ({currentUser}) => -registerComponent('Header', Header, withCurrentUser); \ No newline at end of file +registerComponent('Header', Header, withCurrentUser); diff --git a/packages/example-membership/lib/components/common/Header.jsx b/packages/example-membership/lib/components/common/Header.jsx index 09f4c49ce..da2883252 100644 --- a/packages/example-membership/lib/components/common/Header.jsx +++ b/packages/example-membership/lib/components/common/Header.jsx @@ -11,7 +11,7 @@ component (if the "component" prop is specified). import React from 'react'; import { registerComponent, Components, withCurrentUser } from 'meteor/vulcan:core'; import Users from 'meteor/vulcan:users'; -import PicsNewForm from '../pics/PicsNewForm'; +// import PicsNewForm from '../pics/PicsNewForm'; // navigation bar component when the user is logged in @@ -33,7 +33,7 @@ const NavLoggedIn = ({currentUser}) => - + @@ -71,4 +71,4 @@ const Header = ({currentUser}) => -registerComponent('Header', Header, withCurrentUser); \ No newline at end of file +registerComponent('Header', Header, withCurrentUser); diff --git a/packages/example-permissions/lib/components/common/Header.jsx b/packages/example-permissions/lib/components/common/Header.jsx index 881876f54..647db5063 100644 --- a/packages/example-permissions/lib/components/common/Header.jsx +++ b/packages/example-permissions/lib/components/common/Header.jsx @@ -11,7 +11,7 @@ component (if the "component" prop is specified). import React from 'react'; import { registerComponent, Components, withCurrentUser } from 'meteor/vulcan:core'; import Users from 'meteor/vulcan:users'; -import PicsNewForm from '../pics/PicsNewForm'; +// import PicsNewForm from '../pics/PicsNewForm'; // navigation bar component when the user is logged in @@ -34,7 +34,7 @@ const NavLoggedIn = ({currentUser}) => {Users.canDo(currentUser, 'pics.new') ? - + : null } diff --git a/packages/vulcan-accounts/imports/accounts_ui.js b/packages/vulcan-accounts/imports/accounts_ui.js index 3d9fb17de..24546e64d 100755 --- a/packages/vulcan-accounts/imports/accounts_ui.js +++ b/packages/vulcan-accounts/imports/accounts_ui.js @@ -21,14 +21,14 @@ Accounts.ui._options = { passwordSignupFields: 'USERNAME_AND_EMAIL', minimumPasswordLength: 7, loginPath: '/', - signUpPath: null, + signUpPath: '/', resetPasswordPath: null, profilePath: '/', changePasswordPath: null, homeRoutePath: '/', onSubmitHook: () => {}, onPreSignUpHook: () => new Promise(resolve => resolve()), - onPostSignUpHook: () => {}, + onPostSignUpHook: () => redirect(`${Accounts.ui._options.signUpPath}`), onEnrollAccountHook: () => redirect(`${Accounts.ui._options.loginPath}`), onResetPasswordHook: () => redirect(`${Accounts.ui._options.loginPath}`), onVerifyEmailHook: () => redirect(`${Accounts.ui._options.profilePath}`), diff --git a/packages/vulcan-accounts/package.js b/packages/vulcan-accounts/package.js index 747b03fa2..78bedfb1e 100755 --- a/packages/vulcan-accounts/package.js +++ b/packages/vulcan-accounts/package.js @@ -1,6 +1,6 @@ Package.describe({ name: 'vulcan:accounts', - version: '1.8.0', + version: '1.8.1', summary: 'Accounts UI for React in Meteor 1.3+', git: 'https://github.com/studiointeract/accounts-ui', documentation: 'README.md' @@ -9,7 +9,7 @@ Package.describe({ Package.onUse(function(api) { api.versionsFrom('1.3'); - api.use('vulcan:core@1.8.0'); + api.use('vulcan:core@1.8.1'); api.use('ecmascript'); api.use('tracker'); diff --git a/packages/vulcan-admin/package.js b/packages/vulcan-admin/package.js index 0d421bdef..804f55e4a 100644 --- a/packages/vulcan-admin/package.js +++ b/packages/vulcan-admin/package.js @@ -1,7 +1,7 @@ Package.describe({ name: "vulcan:admin", summary: "Vulcan components package", - version: '1.8.0', + version: '1.8.1', git: "https://github.com/VulcanJS/Vulcan.git" }); @@ -14,7 +14,7 @@ Package.onUse(function (api) { 'fourseven:scss@4.5.0', 'dynamic-import@0.1.1', // Vulcan packages - 'vulcan:core@1.8.0', + 'vulcan:core@1.8.1', ]); diff --git a/packages/vulcan-cloudinary/package.js b/packages/vulcan-cloudinary/package.js index 77522ff65..4213fd5f7 100644 --- a/packages/vulcan-cloudinary/package.js +++ b/packages/vulcan-cloudinary/package.js @@ -1,7 +1,7 @@ Package.describe({ name: 'vulcan:cloudinary', summary: 'Vulcan file upload 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' ]); api.mainModule("lib/client/main.js", "client"); diff --git a/packages/vulcan-core/lib/modules/callbacks.js b/packages/vulcan-core/lib/modules/callbacks.js index e70209aab..f9feda082 100644 --- a/packages/vulcan-core/lib/modules/callbacks.js +++ b/packages/vulcan-core/lib/modules/callbacks.js @@ -12,7 +12,7 @@ import { addCallback, getActions } from 'meteor/vulcan:lib'; * @param {Object} Redux store reference instantiated on the current connected client * @param {Object} Apollo Client reference instantiated on the current connected client */ -function RouterClearMessages(unusedItem, store, apolloClient) { +function RouterClearMessages(unusedItem, nextRoute, store, apolloClient) { store.dispatch(getActions().messages.clearSeen()); return unusedItem; diff --git a/packages/vulcan-core/lib/modules/components/App.jsx b/packages/vulcan-core/lib/modules/components/App.jsx index 803b89d35..e47fd659d 100644 --- a/packages/vulcan-core/lib/modules/components/App.jsx +++ b/packages/vulcan-core/lib/modules/components/App.jsx @@ -1,36 +1,68 @@ -import { Components, registerComponent, registerSetting, getSetting, Strings } from 'meteor/vulcan:lib'; +import { + Components, + registerComponent, + registerSetting, + getSetting, + Strings, + runCallbacks, +} from 'meteor/vulcan:lib'; import React, { PureComponent } from 'react'; import PropTypes from 'prop-types'; -import { IntlProvider, intlShape} from 'meteor/vulcan:i18n'; +import { IntlProvider, intlShape } from 'meteor/vulcan:i18n'; import withCurrentUser from '../containers/withCurrentUser.js'; class App extends PureComponent { + constructor(props) { + super(props); + if (props.currentUser) { + runCallbacks('events.identify', props.currentUser); + } + } getLocale() { return getSetting('locale', 'en'); } getChildContext() { - const messages = Strings[this.getLocale()] || {}; - const intlProvider = new IntlProvider({locale: this.getLocale()}, messages); + const intlProvider = new IntlProvider( + { locale: this.getLocale() }, + messages + ); const { intl } = intlProvider.getChildContext(); return { - intl: intl + intl: intl, }; } + componentWillUpdate(nextProps) { + if (!this.props.currentUser && nextProps.currentUser) { + runCallbacks('events.identify', nextProps.currentUser); + } + } + render() { - const currentRoute = _.last(this.props.routes); - const LayoutComponent = currentRoute.layoutName ? Components[currentRoute.layoutName] : Components.Layout; + const LayoutComponent = currentRoute.layoutName + ? Components[currentRoute.layoutName] + : Components.Layout; return ( - +
+ - { 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.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.props.cancelCallback ? {e.preventDefault(); this.props.cancelCallback(this.getDocument())}}>{this.props.cancelLabel ? this.props.cancelLabel : } : null} -
+ {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 + }) => ( +
+ + + + { + cancelCallback + ? + { + e.preventDefault(); + cancelCallback(document); + }}>{cancelLabel ? cancelLabel : + } + : + null + } + + { + deleteDocument + ? +
+
+ + + +
+ : + null + } + +
+); + + +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")); +}