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`] = `
+
+
+
+`;
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 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);
+ }}
+ >
+
+
+
+
+
+ );
+}
+
+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]({defaultLogo})
+ {shouldShowShopName &&
+
+ Reaction Commerce
+
+ }
+
+ );
+ }
+
+ const customLogoFromUpload = shop.brandAssets && shop.brandAssets.navbarBrandImage && shop.brandAssets.navbarBrandImage.large;
+ const customLogoFromUrlInput = shop.shopLogoUrls && shop.shopLogoUrls.primaryShopLogoUrl;
+
+ return (
+
+
+
+ {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
+
+
+ )
+ });
+}