├── .eslintignore ├── .eslintrc ├── .github └── workflows │ └── main.yml ├── .gitignore ├── CHANGELOG.md ├── LICENSE ├── README.md ├── config ├── webpack.config.base.js ├── webpack.config.dev.js └── webpack.config.prod.js ├── cypress.json ├── cypress ├── .eslintrc ├── components │ ├── Sample.tsx │ └── shared │ │ └── Auth │ │ └── Login.tsx ├── cypress.d.ts ├── fixtures │ └── example.json ├── integration │ └── config.spec.ts ├── plugins │ ├── cy-ts-preprocessor.js │ └── index.js ├── support │ ├── commands.js │ └── index.js └── tsconfig.json ├── dotenv ├── package-lock.json ├── package.json ├── scripts └── build.sh ├── src ├── App.tsx ├── index.ejs ├── index.html ├── index.tsx ├── pages │ ├── Configuration.tsx │ ├── index.tsx │ ├── routes.ts │ └── users │ │ ├── UserCreate.tsx │ │ └── index.tsx ├── registerServiceWorker.ts └── shared │ ├── ApolloClient.ts │ ├── AuthProvder.ts │ ├── components │ ├── Auth │ │ └── Login.tsx │ └── Layout │ │ ├── AppBar.tsx │ │ ├── Layout.tsx │ │ ├── Menu.tsx │ │ ├── SubMenu.tsx │ │ ├── index.ts │ │ └── themes.ts │ ├── config.ts │ ├── i18n │ ├── en.ts │ ├── index.ts │ └── vi.ts │ ├── store │ ├── action.ts │ ├── config │ │ ├── action.ts │ │ ├── reducer.ts │ │ └── types.ts │ ├── reducer.ts │ └── types.ts │ └── types.ts └── tsconfig.json /.eslintignore: -------------------------------------------------------------------------------- 1 | config/*.js 2 | node_modules 3 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "browser": true, 4 | "es6": true, 5 | "node": true 6 | }, 7 | "parser": "@typescript-eslint/parser", 8 | 9 | "parserOptions": { 10 | "project": "./tsconfig.json", 11 | "sourceType": "module" 12 | }, 13 | "plugins": [ 14 | "@typescript-eslint", 15 | "functional" 16 | ], 17 | "settings": { 18 | "import/parsers": { 19 | "@typescript-eslint/parser": [".ts", ".tsx"] 20 | }, 21 | "import/resolver": { 22 | "typescript": { 23 | "alwaysTryTypes": true 24 | } 25 | }, 26 | "react": { 27 | "createClass": "createReactClass", // Regex for Component Factory to use, 28 | // default to "createReactClass" 29 | "pragma": "React", // Pragma to use, default to "React" 30 | "version": "detect" // React version. "detect" automatically picks the version you have installed. 31 | // You can also use `16.0`, `16.3`, etc, if you want to override the detected value. 32 | // default to latest and warns if missing 33 | // It will default to "detect" in the future 34 | } 35 | }, 36 | "extends": [ 37 | 38 | "eslint:recommended", 39 | "plugin:react/recommended", 40 | "plugin:@typescript-eslint/eslint-recommended", 41 | "plugin:@typescript-eslint/recommended", 42 | "plugin:@typescript-eslint/recommended-requiring-type-checking", 43 | "plugin:functional/external-recommended", 44 | "plugin:functional/recommended", 45 | "plugin:import/errors", 46 | "plugin:import/warnings", 47 | "plugin:import/typescript" 48 | ], 49 | "rules": { 50 | "functional/no-return-void": 0, 51 | "functional/functional-parameters": 0, 52 | "functional/no-try-statement": 0, 53 | "@typescript-eslint/no-unsafe-return": 0, 54 | "functional/no-throw-statement": 0, 55 | "functional/no-expression-statement": 0, 56 | "functional/no-conditional-statement": 0, 57 | "@typescript-eslint/no-unsafe-assignment": 0, 58 | "@typescript-eslint/adjacent-overload-signatures": "error", 59 | "@typescript-eslint/array-type": [ 60 | "error", 61 | { 62 | "default": "array" 63 | } 64 | ], 65 | "@typescript-eslint/ban-types": [ 66 | "error", 67 | { 68 | "types": { 69 | "Object": { 70 | "message": "Avoid using the `Object` type. Did you mean `object`?" 71 | }, 72 | "Function": { 73 | "message": "Avoid using the `Function` type. Prefer a specific function type, like `() => void`." 74 | }, 75 | "Boolean": { 76 | "message": "Avoid using the `Boolean` type. Did you mean `boolean`?" 77 | }, 78 | "Number": { 79 | "message": "Avoid using the `Number` type. Did you mean `number`?" 80 | }, 81 | "String": { 82 | "message": "Avoid using the `String` type. Did you mean `string`?" 83 | }, 84 | "Symbol": { 85 | "message": "Avoid using the `Symbol` type. Did you mean `symbol`?" 86 | } 87 | } 88 | } 89 | ], 90 | "@typescript-eslint/no-unsafe-member-access": 0, 91 | "@typescript-eslint/no-unsafe-call": 0, 92 | "@typescript-eslint/camelcase": "off", 93 | "@typescript-eslint/consistent-type-assertions": "off", 94 | "@typescript-eslint/indent": [ 95 | "error", 96 | 2, 97 | { 98 | "ObjectExpression": "first", 99 | "FunctionDeclaration": { 100 | "parameters": "first" 101 | }, 102 | "FunctionExpression": { 103 | "parameters": "first" 104 | }, 105 | "ignoredNodes": ["TemplateLiteral > *"], 106 | "SwitchCase": 1 107 | } 108 | ], 109 | "@typescript-eslint/member-delimiter-style": [ 110 | "error", 111 | { 112 | "multiline": { 113 | "delimiter": "none", 114 | "requireLast": true 115 | }, 116 | "singleline": { 117 | "delimiter": "semi", 118 | "requireLast": false 119 | } 120 | } 121 | ], 122 | "@typescript-eslint/no-empty-function": "error", 123 | "@typescript-eslint/no-empty-interface": "error", 124 | "@typescript-eslint/no-explicit-any": "off", 125 | "@typescript-eslint/no-misused-new": "error", 126 | "@typescript-eslint/no-namespace": "error", 127 | "@typescript-eslint/no-non-null-assertion": "error", 128 | "@typescript-eslint/no-parameter-properties": "off", 129 | "@typescript-eslint/no-require-imports": "off", 130 | "@typescript-eslint/no-this-alias": "error", 131 | "@typescript-eslint/no-unnecessary-boolean-literal-compare": "error", 132 | "@typescript-eslint/no-use-before-define": "off", 133 | "@typescript-eslint/no-var-requires": "off", 134 | "@typescript-eslint/prefer-for-of": "error", 135 | "@typescript-eslint/prefer-function-type": "error", 136 | "@typescript-eslint/prefer-namespace-keyword": "error", 137 | "@typescript-eslint/promise-function-async": "off", 138 | "@typescript-eslint/quotes": [ 139 | "error", 140 | "double" 141 | ], 142 | "@typescript-eslint/semi": [ 143 | "error" 144 | ], 145 | "@typescript-eslint/triple-slash-reference": "error", 146 | "@typescript-eslint/unified-signatures": "error", 147 | "arrow-body-style": "error", 148 | "arrow-parens": [ 149 | "error", 150 | "always" 151 | ], 152 | "camelcase": "off", 153 | "class-methods-use-this": "error", 154 | "comma-dangle": "error", 155 | "complexity": "off", 156 | "constructor-super": "error", 157 | "default-case": "error", 158 | "dot-notation": "error", 159 | "eqeqeq": [ 160 | "error", 161 | "always" 162 | ], 163 | "guard-for-in": "error", 164 | "id-blacklist": [ 165 | "error", 166 | "any", 167 | "Number", 168 | "number", 169 | "String", 170 | "string", 171 | "Boolean", 172 | "boolean", 173 | "Undefined", 174 | "undefined" 175 | ], 176 | "id-match": "error", 177 | "import/no-default-export": "off", 178 | "import/no-named-as-default": "off", 179 | "import/no-extraneous-dependencies": [ 180 | "off", 181 | { 182 | "devDependencies": false 183 | } 184 | ], 185 | "import/no-internal-modules": "off", 186 | "import/order": "error", 187 | "import/name": "off", 188 | "linebreak-style": [ 189 | "error", 190 | "unix" 191 | ], 192 | "max-classes-per-file": [ 193 | "error", 194 | 3 195 | ], 196 | "max-len": [ 197 | "error", 198 | { 199 | "code": 120 200 | } 201 | ], 202 | "max-lines": [ 203 | "error", 204 | 300 205 | ], 206 | "new-parens": "error", 207 | "no-bitwise": "error", 208 | "no-caller": "error", 209 | "no-cond-assign": "error", 210 | "no-console": "off", 211 | "no-constant-condition": "error", 212 | "no-debugger": "error", 213 | "no-duplicate-case": "error", 214 | "no-duplicate-imports": "error", 215 | "no-empty": "error", 216 | "no-eval": "error", 217 | "no-extra-bind": "error", 218 | "no-fallthrough": "off", 219 | "no-invalid-this": "off", 220 | "no-irregular-whitespace": "error", 221 | "no-magic-numbers": "off", 222 | "no-multiple-empty-lines": [ 223 | "error", 224 | { 225 | "max": 1 226 | } 227 | ], 228 | "no-new-func": "error", 229 | "no-new-wrappers": "error", 230 | "no-redeclare": "error", 231 | "no-return-await": "error", 232 | "no-sequences": "error", 233 | "no-shadow": "off", 234 | "@typescript-eslint/no-shadow": ["error"], 235 | "no-sparse-arrays": "error", 236 | "no-template-curly-in-string": "error", 237 | "no-throw-literal": "error", 238 | "no-trailing-spaces": [ 239 | "error", 240 | { 241 | "ignoreComments": true 242 | } 243 | ], 244 | "no-undef-init": "error", 245 | "no-underscore-dangle": 0, 246 | "no-unsafe-finally": "error", 247 | "no-unused-expressions": [ 248 | "error", 249 | { 250 | "allowShortCircuit": true 251 | } 252 | ], 253 | "no-unused-labels": "error", 254 | "no-var": "error", 255 | "object-shorthand": "error", 256 | "one-var": [ 257 | "off", 258 | "never" 259 | ], 260 | "padding-line-between-statements": [ 261 | "error", 262 | { 263 | "blankLine": "always", 264 | "prev": "*", 265 | "next": "return" 266 | } 267 | ], 268 | "prefer-const": "error", 269 | "prefer-object-spread": "error", 270 | "prefer-template": "error", 271 | "quote-props": [ 272 | "error", 273 | "as-needed" 274 | ], 275 | "radix": "error", 276 | "space-in-parens": [ 277 | "error", 278 | "never" 279 | ], 280 | "spaced-comment": ["error", "always", { "markers": ["/"] }], 281 | "use-isnan": "error", 282 | "valid-typeof": "off", 283 | "react/prop-types": 0, 284 | "react/display-name": 0 285 | } 286 | } 287 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: Build, test pull requests 2 | on: 3 | push: 4 | branches: 5 | - '*' 6 | 7 | jobs: 8 | build: 9 | name: Build 10 | runs-on: ubuntu-latest 11 | steps: 12 | - name: Checkout Repo 13 | uses: actions/checkout@master 14 | - name: Use Node.js v12 15 | uses: actions/setup-node@v1 16 | with: 17 | node-version: 12 18 | 19 | # just so we learn about available environment variables GitHub provides 20 | - name: Print env variables 21 | run: | 22 | npm i -g @bahmutov/print-env 23 | print-env GITHUB 24 | 25 | # Restore the previous NPM modules and Cypress binary archives. 26 | # Any updated archives will be saved automatically after the entire 27 | # workflow successfully finishes. 28 | # See https://github.com/actions/cache 29 | - name: Cache node modules 30 | uses: actions/cache@v1 31 | with: 32 | path: ~/.npm 33 | key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }} 34 | restore-keys: | 35 | ${{ runner.os }}-node- 36 | - name: Cache Cypress binary 37 | uses: actions/cache@v1 38 | with: 39 | path: ~/.cache/Cypress 40 | key: cypress-${{ runner.os }}-cypress-${{ hashFiles('**/package.json') }} 41 | restore-keys: | 42 | cypress-${{ runner.os }}-cypress- 43 | - name: install dependencies and verify Cypress 44 | env: 45 | # make sure every Cypress install prints minimal information 46 | CI: 1 47 | run: | 48 | cp dotenv .env 49 | npm ci 50 | npx cypress verify 51 | npx cypress info 52 | - name: Build 53 | run: bash scripts/build.sh 54 | - name: Cypress tests 55 | run: npm test 56 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .parcel-cache 3 | .env 4 | .env.production 5 | .env.local 6 | cypress/screenshots 7 | cypress/videos 8 | dist 9 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## 2020-06-06 4 | 5 | - Replace Parcel to Webpack 6 | - Restructure Sidebar menu routes to outside `src/pages/routes.ts` 7 | - Update dependencies 8 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright <2019> 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 5 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 6 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ra-hasura-typescript-boilerplate 2 | 3 | ## Prerequisites 4 | 5 | - [React 16+](https://reactjs.org/) 6 | - [React Admin](https://redux.js.org/) 7 | - [Redux](https://redux.js.org/) 8 | - [Hasura data provider](https://github.com/Steams/ra-data-hasura-graphql) 9 | - [Webpack](https://webpack.js.org/) 10 | 11 | ## Templates 12 | 13 | I define multiple templates into branches. You can checkout template that fit your use case: 14 | 15 | - [auth-jwt](https://github.com/hgiasac/ra-hasura-typescript-boilerplate/tree/auth-jwt) 16 | - [auth-firebase](https://github.com/hgiasac/ra-hasura-typescript-boilerplate/tree/auth-firebase) 17 | 18 | For backend templates, go here: https://github.com/hgiasac/hasura-typescript-boilerplate 19 | 20 | ## Development 21 | 22 | Copy `dotenv` to `.env` and edit your environemnt variables 23 | 24 | ```sh 25 | npm run dev 26 | # build source 27 | # This is used for test/staging build, so we don't optimize it 28 | sh ./scripts/build.sh 29 | # build production locally 30 | sh ./scripts/build.sh prod 31 | # build on CI env 32 | sh ./scripts/build.sh ci 33 | # build on CI env 34 | sh ./scripts/build.sh ci-prod 35 | ``` 36 | 37 | ## CHANGELOG 38 | 39 | [Read here](CHANGELOG.md) 40 | -------------------------------------------------------------------------------- /config/webpack.config.base.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const HtmlWebpackPlugin = require('html-webpack-plugin') 3 | 4 | 5 | module.exports = { 6 | entry: { 7 | index: path.join(__dirname, '../src/index.tsx') 8 | }, 9 | output: { 10 | path: path.join(__dirname, '../dist'), 11 | publicPath: '/', 12 | filename: 'js/[name].[contenthash].js', 13 | }, 14 | resolve: { 15 | // Add '.ts' and '.tsx' as resolvable extensions. 16 | extensions: ['.js', ".ts", ".tsx"] 17 | }, 18 | 19 | module: { 20 | rules: [ 21 | { test: /\.m?js$/, type: "javascript/auto" }, 22 | { 23 | enforce: 'pre', 24 | test: /\.js|(\.tsx?)$/, 25 | exclude: /node_modules/, 26 | loader: 'eslint-loader', 27 | options: { 28 | // cache: true, 29 | fix: true, 30 | emitError: true, 31 | failOnError: true, 32 | }, 33 | }, 34 | { 35 | test: /\.ts(x?)$/, 36 | exclude: /node_modules/, 37 | use: [{ 38 | loader: "ts-loader", 39 | options: { 40 | transpileOnly: true, 41 | experimentalWatchApi: true, 42 | }, 43 | }] 44 | }, 45 | // All output '.js' files will have any sourcemaps re-processed by 'source-map-loader'. 46 | { 47 | enforce: "pre", 48 | test: /\.js$/, 49 | loader: "source-map-loader" 50 | } 51 | ] 52 | }, 53 | ignoreWarnings: [/Failed to parse source map/], 54 | plugins: [ 55 | new HtmlWebpackPlugin({ 56 | title: 'Hasura React Admin', 57 | 58 | }) 59 | ], 60 | // When importing a module whose path matches one of the following, just 61 | // assume a corresponding global variable exists and use that instead. 62 | // This is important because it allows us to avoid bundling all of our 63 | // dependencies, which allows browsers to cache those libraries between builds. 64 | externals: { 65 | "react": "React", 66 | "react-dom": "ReactDOM" 67 | } 68 | 69 | }; 70 | -------------------------------------------------------------------------------- /config/webpack.config.dev.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const webpackConfig = require('./webpack.config.base'); 3 | const Dotenv = require('dotenv-webpack'); 4 | 5 | module.exports = { 6 | ...webpackConfig, 7 | 8 | mode: 'development', 9 | 10 | // Enable sourcemaps for debugging webpack's output. 11 | devtool: 'eval-source-map', 12 | 13 | devServer: { 14 | contentBase: path.join(__dirname, '../dist'), 15 | compress: false, 16 | port: 3000, 17 | historyApiFallback: true, 18 | watchOptions: { 19 | aggregateTimeout: 300, 20 | poll: 1000 21 | }, 22 | open: true 23 | }, 24 | 25 | plugins: [ 26 | ...webpackConfig.plugins, 27 | new Dotenv() 28 | ], 29 | }; 30 | -------------------------------------------------------------------------------- /config/webpack.config.prod.js: -------------------------------------------------------------------------------- 1 | const webpack = require('webpack'); 2 | const webpackConfig = require('./webpack.config.base'); 3 | const path = require('path'); 4 | 5 | module.exports = env => { 6 | const isProd = env && env.NODE_ENV === 'production'; 7 | const config = { 8 | ...webpackConfig, 9 | mode: isProd ? 'production' : 'development', 10 | 11 | // Enable sourcemaps for debugging webpack's output. 12 | devtool: isProd ? 'hidden-source-map' : 'source-map', 13 | output: { 14 | ...webpackConfig.output, 15 | chunkFilename: 'js/[name].bundle.js', 16 | }, 17 | plugins: [ 18 | ...webpackConfig.plugins, 19 | new webpack.EnvironmentPlugin([ 20 | 'VERSION', 21 | 'DATA_DOMAIN', 22 | 'DATA_SCHEME', 23 | 'HASURA_GRAPHQL_ADMIN_SECRET', 24 | 'HASURA_CLIENT_NAME', 25 | ]), 26 | ], 27 | 28 | } 29 | 30 | if (isProd) { 31 | config.optimization = { 32 | runtimeChunk: 'single', 33 | splitChunks: { 34 | cacheGroups: { 35 | vendor: { 36 | test: /[\\/]node_modules[\\/]/, 37 | name: 'vendors', 38 | chunks: 'all' 39 | }, 40 | } 41 | } 42 | }; 43 | } 44 | 45 | return config; 46 | }; 47 | -------------------------------------------------------------------------------- /cypress.json: -------------------------------------------------------------------------------- 1 | { 2 | "experimentalComponentTesting": true, 3 | "video": false, 4 | "componentFolder": "./cypress/components", 5 | "testFiles": [ 6 | "**/*.ts", 7 | "**/*.tsx" 8 | ] 9 | } 10 | -------------------------------------------------------------------------------- /cypress/.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "overrides": [ 3 | { 4 | "files": [ 5 | "*.js", 6 | "*.ts", 7 | "*.tsx" 8 | ], 9 | "parserOptions": { 10 | "project": "cypress/tsconfig.json", 11 | "sourceType": "module" 12 | }, 13 | "globals": { 14 | "cy": true, 15 | "it": true 16 | } 17 | } 18 | ], 19 | "rules": { 20 | "import/no-unresolved": 0, 21 | "@typescript-eslint/explicit-function-return-type": 0, 22 | "functional/immutable-data": 0 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /cypress/components/Sample.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { mount } from "cypress-react-unit-test"; 3 | 4 | const Hello = (): JSX.Element => ( 5 | {"Hello World!"} 6 | ); 7 | 8 | describe("HelloWorld component", () => { 9 | it("works", () => { 10 | mount(); 11 | // now use standard Cypress commands 12 | cy.contains("Hello World!").should("be.visible"); 13 | }); 14 | }); 15 | -------------------------------------------------------------------------------- /cypress/components/shared/Auth/Login.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-unnecessary-type-assertion */ 2 | import * as React from "react"; 3 | import { mount } from "cypress-react-unit-test"; 4 | import * as ra from "react-admin"; 5 | import Login from "../../../../src/shared/components/Auth/Login"; 6 | 7 | const TestContext = (ra as any).TestContext; 8 | 9 | describe("HelloWorld component", () => { 10 | it("works", () => { 11 | mount( 12 | 13 | 14 | 15 | ); 16 | 17 | cy.get("input[name=username]").should("be.visible"); 18 | cy.get("input[name=password]").should("be.visible"); 19 | }); 20 | }); 21 | -------------------------------------------------------------------------------- /cypress/cypress.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /cypress/fixtures/example.json: -------------------------------------------------------------------------------- 1 | {} 2 | -------------------------------------------------------------------------------- /cypress/integration/config.spec.ts: -------------------------------------------------------------------------------- 1 | process.env = Cypress.env(); 2 | import { Config } from "../../src/shared/config"; 3 | 4 | it("load config", () => { 5 | const DATA_SCHEME = "http"; 6 | const DATA_DOMAIN = Cypress.env("DATA_DOMAIN"); 7 | const WS_SCHEME = "ws"; 8 | 9 | expect(Config.httpDataHost).eq(`${DATA_SCHEME}://${DATA_DOMAIN}/v1/graphql`); 10 | expect(Config.wsDataHost).eq(`${WS_SCHEME}://${DATA_DOMAIN}/v1/graphql`); 11 | }); 12 | -------------------------------------------------------------------------------- /cypress/plugins/cy-ts-preprocessor.js: -------------------------------------------------------------------------------- 1 | const path = require("path"); 2 | 3 | const webpackOptions = { 4 | resolve: { 5 | extensions: [".js", ".ts", ".tsx"], 6 | alias: { 7 | "@app": path.resolve(__dirname, "../../src") 8 | } 9 | }, 10 | module: { 11 | rules: [ 12 | { 13 | test: /\.tsx?$/, 14 | exclude: [/node_modules/], 15 | use: [ 16 | { 17 | loader: "ts-loader" 18 | } 19 | ] 20 | } 21 | ] 22 | } 23 | }; 24 | 25 | module.exports = webpackOptions; 26 | -------------------------------------------------------------------------------- /cypress/plugins/index.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable functional/immutable-data */ 2 | 3 | const fs = require("fs"); 4 | const path = require("path"); 5 | const dotenv = require("dotenv"); 6 | const filePath = path.resolve(__dirname, "../../.env"); 7 | const envConfig = dotenv.parse(fs.readFileSync(filePath)); 8 | 9 | module.exports = (on, config) => { 10 | config.env = envConfig; 11 | 12 | return config; 13 | }; 14 | -------------------------------------------------------------------------------- /cypress/support/commands.js: -------------------------------------------------------------------------------- 1 | // *********************************************** 2 | // This example commands.js shows you how to 3 | // create various custom commands and overwrite 4 | // existing commands. 5 | // 6 | // For more comprehensive examples of custom 7 | // commands please read more here: 8 | // https://on.cypress.io/custom-commands 9 | // *********************************************** 10 | // 11 | // 12 | // -- This is a parent command -- 13 | // Cypress.Commands.add("login", (email, password) => { ... }) 14 | // 15 | // 16 | // -- This is a child command -- 17 | // Cypress.Commands.add("drag", { prevSubject: 'element'}, (subject, options) => { ... }) 18 | // 19 | // 20 | // -- This is a dual command -- 21 | // Cypress.Commands.add("dismiss", { prevSubject: 'optional'}, (subject, options) => { ... }) 22 | // 23 | // 24 | // -- This will overwrite an existing command -- 25 | // Cypress.Commands.overwrite("visit", (originalFn, url, options) => { ... }) 26 | 27 | require("cypress-react-unit-test/support"); 28 | -------------------------------------------------------------------------------- /cypress/support/index.js: -------------------------------------------------------------------------------- 1 | // *********************************************************** 2 | // This example support/index.js is processed and 3 | // loaded automatically before your test files. 4 | // 5 | // This is a great place to put global configuration and 6 | // behavior that modifies Cypress. 7 | // 8 | // You can change the location of this file or turn off 9 | // automatically serving support files with the 10 | // 'supportFile' configuration option. 11 | // 12 | // You can read more here: 13 | // https://on.cypress.io/configuration 14 | // *********************************************************** 15 | 16 | // Import commands.js using ES2015 syntax: 17 | // import './commands' 18 | 19 | // Alternatively you can use CommonJS syntax: 20 | require('./commands') 21 | 22 | -------------------------------------------------------------------------------- /cypress/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.json", 3 | "compilerOptions": { 4 | "isolatedModules": false, 5 | "noEmit": false, 6 | "target": "es5", 7 | "module": "commonjs", 8 | "jsx": "react", 9 | "types": [ 10 | "cypress", 11 | "node" 12 | ], 13 | "baseUrl": ".", 14 | "paths": { 15 | "@app/*": [ 16 | "../src/*" 17 | ] 18 | } 19 | }, 20 | "include": [ 21 | "**/*.js", 22 | "**/*.ts", 23 | "**/*.tsx", 24 | "*/*.d.ts", 25 | "../src" 26 | ], 27 | } 28 | -------------------------------------------------------------------------------- /dotenv: -------------------------------------------------------------------------------- 1 | VERSION=0.1.0 2 | DATA_DOMAIN=localhost:8080 3 | DATA_SCHEME=http 4 | HASURA_GRAPHQL_ADMIN_SECRET=hasura 5 | HASURA_CLIENT_NAME=hasura-admin 6 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "ra-hasura-typescript-boilerplate", 3 | "version": "1.0.0", 4 | "description": "react admin hasura typescript boilerplate", 5 | "default": "dist/index.html", 6 | "sideEffects": false, 7 | "scripts": { 8 | "dev": "webpack serve --mode development --env development --config ./config/webpack.config.dev.js", 9 | "clean": "rm -rf dist", 10 | "build": "npm run clean && npm run lint && webpack --config ./config/webpack.config.prod.js", 11 | "build:prod": "npm run clean && npm run lint && webpack --env NODE_ENV=production --config ./config/webpack.config.prod.js", 12 | "test": "cypress run", 13 | "cy:open": "cypress open", 14 | "lint": "eslint --ext .js,.ts src" 15 | }, 16 | "keywords": [ 17 | "react", 18 | "admin", 19 | "hasura", 20 | "typescript", 21 | "boilerplate" 22 | ], 23 | "author": "Toan Nguyen", 24 | "license": "MIT", 25 | "targets": { 26 | "default": { 27 | "publicUrl": "./" 28 | } 29 | }, 30 | "devDependencies": { 31 | "@babel/runtime": "^7.12.5", 32 | "@types/node": "^14.14.12", 33 | "@typescript-eslint/eslint-plugin": "^4.9.1", 34 | "@typescript-eslint/parser": "^4.9.1", 35 | "cypress": "^6.1.0", 36 | "cypress-react-unit-test": "^4.17.2", 37 | "dotenv": "^8.2.0", 38 | "dotenv-webpack": "^6.0.0", 39 | "eslint": "^7.15.0", 40 | "eslint-import-resolver-typescript": "^2.3.0", 41 | "eslint-loader": "^4.0.2", 42 | "eslint-plugin-functional": "^3.1.0", 43 | "eslint-plugin-import": "^2.22.1", 44 | "eslint-plugin-react": "^7.21.5", 45 | "html-webpack-plugin": "next", 46 | "husky": "^4.3.5", 47 | "source-map-loader": "^1.1.3", 48 | "ts-loader": "^8.0.12", 49 | "typescript": "^4.1.3", 50 | "webpack": "^5.10.1", 51 | "webpack-cli": "^4.2.0", 52 | "webpack-dev-server": "^3.11.0" 53 | }, 54 | "dependencies": { 55 | "@apollo/client": "^3.3.6", 56 | "@apollo/react-hooks": "^4.0.0", 57 | "@material-ui/core": "^4.11.2", 58 | "@material-ui/icons": "^4.11.2", 59 | "@types/react": "^17.0.0", 60 | "@types/react-dom": "^17.0.0", 61 | "@types/react-redux": "^7.1.12", 62 | "@types/react-router-dom": "^5.1.6", 63 | "graphql": "^15.4.0", 64 | "ra-data-hasura-graphql": "^0.1.13", 65 | "react": "^17.0.1", 66 | "react-admin": "^3.10.4", 67 | "react-dom": "^17.0.1", 68 | "react-redux": "^7.2.2", 69 | "react-router-dom": "^5.2.0", 70 | "subscriptions-transport-ws": "^0.9.18" 71 | }, 72 | "husky": { 73 | "hooks": { 74 | "pre-push": "sh ./scripts/build.sh" 75 | } 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /scripts/build.sh: -------------------------------------------------------------------------------- 1 | #! /bin/bash 2 | 3 | function load_env { 4 | ENV_PATH=".env" 5 | echo "load environment at $ENV_PATH" 6 | 7 | if [ -f "$ENV_PATH" ]; then 8 | # Load Environment Variables 9 | export $(cat $ENV_PATH | grep -v '#' | awk '/=/ {print $1}') 10 | fi 11 | 12 | if [ -z $DATA_DOMAIN ]; then 13 | echo "Environment isn't succesfully loaded. Did you copy 'dotenv' file to '.env' and set variables?" 14 | exit 1 15 | fi 16 | } 17 | 18 | case "$1" in 19 | prod) 20 | echo "building production code..." 21 | load_env 22 | npm run build:prod 23 | ;; 24 | ci*) 25 | echo "building unoptimized code on CI environment..." 26 | npm run build 27 | ;; 28 | ci-prod) 29 | echo "building production code on CI environment..." 30 | npm run build:prod 31 | ;; 32 | *) 33 | echo "building unoptimized code..." 34 | load_env 35 | npm run build 36 | ;; 37 | esac 38 | -------------------------------------------------------------------------------- /src/App.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-unsafe-return */ 2 | /* eslint-disable @typescript-eslint/no-unsafe-call */ 3 | /* eslint-disable @typescript-eslint/no-unsafe-assignment */ 4 | import buildHasuraProvider from "ra-data-hasura-graphql"; 5 | import * as React from "react"; 6 | import { Admin } from "react-admin"; 7 | import { customRoutes, pageResources } from "./pages"; 8 | import { authGQLClient } from "./shared/ApolloClient"; 9 | import { authProvider } from "./shared/AuthProvder"; 10 | import Login from "./shared/components/Auth/Login"; 11 | import { Layout } from "./shared/components/Layout"; 12 | import i18nProvider from "./shared/i18n"; 13 | import { appReducer } from "./shared/store/reducer"; 14 | import { sidebarRoutes } from "./pages/routes"; 15 | 16 | const App = (): JSX.Element => { 17 | const [resolvedDataProvider, setResolvedDataProvider] = React.useState(); 18 | 19 | React.useEffect(() => { 20 | void (async () => { 21 | const dp = await buildHasuraProvider({ client: authGQLClient }); 22 | setResolvedDataProvider(() => dp); 23 | })(); 24 | }, []); 25 | 26 | if (!resolvedDataProvider) { 27 | return (
Loading...
); 28 | } 29 | 30 | return ( 31 | } 38 | login={Login} 39 | authProvider={authProvider} 40 | > 41 | {pageResources} 42 | 43 | ); 44 | }; 45 | 46 | export default App; 47 | -------------------------------------------------------------------------------- /src/index.ejs: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | <%= htmlWebpackPlugin.options.title %> 8 | 9 | 10 | 11 | 12 |
13 | 14 | 15 | -------------------------------------------------------------------------------- /src/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Hasura React Admin 8 | 9 | 10 | 11 |
12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /src/index.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { render } from "react-dom"; 3 | import App from "./App"; 4 | import registerServiceWorker from "./registerServiceWorker"; 5 | 6 | render( 7 | , 8 | document.getElementById("root") 9 | ); 10 | 11 | registerServiceWorker(); 12 | -------------------------------------------------------------------------------- /src/pages/Configuration.tsx: -------------------------------------------------------------------------------- 1 | import { makeStyles, Button, Card, CardContent } from "@material-ui/core"; 2 | import * as React from "react"; 3 | import { useLocale, useSetLocale, useTranslate, Title } from "react-admin"; 4 | import { useDispatch, useSelector } from "react-redux"; 5 | import { Locale } from "../shared/i18n"; 6 | import { changeTheme } from "../shared/store/action"; 7 | import { AppState, Theme } from "../shared/store/types"; 8 | 9 | const useStyles = makeStyles({ 10 | label: { 11 | width: "10em", 12 | display: "inline-block" 13 | }, 14 | button: { margin: "1em" } 15 | }); 16 | 17 | type Props = { 18 | 19 | }; 20 | const Configuration = (props: Props): JSX.Element => { 21 | const translate = useTranslate(); 22 | const locale = useLocale(); 23 | const setLocale = useSetLocale(); 24 | const classes = useStyles(props); 25 | const theme = useSelector((state: AppState) => state.config ? state.config.theme : Theme.Light); 26 | const dispatch = useDispatch(); 27 | 28 | const cbChangeTheme = (value: Theme) => () => dispatch(changeTheme(value)); 29 | const cbSetLocale = (lc: Locale) => () => setLocale(lc); 30 | 31 | return ( 32 | 33 | 34 | <CardContent> 35 | <div className={classes.label}> 36 | {translate("layout.theme.name")} 37 | </div> 38 | <Button 39 | variant="contained" 40 | className={classes.button} 41 | color={theme === Theme.Light ? "primary" : "default"} 42 | onClick={cbChangeTheme(Theme.Light)} 43 | > 44 | {translate("layout.theme.light")} 45 | </Button> 46 | <Button 47 | variant="contained" 48 | className={classes.button} 49 | color={theme === Theme.Dark ? "primary" : "default"} 50 | onClick={cbChangeTheme(Theme.Dark)} 51 | > 52 | {translate("layout.theme.dark")} 53 | </Button> 54 | </CardContent> 55 | <CardContent> 56 | <div className={classes.label}>{translate("common.language")}</div> 57 | <Button 58 | variant="contained" 59 | className={classes.button} 60 | color={locale === "en" ? "primary" : "default"} 61 | onClick={cbSetLocale(Locale.English)} 62 | > 63 | en 64 | </Button> 65 | <Button 66 | variant="contained" 67 | className={classes.button} 68 | color={locale === "vi" ? "primary" : "default"} 69 | onClick={cbSetLocale(Locale.Vietnamese)} 70 | > 71 | fr 72 | </Button> 73 | </CardContent> 74 | </Card> 75 | ); 76 | }; 77 | 78 | export default Configuration; 79 | -------------------------------------------------------------------------------- /src/pages/index.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { Route } from "react-router"; 3 | import Configuration from "./Configuration"; 4 | import { userResources } from "./users"; 5 | 6 | type Renderer = () => JSX.Element; 7 | export type CustomRoute = { 8 | readonly exact: boolean 9 | readonly path: string 10 | readonly render: Renderer 11 | }; 12 | 13 | export const pageResources = [ 14 | ...userResources 15 | ]; 16 | 17 | export const customRoutes = [{ 18 | exact: true, 19 | path: "/configuration", 20 | render: () => <Configuration /> 21 | }].map((m) => ( 22 | <Route {...m} key={m.path} /> 23 | )); 24 | -------------------------------------------------------------------------------- /src/pages/routes.ts: -------------------------------------------------------------------------------- 1 | import AccountBoxIcon from "@material-ui/icons/AccountBox"; 2 | import { RouteGroup } from "../shared/components/Layout/Menu"; 3 | 4 | export const ROUTE_USERS = "/users"; 5 | 6 | export const sidebarRoutes: readonly RouteGroup[] = [{ 7 | name: "users", 8 | title: "layout.menu.users", 9 | iconComponent: AccountBoxIcon, 10 | items: [{ 11 | href: ROUTE_USERS, 12 | title: "layout.menu.users", 13 | name: "users-list", 14 | iconComponent: AccountBoxIcon 15 | }] 16 | }]; 17 | -------------------------------------------------------------------------------- /src/pages/users/UserCreate.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { Create, SelectInput, SimpleForm, TextInput } from "react-admin"; 3 | import { Roles } from "../../shared/types"; 4 | 5 | const choices = Roles.map((r) => ({ 6 | id: r, 7 | name: r 8 | })); 9 | 10 | type Props = { 11 | 12 | }; 13 | export default (props: Props): JSX.Element => ( 14 | <Create {...props}> 15 | <SimpleForm> 16 | <TextInput source="email" /> 17 | <TextInput source="password" type="password" /> 18 | <TextInput source="firstName" /> 19 | <TextInput source="lastName" /> 20 | <SelectInput source="role" choices={choices} /> 21 | </SimpleForm> 22 | </Create> 23 | ); 24 | -------------------------------------------------------------------------------- /src/pages/users/index.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { EditGuesser, ListGuesser, Resource } from "react-admin"; 3 | import UserCreate from "./UserCreate"; 4 | 5 | export const userResources = [ 6 | <Resource 7 | key="users" 8 | name="users" 9 | list={ListGuesser} 10 | edit={EditGuesser} 11 | create={UserCreate} 12 | /> 13 | ]; 14 | -------------------------------------------------------------------------------- /src/registerServiceWorker.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-floating-promises */ 2 | /* eslint-disable @typescript-eslint/no-unsafe-return */ 3 | /* eslint-disable functional/immutable-data */ 4 | // In production, we register a service worker to serve assets from local cache. 5 | 6 | // This lets the app load faster on subsequent visits in production, and gives 7 | // it offline capabilities. However, it also means that developers (and users) 8 | // will only see deployed updates on the "N+1" visit to a page, since previously 9 | // cached resources are updated in the background. 10 | 11 | // To learn more about the benefits of this model, read https://goo.gl/KwvDNy. 12 | // This link also includes instructions on opting out of this behavior. 13 | 14 | const isLocalhost = Boolean( 15 | window.location.hostname === "localhost" || 16 | // [::1] is the IPv6 localhost address. 17 | window.location.hostname === "[::1]" || 18 | // 127.0.0.1/8 is considered localhost for IPv4. 19 | // eslint-disable-next-line @typescript-eslint/prefer-regexp-exec 20 | window.location.hostname.match( 21 | /^127(?:\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$/ 22 | ) 23 | ); 24 | 25 | export default function register(): void { 26 | if (!(process.env.NODE_ENV === "production" && "serviceWorker" in navigator)) { 27 | return; 28 | } 29 | 30 | // The URL constructor is available in all browsers that support SW. 31 | const publicUrl = new URL(process.env.PUBLIC_URL, (<any>window).location); 32 | if (publicUrl.origin !== window.location.origin) { 33 | // Our service worker won't work if PUBLIC_URL is on a different origin 34 | // from what our page is served on. This might happen if a CDN is used to 35 | // serve assets; see https://github.com/facebookincubator/create-react-app/issues/2374 36 | return; 37 | } 38 | 39 | window.addEventListener("load", () => { 40 | const swUrl = `${process.env.PUBLIC_URL}/service-worker.js`; 41 | 42 | if (isLocalhost) { 43 | return registerValidSW(swUrl); 44 | } 45 | // This is running on localhost. Lets check if a service worker still exists or not. 46 | checkValidServiceWorker(swUrl); 47 | 48 | // Add some additional logging to localhost, pointing developers to the 49 | // service worker/PWA documentation. 50 | navigator.serviceWorker.ready.then(() => { 51 | console.log( 52 | "This web app is being served cache-first by a service " + 53 | "worker. To learn more, visit https://goo.gl/SC7cgQ" 54 | ); 55 | }); 56 | }); 57 | } 58 | 59 | function registerValidSW(swUrl): any { 60 | navigator.serviceWorker 61 | .register(swUrl) 62 | .then((registration) => { 63 | registration.onupdatefound = () => { 64 | const installingWorker = registration.installing; 65 | installingWorker.onstatechange = () => { 66 | if (installingWorker.state !== "installed") { 67 | return; 68 | } 69 | 70 | if (navigator.serviceWorker.controller) { 71 | // At this point, the old content will have been purged and 72 | // the fresh content will have been added to the cache. 73 | // It's the perfect time to display a "New content is 74 | // available; please refresh." message in your web app. 75 | console.log("New content is available; please refresh."); 76 | } else { 77 | // At this point, everything has been precached. 78 | // It's the perfect time to display a 79 | // "Content is cached for offline use." message. 80 | console.log("Content is cached for offline use."); 81 | } 82 | }; 83 | }; 84 | }) 85 | .catch((error) => { 86 | console.error("Error during service worker registration:", error); 87 | }); 88 | } 89 | 90 | function checkValidServiceWorker(swUrl): void { 91 | // Check if the service worker can be found. If it can't reload the page. 92 | fetch(swUrl) 93 | .then((response) => { 94 | // Ensure service worker exists, and that we really are getting a JS file. 95 | if (response.status === 404 || 96 | !response.headers.get("content-type").includes("javascript") 97 | ) { 98 | // No service worker found. Probably a different app. Reload the page. 99 | navigator.serviceWorker.ready.then((registration) => { 100 | registration.unregister().then(() => { 101 | window.location.reload(); 102 | }); 103 | }); 104 | 105 | return; 106 | } 107 | // Service worker found. Proceed as normal. 108 | registerValidSW(swUrl); 109 | 110 | }) 111 | .catch(() => { 112 | console.log("No internet connection found. App is running in offline mode."); 113 | }); 114 | } 115 | 116 | export function unregister(): void { 117 | if (!("serviceWorker" in navigator)) { 118 | return; 119 | } 120 | navigator.serviceWorker.ready.then((registration) => { 121 | registration.unregister(); 122 | }); 123 | } 124 | -------------------------------------------------------------------------------- /src/shared/ApolloClient.ts: -------------------------------------------------------------------------------- 1 | import { from, ApolloClient, ApolloLink, HttpLink, InMemoryCache, split } from "@apollo/client"; 2 | import { getMainDefinition } from "@apollo/client/utilities"; 3 | import { OperationDefinitionNode } from "graphql"; 4 | import { WebSocketLink } from "@apollo/client/link/ws"; 5 | import { XHasuraAdminSecret } from "./AuthProvder"; 6 | import { Config } from "./config"; 7 | 8 | export const XHasuraClientName = "hasura-client-name"; 9 | 10 | const authLink = new ApolloLink((operation, forward) => { 11 | operation.setContext(({ headers }) => ({ 12 | headers: { 13 | [XHasuraAdminSecret]: Config.adminSecret, 14 | ...headers 15 | } 16 | })); 17 | 18 | return forward(operation); 19 | }); 20 | 21 | const splitLink = (http: ApolloLink, ws: WebSocketLink): ApolloLink => split( 22 | // split based on operation type 23 | ({ query }) => { 24 | const { kind, operation } = getMainDefinition(query) as OperationDefinitionNode; 25 | 26 | return kind === "OperationDefinition" && operation === "subscription"; 27 | }, 28 | ws, 29 | http 30 | ); 31 | 32 | const httpLink = from([ 33 | authLink, 34 | new HttpLink({ 35 | uri: Config.httpDataHost, 36 | headers: { 37 | [XHasuraClientName]: Config.hasuraClientName 38 | } 39 | }) 40 | ]); 41 | 42 | const wsLink = new WebSocketLink({ 43 | uri: Config.wsDataHost, 44 | options: { 45 | reconnect: true, 46 | connectionParams: { 47 | headers: { 48 | [XHasuraAdminSecret]: Config.adminSecret, 49 | [XHasuraClientName]: Config.hasuraClientName 50 | } 51 | }, 52 | lazy: true, 53 | connectionCallback: (error) => { 54 | console.error("connection error: ", error); 55 | } 56 | } 57 | }); 58 | 59 | const commonApolloOptions = { 60 | version: Config.version 61 | }; 62 | 63 | export const authGQLClient = new ApolloClient({ 64 | cache: new InMemoryCache(), 65 | link: splitLink(httpLink, wsLink), 66 | ...commonApolloOptions 67 | }); 68 | 69 | export const gqlClient = new ApolloClient({ 70 | cache: new InMemoryCache(), 71 | link: new HttpLink({ 72 | uri: Config.httpDataHost 73 | }), 74 | ...commonApolloOptions 75 | }); 76 | -------------------------------------------------------------------------------- /src/shared/AuthProvder.ts: -------------------------------------------------------------------------------- 1 | import { fetchUtils } from "ra-core/lib/dataProvider"; 2 | import { AuthProvider } from "react-admin"; 3 | export const AuthorizationHeader = "Authorization"; 4 | export const XHasuraAdminSecret = "X-Hasura-Admin-Secret"; 5 | 6 | export type FetchResponse = { 7 | readonly status: number 8 | readonly headers: Headers 9 | readonly body: string 10 | readonly json: any 11 | }; 12 | 13 | export const httpClient = (url: string, options: any = {}): Promise<FetchResponse> => { 14 | if (!options.headers) { 15 | // eslint-disable-next-line functional/immutable-data 16 | options.headers = new Headers({ 17 | Accept: "application/json" 18 | }); 19 | } 20 | // add your own headers here 21 | // options.headers.set(AuthenticationHeader, "Bearer xxxxx"); 22 | options.headers.set(XHasuraAdminSecret, process.env.HASURA_GRAPHQL_ADMIN_SECRET); 23 | 24 | return fetchUtils.fetchJson(url, options); 25 | }; 26 | 27 | export const authProvider: AuthProvider = { 28 | login: ({ username }) => { 29 | localStorage.setItem("username", username); 30 | 31 | // accept all username/password combinations 32 | return Promise.resolve(); 33 | }, 34 | logout: () => { 35 | localStorage.removeItem("username"); 36 | 37 | return Promise.resolve(); 38 | }, 39 | checkError: () => Promise.resolve(), 40 | checkAuth: () => 41 | localStorage.getItem("username") ? Promise.resolve() : Promise.reject(), 42 | getPermissions: () => Promise.reject("Unknown method") 43 | }; 44 | -------------------------------------------------------------------------------- /src/shared/components/Auth/Login.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/ban-ts-ignore */ 2 | import * as React from "react"; 3 | import { withTypes, Field } from "react-final-form"; 4 | import { 5 | createMuiTheme, 6 | makeStyles, 7 | Avatar, 8 | Button, 9 | Card, 10 | CardActions, 11 | CircularProgress, 12 | TextField 13 | } from "@material-ui/core"; 14 | import { Lock } from "@material-ui/icons"; 15 | import { ThemeProvider } from "@material-ui/styles"; 16 | import { useLogin, useNotify, useTranslate } from "ra-core"; 17 | import { Notification } from "react-admin"; 18 | import { lightTheme } from "../Layout/themes"; 19 | import { AppLocation } from "../../types"; 20 | 21 | const useStyles = makeStyles((theme) => ({ 22 | main: { 23 | display: "flex", 24 | flexDirection: "column", 25 | minHeight: "100vh", 26 | alignItems: "center", 27 | justifyContent: "flex-start", 28 | background: "url(https://source.unsplash.com/random/1600x900)", 29 | backgroundRepeat: "no-repeat", 30 | backgroundSize: "cover" 31 | }, 32 | card: { 33 | minWidth: 300, 34 | marginTop: "6em" 35 | }, 36 | avatar: { 37 | margin: "1em", 38 | display: "flex", 39 | justifyContent: "center" 40 | }, 41 | icon: { 42 | backgroundColor: theme.palette.secondary.main 43 | }, 44 | hint: { 45 | marginTop: "1em", 46 | display: "flex", 47 | justifyContent: "center", 48 | color: theme.palette.grey[500] 49 | }, 50 | form: { 51 | padding: "0 1em 1em 1em" 52 | }, 53 | input: { 54 | marginTop: "1em" 55 | }, 56 | actions: { 57 | padding: "0 1em 1em 1em" 58 | } 59 | })); 60 | 61 | const renderInput = ({ 62 | meta: { touched, error } = { touched: false, error: null }, 63 | input: { ...inputProps }, 64 | ...props 65 | }): JSX.Element => 66 | ( 67 | <TextField 68 | error={!!(touched && error)} 69 | helperText={touched && error} 70 | {...inputProps} 71 | {...props} 72 | fullWidth={true} 73 | /> 74 | ); 75 | 76 | type FormValues = { 77 | readonly username?: string 78 | readonly password?: string 79 | }; 80 | 81 | const { Form } = withTypes<FormValues>(); 82 | 83 | const Login = (props: { readonly location: AppLocation }): JSX.Element => { 84 | const { location } = props; 85 | const [loading, setLoading] = React.useState(false); 86 | const translate = useTranslate(); 87 | const classes = useStyles(props); 88 | const notify = useNotify(); 89 | const login = useLogin(); 90 | 91 | const handleSubmitFn = (auth: FormValues): void => { 92 | setLoading(true); 93 | login(auth, location.state ? location.state.nextPathname : "/").catch( 94 | (error: Error) => { 95 | setLoading(false); 96 | notify( 97 | typeof error === "string" 98 | ? error 99 | : typeof error === "undefined" || !error.message 100 | ? "ra.auth.sign_in_error" 101 | : error.message, 102 | "warning" 103 | ); 104 | } 105 | ); 106 | }; 107 | 108 | const validate = (values: FormValues): FormValues => Object.keys(values).reduce((acc, k) => values[k] ? acc : ({ 109 | ...acc, 110 | [k]: translate("ra.validation.required") 111 | }), {}); 112 | 113 | return ( 114 | <Form 115 | onSubmit={handleSubmitFn} 116 | validate={validate} 117 | render={({ handleSubmit }) => ( 118 | <form onSubmit={handleSubmit} noValidate={true}> 119 | <div className={classes.main}> 120 | <Card className={classes.card}> 121 | <div className={classes.avatar}> 122 | <Avatar className={classes.icon}> 123 | <Lock /> 124 | </Avatar> 125 | </div> 126 | <div className={classes.hint}> 127 | Hint: demo / demo 128 | </div> 129 | <div className={classes.form}> 130 | <div className={classes.input}> 131 | <Field 132 | autoFocus={true} 133 | name="username" 134 | // @ts-ignore 135 | component={renderInput} 136 | label={translate("ra.auth.username")} 137 | disabled={loading} 138 | /> 139 | </div> 140 | <div className={classes.input}> 141 | <Field 142 | name="password" 143 | // @ts-ignore 144 | component={renderInput} 145 | label={translate("ra.auth.password")} 146 | type="password" 147 | disabled={loading} 148 | /> 149 | </div> 150 | </div> 151 | <CardActions className={classes.actions}> 152 | <Button 153 | variant="contained" 154 | type="submit" 155 | color="primary" 156 | disabled={loading} 157 | fullWidth={true} 158 | > 159 | {loading && ( 160 | <CircularProgress 161 | size={25} 162 | thickness={2} 163 | /> 164 | )} 165 | {translate("ra.auth.sign_in")} 166 | </Button> 167 | </CardActions> 168 | </Card> 169 | <Notification /> 170 | </div> 171 | </form> 172 | )} 173 | /> 174 | ); 175 | }; 176 | 177 | // We need to put the ThemeProvider decoration in another component 178 | // Because otherwise the useStyles() hook used in Login won't get 179 | // the right theme 180 | const LoginWithTheme = (props: any): JSX.Element => ( 181 | <ThemeProvider theme={createMuiTheme(lightTheme)}> 182 | <Login {...props} /> 183 | </ThemeProvider> 184 | ); 185 | 186 | export default LoginWithTheme; 187 | -------------------------------------------------------------------------------- /src/shared/components/Layout/AppBar.tsx: -------------------------------------------------------------------------------- 1 | import { makeStyles, Typography } from "@material-ui/core"; 2 | import { Settings } from "@material-ui/icons"; 3 | import * as React from "react"; 4 | import { useTranslate, AppBar, MenuItemLink, UserMenu } from "react-admin"; 5 | 6 | const useStyles = makeStyles({ 7 | title: { 8 | flex: 1, 9 | textOverflow: "ellipsis", 10 | whiteSpace: "nowrap", 11 | overflow: "hidden" 12 | }, 13 | spacer: { 14 | flex: 1 15 | } 16 | }); 17 | 18 | const ConfigurationMenu = React.forwardRef<any, any>((props, ref) => { 19 | const translate = useTranslate(); 20 | 21 | return ( 22 | <MenuItemLink 23 | ref={ref} 24 | to="/configuration" 25 | primaryText={translate("configuration")} 26 | leftIcon={<Settings />} 27 | onClick={props.onClick} 28 | /> 29 | ); 30 | }); 31 | 32 | const CustomUserMenu = (props: any): JSX.Element => ( 33 | <UserMenu {...props}> 34 | <ConfigurationMenu /> 35 | </UserMenu> 36 | ); 37 | 38 | type CustomAppBarProps = { 39 | 40 | }; 41 | const CustomAppBar = (props: CustomAppBarProps): JSX.Element => { 42 | const classes = useStyles(props); 43 | 44 | return ( 45 | <AppBar {...props} userMenu={<CustomUserMenu />}> 46 | <Typography 47 | variant="h6" 48 | color="inherit" 49 | className={classes.title} 50 | id="react-admin-title" 51 | /> 52 | <span className={classes.spacer} /> 53 | </AppBar> 54 | ); 55 | }; 56 | 57 | export default CustomAppBar; 58 | -------------------------------------------------------------------------------- /src/shared/components/Layout/Layout.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { Layout, Sidebar } from "react-admin"; 3 | import { useSelector } from "react-redux"; 4 | import { AppState, Theme } from "../../store/types"; 5 | import AppBar from "./AppBar"; 6 | import Menu, { RouteGroup } from "./Menu"; 7 | import { darkTheme, lightTheme } from "./themes"; 8 | 9 | const CustomSidebar = (props: any): JSX.Element => 10 | <Sidebar {...props} size={200} />; 11 | 12 | type Props = { 13 | readonly sidebarRoutes: readonly RouteGroup[] 14 | }; 15 | 16 | export default (props: Props): JSX.Element => { 17 | const theme = useSelector((state: AppState) => 18 | state.config && state.config.theme === Theme.Dark ? darkTheme : lightTheme 19 | ); 20 | 21 | return ( 22 | <Layout 23 | {...props} 24 | appBar={AppBar} 25 | sidebar={CustomSidebar} 26 | menu={(mProps) => <Menu subMenus={props.sidebarRoutes} {...mProps} />} 27 | theme={theme} 28 | /> 29 | ); 30 | }; 31 | -------------------------------------------------------------------------------- /src/shared/components/Layout/Menu.tsx: -------------------------------------------------------------------------------- 1 | import { useMediaQuery, Theme } from "@material-ui/core"; 2 | import { Settings } from "@material-ui/icons"; 3 | import * as React from "react"; 4 | import { useTranslate, DashboardMenuItem, MenuItemLink } from "react-admin"; 5 | import { useSelector } from "react-redux"; 6 | import { OverridableComponent } from "@material-ui/core/OverridableComponent"; 7 | import { AppState } from "../../store/types"; 8 | import SubMenu from "./SubMenu"; 9 | 10 | export type RouteItem = { 11 | readonly name: string 12 | readonly title: string 13 | readonly href: string 14 | readonly iconComponent?: OverridableComponent<any> 15 | }; 16 | 17 | export type RouteGroup = { 18 | 19 | readonly name: string 20 | readonly title: string 21 | readonly iconComponent?: OverridableComponent<any> 22 | readonly items: readonly RouteItem[] 23 | }; 24 | 25 | type MenuProps = { 26 | readonly dense: boolean 27 | readonly logout: VoidFunction 28 | readonly onMenuClick: VoidFunction 29 | readonly subMenus: readonly RouteGroup[] 30 | }; 31 | 32 | const Menu: React.FC<MenuProps> = ({ onMenuClick, dense, logout, subMenus }) => { 33 | const [state, setState] = React.useState(subMenus.reduce((acc, sm) => ({ 34 | ...acc, 35 | [sm.name]: false 36 | }), {})); 37 | const translate = useTranslate(); 38 | const isXSmall = useMediaQuery((theme: Theme) => 39 | theme.breakpoints.down("xs") 40 | ); 41 | const open = useSelector((st: AppState) => st.admin.ui.sidebarOpen); 42 | useSelector((st: AppState) => st.config && st.config.theme); // force rerender on theme change 43 | 44 | const handleToggle = (menu: string): void => { 45 | setState((st) => ({ ...st, [menu]: !state[menu] })); 46 | }; 47 | 48 | const renderMenuItem = (item: RouteItem): JSX.Element => ( 49 | <MenuItemLink 50 | key={`menu-${item.title}`} 51 | to={item.href} 52 | primaryText={translate(item.title)} 53 | leftIcon={item.iconComponent ? <item.iconComponent /> : null} 54 | onClick={onMenuClick} 55 | sidebarIsOpen={open} 56 | dense={dense} 57 | /> 58 | ); 59 | 60 | const renderSubMemu = ({ name, title, items, iconComponent: IconComponent }: RouteGroup): JSX.Element => ( 61 | <SubMenu 62 | handleToggle={() => handleToggle(name)} 63 | isOpen={state[name]} 64 | sidebarIsOpen={open} 65 | name={title} 66 | icon={IconComponent ? <IconComponent /> : undefined} 67 | dense={dense}> 68 | {items.map((r) => renderMenuItem(r))} 69 | </SubMenu> 70 | ); 71 | 72 | return ( 73 | <div> 74 | {" "} 75 | <DashboardMenuItem onClick={onMenuClick} sidebarIsOpen={open} /> 76 | {subMenus.map((sm) => renderSubMemu(sm))} 77 | 78 | {isXSmall && ( 79 | <MenuItemLink 80 | to="/configuration" 81 | primaryText={translate("resources.configuration.name")} 82 | leftIcon={<Settings />} 83 | onClick={onMenuClick} 84 | sidebarIsOpen={open} 85 | dense={dense} 86 | /> 87 | )} 88 | {isXSmall && logout} 89 | </div> 90 | ); 91 | }; 92 | 93 | export default Menu; 94 | -------------------------------------------------------------------------------- /src/shared/components/Layout/SubMenu.tsx: -------------------------------------------------------------------------------- 1 | import { makeStyles, Collapse, Divider, List, ListItemIcon, MenuItem, Tooltip, Typography } from "@material-ui/core"; 2 | import { ExpandMore } from "@material-ui/icons"; 3 | import * as React from "react"; 4 | import { useTranslate } from "react-admin"; 5 | 6 | const useStyles = makeStyles((theme) => ({ 7 | icon: { minWidth: theme.spacing(5) }, 8 | sidebarIsOpen: { 9 | paddingLeft: 25, 10 | transition: "padding-left 195ms cubic-bezier(0.4, 0, 0.6, 1) 0ms" 11 | }, 12 | sidebarIsClosed: { 13 | paddingLeft: 0, 14 | transition: "padding-left 195ms cubic-bezier(0.4, 0, 0.6, 1) 0ms" 15 | } 16 | })); 17 | 18 | type Props = { 19 | readonly dense: boolean 20 | readonly handleToggle: VoidFunction 21 | readonly icon: React.ReactElement 22 | readonly isOpen: boolean 23 | readonly name: string 24 | readonly sidebarIsOpen: boolean 25 | }; 26 | 27 | const SubMenu: React.FC<Props> = (props) => { 28 | const { 29 | handleToggle, 30 | sidebarIsOpen, 31 | isOpen, 32 | name, 33 | icon, 34 | children, 35 | dense 36 | } = props; 37 | const translate = useTranslate(); 38 | const classes = useStyles(props); 39 | 40 | const header = ( 41 | <MenuItem dense={dense} button={true} onClick={handleToggle}> 42 | <ListItemIcon className={classes.icon}> 43 | {isOpen ? <ExpandMore /> : icon} 44 | </ListItemIcon> 45 | <Typography variant="inherit" color="textSecondary"> 46 | {translate(name)} 47 | </Typography> 48 | </MenuItem> 49 | ); 50 | 51 | return ( 52 | <React.Fragment> 53 | {sidebarIsOpen || isOpen ? ( 54 | header 55 | ) : 56 | ( 57 | <Tooltip title={translate(name)} placement="right"> 58 | {header} 59 | </Tooltip> 60 | )} 61 | <Collapse in={isOpen} timeout="auto" unmountOnExit={true}> 62 | <List 63 | dense={dense} 64 | component="div" 65 | disablePadding={true} 66 | className={ 67 | sidebarIsOpen 68 | ? classes.sidebarIsOpen 69 | : classes.sidebarIsClosed 70 | } 71 | > 72 | {children} 73 | </List> 74 | <Divider /> 75 | </Collapse> 76 | </React.Fragment> 77 | ); 78 | }; 79 | 80 | export default SubMenu; 81 | -------------------------------------------------------------------------------- /src/shared/components/Layout/index.ts: -------------------------------------------------------------------------------- 1 | import AppBar from "./AppBar"; 2 | import Layout from "./Layout"; 3 | import Menu from "./Menu"; 4 | 5 | export { AppBar, Layout, Menu }; 6 | -------------------------------------------------------------------------------- /src/shared/components/Layout/themes.ts: -------------------------------------------------------------------------------- 1 | export const darkTheme = { 2 | palette: { 3 | type: "dark" // Switching the dark mode on is a single property value change. 4 | } 5 | }; 6 | 7 | export const lightTheme = { 8 | palette: { 9 | secondary: { 10 | light: "#5f5fc4", 11 | main: "#283593", 12 | dark: "#001064", 13 | contrastText: "#fff" 14 | } 15 | }, 16 | overrides: { 17 | MuiFilledInput: { 18 | root: { 19 | backgroundColor: "rgba(0, 0, 0, 0.04)", 20 | "&$disabled": { 21 | backgroundColor: "rgba(0, 0, 0, 0.04)" 22 | } 23 | } 24 | } 25 | } 26 | }; 27 | -------------------------------------------------------------------------------- /src/shared/config.ts: -------------------------------------------------------------------------------- 1 | function assertEnv(value: string, key: string): string { 2 | 3 | if (!value) { 4 | throw new Error(`Environment ${key} doesn't exist`); 5 | } 6 | 7 | return value; 8 | } 9 | 10 | const DATA_SCHEME = process.env.DATA_SCHEME || "http"; 11 | const DATA_DOMAIN = assertEnv(process.env.DATA_DOMAIN, "DATA_DOMAIN"); 12 | const WS_SCHEME = DATA_SCHEME === "https" ? "wss" : "ws"; 13 | 14 | export const Config = { 15 | httpDataHost: `${DATA_SCHEME}://${DATA_DOMAIN}/v1/graphql`, 16 | wsDataHost: `${WS_SCHEME}://${DATA_DOMAIN}/v1/graphql`, 17 | adminSecret: assertEnv(process.env.HASURA_GRAPHQL_ADMIN_SECRET, "HASURA_GRAPHQL_ADMIN_SECRET"), 18 | hasuraClientName: assertEnv(process.env.HASURA_CLIENT_NAME, "HASURA_CLIENT_NAME"), 19 | version: process.env.VERSION || "1.0.0", 20 | debug: process.env.NODE_ENV !== "production" 21 | }; 22 | -------------------------------------------------------------------------------- /src/shared/i18n/en.ts: -------------------------------------------------------------------------------- 1 | import englishMessages from "ra-language-english"; 2 | 3 | // https://marmelab.com/react-admin/Translation.html 4 | export default { 5 | ...englishMessages, 6 | layout: { 7 | theme: { 8 | light: "light", 9 | dark: "dark" 10 | }, 11 | menu: { 12 | users: "Users" 13 | } 14 | }, 15 | common: { 16 | language: "language" 17 | }, 18 | resources: { 19 | users: { 20 | name: "User" 21 | }, 22 | configuration: { 23 | name: "configuration" 24 | } 25 | } 26 | }; 27 | -------------------------------------------------------------------------------- /src/shared/i18n/index.ts: -------------------------------------------------------------------------------- 1 | import polyglotI18nProvider from "ra-i18n-polyglot"; 2 | import en from "./en"; 3 | import vi from "./vi"; 4 | 5 | export enum Locale { 6 | English = "en", 7 | Vietnamese = "vi", 8 | } 9 | 10 | export default polyglotI18nProvider((locale) => { 11 | switch (locale) { 12 | case Locale.Vietnamese: 13 | return vi; 14 | default: 15 | return en; 16 | } 17 | }, Locale.English); 18 | -------------------------------------------------------------------------------- /src/shared/i18n/vi.ts: -------------------------------------------------------------------------------- 1 | import englishMessages from "ra-language-english"; 2 | 3 | // https://marmelab.com/react-admin/Translation.html 4 | export default { 5 | ...englishMessages, 6 | layout: { 7 | theme: { 8 | light: "sáng", 9 | dark: "tối" 10 | }, 11 | menu: { 12 | users: "Users" 13 | } 14 | }, 15 | common: { 16 | language: "language" 17 | }, 18 | resources: { 19 | users: { 20 | name: "User" 21 | }, 22 | configuration: { 23 | name: "configuration" 24 | } 25 | } 26 | }; 27 | -------------------------------------------------------------------------------- /src/shared/store/action.ts: -------------------------------------------------------------------------------- 1 | export * from "./config/action"; 2 | -------------------------------------------------------------------------------- /src/shared/store/config/action.ts: -------------------------------------------------------------------------------- 1 | import { CHANGE_THEME, ChangeThemeAction, Theme } from "./types"; 2 | 3 | export function changeTheme(theme: Theme): ChangeThemeAction { 4 | return { 5 | type: CHANGE_THEME, 6 | payload: theme 7 | }; 8 | } 9 | -------------------------------------------------------------------------------- /src/shared/store/config/reducer.ts: -------------------------------------------------------------------------------- 1 | import { Reducer } from "redux"; 2 | import { ConfigAction, CHANGE_THEME, ConfigState, Theme } from "./types"; 3 | 4 | export const initialConfigState: () => ConfigState = () => ({ 5 | theme: Theme.Light 6 | }); 7 | 8 | type ConfigReducer = Reducer<ConfigState, ConfigAction>; 9 | export const configReducer: ConfigReducer = (state = initialConfigState(), action) => { 10 | switch (action.type) { 11 | case CHANGE_THEME: 12 | return { 13 | ...state, 14 | theme: action.payload 15 | }; 16 | default: 17 | return state; 18 | } 19 | }; 20 | -------------------------------------------------------------------------------- /src/shared/store/config/types.ts: -------------------------------------------------------------------------------- 1 | export enum Theme { 2 | Light = "Light", 3 | Dark = "Dark", 4 | } 5 | 6 | export type ConfigState = { 7 | readonly theme: Theme 8 | }; 9 | 10 | export const CHANGE_THEME = "CHANGE_THEME"; 11 | 12 | export type ChangeThemeAction = { 13 | readonly type: typeof CHANGE_THEME 14 | readonly payload: Theme 15 | }; 16 | 17 | export type ConfigAction 18 | = ChangeThemeAction; 19 | -------------------------------------------------------------------------------- /src/shared/store/reducer.ts: -------------------------------------------------------------------------------- 1 | import { configReducer, initialConfigState } from "./config/reducer"; 2 | import { InternalAppState } from "./types"; 3 | 4 | export const initialAppState = (): InternalAppState => ({ 5 | config: initialConfigState() 6 | }); 7 | 8 | export const appReducer = { 9 | config: configReducer 10 | }; 11 | -------------------------------------------------------------------------------- /src/shared/store/types.ts: -------------------------------------------------------------------------------- 1 | import { ReduxState } from "ra-core"; 2 | import { ConfigState } from "./config/types"; 3 | export * from "./config/types"; 4 | 5 | export type InternalAppState = { 6 | readonly config: ConfigState 7 | }; 8 | 9 | export type AppState = ReduxState & InternalAppState; 10 | -------------------------------------------------------------------------------- /src/shared/types.ts: -------------------------------------------------------------------------------- 1 | import { Location } from "history"; 2 | 3 | export enum Role { 4 | Admin = "admin", 5 | User = "user" 6 | } 7 | 8 | export const Roles = [ 9 | Role.Admin, 10 | Role.User 11 | ]; 12 | 13 | export type AppLocation = Location<{ 14 | readonly nextPathname: string 15 | }>; 16 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "es2015", 4 | "moduleResolution": "node", 5 | "jsx": "react", 6 | "target": "es6", 7 | "lib": [ 8 | "es2015", 9 | "dom" 10 | ], 11 | "declaration": false, 12 | "noImplicitAny": false, 13 | "noImplicitReturns": true, 14 | "noUnusedLocals": true, 15 | "noUnusedParameters": true, 16 | "allowSyntheticDefaultImports": false, 17 | "esModuleInterop": true, 18 | "sourceMap": true, 19 | "outDir": "dist" 20 | }, 21 | "formatCodeOptions": { 22 | "indentSize": 2, 23 | "tabSize": 2 24 | }, 25 | "include": [ 26 | "src" 27 | ], 28 | "exclude": [ 29 | "**/node_modules", 30 | "**/.git", 31 | ] 32 | } 33 | --------------------------------------------------------------------------------