diff --git a/.env.example b/.env.example index 7395b03..15f48c1 100644 --- a/.env.example +++ b/.env.example @@ -9,6 +9,8 @@ HYDRA_REDIRECT_URL=http://localhost:8081 # Public vars for the web-app PUBLIC_GRAPHQL_API_URL=http://localhost:3000/graphql +PUBLIC_I18N_BASE_URL=http://localhost:3000 +PUBLIC_I18N_FALLBACK_LANGUAGE=en PUBLIC_OIDC_CLIENT_ID=reaction-admin-core PUBLIC_OIDC_URL=http://localhost:4444 PUBLIC_ROOT_URL=http://localhost:8081 diff --git a/package-lock.json b/package-lock.json index 0c9d6ad..0340b64 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2147,6 +2147,11 @@ "regenerator-runtime": "^0.12.0" } }, + "mdi-material-ui": { + "version": "5.8.0", + "resolved": "https://registry.npmjs.org/mdi-material-ui/-/mdi-material-ui-5.8.0.tgz", + "integrity": "sha512-KixZVfNg0ejURv9CIliB1M3kl4Soe6f6yAFjFNsoYPMvGGw1AhnQKGZ3EOEKOIdp2X9YeZFOaO+i9e4ZHUrpNA==" + }, "react-is": { "version": "16.4.2", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.4.2.tgz", @@ -9476,9 +9481,9 @@ } }, "mdi-material-ui": { - "version": "5.8.0", - "resolved": "https://registry.npmjs.org/mdi-material-ui/-/mdi-material-ui-5.8.0.tgz", - "integrity": "sha512-KixZVfNg0ejURv9CIliB1M3kl4Soe6f6yAFjFNsoYPMvGGw1AhnQKGZ3EOEKOIdp2X9YeZFOaO+i9e4ZHUrpNA==" + "version": "6.13.0", + "resolved": "https://registry.npmjs.org/mdi-material-ui/-/mdi-material-ui-6.13.0.tgz", + "integrity": "sha512-ZMHVKH636QcCIX6NRoTsCd9fS/vRFZS4eSXra8vU48HW4JEVFxru5rrAJyFEJEibo0xSnz/+EqDMNBKiVW0kyg==" }, "meant": { "version": "1.0.1", @@ -10849,20 +10854,6 @@ } } }, - "react-router-dom": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-5.1.2.tgz", - "integrity": "sha512-7BPHAaIwWpZS074UKaw1FjVdZBSVWEk8IuDXdB+OkLb8vd/WRQIpA4ag9WQk61aEfQs47wHyjWUoUGGZxpQXew==", - "requires": { - "@babel/runtime": "^7.1.2", - "history": "^4.9.0", - "loose-envify": "^1.3.1", - "prop-types": "^15.6.2", - "react-router": "5.1.2", - "tiny-invariant": "^1.0.2", - "tiny-warning": "^1.0.0" - } - }, "react-select": { "version": "3.0.8", "resolved": "https://registry.npmjs.org/react-select/-/react-select-3.0.8.tgz", diff --git a/package.json b/package.json index e4dcb85..1ee00d3 100644 --- a/package.json +++ b/package.json @@ -61,12 +61,12 @@ "html-webpack-plugin": "^3.2.0", "husky": "^4.2.3", "jest": "^25.1.0", + "mdi-material-ui": "^6.13.0", "prop-types": "^15.7.2", "raf": "^3.4.1", "react": "^16.13.1", "react-dom": "^16.13.1", "react-router": "^5.1.2", - "react-router-dom": "^5.1.2", "rimraf": "^3.0.2", "webpack": "^4.42.0", "webpack-cli": "^3.3.11", diff --git a/package/config/eslintrc.js b/package/config/eslintrc.js index 4fee098..e18d1dd 100644 --- a/package/config/eslintrc.js +++ b/package/config/eslintrc.js @@ -15,6 +15,7 @@ module.exports = { } }, rules: { + "id-length": ["error", { exceptions: ["t", "_"] }], "jsx-a11y/label-has-for": "off", "node/no-missing-import": "off", "node/no-missing-require": "off", diff --git a/package/config/webpack.common.js b/package/config/webpack.common.js index 8204115..ae4e08c 100644 --- a/package/config/webpack.common.js +++ b/package/config/webpack.common.js @@ -1,6 +1,7 @@ const fs = require("fs"); const path = require("path"); const { CleanWebpackPlugin } = require("clean-webpack-plugin"); +const CopyWebpackPlugin = require("copy-webpack-plugin"); const HtmlWebPackPlugin = require("html-webpack-plugin"); const Dotenv = require("dotenv-webpack"); @@ -21,7 +22,6 @@ const resolvePathFromRoot = (relativePath) => ( module.exports = { output: { - // path: path.resolve(__dirname, "dist"), path: resolvePathFromRoot("dist"), publicPath: "/", chunkFilename: "[chunkhash]-[name].bundle.js", @@ -99,6 +99,9 @@ module.exports = { systemvars: true }), new CleanWebpackPlugin(), + new CopyWebpackPlugin([ + { from: "public" } + ]), new HtmlWebPackPlugin({ template: "./src/index.html" }) diff --git a/package/package-lock.json b/package/package-lock.json index eaf640a..0e5b356 100644 --- a/package/package-lock.json +++ b/package/package-lock.json @@ -3521,6 +3521,11 @@ } } }, + "clsx": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-1.1.0.tgz", + "integrity": "sha512-3avwM37fSK5oP6M5rQ9CNe99lwxhXDOeSWVPAOYF6OazUTgZCMb0yWlJpmdD74REy1gkEaFiub2ULv4fq9GUhA==" + }, "co": { "version": "4.6.0", "resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz", @@ -3698,6 +3703,55 @@ "resolved": "https://registry.npmjs.org/copy-descriptor/-/copy-descriptor-0.1.1.tgz", "integrity": "sha1-Z29us8OZl8LuGsOpJP1hJHSPV40=" }, + "copy-webpack-plugin": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/copy-webpack-plugin/-/copy-webpack-plugin-5.1.1.tgz", + "integrity": "sha512-P15M5ZC8dyCjQHWwd4Ia/dm0SgVvZJMYeykVIVYXbGyqO4dWB5oyPHp9i7wjwo5LhtlhKbiBCdS2NvM07Wlybg==", + "requires": { + "cacache": "^12.0.3", + "find-cache-dir": "^2.1.0", + "glob-parent": "^3.1.0", + "globby": "^7.1.1", + "is-glob": "^4.0.1", + "loader-utils": "^1.2.3", + "minimatch": "^3.0.4", + "normalize-path": "^3.0.0", + "p-limit": "^2.2.1", + "schema-utils": "^1.0.0", + "serialize-javascript": "^2.1.2", + "webpack-log": "^2.0.0" + }, + "dependencies": { + "globby": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/globby/-/globby-7.1.1.tgz", + "integrity": "sha1-+yzP+UAfhgCUXfral0QMypcrhoA=", + "requires": { + "array-union": "^1.0.1", + "dir-glob": "^2.0.0", + "glob": "^7.1.2", + "ignore": "^3.3.5", + "pify": "^3.0.0", + "slash": "^1.0.0" + } + }, + "ignore": { + "version": "3.3.10", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-3.3.10.tgz", + "integrity": "sha512-Pgs951kaMm5GXP7MOvxERINe3gsaVjUWFm+UZPSq9xYriQAksyhg0csnS0KXSNRD5NmNdapXEpjxG49+AKh/ug==" + }, + "pify": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-3.0.0.tgz", + "integrity": "sha1-5aSs0sEB/fPZpNB/DbxNtJ3SgXY=" + }, + "slash": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-1.0.0.tgz", + "integrity": "sha1-xB8vbDn8FtHNF61LXYlhFK5HDVU=" + } + } + }, "core-js": { "version": "2.6.11", "resolved": "https://registry.npmjs.org/core-js/-/core-js-2.6.11.tgz", @@ -4058,6 +4112,29 @@ "randombytes": "^2.0.0" } }, + "dir-glob": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-2.2.2.tgz", + "integrity": "sha512-f9LBi5QWzIW3I6e//uxZoLBlUt9kcp66qo0sSCxL6YZKc75R1c4MFCoe/LaZiBGmgujvQdxc5Bn3QhfyvK5Hsw==", + "requires": { + "path-type": "^3.0.0" + }, + "dependencies": { + "path-type": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/path-type/-/path-type-3.0.0.tgz", + "integrity": "sha512-T2ZUsdZFHgA3u4e5PfPbjd7HDDpxPnQb5jN0SrDsjNSuVXHJqtwTnWqG0B1jZrgmJ/7lj1EmVIByWt1gxGkWvg==", + "requires": { + "pify": "^3.0.0" + } + }, + "pify": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-3.0.0.tgz", + "integrity": "sha1-5aSs0sEB/fPZpNB/DbxNtJ3SgXY=" + } + } + }, "dns-equal": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/dns-equal/-/dns-equal-1.0.0.tgz", @@ -5998,6 +6075,11 @@ "integrity": "sha1-8QdIy+dq+WS3yWyTxrzCivEgwIE=", "optional": true }, + "gud": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/gud/-/gud-1.0.0.tgz", + "integrity": "sha512-zGEOVKFM5sVPPrYs7J5/hYEw2Pof8KCyOwyhG8sAF26mCAeUFAcYPu1mwB7hhpIP29zOIBaDqwuHdLp0jvZXjw==" + }, "handle-thing": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/handle-thing/-/handle-thing-2.0.0.tgz", @@ -6087,6 +6169,19 @@ "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz", "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==" }, + "history": { + "version": "4.10.1", + "resolved": "https://registry.npmjs.org/history/-/history-4.10.1.tgz", + "integrity": "sha512-36nwAD620w12kuzPAsyINPWJqlNbij+hpK1k9XRloDtym8mxzGYl2c17LnV6IAGB2Dmg4tEa7G7DlawS0+qjew==", + "requires": { + "@babel/runtime": "^7.1.2", + "loose-envify": "^1.2.0", + "resolve-pathname": "^3.0.0", + "tiny-invariant": "^1.0.2", + "tiny-warning": "^1.0.0", + "value-equal": "^1.0.1" + } + }, "hmac-drbg": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/hmac-drbg/-/hmac-drbg-1.0.1.tgz", @@ -6180,6 +6275,14 @@ } } }, + "html-parse-stringify2": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/html-parse-stringify2/-/html-parse-stringify2-2.0.1.tgz", + "integrity": "sha1-3FZwtyksoVi3vJFsmmc1rIhyg0o=", + "requires": { + "void-elements": "^2.0.1" + } + }, "html-webpack-plugin": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/html-webpack-plugin/-/html-webpack-plugin-3.2.0.tgz", @@ -6322,6 +6425,37 @@ "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-1.1.1.tgz", "integrity": "sha512-SEQu7vl8KjNL2eoGBLF3+wAjpsNfA9XMlXAYj/3EdaNfAlxKthD1xjEQfGOUhllCGGJVNY34bRr6lPINhNjyZw==" }, + "i18next": { + "version": "19.3.4", + "resolved": "https://registry.npmjs.org/i18next/-/i18next-19.3.4.tgz", + "integrity": "sha512-ef7AxxutzdhBsBNugE9jgqsbwesG1muJOtZ9ZrPARPs/jXegViTp4+8JCeMp8BAyTIo1Zn0giqc8+2UpqFjU0w==", + "requires": { + "@babel/runtime": "^7.3.1" + } + }, + "i18next-browser-languagedetector": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/i18next-browser-languagedetector/-/i18next-browser-languagedetector-4.0.2.tgz", + "integrity": "sha512-AK4IZ3XST4HIKShgpB2gOFeDPrMOnZx56GLA6dGo/8rvkiczIlq05lV8w77c3ShEZxtTZeUVRI4Q/cBFFVXS/w==", + "requires": { + "@babel/runtime": "^7.5.5" + } + }, + "i18next-fetch-backend": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/i18next-fetch-backend/-/i18next-fetch-backend-2.2.0.tgz", + "integrity": "sha512-HodOCr4fezjMgJwWnOR/JUotdbM1onXdnB6Y+XDgDpXX58SkZXcyz6VmmUGc/8XMxFzq3162Qs2vO+SlO4TCFw==" + }, + "i18next-multiload-backend-adapter": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/i18next-multiload-backend-adapter/-/i18next-multiload-backend-adapter-1.0.0.tgz", + "integrity": "sha512-rZd/Qmr7KkGktVgJa78GPLXEnd51OyB2I9qmbI/mXKPm3MWbXwplIApqmZgxkPC9ce+b8Jnk227qX62W9SaLPQ==" + }, + "i18next-sprintf-postprocessor": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/i18next-sprintf-postprocessor/-/i18next-sprintf-postprocessor-0.2.2.tgz", + "integrity": "sha1-LkCfEENXk4Jpi2otpwzapVHWfqQ=" + }, "iconv-lite": { "version": "0.4.24", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", @@ -8854,6 +8988,16 @@ "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.0.tgz", "integrity": "sha1-z8RcN+nsDY8KDsPdTvf3w6vjklY=" }, + "mini-create-react-context": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/mini-create-react-context/-/mini-create-react-context-0.3.2.tgz", + "integrity": "sha512-2v+OeetEyliMt5VHMXsBhABoJ0/M4RCe7fatd/fBy6SMiKazUSEt3gxxypfnk2SHMkdBYvorHRoQxuGoiwbzAw==", + "requires": { + "@babel/runtime": "^7.4.0", + "gud": "^1.0.0", + "tiny-warning": "^1.0.2" + } + }, "minimalistic-assert": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz", @@ -9961,11 +10105,90 @@ "@apollo/react-ssr": "^3.1.3" } }, + "react-fast-compare": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/react-fast-compare/-/react-fast-compare-2.0.4.tgz", + "integrity": "sha512-suNP+J1VU1MWFKcyt7RtjiSWUjvidmQSlqu+eHslq+342xCbGTYmC0mEhPCOHxlW0CywylOC1u2DFAT+bv4dBw==" + }, + "react-helmet": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/react-helmet/-/react-helmet-5.2.1.tgz", + "integrity": "sha512-CnwD822LU8NDBnjCpZ4ySh8L6HYyngViTZLfBBb3NjtrpN8m49clH8hidHouq20I51Y6TpCTISCBbqiY5GamwA==", + "requires": { + "object-assign": "^4.1.1", + "prop-types": "^15.5.4", + "react-fast-compare": "^2.0.2", + "react-side-effect": "^1.1.0" + } + }, + "react-i18next": { + "version": "11.3.4", + "resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-11.3.4.tgz", + "integrity": "sha512-IRZMD7PAM3C+fJNzRbyLNi1ZD0kc3Z3obBspJjEl+9H+ME41PhVor3BpdIqv/Rm7lUoGhMjmpu42J45ooJ61KA==", + "requires": { + "@babel/runtime": "^7.3.1", + "html-parse-stringify2": "2.0.1" + } + }, "react-is": { "version": "16.13.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==" }, + "react-router": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-5.1.2.tgz", + "integrity": "sha512-yjEuMFy1ONK246B+rsa0cUam5OeAQ8pyclRDgpxuSCrAlJ1qN9uZ5IgyKC7gQg0w8OM50NXHEegPh/ks9YuR2A==", + "requires": { + "@babel/runtime": "^7.1.2", + "history": "^4.9.0", + "hoist-non-react-statics": "^3.1.0", + "loose-envify": "^1.3.1", + "mini-create-react-context": "^0.3.0", + "path-to-regexp": "^1.7.0", + "prop-types": "^15.6.2", + "react-is": "^16.6.0", + "tiny-invariant": "^1.0.2", + "tiny-warning": "^1.0.0" + }, + "dependencies": { + "isarray": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-0.0.1.tgz", + "integrity": "sha1-ihis/Kmo9Bd+Cav8YDiTmwXR7t8=" + }, + "path-to-regexp": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-1.8.0.tgz", + "integrity": "sha512-n43JRhlUKUAlibEJhPeir1ncUID16QnEjNpwzNdO3Lm4ywrBpBZ5oLD0I6br9evr1Y9JTqwRtAh7JLoOzAQdVA==", + "requires": { + "isarray": "0.0.1" + } + } + } + }, + "react-router-dom": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-5.1.2.tgz", + "integrity": "sha512-7BPHAaIwWpZS074UKaw1FjVdZBSVWEk8IuDXdB+OkLb8vd/WRQIpA4ag9WQk61aEfQs47wHyjWUoUGGZxpQXew==", + "requires": { + "@babel/runtime": "^7.1.2", + "history": "^4.9.0", + "loose-envify": "^1.3.1", + "prop-types": "^15.6.2", + "react-router": "5.1.2", + "tiny-invariant": "^1.0.2", + "tiny-warning": "^1.0.0" + } + }, + "react-side-effect": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/react-side-effect/-/react-side-effect-1.2.0.tgz", + "integrity": "sha512-v1ht1aHg5k/thv56DRcjw+WtojuuDHFUgGfc+bFHOWsF4ZK6C2V57DO0Or0GPsg6+LSTE0M6Ry/gfzhzSwbc5w==", + "requires": { + "shallowequal": "^1.0.1" + } + }, "read-pkg": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/read-pkg/-/read-pkg-2.0.0.tgz", @@ -10301,6 +10524,11 @@ "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-3.0.0.tgz", "integrity": "sha1-six699nWiBvItuZTM17rywoYh0g=" }, + "resolve-pathname": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/resolve-pathname/-/resolve-pathname-3.0.0.tgz", + "integrity": "sha512-C7rARubxI8bXFNB/hqcp/4iUeIXJhJZvFPFPiSPRnhU5UPxzMFIl+2E6yY6c4k9giDJAhtV+enfA+G89N6Csng==" + }, "resolve-url": { "version": "0.2.1", "resolved": "https://registry.npmjs.org/resolve-url/-/resolve-url-0.2.1.tgz", @@ -10572,6 +10800,11 @@ "safe-buffer": "^5.0.1" } }, + "shallowequal": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/shallowequal/-/shallowequal-1.1.0.tgz", + "integrity": "sha512-y0m1JoUZSlPAjXVtPPW70aZWfIL/dSP7AFkRnniLCrK/8MDKog3TySTBmckD+RObVxH0v4Tox67+F14PdED2oQ==" + }, "shebang-command": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-1.2.0.tgz", @@ -11292,6 +11525,16 @@ "setimmediate": "^1.0.4" } }, + "tiny-invariant": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.1.0.tgz", + "integrity": "sha512-ytxQvrb1cPc9WBEI/HSeYYoGD0kWnGEOR8RY6KomWLBVhqz0RgTwVO9dLrGz7dC+nN9llyI7OKAgRq8Vq4ZBSw==" + }, + "tiny-warning": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/tiny-warning/-/tiny-warning-1.0.3.tgz", + "integrity": "sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA==" + }, "tmp": { "version": "0.0.33", "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.0.33.tgz", @@ -11715,6 +11958,11 @@ "resolved": "https://registry.npmjs.org/validator/-/validator-12.2.0.tgz", "integrity": "sha512-jJfE/DW6tIK1Ek8nCfNFqt8Wb3nzMoAbocBF6/Icgg1ZFSBpObdnwVY2jQj6qUqzhx5jc71fpvBWyLGO7Xl+nQ==" }, + "value-equal": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/value-equal/-/value-equal-1.0.1.tgz", + "integrity": "sha512-NOJ6JZCAWr0zlxZt+xqCHNTEKOsrks2HQd4MqhP1qy4z1SkbEP467eNx6TgDKXMvUOb+OENfJCZwM+16n7fRfw==" + }, "vary": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", @@ -11735,6 +11983,11 @@ "resolved": "https://registry.npmjs.org/vm-browserify/-/vm-browserify-1.1.2.tgz", "integrity": "sha512-2ham8XPWTONajOR0ohOKOHXkm3+gaBmGut3SRuu75xLd/RRaY6vqgh8NBYYk7+RW3u5AtzPQZG8F10LHkl0lAQ==" }, + "void-elements": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/void-elements/-/void-elements-2.0.1.tgz", + "integrity": "sha1-wGavtYK7HLQSjWDqkjkulNXp2+w=" + }, "w3c-hr-time": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/w3c-hr-time/-/w3c-hr-time-1.0.2.tgz", diff --git a/package/package.json b/package/package.json index daff8d8..4c30661 100644 --- a/package/package.json +++ b/package/package.json @@ -57,6 +57,8 @@ "babel-loader": "~8.0.6", "babel-plugin-transform-define": "~2.0.0", "clean-webpack-plugin": "^3.0.0", + "clsx": "^1.1.0", + "copy-webpack-plugin": "^5.1.1", "dotenv-webpack": "^1.7.0", "envalid": "^6.0.1", "eslint": "^6.8.0", @@ -72,11 +74,19 @@ "graphql-tag": "~2.10.3", "html-loader": "~0.5.5", "html-webpack-plugin": "~3.2.0", + "i18next": "^19.3.4", + "i18next-browser-languagedetector": "^4.0.2", + "i18next-fetch-backend": "^2.2.0", + "i18next-multiload-backend-adapter": "^1.0.0", + "i18next-sprintf-postprocessor": "^0.2.2", "jest": "^25.1.0", "oidc-client": "~1.10.1", "prop-types": "^15.7.2", "raf": "^3.4.1", "react-apollo": "~3.1.3", + "react-helmet": "^5.2.1", + "react-i18next": "^11.3.4", + "react-router-dom": "^5.1.2", "rimraf": "~3.0.2", "unfetch": "^4.1.0", "webpack": "~4.42.0", @@ -86,31 +96,11 @@ "whatwg-fetch": "^3.0.0" }, "peerDependencies": { - "@material-ui/core": "~4.9.7", - "@material-ui/lab": "~4.0.0-alpha.46", - "@reactioncommerce/catalyst": "~1.19.1", - "react": "~16.13.1", - "react-dom": "~16.13.1", - "react-router-dom": "~5.1.2" - }, - "eslintConfig": { - "extends": "@reactioncommerce", - "globals": { - "jasmine": true, - "jest/globals": true - }, - "settings": { - "react": { - "version": "detect" - } - }, - "rules": { - "jsx-a11y/label-has-for": "off", - "node/no-missing-import": "off", - "node/no-missing-require": "off", - "node/no-unsupported-features/es-syntax": "off", - "node/no-unpublished-import": "off", - "node/no-unpublished-require": "off" - } + "@material-ui/core": ">=4.9.7", + "@material-ui/lab": ">=4.0.0-alpha.46", + "@reactioncommerce/catalyst": ">=1.19.1", + "mdi-material-ui": ">=6.13.0", + "react": ">=16.13.1", + "react-dom": ">=16.13.1" } } diff --git a/package/scripts/devServer.js b/package/scripts/devServer.js index 8a2715f..32b61bf 100755 --- a/package/scripts/devServer.js +++ b/package/scripts/devServer.js @@ -27,7 +27,6 @@ const options = { hot: true, inline: true, open: true, - contentBase: "build", stats: { colors: true }, historyApiFallback: true }; diff --git a/package/src/App/App.js b/package/src/App/App.js index 342da82..2cf5a8e 100644 --- a/package/src/App/App.js +++ b/package/src/App/App.js @@ -1,4 +1,4 @@ -import React from "react"; +import React, { Suspense } from "react"; import PropTypes from "prop-types"; import { ThemeProvider } from "@material-ui/core"; import { defaultTheme } from "@reactioncommerce/catalyst"; @@ -24,20 +24,22 @@ function App(props) { const DashboardComponent = DashboardComponentProp || Dashboard; return ( - - - - - - - - - - - + + + + + + + + + + + + + ); } diff --git a/package/src/AppBar/AppBar.js b/package/src/AppBar/AppBar.js new file mode 100644 index 0000000..680ab2b --- /dev/null +++ b/package/src/AppBar/AppBar.js @@ -0,0 +1,99 @@ +import React, { Children, useContext } from "react"; +import PropTypes from "prop-types"; +import clsx from "clsx"; +import { + AppBar as MuiAppBar, + Box, + Hidden, + IconButton, + Toolbar, + Typography, + makeStyles +} from "@material-ui/core"; +import MenuIcon from "mdi-material-ui/Menu"; +import ArrowLeftIcon from "mdi-material-ui/ArrowLeft"; +import UIContext from "../context/UIContext"; + +const useStyles = makeStyles((theme) => ({ + action: { + marginLeft: theme.spacing() + }, + navigationDrawerOpen: { + ...theme.mixins.leadingPaddingWhenPrimaryDrawerIsOpen + }, + detailDrawerOpen: { + ...theme.mixins.trailingPaddingWhenDetailDrawerIsOpen + }, + title: { + flex: 1 + } +})); + +/** + * An AppBar for the main content area that provides a place for a title, + * actions to the right, and a menu button for opening and closing the sidebar drawer menu + * @param {Object} props Component props + * @returns {React.Component} A react component + */ +function AppBar({ children, title, onBackButtonClick, shouldShowBackButton = true }) { + const classes = useStyles(); + const { + isDetailDrawerOpen, + isMobile, + isNavigationDrawerOpen, + onToggleNavigationDrawer + } = useContext(UIContext); + + const toolbarClassName = clsx({ + // Add padding to the left when the primary sidebar is open, + // only if we're on desktop. On mobile the sidebar floats over + // the content like a modal that's docked to either the left + // or right side of the screen. + [classes.navigationDrawerOpen]: isNavigationDrawerOpen && !isMobile, + + // Add padding to the right when the detail sidebar is open. + // Omit on mobile as the sidebar will float over content. + [classes.detailDrawerOpen]: isDetailDrawerOpen && !isMobile + }); + + return ( + + + + + + + + {(shouldShowBackButton && onBackButtonClick) && ( + + + + + + )} + + {title} + + {Children.map(children, (child) => ( +
+ {child} +
+ ))} +
+
+ ); +} + +AppBar.propTypes = { + children: PropTypes.node, + onBackButtonClick: PropTypes.func, + onToggleDrawerOpen: PropTypes.func, + shouldShowBackButton: PropTypes.bool, + title: PropTypes.node +}; + +export default AppBar; diff --git a/package/src/AppBar/AppBar.test.js b/package/src/AppBar/AppBar.test.js new file mode 100644 index 0000000..1e185d8 --- /dev/null +++ b/package/src/AppBar/AppBar.test.js @@ -0,0 +1,12 @@ +import React from "react"; +import { render } from "../test-utils"; +import AppBar from "./AppBar"; + +test("render the AppBar with a title and children", () => { + const { asFragment } = render(( + + Sub Component + + )); + expect(asFragment()).toMatchSnapshot(); +}); diff --git a/package/src/AppBar/__snapshots__/AppBar.test.js.snap b/package/src/AppBar/__snapshots__/AppBar.test.js.snap new file mode 100644 index 0000000..8786bc9 --- /dev/null +++ b/package/src/AppBar/__snapshots__/AppBar.test.js.snap @@ -0,0 +1,26 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`render the AppBar with a title and children 1`] = ` + +
+
+

+ Title +

+
+ + Sub Component + +
+
+
+
+`; diff --git a/package/src/AppBar/index.js b/package/src/AppBar/index.js new file mode 100644 index 0000000..cc81140 --- /dev/null +++ b/package/src/AppBar/index.js @@ -0,0 +1 @@ +export { default } from "./AppBar"; diff --git a/package/src/Blocks/Blocks.js b/package/src/Blocks/Blocks.js new file mode 100644 index 0000000..b2a51c4 --- /dev/null +++ b/package/src/Blocks/Blocks.js @@ -0,0 +1,36 @@ +import React from "react"; +import PropTypes from "prop-types"; +import { BlockComponents } from "../lib/core/blocks"; + +/** + * @name Blocks + * @method + * @memberof Components/Helpers + * @param {Object} props Component props + * @returns {React.Component} returns a React component containing requested blocks + */ +export function Blocks(props) { + const { + region, + children, + blockProps + } = props; + const blocks = BlockComponents[region]; + if (!blocks) return null; + + const elements = blocks.map((BlockComponent, key) => ( + + )); + + return children(elements); +} + +Blocks.defaultProps = { + children: (blocks) => blocks +}; + +Blocks.propTypes = { + children: PropTypes.func.isRequired +}; + +export default Blocks; diff --git a/package/src/Blocks/Blocks.test.js b/package/src/Blocks/Blocks.test.js new file mode 100644 index 0000000..3b9af82 --- /dev/null +++ b/package/src/Blocks/Blocks.test.js @@ -0,0 +1,26 @@ +/* eslint-disable react/display-name */ +/* eslint-disable react/prop-types */ +/* eslint-disable react/no-multi-comp */ +import React from "react"; +import { render } from "../test-utils"; +import { registerBlock } from "../lib/core/blocks"; +import Blocks from "./Blocks"; + +registerBlock({ + name: "block-1", + region: "test", + Component: () => (Block 1) +}); + +registerBlock({ + name: "block-2", + region: "test", + Component: () => (Block 2) +}); + +test("render the Blocks region named 'test' with two registered blocks", () => { + const { asFragment } = render(( + + )); + expect(asFragment()).toMatchSnapshot(); +}); diff --git a/package/src/Blocks/__snapshots__/Blocks.test.js.snap b/package/src/Blocks/__snapshots__/Blocks.test.js.snap new file mode 100644 index 0000000..10db80a --- /dev/null +++ b/package/src/Blocks/__snapshots__/Blocks.test.js.snap @@ -0,0 +1,3 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`render the Blocks region named 'test' with two registered blocks 1`] = ``; diff --git a/package/src/Blocks/index.js b/package/src/Blocks/index.js new file mode 100644 index 0000000..8ec6cd2 --- /dev/null +++ b/package/src/Blocks/index.js @@ -0,0 +1 @@ +export { default } from "./Blocks"; diff --git a/package/src/ContentLayout/ContentLayout.js b/package/src/ContentLayout/ContentLayout.js new file mode 100644 index 0000000..3f7d0ba --- /dev/null +++ b/package/src/ContentLayout/ContentLayout.js @@ -0,0 +1,89 @@ +import React from "react"; +import PropTypes from "prop-types"; +import clsx from "clsx"; +import { makeStyles } from "@material-ui/core"; + +const useStyles = makeStyles((theme) => ({ + root: { + width: "100vw", + flexGrow: 1, + transition: "padding 225ms cubic-bezier(0, 0, 0.2, 1) 0ms" + }, + standardContent: { + maxWidth: 1140, + paddingTop: theme.mixins.toolbar.minHeight + (theme.spacing(2)), + paddingLeft: theme.spacing(2), + paddingRight: theme.spacing(2), + paddingBottom: theme.spacing(2), + margin: "0 auto" + }, + wideContent: { + width: "100vw", + paddingTop: theme.mixins.toolbar.minHeight + (theme.spacing(3)), + paddingLeft: theme.spacing(3), + paddingRight: theme.spacing(3), + paddingBottom: theme.spacing(3), + flexGrow: 1, + transition: "padding 225ms cubic-bezier(0, 0, 0.2, 1) 0ms" + }, + fullContent: { + width: "100vw", + height: "100vh", + paddingTop: theme.mixins.toolbar.minHeight, + flexGrow: 1, + transition: "padding 225ms cubic-bezier(0, 0, 0.2, 1) 0ms", + overflow: "hidden" + }, + leadingDrawerOpen: { + paddingLeft: theme.dimensions.drawerWidth + theme.spacing(2) + }, + trailingDrawerOpen: { + paddingRight: theme.dimensions.detailDrawerWidth + theme.spacing(2) + }, + leadingDrawerOpenFullLayout: { + paddingLeft: theme.dimensions.drawerWidth + }, + trailingDrawerOpenFullLayout: { + paddingRight: theme.dimensions.detailDrawerWidth + } +})); + +const ContentLayout = ({ + children, + isLeadingDrawerOpen, + isTrailingDrawerOpen, + size +}) => { + const classes = useStyles(); + + return ( +
+ {children} +
+ ); +}; + +ContentLayout.propTypes = { + children: PropTypes.node, + classes: PropTypes.object, + isLeadingDrawerOpen: PropTypes.bool, + isMobile: PropTypes.bool, + isTrailingDrawerOpen: PropTypes.bool, + size: PropTypes.oneOf(["standard", "wide", "full"]) +}; + +ContentLayout.defaultProps = { + size: "standard" +}; + +export default ContentLayout; diff --git a/package/src/ContentLayout/index.js b/package/src/ContentLayout/index.js new file mode 100644 index 0000000..599b8ab --- /dev/null +++ b/package/src/ContentLayout/index.js @@ -0,0 +1 @@ +export { default } from "./ContentLayout"; diff --git a/package/src/ContentLayoutTwoColumn/ContentLayoutTwoColumn.js b/package/src/ContentLayoutTwoColumn/ContentLayoutTwoColumn.js new file mode 100644 index 0000000..272cec6 --- /dev/null +++ b/package/src/ContentLayoutTwoColumn/ContentLayoutTwoColumn.js @@ -0,0 +1,179 @@ +/** + * Component provides a regions for a primary (sidebar) and detail view + */ +import React, { useContext, useState } from "react"; +import PropTypes from "prop-types"; +import clsx from "clsx"; +import { + AppBar, + Box, + Button, + Drawer, + Toolbar, + IconButton, + makeStyles +} from "@material-ui/core"; +import ChevronDownIcon from "mdi-material-ui/ChevronDown"; +import useMediaQuery from "../hooks/useMediaQuery"; +import UIContext from "../context/UIContext"; +import Blocks from "../Blocks"; + +const useStyles = makeStyles((theme) => ({ + root: { + width: "100vw", + height: "100vh", + paddingTop: theme.mixins.toolbar.minHeight, + flexGrow: 1, + transition: "padding 225ms cubic-bezier(0, 0, 0.2, 1) 0ms", + overflow: "hidden", + [`${theme.breakpoints.up("xs")} and (orientation: landscape)`]: { + paddingTop: 54 + }, + [`${theme.breakpoints.up("xs")} and (orientation: portrait)`]: { + paddingTop: 54 + }, + [theme.breakpoints.up("sm")]: { + paddingTop: theme.mixins.toolbar.minHeight + } + }, + block: { + marginBottom: theme.spacing(3) + }, + drawerButton: { + borderRadius: 0 + }, + sidebar: { + flex: "1 1 auto", + minWidth: 330, + maxWidth: 330, + height: `calc(100vh - ${theme.mixins.toolbar.minHeight}px)`, + overflowY: "auto", + borderRight: `1px solid ${theme.palette.divider}` + }, + content: { + flex: "1 1 auto", + height: `calc(100vh - ${theme.mixins.toolbar.minHeight}px)`, + overflowY: "auto", + paddingTop: theme.spacing(5) + }, + title: { + flex: 1 + }, + leadingDrawerOpen: { + paddingLeft: theme.dimensions.drawerWidth + }, + trailingDrawerOpen: { + paddingRight: theme.dimensions.detailDrawerWidth + }, + drawerPaperAnchorBottom: { + width: "100%", + height: "80%" + } +})); + +/** + * Primary/Detail layout + * @param {Object} props ComponentProps + * @returns {React.ReactElement} A react element representing the primary/detail layout + */ +function ContentLayoutTwoColumn(props) { + const [isDrawerOpen, setDrawerOpen] = useState(false); + const { + AppBarComponent, + DetailComponent, + PrimaryComponent, + children, + detailBlockRegionName, + drawerButtonTitle, + primaryBlockRegionName, + ...blockProps + } = props; + + const classes = useStyles(); + const { isPrimarySidebarOpen, isDetailDrawerOpen } = useContext(UIContext); + const isMobile = useMediaQuery("mobile"); + + const closeDrawer = () => { + setDrawerOpen(false); + }; + + return ( +
+ {AppBarComponent} + {isMobile && + <> + + + + + + + + + + + + + {PrimaryComponent || } + + + } + + {!isMobile && +
+ {PrimaryComponent || } +
+ } + +
+ {DetailComponent || } +
+
+
+ ); +} + +ContentLayoutTwoColumn.propTypes = { + AppBarComponent: PropTypes.node, + DetailComponent: PropTypes.node, + DetailContainerProps: PropTypes.object, + PrimaryComponent: PropTypes.node, + children: PropTypes.node, + detailBlockRegionName: PropTypes.string, + drawerButtonTitle: PropTypes.string, + isMobile: PropTypes.bool, + primaryBlockRegionName: PropTypes.string +}; + +ContentLayoutTwoColumn.defaultProps = { + drawerButtonTitle: "More" +}; + +export default ContentLayoutTwoColumn; diff --git a/package/src/ContentLayoutTwoColumn/index.js b/package/src/ContentLayoutTwoColumn/index.js new file mode 100644 index 0000000..682030e --- /dev/null +++ b/package/src/ContentLayoutTwoColumn/index.js @@ -0,0 +1 @@ +export { default } from "./ContentLayoutTwoColumn"; diff --git a/package/src/Dashboard/Dashboard.js b/package/src/Dashboard/Dashboard.js index 901b3b7..fd5860c 100644 --- a/package/src/Dashboard/Dashboard.js +++ b/package/src/Dashboard/Dashboard.js @@ -1,144 +1,77 @@ -import React from "react"; -import PropTypes from "prop-types"; -import { - Drawer, - Container, - List, - ListItem, - ListItemText, - Typography, - makeStyles -} from "@material-ui/core"; -import { Route, Switch, useHistory } from "react-router-dom"; -import useAuth from "../hooks/useAuth"; - -const activeClassName = "active"; +import React, { useMemo, useState } from "react"; +import { makeStyles } from "@material-ui/core"; +import CssBaseline from "@material-ui/core/CssBaseline"; +import AppBar from "../AppBar"; +import ProfileMenu from "../ProfileMenu"; +import NavigationDrawer from "../NavigationDrawer"; +import Routes from "../Routes"; +import UIContext from "../context/UIContext"; +import useMediaQuery from "../hooks/useMediaQuery"; +import useRoutes from "../hooks/useRoutes"; const useStyles = makeStyles((theme) => ({ - closeButton: { - "color": theme.palette.colors.white, - "backgroundColor": theme.palette.colors.darkBlue500, - "&:hover": { - "backgroundColor": theme.palette.colors.darkBlue600, - // Reset on touch devices, it doesn't add specificity - "@media (hover: none)": { - backgroundColor: theme.palette.colors.darkBlue500 - } - } - }, - icon: { - minWidth: 32, - display: "flex", - justifyContent: "center", - marginRight: theme.spacing(2), - color: theme.palette.colors.coolGrey300 - }, - iconActive: { - color: theme.palette.text.active - }, - shopLogo: { - flex: 1, - marginRight: theme.spacing(2) - }, - toolbar: { - paddingLeft: theme.spacing(2), - paddingRight: theme.spacing(2) - }, - listItem: { - "paddingLeft": theme.spacing(2), - "paddingRight": theme.spacing(2), - "&:hover": { - backgroundColor: theme.palette.colors.darkBlue600, - transition: `background-color ${theme.transitions.duration.shortest} ${theme.transitions.easing.easeInOut}` - } - }, - listItemText: { - paddingLeft: 0, - fontSize: theme.typography.fontSize, - lineHeight: 1.5, - letterSpacing: 0.5, - color: theme.palette.colors.black15 + container: { + display: "flex" }, - listItemNested: { - "paddingTop": 0, - "paddingBottom": 0, - "paddingLeft": theme.spacing(8), - "&:hover": { - backgroundColor: theme.palette.colors.darkBlue600, - transition: `background-color ${theme.transitions.duration.shortest} ${theme.transitions.easing.easeInOut}` - } - }, - link: { - [`&.${activeClassName} span`]: { - color: theme.palette.text.secondaryActive, - fontWeight: theme.typography.fontWeightSemiBold - }, - [`&.${activeClassName} $icon`]: { - color: theme.palette.text.active - } + leftSidebarOpen: { + ...theme.mixins.leadingPaddingWhenPrimaryDrawerIsOpen } })); /** - * App component - * @param {Object} props Props - * @returns {React.ReactElement} App + * Main dashboard layout + * @returns {React.ReactElement} A react element representing the main dashboard */ -function Dashboard({ title, plugins }) { - const history = useHistory(); +function Dashboard() { const classes = useStyles(); - const { viewer, logout } = useAuth(); + const isMobile = useMediaQuery("mobile"); + const routes = useRoutes(); + const [isDetailDrawerOpen, setDetailDrawerOpen] = useState(false); + const [isNavigationDrawerOpen, setNavigationDrawerOpen] = useState(false); + + const onToggleNavigationDrawer = () => { + setNavigationDrawerOpen((prevValue) => !prevValue); + }; + + const onToggleDetailDrawer = () => { + setDetailDrawerOpen((prevValue) => !prevValue); + }; + + const onCloseDetailDrawer = () => { + setDetailDrawerOpen(false); + }; + + const onCloseNavigationDrawer = () => { + setNavigationDrawerOpen(false); + }; + + const contextValue = useMemo(() => ({ + isDetailDrawerOpen, + isMobile, + isNavigationDrawerOpen: (isMobile && isNavigationDrawerOpen) || isNavigationDrawerOpen, + onCloseNavigationDrawer, + onToggleNavigationDrawer, + onCloseDetailDrawer, + onToggleDetailDrawer, + setDetailDrawerOpen, + setNavigationDrawerOpen + }), [isDetailDrawerOpen, isMobile, isNavigationDrawerOpen]); return ( - - - - {plugins.map(({ route, navTitle }, index) => ( - history.push(route)} - className={classes.listItem} - > - - - ))} - logout()} - className={classes.listItem} - > - - - - -
- {title || "Reaction Admin"} - Welcome {viewer && viewer.primaryEmailAddress} - - {plugins.map(({ MainComponent, route }, index) => ( - - ))} - + + +
+ + + + +
- +
); } -Dashboard.propTypes = { - plugins: PropTypes.arrayOf(PropTypes.object), - title: PropTypes.string -}; - export default Dashboard; diff --git a/package/src/Dashboard/Dashboard.test.js b/package/src/Dashboard/Dashboard.test.js index be72a9f..b66c382 100644 --- a/package/src/Dashboard/Dashboard.test.js +++ b/package/src/Dashboard/Dashboard.test.js @@ -1,5 +1,9 @@ +/* eslint-disable react/display-name */ +/* eslint-disable react/prop-types */ +/* eslint-disable react/no-multi-comp */ import React from "react"; import { render } from "../test-utils"; +import { registerRoute } from "../lib/core/routes"; import Dashboard from "./Dashboard"; jest.mock("../hooks/useAuth", () => ({ @@ -12,21 +16,21 @@ jest.mock("../hooks/useAuth", () => ({ }) })); -const mockPlugins = [ - { - route: "/test-1", - navTitle: "Test 1", - // eslint-disable-next-line react/display-name - MainComponent: () => (Route Test 1) - } -]; +jest.mock("../ShopLogo", () => ({ + __esModule: true, + default: () => null +})); + +registerRoute({ + group: "navigation", + path: "/test", + navigationItemTitle: "Test", + MainComponent: () => (Main Component) +}); -test("should be true", () => { +test("should render the Dashboard component with a single route", () => { const { asFragment } = render(( - + )); expect(asFragment()).toMatchSnapshot(); }); diff --git a/package/src/Dashboard/__snapshots__/Dashboard.test.js.snap b/package/src/Dashboard/__snapshots__/Dashboard.test.js.snap index d3ce82e..d4907e8 100644 --- a/package/src/Dashboard/__snapshots__/Dashboard.test.js.snap +++ b/package/src/Dashboard/__snapshots__/Dashboard.test.js.snap @@ -1,45 +1,82 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`should be true 1`] = ` +exports[`should render the Dashboard component with a single route 1`] = `
-
-
    +
    -
    - Test 1 +
    + +
    +
    + +
    +
    +
    +
    +
    +
    +
      - Sign Out -
      + class="MuiListItemIcon-root makeStyles-icon-104" + /> +
      @@ -47,20 +84,6 @@ exports[`should be true 1`] = `
    -
    -

    - Test App -

    -

    - Welcome -

    -
`; diff --git a/package/src/GenericErrorBoundary/GenericErrorBoundary.js b/package/src/GenericErrorBoundary/GenericErrorBoundary.js new file mode 100644 index 0000000..43807fe --- /dev/null +++ b/package/src/GenericErrorBoundary/GenericErrorBoundary.js @@ -0,0 +1,36 @@ +import { Component } from "react"; +import PropTypes from "prop-types"; +import Logger from "../utils/logger"; + +class GenericErrorBoundary extends Component { + static getDerivedStateFromError() { + // Update state so the next render will show the fallback UI. + return { hasError: true }; + } + + static propTypes = { + children: PropTypes.node, + fallbackComponent: PropTypes.node + } + + constructor(props) { + super(props); + this.state = { hasError: false }; + } + + componentDidCatch(error, info) { + Logger.error(info, error); + } + + render() { + const { children, fallbackComponent } = this.props; + if (this.state.hasError) { + // Render the fallback if there's an error + return fallbackComponent; + } + + return children; + } +} + +export default GenericErrorBoundary; diff --git a/package/src/GenericErrorBoundary/index.js b/package/src/GenericErrorBoundary/index.js new file mode 100644 index 0000000..c515153 --- /dev/null +++ b/package/src/GenericErrorBoundary/index.js @@ -0,0 +1 @@ +export { default } from "./GenericErrorBoundary"; diff --git a/package/src/NavigationDrawer/NavigationDrawer.js b/package/src/NavigationDrawer/NavigationDrawer.js new file mode 100644 index 0000000..4a51062 --- /dev/null +++ b/package/src/NavigationDrawer/NavigationDrawer.js @@ -0,0 +1,179 @@ +import React, { useContext } from "react"; +import { useHistory, useRouteMatch } from "react-router-dom"; +import { + AppBar, + Box, + Drawer, + Fab, + Hidden, + List, + ListItem, + ListItemIcon, + ListItemText, + Toolbar, + makeStyles +} from "@material-ui/core"; +import CloseIcon from "mdi-material-ui/Close"; +import { useTranslation } from "react-i18next"; +import ShopLogo from "../ShopLogo"; +import UIContext from "../context/UIContext"; +import useRoutes from "../hooks/useRoutes"; + +const useStyles = makeStyles((theme) => ({ + closeButton: { + "color": theme.palette.colors.white, + "backgroundColor": theme.palette.colors.darkBlue500, + "&:hover": { + "backgroundColor": theme.palette.colors.darkBlue600, + // Reset on touch devices, it doesn't add specificity + "@media (hover: none)": { + backgroundColor: theme.palette.colors.darkBlue500 + } + } + }, + icon: { + minWidth: 32, + display: "flex", + justifyContent: "center", + marginRight: theme.spacing(2), + color: theme.palette.colors.coolGrey300 + }, + iconActive: { + color: theme.palette.text.active + }, + toolbar: { + paddingLeft: theme.spacing(2), + paddingRight: theme.spacing(2) + }, + listItemRoot: { + "paddingLeft": theme.spacing(2), + "paddingRight": theme.spacing(2), + "&$focusVisible": { + color: theme.palette.text.secondaryActive, + fontWeight: theme.typography.fontWeightSemiBold, + backgroundColor: theme.palette.colors.darkBlue600 + }, + "&$selected, &$selected:hover": { + color: theme.palette.text.secondaryActive, + fontWeight: theme.typography.fontWeightSemiBold, + backgroundColor: theme.palette.colors.darkBlue600 + }, + "&$selected $icon, &$selected:hover $icon": { + color: theme.palette.text.active + } + }, + listItemText: { + paddingLeft: 0, + fontSize: theme.typography.fontSize, + lineHeight: 1.5, + letterSpacing: 0.5, + color: theme.palette.colors.black15 + }, + listItemButton: { + "borderRadius": theme.shape.borderRadius, + "&:hover": { + textDecoration: "none", + backgroundColor: theme.palette.colors.darkBlue600 + } + }, + /* Pseudo-class applied to the `component`'s `focusVisibleClassName` prop if `button={true}`. */ + focusVisible: {}, + /* Pseudo-class applied to the root element if `selected={true}`. */ + selected: {} +})); + +/** + * Navigation Drawer component + * @returns {React.Component} NavigationDrawer component + */ +function NavigationDrawer() { + const classes = useStyles(); + const history = useHistory(); + const routeMatch = useRouteMatch("/:any"); + const primaryRoutes = useRoutes({ groups: ["navigation"] }); + const { t } = useTranslation(); + const { + isMobile, + isNavigationDrawerOpen, + onCloseNavigationDrawer + } = useContext(UIContext); + + let drawerProps = { + classes: { + paper: classes.drawerPaper + }, + open: true, + variant: "persistent" + }; + + if (isMobile) { + drawerProps = { + variant: "temporary", + anchor: "left", + open: isNavigationDrawerOpen, + onClose: onCloseNavigationDrawer, + ModalProps: { + keepMounted: true // Better open performance on mobile. + } + }; + } + + return ( + + + + + + + + + + + + + + + + + {primaryRoutes.map(({ + IconComponent, + href, + path, + navigationItemLabel + }) => ( + { + history.push(href || path); + onCloseNavigationDrawer(); + }} + > + + {IconComponent && } + + + {t(navigationItemLabel)} + + + ))} + + + ); +} + +export default NavigationDrawer; diff --git a/package/src/NavigationDrawer/index.js b/package/src/NavigationDrawer/index.js new file mode 100644 index 0000000..1bcc2d9 --- /dev/null +++ b/package/src/NavigationDrawer/index.js @@ -0,0 +1 @@ +export { default } from "./NavigationDrawer"; diff --git a/package/src/Profile/Profile.js b/package/src/Profile/Profile.js new file mode 100644 index 0000000..ec85e0b --- /dev/null +++ b/package/src/Profile/Profile.js @@ -0,0 +1,22 @@ +import React from "react"; +import { Box } from "@material-ui/core"; +import Blocks from "../Blocks"; + +/** + * @summary Profile React component + * @param {Object} props React props + * @return {React.Node} React node + */ +export default function ProfileMenu() { + return ( + + {(blocks) => + blocks.map((block, index) => ( + + {block} + + )) + } + + ); +} diff --git a/package/src/Profile/index.js b/package/src/Profile/index.js new file mode 100644 index 0000000..2623c86 --- /dev/null +++ b/package/src/Profile/index.js @@ -0,0 +1 @@ +export { default } from "./Profile"; diff --git a/package/src/ProfileMenu/ProfileMenu.js b/package/src/ProfileMenu/ProfileMenu.js new file mode 100644 index 0000000..d7300f7 --- /dev/null +++ b/package/src/ProfileMenu/ProfileMenu.js @@ -0,0 +1,61 @@ +import React, { Fragment, useState } from "react"; +import PropTypes from "prop-types"; +import { useHistory } from "react-router-dom"; +import i18next from "i18next"; +import ButtonBase from "@material-ui/core/ButtonBase"; +import Menu from "@material-ui/core/Menu"; +import MenuItem from "@material-ui/core/MenuItem"; +import { + Avatar +} from "@material-ui/core"; +import useAuth from "../hooks/useAuth"; + +/** + * @summary ProfileMenu React component + * @param {Object} props React props + * @return {React.Node} React node + */ +function ProfileMenu(props) { + const { logout, viewer } = useAuth(); + const history = useHistory(); + const [menuAnchorEl, setMenuAnchorEl] = useState(null); + + if (!viewer) return null; + + return ( + + { + setMenuAnchorEl(event.currentTarget); + }} + > + + + + setMenuAnchorEl(null)} + > + { + setMenuAnchorEl(null); // close menu + history.push("/profile"); + }} + > + {i18next.t("admin.userAccountDropdown.profileLabel")} + + {i18next.t("accountsUI.signOut")} + + + ); +} + +ProfileMenu.propTypes = { + logout: PropTypes.func, + viewer: PropTypes.object +}; + +export default ProfileMenu; diff --git a/package/src/ProfileMenu/index.js b/package/src/ProfileMenu/index.js new file mode 100644 index 0000000..66e00e5 --- /dev/null +++ b/package/src/ProfileMenu/index.js @@ -0,0 +1 @@ +export { default } from "./ProfileMenu"; diff --git a/package/src/Routes/Routes.js b/package/src/Routes/Routes.js new file mode 100644 index 0000000..dddf35a --- /dev/null +++ b/package/src/Routes/Routes.js @@ -0,0 +1,71 @@ +import React, { useContext } from "react"; +import PropTypes from "prop-types"; +import { Switch, Route } from "react-router-dom"; +import Helmet from "react-helmet"; +import i18next from "i18next"; +import UIContext from "../context/UIContext"; +import useMediaQuery from "../hooks/useMediaQuery"; +import ContentLayout from "../ContentLayout"; + +/** + * Operator routes + * @param {Object} props ComponentProps + * @returns {Object} An object containing filtered routes + */ +function Routes({ + DefaultLayoutComponent = ContentLayout, + isExactMatch, + routes +}) { + const isMobile = useMediaQuery("mobile"); + const uiContext = useContext(UIContext); + + return ( + + {routes.map((route) => ( + { + const title = i18next.t(route.navigationItemLabel); + + if (route.LayoutComponent === null) { + return ( + <> + + + + ); + } + + const LayoutComponent = route.LayoutComponent || DefaultLayoutComponent; + + return ( + + + + + ); + }} + {...route.props} + /> + ))} + + ); +} + +Routes.propTypes = { + DefaultLayoutComponent: PropTypes.element, + isExactMatch: PropTypes.bool, + routes: PropTypes.arrayOf(PropTypes.shape({ + path: PropTypes.string, + props: PropTypes.object + })) +}; + +export default Routes; diff --git a/package/src/Routes/index.js b/package/src/Routes/index.js new file mode 100644 index 0000000..029bd89 --- /dev/null +++ b/package/src/Routes/index.js @@ -0,0 +1 @@ +export { default } from "./Routes"; diff --git a/package/src/SettingsDashboard/SettingsDashboard.js b/package/src/SettingsDashboard/SettingsDashboard.js new file mode 100644 index 0000000..81b42ea --- /dev/null +++ b/package/src/SettingsDashboard/SettingsDashboard.js @@ -0,0 +1,43 @@ +import React from "react"; +import i18next from "i18next"; +import { Switch, Route } from "react-router-dom"; +import { Container, Box } from "@material-ui/core"; +import useRoutes from "../hooks/useRoutes"; +import AppBar from "../AppBar"; +import ContentLayoutTwoColumn from "../ContentLayoutTwoColumn"; +import SettingsMenu from "../SettingsMenu"; + +/** + * @name SettingsDashboard + * @returns {React.component} a functional React component + */ +export default function SettingsDashboard() { + const settingsRoutes = useRoutes({ groups: ["settings"] }); + return ( + + } + PrimaryComponent={ + + + + } + DetailComponent={ + + + { + settingsRoutes.map((settingRoute) => ( + + )) + } + + + } + /> + ); +} diff --git a/package/src/SettingsDashboard/index.js b/package/src/SettingsDashboard/index.js new file mode 100644 index 0000000..4306e76 --- /dev/null +++ b/package/src/SettingsDashboard/index.js @@ -0,0 +1 @@ +export { default } from "./SettingsDashboard"; diff --git a/package/src/SettingsMenu/SettingsMenu.js b/package/src/SettingsMenu/SettingsMenu.js new file mode 100644 index 0000000..5ed271c --- /dev/null +++ b/package/src/SettingsMenu/SettingsMenu.js @@ -0,0 +1,76 @@ +import React from "react"; +import i18next from "i18next"; +import { + List, + ListItem, + ListItemText, + makeStyles +} from "@material-ui/core"; +import { useHistory, useRouteMatch } from "react-router-dom"; +import useRoutes from "../hooks/useRoutes"; + +const useStyles = makeStyles((theme) => ({ + listItemRoot: { + "paddingTop": theme.spacing(1), + "paddingBottom": theme.spacing(1), + "paddingLeft": theme.spacing(4), + "marginBottom": 2, + "&$focusVisible": { + color: theme.palette.colors.coolGrey500, + fontWeight: theme.typography.fontWeightSemiBold, + backgroundColor: theme.palette.colors.darkBlue100 + }, + "&$selected, &$selected:hover": { + color: theme.palette.colors.coolGrey500, + fontWeight: theme.typography.fontWeightSemiBold, + backgroundColor: theme.palette.colors.darkBlue100 + } + }, + listItemButton: { + "borderRadius": theme.shape.borderRadius, + "&:hover": { + textDecoration: "none", + backgroundColor: theme.palette.colors.darkBlue100 + } + }, + /* Pseudo-class applied to the `component`'s `focusVisibleClassName` prop if `button={true}`. */ + focusVisible: {}, + /* Pseudo-class applied to the root element if `selected={true}`. */ + selected: {} +})); + +/** + * @summary A list settings for a shop + * @returns {Node} React node + */ +export default function SettingsMenu() { + const classes = useStyles(); + const history = useHistory(); + const routeMatch = useRouteMatch("/settings/:setting"); + const settingsRoutes = useRoutes({ groups: ["settings"] }); + + return ( + + {settingsRoutes && Array.isArray(settingsRoutes) && settingsRoutes.map((setting) => ( + { + history.push(setting.path); + }} + > + + + ))} + + ); +} diff --git a/package/src/SettingsMenu/index.js b/package/src/SettingsMenu/index.js new file mode 100644 index 0000000..db8c4a7 --- /dev/null +++ b/package/src/SettingsMenu/index.js @@ -0,0 +1 @@ +export { default } from "./SettingsMenu"; diff --git a/package/src/ShopLogo/ShopLogo.js b/package/src/ShopLogo/ShopLogo.js new file mode 100644 index 0000000..52ad15b --- /dev/null +++ b/package/src/ShopLogo/ShopLogo.js @@ -0,0 +1,101 @@ +import React from "react"; +import PropTypes from "prop-types"; +import { Link } from "react-router-dom"; +import { Typography, makeStyles } from "@material-ui/core"; +import useCurrentShop from "../hooks/useCurrentShop"; +import GenericErrorBoundary from "../GenericErrorBoundary"; + +const defaultLogo = "/public/reaction-logo-circular.svg"; + +const useStyles = makeStyles((theme) => ({ + root: { + display: "flex", + alignItems: "center", + textDecoration: "none" + }, + logo: { + marginRight: theme.spacing(2) + }, + logoName: { + color: theme.palette.colors.black15 + } +})); + +/** + * ShopLogo + * @param {Object} props Component props + * @returns {Node} React component + */ +function ShopLogo({ shouldShowShopName, linkTo, size }) { + const classes = useStyles(); + + const { shop } = useCurrentShop(); + + if (!shop) { + return ( + + Reaction Commerce + {shouldShowShopName && + + Reaction Commerce + + } + + ); + } + + const customLogoFromUpload = shop.brandAssets && shop.brandAssets.navbarBrandImage && shop.brandAssets.navbarBrandImage.large; + const customLogoFromUrlInput = shop.shopLogoUrls && shop.shopLogoUrls.primaryShopLogoUrl; + + return ( + + + {shop.name} + {shouldShowShopName && + + {shop.name} + + } + + + ); +} + +ShopLogo.propTypes = { + linkTo: PropTypes.string, + shopId: PropTypes.string, + shouldShowShopName: PropTypes.bool, + size: PropTypes.oneOfType([PropTypes.string, PropTypes.number]) +}; + +ShopLogo.defaultProps = { + linkTo: "/", + shouldShowShopName: false, + size: 60 +}; + +export default ShopLogo; diff --git a/package/src/ShopLogo/index.js b/package/src/ShopLogo/index.js new file mode 100644 index 0000000..feecf17 --- /dev/null +++ b/package/src/ShopLogo/index.js @@ -0,0 +1 @@ +export { default } from "./ShopLogo"; diff --git a/package/src/context/UIContext.js b/package/src/context/UIContext.js new file mode 100644 index 0000000..7f42907 --- /dev/null +++ b/package/src/context/UIContext.js @@ -0,0 +1,13 @@ +import { createContext } from "react"; + +export default createContext({ + isDetailDrawerOpen: false, + isMobile: false, + isNavigationDrawerOpen: true, + onCloseDetailDrawer: () => { }, + onCloseNavigationDrawer: () => { }, + onToggleDetailDrawer: () => { }, + onToggleNavigationDrawer: () => { }, + setDetailDrawerOpen: () => { }, + setNavigationDrawerOpen: () => { } +}); diff --git a/package/src/graphql/queries/primaryShopId.graphql b/package/src/graphql/queries/primaryShopId.graphql new file mode 100644 index 0000000..adcac48 --- /dev/null +++ b/package/src/graphql/queries/primaryShopId.graphql @@ -0,0 +1,3 @@ +query getPrimaryShopId { + primaryShopId +} \ No newline at end of file diff --git a/package/src/graphql/queries/shop.graphql b/package/src/graphql/queries/shop.graphql new file mode 100644 index 0000000..875aab5 --- /dev/null +++ b/package/src/graphql/queries/shop.graphql @@ -0,0 +1,29 @@ +query getShop($id: ID!) { + shop(id: $id) { + _id + brandAssets { + navbarBrandImageId + navbarBrandImage { + large + } + } + defaultParcelSize { + height + length + weight + width + } + language + name + shopLogoUrls { + primaryShopLogoUrl + } + storefrontUrls { + storefrontHomeUrl + storefrontLoginUrl + storefrontOrderUrl + storefrontOrdersUrl + storefrontAccountProfileUrl + } + } +} \ No newline at end of file diff --git a/package/src/hooks/useCurrentShop.js b/package/src/hooks/useCurrentShop.js new file mode 100644 index 0000000..19b665d --- /dev/null +++ b/package/src/hooks/useCurrentShop.js @@ -0,0 +1,29 @@ +import { useLazyQuery } from "@apollo/react-hooks"; +import shopQuery from "../graphql/queries/shop.graphql"; +import useCurrentShopId from "./useCurrentShopId.js"; + +/** + * React Hook that gets the globally current shop + * @return {Object} Object with `shop` and `shopId` props + */ +export default function useCurrentShop() { + const { currentShopId: shopId } = useCurrentShopId(); + + const [getShop, { called, data, loading, refetch }] = useLazyQuery(shopQuery, { + fetchPolicy: "network-only" + }); + + // Wait until we're sure we have a shop ID to call the query + if (shopId && !called) { + getShop({ + variables: { id: shopId } + }); + } + + return { + isLoadingShop: loading, + refetchShop: refetch, + shop: data && data.shop, + shopId + }; +} diff --git a/package/src/hooks/useCurrentShopId.js b/package/src/hooks/useCurrentShopId.js new file mode 100644 index 0000000..ac4d497 --- /dev/null +++ b/package/src/hooks/useCurrentShopId.js @@ -0,0 +1,20 @@ +import { useQuery } from "@apollo/react-hooks"; +import primaryShopIdQuery from "../graphql/queries/primaryShopId.graphql"; + +/** + * React Hook that gets the current shop ID + * @return {Array} [currentShopId] + */ +export default function useCurrentShopId() { + let currentShopId = null; + const { data, loading } = useQuery(primaryShopIdQuery); + + if (data && data.primaryShopId) { + currentShopId = data.primaryShopId; + } + + return { + currentShopId, + isLoadingCurrentShopId: loading + }; +} diff --git a/package/src/hooks/useMediaQuery.js b/package/src/hooks/useMediaQuery.js new file mode 100644 index 0000000..f21faa1 --- /dev/null +++ b/package/src/hooks/useMediaQuery.js @@ -0,0 +1,20 @@ +import { useMediaQuery as useMediaQueryMui } from "@material-ui/core"; + +/** + * Media query hook. Wraps the mui hook `useMediaQuery` with some additional options. + * @param {String} breakpoint Breakpoint name. `mobile|tablet|desktop|xs|sm|md|lg` + * @param {Options} options Options + * @returns {Boolean} Whether the breakpoint is active + */ +export default function useMediaQuery(breakpoint = "mobile", options) { + return useMediaQueryMui((theme) => { + if (breakpoint === "mobile") { + return theme.breakpoints.down("sm", options); + } else if (breakpoint === "tablet") { + return theme.breakpoints.down("md", options); + } else if (breakpoint === "desktop") { + return theme.breakpoints.up("md", options); + } + return theme.breakpoints.up(breakpoint, options); + }); +} diff --git a/package/src/hooks/useRoutes.js b/package/src/hooks/useRoutes.js new file mode 100644 index 0000000..5ee9c42 --- /dev/null +++ b/package/src/hooks/useRoutes.js @@ -0,0 +1,42 @@ +import { useMemo } from "react"; +import { routes } from "../lib/core/routes"; + +export const defaultRouteSort = (routeA, routeB) => ( + (routeA.priority || Number.MAX_SAFE_INTEGER) - (routeB.priority || Number.MAX_SAFE_INTEGER) +); + +/** + * Operator routes hook + * @param {Object} options Options + * @param {Object} [options.LayoutComponent] LayoutComponent override + * @param {Object} [options.group] Filter routes by group name + * @param {Object} [options.filter] Custom filter + * @param {Object} [options.sort] Route sort function + * @returns {Array} An array containing filtered routes + */ +export default function useRoutes(options = {}) { + const { + groups, + filter, + sort = defaultRouteSort + } = options; + + const memoizedRoutes = useMemo(() => { + let filteredRoutes; + if (Array.isArray(groups)) { + filteredRoutes = routes.filter(({ group: routeGroup }) => groups.includes(routeGroup)); + } else if (filter) { + filteredRoutes = routes.filter(filter); + } else { + filteredRoutes = routes; + } + + if (sort) { + filteredRoutes = filteredRoutes.sort(sort); + } + + return filteredRoutes; + }, [filter, groups, sort]); + + return memoizedRoutes; +} diff --git a/package/src/index.js b/package/src/index.js index f4fd11d..ea0f47c 100644 --- a/package/src/index.js +++ b/package/src/index.js @@ -5,4 +5,13 @@ export { Reaction } from "./lib/core/reaction"; // Export React components export { default as App } from "./App"; +export { default as AppBar } from "./AppBar"; +export { default as Blocks } from "./Blocks"; export { default as Dashboard } from "./Dashboard"; +export { default as ContentLayout } from "./ContentLayout"; +export { default as GenericErrorBoundary } from "./GenericErrorBoundary"; +export { default as NavigationDrawer } from "./NavigationDrawer"; +export { default as ProfileMenu } from "./ProfileMenu"; +export { default as Routes } from "./Routes"; +export { default as ShopLogo } from "./ShopLogo"; +export { default as UIContext } from "./context/UIContext"; diff --git a/package/src/lib/core/blocks.js b/package/src/lib/core/blocks.js new file mode 100644 index 0000000..26be096 --- /dev/null +++ b/package/src/lib/core/blocks.js @@ -0,0 +1,157 @@ +import Logger from "../../utils/logger"; + +export const BlocksTable = {}; // storage for separate elements of each block +export const BlockComponents = {}; + +/** + * @example // Register a component and container(s) with a name. + * // The raw component can then be extended or replaced. + * + * // Structure of a component in the list: + * + * BlocksTable.MyComponent = { + * name: 'MyComponent', + * hocs: [fn1, fn2], + * rawComponent: React.Component + * } + * @name registerComponent + * @method + * @memberof Components/Helpers + * @param {Object} options The name of the component to register. + * @param {Object} options.name The name of the component to register. + * @param {Object} options.Component The name of the component to register. + * @param {Object} options.region The name of the component to register. + * @param {Object} options.priority The name of the component to register. + * @param {Object} options.priority The name of the component to register. + * @param {React.Component} rawComponent Interchangeable/extendable component. + * @param {Function|Array} hocs The HOCs to wrap around the raw component. + * + * @returns {React.Component} returns the final wrapped component + */ +export function registerBlock(options) { + const { + name, + Component, + region, + priority + } = options; + + if (!region) { + throw new Error("A region is required for registerBlock"); + } + + if (!name) { + throw new Error("A name is required for registerBlock"); + } + + if (!Component) { + throw new Error("A component is required for registerBlock"); + } + + if (!BlocksTable[region]) { + BlocksTable[region] = {}; + } + + // store the component in the table + BlocksTable[region][name] = { + name, + Component, + region, + priority + }; +} + +/** + * @name getBlock + * @method + * @summary Get a block registered with registerBlock({ name, component, hocs, region }). + * @param {String} regionName The name of region the block belongs to. + * @param {String} blockName The name of the block. + * @returns {Function|React.Component} A (wrapped) React component + * @memberof Components/Helpers + */ +export function getBlock(regionName, blockName) { + const block = BlocksTable[regionName][blockName]; + + if (!block) { + throw new Error(`Block ${blockName} in region ${regionName} not registered.`); + } + + return block.Component; +} + +/** + * @name getBlocks + * @method + * @summary Get a component registered with registerComponent(name, component, ...hocs). + * @param {String} regionName The name of the region to get. + * @returns {Function|React.Component} A (wrapped) React component + * @memberof Components/Helpers + */ +export function getBlocks(regionName) { + const region = BlocksTable[regionName]; + + if (!region) { + Logger.warn(`No blocks available for region named ${regionName}.`); + return null; + } + + const blocks = Object + .values(region) + .sort((blockA, blockB) => blockA.priority - blockB.priority) + .map(({ Component }) => Component); + + return blocks; +} + +/** + * @name replaceBlock + * @method + * @summary Replace a Reaction component with a new component and optionally add one or more higher order components. + * This function keeps track of the previous HOCs and wraps the new HOCs around previous ones + * @param {Object} options Object containing block information + * @param {String} options.region The region of the block that will be replaced + * @param {String} options.block The name of the block that will be replaced + * @param {React.Component} options.component Interchangeable/extendable component. + * @returns {Function|React.Component} A component callable with Components[name] + * @memberof Components/Helpers + */ +export function replaceBlock({ region, block, Component }) { + const previousBlock = BlocksTable[region][block]; + + if (!previousBlock) { + throw new Error(`Block '${block}' of region ${region} not found. Use registerComponent to create it.`); + } + + return registerBlock({ + name: block, + region, + Component + }); +} + +/** + * @name getBlockComponent + * @method + * @summary Get the Component registered within a block + * @param {String} regionName The name of the block region. + * @param {String} blockName The name of the block component to get. + * @returns {Function|React.Component} A React component + * @memberof Components/Helpers + */ +export const getBlockComponent = (regionName, blockName) => BlocksTable[regionName][blockName].Component; + +/** + * @name loadRegisteredBlocks + * @method + * @summary Populate the final BlockComponents object with the contents of the lookup table. + * This should only be called once on app startup. + * @returns {Object} An object containing all of the registered blocks by region + * @memberof Components/Helpers + **/ +export function loadRegisteredBlocks() { + Object.keys(BlocksTable).forEach((regionName) => { + BlockComponents[regionName] = getBlocks(regionName); + }); + return BlockComponents; +} diff --git a/package/src/lib/core/i18n.js b/package/src/lib/core/i18n.js new file mode 100644 index 0000000..b13069b --- /dev/null +++ b/package/src/lib/core/i18n.js @@ -0,0 +1,58 @@ +import i18n from "i18next"; +import { initReactI18next } from "react-i18next"; +import i18nextBrowserLanguageDetector from "i18next-browser-languagedetector"; +import i18nextSprintfPostProcessor from "i18next-sprintf-postprocessor"; +import i18nextFetch from "i18next-fetch-backend"; +import i18nextMultiLoadBackendAdapter from "i18next-multiload-backend-adapter"; + +const configuredI18n = i18n + .use(initReactI18next) // passes i18n down to react-i18next + // https://github.com/i18next/i18next-browser-languageDetector + // Sets initial language to load based on `lng` query string + // with various fallbacks. + .use(i18nextBrowserLanguageDetector) + // https://github.com/i18next/i18next-sprintf-postProcessor + // key: 'Hello %(users[0].name)s, %(users[1].name)s and %(users[2].name)s', + // i18next.t('key2', { postProcess: 'sprintf', sprintf: { users: [{name: 'Dolly'}, {name: 'Molly'}, {name: 'Polly'}] } }); + // --> 'Hello Dolly, Molly and Polly' + .use(i18nextSprintfPostProcessor) + // https://github.com/perrin4869/i18next-fetch-backend + // This uses `fetch` to load resources from the backend based on `backend` + // config object below. + .use(i18nextMultiLoadBackendAdapter); + +/** + * Init i18next + * @returns {undefined} + */ +export async function initI18next({ i18nBaseUrl, fallbackLng = "en" }) { + // Reaction does not have a predefined list of namespaces. Any API plugin can + // add any namespaces. So we must first get the list of namespaces from the API. + const namespaceResponse = await fetch(`${i18nBaseUrl}/locales/namespaces.json`); + const allTranslationNamespaces = await namespaceResponse.json(); + + await configuredI18n.init({ + backend: { + backend: i18nextFetch, + backendOption: { + allowMultiLoading: true, + loadPath: `${i18nBaseUrl}/locales/resources.json?lng={{lng}}&ns={{ns}}` + } + }, + debug: false, + detection: { + // We primarily set language according to `navigator.language`, + // which is supported in all modern browsers and can be changed + // in the browser settings. This is the same list that browsers + // send in the `Accept-Language` header. + // + // For ease of testing translations, we also support `lng` + // query string to override the browser setting. + order: ["querystring", "navigator"] + }, + ns: allTranslationNamespaces, + defaultNS: "core", // reaction "core" is the default namespace + fallbackNS: allTranslationNamespaces, + fallbackLng + }); +} diff --git a/package/src/lib/core/plugins.js b/package/src/lib/core/plugins.js index 7f213f8..b8c25c7 100644 --- a/package/src/lib/core/plugins.js +++ b/package/src/lib/core/plugins.js @@ -1,3 +1,6 @@ +import { registerRoute, registerSetting } from "./routes"; +import { registerBlock, replaceBlock } from "./blocks"; + export const plugins = []; /** @@ -6,5 +9,10 @@ export const plugins = []; * @returns {undefined} no return */ export function registerPlugin(plugin) { - plugins.push(plugin()); + plugins.push(plugin({ + registerRoute, + registerSetting, + registerBlock, + replaceBlock + })); } diff --git a/package/src/lib/core/reaction.js b/package/src/lib/core/reaction.js index b977f81..16a1ffb 100644 --- a/package/src/lib/core/reaction.js +++ b/package/src/lib/core/reaction.js @@ -1,30 +1,70 @@ import React from "react"; import ReactDOM from "react-dom"; +import SettingsIcon from "mdi-material-ui/Settings"; import initApollo from "../graphql/initApollo"; +import SettingsDashboard from "../../SettingsDashboard"; +import Profile from "../../Profile"; import { getOidcProps } from "./authentication"; import { plugins } from "./plugins"; +import { registerRoute } from "./routes"; +import { initI18next } from "./i18n"; +import { loadRegisteredBlocks } from "./blocks"; /** * The starting point for the web-app * @param {Object} props Props and config for the app * @returns {undefined} no return */ -export function Reaction(props) { +export async function Reaction(props) { const { config: { PUBLIC_GRAPHQL_API_URL, + PUBLIC_I18N_BASE_URL, + PUBLIC_I18N_FALLBACK_LANGUAGE, PUBLIC_OIDC_CLIENT_ID, PUBLIC_OIDC_URL, PUBLIC_ROOT_URL }, AppComponent, DashboardComponent, - dashboardComponentProps + SettingsDashboardComponent = SettingsDashboard, + dashboardComponentProps, + settingsRouteProps, + shouldShowSettingsInNavigation = true } = props; // Initialize apollo client to be used for the ApolloProvider in the AppComponent const apolloClient = initApollo({ graphqlApiUrl: PUBLIC_GRAPHQL_API_URL }); + // Init i18next + await initI18next({ + i18nBaseUrl: PUBLIC_I18N_BASE_URL, + fallbackLng: PUBLIC_I18N_FALLBACK_LANGUAGE + }); + + // Load registered blocks + loadRegisteredBlocks(); + + if (shouldShowSettingsInNavigation) { + registerRoute({ + group: "navigation", + priority: 9999, + path: "/settings/:setting?", + href: "/settings/shop", + LayoutComponent: null, + MainComponent: SettingsDashboardComponent, + IconComponent: SettingsIcon, + navigationItemLabel: "admin.settings.settingsLabel", + ...settingsRouteProps + }); + } + + // Register a profile page + registerRoute({ + path: "/profile", + MainComponent: Profile + }); + // Create OIDC props to be used on the AuthenticationProvider in the AppComponent const authenticationProviderProps = getOidcProps({ oidcClientId: PUBLIC_OIDC_CLIENT_ID, diff --git a/package/src/lib/core/routes.js b/package/src/lib/core/routes.js new file mode 100644 index 0000000..6703d5e --- /dev/null +++ b/package/src/lib/core/routes.js @@ -0,0 +1,45 @@ +export const routes = []; +export const defaultRouteGroups = { + navigation: "navigation", + settings: "settings" +}; + +/** + * @name registerRoute + * @summary Registers new route for the admin UI. + * @param {Object} route - The route + * @param {String} route.path - The URL path for this route + * @param {Node|String} route.MainComponent - A react component to render in + * the main content area or the name of a Blaze template that has been registered + * by a package. + * @param {Node} route.IconComponent - A React component that renders the menu icon for this route + * @param {String} route.navigationItemLabel - The i18n key for this route, i.e. "admin.dashboard.ordersLabel" + * @returns {undefined} + */ +export function registerRoute(route) { + routes.push({ + priority: -1, + ...route + }); +} + +/** + * @name registerSetting + * @summary Registers new route for specifically for settings. + * @param {Object} route - The route + * @param {String} route.path - The URL path for this route + * @param {Node|String} route.MainComponent - A react component to render in + * the main content area or the name of a Blaze template that has been registered + * by a package. + * @param {Node} route.IconComponent - A React component that renders the menu icon for this route + * @param {String} route.navigationItemLabel - The i18n key for this route, i.e. "admin.dashboard.ordersLabel" + * @returns {undefined} + */ +export function registerSetting(route) { + routes.push({ + ...route, + path: `/settings/${route.name}`, + href: `/settings/${route.name || route.href || route.path}`, + group: defaultRouteGroups.settings + }); +} diff --git a/package/src/test-utils/index.js b/package/src/test-utils/index.js index c5bac1f..3649571 100644 --- a/package/src/test-utils/index.js +++ b/package/src/test-utils/index.js @@ -6,8 +6,26 @@ import { ThemeProvider } from "@material-ui/core"; import { MemoryRouter } from "react-router-dom"; import { MockedProvider } from "@apollo/react-testing"; import { defaultTheme } from "@reactioncommerce/catalyst"; +import i18n from "i18next"; +import { initReactI18next } from "react-i18next"; import apolloMocks from "./apolloMocks"; +i18n + .use(initReactI18next) + .init({ + resources: { + en: { + translation: {} + } + }, + lng: "en", + fallbackLng: "en", + + interpolation: { + escapeValue: false + } + }); + /** * Component that wraps components with mock providers during testing. * @return {Component} - Component wrapped with mock providers diff --git a/package/src/utils/logger.js b/package/src/utils/logger.js new file mode 100644 index 0000000..1d59b6c --- /dev/null +++ b/package/src/utils/logger.js @@ -0,0 +1,20 @@ +/* eslint-disable no-console */ +/* + * configure bunyan logging module for reaction client + * See: https://github.com/trentm/node-bunyan#levels + * client we'll cofigure WARN as default + */ +const levels = ["FATAL", "ERROR", "WARN", "INFO", "DEBUG", "TRACE"]; + +// set stdout log level +let level = process.env.REACTION_LOG_LEVEL || "WARN"; + +level = level.toUpperCase(); + +if (!levels.includes(level)) { + level = "WARN"; +} + +const Logger = console.log; + +export default Logger; diff --git a/public/locales/en-en.json b/public/locales/en-en.json new file mode 100644 index 0000000..e69de29 diff --git a/public/reaction-logo-circular.svg b/public/reaction-logo-circular.svg new file mode 100644 index 0000000..b9130f9 --- /dev/null +++ b/public/reaction-logo-circular.svg @@ -0,0 +1,38 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/src/config.js b/src/config.js index 12725ed..f0f88d0 100644 --- a/src/config.js +++ b/src/config.js @@ -4,7 +4,9 @@ const config = { PUBLIC_GRAPHQL_API_URL: process.env.PUBLIC_GRAPHQL_API_URL, PUBLIC_OIDC_CLIENT_ID: process.env.PUBLIC_OIDC_CLIENT_ID, PUBLIC_OIDC_URL: process.env.PUBLIC_OIDC_URL, - PUBLIC_ROOT_URL: process.env.PUBLIC_ROOT_URL + PUBLIC_ROOT_URL: process.env.PUBLIC_ROOT_URL, + PUBLIC_I18N_BASE_URL: process.env.PUBLIC_I18N_BASE_URL, + PUBLIC_I18N_FALLBACK_LANGUAGE: process.env.PUBLIC_I18N_FALLBACK_LANGUAGE }; export default envalid.cleanEnv(config, { @@ -12,6 +14,15 @@ export default envalid.cleanEnv(config, { desc: "A URL that is accessible from browsers and accepts GraphQL POST requests over HTTP", example: "http://localhost:3000/graphql" }), + PUBLIC_I18N_BASE_URL: envalid.str({ + desc: "A URL that has /locales/namespaces.json and /locales/resources.json endpoints for loading translations", + example: "http://localhost:3000" + }), + PUBLIC_I18N_FALLBACK_LANGUAGE: envalid.str({ + default: "en", + desc: "The language to fallback on in case of a failure to detect or load the preferred language", + example: "en" + }), PUBLIC_OIDC_CLIENT_ID: envalid.str({ default: "reaction-admin-core", desc: "The OAuth2 client ID to use for authentication flows from the browser", diff --git a/src/index.html b/src/index.html index f33f731..bc3722b 100644 --- a/src/index.html +++ b/src/index.html @@ -3,6 +3,8 @@ Reaction Admin +
diff --git a/src/index.js b/src/index.js index 0092f48..af45366 100644 --- a/src/index.js +++ b/src/index.js @@ -1,22 +1,22 @@ +/* eslint-disable react/display-name */ /* eslint-disable react/no-multi-comp */ -import React from "react"; import { Reaction, registerPlugin, App } from "../package/src"; import config from "./config"; +import ExamplePluginProducts from "./plugins/ExamplePluginProducts"; +import ExamplePluginTags from "./plugins/ExamplePluginTags"; +import ExampleLayouts from "./plugins/ExampleLayouts"; -// Register fake plugins for testing -registerPlugin(() => ({ - route: "/test-1", - navTitle: "Test 1", - // eslint-disable-next-line react/display-name - MainComponent: () => (Route Test 1) -})); - -registerPlugin(() => ({ - route: "/test-2", - navTitle: "Test 2", - // eslint-disable-next-line react/display-name - MainComponent: () => (Route Test 2) -})); +/** + * Register plugins for testing. + * + * In a real application, plugins can either live locally in the project, + * or installed as NPM packages and registered here. + * + * For this sample application, the plugins are all local. + */ +registerPlugin(ExamplePluginProducts); +registerPlugin(ExamplePluginTags); +registerPlugin(ExampleLayouts); // Configure and "start" the Reaction webapp Reaction({ diff --git a/src/plugins/ExampleLayouts/index.js b/src/plugins/ExampleLayouts/index.js new file mode 100644 index 0000000..2a2a90a --- /dev/null +++ b/src/plugins/ExampleLayouts/index.js @@ -0,0 +1,62 @@ +/* eslint-disable react/display-name */ +/* eslint-disable react/no-multi-comp */ +import React from "react"; +import TagIcon from "mdi-material-ui/Tag"; +import { Card, CardContent } from "@material-ui/core"; + +/** + * Register sample tags plugin + * @param {Object} params Params provided by `registerPlugin` + * @returns {undefined} + */ +export default function ({ registerRoute }) { + // Register routes + registerRoute({ + path: "/layout-standard", + group: "navigation", + navigationItemLabel: "Standard Layout", + IconComponent: TagIcon, + MainComponent: () => ( + + + Standard layout is centered with a max-width. + + + ) + }); + + registerRoute({ + path: "/layout-wide", + group: "navigation", + navigationItemLabel: "Wide Layout", + layoutComponentProps: { + size: "wide" + }, + IconComponent: TagIcon, + MainComponent: () => ( + + + Wide layout is full width including padding on the leading and trailing edges of the view. + + + ) + }); + + registerRoute({ + path: "/layout-full", + group: "navigation", + navigationItemLabel: "Full Layout", + layoutComponentProps: { + size: "full" + }, + IconComponent: TagIcon, + MainComponent: () => ( + + + Full layout if full width with padding on the leading and trailing edges of the view. + The "Full" layout doesn't scroll. + + + ) + }); +} diff --git a/src/plugins/ExamplePluginProducts/index.js b/src/plugins/ExamplePluginProducts/index.js new file mode 100644 index 0000000..7c9a798 --- /dev/null +++ b/src/plugins/ExamplePluginProducts/index.js @@ -0,0 +1,72 @@ +/* eslint-disable react/display-name */ +/* eslint-disable react/no-multi-comp */ +import React from "react"; +import FolderIcon from "mdi-material-ui/Folder"; +import { Card, CardContent, Box } from "@material-ui/core"; + +// Normally imported like `import { Blocks } from "@reactioncommerce/admin-core";` +import { Blocks } from "../../../package/src"; + +/** + * Register sample products plugin + * @param {Object} params Params provided by `registerPlugin` + * @returns {undefined} + */ +export default function ({ registerRoute, registerSetting, registerBlock }) { + // Register routes + registerRoute({ + path: "/products", + group: "navigation", + navigationItemLabel: "Products (sample)", + IconComponent: FolderIcon, + MainComponent: () => ( + + {(blocks) => + blocks.map((block, index) => ( + + {block} + + )) + } + + ) + }); + + // Register settings + registerSetting({ + name: "products", + navigationItemLabel: "Products", + MainComponent: () => ( + + + Product plugin settings + + + ) + }); + + // Register blocks + registerBlock({ + name: "ProductDetails", + region: "ProductDetailMain", + Component: () => ( + + + "Product Details" Block registered in "Products" plugin + + + ) + }); + + registerBlock({ + name: "ProductMedia", + region: "ProductDetailMain", + Component: () => ( + + + "Product Media" Block registered in "Products" plugin + + + ) + }); +} diff --git a/src/plugins/ExamplePluginTags/index.js b/src/plugins/ExamplePluginTags/index.js new file mode 100644 index 0000000..47728bb --- /dev/null +++ b/src/plugins/ExamplePluginTags/index.js @@ -0,0 +1,52 @@ +/* eslint-disable react/display-name */ +/* eslint-disable react/no-multi-comp */ +import React from "react"; +import TagIcon from "mdi-material-ui/Tag"; +import { Card, CardContent } from "@material-ui/core"; + +/** + * Register sample tags plugin + * @param {Object} params Params provided by `registerPlugin` + * @returns {undefined} + */ +export default function ({ registerRoute, registerSetting, registerBlock }) { + // Register routes + registerRoute({ + path: "/tags", + group: "navigation", + navigationItemLabel: "Tags (sample)", + IconComponent: TagIcon, + MainComponent: () => ( + + + Display Tags + + + ) + }); + + registerSetting({ + name: "tags", + navigationItemLabel: "Tags", + MainComponent: () => ( + + + Setting for tags + + + ) + }); + + // Register blocks + registerBlock({ + name: "ProductTags", + region: "ProductDetailMain", + Component: () => ( + + + "Product Tags" Block registered in "Tags" plugin + + + ) + }); +}