diff --git a/.eslintrc b/.eslintrc index e175ad797..8254b5ac7 100644 --- a/.eslintrc +++ b/.eslintrc @@ -54,6 +54,7 @@ "single", "avoid-escape" ], + "react/display-name": 1, "react/prop-types": 0, "semi": [1, "always"] }, diff --git a/package-lock.json b/package-lock.json index adbaeba4d..cdf6b7e43 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,6 +1,6 @@ { "name": "Vulcan", - "version": "1.12.16", + "version": "1.12.17", "lockfileVersion": 1, "requires": true, "dependencies": { @@ -330,13 +330,22 @@ "warning": "^3.0.0" }, "dependencies": { - "prop-types": { - "version": "15.6.2", - "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.6.2.tgz", - "integrity": "sha512-3pboPvLiWD7dkI3qf3KbUe6hKFKa52w+AE0VCqECtf+QHAKgOL37tTaNCnuX1nAAQ4ZhyP+kYVKf8rLmJ/feDQ==", + "loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", "requires": { - "loose-envify": "^1.3.1", - "object-assign": "^4.1.1" + "js-tokens": "^3.0.0 || ^4.0.0" + } + }, + "prop-types": { + "version": "15.7.2", + "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.7.2.tgz", + "integrity": "sha512-8QQikdH7//R2vurIJSutZ1smHYTcLpRWEOlHnzcWHmBYrOGUysKwSsrC89BCiFj3CbrfJ/nXFdJepOVrY1GCHQ==", + "requires": { + "loose-envify": "^1.4.0", + "object-assign": "^4.1.1", + "react-is": "^16.8.1" } } } @@ -702,7 +711,6 @@ "version": "0.1.4", "resolved": "https://registry.npmjs.org/align-text/-/align-text-0.1.4.tgz", "integrity": "sha1-DNkKVhCT810KmSVsIrcGlDP60Rc=", - "optional": true, "requires": { "kind-of": "^3.0.2", "longest": "^1.0.1", @@ -898,11 +906,12 @@ } }, "apollo-errors": { - "version": "1.5.1", - "resolved": "https://registry.npmjs.org/apollo-errors/-/apollo-errors-1.5.1.tgz", - "integrity": "sha512-gYAceMzNJfF+mUHH2/4UcZTkZtDY54arCTKGbKa7WU5IXnTJ4V+P94wHodcDkLLHWpHL8SW1hEgjN5ZINcPb1w==", + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/apollo-errors/-/apollo-errors-1.9.0.tgz", + "integrity": "sha512-XVukHd0KLvgY6tNjsPS3/Re3U6RQlTKrTbIpqqeTMo2N34uQMr+H1UheV21o8hOZBAFosvBORVricJiP5vfmrw==", "requires": { - "es6-error": "^4.0.0" + "assert": "^1.4.1", + "extendable-error": "^0.1.5" } }, "apollo-link": { @@ -1289,6 +1298,14 @@ "safer-buffer": "~2.1.0" } }, + "assert": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/assert/-/assert-1.4.1.tgz", + "integrity": "sha1-mZEtWRg2tab1s0XA8H7vwI/GXZE=", + "requires": { + "util": "0.10.3" + } + }, "assert-err": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/assert-err/-/assert-err-1.1.0.tgz", @@ -2384,7 +2401,7 @@ }, "cheerio": { "version": "0.22.0", - "resolved": "https://registry.npmjs.org/cheerio/-/cheerio-0.22.0.tgz", + "resolved": "http://registry.npmjs.org/cheerio/-/cheerio-0.22.0.tgz", "integrity": "sha1-qbqoYKP5tZWmuBsahocxIe06Jp4=", "requires": { "css-select": "~1.2.0", @@ -2831,7 +2848,7 @@ }, "css-select": { "version": "1.2.0", - "resolved": "https://registry.npmjs.org/css-select/-/css-select-1.2.0.tgz", + "resolved": "http://registry.npmjs.org/css-select/-/css-select-1.2.0.tgz", "integrity": "sha1-KzoRBTnFNV8c2NMUYj6HCxIeyFg=", "requires": { "boolbase": "~1.0.0", @@ -3370,11 +3387,6 @@ "es6-symbol": "~3.1.1" } }, - "es6-error": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/es6-error/-/es6-error-4.1.1.tgz", - "integrity": "sha512-Um/+FxMr9CISWh0bi5Zv0iOD+4cFh5qLeks1qhAopKVAJw3drgKbKySikp7wGhDL0HPeaja0P5ULZrxLkniUVg==" - }, "es6-iterator": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/es6-iterator/-/es6-iterator-2.0.3.tgz", @@ -3977,6 +3989,11 @@ "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.1.tgz", "integrity": "sha1-p1Xqe8Gt/MWjHOfnYtuq3F5jZEQ=" }, + "extendable-error": { + "version": "0.1.5", + "resolved": "https://registry.npmjs.org/extendable-error/-/extendable-error-0.1.5.tgz", + "integrity": "sha1-EiMIpwl7yJomOyxPvwiceBQOO20=" + }, "extglob": { "version": "0.3.2", "resolved": "https://registry.npmjs.org/extglob/-/extglob-0.3.2.tgz", @@ -6168,8 +6185,7 @@ "longest": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/longest/-/longest-1.0.1.tgz", - "integrity": "sha1-MKCy2jj3N3DoKUoNIuZiXtd9AJc=", - "optional": true + "integrity": "sha1-MKCy2jj3N3DoKUoNIuZiXtd9AJc=" }, "loose-envify": { "version": "1.3.1", @@ -6261,7 +6277,7 @@ "process": "^0.11.9", "punycode": "^1.4.1", "querystring-es3": "^0.2.1", - "readable-stream": "git+https://github.com/meteor/readable-stream.git", + "readable-stream": "git+https://github.com/meteor/readable-stream.git#c688cdd193549919b840e8d72a86682d91961e12", "stream-browserify": "^2.0.1", "string_decoder": "^1.0.1", "timers-browserify": "^1.4.2", @@ -6711,12 +6727,19 @@ "version": "git+https://github.com/meteor/readable-stream.git#c688cdd193549919b840e8d72a86682d91961e12", "from": "git+https://github.com/meteor/readable-stream.git", "requires": { - "inherits": "~2.0.3", + "inherits": "~2.0.1", "isarray": "~1.0.0", - "process-nextick-args": "~2.0.0", - "safe-buffer": "~5.1.1", - "string_decoder": "~1.1.0", + "process-nextick-args": "~1.0.6", + "safe-buffer": "^5.0.1", + "string_decoder": "~1.0.0", "util-deprecate": "~1.0.1" + }, + "dependencies": { + "process-nextick-args": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-1.0.7.tgz", + "integrity": "sha1-FQ4gt1ZZCtP5EJPyWk8q2L/zC6M=" + } } }, "rimraf": { @@ -7790,31 +7813,31 @@ } }, "react-bootstrap": { - "version": "1.0.0-beta.3", - "resolved": "https://registry.npmjs.org/react-bootstrap/-/react-bootstrap-1.0.0-beta.3.tgz", - "integrity": "sha512-/eiSmRZE92q6m7uen3oAsOTGY4uBJkZDv32fwxUeyjesf834GUDaEhu1dzj10fmxSeVHW9O6UOKj9GkbwIIkMg==", + "version": "1.0.0-beta.5", + "resolved": "https://registry.npmjs.org/react-bootstrap/-/react-bootstrap-1.0.0-beta.5.tgz", + "integrity": "sha512-Osm0OtTbYwfsT1rpu88ESWuAHZxfaHFNKFiW8w3w+6YY9/bLEPHbGRZA6W21fg5yvcuKN9hJKT857TTHgY7SoQ==", "requires": { - "@babel/runtime": "^7.0.0", + "@babel/runtime": "^7.2.0", "@react-bootstrap/react-popper": "1.2.1", "classnames": "^2.2.6", - "dom-helpers": "^3.2.0", + "dom-helpers": "^3.4.0", "invariant": "^2.2.3", "keycode": "^2.1.2", - "popper.js": "^1.14.3", + "popper.js": "^1.14.6", "prop-types": "^15.6.2", "prop-types-extra": "^1.1.0", - "react-context-toolbox": "^1.2.3", - "react-overlays": "^1.0.0-beta.17", + "react-context-toolbox": "^2.0.2", + "react-overlays": "^1.0.0", "react-prop-types": "^0.4.0", - "react-transition-group": "^2.4.0", + "react-transition-group": "^2.5.1", "uncontrollable": "^6.0.0", "warning": "^4.0.1" }, "dependencies": { "@babel/runtime": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.2.0.tgz", - "integrity": "sha512-oouEibCbHMVdZSDlJBO6bZmID/zA/G/Qx3H1d3rSNPTD+L8UNKvCat7aKWSJ74zYbm5zWGh0GQN0hKj8zYFTCg==", + "version": "7.3.4", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.3.4.tgz", + "integrity": "sha512-IvfvnMdSaLBateu0jfsYIpZTxAc2cKEXEMiezGGN75QcBcecDUKd3PgLAncT0oOgxKy8dd8hrJKj9MfzgfZd6g==", "requires": { "regenerator-runtime": "^0.12.0" } @@ -7824,6 +7847,14 @@ "resolved": "https://registry.npmjs.org/classnames/-/classnames-2.2.6.tgz", "integrity": "sha512-JR/iSQOSt+LQIWwrwEzJ9uk0xfN3mTVYMwt1Ir5mUcSN6pU+V4zQFFaJsclJbPuAUQH+yfWef6tm7l1quW3C8Q==" }, + "dom-helpers": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-3.4.0.tgz", + "integrity": "sha512-LnuPJ+dwqKDIyotW1VzmOZ5TONUN7CwkCR5hrgawTUbkBGYdeoNLZo6nNfGkCrjtE1nXXaj7iMMpDa8/d9WoIA==", + "requires": { + "@babel/runtime": "^7.1.2" + } + }, "invariant": { "version": "2.2.4", "resolved": "https://registry.npmjs.org/invariant/-/invariant-2.2.4.tgz", @@ -7833,12 +7864,23 @@ } }, "prop-types": { - "version": "15.6.2", - "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.6.2.tgz", - "integrity": "sha512-3pboPvLiWD7dkI3qf3KbUe6hKFKa52w+AE0VCqECtf+QHAKgOL37tTaNCnuX1nAAQ4ZhyP+kYVKf8rLmJ/feDQ==", + "version": "15.7.2", + "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.7.2.tgz", + "integrity": "sha512-8QQikdH7//R2vurIJSutZ1smHYTcLpRWEOlHnzcWHmBYrOGUysKwSsrC89BCiFj3CbrfJ/nXFdJepOVrY1GCHQ==", "requires": { - "loose-envify": "^1.3.1", - "object-assign": "^4.1.1" + "loose-envify": "^1.4.0", + "object-assign": "^4.1.1", + "react-is": "^16.8.1" + }, + "dependencies": { + "loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "requires": { + "js-tokens": "^3.0.0 || ^4.0.0" + } + } } }, "regenerator-runtime": { @@ -7847,9 +7889,9 @@ "integrity": "sha512-odxIc1/vDlo4iZcfXqRYFj0vpXFNoGdKMAUieAlFYO6m/nl5e9KR/beGf41z4a1FI+aQgtjhuaSlDxQ0hmkrHg==" }, "warning": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/warning/-/warning-4.0.2.tgz", - "integrity": "sha512-wbTp09q/9C+jJn4KKJfJfoS6VleK/Dti0yqWSm6KMvJ4MRCXFQNapHuJXutJIrWV0Cf4AhTdeIe4qdKHR1+Hug==", + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/warning/-/warning-4.0.3.tgz", + "integrity": "sha512-rpJyN222KWIvHJ/F53XSZv0Zl/accqHR8et1kpaMTD/fLCRxtV8iX8czMzY7sVZupTI3zcUTg8eycS2kNF9l6w==", "requires": { "loose-envify": "^1.0.0" } @@ -7882,9 +7924,9 @@ } }, "react-context-toolbox": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/react-context-toolbox/-/react-context-toolbox-1.2.3.tgz", - "integrity": "sha512-ArHw0UFDM6X8Z9lHZ1rZOhMcn8TXWC9y9sFpeJm11YTIlQsN4A0MadKcps2pCGMwWKmH0o/67t+TmVapwWm5Sw==" + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/react-context-toolbox/-/react-context-toolbox-2.0.2.tgz", + "integrity": "sha512-tY4j0imkYC3n5ZlYSgFkaw7fmlCp3IoQQ6DxpqeNHzcD0hf+6V+/HeJxviLUZ1Rv1Yn3N3xyO2EhkkZwHn0m1A==" }, "react-cookie": { "version": "2.1.6", @@ -8001,9 +8043,9 @@ } }, "react-is": { - "version": "16.4.1", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.4.1.tgz", - "integrity": "sha512-xpb0PpALlFWNw/q13A+1aHeyJyLYCg0/cCHPUA43zYluZuIPHaHL3k8OBsTgQtxqW0FhyDEMvi8fZ/+7+r4OSQ==" + "version": "16.8.4", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.8.4.tgz", + "integrity": "sha512-PVadd+WaUDOAciICm/J1waJaSvgq+4rHE/K70j0PFqKhkTBsPv/82UGQJNXAngz1fOQLLxI6z1sEDmJDQhCTAA==" }, "react-lifecycles-compat": { "version": "3.0.4", @@ -8038,9 +8080,9 @@ "integrity": "sha512-IBivBP7xayM7SbbVlAnKgHgoWdfCVqnNBNgQRY5x9iFQm55tFdolR02hX1fCJJtTEKnbaL1stB72/TZc6+p2+Q==" }, "react-overlays": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/react-overlays/-/react-overlays-1.0.0.tgz", - "integrity": "sha512-YDuUwqWBuVvQvvxPTxKpCuaEZRegDhfgJdQUbVmWVI4gag53zukN/6tNxt0XZpgODQVzLf/w7dFuoDq7YMhygg==", + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/react-overlays/-/react-overlays-1.2.0.tgz", + "integrity": "sha512-i/FCV8wR6aRaI+Kz/dpJhOdyx+ah2tN1RhT9InPrexyC4uzf3N4bNayFTGtUeQVacj57j1Mqh1CwV60/5153Iw==", "requires": { "classnames": "^2.2.6", "dom-helpers": "^3.4.0", @@ -8048,13 +8090,14 @@ "prop-types-extra": "^1.1.0", "react-context-toolbox": "^2.0.2", "react-popper": "^1.3.2", + "uncontrollable": "^6.0.0", "warning": "^4.0.2" }, "dependencies": { "@babel/runtime": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.2.0.tgz", - "integrity": "sha512-oouEibCbHMVdZSDlJBO6bZmID/zA/G/Qx3H1d3rSNPTD+L8UNKvCat7aKWSJ74zYbm5zWGh0GQN0hKj8zYFTCg==", + "version": "7.3.4", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.3.4.tgz", + "integrity": "sha512-IvfvnMdSaLBateu0jfsYIpZTxAc2cKEXEMiezGGN75QcBcecDUKd3PgLAncT0oOgxKy8dd8hrJKj9MfzgfZd6g==", "requires": { "regenerator-runtime": "^0.12.0" } @@ -8072,19 +8115,23 @@ "@babel/runtime": "^7.1.2" } }, - "prop-types": { - "version": "15.6.2", - "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.6.2.tgz", - "integrity": "sha512-3pboPvLiWD7dkI3qf3KbUe6hKFKa52w+AE0VCqECtf+QHAKgOL37tTaNCnuX1nAAQ4ZhyP+kYVKf8rLmJ/feDQ==", + "loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", "requires": { - "loose-envify": "^1.3.1", - "object-assign": "^4.1.1" + "js-tokens": "^3.0.0 || ^4.0.0" } }, - "react-context-toolbox": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/react-context-toolbox/-/react-context-toolbox-2.0.2.tgz", - "integrity": "sha512-tY4j0imkYC3n5ZlYSgFkaw7fmlCp3IoQQ6DxpqeNHzcD0hf+6V+/HeJxviLUZ1Rv1Yn3N3xyO2EhkkZwHn0m1A==" + "prop-types": { + "version": "15.7.2", + "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.7.2.tgz", + "integrity": "sha512-8QQikdH7//R2vurIJSutZ1smHYTcLpRWEOlHnzcWHmBYrOGUysKwSsrC89BCiFj3CbrfJ/nXFdJepOVrY1GCHQ==", + "requires": { + "loose-envify": "^1.4.0", + "object-assign": "^4.1.1", + "react-is": "^16.8.1" + } }, "regenerator-runtime": { "version": "0.12.1", @@ -8092,9 +8139,9 @@ "integrity": "sha512-odxIc1/vDlo4iZcfXqRYFj0vpXFNoGdKMAUieAlFYO6m/nl5e9KR/beGf41z4a1FI+aQgtjhuaSlDxQ0hmkrHg==" }, "warning": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/warning/-/warning-4.0.2.tgz", - "integrity": "sha512-wbTp09q/9C+jJn4KKJfJfoS6VleK/Dti0yqWSm6KMvJ4MRCXFQNapHuJXutJIrWV0Cf4AhTdeIe4qdKHR1+Hug==", + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/warning/-/warning-4.0.3.tgz", + "integrity": "sha512-rpJyN222KWIvHJ/F53XSZv0Zl/accqHR8et1kpaMTD/fLCRxtV8iX8czMzY7sVZupTI3zcUTg8eycS2kNF9l6w==", "requires": { "loose-envify": "^1.0.0" } @@ -8111,9 +8158,9 @@ } }, "react-popper": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/react-popper/-/react-popper-1.3.2.tgz", - "integrity": "sha512-UbFWj55Yt9uqvy0oZ+vULDL2Bw1oxeZF9/JzGyxQ5ypgauRH/XlarA5+HLZWro/Zss6Ht2kqpegtb6sYL8GUGw==", + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/react-popper/-/react-popper-1.3.3.tgz", + "integrity": "sha512-ynMZBPkXONPc5K4P5yFWgZx5JGAUIP3pGGLNs58cfAPgK67olx7fmLp+AdpZ0+GoQ+ieFDa/z4cdV6u7sioH6w==", "requires": { "@babel/runtime": "^7.1.2", "create-react-context": "<=0.2.2", @@ -8124,9 +8171,9 @@ }, "dependencies": { "@babel/runtime": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.2.0.tgz", - "integrity": "sha512-oouEibCbHMVdZSDlJBO6bZmID/zA/G/Qx3H1d3rSNPTD+L8UNKvCat7aKWSJ74zYbm5zWGh0GQN0hKj8zYFTCg==", + "version": "7.3.4", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.3.4.tgz", + "integrity": "sha512-IvfvnMdSaLBateu0jfsYIpZTxAc2cKEXEMiezGGN75QcBcecDUKd3PgLAncT0oOgxKy8dd8hrJKj9MfzgfZd6g==", "requires": { "regenerator-runtime": "^0.12.0" } @@ -8140,13 +8187,22 @@ "gud": "^1.0.0" } }, - "prop-types": { - "version": "15.6.2", - "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.6.2.tgz", - "integrity": "sha512-3pboPvLiWD7dkI3qf3KbUe6hKFKa52w+AE0VCqECtf+QHAKgOL37tTaNCnuX1nAAQ4ZhyP+kYVKf8rLmJ/feDQ==", + "loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", "requires": { - "loose-envify": "^1.3.1", - "object-assign": "^4.1.1" + "js-tokens": "^3.0.0 || ^4.0.0" + } + }, + "prop-types": { + "version": "15.7.2", + "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.7.2.tgz", + "integrity": "sha512-8QQikdH7//R2vurIJSutZ1smHYTcLpRWEOlHnzcWHmBYrOGUysKwSsrC89BCiFj3CbrfJ/nXFdJepOVrY1GCHQ==", + "requires": { + "loose-envify": "^1.4.0", + "object-assign": "^4.1.1", + "react-is": "^16.8.1" } }, "regenerator-runtime": { @@ -8160,9 +8216,9 @@ "integrity": "sha512-pzP0PWoZUhsECYjABgCGQlRGL1n7tOHsgwYv3oIiEpJwGhFTuty/YNeduxQYzXXa3Ge5BdT6sHYIQYpl4uJ+5Q==" }, "warning": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/warning/-/warning-4.0.2.tgz", - "integrity": "sha512-wbTp09q/9C+jJn4KKJfJfoS6VleK/Dti0yqWSm6KMvJ4MRCXFQNapHuJXutJIrWV0Cf4AhTdeIe4qdKHR1+Hug==", + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/warning/-/warning-4.0.3.tgz", + "integrity": "sha512-rpJyN222KWIvHJ/F53XSZv0Zl/accqHR8et1kpaMTD/fLCRxtV8iX8czMzY7sVZupTI3zcUTg8eycS2kNF9l6w==", "requires": { "loose-envify": "^1.0.0" } @@ -8202,27 +8258,6 @@ "prop-types": "^15.5.10" } }, - "react-router": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/react-router/-/react-router-3.2.0.tgz", - "integrity": "sha512-sXlLOg0TRCqnjCVskqBHGjzNjcJKUqXEKnDSuxMYJSPJNq9hROE9VsiIW2kfIq7Ev+20Iz0nxayekXyv0XNmsg==", - "requires": { - "create-react-class": "^15.5.1", - "history": "^3.0.0", - "hoist-non-react-statics": "^1.2.0", - "invariant": "^2.2.1", - "loose-envify": "^1.2.0", - "prop-types": "^15.5.6", - "warning": "^3.0.0" - }, - "dependencies": { - "hoist-non-react-statics": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-1.2.0.tgz", - "integrity": "sha1-qkSM8JhtVcxAdzsXF0t90GbLfPs=" - } - } - }, "react-router-bootstrap": { "version": "0.24.4", "resolved": "https://registry.npmjs.org/react-router-bootstrap/-/react-router-bootstrap-0.24.4.tgz", @@ -9569,9 +9604,9 @@ "optional": true }, "uncontrollable": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/uncontrollable/-/uncontrollable-6.0.0.tgz", - "integrity": "sha512-gmy2ESW40LGbijSbW5piBGiPv55IgyDbjQcMr7LkDR5icpw/06UgMqULAGDBAcFn2a9d/SRPgcb3oo8hdEUfIw==", + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/uncontrollable/-/uncontrollable-6.1.0.tgz", + "integrity": "sha512-2TzEm0pLKauMBZfAZXsgQvLpZHEp95891frCZdGDrSG7dWYaIQhedwLAzi0X8pR8KHNqlmuYEb2cEgbQzr050A==", "requires": { "invariant": "^2.2.4" }, @@ -9717,6 +9752,21 @@ "os-homedir": "^1.0.0" } }, + "util": { + "version": "0.10.3", + "resolved": "https://registry.npmjs.org/util/-/util-0.10.3.tgz", + "integrity": "sha1-evsa/lCAUkZInj23/g7TeTNqwPk=", + "requires": { + "inherits": "2.0.1" + }, + "dependencies": { + "inherits": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.1.tgz", + "integrity": "sha1-sX0I0ya0Qj5Wjv9xn5GwscvfafE=" + } + } + }, "util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", @@ -9852,7 +9902,7 @@ }, "chalk": { "version": "1.1.3", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-1.1.3.tgz", + "resolved": "http://registry.npmjs.org/chalk/-/chalk-1.1.3.tgz", "integrity": "sha1-qBFcVeSnAv5NFQq9OHKCKn4J/Jg=", "requires": { "ansi-styles": "^2.2.1", diff --git a/package.json b/package.json index 7a34e65e9..62eaf8b77 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "Vulcan", - "version": "1.12.17", + "version": "1.13.0", "engines": { "npm": "^3.0" }, @@ -21,7 +21,7 @@ } }, "dependencies": { - "@babel/runtime": "7.0.0-beta.55", + "@babel/runtime": "7.1.2", "analytics-node": "^2.1.1", "apollo-cache-inmemory": "^1.4.2", "apollo-client": "2.4.12", @@ -30,6 +30,7 @@ "apollo-link-error": "^1.1.5", "apollo-link-schema": "^1.1.1", "apollo-link-state": "^0.4.2", + "apollo-link-watched-mutation": "^0.1.0", "apollo-server": "2.3.3", "apollo-server-express": "2.3.3", "babel-runtime": "^6.26.0", @@ -70,14 +71,14 @@ "moment": "^2.13.0", "prop-types": "^15.6.0", "qs": "^6.6.0", - "react": "^16.2.0", + "react": "^16.4.1", "react-addons-pure-render-mixin": "^15.4.1", "react-apollo": "^2.4.1", - "react-bootstrap": "^1.0.0-beta.3", + "react-bootstrap": "^1.0.0-beta.5", "react-bootstrap-datetimepicker": "0.0.22", "react-cookie": "^2.1.4", "react-datetime": "^2.11.1", - "react-dom": "^16.2.0", + "react-dom": "^16.4.1", "react-dropzone": "^8.0.3", "react-helmet": "^5.1.3", "react-intl": "^2.1.3", diff --git a/packages/vulcan-accounts/imports/ui/components/EnrollAccount.jsx b/packages/vulcan-accounts/imports/ui/components/EnrollAccount.jsx index 012f65456..015f8c6b2 100644 --- a/packages/vulcan-accounts/imports/ui/components/EnrollAccount.jsx +++ b/packages/vulcan-accounts/imports/ui/components/EnrollAccount.jsx @@ -6,7 +6,7 @@ import { STATES } from '../../helpers.js'; class AccountsEnrollAccount extends PureComponent { componentDidMount() { - const token = this.props.params.token; + const token = this.props.match.params.token; Accounts._loginButtonsSession.set('enrollAccountToken', token); } diff --git a/packages/vulcan-accounts/imports/ui/components/LoginFormInner.jsx b/packages/vulcan-accounts/imports/ui/components/LoginFormInner.jsx index c32117135..eb114416c 100644 --- a/packages/vulcan-accounts/imports/ui/components/LoginFormInner.jsx +++ b/packages/vulcan-accounts/imports/ui/components/LoginFormInner.jsx @@ -265,9 +265,20 @@ export class AccountsLoginFormInner extends TrackerComponent { } fields() { - const loginFields = []; + let loginFields = []; const { formState } = this.state; + // if extra fields have been specified, add onChange handler to them + if (this.props.extraFields) { + loginFields = this.props.extraFields.map(field => { + const { id } = field; + return { + ...field, + onChange: this.handleChange.bind(this, id), + } + }); + } + if (!hasPasswordService() && getLoginServices().length == 0) { loginFields.push({ label: 'No login service added, i.e. accounts-password', @@ -766,6 +777,13 @@ export class AccountsLoginFormInner extends TrackerComponent { onSubmitHook } = this.state; + // add extra fields to options + if (this.props.extraFields) { + this.props.extraFields.forEach(({ id })=> { + options[id] = this.state[id]; + }); + } + const self = this; let error = false; diff --git a/packages/vulcan-accounts/package.js b/packages/vulcan-accounts/package.js index fe2016336..28d45d1f5 100755 --- a/packages/vulcan-accounts/package.js +++ b/packages/vulcan-accounts/package.js @@ -1,6 +1,6 @@ Package.describe({ name: 'vulcan:accounts', - version: '1.12.17', + version: '1.13.0', 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.6.1'); - api.use('vulcan:core@1.12.17'); + api.use('vulcan:core@1.13.0'); api.use('ecmascript'); api.use('tracker'); diff --git a/packages/vulcan-admin/package.js b/packages/vulcan-admin/package.js index 6a4849c03..160a26f0f 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.12.17', + version: '1.13.0', git: 'https://github.com/VulcanJS/Vulcan.git', }); @@ -12,7 +12,7 @@ Package.onUse(function(api) { 'fourseven:scss@4.10.0', 'dynamic-import@0.1.1', // Vulcan packages - 'vulcan:core@1.12.17', + 'vulcan:core@1.13.0', ]); api.mainModule('lib/server/main.js', 'server'); diff --git a/packages/vulcan-cloudinary/package.js b/packages/vulcan-cloudinary/package.js index 26e2599d2..bdba9e2ac 100644 --- a/packages/vulcan-cloudinary/package.js +++ b/packages/vulcan-cloudinary/package.js @@ -1,14 +1,14 @@ Package.describe({ name: 'vulcan:cloudinary', summary: 'Vulcan file upload package.', - version: '1.12.17', + version: '1.13.0', git: 'https://github.com/VulcanJS/Vulcan.git', }); Package.onUse(function(api) { api.versionsFrom('1.6.1'); - api.use(['vulcan:core@1.12.17']); + api.use(['vulcan:core@1.13.0']); api.mainModule('lib/client/main.js', 'client'); api.mainModule('lib/server/main.js', 'server'); diff --git a/packages/vulcan-core/lib/modules/components/EditButton.jsx b/packages/vulcan-core/lib/modules/components/EditButton.jsx index 7f15bef7d..9e0d6e28a 100644 --- a/packages/vulcan-core/lib/modules/components/EditButton.jsx +++ b/packages/vulcan-core/lib/modules/components/EditButton.jsx @@ -2,7 +2,7 @@ import { Components, registerComponent } from 'meteor/vulcan:lib'; import React from 'react'; import { FormattedMessage, intlShape } from 'meteor/vulcan:i18n'; -const EditButton = ({ style = 'primary', label, size, showId, modalProps, ...props }, { intl }) => ( +const EditButton = ({ style = 'primary', label, size, showId, modalProps, formProps, ...props }, { intl }) => ( - + ); @@ -29,7 +29,7 @@ registerComponent('EditButton', EditButton); EditForm Component */ -const EditForm = ({ closeModal, successCallback, removeSuccessCallback, ...props }) => { +const EditForm = ({ closeModal, successCallback, removeSuccessCallback, formProps, ...props }) => { const success = successCallback ? document => { @@ -46,7 +46,7 @@ const EditForm = ({ closeModal, successCallback, removeSuccessCallback, ...props : closeModal; return ( - + ); }; registerComponent('EditForm', EditForm); diff --git a/packages/vulcan-core/lib/modules/components/NewButton.jsx b/packages/vulcan-core/lib/modules/components/NewButton.jsx index 262eba4af..e966577d7 100644 --- a/packages/vulcan-core/lib/modules/components/NewButton.jsx +++ b/packages/vulcan-core/lib/modules/components/NewButton.jsx @@ -2,7 +2,7 @@ import { Components, registerComponent } from 'meteor/vulcan:lib'; import React from 'react'; import { FormattedMessage, intlShape } from 'meteor/vulcan:i18n'; -const NewButton = ({ collection, size, label, style = 'primary', ...props }, { intl }) => ( +const NewButton = ({ collection, size, label, style = 'primary', formProps, ...props }, { intl }) => ( } > - + ); @@ -28,7 +28,7 @@ registerComponent('NewButton', NewButton); NewForm Component */ -const NewForm = ({ closeModal, successCallback, ...props }) => { +const NewForm = ({ closeModal, successCallback, formProps, ...props }) => { const success = successCallback ? document => { @@ -37,6 +37,6 @@ const NewForm = ({ closeModal, successCallback, ...props }) => { } : closeModal; - return ; + return ; }; registerComponent('NewForm', NewForm); diff --git a/packages/vulcan-core/lib/modules/default_mutations.js b/packages/vulcan-core/lib/modules/default_mutations.js index 6e7dc3081..c1721e761 100644 --- a/packages/vulcan-core/lib/modules/default_mutations.js +++ b/packages/vulcan-core/lib/modules/default_mutations.js @@ -28,6 +28,135 @@ import get from 'lodash/get'; const defaultOptions = { create: true, update: true, upsert: true, delete: true }; +/** + * Safe getter + * Must returns null if the document is absent (eg in case of validation failure) + * @param {*} mutation + * @param {*} mutationName + */ +const getDocumentFromMutation = (mutation, mutationName) => { + const mutationData = (mutation.result.data[mutationName] || {}); + const document = mutationData.data; + return document; +}; + +const getCreateMutationName = (typeName) => `create${typeName}`; +const getUpdateMutationName = (typeName) => `update${typeName}`; +const getDeleteMutationName = (typeName) => `delete${typeName}`; +const getUpsertMutationName = (typeName) => `upsert${typeName}`; +const getMultiResolverName = (typeName) => Utils.camelCaseify(Utils.pluralize(typeName)); +const getMultiQueryName = (typeName) => `multi${typeName}Query`; + +/* + +Handle post-mutation updates of the client cache +TODO: this is a client only function +it should be called by a callback on collection create? +*/ +export const registerWatchedMutations = (mutations, typeName) => { + if (Meteor.isClient) { + const multiQueryName = getMultiQueryName(typeName); + const multiResolverName = getMultiResolverName(typeName); + // create + if (mutations.create) { + const mutationName = mutations.create.name; + registerWatchedMutation(mutationName, multiQueryName, ({ mutation, query }) => { + // get mongo selector and options objects based on current terms + const terms = query.variables.input.terms; + const collection = Collections.find(c => c.typeName === typeName); + const parameters = collection.getParameters(terms /* apolloClient */); + const { selector, options } = parameters; + let results = query.result; + const document = getDocumentFromMutation(mutation, mutationName); + // nothing to add + if (!document) return results; + + if (belongsToSet(document, selector)) { + if (!isInSet(results[multiResolverName], document)) { + // make sure document hasn't been already added as this may be called several times + results[multiResolverName] = addToSet(results[multiResolverName], document); + } + results[multiResolverName] = reorderSet(results[multiResolverName], options.sort); + } + + results[multiResolverName].__typename = `Multi${typeName}Output`; + + // console.log('// create'); + // console.log(mutation); + // console.log(query); + // console.log(collection); + // console.log(parameters); + // console.log(results); + + return results; + }); + } + //update + if (mutations.update) { + const mutationName = mutations.update.name; + registerWatchedMutation(mutationName, multiQueryName, ({ mutation, query }) => { + // get mongo selector and options objects based on current terms + const terms = query.variables.input.terms; + const collection = Collections.find(c => c.typeName === typeName); + const parameters = collection.getParameters(terms /* apolloClient */); + const { selector, options } = parameters; + let results = query.result; + const document = getDocumentFromMutation(mutation, mutationName); + // nothing to update + if (!document) return results; + + if (belongsToSet(document, selector)) { + // edited document belongs to the list + if (!isInSet(results[multiResolverName], document)) { + // if document wasn't already in list, add it + results[multiResolverName] = addToSet(results[multiResolverName], document); + } else { + // if document was already in the list, update it + results[multiResolverName] = updateInSet(results[multiResolverName], document); + } + results[multiResolverName] = reorderSet( + results[multiResolverName], + options.sort, + selector + ); + } else { + // if edited doesn't belong to current list anymore (based on view selector), remove it + results[multiResolverName] = removeFromSet(results[multiResolverName], document); + } + + results[multiResolverName].__typename = `Multi${typeName}Output`; + + // console.log('// update'); + // console.log(mutation); + // console.log(query); + // console.log(parameters); + // console.log(results); + + return results; + }); + } + //delete + if (mutations.delete) { + const mutationName = mutations.delete.name; + registerWatchedMutation(mutationName, multiQueryName, ({ mutation, query }) => { + let results = query.result; + const document = getDocumentFromMutation(mutation, mutationName); + // nothing to delete + if (!document) return results; + results[multiResolverName] = removeFromSet(results[multiResolverName], document); + results[multiResolverName].__typename = `Multi${typeName}Output`; + // console.log('// delete') + // console.log(mutation); + // console.log(query); + // console.log(parameters); + // console.log(results); + return results; + }); + } + } +}; + + export function getDefaultMutations(options) { let typeName, collectionName, mutationOptions; @@ -46,17 +175,16 @@ export function getDefaultMutations(options) { // register callbacks for documentation purposes registerCollectionCallbacks(typeName, mutationOptions); - const multiResolverName = Utils.camelCaseify(Utils.pluralize(typeName)); - const multiQueryName = `multi${typeName}Query`; const mutations = {}; if (mutationOptions.create) { // mutation for inserting a new document - const mutationName = `create${typeName}`; + const mutationName = getCreateMutationName(typeName); const createMutation = { description: `Mutation for creating new ${typeName} documents`, + name: mutationName, // check function called on a user to see if they can perform the operation check(user, document) { @@ -99,51 +227,16 @@ export function getDefaultMutations(options) { mutations.create = createMutation; // OpenCRUD backwards compatibility mutations.new = createMutation; - - /* - - Handle post-mutation updates of the client cache - - */ - if (Meteor.isClient) { - registerWatchedMutation(mutationName, multiQueryName, ({ mutation, query }) => { - // get mongo selector and options objects based on current terms - const terms = query.variables.input.terms; - const collection = Collections.find(c => c.typeName === typeName); - const parameters = collection.getParameters(terms /* apolloClient */); - const { selector, options } = parameters; - let results = query.result; - const document = get(mutation, `result.data['${mutationName}'.data]`, {}); - - if (belongsToSet(document, selector)) { - if (!isInSet(results[multiResolverName], document)) { - // make sure document hasn't been already added as this may be called several times - results[multiResolverName] = addToSet(results[multiResolverName], document); - } - results[multiResolverName] = reorderSet(results[multiResolverName], options.sort); - } - - results[multiResolverName].__typename = `Multi${typeName}Output`; - - // console.log('// create'); - // console.log(mutation); - // console.log(query); - // console.log(collection); - // console.log(parameters); - // console.log(results); - - return results; - }); - } } if (mutationOptions.update) { // mutation for editing a specific document - const mutationName = `update${typeName}`; + const mutationName = getUpdateMutationName(typeName); const updateMutation = { description: `Mutation for updating a ${typeName} document`, + name: mutationName, // check function called on a user and document to see if they can perform the operation check(user, document) { @@ -160,13 +253,13 @@ export function getDefaultMutations(options) { // OpenCRUD backwards compatibility return Users.owns(user, document) ? Users.canDo(user, [ - `${typeName.toLowerCase()}.update.own`, - `${collectionName.toLowerCase()}.edit.own`, - ]) + `${typeName.toLowerCase()}.update.own`, + `${collectionName.toLowerCase()}.edit.own`, + ]) : Users.canDo(user, [ - `${typeName.toLowerCase()}.update.all`, - `${collectionName.toLowerCase()}.edit.all`, - ]); + `${typeName.toLowerCase()}.update.all`, + `${collectionName.toLowerCase()}.edit.all`, + ]); }, async mutation(root, { selector, data }, context) { @@ -212,56 +305,13 @@ export function getDefaultMutations(options) { // OpenCRUD backwards compatibility mutations.edit = updateMutation; - /* - - Handle post-mutation updates of the client cache - - */ - if (Meteor.isClient) { - registerWatchedMutation(mutationName, multiQueryName, ({ mutation, query }) => { - // get mongo selector and options objects based on current terms - const terms = query.variables.input.terms; - const collection = Collections.find(c => c.typeName === typeName); - const parameters = collection.getParameters(terms /* apolloClient */); - const { selector, options } = parameters; - let results = query.result; - const document = get(mutation, `result.data['${mutationName}'.data]`, {}); - - if (belongsToSet(document, selector)) { - // edited document belongs to the list - if (!isInSet(results[multiResolverName], document)) { - // if document wasn't already in list, add it - results[multiResolverName] = addToSet(results[multiResolverName], document); - } else { - // if document was already in the list, update it - results[multiResolverName] = updateInSet(results[multiResolverName], document); - } - results[multiResolverName] = reorderSet( - results[multiResolverName], - options.sort, - selector - ); - } else { - // if edited doesn't belong to current list anymore (based on view selector), remove it - results[multiResolverName] = removeFromSet(results[multiResolverName], document); - } - - results[multiResolverName].__typename = `Multi${typeName}Output`; - - // console.log('// update'); - // console.log(mutation); - // console.log(query); - // console.log(parameters); - // console.log(results); - - return results; - }); - } } if (mutationOptions.upsert) { // mutation for upserting a specific document + const mutationName = getUpsertMutationName(typeName); mutations.upsert = { description: `Mutation for upserting a ${typeName} document`, + name: mutationName, async mutation(root, { selector, data }, context) { const collection = context[collectionName]; @@ -286,10 +336,11 @@ export function getDefaultMutations(options) { if (mutationOptions.delete) { // mutation for removing a specific document (same checks as edit mutation) - const mutationName = `delete${typeName}`; + const mutationName = getDeleteMutationName(typeName); const deleteMutation = { description: `Mutation for deleting a ${typeName} document`, + name: mutationName, check(user, document) { // OpenCRUD backwards compatibility @@ -302,13 +353,13 @@ export function getDefaultMutations(options) { // OpenCRUD backwards compatibility return Users.owns(user, document) ? Users.canDo(user, [ - `${typeName.toLowerCase()}.delete.own`, - `${collectionName.toLowerCase()}.remove.own`, - ]) + `${typeName.toLowerCase()}.delete.own`, + `${collectionName.toLowerCase()}.remove.own`, + ]) : Users.canDo(user, [ - `${typeName.toLowerCase()}.delete.all`, - `${collectionName.toLowerCase()}.remove.all`, - ]); + `${typeName.toLowerCase()}.delete.all`, + `${collectionName.toLowerCase()}.remove.all`, + ]); }, async mutation(root, { selector }, context) { @@ -351,26 +402,10 @@ export function getDefaultMutations(options) { // OpenCRUD backwards compatibility mutations.remove = deleteMutation; - /* + } - Handle post-mutation updates of the client cache - - */ - if (Meteor.isClient) { - registerWatchedMutation(mutationName, multiQueryName, ({ mutation, query }) => { - let results = query.result; - const document = get(mutation, `result.data['${mutationName}'.data]`, {}); - - results[multiResolverName] = removeFromSet(results[multiResolverName], document); - results[multiResolverName].__typename = `Multi${typeName}Output`; - // console.log('// delete') - // console.log(mutation); - // console.log(query); - // console.log(parameters); - // console.log(results); - return results; - }); - } + if (Meteor.isClient) { + registerWatchedMutations(mutations, typeName); } return mutations; diff --git a/packages/vulcan-core/package.js b/packages/vulcan-core/package.js index 3868867d6..1bb3b84ad 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.12.17', + version: '1.13.0', git: 'https://github.com/VulcanJS/Vulcan.git', }); @@ -9,14 +9,14 @@ Package.onUse(function(api) { api.versionsFrom('1.6.1'); api.use([ - 'vulcan:lib@1.12.17', - 'vulcan:i18n@1.12.17', - 'vulcan:users@1.12.17', - 'vulcan:routing@1.12.17', - 'vulcan:debug@1.12.17', + 'vulcan:lib@1.13.0', + 'vulcan:i18n@1.13.0', + 'vulcan:users@1.13.0', + 'vulcan:routing@1.13.0', + 'vulcan:debug@1.13.0', ]); - api.imply(['vulcan:lib@1.12.17']); + api.imply(['vulcan:lib@1.13.0']); api.mainModule('lib/server/main.js', 'server'); api.mainModule('lib/client/main.js', 'client'); @@ -25,4 +25,5 @@ Package.onUse(function(api) { Package.onTest(function(api) { api.use(['ecmascript', 'meteortesting:mocha', 'vulcan:test', 'vulcan:core']); api.mainModule('./test/index.js'); + api.mainModule('./test/client/index.js', ['client']); }); diff --git a/packages/vulcan-core/test/client/index.js b/packages/vulcan-core/test/client/index.js new file mode 100644 index 000000000..c49a4a470 --- /dev/null +++ b/packages/vulcan-core/test/client/index.js @@ -0,0 +1 @@ +import './mutations.test'; \ No newline at end of file diff --git a/packages/vulcan-core/test/client/mutations.test.js b/packages/vulcan-core/test/client/mutations.test.js new file mode 100644 index 000000000..344cef32b --- /dev/null +++ b/packages/vulcan-core/test/client/mutations.test.js @@ -0,0 +1,19 @@ +import { registerWatchedMutations } from '../../lib/modules/default_mutations'; +import expect from 'expect'; + +describe('vulcan:core/registerWatchedMutations', function(){ + it('should registerWatchedMutations without failing', function(){ + registerWatchedMutations({ + create:{ + name:'createFoo' + }, + update:{ + name:'updateFoo' + }, + delete:{ + name: 'deleteFoo' + } + }, 'Foo'); + expect(true).toBe(true); + }); +}); \ No newline at end of file diff --git a/packages/vulcan-core/test/index.js b/packages/vulcan-core/test/index.js index 1fe7eec6d..d6ee0c346 100644 --- a/packages/vulcan-core/test/index.js +++ b/packages/vulcan-core/test/index.js @@ -1,3 +1,4 @@ +import './mutations.test'; import './resolvers.test'; import './components.test'; import './containers.test'; diff --git a/packages/vulcan-core/test/mutations.test.js b/packages/vulcan-core/test/mutations.test.js new file mode 100644 index 000000000..d9c0f2687 --- /dev/null +++ b/packages/vulcan-core/test/mutations.test.js @@ -0,0 +1,29 @@ +import { getDefaultMutations } from '../lib/modules/default_mutations'; + +import expect from 'expect'; + + +describe('vulcan:core/default_mutations', function() { + + it('returns mutations', function(){ + const mutations = getDefaultMutations({ + typeName:'Foo', + collectionName:'Foos', + options: {} + }); + expect(mutations.create).toBeDefined(); + expect(mutations.update).toBeDefined(); + expect(mutations.delete).toBeDefined(); + }); + it('preserves openCRUD backward compatibility', function(){ + const mutations = getDefaultMutations({ + typeName:'Foo', + collectionName:'Foos', + options: {} + }); + expect(mutations.new).toBeDefined(); + expect(mutations.edit).toBeDefined(); + expect(mutations.remove).toBeDefined(); + }); + +}); \ No newline at end of file diff --git a/packages/vulcan-debug/package.js b/packages/vulcan-debug/package.js index 99a5f29c4..1e6153d27 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.12.17', + version: '1.13.0', git: 'https://github.com/VulcanJS/Vulcan.git', debugOnly: true, }); @@ -15,8 +15,8 @@ Package.onUse(function(api) { // Vulcan packages - 'vulcan:lib@1.12.17', - 'vulcan:email@1.12.17', + 'vulcan:lib@1.13.0', + 'vulcan:email@1.13.0', ]); api.addFiles(['lib/stylesheets/debug.scss'], ['client']); diff --git a/packages/vulcan-email/package.js b/packages/vulcan-email/package.js index a8f1f0eee..0b583e12c 100644 --- a/packages/vulcan-email/package.js +++ b/packages/vulcan-email/package.js @@ -1,14 +1,14 @@ Package.describe({ name: 'vulcan:email', summary: 'Vulcan email package', - version: '1.12.17', + version: '1.13.0', git: 'https://github.com/VulcanJS/Vulcan.git', }); Package.onUse(function(api) { api.versionsFrom('1.6.1'); - api.use(['vulcan:lib@1.12.17']); + api.use(['vulcan:lib@1.13.0']); api.mainModule('lib/server.js', 'server'); api.mainModule('lib/client.js', 'client'); diff --git a/packages/vulcan-embed/package.js b/packages/vulcan-embed/package.js index 7ff649f27..6d42f14e7 100644 --- a/packages/vulcan-embed/package.js +++ b/packages/vulcan-embed/package.js @@ -1,14 +1,14 @@ Package.describe({ name: 'vulcan:embed', summary: 'Vulcan Embed package', - version: '1.12.17', + version: '1.13.0', git: 'https://github.com/VulcanJS/Vulcan.git', }); Package.onUse(function(api) { api.versionsFrom('1.6.1'); - api.use(['http', 'vulcan:core@1.12.17', 'fourseven:scss@4.10.0']); + api.use(['http', 'vulcan:core@1.13.0', 'fourseven:scss@4.10.0']); api.addFiles(['lib/stylesheets/embedly.scss'], ['client']); diff --git a/packages/vulcan-errors-sentry/package.js b/packages/vulcan-errors-sentry/package.js index 4a74bd211..f8a669c41 100755 --- a/packages/vulcan-errors-sentry/package.js +++ b/packages/vulcan-errors-sentry/package.js @@ -1,14 +1,14 @@ Package.describe({ name: 'vulcan:errors-sentry', summary: 'Vulcan Sentry error tracking package', - version: '1.12.17', + version: '1.13.0', git: 'https://github.com/VulcanJS/Vulcan.git', }); Package.onUse(function(api) { api.versionsFrom('1.6.1'); - api.use(['ecmascript', 'vulcan:core@1.12.17', 'vulcan:users@1.12.17', 'vulcan:errors@1.12.17']); + api.use(['ecmascript', 'vulcan:core@1.13.0', 'vulcan:users@1.13.0', 'vulcan:errors@1.13.0']); api.mainModule('lib/server/main.js', 'server'); api.mainModule('lib/client/main.js', 'client'); diff --git a/packages/vulcan-errors/package.js b/packages/vulcan-errors/package.js index ece2e44c5..550c34a54 100644 --- a/packages/vulcan-errors/package.js +++ b/packages/vulcan-errors/package.js @@ -1,14 +1,14 @@ Package.describe({ name: 'vulcan:errors', summary: 'Vulcan error tracking package', - version: '1.12.17', + version: '1.13.0', git: 'https://github.com/VulcanJS/Vulcan.git', }); Package.onUse(function(api) { api.versionsFrom('1.6.1'); - api.use(['ecmascript', 'vulcan:core@1.12.17']); + api.use(['ecmascript', 'vulcan:core@1.13.0']); api.mainModule('lib/server/main.js', 'server'); api.mainModule('lib/client/main.js', 'client'); diff --git a/packages/vulcan-events-ga/package.js b/packages/vulcan-events-ga/package.js index e220d3194..13dbf655d 100644 --- a/packages/vulcan-events-ga/package.js +++ b/packages/vulcan-events-ga/package.js @@ -1,14 +1,14 @@ Package.describe({ name: 'vulcan:events-ga', summary: 'Vulcan Google Analytics event tracking package', - version: '1.12.17', + version: '1.13.0', git: 'https://github.com/VulcanJS/Vulcan.git', }); Package.onUse(function(api) { api.versionsFrom('1.6.1'); - api.use(['vulcan:core@1.12.17', 'vulcan:events@1.12.17']); + api.use(['vulcan:core@1.13.0', 'vulcan:events@1.13.0']); api.mainModule('lib/server/main.js', 'server'); api.mainModule('lib/client/main.js', 'client'); diff --git a/packages/vulcan-events-intercom/package.js b/packages/vulcan-events-intercom/package.js index 8d8a7cdcb..eaf6af7e4 100644 --- a/packages/vulcan-events-intercom/package.js +++ b/packages/vulcan-events-intercom/package.js @@ -1,14 +1,14 @@ Package.describe({ name: 'vulcan:events-intercom', summary: 'Vulcan Intercom integration package.', - version: '1.12.17', + version: '1.13.0', git: 'https://github.com/VulcanJS/Vulcan.git', }); Package.onUse(function(api) { api.versionsFrom('1.6.1'); - api.use(['vulcan:core@1.12.17', 'vulcan:events@1.12.17']); + api.use(['vulcan:core@1.13.0', 'vulcan:events@1.13.0']); api.mainModule('lib/client/main.js', 'client'); api.mainModule('lib/server/main.js', 'server'); diff --git a/packages/vulcan-events-internal/package.js b/packages/vulcan-events-internal/package.js index 68fedb9e9..1d8d8f6e1 100644 --- a/packages/vulcan-events-internal/package.js +++ b/packages/vulcan-events-internal/package.js @@ -1,14 +1,14 @@ Package.describe({ name: 'vulcan:events-internal', summary: 'Vulcan internal event tracking package', - version: '1.12.17', + version: '1.13.0', git: 'https://github.com/VulcanJS/Vulcan.git', }); Package.onUse(function(api) { api.versionsFrom('1.6.1'); - api.use(['vulcan:core@1.12.17', 'vulcan:events@1.12.17']); + api.use(['vulcan:core@1.13.0', 'vulcan:events@1.13.0']); api.mainModule('lib/server/main.js', 'server'); api.mainModule('lib/client/main.js', 'client'); diff --git a/packages/vulcan-events-segment/package.js b/packages/vulcan-events-segment/package.js index 06e413d9b..a455202ee 100644 --- a/packages/vulcan-events-segment/package.js +++ b/packages/vulcan-events-segment/package.js @@ -1,14 +1,14 @@ Package.describe({ name: 'vulcan:events-segment', summary: 'Vulcan Segment', - version: '1.12.17', + version: '1.13.0', git: 'https://github.com/VulcanJS/Vulcan.git', }); Package.onUse(function(api) { api.versionsFrom('1.6.1'); - api.use(['vulcan:core@1.12.17', 'vulcan:events@1.12.17']); + api.use(['vulcan:core@1.13.0', 'vulcan:events@1.13.0']); api.mainModule('lib/server/main.js', 'server'); api.mainModule('lib/client/main.js', 'client'); diff --git a/packages/vulcan-events/package.js b/packages/vulcan-events/package.js index 5c59a1e5a..469c906b2 100644 --- a/packages/vulcan-events/package.js +++ b/packages/vulcan-events/package.js @@ -1,14 +1,14 @@ Package.describe({ name: 'vulcan:events', summary: 'Vulcan event tracking package', - version: '1.12.17', + version: '1.13.0', git: 'https://github.com/VulcanJS/Vulcan.git', }); Package.onUse(function(api) { api.versionsFrom('1.6.1'); - api.use(['vulcan:core@1.12.17']); + api.use(['vulcan:core@1.13.0']); 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 6da54d2f8..4cf501499 100644 --- a/packages/vulcan-forms-tags/package.js +++ b/packages/vulcan-forms-tags/package.js @@ -1,14 +1,14 @@ Package.describe({ name: 'vulcan:forms-tags', summary: 'Vulcan tag input package', - version: '1.12.17', + version: '1.13.0', git: 'https://github.com/VulcanJS/Vulcan.git', }); Package.onUse(function(api) { api.versionsFrom('1.6.1'); - api.use(['vulcan:core@1.12.17', 'vulcan:forms@1.12.17']); + api.use(['vulcan:core@1.13.0', 'vulcan:forms@1.13.0']); api.mainModule('lib/export.js', ['client', 'server']); }); diff --git a/packages/vulcan-forms-upload/package.js b/packages/vulcan-forms-upload/package.js index 2cafe0aa8..3269975de 100755 --- a/packages/vulcan-forms-upload/package.js +++ b/packages/vulcan-forms-upload/package.js @@ -1,14 +1,14 @@ Package.describe({ name: 'vulcan:forms-upload', summary: 'Vulcan package extending vulcan:forms to upload images to Cloudinary from a drop zone.', - version: '1.12.17', + version: '1.13.0', git: 'https://github.com/xavcz/nova-forms-upload.git', }); Package.onUse(function(api) { api.versionsFrom('1.6.1'); - api.use(['vulcan:core@1.12.17', 'vulcan:forms@1.12.17', 'fourseven:scss@4.10.0']); + api.use(['vulcan:core@1.13.0', 'vulcan:forms@1.13.0', 'fourseven:scss@4.10.0']); api.addFiles(['lib/Upload.scss'], 'client'); diff --git a/packages/vulcan-forms/lib/components/Form.jsx b/packages/vulcan-forms/lib/components/Form.jsx index 3b4659f36..db2201f53 100644 --- a/packages/vulcan-forms/lib/components/Form.jsx +++ b/packages/vulcan-forms/lib/components/Form.jsx @@ -66,7 +66,7 @@ import { callbackProps } from './propTypes'; // props that should trigger a form reset const RESET_PROPS = [ - 'collection', 'collectionName', 'typeName', 'document', 'schema', 'currentUser', + 'collection', 'collectionName', 'typeName', 'document', 'schema', 'currentUser', 'fields', 'removeFields', 'prefilledProps' // TODO: prefilledProps should be merged instead? ]; @@ -104,17 +104,17 @@ const getInitialStateFromProps = nextProps => { nextProps.prefilledProps, nextProps.document ); - + //if minCount is specified, go ahead and create empty nested documents Object.keys(convertedSchema).forEach(key => { let minCount = convertedSchema[key].minCount; - if(minCount) { + if (minCount) { initialDocument[key] = initialDocument[key] || []; - while(initialDocument[key].length < minCount) + while (initialDocument[key].length < minCount) initialDocument[key].push({}); } }); - + // remove all instances of the `__typename` property from document Utils.removeProperty(initialDocument, '__typename'); @@ -154,7 +154,7 @@ class SmartForm extends Component { }; } - defaultValues = {}; + defaultValues = {}; submitFormCallbacks = []; successFormCallbacks = []; @@ -266,7 +266,7 @@ class SmartForm extends Component { }); // run data object through submitForm callbacks - data = runCallbacks({ callbacks: this.submitFormCallbacks, iterator: data, properties: { form: this }}); + data = runCallbacks({ callbacks: this.submitFormCallbacks, iterator: data, properties: { form: this } }); return data; }; @@ -737,8 +737,72 @@ class SmartForm extends Component { /* - Warn the user if there are unsaved changes + Install a route leave hook to warn the user if there are unsaved changes + */ + componentDidMount = () => { + this.checkRouteChange(); + this.checkBrowserClosing(); + } + + /* + Remove the closing browser check on component unmount + see https://gist.github.com/mknabe/bfcb6db12ef52323954a28655801792d + */ + componentWillUnmount = () => { + if (this.getWarnUnsavedChanges()) { + // unblock route change + if (this.unblock) { + this.unblock(); + } + // unblock browser change + window.onbeforeunload = undefined; //undefined instead of null to support IE + } + }; + + + // -------------------- Check on form leaving ----- // + + /** + * Check if we must warn user on unsaved change + */ + getWarnUnsavedChanges = () => { + let warnUnsavedChanges = getSetting('forms.warnUnsavedChanges'); + if (typeof this.props.warnUnsavedChanges === 'boolean') { + warnUnsavedChanges = this.props.warnUnsavedChanges; + } + return warnUnsavedChanges; + } + + // check for route change, prevent form content loss + checkRouteChange = () => { + // @see https://github.com/ReactTraining/react-router/issues/4635#issuecomment-297828995 + // @see https://github.com/ReactTraining/history#blocking-transitions + if (this.getWarnUnsavedChanges()) { + this.unblock = this.props.history.block((location, action) => { + // return the message that will pop into a window.confirm alert + // if returns nothing, the message won't appear and the user won't be blocked + return this.handleRouteLeave(); + + /* + // React-router 3 implementtion + const routes = this.props.router.routes; + const currentRoute = routes[routes.length - 1]; + this.props.router.setRouteLeaveHook(currentRoute, this.handleRouteLeave); + + */ + }); + } + } + // check for browser closing + checkBrowserClosing = () => { + //check for closing the browser with unsaved changes too + window.onbeforeunload = this.handlePageLeave; + } + + /* + Check if the user has unsaved changes, returns a message if yes + and nothing if not */ handleRouteLeave = () => { if (this.isChanged()) { @@ -750,9 +814,13 @@ class SmartForm extends Component { } }; - //see https://developer.mozilla.org/en-US/docs/Web/API/WindowEventHandlers/onbeforeunload - //the message returned is actually ignored by most browsers and a default message 'Are you sure you want to leave this page? You might have unsaved changes' is displayed. See the Notes section on the mozilla docs above - handlePageLeave = event => { + /** + * Same for browser closing + * + * see https://developer.mozilla.org/en-US/docs/Web/API/WindowEventHandlers/onbeforeunload + * the message returned is actually ignored by most browsers and a default message 'Are you sure you want to leave this page? You might have unsaved changes' is displayed. See the Notes section on the mozilla docs above + */ + handlePageLeave = (event) => { if (this.isChanged()) { const message = this.context.intl.formatMessage({ id: 'forms.confirm_discard', @@ -765,41 +833,6 @@ class SmartForm extends Component { return message; } }; - - /* - - Install a route leave hook to warn the user if there are unsaved changes - - */ - componentDidMount = () => { - let warnUnsavedChanges = getSetting('forms.warnUnsavedChanges'); - if (typeof this.props.warnUnsavedChanges === 'boolean') { - warnUnsavedChanges = this.props.warnUnsavedChanges; - } - if (warnUnsavedChanges) { - const routes = this.props.router.routes; - const currentRoute = routes[routes.length - 1]; - this.props.router.setRouteLeaveHook(currentRoute, this.handleRouteLeave); - - //check for closing the browser with unsaved changes - window.onbeforeunload = this.handlePageLeave; - } - }; - - /* - Remove the closing browser check on component unmount - see https://gist.github.com/mknabe/bfcb6db12ef52323954a28655801792d - */ - componentWillUnmount = () => { - let warnUnsavedChanges = getSetting('forms.warnUnsavedChanges'); - if (typeof this.props.warnUnsavedChanges === 'boolean') { - warnUnsavedChanges = this.props.warnUnsavedChanges; - } - if (warnUnsavedChanges) { - window.onbeforeunload = undefined; //undefined instead of null to support IE - } - }; - /* Returns true if there are any differences between the initial document and the current one @@ -899,7 +932,7 @@ class SmartForm extends Component { } // run document through mutation success callbacks - document = runCallbacks({ callbacks: this.successFormCallbacks, iterator: document, properties: { form: this }}); + document = runCallbacks({ callbacks: this.successFormCallbacks, iterator: document, properties: { form: this } }); // run success callback if it exists if (this.props.successCallback) this.props.successCallback(document, { form: this }); @@ -915,7 +948,7 @@ class SmartForm extends Component { console.log(error); // run mutation failure callbacks on error, we do not allow the callbacks to change the error - runCallbacks({ callbacks: this.failureFormCallbacks, iterator: error, properties: { error, form: this }}); + runCallbacks({ callbacks: this.failureFormCallbacks, iterator: error, properties: { error, form: this } }); if (!_.isEmpty(error)) { // add error to state @@ -937,7 +970,7 @@ class SmartForm extends Component { submitForm = event => { event && event.preventDefault(); - + // if form is disabled (there is already a submit handler running) don't do anything if (this.state.disabled) { return; @@ -1003,7 +1036,7 @@ class SmartForm extends Component { } }; - + // --------------------------------------------------------------------- // // ------------------------- Props to Pass ----------------------------- // // --------------------------------------------------------------------- // @@ -1045,15 +1078,15 @@ class SmartForm extends Component { cancelCallback: this.props.cancelCallback, revertCallback: this.props.revertCallback, document: this.getDocument(), - deleteDocument: + deleteDocument: (this.getFormType() === 'edit' && this.props.showRemove && this.deleteDocument) || null, - collectionName:this.props.collectionName, - currentValues:this.state.currentValues, - deletedValues:this.state.deletedValues, - errors:this.state.errors, + collectionName: this.props.collectionName, + currentValues: this.state.currentValues, + deletedValues: this.state.deletedValues, + errors: this.state.errors, }); // --------------------------------------------------------------------- // diff --git a/packages/vulcan-forms/lib/components/FormElement.jsx b/packages/vulcan-forms/lib/components/FormElement.jsx new file mode 100644 index 000000000..fc0a619df --- /dev/null +++ b/packages/vulcan-forms/lib/components/FormElement.jsx @@ -0,0 +1,14 @@ +import React from 'react'; +import { registerComponent } from 'meteor/vulcan:core'; + +// this component receives a ref, so it must be a class component +class FormElement extends React.Component { + render(){ + const { children, ...otherProps } = this.props; + return
{children}
; + } +} +registerComponent({ + name:'FormElement', + component: FormElement +}); \ 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 f820acd89..9d6c649e7 100644 --- a/packages/vulcan-forms/lib/modules/components.js +++ b/packages/vulcan-forms/lib/modules/components.js @@ -1,4 +1,5 @@ import '../components/FieldErrors.jsx'; +import '../components/FormElement.jsx'; import '../components/FormErrors.jsx'; import '../components/FormError.jsx'; import '../components/FormComponent.jsx'; diff --git a/packages/vulcan-forms/package.js b/packages/vulcan-forms/package.js index b42014aca..5fdfc9e36 100644 --- a/packages/vulcan-forms/package.js +++ b/packages/vulcan-forms/package.js @@ -1,14 +1,14 @@ Package.describe({ name: 'vulcan:forms', summary: 'Form containers for React', - version: '1.12.17', + version: '1.13.0', git: 'https://github.com/meteor-utilities/react-form-containers.git', }); Package.onUse(function(api) { api.versionsFrom('1.6.1'); - api.use(['vulcan:core@1.12.17']); + api.use(['vulcan:core@1.13.0']); api.mainModule('lib/client/main.js', ['client']); api.mainModule('lib/server/main.js', ['server']); diff --git a/packages/vulcan-i18n-en-us/package.js b/packages/vulcan-i18n-en-us/package.js index 202171609..cc863b098 100644 --- a/packages/vulcan-i18n-en-us/package.js +++ b/packages/vulcan-i18n-en-us/package.js @@ -1,14 +1,14 @@ Package.describe({ name: 'vulcan:i18n-en-us', summary: 'Vulcan i18n package (en_US)', - version: '1.12.17', + version: '1.13.0', git: 'https://github.com/VulcanJS/Vulcan.git', }); Package.onUse(function(api) { api.versionsFrom('1.6.1'); - api.use(['vulcan:core@1.12.17']); + api.use(['vulcan:core@1.13.0']); api.addFiles(['lib/en_US.js'], ['client', 'server']); }); diff --git a/packages/vulcan-i18n-es-es/package.js b/packages/vulcan-i18n-es-es/package.js index a742182d6..816480e2e 100644 --- a/packages/vulcan-i18n-es-es/package.js +++ b/packages/vulcan-i18n-es-es/package.js @@ -1,14 +1,14 @@ Package.describe({ name: 'vulcan:i18n-es-es', summary: 'Vulcan i18n package (es_ES)', - version: '1.12.17', + version: '1.13.0', git: 'https://github.com/VulcanJS/Vulcan.git', }); Package.onUse(function(api) { api.versionsFrom('1.6.1'); - api.use(['vulcan:core@1.12.17']); + api.use(['vulcan:core@1.13.0']); api.addFiles(['lib/es_ES.js'], ['client', 'server']); }); diff --git a/packages/vulcan-i18n-fr-fr/package.js b/packages/vulcan-i18n-fr-fr/package.js index 2da96b969..253847c73 100644 --- a/packages/vulcan-i18n-fr-fr/package.js +++ b/packages/vulcan-i18n-fr-fr/package.js @@ -1,14 +1,14 @@ Package.describe({ name: 'vulcan:i18n-fr-fr', summary: 'Vulcan i18n package (fr_FR)', - version: '1.12.17', + version: '1.13.0', git: 'https://github.com/VulcanJS/Vulcan.git', }); Package.onUse(function(api) { api.versionsFrom('1.6.1'); - api.use(['vulcan:core@1.12.17']); + api.use(['vulcan:core@1.13.0']); api.addFiles(['lib/fr_FR.js'], ['client', 'server']); }); diff --git a/packages/vulcan-i18n/package.js b/packages/vulcan-i18n/package.js index d9e3ae27f..a57538aa8 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.12.17', + version: '1.13.0', git: 'https://github.com/VulcanJS/Vulcan', }); Package.onUse(function(api) { api.versionsFrom('1.6.1'); - api.use(['vulcan:lib@1.12.17']); + api.use(['vulcan:lib@1.13.0']); api.mainModule('lib/server/main.js', 'server'); api.mainModule('lib/client/main.js', 'client'); diff --git a/packages/vulcan-lib/lib/client/apollo-client/links/error.js b/packages/vulcan-lib/lib/client/apollo-client/links/error.js index cbe60daaf..57b6b670c 100644 --- a/packages/vulcan-lib/lib/client/apollo-client/links/error.js +++ b/packages/vulcan-lib/lib/client/apollo-client/links/error.js @@ -1,10 +1,11 @@ import { onError } from 'apollo-link-error'; +const locationsToStr = (locations=[]) => locations.map(({column, line}) => `line ${line}, col ${column}`).join(';'); const errorLink = onError(({ graphQLErrors, networkError }) => { if (graphQLErrors) graphQLErrors.map(({ message, locations, path }) => { // eslint-disable-next-line no-console - console.log(`[GraphQL error]: Message: ${message}, Location: ${locations}, Path: ${path}`); + console.log(`[GraphQL error]: Message: ${message}, Location: ${locationsToStr(locations)}, Path: ${path}`); }); if (networkError) { // eslint-disable-next-line no-console diff --git a/packages/vulcan-lib/lib/modules/config.js b/packages/vulcan-lib/lib/modules/config.js index 4ac9c276b..67c8e8a63 100644 --- a/packages/vulcan-lib/lib/modules/config.js +++ b/packages/vulcan-lib/lib/modules/config.js @@ -9,7 +9,7 @@ import SimpleSchema from 'simpl-schema'; Vulcan = {}; // eslint-disable-next-line no-undef -Vulcan.VERSION = '1.12.17'; +Vulcan.VERSION = '1.13.0'; // ------------------------------------- Schemas -------------------------------- // diff --git a/packages/vulcan-lib/lib/modules/errors.js b/packages/vulcan-lib/lib/modules/errors.js index 3f350abea..24a74f20b 100644 --- a/packages/vulcan-lib/lib/modules/errors.js +++ b/packages/vulcan-lib/lib/modules/errors.js @@ -18,6 +18,8 @@ const getFirstWord = input => { Parse a GraphQL error message +TODO: check if still useful? + Sample message: "GraphQL error: Variable "$data" got invalid value {"meetingDate":"2018-08-07T06:05:51.704Z"}. @@ -28,6 +30,11 @@ In field "addresses": Expected "[JSON]!", found null." */ const parseErrorMessage = message => { + + if (!message) { + return null; + } + // note: optionally add .slice(1) at the end to get rid of the first error, which is not that helpful let fieldErrors = message.split('\n'); @@ -52,6 +59,7 @@ const parseErrorMessage = message => { }); return fieldErrors; }; + /* Errors can have the following properties stored on their `data` property: @@ -60,37 +68,17 @@ Errors can have the following properties stored on their `data` property: - properties: additional data. Will be passed to vulcan-i18n as values - message: if id cannot be used as i81n key, message will be used -Scenario 1: normal error thrown with new Error(), put it in array and return it - -Scenario 2: multiple GraphQL errors stored on data.errors - -Scenario 3: single GraphQL error with data property - -Scenario 4: single GraphQL error with no data property - */ export const getErrors = error => { - // 1. by default, return raw error wrapped in array - let errors = [error]; + const graphQLErrors = error.graphQLErrors; + + // error thrown using new ApolloError + const apolloErrors = get(graphQLErrors, '0.extensions.exception.data.errors'); - // if this is one or more GraphQL errors, extract and convert them - if (error.graphQLErrors && error.graphQLErrors.length > 0) { - // get first graphQL error (see https://github.com/thebigredgeek/apollo-errors/issues/12) - const graphQLError = error.graphQLErrors[0]; - const data = get(graphQLError, 'extensions.exception.data') - if (data && !isEmpty(data)) { - if (data.errors) { - // 2. there are multiple errors on the data.errors object - errors = data.errors; - } else { - // 3. there is only one error - errors = [data]; - } - } else { - // 4. there is no data object, try to parse raw error message - errors = parseErrorMessage(graphQLError.message); - } - } - return errors; + // regular server error (with schema stitching) + const regularErrors = get(graphQLErrors, '0.extensions.exception.errors'); + + return apolloErrors || regularErrors || graphQLErrors; + }; diff --git a/packages/vulcan-lib/lib/server/apollo-server/apollo_server2.js b/packages/vulcan-lib/lib/server/apollo-server/apollo_server.js similarity index 100% rename from packages/vulcan-lib/lib/server/apollo-server/apollo_server2.js rename to packages/vulcan-lib/lib/server/apollo-server/apollo_server.js diff --git a/packages/vulcan-lib/lib/server/apollo-server/index.js b/packages/vulcan-lib/lib/server/apollo-server/index.js index 502f69a5a..b7d314606 100644 --- a/packages/vulcan-lib/lib/server/apollo-server/index.js +++ b/packages/vulcan-lib/lib/server/apollo-server/index.js @@ -1,4 +1,4 @@ -export * from './apollo_server2'; +export * from './apollo_server'; export * from './settings'; export { default as initGraphQL } from './initGraphQL'; diff --git a/packages/vulcan-lib/lib/server/apollo-server/initGraphQL.js b/packages/vulcan-lib/lib/server/apollo-server/initGraphQL.js index 789ccbe62..e0e73f481 100644 --- a/packages/vulcan-lib/lib/server/apollo-server/initGraphQL.js +++ b/packages/vulcan-lib/lib/server/apollo-server/initGraphQL.js @@ -75,7 +75,8 @@ const initGraphQL = () => { resolvers: GraphQLSchema.resolvers, schemaDirectives: GraphQLSchema.directives, }); - const mergedSchema = mergeSchemas({ schemas: [executableSchema, ...GraphQLSchema.stitchedSchemas] }); + // only call mergeSchemas if we actually have stitchedSchemas + const mergedSchema = GraphQLSchema.stitchedSchemas.length > 0 ? mergeSchemas({ schemas: [executableSchema, ...GraphQLSchema.stitchedSchemas] }) : executableSchema; GraphQLSchema.finalSchema = typeDefs; GraphQLSchema.executableSchema = mergedSchema; diff --git a/packages/vulcan-lib/lib/server/apollo-server/startup.js b/packages/vulcan-lib/lib/server/apollo-server/startup.js index bf4fcb44a..61f12d86c 100644 --- a/packages/vulcan-lib/lib/server/apollo-server/startup.js +++ b/packages/vulcan-lib/lib/server/apollo-server/startup.js @@ -1,3 +1,3 @@ -const { onStart } = require('./apollo_server2'); +const { onStart } = require('./apollo_server'); // createApolloServer when server startup Meteor.startup(onStart); diff --git a/packages/vulcan-lib/lib/server/apollo_server.js b/packages/vulcan-lib/lib/server/apollo_server.js deleted file mode 100644 index 03b5e8ef1..000000000 --- a/packages/vulcan-lib/lib/server/apollo_server.js +++ /dev/null @@ -1,280 +0,0 @@ -/* 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 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, registerSetting } from '../modules/settings.js'; -import { Collections } from '../modules/collections.js'; -import findByIds from '../modules/findbyids.js'; -import { runCallbacks } from '../modules/callbacks.js'; -import cookiesMiddleware from 'universal-cookie-express'; -// import Cookies from 'universal-cookie'; -import { _hashLoginToken, _tokenExpiration } from './accounts_helpers'; -import { getHeaderLocale } from './intl.js'; - -export let executableSchema; - -registerSetting('apolloEngine.logLevel', 'INFO', 'Log level (one of INFO, DEBUG, WARN, ERROR'); -registerSetting('apolloServer.tracing', Meteor.isDevelopment, 'Tracing by Apollo. Default is true on development and false on prod', true); - -// see https://github.com/apollographql/apollo-cache-control -const engineApiKey = getSetting('apolloEngine.apiKey'); -const engineLogLevel = getSetting('apolloEngine.logLevel', 'INFO'); -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': engineLogLevel - } -}; -let engine; -if (engineApiKey) { - engine = new Engine({ engineConfig }); - engine.start(); -} - -// defaults -const defaultConfig = { - path: '/graphql', - maxAccountsCacheSizeInMB: 1, - graphiql: Meteor.isDevelopment, - graphiqlPath: '/graphiql', - graphiqlOptions: { - passHeader: "'Authorization': localStorage['Meteor.loginToken']", // eslint-disable-line quotes - }, - configServer: (graphQLServer) => { }, -}; - -const defaultOptions = { - formatError: e => ({ - message: e.message, - locations: e.locations, - path: e.path, - }), -}; - -if (Meteor.isDevelopment) { - defaultOptions.debug = true; -} - -// createApolloServer -const createApolloServer = (givenOptions = {}, givenConfig = {}) => { - const graphiqlOptions = { ...defaultConfig.graphiqlOptions, ...givenConfig.graphiqlOptions }; - const config = { ...defaultConfig, ...givenConfig }; - config.graphiqlOptions = graphiqlOptions; - - const graphQLServer = express(); - - config.configServer(graphQLServer); - - // Use Engine middleware - if (engineApiKey) { - graphQLServer.use(engine.expressMiddleware()); - } - - // cookies - graphQLServer.use(cookiesMiddleware()); - - // compression - graphQLServer.use(compression()); - - // GraphQL endpoint - graphQLServer.use(config.path, bodyParser.json({ limit: getSetting('apolloServer.jsonParserOptions.limit') }), graphqlExpress(async (req) => { - let options; - let user = null; - - if (typeof givenOptions === 'function') { - options = givenOptions(req); - } else { - options = givenOptions; - } - - // Merge in the defaults - options = { ...defaultOptions, ...options }; - if (options.context) { - // don't mutate the context provided in options - options.context = { ...options.context }; - } else { - options.context = {}; - } - - // enable tracing and caching - options.tracing = getSetting('apolloServer.tracing', Meteor.isDevelopment); - options.cacheControl = true; - - // note: custom default resolver doesn't currently work - // see https://github.com/apollographql/apollo-server/issues/716 - // options.fieldResolver = (source, args, context, info) => { - // return source[info.fieldName]; - // } - - // console.log('// apollo_server.js req.renderContext'); - // console.log(req.renderContext); - // console.log('\n\n'); - - // Get the token from the header - if (req.headers.authorization) { - const token = req.headers.authorization; - check(token, String); - const hashedToken = _hashLoginToken(token); - - // Get the user from the database - user = await Meteor.users.findOne( - { 'services.resume.loginTokens.hashedToken': hashedToken }, - ); - - 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 = _tokenExpiration(loginToken.when); - const isExpired = expiresAt < new Date(); - - if (!isExpired) { - options.context.userId = user._id; - options.context.currentUser = user; - } - } - } - - //add the headers to the context - options.context.headers = req.headers; - - - // merge with custom context - options.context = deepmerge(options.context, GraphQLSchema.context); - - // go over context and add Dataloader to each collection - Collections.forEach(collection => { - options.context[collection.options.collectionName].loader = new DataLoader(ids => findByIds(collection, ids, options.context), { cache: true }); - }); - - // look for headers either in renderContext (SSR) or req (normal request to the endpoint) - const headers = req.renderContext.originalHeaders || req.headers; - - options.context.locale = getHeaderLocale(headers, user && user.locale); - - if (headers.apikey && (headers.apikey === getSetting('vulcan.apiKey'))) { - options.context.currentUser = { isAdmin: true, isApiUser: true }; - } - // console.log('// apollo_server.js isSSR?', !!req.renderContext.originalHeaders ? 'yes' : 'no'); - // console.log('// apollo_server.js headers:'); - // console.log(headers); - // console.log('// apollo_server.js final locale: ', options.context.locale); - // console.log('\n\n'); - - // add error formatting from apollo-errors - options.formatError = formatError; - - return options; - })); - - // Start GraphiQL if enabled - if (config.graphiql) { - graphQLServer.use(config.graphiqlPath, graphiqlExpress({ ...config.graphiqlOptions, endpointURL: config.path })); - } - - // This binds the specified paths to the Express server running Apollo + GraphiQL - webAppConnectHandlersUse(Meteor.bindEnvironment(graphQLServer), { - name: 'graphQLServerMiddleware_bindEnvironment', - order: 30, - }); -}; - -// createApolloServer when server startup -Meteor.startup(() => { - - runCallbacks('graphql.init.before'); - - // typeDefs - const generateTypeDefs = () => [` -scalar JSON -scalar Date - -${GraphQLSchema.getAdditionalSchemas()} - -${GraphQLSchema.getCollectionsSchemas()} - -type Query { - -${GraphQLSchema.queries.map(q => ( - `${q.description ? ` # ${q.description} -` : ''} ${q.query} - `)).join('\n')} -} - -${GraphQLSchema.mutations.length > 0 ? `type Mutation { - -${GraphQLSchema.mutations.map(m => ( -`${m.description ? ` # ${m.description} -` : ''} ${m.mutation} -`)).join('\n')} -} -` : ''} -`]; - - const typeDefs = generateTypeDefs(); - - GraphQLSchema.finalSchema = typeDefs; - - executableSchema = makeExecutableSchema({ - typeDefs, - resolvers: GraphQLSchema.resolvers, - schemaDirectives: GraphQLSchema.directives, - }); - - createApolloServer({ - schema: executableSchema, - }); -}); - */ diff --git a/packages/vulcan-lib/lib/server/errors.js b/packages/vulcan-lib/lib/server/errors.js index cd7d70e8b..000b3870d 100644 --- a/packages/vulcan-lib/lib/server/errors.js +++ b/packages/vulcan-lib/lib/server/errors.js @@ -1,4 +1,4 @@ -import { ApolloError } from 'apollo-server'; +import { UserInputError } from 'apollo-server'; /* @@ -11,5 +11,5 @@ An error should have: */ export const throwError = error => { const { id, } = error; - throw new ApolloError(id, 'VALIDATION_ERROR', error); + throw new UserInputError(id, error); }; diff --git a/packages/vulcan-lib/lib/server/main.js b/packages/vulcan-lib/lib/server/main.js index 731dafd3f..181b49f18 100644 --- a/packages/vulcan-lib/lib/server/main.js +++ b/packages/vulcan-lib/lib/server/main.js @@ -18,4 +18,6 @@ export * from './intl.js'; export * from './accounts_helpers.js'; export * from './source_version.js'; +export * from './apollo-server/settings.js'; + import './apollo-server/startup'; diff --git a/packages/vulcan-lib/lib/server/mutators.js b/packages/vulcan-lib/lib/server/mutators.js index cf6d856fd..2a422264b 100644 --- a/packages/vulcan-lib/lib/server/mutators.js +++ b/packages/vulcan-lib/lib/server/mutators.js @@ -80,7 +80,7 @@ export const createMutator = async ({ Note: keep newDocument for backwards compatibility */ - const properties = { data, currentUser, collection, context, document, newDocument: document }; + const properties = { data, originalData: clone(data), currentUser, collection, context, document, newDocument: document, schema }; /* @@ -273,7 +273,7 @@ export const updateMutator = async ({ Properties */ - const properties = { data, oldDocument, document, currentUser, collection, context }; + const properties = { data, oldDocument, document, currentUser, collection, context, schema }; /* @@ -473,7 +473,7 @@ export const deleteMutator = async ({ Properties */ - const properties = { document, currentUser, collection, context }; + const properties = { document, currentUser, collection, context, schema }; /* @@ -584,7 +584,7 @@ const startDebugMutator = (name, action, properties) => { }); }; -const endDebugMutator = (name, action, properties) => { +const endDebugMutator = (name, action, properties = {}) => { Object.keys(properties).forEach(p => { debug(`// ${p}: `, properties[p]); }); diff --git a/packages/vulcan-lib/lib/server/query.js b/packages/vulcan-lib/lib/server/query.js index 15f60eb0e..ae573769b 100644 --- a/packages/vulcan-lib/lib/server/query.js +++ b/packages/vulcan-lib/lib/server/query.js @@ -93,9 +93,10 @@ Meteor.startup(() => { Collections.forEach(collection => { const typeName = collection.options.typeName; - collection.queryOne = async (documentId, { fragmentName, fragmentText, context }) => { + collection.queryOne = async (documentIdOrSelector, { fragmentName, fragmentText, context }) => { + const selector = typeof documentIdOrSelector === 'string' ? { documentId: documentIdOrSelector } : documentIdOrSelector; const query = buildQuery(collection, { fragmentName, fragmentText }); - const result = await runQuery(query, { input: { selector: { documentId } } }, context); + const result = await runQuery(query, { input: { selector } }, context); return result.data[Utils.camelCaseify(typeName)].result; }; }); diff --git a/packages/vulcan-lib/package.js b/packages/vulcan-lib/package.js index f2feff9e2..edcd4a486 100644 --- a/packages/vulcan-lib/package.js +++ b/packages/vulcan-lib/package.js @@ -1,7 +1,7 @@ Package.describe({ name: 'vulcan:lib', summary: 'Vulcan libraries.', - version: '1.12.17', + version: '1.13.0', git: 'https://github.com/VulcanJS/Vulcan.git', }); diff --git a/packages/vulcan-lib/test/server/apollo-server.test.js b/packages/vulcan-lib/test/server/apollo-server.test.js index 53fe7bd2c..e6e1d41d7 100644 --- a/packages/vulcan-lib/test/server/apollo-server.test.js +++ b/packages/vulcan-lib/test/server/apollo-server.test.js @@ -1,4 +1,4 @@ -import { createApolloServer } from '../../lib/server/apollo-server/apollo_server2'; +import { createApolloServer } from '../../lib/server/apollo-server/apollo_server'; //import initGraphQL from '../../lib/server/apollo-server/initGraphQL'; //import { GraphQLSchema } from '../../lib/modules/graphql'; import expect from 'expect'; diff --git a/packages/vulcan-newsletter/package.js b/packages/vulcan-newsletter/package.js index 08cf8b80a..4a0a59052 100644 --- a/packages/vulcan-newsletter/package.js +++ b/packages/vulcan-newsletter/package.js @@ -1,14 +1,14 @@ Package.describe({ name: 'vulcan:newsletter', summary: 'Vulcan email newsletter package', - version: '1.12.17', + version: '1.13.0', git: 'https://github.com/VulcanJS/Vulcan.git', }); Package.onUse(function(api) { api.versionsFrom('1.6.1'); - api.use(['vulcan:core@1.12.17', 'vulcan:email@1.12.17']); + api.use(['vulcan:core@1.13.0', 'vulcan:email@1.13.0']); api.mainModule('lib/server/main.js', 'server'); api.mainModule('lib/client/main.js', 'client'); diff --git a/packages/vulcan-payments/package.js b/packages/vulcan-payments/package.js index 6c4c1adac..4e05f2f90 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.12.17', + version: '1.13.0', git: 'https://github.com/VulcanJS/Vulcan.git', }); Package.onUse(function(api) { api.versionsFrom('1.6.1'); - api.use(['promise', 'vulcan:core@1.12.17', 'fourseven:scss@4.5.4']); + api.use(['promise', 'vulcan:core@1.13.0', 'fourseven:scss@4.5.4']); api.mainModule('lib/server/main.js', 'server'); api.mainModule('lib/client/main.js', 'client'); diff --git a/packages/vulcan-redux/README.md b/packages/vulcan-redux/README.md new file mode 100644 index 000000000..a6b3fd7ef --- /dev/null +++ b/packages/vulcan-redux/README.md @@ -0,0 +1 @@ +Redux package. \ No newline at end of file diff --git a/packages/vulcan-routing/package.js b/packages/vulcan-routing/package.js index dfc708ef5..06f65d56a 100644 --- a/packages/vulcan-routing/package.js +++ b/packages/vulcan-routing/package.js @@ -1,14 +1,14 @@ Package.describe({ name: 'vulcan:routing', summary: 'Vulcan router package', - version: '1.12.17', + version: '1.13.0', git: 'https://github.com/VulcanJS/Vulcan.git', }); Package.onUse(function(api) { api.versionsFrom('1.6.1'); - api.use(['vulcan:lib@1.12.17']); + api.use(['vulcan:lib@1.13.0']); api.mainModule('lib/server/main.js', 'server'); api.mainModule('lib/client/main.js', 'client'); diff --git a/packages/vulcan-styled-components/README.md b/packages/vulcan-styled-components/README.md new file mode 100644 index 000000000..95409a7dc --- /dev/null +++ b/packages/vulcan-styled-components/README.md @@ -0,0 +1 @@ +Styled components package. \ No newline at end of file diff --git a/packages/vulcan-subscribe/package.js b/packages/vulcan-subscribe/package.js index 0951d7e07..1c21c1b8b 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.12.17', + version: '1.13.0', git: 'https://github.com/VulcanJS/Vulcan.git', }); @@ -9,11 +9,11 @@ Package.onUse(function(api) { api.versionsFrom('1.6.1'); api.use([ - 'vulcan:core@1.12.17', + 'vulcan:core@1.13.0', // dependencies on posts, categories are done with nested imports to reduce explicit dependencies ]); - api.use(['vulcan:posts@1.12.17', 'vulcan:comments@1.12.17', 'vulcan:categories@1.12.17'], { + api.use(['vulcan:posts@1.13.0', 'vulcan:comments@1.13.0', 'vulcan:categories@1.13.0'], { weak: true, }); diff --git a/packages/vulcan-test/README.md b/packages/vulcan-test/README.md new file mode 100644 index 000000000..05740f98f --- /dev/null +++ b/packages/vulcan-test/README.md @@ -0,0 +1 @@ +Test package. \ No newline at end of file diff --git a/packages/vulcan-ui-bootstrap/package.js b/packages/vulcan-ui-bootstrap/package.js index dc984387f..69114840b 100644 --- a/packages/vulcan-ui-bootstrap/package.js +++ b/packages/vulcan-ui-bootstrap/package.js @@ -1,14 +1,14 @@ Package.describe({ name: 'vulcan:ui-bootstrap', summary: 'Vulcan Bootstrap UI components.', - version: '1.12.17', + version: '1.13.0', git: 'https://github.com/VulcanJS/Vulcan.git', }); Package.onUse(function(api) { api.versionsFrom('1.6.1'); - api.use(['vulcan:lib@1.12.17', 'fourseven:scss@4.10.0']); + api.use(['vulcan:lib@1.13.0', 'fourseven:scss@4.10.0']); api.addFiles(['lib/stylesheets/style.scss', 'lib/stylesheets/datetime.scss'], 'client'); diff --git a/packages/vulcan-ui-material/.gitignore b/packages/vulcan-ui-material/.gitignore new file mode 100644 index 000000000..f8ca86071 --- /dev/null +++ b/packages/vulcan-ui-material/.gitignore @@ -0,0 +1,6 @@ +npm-debug.log +node_modules +.idea/workspace.xml + +### eslint-config +.eslintrc diff --git a/packages/vulcan-ui-material/.versions b/packages/vulcan-ui-material/.versions new file mode 100644 index 000000000..5e93359dd --- /dev/null +++ b/packages/vulcan-ui-material/.versions @@ -0,0 +1,89 @@ +accounts-base@1.4.3 +allow-deny@1.1.0 +autoupdate@1.5.0 +babel-compiler@7.2.4 +babel-runtime@1.3.0 +base64@1.0.11 +binary-heap@1.0.11 +blaze-tools@1.0.10 +boilerplate-generator@1.6.0 +buffer@0.0.0 +caching-compiler@1.2.1 +caching-html-compiler@1.1.3 +callback-hook@1.1.0 +check@1.3.1 +ddp@1.4.0 +ddp-client@2.3.3 +ddp-common@1.4.0 +ddp-rate-limiter@1.0.7 +ddp-server@2.2.0 +deps@1.0.12 +diff-sequence@1.1.1 +dynamic-import@0.5.0 +ecmascript@0.12.4 +ecmascript-runtime@0.7.0 +ecmascript-runtime-client@0.8.0 +ecmascript-runtime-server@0.7.1 +ejson@1.1.0 +email@1.2.3 +erikdakoda:vulcan-material-ui@1.12.8_17 +es5-shim@4.8.0 +fetch@0.1.0 +fourseven:scss@4.10.0 +geojson-utils@1.0.10 +hot-code-push@1.0.4 +html-tools@1.0.11 +htmljs@1.0.11 +http@1.4.1 +id-map@1.1.0 +inter-process-messaging@0.1.0 +localstorage@1.2.0 +logging@1.1.20 +meteor@1.9.2 +meteorhacks:inject-initial@1.0.4 +meteorhacks:picker@1.0.3 +minifier-css@1.4.1 +minifier-js@2.4.0 +minimongo@1.4.5 +modern-browsers@0.1.3 +modules@0.13.0 +modules-runtime@0.10.3 +mongo@1.6.0 +mongo-decimal@0.1.0 +mongo-dev-server@1.1.0 +mongo-id@1.0.7 +npm-mongo@3.1.1 +ordered-dict@1.1.0 +percolatestudio:synced-cron@1.1.0 +promise@0.11.2 +random@1.1.0 +rate-limit@1.0.9 +reactive-dict@1.2.1 +reactive-var@1.0.11 +reload@1.2.0 +retry@1.1.0 +routepolicy@1.1.0 +server-render@0.3.1 +service-configuration@1.0.11 +session@1.2.0 +shell-server@0.4.0 +socket-stream-client@0.2.2 +spacebars-compiler@1.1.3 +standard-minifier-css@1.5.2 +standard-minifier-js@2.4.0 +static-html@1.2.2 +templating-tools@1.1.2 +tracker@1.2.0 +underscore@1.0.10 +url@1.2.0 +vulcan:accounts@1.12.8 +vulcan:core@1.12.8 +vulcan:debug@1.12.8 +vulcan:email@1.12.8 +vulcan:forms@1.12.8 +vulcan:i18n@1.12.8 +vulcan:lib@1.12.8 +vulcan:routing@1.12.8 +vulcan:users@1.12.8 +webapp@1.7.1 +webapp-hashing@1.0.9 diff --git a/packages/vulcan-ui-material/accounts.css b/packages/vulcan-ui-material/accounts.css new file mode 100644 index 000000000..73485abcf --- /dev/null +++ b/packages/vulcan-ui-material/accounts.css @@ -0,0 +1,18 @@ +.accounts-ui .form-control { + color: rgba(0, 0, 0, 0.87); + padding: 8px 0; + font-size: 1rem; + font-family: "Roboto", "Helvetica", "Arial", sans-serif; + border: none; + box-shadow: none; + border-bottom: 1px solid rgba(0, 0, 0, 0.87); + margin-bottom: 1px; + border-radius: 0; + width: 100%; +} + +.accounts-ui .form-control:focus { + border-bottom-width: 2px; + margin-bottom: 0; +} + diff --git a/packages/vulcan-ui-material/en_US.js b/packages/vulcan-ui-material/en_US.js new file mode 100644 index 000000000..29711d64d --- /dev/null +++ b/packages/vulcan-ui-material/en_US.js @@ -0,0 +1,12 @@ +import { addStrings } from 'meteor/vulcan:core'; + + +addStrings('en', { + + 'search.search': 'Search', + 'search.clear': 'Clear search', + 'load_more.load_more': 'Load more', + 'load_more.loaded_count': 'Loaded {count} of {totalCount}', + 'load_more.loaded_all': '{totalCount, plural, =0 {No items} one {One item} other {# items}}', + +}); diff --git a/packages/vulcan-ui-material/forms.css b/packages/vulcan-ui-material/forms.css new file mode 100644 index 000000000..219fee574 --- /dev/null +++ b/packages/vulcan-ui-material/forms.css @@ -0,0 +1,16 @@ +.form-nested-item { + display: flex; +} + +.form-nested-item-inner { + flex-grow: 1; +} + +.form-nested-item-remove { + padding-top: 8px; + margin-left: 8px; +} + +.form-nested-item-remove > button { + margin-right: -4px; +} diff --git a/packages/vulcan-ui-material/fr_FR.js b/packages/vulcan-ui-material/fr_FR.js new file mode 100644 index 000000000..7ba3c6d2f --- /dev/null +++ b/packages/vulcan-ui-material/fr_FR.js @@ -0,0 +1,7 @@ +import { addStrings } from 'meteor/vulcan:core'; + +addStrings('fr', { + 'search.search': 'Recherche', + 'search.clear': 'Effacer la recherche', + 'modal.close': 'Fermer', +}); diff --git a/packages/vulcan-ui-material/history.md b/packages/vulcan-ui-material/history.md new file mode 100644 index 000000000..8613491ff --- /dev/null +++ b/packages/vulcan-ui-material/history.md @@ -0,0 +1,109 @@ +1.12.8_17 / 2019-02-02 +====================== + + * TooltipIntl: Changed display from 'inline-block' to 'inherit' for more flexibility + * Countries: Added getRegionLabel function + +1.12.8_16 / 2019-01-21 +====================== + + * Countries: Fixed bug in validateRegion + +1.12.8_15 / 2019-01-21 +====================== + + * Countries: Fixed bug in validateRegion + +1.12.8_14 / 2019-01-20 +====================== + + * Countries: Added validateRegion function, which given a region value or label, will return the region value ('NY' or 'New York' => 'NY) + * The contents of countries is now exported - this may be refactored out of the core vulcan-material-ui as some point + +1.12.8_13 / 2019-01-14 +====================== + + * ModalTrigger: Added boolean dialogOverflow prop for use cases like popups that can go beyond the size of the dialog box + * MuiSuggest: Fixed bug - The disabled state was not displayed correctly + * MuiSuggest: Fixed bug - After selecting a suggestion, clicking on the control did not re-open the suggestions menu + +1.12.8_12 / 2019-01-12 +====================== + + * Upgraded to Meteor 1.8.0.2 + +1.12.8_11 / 2018-12-21 +====================== + + * SearchInput: Added install autosize-input to readme + * Datatable: Fixed sorting delay + * Datatable: Added tableHeadCell class + * Datatable: Added cellClass column property, which can be a string or a function: column.cellClass({ column, document, currentUser }) + +1.12.8_10 / 2018-12-09 +====================== + + * TooltipIntl: Added icon class + * FormGroupWithLine: Moved caret from the right side to next to the title + * Changed load_more.loaded_all string + +1.12.8_9 / 2018-11-26 +===================== + + * Fixed bug that displayed invalid total count at the bottom of data tables + +1.12.8_8 / 2018-11-23 +===================== + + * Improved the functionality of the LoadMore component + * The showNoMore property has been deprecated + * A showCount property has been added (true by default) that shows a count of loaded and total items + * The load more icon or button is displayed even when infiniteScroll is enabled + +1.12.8_7 / 2018-11-10 +===================== + + * Fixed bug in Datatable.jsx + * Updated ReadMe + +1.12.8_6 / 2018-11-06 +===================== + + * Fixed bug in Datatable.jsx + * Reduced spacing of form components + +1.12.8_5 / 2018-10-31 +===================== + + * Fixed bugs in Datatable pagination + * Set Datatable paginate prop to false by default + +1.12.8_4 / 2018-10-31 +===================== + + * Removed 'fr_FR.js' from package.js because any french strings loaded activates the french language + * Fixed delete button and its tooltips positioning in FormSubmit + * Added pagination to Datatable + +1.12.8_2 / 2018-10-29 +===================== + + * Fixed localization in "clear search" tooltip + * Added name and aria-haspopup properties to the input component to improve compliance and facilitate UAT + * Replaced Date, Time and DateTime form controls with native controls as recommended by MUI. + The deprecated react-datetime version of the controls are still there as DateRdt, TimeRdt and DateTimeRdt, but they are not registered. + * Updated readme + +1.12.8_1 / 2018-10-22 +===================== + + * Made form components compatible with new Form.formComponents property + +1.12.8 / 2018-10-19 +=================== + + * Made improvements to the search box, including keyboard shortcuts (s: focus search; c: clear search) + * Added support in TooltipIntl for tooltips in popovers + * Added action prop to ModalTrigger that enables a parent component to call openModal and closeModal + * Started using MUI tables in Card component + * Fixed bugs in MuiSuggest component diff --git a/packages/vulcan-ui-material/lib/client/main.js b/packages/vulcan-ui-material/lib/client/main.js new file mode 100644 index 000000000..dadfe15cf --- /dev/null +++ b/packages/vulcan-ui-material/lib/client/main.js @@ -0,0 +1,2 @@ +export * from '../modules/index'; +import './wrapWithMuiTheme'; diff --git a/packages/vulcan-ui-material/lib/client/wrapWithMuiTheme.jsx b/packages/vulcan-ui-material/lib/client/wrapWithMuiTheme.jsx new file mode 100644 index 000000000..8b0c3baa7 --- /dev/null +++ b/packages/vulcan-ui-material/lib/client/wrapWithMuiTheme.jsx @@ -0,0 +1,29 @@ +import React from 'react'; +import { addCallback, registerComponent, Components } from 'meteor/vulcan:core'; +import MuiThemeProvider from '@material-ui/core/styles/MuiThemeProvider'; +import { getCurrentTheme } from '../modules/themes'; +import JssCleanup from '../components/theme/JssCleanup'; + +class ThemeProvider extends React.Component { + render() { + const theme = getCurrentTheme(); + return ( + + {this.props.children} + + ); + } +} + +registerComponent('ThemeProvider', ThemeProvider); + +function wrapWithMuiTheme(app) { + return ( + + {app} + + ); +} + + +addCallback('router.client.wrapper', wrapWithMuiTheme); diff --git a/packages/vulcan-ui-material/lib/components/accounts/AccountsButton.jsx b/packages/vulcan-ui-material/lib/components/accounts/AccountsButton.jsx new file mode 100755 index 000000000..d80dfe629 --- /dev/null +++ b/packages/vulcan-ui-material/lib/components/accounts/AccountsButton.jsx @@ -0,0 +1,46 @@ +import React, { Component } from 'react'; +import PropTypes from 'prop-types'; +import Button from '@material-ui/core/Button'; +import { replaceComponent, Utils } from 'meteor/vulcan:core'; +import classNames from 'classnames'; + + +export class AccountsButton extends Component { + render () { + + const { + label, + type, + disabled = false, + className, + onClick + } = this.props; + + return ( + + ); + } +} + + +AccountsButton.propTypes = { + label: PropTypes.string.isRequired, + type: PropTypes.oneOf(['link', 'submit', 'button']), + disabled: PropTypes.bool, + className: PropTypes.string, + onClick: PropTypes.func.isRequired, +}; + + +replaceComponent('AccountsButton', AccountsButton); diff --git a/packages/vulcan-ui-material/lib/components/accounts/AccountsButtons.jsx b/packages/vulcan-ui-material/lib/components/accounts/AccountsButtons.jsx new file mode 100755 index 000000000..dc55093d9 --- /dev/null +++ b/packages/vulcan-ui-material/lib/components/accounts/AccountsButtons.jsx @@ -0,0 +1,48 @@ +import React, { Component } from 'react'; +import PropTypes from 'prop-types'; +import { Components, replaceComponent } from 'meteor/vulcan:core'; +import CardActions from '@material-ui/core/CardActions'; +import withStyles from '@material-ui/core/styles/withStyles'; +import classNames from 'classnames'; + + +const styles = theme => ({ + root: { + flexDirection: 'row-reverse', + padding: theme.spacing.unit * 2, + height: 'auto', + }, +}); + + +export class AccountsButtons extends Component { + render () { + + const { + classes, + buttons = {}, + className = 'buttons', + } = this.props; + + return ( + + {Object.keys(buttons).map((id, i) => + + )} + + ); + } +} + + +AccountsButtons.propTypes = { + classes: PropTypes.object.isRequired, + buttons: PropTypes.object, + className: PropTypes.string, +}; + + +AccountsButtons.displayName = 'AccountsButtons'; + + +replaceComponent('AccountsButtons', AccountsButtons, [withStyles, styles]); diff --git a/packages/vulcan-ui-material/lib/components/accounts/AccountsField.jsx b/packages/vulcan-ui-material/lib/components/accounts/AccountsField.jsx new file mode 100755 index 000000000..4ac940a51 --- /dev/null +++ b/packages/vulcan-ui-material/lib/components/accounts/AccountsField.jsx @@ -0,0 +1,114 @@ +import React, { PureComponent } from 'react'; +import PropTypes from 'prop-types'; +import { replaceComponent } from 'meteor/vulcan:core'; +import TextField from '@material-ui/core/TextField'; + + +const autocompleteValues = { + 'username': 'username', + 'usernameOrEmail': 'email', + 'email': 'email', + 'password': 'current-password' +}; + + +export class AccountsField extends PureComponent { + + + constructor (props) { + super(props); + this.state = { + mount: true + }; + } + + + triggerUpdate () { + // Trigger an onChange on initial load, to support browser pre-filled values. + const { onChange } = this.props; + if (this.input && onChange) { + onChange({ target: { value: this.input.value } }); + } + } + + + componentDidMount () { + this.triggerUpdate(); + } + + + componentDidUpdate (prevProps) { + // Re-mount component so that we don't expose browser pre-filled passwords if the component was + // a password before and now something else. + if (prevProps.id !== this.props.id) { + this.setState({ mount: false }); + } else if (!this.state.mount) { + this.setState({ mount: true }); + this.triggerUpdate(); + } + } + + + render () { + const { + id, + hint, + label, + type = 'text', + onChange, + required = false, + className = 'field', + defaultValue = '', + autoFocus, + messages, + } = this.props; + let { message } = this.props; + const { mount = true } = this.state; + + if (type === 'notice') { + return
{label}
; + } + + const autoComplete = autocompleteValues[id]; + + if (messages && messages.find && typeof id === 'string') { + const foundMessage = messages.find(element => { + if (typeof element.field !== 'string') return false; + return id.toLowerCase().indexOf(element.field.toLowerCase()) > -1; + }); + if (foundMessage) { + message = foundMessage; + } + } + + return ( + mount && + +
+ { this.input = ref; }} + onChange={onChange} + placeholder={hint} + defaultValue={defaultValue} + autoComplete={autoComplete } + label={label} + autoFocus={autoFocus} + required={required} + error={!!message} + helperText={message && message.message} + fullWidth + /> +
+ ); + } +} + + +AccountsField.propTypes = { + onChange: PropTypes.func, +}; + + +replaceComponent('AccountsField', AccountsField); diff --git a/packages/vulcan-ui-material/lib/components/accounts/AccountsFields.jsx b/packages/vulcan-ui-material/lib/components/accounts/AccountsFields.jsx new file mode 100755 index 000000000..59309c9a3 --- /dev/null +++ b/packages/vulcan-ui-material/lib/components/accounts/AccountsFields.jsx @@ -0,0 +1,27 @@ +import React, { Component } from 'react'; +import { Components, replaceComponent } from 'meteor/vulcan:core'; +import CardContent from '@material-ui/core/CardContent'; + + +export class AccountsFields extends Component { + render () { + const { + fields = {}, + className = 'fields', + messages, + } = this.props; + + return ( + + { + Object.keys(fields).map((id, i) => + + ) + } + + ); + } +} + + +replaceComponent('AccountsFields', AccountsFields); diff --git a/packages/vulcan-ui-material/lib/components/accounts/AccountsForm.jsx b/packages/vulcan-ui-material/lib/components/accounts/AccountsForm.jsx new file mode 100755 index 000000000..af28fb37a --- /dev/null +++ b/packages/vulcan-ui-material/lib/components/accounts/AccountsForm.jsx @@ -0,0 +1,68 @@ +import React, { Component } from 'react'; +import PropTypes from 'prop-types'; +import classNames from 'classnames'; +import { Components, registerComponent } from 'meteor/vulcan:core'; +import withStyles from '@material-ui/core/styles/withStyles'; + + +const styles = theme => ({ + messages: theme.utils.errorMessage, +}); + + +export class AccountsForm extends Component { + + + componentDidMount () { + let form = this.form; + if (form) { + form.addEventListener('submit', (e) => { + e.preventDefault(); + }); + } + } + + + render () { + const { + oauthServices, + fields, + buttons, + messages, + ready = true, + className, + classes, + } = this.props; + + return ( +
this.form = ref} + className={classNames(className, 'accounts-ui', { 'ready': ready, })} + noValidate + > + + + + + + + ); + } + + +} + + +AccountsForm.propTypes = { + oauthServices: PropTypes.object, + fields: PropTypes.object.isRequired, + buttons: PropTypes.object.isRequired, + error: PropTypes.string, + ready: PropTypes.bool, + classes: PropTypes.object.isRequired, +}; + + +AccountsForm.displayName = 'AccountsForm'; + + +registerComponent('AccountsForm', AccountsForm, [withStyles, styles]); diff --git a/packages/vulcan-ui-material/lib/components/accounts/AccountsPasswordOrService.jsx b/packages/vulcan-ui-material/lib/components/accounts/AccountsPasswordOrService.jsx new file mode 100644 index 000000000..8f3a40667 --- /dev/null +++ b/packages/vulcan-ui-material/lib/components/accounts/AccountsPasswordOrService.jsx @@ -0,0 +1,57 @@ +import React, { PureComponent } from 'react'; +import PropTypes from 'prop-types'; +import { replaceComponent } from 'meteor/vulcan:core'; +import { intlShape } from 'meteor/vulcan:i18n'; +import Typography from '@material-ui/core/Typography'; +import CardActions from '@material-ui/core/CardActions'; +import withStyles from '@material-ui/core/styles/withStyles'; +import classNames from 'classnames'; + +const styles = theme => ({ + root: { + flexDirection: 'row-reverse', + paddingRight: theme.spacing.unit * 2, + paddingLeft: theme.spacing.unit * 2, + height: 'auto', + }, + typography: { + marginRight: theme.spacing.unit, + } +}); + +export function hasPasswordService() { + // First look for OAuth services. + return !!Package['accounts-password']; +} + +export class AccountsPasswordOrService extends PureComponent { + render () { + let { className = 'password-or-service', style = {}, classes } = this.props; + const services = Object.keys(this.props.oauthServices).map(service => { + return this.props.oauthServices[service].label; + }); + let labels = services; + if (services.length > 2) { + labels = []; + } + + if (hasPasswordService() && services.length > 0) { + return ( + + { `${this.context.intl.formatMessage({id: 'accounts.or_use'})} ${ labels.join(' / ') }` } + + ); + } + return null; + } +} + +AccountsPasswordOrService.propTypes = { + oauthServices: PropTypes.object +}; + +AccountsPasswordOrService.contextTypes = { + intl: intlShape +}; + +replaceComponent('AccountsPasswordOrService', AccountsPasswordOrService, [withStyles, styles]); diff --git a/packages/vulcan-ui-material/lib/components/accounts/AccountsSocialButtons.jsx b/packages/vulcan-ui-material/lib/components/accounts/AccountsSocialButtons.jsx new file mode 100644 index 000000000..4667b8150 --- /dev/null +++ b/packages/vulcan-ui-material/lib/components/accounts/AccountsSocialButtons.jsx @@ -0,0 +1,28 @@ +import React from 'react'; +import { Components, replaceComponent } from 'meteor/vulcan:core'; +import CardActions from '@material-ui/core/CardActions'; +import withStyles from '@material-ui/core/styles/withStyles'; +import classNames from 'classnames'; + +const styles = theme => ({ + root: { + justifyContent: 'flex-end', + padding: theme.spacing.unit * 2, + height: 'auto', + }, +}); + +export class AccountsSocialButtons extends React.Component { + render() { + let { oauthServices = {}, className = 'social-buttons', classes } = this.props; + return( + + {Object.keys(oauthServices).map((id, i) => { + return ; + })} + + ); + } +} + +replaceComponent('AccountsSocialButtons', AccountsSocialButtons, [withStyles, styles]); diff --git a/packages/vulcan-ui-material/lib/components/bonus/LoadMore.jsx b/packages/vulcan-ui-material/lib/components/bonus/LoadMore.jsx new file mode 100644 index 000000000..934da0c7d --- /dev/null +++ b/packages/vulcan-ui-material/lib/components/bonus/LoadMore.jsx @@ -0,0 +1,133 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { FormattedMessage, intlShape } from 'react-intl'; +import { Components, registerComponent } from 'meteor/vulcan:core'; +import withStyles from '@material-ui/core/styles/withStyles'; +import Typography from '@material-ui/core/Typography'; +import Button from '@material-ui/core/Button'; +import IconButton from '@material-ui/core/IconButton'; +import ArrowDownIcon from 'mdi-material-ui/ArrowDown'; +import ScrollTrigger from './ScrollTrigger'; +import classNames from 'classnames'; + + +const styles = theme => ({ + + root: { + textAlign: 'center', + flexBasis: '100%', + }, + + textButton: { + marginTop: theme.spacing.unit * 2, + }, + + iconButton: {}, + + caption: { + marginTop: theme.spacing.unit * 3, + paddingTop: theme.spacing.unit, + paddingBottom: theme.spacing.unit, + }, + +}); + + +const LoadMore = ({ + classes, + count, + totalCount, + loadMore, + networkStatus, + showCount, + useTextButton, + className, + infiniteScroll, + }, { intl }) => { + + const isLoadingMore = networkStatus === 2; + const loadMoreText = intl.formatMessage({ id: 'load_more.load_more' }); + const title = `${loadMoreText} (${count}/${totalCount})`; + const hasMore = totalCount > count; + const countValues = { count, totalCount }; + + const loadMoreButton = useTextButton + ? + + : + loadMore()}> + + ; + + return ( +
+ { + showCount && + + + + + } + { + isLoadingMore + + ? + + + + : + + hasMore + + ? + + infiniteScroll + + ? + + loadMore()}> + {loadMoreButton} + + + : + + loadMoreButton + : + + null + } +
+ ); +}; + + +LoadMore.propTypes = { + classes: PropTypes.object.isRequired, + count: PropTypes.number, + totalCount: PropTypes.number, + loadMore: PropTypes.func, + networkStatus: PropTypes.number, + showCount: PropTypes.bool, + useTextButton: PropTypes.bool, + className: PropTypes.string, + infiniteScroll: PropTypes.bool, +}; + + +LoadMore.defaultProps = { + showCount: true, +}; + + +LoadMore.contextTypes = { + intl: intlShape.isRequired, +}; + + +LoadMore.displayName = 'LoadMore'; + + +registerComponent('LoadMore', LoadMore, [withStyles, styles]); + diff --git a/packages/vulcan-ui-material/lib/components/bonus/ScrollTrigger.jsx b/packages/vulcan-ui-material/lib/components/bonus/ScrollTrigger.jsx new file mode 100644 index 000000000..6cbf40aa2 --- /dev/null +++ b/packages/vulcan-ui-material/lib/components/bonus/ScrollTrigger.jsx @@ -0,0 +1,125 @@ +import React, { Component } from 'react'; +import PropTypes from 'prop-types'; +import ReactDOM from 'react-dom'; +import _throttle from 'lodash/throttle'; + + +class ScrollTrigger extends Component { + + constructor (props) { + super(props); + + this.onScroll = _throttle(this.onScroll.bind(this), 100, { + leading: true, + trailing: true, + }); + + this.onResize = _throttle(this.onResize.bind(this), 100, { + leading: true, + trailing: true, + }); + + this.inViewport = false; + this.passive = this.supportsPassive ? { passive: true } : false; + } + + supportsPassive () { + let supportsPassive = false; + try { + const opts = Object.defineProperty({}, 'passive', { + get: function() { + supportsPassive = true; + } + }); + window.addEventListener('testPassive', null, opts); + window.removeEventListener('testPassive', null, opts); + //eslint-disable-next-line no-empty + } catch (e) {} + return supportsPassive; + } + + componentDidMount () { + this.scroller = document.getElementById('main'); + this.scroller.addEventListener('resize', this.onResize, this.passive); + this.scroller.addEventListener('scroll', this.onScroll, this.passive); + + this.inViewport = false; + + if (this.props.triggerOnLoad) { + this.checkStatus(); + } + } + + componentWillUnmount () { + if (!this.scroller) return; + this.scroller.removeEventListener('resize', this.onResize); + this.scroller.removeEventListener('scroll', this.onScroll); + this.scroller = null; + } + + onResize () { + this.checkStatus(); + } + + onScroll () { + this.checkStatus(); + } + + checkStatus () { + if (!this.scroller) return; + + const { + onEnter, + } = this.props; + + //eslint-disable-next-line + const element = ReactDOM.findDOMNode(this.element); + const elementRect = element.getBoundingClientRect(); + const viewportEnd = this.scroller.clientHeight + this.props.preload; + const inViewport = elementRect.top < viewportEnd; + + if (inViewport) { + if (!this.inViewport) { + this.inViewport = true; + + onEnter(this); + } + + } else { + if (this.inViewport) { + this.inViewport = false; + } + } + } + + render () { + const { + children, + } = this.props; + + return ( +
{this.element = element;}}> + {children} +
+ ); + } +} + + +ScrollTrigger.propTypes = { + scrollerId: PropTypes.string, + triggerOnLoad: PropTypes.bool, + preload: PropTypes.number, + onEnter: PropTypes.func, +}; + + +ScrollTrigger.defaultProps = { + scrollerId: 'main', + preload: 1000, + triggerOnLoad: true, + onEnter: () => {}, +}; + + +export default ScrollTrigger; diff --git a/packages/vulcan-ui-material/lib/components/bonus/SearchInput.jsx b/packages/vulcan-ui-material/lib/components/bonus/SearchInput.jsx new file mode 100644 index 000000000..bcfb10a27 --- /dev/null +++ b/packages/vulcan-ui-material/lib/components/bonus/SearchInput.jsx @@ -0,0 +1,228 @@ +import React, { PureComponent } from 'react'; +import PropTypes from 'prop-types'; +import { Components, registerComponent } from 'meteor/vulcan:core'; +import withStyles from '@material-ui/core/styles/withStyles'; +import SearchIcon from 'mdi-material-ui/Magnify'; +import ClearIcon from 'mdi-material-ui/CloseCircle'; +import TextField from '@material-ui/core/TextField'; +import NoSsr from '@material-ui/core/NoSsr'; +import classNames from 'classnames'; +import _debounce from 'lodash/debounce'; +import KeyboardEventHandler from 'react-keyboard-event-handler'; + +const styles = theme => ({ + + '@global': { + 'input[type=text]::-ms-clear, input[type=text]::-ms-reveal': + { + display: 'none', + width: 0, + height: 0, + }, + 'input[type="search"]::-webkit-search-decoration, input[type="search"]::-webkit-search-cancel-button': + { display: 'none' }, + 'input[type="search"]::-webkit-search-results-button, input[type="search"]::-webkit-search-results-decoration': + { display: 'none' }, + }, + + root: { + marginTop: 0 + }, + + clear: { + transition: theme.transitions.create('opacity,transform', { + duration: theme.transitions.duration.short, + }), + opacity: 0.65, + width: 36, + height: 36, + margin: -6, + marginLeft: 0, + '& svg': { + width: 16, + height: 16, + }, + flexDirection: 'column', + }, + + clearDense: { + width: 32, + height: 32, + margin: -4, + marginLeft: 0, + }, + + clearDisabled: { + opacity: 0, + pointerEvents: 'none', + }, + + icon: { + color: theme.palette.common.lightBlack, + marginLeft: theme.spacing.unit, + marginRight: theme.spacing.unit, + }, + + input: { + lineHeight: 1, + paddingTop: 2, + paddingBottom: 2, + marginBottom: 1, + /*transition: theme.transitions.create('width', { + duration: theme.transitions.duration.shortest, + }),*/ + minWidth: 130, + }, + +}); + + +class SearchInput extends PureComponent { + + constructor (props) { + super(props); + + this.state = { + value: props.defaultValue || '', + }; + + this.input = null; + + this.updateQuery = _debounce(this.updateQuery, 500); + } + + componentDidMount () { + if (!document) return; + const element = document.querySelector(`.search-input-${this.props.name} input[type=search]`); + + element._addEventListener = element.addEventListener; + element.addEventListener = function(type, listener, useCapture) { + if(useCapture === undefined) + useCapture = false; + this._addEventListener(type, listener, useCapture); + }; + + element.addEventListener = element._addEventListener; + } + + componentWillUnmount () { + } + + handleShortcutKeys = (key, event) => { + switch (key) { + case 's': + this.focusInput(); + event.preventDefault(); + break; + case 'c': + case 'esc': + this.clearSearch(event, true); + event.preventDefault(); + break; + } + }; + + handleFocus = () => { + this.input.select(); + }; + + focusInput = (event) => { + this.input.focus(); + }; + + clearSearch = (event, dontFocus) => { + this.setState({ value: '' }); + this.updateQuery(''); + + if (!dontFocus) { + this.focusInput(); + } + }; + + updateSearch = (event) => { + const value = event.target.value; + this.setState({ value: value }); + this.updateQuery(value); + }; + + updateQuery = (value) => { + this.props.updateQuery(value); + }; + + render () { + const { + classes, + className, + dense, + noShortcuts, + name, + } = this.props; + + const searchIcon = ; + + const clearButton = } + onClick={this.clearSearch} + classes={{ + root: classNames(!this.state.value && classes.clearDisabled), + button: classNames('clear-button', classes.clear, dense && classes.clearDense), + }} + disabled={!this.state.value} + />; + + return ( + + this.input = input} + fullWidth + className={classNames('search-input', `search-input-${name}`, classes.root, dense && classes.inputTypeSearch, className, classes.textField)} + margin="normal" + variant="outlined" + onChange={this.updateSearch} + onFocus={this.handleFocus} + InputProps={{ + startAdornment: searchIcon, + endAdornment: clearButton + }} + /> + + { + // KeyboardEventHandler is not valid on the server, where its name is undefined + typeof window !== 'undefined' && KeyboardEventHandler.name && !noShortcuts && + + + } + + + ); + } + +} + + +SearchInput.propTypes = { + classes: PropTypes.object.isRequired, + updateQuery: PropTypes.func.isRequired, + className: PropTypes.string, + dense: PropTypes.bool, + noShortcuts: PropTypes.bool, + name: PropTypes.string.isRequired, +}; + + +SearchInput.defaultProps = { + name: 'search', +}; + + +SearchInput.displayName = 'SearchInput'; + + +registerComponent('SearchInput', SearchInput, [withStyles, styles]); diff --git a/packages/vulcan-ui-material/lib/components/bonus/TooltipIconButton.jsx b/packages/vulcan-ui-material/lib/components/bonus/TooltipIconButton.jsx new file mode 100644 index 000000000..45e3ba288 --- /dev/null +++ b/packages/vulcan-ui-material/lib/components/bonus/TooltipIconButton.jsx @@ -0,0 +1,108 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { Components, registerComponent, Utils } from 'meteor/vulcan:core'; +import { intlShape } from 'meteor/vulcan:i18n'; +import withStyles from '@material-ui/core/styles/withStyles'; +import withTheme from '@material-ui/core/styles/withTheme'; +import Tooltip from '@material-ui/core/Tooltip'; +import IconButton from '@material-ui/core/IconButton'; +import Button from '@material-ui/core/Button'; +import classNames from 'classnames'; + + +const styles = theme => ({ + root: {}, + tooltip: { + margin: '4px !important', + }, + buttonWrap: { + display: 'inline-block', + }, + button: {}, +}); + + +const TooltipIconButton = (props, { intl }) => { + + const { + title, + titleId, + placement, + icon, + className, + classes, + theme, + buttonRef, + variant, + ...properties + } = props; + + const titleText = props.title || intl.formatMessage({ id: titleId }); + const slug = Utils.slugify(titleId); + + return ( + +
+ { + variant === 'fab' + + ? + + + + : + + + {icon} + + } +
+
+ ); + +}; + + +TooltipIconButton.propTypes = { + title: PropTypes.node, + titleId: PropTypes.string, + placement: PropTypes.string, + icon: PropTypes.node.isRequired, + className: PropTypes.string, + classes: PropTypes.object, + buttonRef: PropTypes.func, + variant: PropTypes.string, + theme: PropTypes.object, +}; + + +TooltipIconButton.defaultProps = { + placement: 'bottom', +}; + + +TooltipIconButton.contextTypes = { + intl: intlShape.isRequired, +}; + + +TooltipIconButton.displayName = 'TooltipIconButton'; + + +registerComponent('TooltipIconButton', TooltipIconButton, [withStyles, styles], [withTheme]); diff --git a/packages/vulcan-ui-material/lib/components/bonus/TooltipIntl.jsx b/packages/vulcan-ui-material/lib/components/bonus/TooltipIntl.jsx new file mode 100644 index 000000000..5e21e277d --- /dev/null +++ b/packages/vulcan-ui-material/lib/components/bonus/TooltipIntl.jsx @@ -0,0 +1,168 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { Components, registerComponent, Utils } from 'meteor/vulcan:core'; +import { intlShape } from 'meteor/vulcan:i18n'; +import withStyles from '@material-ui/core/styles/withStyles'; +import withTheme from '@material-ui/core/styles/withTheme'; +import Tooltip from '@material-ui/core/Tooltip'; +import IconButton from '@material-ui/core/IconButton'; +import Button from '@material-ui/core/Button'; +import classNames from 'classnames'; + + +const styles = theme => ({ + + root: { + display: 'inherit', + }, + + tooltip: { + margin: '4px !important', + }, + + buttonWrap: { + display: 'inherit', + }, + + button: {}, + + icon: {}, + + popoverPopper: { + zIndex: 1700, + }, + + popoverTooltip: { + zIndex: 1701, + }, + +}); + + +const TooltipIntl = (props, { intl }) => { + + const { + title, + titleId, + titleValues, + placement, + icon, + className, + classes, + theme, + enterDelay, + leaveDelay, + buttonRef, + variant, + parent, + children, + ...properties + } = props; + + const iconWithClass = icon && React.cloneElement(icon, { className: classes.icon }); + const popperClass = parent === 'popover' && classes.popoverPopper; + const tooltipClass = parent === 'popover' && classes.popoverTooltip; + const tooltipEnterDelay = typeof enterDelay === 'number' ? enterDelay : theme.utils.tooltipEnterDelay; + const tooltipLeaveDelay = typeof leaveDelay === 'number' ? leaveDelay : theme.utils.tooltipLeaveDelay; + const titleText = props.title || intl.formatMessage({ id: titleId }, titleValues); + const slug = Utils.slugify(titleId); + + return ( + + + + { + variant === 'fab' && !!icon + + ? + + + + : + + !!icon + + ? + + + {iconWithClass} + + + : + + variant === 'button' + + ? + + + : + + children + } + + + + ); + +}; + + +TooltipIntl.propTypes = { + title: PropTypes.node, + titleId: PropTypes.string, + titleValues: PropTypes.object, + placement: PropTypes.string, + icon: PropTypes.node, + className: PropTypes.string, + classes: PropTypes.object, + buttonRef: PropTypes.func, + variant: PropTypes.string, + theme: PropTypes.object, + enterDelay: PropTypes.number, + leaveDelay: PropTypes.number, + parent: PropTypes.oneOf(['default', 'popover']), + children: PropTypes.node, +}; + + +TooltipIntl.defaultProps = { + placement: 'bottom', + parent: 'default', +}; + + +TooltipIntl.contextTypes = { + intl: intlShape.isRequired, +}; + + +TooltipIntl.displayName = 'TooltipIntl'; + + +registerComponent('TooltipIntl', TooltipIntl, [withStyles, styles], [withTheme]); diff --git a/packages/vulcan-ui-material/lib/components/core/Card.jsx b/packages/vulcan-ui-material/lib/components/core/Card.jsx new file mode 100644 index 000000000..a311c4841 --- /dev/null +++ b/packages/vulcan-ui-material/lib/components/core/Card.jsx @@ -0,0 +1,212 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { intlShape } from 'meteor/vulcan:i18n'; +import { replaceComponent, Components } from 'meteor/vulcan:core'; +import moment from 'moment'; +import withStyles from '@material-ui/core/styles/withStyles'; +import IconButton from '@material-ui/core/IconButton'; +import Checkbox from '@material-ui/core/Checkbox'; +import EditIcon from 'mdi-material-ui/Pencil'; +import Table from '@material-ui/core/Table'; +import TableBody from '@material-ui/core/TableBody'; +import TableRow from '@material-ui/core/TableRow'; +import TableCell from '@material-ui/core/TableCell'; +import classNames from 'classnames'; + + +const getLabel = (field, fieldName, collection, intl) => { + const schema = collection.simpleSchema()._schema; + const fieldSchema = schema[fieldName]; + if (fieldSchema) { + return intl.formatMessage( + { id: `${collection._name}.${fieldName}`, defaultMessage: fieldSchema.label }); + } else { + return fieldName; + } +}; + + +const getTypeName = (field, fieldName, collection) => { + const schema = collection.simpleSchema()._schema; + const fieldSchema = schema[fieldName]; + if (fieldSchema) { + const type = fieldSchema.type.singleType; + const typeName = typeof type === 'function' ? type.name : type; + return typeName; + } else { + return typeof field; + } +}; + + +const parseImageUrl = value => { + const isImage = ['.png', '.jpg', '.gif'].indexOf(value.substr(-4)) !== -1 || + ['.webp', '.jpeg'].indexOf(value.substr(-5)) !== -1; + return isImage ? + {value}/ : + ; +}; + + +const LimitedString = ({ string }) => +
+ {string.indexOf(' ') === -1 && string.length > 30 ? + {string.substr(0, 30)}… : + {string} + } +
; + + +export const getFieldValue = (value, typeName, classes={}) => { + + if (typeof value === 'undefined' || value === null) { + return ''; + } + + if (Array.isArray(value)) { + typeName = 'Array'; + } + + if (typeof typeName === 'undefined') { + typeName = typeof value; + } + + switch (typeName) { + + case 'Boolean': + case 'boolean': + return ; + + case 'Number': + case 'number': + case 'SimpleSchema.Integer': + return {value.toString()}; + + case 'Array': + return
    {value.map( + (item, index) =>
  1. {getFieldValue(item, typeof item, classes)}
  2. )}
; + + case 'Object': + case 'object': + return ( + + + {_.map(value, (value, key) => + + {key} + {getFieldValue(value, typeof value, classes)} + + )} + +
+ ); + + case 'Date': + return moment(new Date(value)).format('dddd, MMMM Do YYYY, h:mm:ss'); + + default: + return parseImageUrl(value); + } +}; + + +const CardItem = ({ label, value, typeName, classes }) => + + + {label} + + + {getFieldValue(value, typeName, classes)} + + ; + + +const CardEdit = (props, context) => { + const classes = props.classes; + const editTitle = context.intl.formatMessage({ id: 'cards.edit' }); + return ( + + + + + } + > + + + + + ); +}; + + +CardEdit.contextTypes = { intl: intlShape }; + + +const CardEditForm = ({ collection, document, closeModal }) => + { + closeModal(); + }} + />; + + +const styles = theme => ({ + root: {}, + table: { + maxWidth: '100%' + }, + tableBody: {}, + tableRow: {}, + tableCell: {}, + tableHeadCell: {}, +}); + + +const Card = ({ className, collection, document, currentUser, fields, classes }, { intl }) => { + + const fieldNames = fields ? fields : _.without(_.keys(document), '__typename'); + const canUpdate = currentUser && collection.options.mutations.update.check(currentUser, document); + + return ( +
+ + + {canUpdate ? : null} + {fieldNames.map((fieldName, index) => + + )} + +
+
+ ); +}; + + +Card.displayName = 'Card'; + + +Card.propTypes = { + className: PropTypes.string, + collection: PropTypes.object, + document: PropTypes.object, + currentUser: PropTypes.object, + fields: PropTypes.array, + classes: PropTypes.object.isRequired, +}; + + +Card.contextTypes = { + intl: intlShape +}; + + +replaceComponent('Card', Card, [withStyles, styles]); diff --git a/packages/vulcan-ui-material/lib/components/core/Datatable.jsx b/packages/vulcan-ui-material/lib/components/core/Datatable.jsx new file mode 100644 index 000000000..d2561b1b4 --- /dev/null +++ b/packages/vulcan-ui-material/lib/components/core/Datatable.jsx @@ -0,0 +1,649 @@ +import React, { PureComponent } from 'react'; +import PropTypes from 'prop-types'; +import { + Components, + registerComponent, + replaceComponent, + withCurrentUser, + withMulti, + Utils +} from 'meteor/vulcan:core'; +import { intlShape } from 'meteor/vulcan:i18n'; +import withStyles from '@material-ui/core/styles/withStyles'; +import Table from '@material-ui/core/Table'; +import TableBody from '@material-ui/core/TableBody'; +import TableHead from '@material-ui/core/TableHead'; +import TableRow from '@material-ui/core/TableRow'; +import TableCell from '@material-ui/core/TableCell'; +import TableFooter from '@material-ui/core/TableFooter'; +import Tooltip from '@material-ui/core/Tooltip'; +import TableSortLabel from '@material-ui/core/TableSortLabel'; +import TablePagination from '@material-ui/core/TablePagination'; +import Toolbar from '@material-ui/core/Toolbar'; +import Typography from '@material-ui/core/Typography'; +import { getFieldValue } from './Card'; +import _assign from 'lodash/assign'; +import classNames from 'classnames'; + + +/* + +Datatable Component + +*/ +const baseStyles = theme => ({ + root: { + position: 'relative', + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + }, + addButton: { + top: '9.5rem', + right: '2rem', + position: 'fixed', + bottom: 'auto', + }, + table: { + marginTop:0 + }, + denseTable: {}, + denserTable: {}, + flatTable: {}, + tableHead: {}, + tableBody: {}, + tableFooter: {}, + tableRow: {}, + tableHeadCell: {}, + tableCell: {}, + clickRow: {}, + editCell: {}, + editButton: {} +}); + +const delay = (function () { + var timer = 0; + return function (callback, ms) { + clearTimeout(timer); + timer = setTimeout(callback, ms); + }; +})(); + +class Datatable extends PureComponent { + + constructor (props) { + super(props); + + this.updateQuery = this.updateQuery.bind(this); + + this.state = { + value: '', + query: '', + currentSort: {}, + }; + } + + toggleSort = column => { + let currentSort; + if (!this.state.currentSort[column]) { + currentSort = { [column]: 1 }; + } else if (this.state.currentSort[column] === 1) { + currentSort = { [column]: -1 }; + } else { + currentSort = {}; + } + this.setState({ currentSort }); + }; + + updateQuery (value) { + this.setState({ + value: value + }); + delay(() => { + this.setState({ + query: value + }); + }, 700); + } + + render () { + if (this.props.data) { + + return ; + + } else { + + const { + className, + collection, + options, + showSearch, + showNew, + currentUser, + classes, + } = this.props; + + const listOptions = { + collection: collection, + ...options, + }; + + const DatatableWithMulti = withMulti(listOptions)(Components.DatatableContents); + + // add _id to orderBy when we want to sort a column, to avoid breaking the graphql() hoc; + // see https://github.com/VulcanJS/Vulcan/issues/2090#issuecomment-433860782 + // this.state.currentSort !== {} is always false, even when console.log(this.state.currentSort) displays + // {}. So we test on the length of keys for this object. + const orderBy = Object.keys(this.state.currentSort).length == 0 ? {} : + { ...this.state.currentSort, _id: -1 }; + + return ( +
+ {/* DatatableAbove Component part*/} + { + showSearch && + + + } + { + showNew && + + + } + + +
+ ); + } + } +} + + +Datatable.propTypes = { + title: PropTypes.string, + className: PropTypes.string, + collection: PropTypes.object, + options: PropTypes.object, + columns: PropTypes.array, + showEdit: PropTypes.bool, + editComponent: PropTypes.func, + showNew: PropTypes.bool, + showSearch: PropTypes.bool, + emptyState: PropTypes.node, + currentUser: PropTypes.object, + classes: PropTypes.object, + data: PropTypes.array, + footerData: PropTypes.array, + dense: PropTypes.string, + queryDataRef: PropTypes.func, + rowClass: PropTypes.oneOfType([PropTypes.string, PropTypes.func]), + handleRowClick: PropTypes.func, + intlNamespace: PropTypes.string, + toggleSort: PropTypes.func, + currentSort: PropTypes.object, + paginate: PropTypes.bool, +}; + + +Datatable.defaultProps = { + showNew: true, + showEdit: true, + showSearch: true, + paginate: false, +}; + + +replaceComponent('Datatable', Datatable, withCurrentUser, [withStyles, baseStyles]); + + +/* + +DatatableContents Component + +*/ +const datatableContentsStyles = theme => (_assign({}, baseStyles(theme), { + table: { + marginTop: theme.spacing.unit * 3, + marginBottom: theme.spacing.unit * 3, + }, + denseTable: theme.utils.denseTable, + flatTable: theme.utils.flatTable, + denserTable: theme.utils.denserTable, +})); + + +const DatatableContents = ({ + collection, + columns, + results, + loading, + loadMore, + count, + totalCount, + networkStatus, + refetch, + showEdit, + editComponent, + emptyState, + currentUser, + classes, + footerData, + dense, + queryDataRef, + rowClass, + handleRowClick, + intlNamespace, + title, + toggleSort, + currentSort, + paginate, + paginationTerms, + setPaginationTerms + }) => { + + if (loading) { + return ; + } else if (!results || !results.length) { + return emptyState || null; + } + + if (queryDataRef) queryDataRef(this.props); + + const denseClass = dense && classes[dense + 'Table']; + + // Pagination functions + const getPage = (paginationTerms) => (parseInt((paginationTerms.limit - 1) / paginationTerms.itemsPerPage)); + + const onChangePage = (event, page) => { + setPaginationTerms({ + itemsPerPage: paginationTerms.itemsPerPage, + limit: (page + 1) * paginationTerms.itemsPerPage, + offset: page * paginationTerms.itemsPerPage + }); + }; + + const onChangeRowsPerPage = (event) => { + let value = event.target.value; + let offset = Math.max(0, parseInt((paginationTerms.limit - paginationTerms.itemsPerPage) / value) * value); + let limit = Math.min(offset + value, totalCount); + setPaginationTerms({ + itemsPerPage: value, + limit: limit, + offset: offset + }); + }; + + return ( + + { + (title)? + + + title + + + :null + } + + { + columns && + + + { + _.sortBy(columns, column => column.order).map( + (column, index) => + + ) + } + { + (showEdit || editComponent) && + + + } + + + } + + { + results && + + + { + results.map( + (document, index) => + ) + } + + } + + { + footerData && + + + + { + _.sortBy(columns, column => column.order).map( + (column, index) => + + {footerData[index]} + + ) + } + { + (showEdit || editComponent) && + + + } + + + + } + +
+ { + paginate && + + + } + { + !paginate && loadMore && + + + } +
+ ); +}; + + +replaceComponent('DatatableContents', DatatableContents, [withStyles, datatableContentsStyles]); + + +/* + +DatatableHeader Component + +*/ +const DatatableHeader = ({ collection, intlNamespace, column, classes, toggleSort, currentSort }, { intl }) => { + const columnName = typeof column === 'string' ? column : column.name || column.label; + let formattedLabel = ''; + + if (collection) { + const schema = collection.simpleSchema()._schema; + + /* + use either: + + 1. the column name translation + 2. the column name label in the schema (if the column name matches a schema field) + 3. the raw column name. + */ + const defaultMessage = schema[columnName] ? schema[columnName].label : Utils.camelToSpaces(columnName); + formattedLabel = typeof columnName === 'string' ? + intl.formatMessage({ + id: `${collection._name}.${columnName}`, + defaultMessage: defaultMessage + }) : + ''; + + // if sortable is a string, use it as the name of the property to sort by. If it's just `true`, use + // column.name + const sortPropertyName = typeof column.sortable === 'string' ? column.sortable : column.name; + + if (column.sortable) { + return ; + } + } else if (intlNamespace) { + formattedLabel = typeof columnName === 'string' ? + intl.formatMessage({ + id: `${intlNamespace}.${columnName}`, + defaultMessage: columnName + }) : + ''; + } else { + formattedLabel = intl.formatMessage({ id: columnName, defaultMessage: columnName }); + } + + return {formattedLabel}; +}; + + +DatatableHeader.contextTypes = { + intl: intlShape, +}; + + +replaceComponent('DatatableHeader', DatatableHeader); + + +/* + +DatatableSorter Component + +*/ + +const DatatableSorter = ({ name, label, toggleSort, currentSort, sortable }) => + + + toggleSort(name)} + > + {label} + + + ; + +replaceComponent('DatatableSorter', DatatableSorter); + + +/* + +DatatableRow Component + +*/ +const datatableRowStyles = theme => (_assign({}, baseStyles(theme), { + clickRow: { + cursor: 'pointer', + }, + editCell: { + paddingTop: '0 !important', + paddingBottom: '0 !important', + textAlign: 'right', + }, +})); + + +const DatatableRow = ({ + collection, + columns, + document, + refetch, + showEdit, + editComponent, + currentUser, + rowClass, + handleRowClick, + classes, + }, { intl }) => { + + const EditComponent = editComponent; + + if (typeof rowClass === 'function') { + rowClass = rowClass(document); + } + + return ( + handleRowClick(event, document))} + hover + > + + { + _.sortBy(columns, column => column.order).map( + (column, index) => + ) + } + + { + (showEdit || editComponent) && + + + { + EditComponent && + + + } + { + showEdit && + + + } + + } + + + ); +}; + + +replaceComponent('DatatableRow', DatatableRow, [withStyles, datatableRowStyles]); + + +DatatableRow.contextTypes = { + intl: intlShape +}; + + +/* + +DatatableCell Component + +*/ +const DatatableCell = ({ column, document, currentUser, classes }) => { + const Component = column.component || + Components[column.componentName] || + Components.DatatableDefaultCell; + + const columnName = typeof column === 'string' ? column : column.name; + const className = typeof columnName === 'string' ? + `datatable-item-${columnName.toLowerCase()}` : + ''; + const cellClass = typeof column.cellClass === 'function' ? + column.cellClass({ column, document, currentUser }) : + typeof column.cellClass === 'string' ? + column.cellClass : + null; + + return ( + + + + ); +}; + + +replaceComponent('DatatableCell', DatatableCell); + + +/* + +DatatableDefaultCell Component + +*/ +const DatatableDefaultCell = ({ column, document }) => +
+ { + typeof column === 'string' + ? + getFieldValue(document[column]) + : + getFieldValue(document[column.name]) + } +
; + + +replaceComponent('DatatableDefaultCell', DatatableDefaultCell); diff --git a/packages/vulcan-ui-material/lib/components/core/EditButton.jsx b/packages/vulcan-ui-material/lib/components/core/EditButton.jsx new file mode 100644 index 000000000..ba1f9ff77 --- /dev/null +++ b/packages/vulcan-ui-material/lib/components/core/EditButton.jsx @@ -0,0 +1,100 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { Components, registerComponent } from 'meteor/vulcan:core'; +import { intlShape } from 'meteor/vulcan:i18n'; +import EditIcon from 'mdi-material-ui/Pencil'; + +const EditButton = ( + { + collection, + document, + color = 'default', + variant, + triggerClasses, + buttonClasses, + showRemove, + ...props + }, + { intl } +) => ( + } + color={color} + variant={variant} + classes={buttonClasses} + /> + } + > + + +); + +EditButton.propTypes = { + collection: PropTypes.object.isRequired, + document: PropTypes.object.isRequired, + color: PropTypes.oneOf(['default', 'inherit', 'primary', 'secondary']), + variant: PropTypes.string, + triggerClasses: PropTypes.object, + buttonClasses: PropTypes.object, + showRemove: PropTypes.bool +}; + +EditButton.contextTypes = { + intl: intlShape +}; + +EditButton.displayName = 'EditButton'; + +registerComponent('EditButton', EditButton); + +/* + +EditForm Component + +*/ +const EditForm = ({ + collection, + document, + closeModal, + options, + successCallback, + removeSuccessCallback, + showRemove, + ...props +}) => { + const success = successCallback + ? () => { + successCallback(); + closeModal(); + } + : closeModal; + + const remove = removeSuccessCallback + ? () => { + removeSuccessCallback(); + closeModal(); + } + : closeModal; + + return ( + + ); +}; + +registerComponent('EditForm', EditForm); diff --git a/packages/vulcan-ui-material/lib/components/core/Loading.jsx b/packages/vulcan-ui-material/lib/components/core/Loading.jsx new file mode 100644 index 000000000..dc821a032 --- /dev/null +++ b/packages/vulcan-ui-material/lib/components/core/Loading.jsx @@ -0,0 +1,9 @@ +import React from 'react'; +import { replaceComponent } from 'meteor/vulcan:core'; +import CircularProgress from '@material-ui/core/CircularProgress'; + +function Loading(props) { + return ; +} + +replaceComponent('Loading', Loading); diff --git a/packages/vulcan-ui-material/lib/components/core/ModalTrigger.jsx b/packages/vulcan-ui-material/lib/components/core/ModalTrigger.jsx new file mode 100644 index 000000000..928256805 --- /dev/null +++ b/packages/vulcan-ui-material/lib/components/core/ModalTrigger.jsx @@ -0,0 +1,159 @@ +import React, { PureComponent } from 'react'; +import PropTypes from 'prop-types'; +import { intlShape } from 'meteor/vulcan:i18n'; +import { registerComponent, Components } from 'meteor/vulcan:core'; +import withStyles from '@material-ui/core/styles/withStyles'; +import Dialog from '@material-ui/core/Dialog'; +import DialogTitle from '@material-ui/core/DialogTitle'; +import DialogContent from '@material-ui/core/DialogContent'; +import Button from '@material-ui/core/Button'; +import Tooltip from '@material-ui/core/Tooltip'; +import Close from 'mdi-material-ui/Close'; + +import classNames from 'classnames'; + + +const styles = theme => ({ + root: { + display: 'inline-block', + }, + button: {}, + anchor: {}, + dialog: {}, + dialogPaper: { + overflowY: 'visible', + }, + dialogTitle: { + padding: theme.spacing.unit * 4, + }, + dialogContent: { + paddingTop: '4px', + }, + closeButton: { + position: 'absolute', + right: theme.spacing.unit, + top: theme.spacing.unit, + } +}); + +class ModalTrigger extends PureComponent { + + constructor (props) { + super(props); + this.state = { modalIsOpen: false }; + } + + componentDidMount() { + if (this.props.action) { + this.props.action({ + openModal: this.openModal, + closeModal: this.closeModal, + }); + } + } + + openModal = () => { + this.setState({ modalIsOpen: true }); + }; + + closeModal = () => { + this.setState({ modalIsOpen: false }); + }; + + render () { + const { + className, + dialogClassName, + dialogOverflow, + labelId, + component, + titleId, + type, + children, + classes, + } = this.props; + + const intl = this.context.intl; + + const label = labelId ? intl.formatMessage({ id: labelId }) : this.props.label; + const title = titleId ? intl.formatMessage({ id: titleId }) : this.props.title; + const overflowClass = dialogOverflow && classes.dialogOverflow; + + const triggerComponent = component + ? + React.cloneElement(component, { onClick: this.openModal }) + : + type === 'button' + ? + + : + {label}; + + const childrenComponent = typeof children.type === 'function' ? + React.cloneElement(children, { closeModal: this.closeModal }) : + children; + + return ( + + + {triggerComponent} + + + {title} + + + + + + + + + + + + {childrenComponent} + + + + + + ); + } +} + + +ModalTrigger.propTypes = { + /** + * Callback fired when the component mounts. + * This is useful when you want to trigger an action programmatically. + * It supports `openModal()` and `closeModal()`. + * + * @param {object} actions This object contains all possible actions + * that can be triggered programmatically. + */ + action: PropTypes.func, + className: PropTypes.string, + dialogClassName: PropTypes.string, + dialogOverflow: PropTypes.bool, + label: PropTypes.string, + labelId: PropTypes.string, + component: PropTypes.object, + title: PropTypes.node, + titleId: PropTypes.string, + type: PropTypes.oneOf(['link', 'button']), + children: PropTypes.node, + classes: PropTypes.object, +}; + + +ModalTrigger.contextTypes = { + intl: intlShape, +}; + + +registerComponent('ModalTrigger', ModalTrigger, [withStyles, styles]); diff --git a/packages/vulcan-ui-material/lib/components/core/NewButton.jsx b/packages/vulcan-ui-material/lib/components/core/NewButton.jsx new file mode 100644 index 000000000..1ce2cbf93 --- /dev/null +++ b/packages/vulcan-ui-material/lib/components/core/NewButton.jsx @@ -0,0 +1,44 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { Components, replaceComponent } from 'meteor/vulcan:core'; +import { intlShape } from 'meteor/vulcan:i18n'; +import AddIcon from 'mdi-material-ui/Plus'; + + +const NewButton = ({ + className, + collection, + color = 'default', + variant, + }, { intl }) => ( + + } + color={color} + variant={variant} + />} + > + + +); + + +NewButton.propTypes = { + className: PropTypes.string, + collection: PropTypes.object.isRequired, + color: PropTypes.oneOf(['default', 'inherit', 'primary', 'secondary']), + variant: PropTypes.string, +}; + + +NewButton.contextTypes = { + intl: intlShape +}; + + +NewButton.displayName = 'NewButton'; + + +replaceComponent('NewButton', NewButton); diff --git a/packages/vulcan-ui-material/lib/components/forms/FormComponentInner.jsx b/packages/vulcan-ui-material/lib/components/forms/FormComponentInner.jsx new file mode 100644 index 000000000..de8fdf7f2 --- /dev/null +++ b/packages/vulcan-ui-material/lib/components/forms/FormComponentInner.jsx @@ -0,0 +1,129 @@ +import React, { PureComponent } from 'react'; +import PropTypes from 'prop-types'; +import { intlShape } from 'meteor/vulcan:i18n'; +import { Components, registerComponent, instantiateComponent } from 'meteor/vulcan:core'; +import withStyles from '@material-ui/core/styles/withStyles'; +import _omit from 'lodash/omit'; +import classNames from 'classnames'; + + +const styles = theme => ({ + + formInput: { + position: 'relative', + marginBottom: theme.spacing.unit * 3, + }, + + halfWidthLeft: { + display: 'inline-block', + width: '48%', + verticalAlign: 'top', + marginRight: '4%', + }, + + halfWidthRight: { + display: 'inline-block', + width: '48%', + verticalAlign: 'top', + }, + + thirdWidthLeft: { + display: 'inline-block', + width: '31%', + verticalAlign: 'top', + marginRight: '3.5%', + }, + + thirdWidthRight: { + display: 'inline-block', + width: '31%', + verticalAlign: 'top', + }, + + hidden: { + display: 'none', + }, + +}); + + +class FormComponentInner extends PureComponent { + + getProperties = () => { + return _omit(this.props, 'classes'); + }; + + render () { + const { + classes, + inputClassName, + name, + input, + hidden, + beforeComponent, + afterComponent, + formInput, + intlInput, + nestedInput, + formComponents, + } = this.props; + + const FormComponents = formComponents; + + const inputClass = classNames( + classes.formInput, + hidden && classes.hidden, + inputClassName && classes[inputClassName], + `input-${name}`, + `form-component-${input || 'default'}` + ); + + const properties = this.getProperties(); + + const FormInput = formInput; + + if (intlInput) { + return ; + } else if (nestedInput){ + return ; + } else { + return ( +
+ {instantiateComponent(beforeComponent, properties)} + + {instantiateComponent(afterComponent, properties)} +
+ ); + } + + } +} + + +FormComponentInner.contextTypes = { + intl: intlShape, +}; + + +FormComponentInner.propTypes = { + classes: PropTypes.object.isRequired, + inputClassName: PropTypes.string, + name: PropTypes.string.isRequired, + input: PropTypes.any, + beforeComponent: PropTypes.oneOfType([PropTypes.string, PropTypes.node]), + afterComponent: PropTypes.oneOfType([PropTypes.string, PropTypes.node]), + errors: PropTypes.array.isRequired, + help: PropTypes.node, + onChange: PropTypes.func.isRequired, + showCharsRemaining: PropTypes.bool.isRequired, + charsRemaining: PropTypes.number, + charsCount: PropTypes.number, + max: PropTypes.oneOfType([PropTypes.number, PropTypes.instanceOf(Date)]), + formInput: PropTypes.func.isRequired, +}; + + +FormComponentInner.displayName = 'FormComponentInner'; + + +registerComponent('FormComponentInner', FormComponentInner, [withStyles, styles]); diff --git a/packages/vulcan-ui-material/lib/components/forms/FormErrors.jsx b/packages/vulcan-ui-material/lib/components/forms/FormErrors.jsx new file mode 100644 index 000000000..049d13ce1 --- /dev/null +++ b/packages/vulcan-ui-material/lib/components/forms/FormErrors.jsx @@ -0,0 +1,51 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { replaceComponent, Components } from 'meteor/vulcan:core'; +import { FormattedMessage } from 'meteor/vulcan:i18n'; + +import Snackbar from '@material-ui/core/Snackbar'; +import withStyles from '@material-ui/core/styles/withStyles'; +import classNames from 'classnames'; + + +const styles = theme => ({ + root: { + position: 'relative', + boxShadow: 'none', + marginBottom: theme.spacing.unit * 2 + }, + list: { + marginBottom: 0 + }, + error: { '& > div': { backgroundColor: theme.palette.error[500] } }, + danger: { '& > div': { backgroundColor: theme.palette.error[500] } }, + warning: { '& > div': { backgroundColor: theme.palette.error[500] } } +}); + + +const FormErrors = ({ errors, classes }) => { + const messageNode = ( +
    + {errors.map((error, index) => ( +
  • + +
  • + ))} +
+ ); + + return ( +
+ {!!errors.length && ( + + )} +
+ ); +}; + + +replaceComponent('FormErrors', FormErrors, [withStyles, styles]); diff --git a/packages/vulcan-ui-material/lib/components/forms/FormGroupNone.jsx b/packages/vulcan-ui-material/lib/components/forms/FormGroupNone.jsx new file mode 100644 index 000000000..773b11eba --- /dev/null +++ b/packages/vulcan-ui-material/lib/components/forms/FormGroupNone.jsx @@ -0,0 +1,95 @@ +import React, { PureComponent } from 'react'; +import PropTypes from 'prop-types'; +import { + Components, + registerComponent, + instantiateComponent, +} from 'meteor/vulcan:core'; +import withStyles from '@material-ui/core/styles/withStyles'; +import Users from 'meteor/vulcan:users'; +import classNames from 'classnames'; + + +const styles = theme => ({ + root: { + minWidth: '320px' + }, +}); + + +class FormGroupNone extends PureComponent { + + + render () { + const { + name, + hidden, + classes, + currentUser, + } = this.props; + + if (this.isAdmin && !Users.isAdmin(currentUser)) { + return null; + } + + if (typeof hidden === 'function' ? hidden({ ...this.props }) : hidden) { + return null; + } + + //do not display if no fields, no startComponent and no endComponent + if (!this.props.startComponent && !this.props.endComponent && !this.props.fields.length) { + return null; + } + + const anchorName = name.split('.').length > 1 ? name.split('.')[1] : name; + + const FormComponents = this.props.formComponents; + + return ( + + ); + } + + +} + + +FormGroupNone.propTypes = { + name: PropTypes.string, + order: PropTypes.number, + hidden: PropTypes.bool, + fields: PropTypes.array, + updateCurrentValues: PropTypes.func, + startComponent: PropTypes.node, + endComponent: PropTypes.node, + currentUser: PropTypes.object, +}; + + +registerComponent('FormGroupNone', FormGroupNone, [withStyles, styles]); diff --git a/packages/vulcan-ui-material/lib/components/forms/FormGroupWithLine.jsx b/packages/vulcan-ui-material/lib/components/forms/FormGroupWithLine.jsx new file mode 100644 index 000000000..7ea0c0965 --- /dev/null +++ b/packages/vulcan-ui-material/lib/components/forms/FormGroupWithLine.jsx @@ -0,0 +1,198 @@ +import React, { PureComponent } from 'react'; +import PropTypes from 'prop-types'; +import { Components, registerComponent, instantiateComponent } from 'meteor/vulcan:core'; +import Users from 'meteor/vulcan:users'; +import withStyles from '@material-ui/core/styles/withStyles'; +import Collapse from '@material-ui/core/Collapse'; +import Divider from '@material-ui/core/Divider'; +import Typography from '@material-ui/core/Typography'; +import ExpandLessIcon from 'mdi-material-ui/ChevronUp'; +import ExpandMoreIcon from 'mdi-material-ui/ChevronDown'; +import classNames from 'classnames'; + + +const styles = theme => ({ + root: { + minWidth: '320px', + }, + divider: { + marginLeft: theme.spacing.unit * -3, + marginRight: theme.spacing.unit * -3, + }, + subtitle1: { + marginTop: theme.spacing.unit * 5, + position: 'relative', + }, + collapsible: { + cursor: 'pointer', + }, + typography: { + display: 'flex', + alignItems: 'center', + '& > div': { + display: 'flex', + alignItems: 'center', + }, + '& > div:first-child': { + ...theme.typography.subtitle1, + }, + paddingTop: theme.spacing.unit, + paddingBottom: theme.spacing.unit, + }, + toggle: { + color: theme.palette.action.active, + }, + entered: { + overflow: 'visible', + }, +}); + + +class FormGroupWithLine extends PureComponent { + + constructor (props) { + super(props); + + this.isAdmin = props.name === 'admin'; + + this.state = { + collapsed: props.startCollapsed || this.isAdmin, + }; + } + + + toggle = () => { + const collapsible = this.props.collapsible || this.isAdmin; + if (!collapsible) return; + + this.setState({ + collapsed: !this.state.collapsed + }); + }; + + + renderHeading = () => { + const { classes } = this.props; + const collapsible = this.props.collapsible || this.isAdmin; + + return ( +
+ + + + +
+ {this.props.label} +
+ { + collapsible && + +
+ { + this.state.collapsed + ? + + : + + } +
+ } +
+ +
+ ); + }; + + + // if at least one of the fields in the group has an error, the group as a whole has an error + hasErrors = () => _.some(this.props.fields, field => { + return !!this.props.errors.filter(error => error.path === field.path).length; + }); + + + render () { + const { + name, + hidden, + classes, + currentUser, + } = this.props; + + if (this.isAdmin && !Users.isAdmin(currentUser)) { + return null; + } + + if (typeof hidden === 'function' ? hidden({ ...this.props }) : hidden) { + return null; + } + + //do not display if no fields, no startComponent and no endComponent + if (!this.props.startComponent && !this.props.endComponent && !this.props.fields.length) { + return null; + } + + const anchorName = name.split('.').length > 1 ? name.split('.')[1] : name; + const collapseIn = !this.state.collapsed || this.hasErrors(); + + const FormComponents = this.props.formComponents; + + return ( +
+ ); + } +} + + +FormGroupWithLine.propTypes = { + name: PropTypes.string, + label: PropTypes.string, + order: PropTypes.number, + fields: PropTypes.array, + collapsible: PropTypes.bool, + startCollapsed: PropTypes.bool, + updateCurrentValues: PropTypes.func, + startComponent: PropTypes.node, + endComponent: PropTypes.node, + currentUser: PropTypes.object, +}; + + +registerComponent('FormGroupWithLine', FormGroupWithLine, [withStyles, styles]); diff --git a/packages/vulcan-ui-material/lib/components/forms/FormNested.jsx b/packages/vulcan-ui-material/lib/components/forms/FormNested.jsx new file mode 100644 index 000000000..2b3c7e5db --- /dev/null +++ b/packages/vulcan-ui-material/lib/components/forms/FormNested.jsx @@ -0,0 +1,13 @@ +import React from 'react'; +import { replaceComponent } from 'meteor/vulcan:core'; +import Delete from 'mdi-material-ui/Delete'; +import Plus from 'mdi-material-ui/Plus'; + + +const IconRemove = () => ; + +replaceComponent('IconRemove', IconRemove); + +const IconAdd = () => ; + +replaceComponent('IconAdd', IconAdd); diff --git a/packages/vulcan-ui-material/lib/components/forms/FormNestedArrayLayout.jsx b/packages/vulcan-ui-material/lib/components/forms/FormNestedArrayLayout.jsx new file mode 100644 index 000000000..1a7e4e4a0 --- /dev/null +++ b/packages/vulcan-ui-material/lib/components/forms/FormNestedArrayLayout.jsx @@ -0,0 +1,24 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { replaceComponent } from 'meteor/vulcan:core'; + +import Grid from '@material-ui/core/Grid'; +import Typography from '@material-ui/core/Typography'; + +const FormNestedArrayLayout = ({ hasErrors, label, content }) => ( +
+ + {label} + +
{content}
+
+); +FormNestedArrayLayout.propTypes = { + hasErrors: PropTypes.bool, + label: PropTypes.node, + content: PropTypes.node, +}; +replaceComponent({ + name: 'FormNestedArrayLayout', + component: FormNestedArrayLayout, +}); diff --git a/packages/vulcan-ui-material/lib/components/forms/FormNestedDivider.jsx b/packages/vulcan-ui-material/lib/components/forms/FormNestedDivider.jsx new file mode 100644 index 000000000..4f49615d4 --- /dev/null +++ b/packages/vulcan-ui-material/lib/components/forms/FormNestedDivider.jsx @@ -0,0 +1,25 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { replaceComponent } from 'meteor/vulcan:core'; +// import { FormattedMessage } from 'meteor/vulcan:i18n'; +import withStyles from '@material-ui/core/styles/withStyles'; +import Divider from '@material-ui/core/Divider'; + +const styles = theme => ({ + divider: { + // marginLeft: -24, + // marginRight: -24, + marginTop: 16, + marginBottom: 23, + }, +}); + +const FormNestedDivider = ({ classes, label, addItem }) => ; + +FormNestedDivider.propTypes = { + classes: PropTypes.object.isRequired, + label: PropTypes.string, + addItem: PropTypes.func, +}; + +replaceComponent('FormNestedDivider', FormNestedDivider, [withStyles, styles]); diff --git a/packages/vulcan-ui-material/lib/components/forms/FormNestedFoot.jsx b/packages/vulcan-ui-material/lib/components/forms/FormNestedFoot.jsx new file mode 100644 index 000000000..c78ab73ad --- /dev/null +++ b/packages/vulcan-ui-material/lib/components/forms/FormNestedFoot.jsx @@ -0,0 +1,20 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { Components, registerComponent } from 'meteor/vulcan:core'; +import { FormattedMessage } from 'meteor/vulcan:i18n'; +import Grid from '@material-ui/core/Grid'; + +const FormNestedFoot = ({ label, addItem }) => ( + + + + + +); + +FormNestedFoot.propTypes = { + label: PropTypes.string, + addItem: PropTypes.func, +}; + +registerComponent('FormNestedFoot', FormNestedFoot); diff --git a/packages/vulcan-ui-material/lib/components/forms/FormNestedHead.jsx b/packages/vulcan-ui-material/lib/components/forms/FormNestedHead.jsx new file mode 100644 index 000000000..bd24a88d5 --- /dev/null +++ b/packages/vulcan-ui-material/lib/components/forms/FormNestedHead.jsx @@ -0,0 +1,13 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { replaceComponent } from 'meteor/vulcan:core'; +import { FormattedMessage } from 'meteor/vulcan:i18n'; + +const FormNestedHead = ({ label, addItem }) => ; + +FormNestedHead.propTypes = { + label: PropTypes.string, + addItem: PropTypes.func, +}; + +replaceComponent('FormNestedHead', FormNestedHead); diff --git a/packages/vulcan-ui-material/lib/components/forms/FormSubmit.jsx b/packages/vulcan-ui-material/lib/components/forms/FormSubmit.jsx new file mode 100644 index 000000000..b8b92a301 --- /dev/null +++ b/packages/vulcan-ui-material/lib/components/forms/FormSubmit.jsx @@ -0,0 +1,136 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { Components, replaceComponent } from 'meteor/vulcan:core'; +import { intlShape } from 'meteor/vulcan:i18n'; +import withStyles from '@material-ui/core/styles/withStyles'; +import Button from '@material-ui/core/Button'; +import IconButton from '@material-ui/core/IconButton'; +import DeleteIcon from 'mdi-material-ui/Delete'; +import Tooltip from '@material-ui/core/Tooltip'; +import { FormattedMessage } from 'meteor/vulcan:i18n'; +import classNames from 'classnames'; + + +const styles = theme => ({ + root: { + textAlign: 'center', + marginTop: theme.spacing.unit * 4, + }, + button: { + margin: theme.spacing.unit, + }, + delete: { + float: 'left', + }, + tooltip: { + margin: 3, + } +}); + + +const FormSubmit = ({ + submitLabel, + cancelLabel, + cancelCallback, + revertLabel, + revertCallback, + document, + deleteDocument, + collectionName, + classes + }, { + intl, + isChanged, + clearForm, + }) => { + + if (typeof isChanged !== 'function') { + isChanged = () => true; + } + + return ( +
+ + { + deleteDocument + ? + + + + + + : + null + } + + { + cancelCallback + ? + + : + null + } + + { + revertCallback + ? + + : + null + } + + + +
+ ); +}; + + +FormSubmit.propTypes = { + submitLabel: PropTypes.node, + cancelLabel: PropTypes.node, + revertLabel: PropTypes.node, + cancelCallback: PropTypes.func, + revertCallback: PropTypes.func, + document: PropTypes.object, + deleteDocument: PropTypes.func, + collectionName: PropTypes.string, + classes: PropTypes.object, +}; + + +FormSubmit.contextTypes = { + intl: intlShape, + isChanged: PropTypes.func, + clearForm: PropTypes.func, +}; + + +replaceComponent('FormSubmit', FormSubmit, [withStyles, styles]); diff --git a/packages/vulcan-ui-material/lib/components/forms/base-controls/EndAdornment.jsx b/packages/vulcan-ui-material/lib/components/forms/base-controls/EndAdornment.jsx new file mode 100644 index 000000000..4f136be08 --- /dev/null +++ b/packages/vulcan-ui-material/lib/components/forms/base-controls/EndAdornment.jsx @@ -0,0 +1,84 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { instantiateComponent } from 'meteor/vulcan:core'; +import withStyles from '@material-ui/core/styles/withStyles'; +import InputAdornment from '@material-ui/core/InputAdornment'; +import IconButton from '@material-ui/core/IconButton'; +import CloseIcon from 'mdi-material-ui/CloseCircle'; +import classNames from 'classnames'; + + +export const styles = theme => ({ + inputAdornment: { + whiteSpace: 'nowrap', + marginTop: '0 !important', + '& > *': { + verticalAlign: 'bottom', + }, + '& > svg': { + color: theme.palette.common.darkBlack, + }, + '& > * + *': { + marginLeft: 8, + } + }, + clearButton: { + opacity: 0, + '& svg': { + width: 20, + height: 20, + }, + marginRight: -12, + marginLeft: -4, + '&:first-child': { + marginLeft: -12, + }, + transition: theme.transitions.create('opacity', { + duration: theme.transitions.duration.short, + }), + }, + urlButton: { + verticalAlign: 'bottom', + width: 24, + height: 24, + fontSize: 20, + } +}); + + +const EndAdornment = (props) => { + const { classes, value, addonAfter, changeValue, hideClear, disabled } = props; + + if (!addonAfter && (!changeValue || hideClear || disabled)) return null; + const hasValue = !!value || value === 0; + + const clearButton = changeValue && !hideClear && !disabled && + { + event.preventDefault(); + changeValue(null); + }} + tabIndex="-1" + > + + ; + + return ( + + {instantiateComponent(addonAfter)} + {clearButton} + + ); +}; + + +EndAdornment.propTypes = { + classes: PropTypes.object.isRequired, + value: PropTypes.any, + changeValue: PropTypes.func, + hideClear: PropTypes.bool, + addonAfter: PropTypes.oneOfType([PropTypes.string, PropTypes.node, PropTypes.func]), +}; + + +export default withStyles(styles)(EndAdornment); diff --git a/packages/vulcan-ui-material/lib/components/forms/base-controls/MuiCheckboxGroup.jsx b/packages/vulcan-ui-material/lib/components/forms/base-controls/MuiCheckboxGroup.jsx new file mode 100644 index 000000000..19070819d --- /dev/null +++ b/packages/vulcan-ui-material/lib/components/forms/base-controls/MuiCheckboxGroup.jsx @@ -0,0 +1,150 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import createReactClass from 'create-react-class'; +import ComponentMixin from './mixins/component'; +import withStyles from '@material-ui/core/styles/withStyles'; +import FormGroup from '@material-ui/core/FormGroup'; +import FormControlLabel from '@material-ui/core/FormControlLabel'; +import MuiFormControl from './MuiFormControl'; +import MuiFormHelper from './MuiFormHelper'; +import Checkbox from '@material-ui/core/Checkbox'; +import Switch from '@material-ui/core/Switch'; +import classNames from 'classnames'; + + +const styles = theme => ({ + group: { + marginTop: '8px', + }, + twoColumn: { + display: 'block', + [theme.breakpoints.down('md')]: { + '& > label': { + marginRight: theme.spacing.unit * 5, + }, + }, + [theme.breakpoints.up('md')]: { + '& > label': { + width: '49%', + }, + }, + }, + threeColumn: { + display: 'block', + [theme.breakpoints.down('xs')]: { + '& > label': { + marginRight: theme.spacing.unit * 5, + }, + }, + [theme.breakpoints.up('xs')]: { + '& > label': { + width: '49%', + }, + }, + [theme.breakpoints.up('md')]: { + '& > label': { + width: '32%', + }, + }, + }, +}); + + +const MuiCheckboxGroup = createReactClass({ + + mixins: [ComponentMixin], + + propTypes: { + name: PropTypes.string.isRequired, + options: PropTypes.array.isRequired, + classes: PropTypes.object.isRequired, + variant: PropTypes.oneOf(['checkbox', 'switch']), + }, + + componentDidMount: function () { + if (this.props.refFunction) { + this.props.refFunction(this); + } + }, + + getDefaultProps: function () { + return { + label: '', + help: null, + variant: 'checkbox', + }; + }, + + changeCheckbox: function () { + const value = []; + this.props.options.forEach(function (option, key) { + if (this[this.props.name + '-' + option.value].checked) { + value.push(option.value); + } + }.bind(this)); + + this.props.onChange(value); + }, + + validate: function () { + if (this.props.onBlur) { + this.props.onBlur(); + } + return true; + }, + + renderElement: function () { + const controls = this.props.options.map((checkbox, key) => { + let value = checkbox.value; + let checked = (this.props.value.indexOf(value) !== -1); + let disabled = checkbox.disabled || this.props.disabled; + const Component = this.props.variant === 'switch' ? Switch : Checkbox; + + return ( + this[this.props.name + '-' + value] = c} + checked={checked} + onChange={this.changeCheckbox} + value={value} + disabled={disabled} + /> + } + label={checkbox.label} + /> + ); + }); + + const maxLength = this.props.options.reduce((max, option) => + option.label.length > max ? option.label.length : max, 0); + + const columnClass = maxLength < 20 ? 'threeColumn' : maxLength < 30 ? 'twoColumn' : ''; + + return ( + + {controls} + + ); + }, + + render: function () { + + if (this.props.layout === 'elementOnly') { + return ( +
{this.renderElement()}
+ ); + } + + return ( + + {this.renderElement()} + + + ); + } +}); + + +export default withStyles(styles)(MuiCheckboxGroup); diff --git a/packages/vulcan-ui-material/lib/components/forms/base-controls/MuiFormControl.jsx b/packages/vulcan-ui-material/lib/components/forms/base-controls/MuiFormControl.jsx new file mode 100644 index 000000000..7b426355d --- /dev/null +++ b/packages/vulcan-ui-material/lib/components/forms/base-controls/MuiFormControl.jsx @@ -0,0 +1,91 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import createReactClass from 'create-react-class'; +import InputLabel from '@material-ui/core/InputLabel'; +import FormControl from '@material-ui/core/FormControl'; +import FormLabel from '@material-ui/core/FormLabel'; + + +//noinspection JSUnusedGlobalSymbols +const MuiFormControl = createReactClass({ + + propTypes: { + label: PropTypes.node, + children: PropTypes.node, + required: PropTypes.bool, + hasErrors: PropTypes.bool, + fakeLabel: PropTypes.bool, + hideLabel: PropTypes.bool, + layout: PropTypes.oneOf(['horizontal', 'vertical', 'elementOnly']), + htmlFor: PropTypes.string + }, + + getDefaultProps: function () { + return { + label: '', + required: false, + hasErrors: false, + fakeLabel: false, + hideLabel: false, + }; + }, + + renderRequiredSymbol: function () { + if (this.props.required === false) { + return null; + } + return ( + * + ); + }, + + renderLabel: function () { + if (this.props.layout === 'elementOnly' || this.props.hideLabel) { + return null; + } + + if (this.props.fakeLabel) { + return ( + + {this.props.label} + {this.renderRequiredSymbol()} + + ); + } + + const shrink = ['date', 'time', 'datetime'].includes(this.props.inputType) ? true : undefined; + + return ( + + {this.props.label} + {this.renderRequiredSymbol()} + + ); + }, + + render: function () { + const { layout, className, children, hasErrors } = this.props; + + if (layout === 'elementOnly') { + return {children}; + } + + return ( + + {this.renderLabel()} + {children} + + ); + } + +}); + + +export default MuiFormControl; diff --git a/packages/vulcan-ui-material/lib/components/forms/base-controls/MuiFormHelper.jsx b/packages/vulcan-ui-material/lib/components/forms/base-controls/MuiFormHelper.jsx new file mode 100644 index 000000000..58a5f4e5d --- /dev/null +++ b/packages/vulcan-ui-material/lib/components/forms/base-controls/MuiFormHelper.jsx @@ -0,0 +1,75 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { instantiateComponent, Components } from 'meteor/vulcan:core'; +import withStyles from '@material-ui/core/styles/withStyles'; +import FormHelperText from '@material-ui/core/FormHelperText'; +import { FormattedMessage } from 'meteor/vulcan:i18n'; +import classNames from 'classnames'; + + +export const styles = theme => ({ + + error: { + color: theme.palette.error.main, + }, + + formHelperText: { + display: 'flex', + '& :first-child': { + flexGrow: 1, + } + }, + +}); + + +const MuiFormHelper = (props) => { + const { + classes, + help, + errors, + hasErrors, + showCharsRemaining, + charsRemaining, + charsCount, + max, + } = props; + + if (!help && !hasErrors && !showCharsRemaining) { + return null; + } + + const errorMessage = hasErrors && + ; + + return ( + + + + { + hasErrors ? errorMessage : help + } + + + { + showCharsRemaining && + + + {charsCount} / {max} + + } + + + ); +}; + + +MuiFormHelper.propTypes = { + classes: PropTypes.object.isRequired, + value: PropTypes.any, + changeValue: PropTypes.func, + addonAfter: PropTypes.oneOfType([PropTypes.string, PropTypes.node, PropTypes.func]), +}; + + +export default withStyles(styles)(MuiFormHelper); diff --git a/packages/vulcan-ui-material/lib/components/forms/base-controls/MuiInput.jsx b/packages/vulcan-ui-material/lib/components/forms/base-controls/MuiInput.jsx new file mode 100644 index 000000000..e10e80da1 --- /dev/null +++ b/packages/vulcan-ui-material/lib/components/forms/base-controls/MuiInput.jsx @@ -0,0 +1,138 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import createReactClass from 'create-react-class'; +import withStyles from '@material-ui/core/styles/withStyles'; +import ComponentMixin from './mixins/component'; +import MuiFormControl from './MuiFormControl'; +import MuiFormHelper from './MuiFormHelper'; +import Input from '@material-ui/core/Input'; +import StartAdornment, { hideStartAdornment, fixUrl } from './StartAdornment'; +import EndAdornment from './EndAdornment'; + + +export const styles = theme => ({ + inputRoot: { + '& .clear-enabled': { opacity: 0 }, + '&:hover .clear-enabled': { opacity: 0.54 }, + }, + inputFocused: { + '& .clear-enabled': { opacity: 0.54 } + }, +}); + + +//noinspection JSUnusedGlobalSymbols +const MuiInput = createReactClass({ + element: null, + + mixins: [ComponentMixin], + + displayName: 'MuiInput', + + propTypes: { + type: PropTypes.oneOf([ + 'color', + 'date', + 'datetime', + 'datetime-local', + 'email', + 'hidden', + 'month', + 'number', + 'password', + 'range', + 'search', + 'tel', + 'text', + 'time', + 'url', + 'week' + ]), + errors: PropTypes.array, + placeholder: PropTypes.string, + formatValue: PropTypes.func, + scrubValue: PropTypes.func, + hideClear: PropTypes.bool, + }, + + getDefaultProps: function () { + return { + type: 'text', + }; + }, + + handleChange: function (event) { + let value = event.target.value; + if (this.props.scrubValue) { + value = this.props.scrubValue(value); + } + this.changeValue(value); + }, + + changeValue: function (value) { + this.props.onChange(value); + }, + + handleBlur: function (event) { + const { type, value } = this.props; + + if (type === 'url' && !!value && value !== fixUrl(value)) { + this.changeValue(fixUrl(value)); + } + }, + + render: function () { + const startAdornment = hideStartAdornment(this.props) ? null : + ; + const endAdornment = + ; + + let element = this.renderElement(startAdornment, endAdornment); + + if (this.props.layout === 'elementOnly' || this.props.type === 'hidden') { + return element; + } + + return ( + + {element} + + + ); + }, + + renderElement: function (startAdornment, endAdornment) { + const { classes, disabled, autoFocus, formatValue } = this.props; + const value = formatValue ? formatValue(this.props.value) : this.props.value; + const options = this.props.options || {}; + + return ( + (this.element = c)} + {...this.cleanProps(this.props)} + id={this.getId()} + value={value} + onChange={this.handleChange} + onBlur={this.handleBlur} + disabled={disabled} + rows={options.rows || this.props.rows} + autoFocus={options.autoFocus || autoFocus} + startAdornment={startAdornment} + endAdornment={endAdornment} + placeholder={this.props.placeholder} + classes={{ root: classes.inputRoot, focused: classes.inputFocused }} + /> + ); + }, + + +}); + + +export default withStyles(styles)(MuiInput); diff --git a/packages/vulcan-ui-material/lib/components/forms/base-controls/MuiPicker.jsx b/packages/vulcan-ui-material/lib/components/forms/base-controls/MuiPicker.jsx new file mode 100644 index 000000000..4b4576c27 --- /dev/null +++ b/packages/vulcan-ui-material/lib/components/forms/base-controls/MuiPicker.jsx @@ -0,0 +1,85 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import createReactClass from 'create-react-class'; +import withStyles from '@material-ui/core/styles/withStyles'; +import ComponentMixin from './mixins/component'; +import MuiFormControl from './MuiFormControl'; +import MuiFormHelper from './MuiFormHelper'; +import TextField from '@material-ui/core/TextField'; + +import moment from 'moment'; +//import 'moment-timezone'; + +const dateFormat = 'YYYY-MM-DD'; + +export const styles = theme => ({ + inputRoot: { + 'marginTop': '16px', + '& .clear-enabled': { opacity: 0 }, + '&:hover .clear-enabled': { opacity: 0.54 }, + }, + inputFocused: { + '& .clear-enabled': { opacity: 0.54 } + }, +}); + +//noinspection JSUnusedGlobalSymbols +const MuiPicker = createReactClass({ + + mixins: [ComponentMixin], + + displayName: 'MuiPicker', + + propTypes: { + type: PropTypes.oneOf([ + 'date', + 'datetime', + 'datetime-local', + ]), + errors: PropTypes.array, + placeholder: PropTypes.string, + formatValue: PropTypes.func, + hideClear: PropTypes.bool, + }, + + getDefaultProps: function () { + return { + type: 'date', + }; + }, + + handleChange: function (event) { + let value = event.target.value; + if (this.props.scrubValue) { + value = this.props.scrubValue(value); + } + this.props.onChange(value); + }, + + render: function () { + const { classes, disabled, autoFocus } = this.props; + const value = moment(this.props.value, dateFormat, true).isValid() ? this.props.value : moment(this.props.value).format(dateFormat); + + const options = this.props.options || {}; + + return ( + + (this.element = c)} + {...this.cleanProps(this.props)} + id={this.getId()} + value={value} + autoFocus={options.autoFocus || autoFocus} + onChange={this.handleChange} + disabled={disabled} + placeholder={this.props.placeholder} + classes={{ root: classes.inputRoot }} + /> + + + ); + } +}); + + +export default withStyles(styles)(MuiPicker); diff --git a/packages/vulcan-ui-material/lib/components/forms/base-controls/MuiRadioGroup.jsx b/packages/vulcan-ui-material/lib/components/forms/base-controls/MuiRadioGroup.jsx new file mode 100644 index 000000000..229b3d7c6 --- /dev/null +++ b/packages/vulcan-ui-material/lib/components/forms/base-controls/MuiRadioGroup.jsx @@ -0,0 +1,158 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import createReactClass from 'create-react-class'; +import ComponentMixin from './mixins/component'; +import withStyles from '@material-ui/core/styles/withStyles'; +import MuiFormControl from './MuiFormControl'; +import MuiFormHelper from './MuiFormHelper'; +import FormControlLabel from '@material-ui/core/FormControlLabel'; +import Radio from '@material-ui/core/Radio'; +import RadioGroup from '@material-ui/core/RadioGroup'; +import classNames from 'classnames'; + + +const styles = theme => ({ + group: { + marginTop: '8px', + }, + inline: { + flexDirection: 'row', + '& > label': { + marginRight: theme.spacing.unit * 5, + }, + }, + twoColumn: { + display: 'block', + [theme.breakpoints.down('md')]: { + '& > label': { + marginRight: theme.spacing.unit * 5, + }, + }, + [theme.breakpoints.up('md')]: { + '& > label': { + width: '49%', + }, + }, + }, + threeColumn: { + display: 'block', + [theme.breakpoints.down('xs')]: { + '& > label': { + marginRight: theme.spacing.unit * 5, + }, + }, + [theme.breakpoints.up('xs')]: { + '& > label': { + width: '49%', + }, + }, + [theme.breakpoints.up('md')]: { + '& > label': { + width: '32%', + }, + }, + }, + radio: { + width: '32px', + height: '32px', + marginLeft: '8px', + }, + line: { + marginBottom: '12px', + }, +}); + + +const MuiRadioGroup = createReactClass({ + + mixins: [ComponentMixin], + + propTypes: { + name: PropTypes.string.isRequired, + type: PropTypes.oneOf(['inline', 'stacked']), + options: PropTypes.array.isRequired + }, + + + + getDefaultProps: function () { + return { + type: 'stacked', + label: '', + help: null, + classes: PropTypes.object.isRequired, + }; + }, + + changeRadio: function (event) { + const value = event.target.value; + //this.setValue(value); + this.props.onChange(this.props.name, value); + }, + + validate: function () { + if (this.props.onBlur) { + this.props.onBlur(); + } + return true; + }, + + renderElement: function () { + const controls = this.props.options.map((radio, key) => { + let checked = (this.props.value === radio.value); + let disabled = radio.disabled || this.props.disabled; + + return ( + this['element-' + key] = c} + checked={checked} + disabled={disabled} + />} + className={this.props.classes.line} + label={radio.label} + /> + ); + }); + + const maxLength = this.props.options.reduce((max, option) => + option.label.length > max ? option.label.length : max, 0); + + let columnClass = maxLength < 18 ? 'threeColumn' : maxLength < 30 ? 'twoColumn' : ''; + if (this.props.type === 'inline') columnClass = 'inline'; + + return ( + + {controls} + + ); + }, + + render: function () { + + if (this.props.layout === 'elementOnly') { + return ( +
{this.renderElement()}
+ ); + } + + return ( + + {this.renderElement()} + + + ); + } +}); + + +export default withStyles(styles)(MuiRadioGroup); diff --git a/packages/vulcan-ui-material/lib/components/forms/base-controls/MuiSelect.jsx b/packages/vulcan-ui-material/lib/components/forms/base-controls/MuiSelect.jsx new file mode 100644 index 000000000..8bd7cdfcd --- /dev/null +++ b/packages/vulcan-ui-material/lib/components/forms/base-controls/MuiSelect.jsx @@ -0,0 +1,210 @@ +import withStyles from '@material-ui/core/styles/withStyles'; +import React from 'react'; +import createReactClass from 'create-react-class'; +import ComponentMixin from './mixins/component'; +import MuiFormControl from './MuiFormControl'; +import MuiFormHelper from './MuiFormHelper'; +import Select from '@material-ui/core/Select'; +import Input from '@material-ui/core/Input'; +import MenuItem from '@material-ui/core/MenuItem'; +import MenuList from '@material-ui/core/MenuList'; +import ListSubheader from '@material-ui/core/ListSubheader'; +import StartAdornment, { hideStartAdornment } from './StartAdornment'; +import EndAdornment from './EndAdornment'; +import _isArray from 'lodash/isArray'; + + +export const styles = theme => ({ + + inputRoot: { + '& .clear-enabled': { opacity: 0 }, + '&:hover .clear-enabled': { opacity: 0.54 }, + }, + + inputFocused: { + '& .clear-enabled': { opacity: 0.54 } + }, + + menuItem: { + paddingTop: 4, + paddingBottom: 4, + paddingLeft: 9, + fontFamily: theme.typography.fontFamily, + color: theme.palette.type === 'light' ? 'rgba(0, 0, 0, 0.87)' : theme.palette.common.white, + fontSize: theme.typography.pxToRem(16), + lineHeight: '1.1875em', + }, + + input: { + paddingLeft: 8, + }, + +}); + + +const MuiSelect = createReactClass({ + + element: null, + + mixins: [ComponentMixin], + + getInitialState: function () { + return { + isOpen: false, + }; + }, + + handleOpen: function () { + // this doesn't work + this.setState({ + isOpen: true, + }); + }, + + handleClose: function () { + // this doesn't work + this.setState({ + isOpen: false, + }); + }, + + handleChange: function (event) { + const target = event.target; + let value; + if (this.props.multiple) { + value = []; + for (let i = 0; i < target.length; i++) { + const option = target.options[i]; + if (option.selected) { + value.push(option.value); + } + } + } else { + value = target.value; + } + this.changeValue(value); + }, + + changeValue: function (value) { + this.props.onChange(value); + }, + + render: function () { + if (this.props.layout === 'elementOnly') { + return this.renderElement(); + } + + return ( + + {this.renderElement()} + + + ); + }, + + renderElement: function () { + const renderOption = (item, key) => { + //eslint-disable-next-line no-unused-vars + const { group, label, ...rest } = item; + return this.props.native + ? + + : + {label}; + }; + + const renderGroup = (label, key, nodes) => { + return this.props.native + ? + + {nodes} + + : + {label}} key={key}> + {nodes} + ; + }; + + const { options, classes } = this.props; + + let groups = options.filter(function (item) { + return item.group; + }).map(function (item) { + return item.group; + }); + // Get the unique items in group. + groups = [...new Set(groups)]; + + let optionNodes = []; + + if (groups.length === 0) { + optionNodes = options.map(function (item, index) { + return renderOption(item, index); + }); + } else { + // For items without groups. + const itemsWithoutGroup = options.filter(function (item) { + return !item.group; + }); + + itemsWithoutGroup.forEach(function (item, index) { + optionNodes.push(renderOption(item, 'no-group-' + index)); + }); + + groups.forEach(function (group, groupIndex) { + + const groupItems = options.filter(function (item) { + return item.group === group; + }); + + const groupOptionNodes = groupItems.map(function (item, index) { + return renderOption(item, groupIndex + '-' + index); + }); + + optionNodes.push(renderGroup(group, groupIndex, groupOptionNodes)); + }); + } + + let value = this.props.value; + if (!this.props.multiple && _isArray(value)) { + value = value.length ? value[0] : ''; + } + + const startAdornment = hideStartAdornment(this.props) ? null : + ; + const endAdornment = + ; + + return ( + } + > + {optionNodes} + + ); + } +}); + + +export default withStyles(styles)(MuiSelect); diff --git a/packages/vulcan-ui-material/lib/components/forms/base-controls/MuiSuggest.jsx b/packages/vulcan-ui-material/lib/components/forms/base-controls/MuiSuggest.jsx new file mode 100644 index 000000000..287491b60 --- /dev/null +++ b/packages/vulcan-ui-material/lib/components/forms/base-controls/MuiSuggest.jsx @@ -0,0 +1,437 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import createReactClass from 'create-react-class'; +import ComponentMixin from './mixins/component'; +import withStyles from '@material-ui/core/styles/withStyles'; +import Input from '@material-ui/core/Input'; +import Autosuggest from 'react-autosuggest'; +import Paper from '@material-ui/core/Paper'; +import MenuItem from '@material-ui/core/MenuItem'; +import match from 'autosuggest-highlight/match'; +import parse from 'autosuggest-highlight/parse'; +import { registerComponent } from 'meteor/vulcan:core'; +import StartAdornment, { hideStartAdornment } from './StartAdornment'; +import EndAdornment from './EndAdornment'; +import MuiFormControl from './MuiFormControl'; +import MuiFormHelper from './MuiFormHelper'; +import _isEqual from 'lodash/isEqual'; +import classNames from 'classnames'; +import IsolatedScroll from 'react-isolated-scroll'; + + +const maxSuggestions = 100; + + +/*{ + container: 'react-autosuggest__container', + containerOpen: 'react-autosuggest__container--open', + input: 'react-autosuggest__input', + inputOpen: 'react-autosuggest__input--open', + inputFocused: 'react-autosuggest__input--focused', + suggestionsContainer: 'react-autosuggest__suggestions-container', + suggestionsContainerOpen: 'react-autosuggest__suggestions-container--open', + suggestionsList: 'react-autosuggest__suggestions-list', + suggestion: 'react-autosuggest__suggestion', + suggestionFirst: 'react-autosuggest__suggestion--first', + suggestionHighlighted: 'react-autosuggest__suggestion--highlighted', + sectionContainer: 'react-autosuggest__section-container', + sectionContainerFirst: 'react-autosuggest__section-container--first', + sectionTitle: 'react-autosuggest__section-title' +}*/ +const styles = theme => ({ + container: { + flexGrow: 1, + position: 'relative', + }, + textField: { + width: '100%', + 'label + div > &': { + marginTop: theme.spacing.unit * 2, + }, + }, + input: { + outline: 0, + font: 'inherit', + color: 'currentColor', + width: '100%', + border: '0', + margin: '0', + padding: '7px 0', + display: 'block', + boxSizing: 'content-box', + background: 'none', + verticalAlign: 'middle', + '&::-webkit-search-decoration, &::-webkit-search-cancel-button, &::after, &:after': + { display: 'none' }, + '&::-webkit-search-results, &::-webkit-search-results-decoration': + { display: 'none' }, + }, + readOnly: { + cursor: 'pointer', + }, + suggestionsContainer: { + display: 'none', + position: 'absolute', + left: 0, + right: 0, + zIndex: theme.zIndex.modal, + marginBottom: theme.spacing.unit * 3, + maxHeight: 48 * 8, + }, + suggestionsContainerOpen: { + display: 'flex', + }, + scroller: { + flexGrow: 1, + overflowY: 'auto', + }, + suggestion: { + display: 'block', + }, + suggestionIcon: { + marginRight: theme.spacing.unit * 2, + }, + selected: { + backgroundColor: theme.palette.secondary.light, + }, + suggestionsList: { + margin: 0, + padding: 0, + listStyleType: 'none', + }, + inputRoot: { + '& .clear-enabled': { opacity: 0 }, + '&:hover .clear-enabled': { opacity: 0.54 }, + }, + inputFocused: { + '& .clear-enabled': { opacity: 0.54 } + }, +}); + + +const MuiSuggest = createReactClass({ + + inputElement: null, + + mixins: [ComponentMixin], + + propTypes: { + options: PropTypes.arrayOf(PropTypes.shape({ + label: PropTypes.string, + value: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), + formatted: PropTypes.node, + iconComponent: PropTypes.node, + onClick: PropTypes.func, + })), + classes: PropTypes.object.isRequired, + limitToList: PropTypes.bool, + disableText: PropTypes.bool, + showAllOptions: PropTypes.bool, + className: PropTypes.string, + autoComplete: PropTypes.string, + autoFocus: PropTypes.bool, + }, + + getDefaultProps: function () { + return { + autoComplete: 'off', + autoFocus: false, + }; + }, + + getOptionFormatted: function (option) { + return option.formatted || option.label || option.value || ''; + }, + + getOptionLabel: function (option) { + return option.label || option.value || ''; + }, + + getInitialState: function () { + if (this.props.refFunction) { + this.props.refFunction(this); + } + + const selectedOption = this.getSelectedOption(); + return { + inputValue: this.getOptionLabel(selectedOption), + selectedOption: selectedOption, + suggestions: [], + }; + }, + + componentWillReceiveProps: function (nextProps) { + if (nextProps.value !== this.state.value || + nextProps.options !== this.props.options) { + const selectedOption = this.getSelectedOption(nextProps); + this.setState({ + inputValue: this.getOptionLabel(selectedOption), + selectedOption: selectedOption, + }); + } + }, + + shouldComponentUpdate: function (nextProps, nextState) { + return !_isEqual(nextState, this.state) || + nextProps.help !== this.props.help || + nextProps.charsCount !== this.props.charsCount || + !_isEqual(nextProps.errors, this.props.errors) || + nextProps.options !== this.props.options; + }, + + getSelectedOption: function (props) { + props = props || this.props; + const selectedOption = props.options.find((opt) => opt.value === props.value); + return selectedOption || { label: '', value: null }; + }, + + handleFocus: function (event) { + if (!this.inputElement) return; + + this.inputElement.select(); + }, + + handleBlur: function (event, { highlightedSuggestion: suggestion }) { + if (suggestion) { + this.changeValue(suggestion); + } else if (this.props.limitToList) { + const selectedOption = this.getSelectedOption(); + this.setState({ + inputValue: this.getOptionLabel(selectedOption), + }); + } + }, + + suggestionSelected: function (event, { suggestion }) { + this.changeValue(suggestion); + }, + + changeValue: function (suggestion) { + if (!suggestion) { + suggestion = { label: '', value: null }; + } + if (suggestion.onClick) { + return; + } + this.setState({ + selectedOption: suggestion, + inputValue: this.getOptionLabel(suggestion), + }); + this.props.onChange(this.props.name, suggestion.value, this.getOptionLabel(suggestion)); + }, + + handleInputChange: function (event) { + const value = event.target.value; + this.setState({ + inputValue: value, + }); + }, + + handleSuggestionsFetchRequested: function ({ value, reason }) { + this.setState({ + suggestions: this.getSuggestions(value), + }); + }, + + handleSuggestionsClearRequested: function () { + this.setState({ + suggestions: [], + }); + }, + + shouldRenderSuggestions: function (value) { + return true; + }, + + render: function () { + const value = this.props.value; + + const startAdornment = hideStartAdornment(this.props) ? null : + ; + const endAdornment = + ; + + const element = this.renderElement(startAdornment, endAdornment); + + if (this.props.layout === 'elementOnly') { + return element; + } + + return ( + + {element} + + + ); + }, + + renderElement: function (startAdornment, endAdornment) { + const { classes, autoFocus, disableText, showAllOptions } = this.props; + + return ( + + ); + }, + + renderInputComponent: function (inputProps) { + const { classes, autoFocus, autoComplete, value, ref, startAdornment, endAdornment, disabled, ...rest } = inputProps; + + return ( + { ref(c); this.inputElement = c; }} + type="text" + startAdornment={startAdornment} + endAdornment={endAdornment} + disabled={disabled} + inputProps={{ + ...rest, + }} + /> + ); + }, + + renderSuggestion: function (suggestion, { query, isHighlighted }) { + const label = this.getOptionFormatted(suggestion); + const matches = match(label, query); + const parts = parse(label, matches); + const isSelected = suggestion.value === this.props.value; + const className = isSelected ? this.props.classes.selected : null; + + return ( + + { + suggestion.iconComponent && +
+ {suggestion.iconComponent} +
+ } +
+ {parts.map((part, index) => { + return part.highlight ? ( + + {part.text} + + ) : ( + + {part.text} + + ); + })} +
+
+ ); + }, + + renderSuggestionsContainer: function ({ containerProps, children }) { + const { classes } = this.props; + + return ( + + + {children} + + + ); + }, + + getSuggestionValue: function (suggestion) { + return suggestion.value; + }, + + getSuggestions: function (value) { + const inputValue = value.trim().toLowerCase(); + const inputLength = inputValue.length; + let count = 0; + const inputMatchesSelection = value === this.getOptionLabel(this.state.selectedOption); + + return (this.props.disableText || this.props.showAllOptions) && inputMatchesSelection ? + + this.props.options.filter(suggestion => { + return true; + }) + + : + + inputLength === 0 + + ? + + this.props.options.filter(suggestion => { + count += 1; + return count <= maxSuggestions; + }) + + : + + this.props.options.filter(suggestion => { + const label = this.getOptionLabel(suggestion); + const keep = + count < maxSuggestions && label.toLowerCase().slice(0, inputLength) === + inputValue; + + if (keep) { + count += 1; + } + + return keep; + }); + }, + +}); + + +export default withStyles(styles)(MuiSuggest); +registerComponent('MuiSuggest', MuiSuggest, [withStyles, styles]); diff --git a/packages/vulcan-ui-material/lib/components/forms/base-controls/MuiSwitch.jsx b/packages/vulcan-ui-material/lib/components/forms/base-controls/MuiSwitch.jsx new file mode 100644 index 000000000..1d4c2a5f2 --- /dev/null +++ b/packages/vulcan-ui-material/lib/components/forms/base-controls/MuiSwitch.jsx @@ -0,0 +1,70 @@ +import React from 'react'; +import createReactClass from 'create-react-class'; +import ComponentMixin from './mixins/component'; +import FormControlLabel from '@material-ui/core/FormControlLabel'; +import Switch from '@material-ui/core/Switch'; +import MuiFormControl from './MuiFormControl'; +import MuiFormHelper from './MuiFormHelper'; + + +const MuiSwitch = createReactClass({ + + mixins: [ComponentMixin], + + getDefaultProps: function () { + return { + label: '', + rowLabel: '', + value: false + }; + }, + + changeValue: function (event) { + const target = event.target; + const value = target.checked; + + this.props.onChange(value); + + setTimeout(() => {document.activeElement.blur();}); + }, + + render: function () { + + const element = this.renderElement(); + + if (this.props.layout === 'elementOnly') { + return element; + } + + return ( + + {element} + + + ); + }, + + renderElement: function () { + return ( + this.element = c} + {...this.cleanSwitchProps(this.cleanProps(this.props))} + id={this.getId()} + checked={this.props.value === true} + onChange={this.changeValue} + disabled={this.props.disabled} + /> + } + label={this.props.label} + /> + ); + }, + +}); + + +export default MuiSwitch; diff --git a/packages/vulcan-ui-material/lib/components/forms/base-controls/StartAdornment.jsx b/packages/vulcan-ui-material/lib/components/forms/base-controls/StartAdornment.jsx new file mode 100644 index 000000000..58d6a7434 --- /dev/null +++ b/packages/vulcan-ui-material/lib/components/forms/base-controls/StartAdornment.jsx @@ -0,0 +1,54 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import { instantiateComponent } from 'meteor/vulcan:core'; +import withStyles from '@material-ui/core/styles/withStyles'; +import InputAdornment from '@material-ui/core/InputAdornment'; +import IconButton from '@material-ui/core/IconButton'; +import OpenInNewIcon from 'mdi-material-ui/OpenInNew'; +import { styles } from './EndAdornment'; + + +export const hideStartAdornment = (props) => { + return !props.addonBefore && !props.isUrl; +}; + + +export const fixUrl = (url) => { + return url.indexOf('http://') === -1 && url.indexOf('https://') ? 'http://' + url : url; +}; + + +const StartAdornment = (props) => { + const { classes, value, type, addonBefore } = props; + + if (hideStartAdornment(props)) return null; + + const urlButton = type === 'url' && + + + ; + + + return ( + + {instantiateComponent(addonBefore)} + {urlButton} + + ); +}; + + +StartAdornment.propTypes = { + classes: PropTypes.object.isRequired, + value: PropTypes.any, + type: PropTypes.string, + addonBefore: PropTypes.oneOfType([PropTypes.string, PropTypes.node, PropTypes.func]), +}; + + +export default withStyles(styles)(StartAdornment); diff --git a/packages/vulcan-ui-material/lib/components/forms/base-controls/mixins/component.jsx b/packages/vulcan-ui-material/lib/components/forms/base-controls/mixins/component.jsx new file mode 100644 index 000000000..fa74a6414 --- /dev/null +++ b/packages/vulcan-ui-material/lib/components/forms/base-controls/mixins/component.jsx @@ -0,0 +1,140 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { FormattedMessage } from 'meteor/vulcan:i18n'; +import _omit from 'lodash/omit'; + + +export default { + + propTypes: { + label: PropTypes.oneOfType([PropTypes.string, PropTypes.node]), + hideLabel: PropTypes.bool, + layout: PropTypes.string, + required: PropTypes.bool, + errors: PropTypes.arrayOf(PropTypes.object), + }, + + getFormControlProperties: function () { + return { + label: this.props.label, + hideLabel: this.props.hideLabel, + layout: this.props.layout, + required: this.props.required, + hasErrors: this.hasErrors(), + className: this.props.className, + inputType: this.props.inputType, + }; + }, + + getFormHelperProperties: function () { + return { + help: this.props.help, + errors: this.props.errors, + hasErrors: this.hasErrors(), + showCharsRemaining: this.props.showCharsRemaining, + charsRemaining: this.props.charsRemaining, + charsCount: this.props.charsCount, + max: this.props.max, + }; + }, + + hashString: function (string) { + let hash = 0; + for (let i = 0; i < string.length; i++) { + hash = (((hash << 5) - hash) + string.charCodeAt(i)) & 0xFFFFFFFF; + } + return hash; + }, + + /** + * The ID is used as an attribute on the form control, and is used to allow + * associating the label element with the form control. + * + * If we don't explicitly pass an `id` prop, we generate one based on the + * `name`, `label` and `itemIndex` (for nested forms) properties. + */ + getId: function () { + if (this.props.id) { + return this.props.id; + } + const label = (typeof this.props.label === 'undefined' ? '' : this.props.label); + const itemIndex = (typeof this.props.itemIndex=== 'undefined' ? '' : this.props.itemIndex); + return [ + 'frc', + this.props.name.split('[').join('_').replace(']', ''), + itemIndex, + this.hashString(JSON.stringify(label)) + ].join('-'); + }, + + hasErrors: function () { + return !!(this.props.errors && this.props.errors.length); + }, + + cleanProps: function (props) { + const removedFields = [ + 'beforeComponent', + 'afterComponent', + 'addonAfter', + 'addonBefore', + 'help', + 'label', + 'hideLabel', + 'options', + 'layout', + 'rowLabel', + 'validatePristine', + 'validateOnSubmit', + 'inputClassName', + 'optional', + 'throwError', + 'currentValues', + 'addToDeletedValues', + 'deletedValues', + 'clearFieldErrors', + 'formType', + 'inputType', + 'showCharsRemaining', + 'charsCount', + 'charsRemaining', + 'handleChange', + 'document', + 'updateCurrentValues', + 'classes', + 'errors', + 'description', + 'clearField', + 'regEx', + 'allowedValues', + 'mustComplete', + 'renderComponent', + 'formInput', + 'className', + 'formatValue', + 'scrubValue', + 'custom', + 'hideClear', + 'inputProperties', + 'currentUser', + 'nestedSchema', + 'parentFieldName', + 'itemIndex', + 'formComponents', + 'autoValue', + 'minCount', + 'maxCount' + ]; + + return _omit(props, removedFields); + }, + + cleanSwitchProps: function (props) { + const removedFields = [ + 'value', + 'error', + ]; + + return _omit(props, removedFields); + }, + +}; diff --git a/packages/vulcan-ui-material/lib/components/forms/controls/Checkbox.jsx b/packages/vulcan-ui-material/lib/components/forms/controls/Checkbox.jsx new file mode 100644 index 000000000..4f016d1ad --- /dev/null +++ b/packages/vulcan-ui-material/lib/components/forms/controls/Checkbox.jsx @@ -0,0 +1,10 @@ +import React from 'react'; +import MuiSwitch from '../base-controls/MuiSwitch'; +import { registerComponent } from 'meteor/vulcan:core'; + + +const CheckboxComponent = ({ refFunction, ...properties }) => + ; + + +registerComponent('FormComponentCheckbox', CheckboxComponent); diff --git a/packages/vulcan-ui-material/lib/components/forms/controls/CheckboxGroup.jsx b/packages/vulcan-ui-material/lib/components/forms/controls/CheckboxGroup.jsx new file mode 100644 index 000000000..226693354 --- /dev/null +++ b/packages/vulcan-ui-material/lib/components/forms/controls/CheckboxGroup.jsx @@ -0,0 +1,10 @@ +import React from 'react'; +import MuiCheckboxGroup from '../base-controls/MuiCheckboxGroup'; +import { registerComponent } from 'meteor/vulcan:core'; + + +const CheckboxGroupComponent = ({ refFunction, ...properties }) => + ; + + +registerComponent('FormComponentCheckboxGroup', CheckboxGroupComponent); diff --git a/packages/vulcan-ui-material/lib/components/forms/controls/CountrySelect.jsx b/packages/vulcan-ui-material/lib/components/forms/controls/CountrySelect.jsx new file mode 100644 index 000000000..1c4cf14be --- /dev/null +++ b/packages/vulcan-ui-material/lib/components/forms/controls/CountrySelect.jsx @@ -0,0 +1,11 @@ +import React from 'react'; +import MuiSuggest from '../base-controls/MuiSuggest'; +import { registerComponent } from 'meteor/vulcan:core'; +import { countries } from './countries'; + + +const CountrySelect = ({ refFunction, ...properties }) => + ; + + +registerComponent('CountrySelect', CountrySelect); diff --git a/packages/vulcan-ui-material/lib/components/forms/controls/Date.jsx b/packages/vulcan-ui-material/lib/components/forms/controls/Date.jsx new file mode 100644 index 000000000..cca995266 --- /dev/null +++ b/packages/vulcan-ui-material/lib/components/forms/controls/Date.jsx @@ -0,0 +1,35 @@ +import React from 'react'; +import MuiPicker from '../base-controls/MuiPicker'; +import { registerComponent } from 'meteor/vulcan:core'; +import withStyles from '@material-ui/core/styles/withStyles'; + + +export const styles = theme => ({ + + '@global': { + 'input[type=date]::-ms-clear, input[type=date]::-ms-reveal': { + display: 'none', + width: 0, + height: 0, + }, + 'input[type=date]::-webkit-search-cancel-button': { + display: 'none', + '-webkit-appearance': 'none', + }, + 'input[type="date"]::-webkit-clear-button': { + display: 'none', + '-webkit-appearance': 'none', + }, + + 'input[type="date"]::-webkit-inner-spin-button,input[type="date"]::-webkit-outer-spin-button': { + '-webkit-appearance': 'none', + margin: 0, + }, + }, + +}); + +const DateComponent = ({ refFunction, classes, ...properties }) => + ; + +registerComponent('FormComponentDate', DateComponent, [withStyles, styles]); diff --git a/packages/vulcan-ui-material/lib/components/forms/controls/DateRdt.jsx b/packages/vulcan-ui-material/lib/components/forms/controls/DateRdt.jsx new file mode 100644 index 000000000..f012d6717 --- /dev/null +++ b/packages/vulcan-ui-material/lib/components/forms/controls/DateRdt.jsx @@ -0,0 +1,60 @@ +// Deprecated react-datetime version + +import React, { PureComponent } from 'react'; +import PropTypes from 'prop-types'; +import DateTimePicker from 'react-datetime'; +import { registerComponent } from 'meteor/vulcan:core'; + +class DateComponent 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.updateDate(this.props.value); + // } + // } + + updateDate(date) { + this.context.updateCurrentValues({[this.props.path]: date}); + } + + render() { + + const date = this.props.value ? (typeof this.props.value === 'string' ? new Date(this.props.value) : this.props.value) : null; + + return ( +
+ +
+ this.updateDate(newDate)} + inputProps={{name: this.props.name}} + /> +
+
+ ); + } +} + +DateComponent.propTypes = { + control: PropTypes.any, + datatype: PropTypes.any, + group: PropTypes.any, + label: PropTypes.string, + name: PropTypes.string, + value: PropTypes.any, +}; + +DateComponent.contextTypes = { + updateCurrentValues: PropTypes.func, +}; + +export default DateComponent; diff --git a/packages/vulcan-ui-material/lib/components/forms/controls/DateTime.jsx b/packages/vulcan-ui-material/lib/components/forms/controls/DateTime.jsx new file mode 100644 index 000000000..5fc870a0a --- /dev/null +++ b/packages/vulcan-ui-material/lib/components/forms/controls/DateTime.jsx @@ -0,0 +1,37 @@ +import React from 'react'; +import MuiInput from '../base-controls/MuiInput'; +import { registerComponent } from 'meteor/vulcan:core'; +import withStyles from '@material-ui/core/styles/withStyles'; + + +export const styles = theme => ({ + + '@global': { + 'input[type=datetime]::-ms-clear, input[type=datetime]::-ms-reveal': { + display: 'none', + width: 0, + height: 0, + }, + 'input[type=datetime]::-webkit-search-cancel-button': { + display: 'none', + '-webkit-appearance': 'none', + }, + 'input[type="datetime"]::-webkit-clear-button': { + display: 'none', + '-webkit-appearance': 'none', + }, + + 'input[type="datetime"]::-webkit-inner-spin-button,input[type="datetime"]::-webkit-outer-spin-button': { + '-webkit-appearance': 'none', + margin: 0, + }, + }, + +}); + + +const DateTimeComponent = ({ refFunction, classes, ...properties }) => + ; + + +registerComponent('FormComponentDateTime', DateTimeComponent, [withStyles, styles]); diff --git a/packages/vulcan-ui-material/lib/components/forms/controls/DateTimeRdt.jsx b/packages/vulcan-ui-material/lib/components/forms/controls/DateTimeRdt.jsx new file mode 100644 index 000000000..5ce0c84d0 --- /dev/null +++ b/packages/vulcan-ui-material/lib/components/forms/controls/DateTimeRdt.jsx @@ -0,0 +1,61 @@ +// Deprecated react-datetime version + +import React, { PureComponent } from 'react'; +import PropTypes from 'prop-types'; +import DateTimePicker from 'react-datetime'; +import { registerComponent } from 'meteor/vulcan:core'; + + +class DateTimeRdt 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.updateDate(this.props.value); + } + } + + updateDate(date) { + this.context.updateCurrentValues({[this.props.name]: date}); + } + + render() { + + const date = this.props.value ? (typeof this.props.value === 'string' ? new Date(this.props.value) : this.props.value) : null; + + return ( +
+ +
+ this.updateDate(newDate._d)} + format={'x'} + inputProps={{name: this.props.name}} + /> +
+
+ ); + } +} + +DateTimeRdt.propTypes = { + control: PropTypes.any, + datatype: PropTypes.any, + group: PropTypes.any, + label: PropTypes.string, + name: PropTypes.string, + value: PropTypes.any, +}; + +DateTimeRdt.contextTypes = { + updateCurrentValues: PropTypes.func, +}; + +export default DateTimeRdt; diff --git a/packages/vulcan-ui-material/lib/components/forms/controls/Default.jsx b/packages/vulcan-ui-material/lib/components/forms/controls/Default.jsx new file mode 100644 index 000000000..e35f64abd --- /dev/null +++ b/packages/vulcan-ui-material/lib/components/forms/controls/Default.jsx @@ -0,0 +1,10 @@ +import React from 'react'; +import MuiInput from '../base-controls/MuiInput'; +import { registerComponent } from 'meteor/vulcan:core'; + + +const Default = ({ refFunction, ...properties }) => + ; + + +registerComponent('FormComponentDefault', Default); diff --git a/packages/vulcan-ui-material/lib/components/forms/controls/Email.jsx b/packages/vulcan-ui-material/lib/components/forms/controls/Email.jsx new file mode 100644 index 000000000..f4688361f --- /dev/null +++ b/packages/vulcan-ui-material/lib/components/forms/controls/Email.jsx @@ -0,0 +1,10 @@ +import React from 'react'; +import MuiInput from '../base-controls/MuiInput'; +import { registerComponent } from 'meteor/vulcan:core'; + + +const EmailComponent = ({ refFunction, ...properties }) => + ; + + +registerComponent('FormComponentEmail', EmailComponent); diff --git a/packages/vulcan-ui-material/lib/components/forms/controls/Number.jsx b/packages/vulcan-ui-material/lib/components/forms/controls/Number.jsx new file mode 100644 index 000000000..03136d0c3 --- /dev/null +++ b/packages/vulcan-ui-material/lib/components/forms/controls/Number.jsx @@ -0,0 +1,10 @@ +import React from 'react'; +import MuiInput from '../base-controls/MuiInput'; +import { registerComponent } from 'meteor/vulcan:core'; + + +const NumberComponent = ({ refFunction, ...properties }) => + ; + + +registerComponent('FormComponentNumber', NumberComponent); diff --git a/packages/vulcan-ui-material/lib/components/forms/controls/PostalCode.jsx b/packages/vulcan-ui-material/lib/components/forms/controls/PostalCode.jsx new file mode 100644 index 000000000..810433e6c --- /dev/null +++ b/packages/vulcan-ui-material/lib/components/forms/controls/PostalCode.jsx @@ -0,0 +1,15 @@ +import React from 'react'; +import MuiInput from '../base-controls/MuiInput'; +import { registerComponent } from 'meteor/vulcan:core'; +import { getCountryInfo } from './RegionSelect'; + + +const PostalCode = ({ classes, refFunction, ...properties }) => { + const currentCountryInfo = getCountryInfo(properties); + const postalLabel = currentCountryInfo ? currentCountryInfo.postalLabel : 'Postal code'; + + return ; +}; + + +registerComponent('PostalCode', PostalCode); diff --git a/packages/vulcan-ui-material/lib/components/forms/controls/RadioGroup.jsx b/packages/vulcan-ui-material/lib/components/forms/controls/RadioGroup.jsx new file mode 100644 index 000000000..63d0052e9 --- /dev/null +++ b/packages/vulcan-ui-material/lib/components/forms/controls/RadioGroup.jsx @@ -0,0 +1,10 @@ +import React from 'react'; +import MuiRadioGroup from '../base-controls/MuiRadioGroup'; +import { registerComponent } from 'meteor/vulcan:core'; + + +const RadioGroupComponent = ({ refFunction, ...properties }) => + ; + + +registerComponent('FormComponentRadioGroup', RadioGroupComponent); diff --git a/packages/vulcan-ui-material/lib/components/forms/controls/RegionSelect.jsx b/packages/vulcan-ui-material/lib/components/forms/controls/RegionSelect.jsx new file mode 100644 index 000000000..a790bb351 --- /dev/null +++ b/packages/vulcan-ui-material/lib/components/forms/controls/RegionSelect.jsx @@ -0,0 +1,31 @@ +import React from 'react'; +import MuiSuggest from '../base-controls/MuiSuggest'; +import MuiInput from '../base-controls/MuiInput'; +import { registerComponent } from 'meteor/vulcan:core'; +import { countryInfo } from './countries'; +import _get from 'lodash/get'; + + +export const getCountryInfo = function (formComponentProps) { + const addressPath = formComponentProps.path; + const countryParts = addressPath.split('.'); + countryParts[countryParts.length-1] = 'country'; + const country = _get(formComponentProps.document, countryParts); + return country && countryInfo[country]; +}; + + +const RegionSelect = ({ classes, refFunction, ...properties }) => { + const currentCountryInfo = getCountryInfo(properties); + const options = currentCountryInfo ? currentCountryInfo.regions : null; + const regionLabel = currentCountryInfo ? currentCountryInfo.regionLabel : 'Region'; + + if (options) { + return ; + } else { + return ; + } +}; + + +registerComponent('RegionSelect', RegionSelect); diff --git a/packages/vulcan-ui-material/lib/components/forms/controls/Select.jsx b/packages/vulcan-ui-material/lib/components/forms/controls/Select.jsx new file mode 100644 index 000000000..7ff92e343 --- /dev/null +++ b/packages/vulcan-ui-material/lib/components/forms/controls/Select.jsx @@ -0,0 +1,14 @@ +import React from 'react'; +import MuiSelect from '../base-controls/MuiSelect'; +import { registerComponent } from 'meteor/vulcan:core'; + + +const SelectComponent = ({ refFunction, ...properties }) => { + const noneOption = { label: '', value: '' }; + properties.options = [noneOption, ...properties.options]; + + return ; +}; + + +registerComponent('FormComponentSelect', SelectComponent); diff --git a/packages/vulcan-ui-material/lib/components/forms/controls/SelectMultiple.jsx b/packages/vulcan-ui-material/lib/components/forms/controls/SelectMultiple.jsx new file mode 100644 index 000000000..5b3f552ce --- /dev/null +++ b/packages/vulcan-ui-material/lib/components/forms/controls/SelectMultiple.jsx @@ -0,0 +1,13 @@ +import React from 'react'; +import MuiSelect from '../base-controls/MuiSelect'; +import { registerComponent } from 'meteor/vulcan:core'; + + +const SelectMultiple = ({ refFunction, ...properties }) => { + properties.multiple = true; + + return ; +}; + + +registerComponent('FormComponentSelectMultiple', SelectMultiple); diff --git a/packages/vulcan-ui-material/lib/components/forms/controls/Textarea.jsx b/packages/vulcan-ui-material/lib/components/forms/controls/Textarea.jsx new file mode 100644 index 000000000..988960741 --- /dev/null +++ b/packages/vulcan-ui-material/lib/components/forms/controls/Textarea.jsx @@ -0,0 +1,15 @@ +import React from 'react'; +import MuiInput from '../base-controls/MuiInput'; +import { registerComponent } from 'meteor/vulcan:core'; + + +const TextareaComponent = ({ refFunction, ...properties }) => + ; + + +registerComponent('FormComponentTextarea', TextareaComponent); diff --git a/packages/vulcan-ui-material/lib/components/forms/controls/Time.jsx b/packages/vulcan-ui-material/lib/components/forms/controls/Time.jsx new file mode 100644 index 000000000..e52bb3cbb --- /dev/null +++ b/packages/vulcan-ui-material/lib/components/forms/controls/Time.jsx @@ -0,0 +1,37 @@ +import React from 'react'; +import MuiInput from '../base-controls/MuiInput'; +import { registerComponent } from 'meteor/vulcan:core'; +import withStyles from '@material-ui/core/styles/withStyles'; + + +export const styles = theme => ({ + + '@global': { + 'input[type=time]::-ms-clear, input[type=time]::-ms-reveal': { + display: 'none', + width: 0, + height: 0, + }, + 'input[type=time]::-webkit-search-cancel-button': { + display: 'none', + '-webkit-appearance': 'none', + }, + 'input[type="time"]::-webkit-clear-button': { + display: 'none', + '-webkit-appearance': 'none', + }, + + 'input[type="time"]::-webkit-inner-spin-button,input[type="time"]::-webkit-outer-spin-button': { + '-webkit-appearance': 'none', + margin: 0, + }, + }, + +}); + + +const TimeComponent = ({ refFunction, classes, ...properties }) => + ; + + +registerComponent('FormComponentTime', TimeComponent, [withStyles, styles]); diff --git a/packages/vulcan-ui-material/lib/components/forms/controls/TimeRdt.jsx b/packages/vulcan-ui-material/lib/components/forms/controls/TimeRdt.jsx new file mode 100644 index 000000000..cf8334241 --- /dev/null +++ b/packages/vulcan-ui-material/lib/components/forms/controls/TimeRdt.jsx @@ -0,0 +1,73 @@ +// Deprecated react-datetime version + +import React, { PureComponent } from 'react'; +import PropTypes from 'prop-types'; +import DateTimePicker from 'react-datetime'; +import { registerComponent } from 'meteor/vulcan:core'; + +class TimeRdt 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.path]: this.props.value}); + // } + // } + + updateDate(mDate) { + // if this is a properly formatted moment date, update time + if (typeof mDate === 'object') { + this.context.updateCurrentValues({[this.props.path]: 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}} + /> +
+
+ ); + } +} + +TimeRdt.propTypes = { + control: PropTypes.any, + datatype: PropTypes.any, + group: PropTypes.any, + label: PropTypes.string, + name: PropTypes.string, + value: PropTypes.any, +}; + +TimeRdt.contextTypes = { + updateCurrentValues: PropTypes.func, +}; + +export default TimeRdt; diff --git a/packages/vulcan-ui-material/lib/components/forms/controls/Url.jsx b/packages/vulcan-ui-material/lib/components/forms/controls/Url.jsx new file mode 100644 index 000000000..d5d9ebf13 --- /dev/null +++ b/packages/vulcan-ui-material/lib/components/forms/controls/Url.jsx @@ -0,0 +1,10 @@ +import React from 'react'; +import MuiInput from '../base-controls/MuiInput'; +import { registerComponent } from 'meteor/vulcan:core'; + + +const UrlComponent = ({ refFunction, ...properties }) => + ; + + +registerComponent('FormComponentUrl', UrlComponent); diff --git a/packages/vulcan-ui-material/lib/components/forms/controls/countries.js b/packages/vulcan-ui-material/lib/components/forms/controls/countries.js new file mode 100644 index 000000000..a2d84b1e7 --- /dev/null +++ b/packages/vulcan-ui-material/lib/components/forms/controls/countries.js @@ -0,0 +1,402 @@ +export const countries = [ + { value: 'AF', label: 'Afghanistan' }, + { value: 'AX', label: 'Åland Islands' }, + { value: 'AL', label: 'Albania' }, + { value: 'DZ', label: 'Algeria' }, + { value: 'AS', label: 'American Samoa' }, + { value: 'AD', label: 'Andorra' }, + { value: 'AO', label: 'Angola' }, + { value: 'AI', label: 'Anguilla' }, + { value: 'AQ', label: 'Antarctica' }, + { value: 'AG', label: 'Antigua and Barbuda' }, + { value: 'AR', label: 'Argentina' }, + { value: 'AM', label: 'Armenia' }, + { value: 'AW', label: 'Aruba' }, + { value: 'AU', label: 'Australia' }, + { value: 'AT', label: 'Austria' }, + { value: 'AZ', label: 'Azerbaijan' }, + { value: 'BS', label: 'Bahamas' }, + { value: 'BH', label: 'Bahrain' }, + { value: 'BD', label: 'Bangladesh' }, + { value: 'BB', label: 'Barbados' }, + { value: 'BY', label: 'Belarus' }, + { value: 'BE', label: 'Belgium' }, + { value: 'BZ', label: 'Belize' }, + { value: 'BJ', label: 'Benin' }, + { value: 'BM', label: 'Bermuda' }, + { value: 'BT', label: 'Bhutan' }, + { value: 'BO', label: 'Bolivia' }, + { value: 'BQ', label: 'Bonaire, Sint Eustatius, Saba' }, + { value: 'BA', label: 'Bosnia and Herzegovina' }, + { value: 'BW', label: 'Botswana' }, + { value: 'BV', label: 'Bouvet Island' }, + { value: 'BR', label: 'Brazil' }, + { value: 'IO', label: 'British Indian Ocean Territory' }, + { value: 'BN', label: 'Brunei Darussalam' }, + { value: 'BG', label: 'Bulgaria' }, + { value: 'BF', label: 'Burkina Faso' }, + { value: 'BI', label: 'Burundi' }, + { value: 'CV', label: 'Cabo Verde' }, + { value: 'KH', label: 'Cambodia' }, + { value: 'CM', label: 'Cameroon' }, + { value: 'CA', label: 'Canada' }, + { value: 'KY', label: 'Cayman Islands' }, + { value: 'CF', label: 'Central African Republic' }, + { value: 'TD', label: 'Chad' }, + { value: 'CL', label: 'Chile' }, + { value: 'CN', label: 'China' }, + { value: 'CX', label: 'Christmas Island' }, + { value: 'CC', label: 'Cocos (Keeling) Islands' }, + { value: 'CO', label: 'Colombia' }, + { value: 'KM', label: 'Comoros' }, + { value: 'CG', label: 'Congo' }, + { value: 'CD', label: 'Congo (Democratic Republic of the)' }, + { value: 'CK', label: 'Cook Islands' }, + { value: 'CR', label: 'Costa Rica' }, + { value: 'CI', label: 'Côte d’Ivoire' }, + { value: 'HR', label: 'Croatia' }, + { value: 'CU', label: 'Cuba' }, + { value: 'CW', label: 'Curaçao' }, + { value: 'CY', label: 'Cyprus' }, + { value: 'CZ', label: 'Czechia' }, + { value: 'DK', label: 'Denmark' }, + { value: 'DJ', label: 'Djibouti' }, + { value: 'DM', label: 'Dominica' }, + { value: 'DO', label: 'Dominican Republic' }, + { value: 'EC', label: 'Ecuador' }, + { value: 'EG', label: 'Egypt' }, + { value: 'SV', label: 'El Salvador' }, + { value: 'GQ', label: 'Equatorial Guinea' }, + { value: 'ER', label: 'Eritrea' }, + { value: 'EE', label: 'Estonia' }, + { value: 'ET', label: 'Ethiopia' }, + { value: 'FK', label: 'Falkland Islands' }, + { value: 'FO', label: 'Faroe Islands' }, + { value: 'FJ', label: 'Fiji' }, + { value: 'FI', label: 'Finland' }, + { value: 'FR', label: 'France' }, + { value: 'GF', label: 'French Guiana' }, + { value: 'PF', label: 'French Polynesia' }, + { value: 'TF', label: 'French Southern Territories' }, + { value: 'GA', label: 'Gabon' }, + { value: 'GM', label: 'Gambia' }, + { value: 'GE', label: 'Georgia' }, + { value: 'DE', label: 'Germany' }, + { value: 'GH', label: 'Ghana' }, + { value: 'GI', label: 'Gibraltar' }, + { value: 'GR', label: 'Greece' }, + { value: 'GL', label: 'Greenland' }, + { value: 'GD', label: 'Grenada' }, + { value: 'GP', label: 'Guadeloupe' }, + { value: 'GU', label: 'Guam' }, + { value: 'GT', label: 'Guatemala' }, + { value: 'GG', label: 'Guernsey' }, + { value: 'GN', label: 'Guinea' }, + { value: 'GW', label: 'Guinea-Bissau' }, + { value: 'GY', label: 'Guyana' }, + { value: 'HT', label: 'Haiti' }, + { value: 'HM', label: 'Heard Island, Mcdonald Islands' }, + { value: 'VA', label: 'Vatican City State' }, + { value: 'HN', label: 'Honduras' }, + { value: 'HK', label: 'Hong Kong' }, + { value: 'HU', label: 'Hungary' }, + { value: 'IS', label: 'Iceland' }, + { value: 'IN', label: 'India' }, + { value: 'ID', label: 'Indonesia' }, + { value: 'IR', label: 'Iran' }, + { value: 'IQ', label: 'Iraq' }, + { value: 'IE', label: 'Ireland' }, + { value: 'IM', label: 'Isle of Man' }, + { value: 'IL', label: 'Israel' }, + { value: 'IT', label: 'Italy' }, + { value: 'JM', label: 'Jamaica' }, + { value: 'JP', label: 'Japan' }, + { value: 'JE', label: 'Jersey' }, + { value: 'JO', label: 'Jordan' }, + { value: 'KZ', label: 'Kazakhstan' }, + { value: 'KE', label: 'Kenya' }, + { value: 'KI', label: 'Kiribati' }, + { value: 'KW', label: 'Kuwait' }, + { value: 'KG', label: 'Kyrgyzstan' }, + { value: 'LA', label: 'Lao' }, + { value: 'LV', label: 'Latvia' }, + { value: 'LB', label: 'Lebanon' }, + { value: 'LS', label: 'Lesotho' }, + { value: 'LR', label: 'Liberia' }, + { value: 'LY', label: 'Libya' }, + { value: 'LI', label: 'Liechtenstein' }, + { value: 'LT', label: 'Lithuania' }, + { value: 'LU', label: 'Luxembourg' }, + { value: 'MO', label: 'Macao' }, + { value: 'MK', label: 'Macedonia' }, + { value: 'MG', label: 'Madagascar' }, + { value: 'MW', label: 'Malawi' }, + { value: 'MY', label: 'Malaysia' }, + { value: 'MV', label: 'Maldives' }, + { value: 'ML', label: 'Mali' }, + { value: 'MT', label: 'Malta' }, + { value: 'MH', label: 'Marshall Islands' }, + { value: 'MQ', label: 'Martinique' }, + { value: 'MR', label: 'Mauritania' }, + { value: 'MU', label: 'Mauritius' }, + { value: 'YT', label: 'Mayotte' }, + { value: 'MX', label: 'Mexico' }, + { value: 'FM', label: 'Micronesia' }, + { value: 'MD', label: 'Moldova' }, + { value: 'MC', label: 'Monaco' }, + { value: 'MN', label: 'Mongolia' }, + { value: 'ME', label: 'Montenegro' }, + { value: 'MS', label: 'Montserrat' }, + { value: 'MA', label: 'Morocco' }, + { value: 'MZ', label: 'Mozambique' }, + { value: 'MM', label: 'Myanmar' }, + { value: 'NA', label: 'Namibia' }, + { value: 'NR', label: 'Nauru' }, + { value: 'NP', label: 'Nepal' }, + { value: 'NL', label: 'Netherlands' }, + { value: 'NC', label: 'New Caledonia' }, + { value: 'NZ', label: 'New Zealand' }, + { value: 'NI', label: 'Nicaragua' }, + { value: 'NE', label: 'Niger' }, + { value: 'NG', label: 'Nigeria' }, + { value: 'NU', label: 'Niue' }, + { value: 'NF', label: 'Norfolk Island' }, + { value: 'KP', label: 'North Korea' }, + { value: 'MP', label: 'Northern Mariana Islands' }, + { value: 'NO', label: 'Norway' }, + { value: 'OM', label: 'Oman' }, + { value: 'PK', label: 'Pakistan' }, + { value: 'PW', label: 'Palau' }, + { value: 'PS', label: 'Palestine' }, + { value: 'PA', label: 'Panama' }, + { value: 'PG', label: 'Papua New Guinea' }, + { value: 'PY', label: 'Paraguay' }, + { value: 'PE', label: 'Peru' }, + { value: 'PH', label: 'Philippines' }, + { value: 'PN', label: 'Pitcairn' }, + { value: 'PL', label: 'Poland' }, + { value: 'PT', label: 'Portugal' }, + { value: 'PR', label: 'Puerto Rico' }, + { value: 'QA', label: 'Qatar' }, + { value: 'RE', label: 'Réunion' }, + { value: 'RO', label: 'Romania' }, + { value: 'RU', label: 'Russian Federation' }, + { value: 'RW', label: 'Rwanda' }, + { value: 'BL', label: 'Saint Barthélemy' }, + { value: 'SH', label: 'Saint Helena, Ascension, Tristan Da Cunha' }, + { value: 'KN', label: 'Saint Kitts and Nevis' }, + { value: 'LC', label: 'Saint Lucia' }, + { value: 'MF', label: 'Saint Martin (French Portion)' }, + { value: 'PM', label: 'Saint Pierre and Miquelon' }, + { value: 'VC', label: 'Saint Vincent and the Grenadines' }, + { value: 'WS', label: 'Samoa' }, + { value: 'SM', label: 'San Marino' }, + { value: 'ST', label: 'Sao Tome and Principe' }, + { value: 'SA', label: 'Saudi Arabia' }, + { value: 'SN', label: 'Senegal' }, + { value: 'RS', label: 'Serbia' }, + { value: 'SC', label: 'Seychelles' }, + { value: 'SL', label: 'Sierra Leone' }, + { value: 'SG', label: 'Singapore' }, + { value: 'SX', label: 'Sint Maarten (Dutch part)' }, + { value: 'SK', label: 'Slovakia' }, + { value: 'SI', label: 'Slovenia' }, + { value: 'SB', label: 'Solomon Islands' }, + { value: 'SO', label: 'Somalia' }, + { value: 'ZA', label: 'South Africa' }, + { value: 'GS', label: 'South Georgia, South Sandwich Islands' }, + { value: 'KR', label: 'South Korea' }, + { value: 'SS', label: 'South Sudan' }, + { value: 'ES', label: 'Spain' }, + { value: 'LK', label: 'Sri Lanka' }, + { value: 'SD', label: 'Sudan' }, + { value: 'SR', label: 'Suriname' }, + { value: 'SJ', label: 'Svalbard and Jan Mayen' }, + { value: 'SZ', label: 'Swaziland' }, + { value: 'SE', label: 'Sweden' }, + { value: 'CH', label: 'Switzerland' }, + { value: 'SY', label: 'Syria' }, + { value: 'TW', label: 'Taiwan' }, + { value: 'TJ', label: 'Tajikistan' }, + { value: 'TZ', label: 'Tanzania' }, + { value: 'TH', label: 'Thailand' }, + { value: 'TL', label: 'Timor-Leste' }, + { value: 'TG', label: 'Togo' }, + { value: 'TK', label: 'Tokelau' }, + { value: 'TO', label: 'Tonga' }, + { value: 'TT', label: 'Trinidad and Tobago' }, + { value: 'TN', label: 'Tunisia' }, + { value: 'TR', label: 'Turkey' }, + { value: 'TM', label: 'Turkmenistan' }, + { value: 'TC', label: 'Turks and Caicos Islands' }, + { value: 'TV', label: 'Tuvalu' }, + { value: 'UG', label: 'Uganda' }, + { value: 'UA', label: 'Ukraine' }, + { value: 'AE', label: 'United Arab Emirates' }, + { value: 'GB', label: 'United Kingdom' }, + { value: 'US', label: 'United States' }, + { value: 'UM', label: 'United States Minor Outlying Islands' }, + { value: 'UY', label: 'Uruguay' }, + { value: 'UZ', label: 'Uzbekistan' }, + { value: 'VU', label: 'Vanuatu' }, + { value: 'VE', label: 'Venezuela' }, + { value: 'VN', label: 'Viet Nam' }, + { value: 'VG', label: 'Virgin Islands, British' }, + { value: 'VI', label: 'Virgin Islands, U.S.' }, + { value: 'WF', label: 'Wallis and Futuna' }, + { value: 'EH', label: 'Western Sahara' }, + { value: 'YE', label: 'Yemen' }, + { value: 'ZM', label: 'Zambia' }, + { value: 'ZW', label: 'Zimbabwe' }, +]; + + +export const countryInfo = { + US: { + regionLabel: 'State', + postalLabel: 'Zip code', + regions: [ + { value: 'AL', label: 'Alabama' }, + { value: 'AK', label: 'Alaska' }, + { value: 'AZ', label: 'Arizona' }, + { value: 'AR', label: 'Arkansas' }, + { value: 'CA', label: 'California' }, + { value: 'CO', label: 'Colorado' }, + { value: 'CT', label: 'Connecticut' }, + { value: 'DE', label: 'Delaware' }, + { value: 'FL', label: 'Florida' }, + { value: 'GA', label: 'Georgia' }, + { value: 'HI', label: 'Hawaii' }, + { value: 'ID', label: 'Idaho' }, + { value: 'IL', label: 'Illinois' }, + { value: 'IN', label: 'Indiana' }, + { value: 'IA', label: 'Iowa' }, + { value: 'KS', label: 'Kansas' }, + { value: 'KY', label: 'Kentucky' }, + { value: 'LA', label: 'Louisiana' }, + { value: 'ME', label: 'Maine' }, + { value: 'MD', label: 'Maryland' }, + { value: 'MA', label: 'Massachusetts' }, + { value: 'MI', label: 'Michigan' }, + { value: 'MN', label: 'Minnesota' }, + { value: 'MS', label: 'Mississippi' }, + { value: 'MO', label: 'Missouri' }, + { value: 'MT', label: 'Montana' }, + { value: 'NE', label: 'Nebraska' }, + { value: 'NV', label: 'Nevada' }, + { value: 'NH', label: 'New Hampshire' }, + { value: 'NJ', label: 'New Jersey' }, + { value: 'NM', label: 'New Mexico' }, + { value: 'NY', label: 'New York' }, + { value: 'NC', label: 'North Carolina' }, + { value: 'ND', label: 'North Dakota' }, + { value: 'OH', label: 'Ohio' }, + { value: 'OK', label: 'Oklahoma' }, + { value: 'OR', label: 'Oregon' }, + { value: 'PA', label: 'Pennsylvania' }, + { value: 'RI', label: 'Rhode Island' }, + { value: 'SC', label: 'South Carolina' }, + { value: 'SD', label: 'South Dakota' }, + { value: 'TN', label: 'Tennessee' }, + { value: 'TX', label: 'Texas' }, + { value: 'UT', label: 'Utah' }, + { value: 'VT', label: 'Vermont' }, + { value: 'VA', label: 'Virginia' }, + { value: 'WA', label: 'Washington' }, + { value: 'WV', label: 'West Virginia' }, + { value: 'WI', label: 'Wisconsin' }, + { value: 'WY', label: 'Wyoming' }, + ] + }, + CA: { + regionLabel: 'Province', + postalLabel: 'Postal code', + regions: [ + { value: 'AB', label: 'Alberta' }, + { value: 'BC', label: 'British Columbia' }, + { value: 'MB', label: 'Manitoba' }, + { value: 'NB', label: 'New Brunswick' }, + { value: 'NL', label: 'Newfoundland and Labrador' }, + { value: 'NS', label: 'Nova Scotia' }, + { value: 'NT', label: 'Northwest Territories' }, + { value: 'NU', label: 'Nunavut' }, + { value: 'ON', label: 'Ontario' }, + { value: 'PE', label: 'Prince Edward Island' }, + { value: 'QC', label: 'Quebec' }, + { value: 'SK', label: 'Saskatchewan' }, + { value: 'YT', label: 'Yukon' }, + ], + }, + AU: { + regionLabel: 'State', + postalLabel: 'Postcode', + regions: [ + { value: 'ACT', label: 'Australian Capital Territory' }, + { value: 'NSW', label: 'New South Wales' }, + { value: 'NT', label: 'Northern Territory' }, + { value: 'QLD', label: 'Queensland' }, + { value: 'SA', label: 'South Australia' }, + { value: 'TAS', label: 'Tasmania' }, + { value: 'VIC', label: 'Victoria' }, + { value: 'WA', label: 'Western Australia' }, + ], + }, + UK: { + regionLabel: 'County', + postalLabel: 'Postcode', + }, +}; + + +export const getCountryLabel = (countryValue) => { + const country = countries.find(country => country.value === countryValue); + return country ? country.label : ''; +}; + + +export const getCountryContinent = (countryValue) => { + const country = countries.find(country => country.value === countryValue); + return country ? country.continent : ''; +}; + + +export const getRegionLabel = (countryValue, regionValue) => { + if (!countryInfo[countryValue] || !countryInfo[countryValue].regions) { + return regionValue; + } + + const regions = countryInfo[countryValue].regions; + + let region = regions.find(nextRegion => nextRegion.value === regionValue); + + if (region) { + return region.label; + } else { + return regionValue; + } +}; + + +// Given a region value or label, returns the region value (QC or Quebec => QC) +// or false if the regionValue is invalid +export const validateRegion = (countryValue, regionValue) => { + if (!countryInfo[countryValue] || !countryInfo[countryValue].regions) { + return regionValue; + } + + const regions = countryInfo[countryValue].regions; + + let region = regions.find(nextRegion => nextRegion.value === regionValue); + + if (region) { + return regionValue; + } + + region = regions.find(nextRegion => nextRegion.label === regionValue); + + if (region) { + return region.value; + } else { + return false; + } +}; diff --git a/packages/vulcan-ui-material/lib/components/index.js b/packages/vulcan-ui-material/lib/components/index.js new file mode 100644 index 000000000..cbee0bccb --- /dev/null +++ b/packages/vulcan-ui-material/lib/components/index.js @@ -0,0 +1,59 @@ +import './accounts/AccountsButton'; +import './accounts/AccountsButtons'; +import './accounts/AccountsField'; +import './accounts/AccountsFields'; +import './accounts/AccountsForm'; +import './accounts/AccountsPasswordOrService'; +import './accounts/AccountsSocialButtons'; + +import './bonus/LoadMore'; +import './bonus/SearchInput'; +import './bonus/TooltipIntl'; +import './bonus/TooltipIconButton'; + +import './core/Card'; +import './core/Datatable'; +import './core/EditButton'; +import './core/Loading'; +import './core/ModalTrigger'; +import './core/NewButton'; + +import './forms/FormComponentInner'; +import './forms/FormErrors'; +//import './forms/FormGroup'; +import './forms/FormGroupNone'; +import './forms/FormGroupWithLine'; +import './forms/FormNested'; +import './forms/FormNestedArrayLayout'; +import './forms/FormNestedDivider'; +import './forms/FormNestedFoot'; +import './forms/FormNestedHead'; +import './forms/FormSubmit'; +import './forms/controls/Checkbox'; +import './forms/controls/CheckboxGroup'; +import './forms/controls/CountrySelect'; +import './forms/controls/Date'; +import './forms/controls/DateRdt'; +import './forms/controls/DateTime'; +import './forms/controls/DateTimeRdt'; +import './forms/controls/Default'; +import './forms/controls/Email'; +import './forms/controls/Number'; +import './forms/controls/PostalCode'; +import './forms/controls/RadioGroup'; +import './forms/controls/RegionSelect'; +import './forms/controls/Select'; +import './forms/controls/Textarea'; +import './forms/controls/Time'; +import './forms/controls/TimeRdt'; +import './forms/controls/Url'; + +import './theme/ThemeStyles'; + +import './ui/Button'; +import './ui/Alert'; + +import './upload/UploadImage'; +import './upload/UploadInner'; + +export * from './forms/controls/countries'; diff --git a/packages/vulcan-ui-material/lib/components/theme/JssCleanup.jsx b/packages/vulcan-ui-material/lib/components/theme/JssCleanup.jsx new file mode 100644 index 000000000..4d344294b --- /dev/null +++ b/packages/vulcan-ui-material/lib/components/theme/JssCleanup.jsx @@ -0,0 +1,23 @@ +import React, { PureComponent } from 'react'; + + +class JssCleanup extends PureComponent { + + + // Remove the server-side injected CSS. + componentDidMount() { + if (!document || !document.getElementById) return; + + const jssStyles = document.getElementById('jss-server-side'); + if (jssStyles && jssStyles.parentNode) { +// jssStyles.parentNode.removeChild(jssStyles); + } + } + + render() { + return this.props.children; + } +} + + +export default JssCleanup; diff --git a/packages/vulcan-ui-material/lib/components/theme/ThemeStyles.jsx b/packages/vulcan-ui-material/lib/components/theme/ThemeStyles.jsx new file mode 100644 index 000000000..7441fc01b --- /dev/null +++ b/packages/vulcan-ui-material/lib/components/theme/ThemeStyles.jsx @@ -0,0 +1,232 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { Components, registerComponent } from 'meteor/vulcan:core'; +import withTheme from '@material-ui/core/styles/withTheme'; +import withStyles from '@material-ui/core/styles/withStyles'; +import Typography from '@material-ui/core/Typography'; +import Grid from '@material-ui/core/Grid'; +import Paper from '@material-ui/core/Paper'; +import { getContrastRatio } from '@material-ui/core/styles/colorManipulator'; +import classNames from 'classnames'; + + +const describeTypography = (theme, className) => { + const typography = className ? theme.typography[className] : theme.typography; + const fontFamily = typography.fontFamily.split(',')[0]; + return `${fontFamily} ${typography.fontWeight} ${typography.fontSize}px`; +}; + + +const mainPalette = [50, 100, 200, 300, 400, 500, 600, 700, 800, 900]; +const altPalette = ['A100', 'A200', 'A400', 'A700']; + + +function getColorBlock(theme, classes, colorName, colorValue, colorTitle) { + const bgColor = theme.palette[colorName][colorValue]; + + let fgColor = theme.palette.common.black; + if (getContrastRatio(bgColor, fgColor) < 7) { + fgColor = theme.palette.common.white; + } + + let blockTitle; + if (colorTitle) { + blockTitle =
{colorName}
; + } + + let rowStyle = { + backgroundColor: bgColor, + color: fgColor, + listStyle: 'none', + padding: 15, + }; + + if (colorValue.toString().indexOf('A1') === 0) { + rowStyle = { + ...rowStyle, + marginTop: 4, + }; + } + + return ( +
  • + {blockTitle} +
    + {colorValue} + {bgColor.toUpperCase()} +
    +
  • + ); +} + +function getColorGroup(options) { + const { theme, classes, color, showAltPalette } = options; + const cssColor = color.replace(' ', '').replace(color.charAt(0), color.charAt(0).toLowerCase()); + let colorsList = []; + colorsList = mainPalette.map(mainValue => getColorBlock(theme, classes, cssColor, mainValue)); + + if (showAltPalette) { + altPalette.forEach(altValue => { + colorsList.push(getColorBlock(theme, classes, cssColor, altValue)); + }); + } + + return ( +
      + {getColorBlock(theme, classes, cssColor, 500, true)} +
      + {colorsList} +
    + ); +} + + +const styles = theme => ({ + root: {}, + paper: { + padding: theme.spacing.unit * 3, + }, + name: { + marginBottom: 60, + }, + blockSpace: { + height: 4, + }, + colorContainer: { + display: 'flex', + justifyContent: 'space-between', + alignItems: 'center', + }, + colorGroup: { + padding: '16px 0', + margin: '0 15px 0 0', + flexGrow: 1, + [theme.breakpoints.up('sm')]: { + flexGrow: 0, + }, + }, + colorValue: { + ...theme.typography.caption, + color: 'inherit', + }, +}); + + +const latin = 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Curabitur justo quam, ' + + 'pellentesque ultrices ex a, aliquet porttitor ante. Donec tellus arcu, viverra ut lorem id, ' + + 'ultrices ultricies enim. Donec enim metus, sollicitudin id lobortis id, iaculis ut arcu. ' + + 'Maecenas sollicitudin congue nisi. Donec convallis, ipsum ac ultricies dignissim, orci ex ' + + 'efficitur lectus, ac lacinia risus nunc at diam. Nam gravida bibendum lectus. Donec ' + + 'scelerisque sem nec urna vestibulum vehicula.'; + + +const ThemeStyles = ({ theme, classes }) => { + return ( + + + + + h1: {describeTypography(theme, 'h1')} + + + h2: {describeTypography(theme, 'h2')} + + + h3: {describeTypography(theme, 'h3')} + + + h4: {describeTypography(theme, 'h4')} + + + + + + + Headline: {describeTypography(theme, 'h5')} + + + Title: {describeTypography(theme, 'h6')} + + + Subtitle1: {describeTypography(theme, 'subtitle1')} + + + Body 1: {describeTypography(theme, 'body1')} - {latin} + + + {latin} + + + Body 2: {describeTypography(theme, 'body2')} - {latin} + + + {latin} + + + Caption: {describeTypography(theme, 'caption')} + + + Base: {describeTypography(theme)} - {latin} + + + Button - {describeTypography(theme)} + + + + + + { + getColorGroup({ + theme, + classes, + color: 'primary', + showAltPalette: true, + }) + } + + + + { + getColorGroup({ + theme, + classes, + color: 'secondary', + showAltPalette: true, + }) + } + + + + { + getColorGroup({ + theme, + classes, + color: 'error', + showAltPalette: true, + }) + } + + + + { + getColorGroup({ + theme, + classes, + color: 'background', + showAltPalette: true, + }) + } + + + + ); +}; + + +ThemeStyles.propTypes = { + theme: PropTypes.object.isRequired, + classes: PropTypes.object.isRequired, +}; + + +registerComponent('ThemeStyles', ThemeStyles, [withTheme, null], [withStyles, styles]); diff --git a/packages/vulcan-ui-material/lib/components/ui/Alert.jsx b/packages/vulcan-ui-material/lib/components/ui/Alert.jsx new file mode 100644 index 000000000..7a1254f5d --- /dev/null +++ b/packages/vulcan-ui-material/lib/components/ui/Alert.jsx @@ -0,0 +1,31 @@ +/** + * @Author: Apollinaire Lecocq + * @Date: 09-01-19 + * @Last modified by: apollinaire + * @Last modified time: 10-01-19 + */ +import React from 'react'; +import withStyles from '@material-ui/core/styles/withStyles'; +import { registerComponent } from 'meteor/vulcan:core'; + +import Card from '@material-ui/core/Card'; +import CardContent from '@material-ui/core/CardContent'; + +const AlertStyle = theme => ({ + error: { + color: theme.palette.error.main, + backgroundColor: theme.palette.error[100], + fontFamily: theme.typography.fontFamily, + }, + other: { + fontFamily: theme.typography.fontFamily, + }, +}); + +const Alert = ({ children, variant, classes, ...rest }) => ( + + {children} + +); + +registerComponent({ name: 'Alert', component: Alert, hocs: [[withStyles, AlertStyle]] }); diff --git a/packages/vulcan-ui-material/lib/components/ui/Button.jsx b/packages/vulcan-ui-material/lib/components/ui/Button.jsx new file mode 100644 index 000000000..23ea96d84 --- /dev/null +++ b/packages/vulcan-ui-material/lib/components/ui/Button.jsx @@ -0,0 +1,51 @@ +import React from 'react'; +import { Components, registerComponent } from 'meteor/vulcan:core'; +import MuiButton from '@material-ui/core/Button'; +import MuiIconButton from '@material-ui/core/IconButton'; + + +const Button = ({ children, color, variant, size, iconButton, ...rest }) => { + switch(variant) { + case 'success': + color = 'primary'; + variant = null; + break; + case 'danger': + color = 'default'; + variant = null; + break; + case 'inverse': + color = 'inherit'; + variant = null; + break; + } + + switch(size) { + case 'xsmall': + size = 'small'; + break; + case 'small': + size = 'medium'; + break; + case 'large': + size = 'large'; + break; + } + + if (iconButton) { + return ( + + {children} + + ); + } + + return ( + + {children} + + ); +}; + + +registerComponent('Button', Button); diff --git a/packages/vulcan-ui-material/lib/components/upload/UploadImage.jsx b/packages/vulcan-ui-material/lib/components/upload/UploadImage.jsx new file mode 100755 index 000000000..6904e2fba --- /dev/null +++ b/packages/vulcan-ui-material/lib/components/upload/UploadImage.jsx @@ -0,0 +1,117 @@ +import React, { PureComponent } from 'react'; +import PropTypes from 'prop-types'; +import { Components, registerComponent } from 'meteor/vulcan:lib'; +import { FormattedMessage } from 'meteor/vulcan:i18n'; +import withStyles from '@material-ui/core/styles/withStyles'; +import IconButton from '@material-ui/core/IconButton'; +import DeleteIcon from 'mdi-material-ui/Delete'; +import classNames from 'classnames'; + +/** + * Used by UploadInner to display a single image + */ +const styles = theme => ({ + + uploadImage: { + textAlign: 'center', + marginBottom: theme.spacing.unit * -1, + marginLeft: theme.spacing.unit * 0.5, + marginRight: theme.spacing.unit * 0.5, + }, + + uploadImageContents: { + position: 'relative', + }, + + uploadImageImg: { + display: 'block', + maxWidth: 150, + maxHeight: 150, + }, + + uploadLoading: { + position: 'absolute', + top: 0, + bottom: 0, + left: 0, + right: 0, + background: 'rgba(255,255,255,0.8)', + display: 'flex', + justifyContent: 'center', + alignItems: 'center', + span: { + display: 'block', + fontSize: '1.5rem', + } + }, + + deleteButton: { + } + +}); + + +class UploadImage extends PureComponent { + + constructor (props) { + super(props); + this.handleClear = this.handleClear.bind(this); + } + + handleClear (event) { + event.preventDefault(); + this.props.clearImage(this.props.index); + } + + // Get the URL of an image or the first in an array of images + getImageUrl (imageOrImageArray) { + // if image is actually an array of formats, use first format + const image = Array.isArray(imageOrImageArray) ? imageOrImageArray[0] : imageOrImageArray; + + // if image is an object, return secure_url; else return image itself + return typeof image === 'string' ? image : image.secure_url; + } + + render () { + const { loading, error, image, style, classes } = this.props; + + return ( +
    + +
    + + + { + loading && + +
    + +
    + } +
    + + + + + +
    + ); + } +} + + +UploadImage.propTypes = { + clearImage: PropTypes.func.isRequired, + index: PropTypes.number.isRequired, + image: PropTypes.oneOfType([PropTypes.string, PropTypes.array, PropTypes.object]), + loading: PropTypes.bool, + error: PropTypes.oneOfType([PropTypes.string, PropTypes.object]), + style: PropTypes.object, + classes: PropTypes.object.isRequired, +}; + + +UploadImage.displayName = 'UploadImageMui'; + + +registerComponent('UploadImage', UploadImage, [withStyles, styles]); diff --git a/packages/vulcan-ui-material/lib/components/upload/UploadInner.jsx b/packages/vulcan-ui-material/lib/components/upload/UploadInner.jsx new file mode 100755 index 000000000..1392ec066 --- /dev/null +++ b/packages/vulcan-ui-material/lib/components/upload/UploadInner.jsx @@ -0,0 +1,181 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { Components, registerComponent, getComponent } from 'meteor/vulcan:lib'; +import Dropzone from 'react-dropzone'; +import withStyles from '@material-ui/core/styles/withStyles'; +import { FormattedMessage } from 'meteor/vulcan:i18n'; +import FormControl from '@material-ui/core/FormControl'; +import FormLabel from '@material-ui/core/FormLabel'; +import FormHelperText from '@material-ui/core/FormHelperText'; + + +/* + +Material UI GUI for Cloudinary Image Upload component + +*/ + + +const styles = theme => ({ + + root: {}, + + label: {}, + + uploadField: { + marginTop: theme.spacing.unit, + }, + + dropzoneBase: { + borderWidth: 3, + borderStyle: 'dashed', + borderColor: theme.palette.background[900], + backgroundColor: theme.palette.background[100], + color: theme.palette.common.lightBlack, + padding: '30px 60px', + transition: 'all 0.5s', + cursor: 'pointer', + position: 'relative', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + '&[aria-disabled="false"]:hover': { + color: theme.palette.common.midBlack, + borderColor: theme.palette.background['A200'], + } + }, + + dropzoneActive: { + borderStyle: 'solid', + borderColor: theme.palette.status.info, + }, + + dropzoneReject: { + borderStyle: 'solid', + borderColor: theme.palette.status.danger, + }, + + uploadState: {}, + + uploadImages: { + border: `1px solid ${theme.palette.background[500]}`, + backgroundColor: theme.palette.background[100], + display: 'flex', + flexDirection: 'row', + flexWrap: 'wrap', + justifyContent: 'center', + paddingTop: theme.spacing.unit, + paddingRight: theme.spacing.unit * 0.5, + paddingBottom: theme.spacing.unit, + paddingLeft: theme.spacing.unit * 0.5, + }, +}); + + +const UploadInner = (props) => { + const { + uploading, + images, + disabled, + maxCount, + label, + help, + options, + enableMultiple, + onDrop, + isDeleted, + clearImage, + classes + } = props; + + const UploadImage = getComponent(options.uploadImageComponentName || 'UploadImage'); + + return ( + + + + {label} + + { + help && + + {help} + } +
    + { + (disabled && !enableMultiple) + ? + null + : + +
    + +
    + {uploading && ( +
    + + + +
    + )} +
    + } + + {!!images.length && ( +
    +
    + {images.map( + (image, index) => + !isDeleted(index) && ( + + ) + )} +
    +
    + )} +
    + +
    + ); +}; + + +UploadInner.propTypes = { + uploading: PropTypes.bool, + images: PropTypes.array.isRequired, + disabled: PropTypes.bool, + maxCount: PropTypes.number.isRequired, + label: PropTypes.string, + help: PropTypes.string, + options: PropTypes.object.isRequired, + enableMultiple: PropTypes.bool, + onDrop: PropTypes.func.isRequired, + isDeleted: PropTypes.func.isRequired, + clearImage: PropTypes.func.isRequired, + classes: PropTypes.object.isRequired, +}; + + +UploadInner.displayName = 'UploadInnerMui'; + + +registerComponent('UploadInner', UploadInner, [withStyles, styles]); diff --git a/packages/vulcan-ui-material/lib/example/Header.jsx b/packages/vulcan-ui-material/lib/example/Header.jsx new file mode 100644 index 000000000..7ae557a4f --- /dev/null +++ b/packages/vulcan-ui-material/lib/example/Header.jsx @@ -0,0 +1,98 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import AppBar from '@material-ui/core/AppBar'; +import Toolbar from '@material-ui/core/Toolbar'; +import IconButton from '@material-ui/core/IconButton'; +import Typography from '@material-ui/core/Typography'; +import MenuIcon from 'mdi-material-ui/Menu'; +import ChevronLeftIcon from 'mdi-material-ui/ChevronLeft'; +import withStyles from '@material-ui/core/styles/withStyles'; +import { getSetting, Components, registerComponent } from 'meteor/vulcan:core'; +import classNames from 'classnames'; + + +const drawerWidth = 240; +const topBarHeight = 100; + + +const styles = theme => ({ + appBar: { + position: 'absolute', + transition: theme.transitions.create(['margin', 'width'], { + easing: theme.transitions.easing.sharp, + duration: theme.transitions.duration.leavingScreen, + }), + }, + appBarShift: { + marginLeft: drawerWidth, + width: `calc(100% - ${drawerWidth}px)`, + transition: theme.transitions.create(['margin', 'width'], { + easing: theme.transitions.easing.easeOut, + duration: theme.transitions.duration.enteringScreen, + }), + }, + toolbar: { + height: `${topBarHeight}px`, + minHeight: `${topBarHeight}px`, + }, + headerMid: { + flexGrow: 1, + display: 'flex', + alignItems: 'center', + '& h1': { + margin: '0 24px 0 0', + fontSize: '18px', + lineHeight: 1, + } + }, + menuButton: { + marginRight: theme.spacing.unit * 3, + }, +}); + + +const Header = (props, context) => { + const classes = props.classes; + const isSideNavOpen = props.isSideNavOpen; + const toggleSideNav = props.toggleSideNav; + + const siteTitle = getSetting('title', 'My App'); + + return ( + + + + toggleSideNav()} + className={classNames(classes.menuButton)} + color="inherit" + > + {isSideNavOpen ? : } + + +
    + + + {siteTitle} + + +
    + +
    +
    + ); +}; + + +Header.propTypes = { + classes: PropTypes.object.isRequired, + isSideNavOpen: PropTypes.bool, + toggleSideNav: PropTypes.func, +}; + + +Header.displayName = 'Header'; + + +registerComponent('Header', Header, [withStyles, styles]); diff --git a/packages/vulcan-ui-material/lib/example/Layout.jsx b/packages/vulcan-ui-material/lib/example/Layout.jsx new file mode 100644 index 000000000..63c472239 --- /dev/null +++ b/packages/vulcan-ui-material/lib/example/Layout.jsx @@ -0,0 +1,137 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import Drawer from '@material-ui/core/Drawer'; +import AppBar from '@material-ui/core/AppBar'; +import Toolbar from '@material-ui/core/Toolbar'; +import { Components, replaceComponent, Utils } from 'meteor/vulcan:core'; +import withStyles from '@material-ui/core/styles/withStyles'; +import classNames from 'classnames'; + + +const drawerWidth = 240; +const topBarHeight = 100; + + +const styles = theme => { + const contentPadding = theme.spacing.unit * 8; + + return { + '@global': { + html: { + background: theme.palette.background.default, + WebkitFontSmoothing: 'antialiased', + MozOsxFontSmoothing: 'grayscale', + overflow: 'hidden', + }, + body: { + margin: 0, + }, + }, + root: { + width: '100%', + zIndex: 1, + overflow: 'hidden', + }, + appFrame: { + position: 'relative', + display: 'flex', + height: '100vh', + alignItems: 'stretch', + }, + drawerPaper: { + position: 'relative', + width: drawerWidth, + backgroundColor: theme.palette.background[200], + }, + drawerHeader: { + height: `${topBarHeight}px !important`, + minHeight: `${topBarHeight}px !important`, + position: 'relative !important', + }, + content: { + padding: contentPadding, + width: '100%', + marginLeft: -drawerWidth, + flexGrow: 1, + backgroundColor: theme.palette.background.default, + color: theme.palette.text.primary, + transition: theme.transitions.create('margin', { + easing: theme.transitions.easing.sharp, + duration: theme.transitions.duration.leavingScreen, + }), + height: `calc(100% - ${topBarHeight}px - ${contentPadding * 2}px)`, + marginTop: topBarHeight, + overflowY: 'scroll', + }, + mainShift: { + marginLeft: 0, + transition: theme.transitions.create('margin', { + easing: theme.transitions.easing.easeOut, + duration: theme.transitions.duration.enteringScreen, + }), + }, + }; +}; + + + + class Layout extends React.Component { + state = { + isOpen: { sideNav: true } + }; + + toggle = (item, openOrClose) => { + const newState = { isOpen: {} }; + newState.isOpen[item] = typeof openOrClose === 'string' ? + openOrClose === 'open' : + !this.state.isOpen[item]; + this.setState(newState); + }; + + render = () => { + const routeName = Utils.slugify(this.props.currentRoute.name); + const classes = this.props.classes; + const isOpen = this.state.isOpen; + + return ( +
    +
    + + + this.toggle('sideNav', openOrClose)} /> + + + + + + + + + +
    + {this.props.children} +
    + + + +
    +
    + ); + }; + } + + + Layout.propTypes = { + classes: PropTypes.object.isRequired, + children: PropTypes.node, + }; + + + Layout.displayName = 'Layout'; + + + replaceComponent('Layout', Layout, [withStyles, styles]); diff --git a/packages/vulcan-ui-material/lib/example/SideNavigation.jsx b/packages/vulcan-ui-material/lib/example/SideNavigation.jsx new file mode 100644 index 000000000..745821389 --- /dev/null +++ b/packages/vulcan-ui-material/lib/example/SideNavigation.jsx @@ -0,0 +1,104 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { Components, registerComponent, withCurrentUser } from 'meteor/vulcan:core'; +import { withRouter } from 'react-router'; +import List from '@material-ui/core/List'; +import ListItem from '@material-ui/core/ListItem'; +import ListItemIcon from '@material-ui/core/ListItemIcon'; +import ListItemText from '@material-ui/core/ListItemText'; +import Divider from '@material-ui/core/Divider'; +import Collapse from '@material-ui/core/Collapse'; +import ExpandLessIcon from 'mdi-material-ui/ChevronUp'; +import ExpandMoreIcon from 'mdi-material-ui/ChevronDown'; +import LockIcon from 'mdi-material-ui/Lock'; +import UsersIcon from 'mdi-material-ui/AccountMultiple'; +import ThemeIcon from 'mdi-material-ui/Palette'; +import HomeIcon from 'mdi-material-ui/Home'; +import withStyles from '@material-ui/core/styles/withStyles'; +import Users from 'meteor/vulcan:users'; + + +const styles = theme => ({ + root: {}, + nested: { + paddingLeft: theme.spacing.unit * 4, + }, +}); + + +class SideNavigation extends React.Component { + state = { + isOpen: { admin: false } + }; + + toggle = (item) => { + const newState = { isOpen: {} }; + newState.isOpen[item] = !this.state.isOpen[item]; + this.setState(newState); + }; + + render () { + const { currentUser, classes, history } = this.props; + const isOpen = this.state.isOpen; + + return ( +
    + + + {history.push('/');}}> + + + + + + + + { + Users.isAdmin(currentUser) && + +
    + + + this.toggle('admin')}> + + + + + {isOpen.admin ? : } + + + {history.push('/admin');}}> + + + + + + {history.push('/theme');}}> + + + + + + + +
    + } + +
    + ); + } +} + + +SideNavigation.propTypes = { + classes: PropTypes.object.isRequired, + currentUser: PropTypes.object, +}; + + +SideNavigation.displayName = 'SideNavigation'; + + +registerComponent('SideNavigation', SideNavigation, [withStyles, styles], withCurrentUser, withRouter); diff --git a/packages/vulcan-ui-material/lib/modules/components.js b/packages/vulcan-ui-material/lib/modules/components.js new file mode 100644 index 000000000..4aba840ab --- /dev/null +++ b/packages/vulcan-ui-material/lib/modules/components.js @@ -0,0 +1 @@ +export * from '../components'; \ No newline at end of file diff --git a/packages/vulcan-ui-material/lib/modules/index.js b/packages/vulcan-ui-material/lib/modules/index.js new file mode 100644 index 000000000..e614e7b6b --- /dev/null +++ b/packages/vulcan-ui-material/lib/modules/index.js @@ -0,0 +1,5 @@ +export * from './components'; +export * from './themes'; +export JssCleanup from '../components/theme/JssCleanup'; +import './sampleTheme'; +import './routes'; diff --git a/packages/vulcan-ui-material/lib/modules/routes.js b/packages/vulcan-ui-material/lib/modules/routes.js new file mode 100755 index 000000000..55d267e77 --- /dev/null +++ b/packages/vulcan-ui-material/lib/modules/routes.js @@ -0,0 +1,10 @@ +import { addRoute } from 'meteor/vulcan:core'; + +//Only create route on dev mode, not production. +if (Meteor.isDevelopment) { + addRoute({ + name: 'theme', + path: '/theme', + componentName: 'ThemeStyles', + }); +} diff --git a/packages/vulcan-ui-material/lib/modules/sampleTheme.js b/packages/vulcan-ui-material/lib/modules/sampleTheme.js new file mode 100644 index 000000000..a382078a7 --- /dev/null +++ b/packages/vulcan-ui-material/lib/modules/sampleTheme.js @@ -0,0 +1,76 @@ +import { registerTheme } from './themes'; +import indigo from '@material-ui/core/colors/indigo'; +import blue from '@material-ui/core/colors/blue'; +import red from '@material-ui/core/colors/red'; + + +/** @ignore */ + +/** + * + * Sample theme to get you out of the gate quickly + * + * For a complete list of configuration variables see: + * https://material-ui.com/customization/themes/ + * + */ + + +const theme = { + + palette: { + primary: indigo, + secondary: blue, + error: red, + }, + + utils: { + + tooltipEnterDelay: 700, + + errorMessage: { + textAlign: 'center', + backgroundColor: red[500], + color: 'white', + borderRadius: '4px', + fontWeight: 'bold', + }, + + denseTable: { + '& > thead > tr > th, & > tbody > tr > td': { + padding: '4px 16px 4px 16px', + }, + '& > thead > tr > th:last-child, & > tbody > tr > td:last-child': { + paddingRight: '16px', + }, + }, + + flatTable: { + '& > thead > tr > th, & > tbody > tr > td': { + padding: '4px 16px 4px 16px', + whiteSpace: 'nowrap', + }, + '& > thead > tr > th:last-child, & > tbody > tr > td:last-child': { + paddingRight: '16px', + }, + }, + + denserTable: { + '& > thead > tr, & > tbody > tr': { + height: '40px', + }, + '& > thead > tr > th, & > tbody > tr > td': { + padding: '4px 16px 4px 16px', + whiteSpace: 'nowrap', + }, + '& > thead > tr > th:last-child, & > tbody > tr > td:last-child': { + paddingRight: '16px', + }, + }, + + }, + +}; + + +registerTheme('Sample', theme); diff --git a/packages/vulcan-ui-material/lib/modules/themes.js b/packages/vulcan-ui-material/lib/modules/themes.js new file mode 100644 index 000000000..d829dc36d --- /dev/null +++ b/packages/vulcan-ui-material/lib/modules/themes.js @@ -0,0 +1,67 @@ +/** @module vulcan-material-ui */ + +import createMuiTheme from '@material-ui/core/styles/createMuiTheme'; +import { registerSetting, getSetting } from 'meteor/vulcan:core'; + + +registerSetting('muiTheme', 'Sample', 'Material UI theme used by erikdakota:vulcan-material-ui'); + + +export const ThemesTable = {}; // storage for info about themes + + +/** + * Register a theme with a name + * + * @param {String} name The name of the theme to register + * @param {Object} theme The theme object - see defaultTheme.js + * + */ +export const registerTheme = (name, theme) => { + const themeInfo = { + name, + theme, + }; + + ThemesTable[name] = themeInfo; +}; + + +/** + * Get a theme registered with registerTheme() + * + * @param {String} name The name of the theme to get + * + * @returns {Object} A theme object + */ +export const getTheme = (name) => { + const themeInfo = ThemesTable[name]; + if (!themeInfo) return null; + themeInfo.theme.typography = { ...themeInfo.theme.typography, useNextVariants: true }; + return createMuiTheme(themeInfo.theme); +}; + +/** + * Get the raw theme object registered with registerTheme() + * + * @param {String} name The name of the theme to get + * + * @returns {Object} The object passed to registerTheme + */ + +export const getRawTheme = (name) => { + const themeInfo = ThemesTable[name]; + if (!themeInfo) return null; + return themeInfo.theme; +}; + +/** + * Get the theme specified in the 'muiTheme' setting + * + * @returns {Object} + */ +export const getCurrentTheme = () => { + const themeName = getSetting('muiTheme', 'Sample'); + const theme = getTheme(themeName); + return theme; +}; diff --git a/packages/vulcan-ui-material/lib/server/main.js b/packages/vulcan-ui-material/lib/server/main.js new file mode 100644 index 000000000..dadfe15cf --- /dev/null +++ b/packages/vulcan-ui-material/lib/server/main.js @@ -0,0 +1,2 @@ +export * from '../modules/index'; +import './wrapWithMuiTheme'; diff --git a/packages/vulcan-ui-material/lib/server/wrapWithMuiTheme.jsx b/packages/vulcan-ui-material/lib/server/wrapWithMuiTheme.jsx new file mode 100644 index 000000000..32933c0e5 --- /dev/null +++ b/packages/vulcan-ui-material/lib/server/wrapWithMuiTheme.jsx @@ -0,0 +1,43 @@ +import React from 'react'; +import { addCallback } from 'meteor/vulcan:core'; +import JssProvider from 'react-jss/lib/JssProvider'; +import MuiThemeProvider from '@material-ui/core/styles/MuiThemeProvider'; +import createGenerateClassName from '@material-ui/core/styles/createGenerateClassName'; +import { getCurrentTheme } from '../modules/themes'; +import { SheetsRegistry } from 'react-jss/lib/jss'; +import JssCleanup from '../components/theme/JssCleanup'; + + +function wrapWithMuiTheme (app, {context }) { + const sheetsRegistry = new SheetsRegistry(); + context.sheetsRegistry = sheetsRegistry; + + const sheetsManager = new Map(); + + const theme = getCurrentTheme(); + + const generateClassName = createGenerateClassName({ seed: '' }); + + return ( + + + + {app} + + + + ); +} + + +function injectJss(sink, { context }) { + const sheets = context.sheetsRegistry.toString(); + sink.appendToHead( + `` + ); + return sink; +} + + +addCallback('router.server.wrapper', wrapWithMuiTheme); +addCallback('router.server.postRender', injectJss); diff --git a/packages/vulcan-ui-material/package.js b/packages/vulcan-ui-material/package.js new file mode 100644 index 000000000..5b4caf370 --- /dev/null +++ b/packages/vulcan-ui-material/package.js @@ -0,0 +1,25 @@ +Package.describe({ + name: 'vulcan:ui-material', + version: '1.13.0', + summary: 'Replacement for Vulcan (http://vulcanjs.org/) components using material-ui', + documentation: 'README.md' +}); + +Package.onUse(function (api) { + api.versionsFrom('METEOR@1.6'); + + api.use([ + 'ecmascript', + 'vulcan:core@1.12.8', + 'vulcan:accounts@1.12.8', + 'vulcan:forms@1.12.8', + ]); + + api.addFiles([ + 'accounts.css', + 'forms.css', + ], ['client', 'server']); + + api.mainModule('lib/client/main.js', 'client'); + api.mainModule('lib/server/main.js', 'server'); +}); \ No newline at end of file diff --git a/packages/vulcan-ui-material/readme.md b/packages/vulcan-ui-material/readme.md new file mode 100644 index 000000000..5e2d912f9 --- /dev/null +++ b/packages/vulcan-ui-material/readme.md @@ -0,0 +1,209 @@ +# vulcan:ui-material 1.12.8_13 + +Package initially created by [Erik Dakoda](https://github.com/ErikDakoda) ([`erikdakoda:vulcan-material-ui`](https://github.com/ErikDakoda/vulcan-material-ui)) + + +Replacement for [Vulcan](http://vulcanjs.org/) components using [Material-UI](https://material-ui.com/). + + +There are some nice bonus features like a CountrySelect with autocomplete and theming. + +All components in vulcan:ui-bootstrap, vulcan:forms and vulcan:accounts have been implemented except for Icon. + +## Installation + +To add vulcan-material-ui to an existing Vulcan project, enter the following: + +``` sh +meteor add vulcan:ui-material + +meteor npm install --save @material-ui/core@3.1.0 +meteor npm install --save react-jss +meteor npm install --save mdi-material-ui +meteor npm install --save react-autosuggest +meteor npm install --save autosuggest-highlight +meteor npm install --save react-isolated-scroll +meteor npm install --save-exact react-keyboard-event-handler@1.3.2 +#meteor npm install --save autosize-input +meteor npm install --save moment-timezone +``` + +> NOTE: If you want to avoid deprecation warnings added in MUI versions after 3.1.0, you can lock MUI to the currently supported version using `meteor npm install --save @material-ui/core@3.1.0`. Don't for get to remove or update the version number when you update this package in the future. + + +> IMPORTANT: Please note that I have abandoned material-ui-icons in favor of mdi-material-ui because it has a much larger [selection of icons](https://materialdesignicons.com/). + +This package no longer depends on `vulcan:ui-boostrap`, so you can remove it. + +To activate the example layout copy the three components to your project and import them: + +``` javascript +import './example/Header', +import './example/Layout', +import './example/SideNavigation', +``` + +## Theming + +For an example theme see `modules/sampleTheme.js`. For a complete list of values you can customize, +see the [MUI Default Theme](https://material-ui-next.com/customization/default-theme/). + +Register your theme in the Vulcan environment by giving it a name: `registerTheme('MyTheme', theme);`. +You can have multiple themes registered and you can specify which one to use in your settings file using the `muiTheme` **public** setting. + +In addition to the Material UI spec, I use a `utils` section in my themes where I place global variables for reusable styles. +For example the sample theme contains + +``` +const theme = { + + . . . + + utils: { + + tooltipEnterDelay: 700, + + errorMessage: { + textAlign: 'center', + backgroundColor: red[500], + color: 'white', + borderRadius: '4px', + }, + + . . . + + // additional utils definitions go here + + }, + +}; +``` + +You can use tooltipEnterDelay (or any other variable you define in utils) anywhere you include the withTheme HOC. See `/components/bonus/TooltipIconButton.jsx` for an example. + +You can use errorMessage (or any other style fragment you define in utils) anywhere you include the withStyles HOC. See `/components/accounts/Form.jsx` for an example. + +## Server Side Rendering (SSR) + +Material UI and Vulcan support SSR, but this is a complex beast with pitfalls. Sometimes you will see a warning like this: + +`Warning: Prop className did not match. Server: "MuiChip-label-131" Client: "MuiChip-label-130"` + +Sometimes the React rendered on the server and the client don't match exactly and this causes a problem with [JSS](https://material-ui-next.com/customization/css-in-js/#jss). This is a complicated issue that has multiple causes and I will be working on solving each of the issues causing this over time. + +Your pages should still render correctly, but there may be a blink and redraw when the first page after SSR loads in the browser. + +In your own code, make sure that your components will render the same on the server and the client. This means not referring to client-side object such as `document` or `jQuery`. If you have a misbehaving component, try wrapping it with [react-no-ssr](https://github.com/kadirahq/react-no-ssr). + +## Form Controls + +You can pass a couple of extra options to inputs from the `form` property of your schema: + +``` javascript + userKey: { + type: String, + label: 'User key', + description: 'The user’s key', + optional: true, + hidden: function ({ document }) { + return !document.platformId || !document.usePlatformApp; + }, + inputProperties: { + autoFocus: true, // focus this input when the form loads + addonBefore: , // adorn the start of the input + addonAfter: , // adorn the end of the input + inputClassName: 'halfWidthLeft', // use 'halfWidthLeft' or 'halfWidthRight' + // to display two controls side by side + hideLabel: true, // hide the label + rows: 10, // for textareas you can specify the rows + variant: 'switch', // for checkboxgroups you can use either + // 'checkbox' (default) or 'switch' + inputProps: { step: 'any' } // Attributes applied to the input element, for + // ex pass the step attr to a number input + }, + group: platformGroup, + canRead: ['members'], + canCreate: ['members'], + canUpdate: ['members'], + }, +``` + +> Note: `form.getHidden` has been deprecated. Now you can just pass a function to `hidden`. + +## Form Groups + +You can pass a couple of extra options to form groups as well: + +``` javascript + const platformGroup: { + name: 'shops.platform', + order: 4, + startComponent: 'ShopsPlatformTitle', // component to put at the top of the form group + endComponent: 'ShopsConnectButtons', // component to put at the bottom of the form group + }, +``` + +## DataTable + +You can pass the DataTable component an `editComponent` property in addition to or instead of `showEdit`. Here is a simplified example: + +``` javascript +const AgendaJobActions = ({ collection, document }) => { + const scheduleAgent = () => { + Meteor.call('scheduleAgent', document.agentId); + }; + + return } + onClick={scheduleAgent}/>; +}; + +AgendaJobActionsInner.propTypes = { + collecion: PropTypes.object.isRequired, + document: PropTypes.object.isRequired, +}; + + +``` + +You can also control the spacing of the table cells using the `dense` property. Valid values are: + +| Value | Description | +| ------- | ------------ | +| dense | right cell padding of 16px instead of 56px | +| flat | right cell padding of 16px and nowrap | +| denser | right cell padding of 16px, nowrap, and row height of 40px instead of 56px | + +You can also use other string values, as long as you define a `utils` entry named the same + `Table`, for example `myCustomTable`. Check out the sample theme for examples. + + +## CountrySelect + +There is an additional component, an autosuggest-based country select. + +``` javascript + country: { + type: String, + label: 'Country', + input: 'CountrySelect', + canRead: ['guests'], + canCreate: ['members'], + canUpdate: ['members'], + }, +``` + +Countries are stored as their 2-letter country codes. I have included a helper function for displaying the country name: + +``` javascript +import Typography from '@material-ui/core/Typography'; +import { getCountryLabel } from 'meteor/erikdakoda:vulcan-material-ui'; + + + {getCountryLabel(supplier.country)} + +``` + diff --git a/packages/vulcan-users/lib/server/callbacks.js b/packages/vulcan-users/lib/server/callbacks.js index d1e719710..c044bef4f 100644 --- a/packages/vulcan-users/lib/server/callbacks.js +++ b/packages/vulcan-users/lib/server/callbacks.js @@ -39,7 +39,7 @@ addCallback('users.new.sync', usersMakeAdmin); function usersEditGenerateHtmlBio (modifier) { - if (modifier.$set && modifier.$set.bio) { + if (modifier.$set && 'bio' in modifier.$set) { modifier.$set.htmlBio = Utils.sanitize(marked(modifier.$set.bio)); } return modifier; diff --git a/packages/vulcan-users/package.js b/packages/vulcan-users/package.js index a5b953789..d0c05f902 100644 --- a/packages/vulcan-users/package.js +++ b/packages/vulcan-users/package.js @@ -1,14 +1,14 @@ Package.describe({ name: 'vulcan:users', summary: 'Vulcan permissions.', - version: '1.12.17', + version: '1.13.0', git: 'https://github.com/VulcanJS/Vulcan.git', }); Package.onUse(function(api) { api.versionsFrom('1.6.1'); - api.use(['vulcan:lib@1.12.17']); + api.use(['vulcan:lib@1.13.0']); api.mainModule('lib/server/main.js', 'server'); api.mainModule('lib/client/main.js', 'client'); diff --git a/packages/vulcan-voting/package.js b/packages/vulcan-voting/package.js index 6430b8ee3..42ca87419 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.12.17', + version: '1.13.0', git: 'https://github.com/VulcanJS/Vulcan.git', }); @@ -9,7 +9,7 @@ Package.onUse(function(api) { api.versionsFrom('1.6.1'); api.use( - ['fourseven:scss@4.10.0', 'vulcan:core@1.12.17', 'vulcan:i18n@1.12.17'], + ['fourseven:scss@4.10.0', 'vulcan:core@1.13.0', 'vulcan:i18n@1.13.0'], ['client', 'server'], );