├── .env ├── .eslintignore ├── .eslintrc.js ├── .github ├── mergeable.yml ├── pull_request_template.md ├── release-drafter.yml └── workflows │ ├── pr-labeler.yml │ └── release-drafter.yml ├── .gitignore ├── .husky └── pre-commit ├── .npmrc ├── .prettierrc.js ├── .vscode ├── extensions.json └── settings.json ├── CONTRIBUTING.md ├── README.md ├── craco.config.js ├── package.json ├── public ├── _redirects ├── favicon.ico ├── index.html ├── logo192.png └── robots.txt ├── src ├── App.tsx ├── api │ └── api.ts ├── assets │ └── images │ │ ├── logo.svg │ │ └── no-image.png ├── components │ ├── atoms │ │ ├── .gitkeep │ │ ├── BrowserTitle │ │ │ └── BrowserTitle.tsx │ │ ├── Button │ │ │ ├── Button.module.scss │ │ │ └── Button.tsx │ │ ├── DebouncedInput │ │ │ └── DebouncedInput.tsx │ │ ├── Form │ │ │ └── Form.tsx │ │ ├── FormModal │ │ │ ├── FormModal.module.scss │ │ │ └── FormModal.tsx │ │ ├── Image │ │ │ └── Image.tsx │ │ ├── LoadingSpinner │ │ │ ├── LoadingSpinner.module.scss │ │ │ └── LoadingSpinner.tsx │ │ ├── ModalConfirm │ │ │ └── ModalConfirm.tsx │ │ └── SpinWrapper │ │ │ ├── SpinWrapper.module.scss │ │ │ └── SpinWrapper.tsx │ ├── layouts │ │ ├── BlankLayout │ │ │ └── BlankLayout.tsx │ │ ├── ContentLayout │ │ │ ├── ContentLayout.module.scss │ │ │ └── ContentLayout.tsx │ │ ├── HeaderLayout │ │ │ ├── HeaderLayout.module.scss │ │ │ └── HeaderLayout.tsx │ │ └── SidebarLayout │ │ │ ├── SidebarLayout.module.scss │ │ │ └── SidebarLayout.tsx │ └── molecules │ │ ├── Navigation │ │ ├── Navigation.module.scss │ │ ├── Navigation.tsx │ │ └── components │ │ │ ├── NavLeftContent │ │ │ ├── NavLeftContent.module.scss │ │ │ └── NavLeftContent.tsx │ │ │ ├── NavLink │ │ │ └── NavLink.tsx │ │ │ ├── NavMenu │ │ │ ├── NavMenu.module.scss │ │ │ └── NavMenu.tsx │ │ │ └── NavRightContent │ │ │ └── NavRightContent.tsx │ │ ├── PageFilter │ │ ├── PageFilter.tsx │ │ ├── components │ │ │ └── FilterItem │ │ │ │ └── FilterItem.tsx │ │ ├── helpers │ │ │ └── page-filter.helpers.ts │ │ ├── page-filter.md │ │ └── types │ │ │ └── page-filter.types.ts │ │ └── TableView │ │ ├── TableView.module.scss │ │ └── TableView.tsx ├── constants │ ├── api.constants.ts │ ├── env.ts │ └── route.constants.ts ├── features │ ├── auth │ │ ├── api │ │ │ └── auth.api.ts │ │ ├── auth.md │ │ ├── auth.ts │ │ ├── constants │ │ │ ├── auth.endpoints.ts │ │ │ ├── auth.keys.ts │ │ │ └── auth.paths.ts │ │ ├── helpers │ │ │ └── auth.helpers.ts │ │ ├── hooks │ │ │ └── useRedirectAfterLogin.ts │ │ ├── redux │ │ │ └── auth.slice.ts │ │ ├── routes │ │ │ └── auth.routes.ts │ │ ├── screens │ │ │ └── LoginScreen │ │ │ │ ├── LoginScreen.module.scss │ │ │ │ └── LoginScreen.tsx │ │ └── types │ │ │ └── auth.types.ts │ ├── home │ │ ├── constants │ │ │ └── home.paths.ts │ │ ├── home.ts │ │ ├── routes │ │ │ └── home.routes.ts │ │ └── screens │ │ │ └── HomeScreen │ │ │ └── HomeScreen.tsx │ ├── localization │ │ ├── config │ │ │ └── localization.config.ts │ │ ├── constants │ │ │ ├── localization.constants.ts │ │ │ └── localization.env.ts │ │ ├── helpers │ │ │ └── localization.helpers.ts │ │ ├── hooks │ │ │ └── useLocalization.ts │ │ └── localization.ts │ ├── permissions │ │ ├── components │ │ │ └── Permission │ │ │ │ └── Permission.tsx │ │ ├── constants │ │ │ ├── permissions.keys.ts │ │ │ └── permissions.scopes.ts │ │ ├── hooks │ │ │ └── permissions.hooks.ts │ │ ├── permissions.md │ │ ├── permissions.ts │ │ └── redux │ │ │ └── permissions.slice.ts │ └── settings │ │ ├── api │ │ └── users.api.ts │ │ ├── constants │ │ └── settings.paths.ts │ │ ├── helpers │ │ └── users.helper.ts │ │ ├── redux │ │ └── users.slice.ts │ │ ├── routes │ │ ├── SettingsRoutes.tsx │ │ └── settings.routes.ts │ │ ├── screens │ │ ├── ProjectsScreen │ │ │ └── ProjectsScreen.tsx │ │ └── UsersScreen │ │ │ ├── UsersScreen.tsx │ │ │ └── components │ │ │ ├── UserRoleModal │ │ │ └── UserRoleModal.tsx │ │ │ ├── UsersFilter │ │ │ └── UsersFilter.tsx │ │ │ ├── UsersModal │ │ │ └── UsersModal.tsx │ │ │ └── UsersTable │ │ │ └── UsersTable.tsx │ │ ├── settings.ts │ │ └── types │ │ └── user.types.ts ├── helpers │ ├── file.helper.ts │ ├── modal.helper.ts │ ├── route.helper.ts │ ├── table.helper.ts │ └── util.helper.ts ├── hooks │ ├── useSearchParams.ts │ ├── useShowModal.ts │ └── useUnsavedPrompt.tsx ├── index.scss ├── index.tsx ├── react-app-env.d.ts ├── redux │ ├── api.thunk.ts │ ├── root-reducer.ts │ └── store.ts ├── reportWebVitals.ts ├── routes │ ├── NestedRouteWrapper.tsx │ ├── Routes.tsx │ ├── components │ │ ├── LoginRedirect │ │ │ └── LoginRedirect.tsx │ │ ├── NotFound │ │ │ └── NotFound.tsx │ │ └── RestrictAccess │ │ │ └── RestrictAccess.tsx │ ├── constants │ │ └── route.layouts.ts │ └── routes.config.ts ├── setupTests.ts ├── styles │ ├── _colors.scss │ ├── _variables.scss │ └── antd-theme.js └── types │ ├── api.types.ts │ ├── pagination.types.ts │ ├── route.types.ts │ └── table.types.ts ├── tsconfig.json ├── tsconfig.paths.json └── yarn.lock /.env: -------------------------------------------------------------------------------- 1 | SASS_PATH=src 2 | 3 | REACT_APP_API_HOST=https://reqres.in/api/ 4 | 5 | REACT_APP_NSTACK_APP_ID= 6 | REACT_APP_NSTACK_API_KEY= -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | craco.config.js 2 | .eslintrc.js 3 | tsconfig.json -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | env: { 3 | jest: true, 4 | }, 5 | extends: ["airbnb-typescript-prettier"], 6 | settings: { 7 | "import/resolver": { 8 | typescript: {}, // this loads /tsconfig.json to eslint 9 | }, 10 | }, 11 | plugins: ["filenames"], 12 | rules: { 13 | "prettier/prettier": [ 14 | "error", 15 | { 16 | trailingComma: "es5", 17 | singleQuote: false, 18 | printWidth: 80, 19 | bracketSpacing: true, 20 | tabWidth: 2, 21 | semi: true, 22 | }, 23 | ], 24 | "react/jsx-filename-extension": [ 25 | 1, 26 | { 27 | extensions: ["ts", "tsx"], 28 | }, 29 | ], 30 | "react/react-in-jsx-scope": 0, 31 | "react/jsx-props-no-spreading": 0, 32 | "react/require-default-props": 0, 33 | "import/prefer-default-export": 0, 34 | "no-unused-expressions": "off", 35 | // disabling circular dependency, as it is causing issues 36 | "import/no-cycle": 0, 37 | // allow param reassign for redux-toolkit 38 | "no-param-reassign": ["error", { props: false }], 39 | // no return types needed if it can be inferred. useful for react components and sagas so it's less to worry about 40 | "@typescript-eslint/explicit-function-return-type": "off", 41 | "@typescript-eslint/explicit-module-boundary-types": "off", 42 | "@typescript-eslint/prefer-nullish-coalescing": "error", 43 | "@typescript-eslint/prefer-optional-chain": "error", 44 | "react/button-has-type": 0, 45 | "import/no-extraneous-dependencies": [ 46 | "error", 47 | { devDependencies: ["**/*.stories.tsx"] }, 48 | ], 49 | "no-restricted-imports": [ 50 | "error", 51 | { 52 | patterns: ["@app/features/*/*/*"], 53 | }, 54 | ], 55 | "import/order": [ 56 | "error", 57 | { 58 | "newlines-between": "always", 59 | groups: [ 60 | ["builtin", "external"], 61 | "internal", 62 | ["parent", "sibling", "index"], 63 | "unknown", 64 | ], 65 | alphabetize: { order: "asc" }, 66 | pathGroups: [ 67 | { 68 | pattern: "styles/**", 69 | group: "internal", 70 | position: "after", 71 | }, 72 | { group: "builtin", pattern: "react", position: "before" }, 73 | ], 74 | pathGroupsExcludedImportTypes: ["builtin"], 75 | }, 76 | ], 77 | }, 78 | overrides: [ 79 | { 80 | files: ["*.ts"], 81 | rules: { 82 | "filenames/match-regex": [2, "^[a-z-.]+$", true], 83 | }, 84 | }, 85 | { 86 | files: ["*.tsx"], 87 | rules: { 88 | "filenames/match-regex": [2, "^[A-Z][a-z].+(?:[A-Z][a-z].+)*$", true], 89 | }, 90 | }, 91 | { 92 | files: ["src/index.tsx", "src/reportWebVitals.ts", "src/setupTests.ts"], 93 | rules: { 94 | "filenames/match-regex": "off", 95 | }, 96 | }, 97 | ], 98 | }; 99 | -------------------------------------------------------------------------------- /.github/mergeable.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | mergeable: 3 | - when: pull_request.* 4 | validate: 5 | - do: title 6 | must_exclude: 7 | regex: ^WIP 8 | must_include: 9 | regex: ^((feat|fix|docs|style|refactor|perf|test|build|ci|chore)(\(.{1,15}\))?|()\-[0-9]{1,5})\:.+$ 10 | message: "Invalid PR title format." 11 | - do: label 12 | must_exclude: 13 | regex: "wip" 14 | pass: 15 | - do: checks 16 | status: "success" 17 | payload: 18 | title: "PR is valid 👍" 19 | summary: "Noice!" 20 | fail: 21 | - do: checks 22 | status: "failure" 23 | payload: 24 | title: "Oops! PR is not valid 💩" 25 | -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | # 2 | 3 | ## What are you adding? 4 | 5 | 6 | ## Breaking changes? 7 | 8 | 9 | ## Related PR 10 | 11 | -------------------------------------------------------------------------------- /.github/release-drafter.yml: -------------------------------------------------------------------------------- 1 | name-template: "v$RESOLVED_VERSION 🌈" 2 | tag-template: "v$RESOLVED_VERSION" 3 | categories: 4 | - title: "🚀 Features" 5 | labels: 6 | - "feature" 7 | - "enhancement" 8 | - title: "🐛 Bug Fixes" 9 | labels: 10 | - "fix" 11 | - "bugfix" 12 | - "bug" 13 | - title: "🧰 Maintenance" 14 | label: "chore" 15 | change-template: "- $TITLE @$AUTHOR (#$NUMBER)" 16 | change-title-escapes: '\<*_&' # You can add # and @ to disable mentions, and add ` to disable code blocks. 17 | version-resolver: 18 | major: 19 | labels: 20 | - "major" 21 | minor: 22 | labels: 23 | - "minor" 24 | patch: 25 | labels: 26 | - "patch" 27 | default: patch 28 | template: | 29 | ## Changes 30 | 31 | $CHANGES 32 | -------------------------------------------------------------------------------- /.github/workflows/pr-labeler.yml: -------------------------------------------------------------------------------- 1 | name: PR Labeler 2 | on: 3 | pull_request: 4 | types: [opened] 5 | 6 | jobs: 7 | pr-labeler: 8 | runs-on: ubuntu-latest 9 | steps: 10 | - uses: TimonVS/pr-labeler-action@v3.1.0 11 | env: 12 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 13 | -------------------------------------------------------------------------------- /.github/workflows/release-drafter.yml: -------------------------------------------------------------------------------- 1 | name: Release Drafter 2 | 3 | on: 4 | push: 5 | # branches to consider in the event; optional, defaults to all 6 | branches: 7 | - main 8 | 9 | jobs: 10 | update_release_draft: 11 | runs-on: ubuntu-latest 12 | steps: 13 | # Drafts your next Release notes as Pull Requests are merged into "master" 14 | - uses: release-drafter/release-drafter@v5 15 | env: 16 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 17 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # production 12 | /build 13 | 14 | # misc 15 | .DS_Store 16 | .env.local 17 | .env.development.local 18 | .env.test.local 19 | .env.production.local 20 | 21 | npm-debug.log* 22 | yarn-debug.log* 23 | yarn-error.log* 24 | 25 | # react 26 | .DS_* 27 | *.log 28 | logs 29 | **/*.backup.* 30 | **/*.back.* 31 | 32 | # cache 33 | .eslintcache -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | npx lint-staged 5 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | engine-strict = true -------------------------------------------------------------------------------- /.prettierrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | trailingComma: "es5", 3 | singleQuote: false, 4 | printWidth: 80, 5 | bracketSpacing: true, 6 | bracketSameLine: false, 7 | tabWidth: 2, 8 | semi: true, 9 | endOfLine: "lf", 10 | arrowParens: "avoid", 11 | }; 12 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "dbaeumer.vscode-eslint", 4 | "christian-kohler.path-intellisense", 5 | "esbenp.prettier-vscode", 6 | "syler.sass-indented", 7 | "mrmlnc.vscode-scss", 8 | "wayou.vscode-todo-highlight", 9 | "clinyong.vscode-css-modules", 10 | "streetsidesoftware.code-spell-checker" 11 | ] 12 | } 13 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.formatOnSave": true, 3 | "eslint.validate": [ 4 | "javascript", 5 | "javascriptreact", 6 | "typescript", 7 | "typescriptreact" 8 | ], 9 | "editor.codeActionsOnSave": { 10 | "source.fixAll.eslint": true 11 | }, 12 | "scss.lint.unknownAtRules": "ignore", 13 | "search.exclude": { 14 | "**/node_modules": true, 15 | "**/bower_components": true, 16 | "**/*.code-search": true, 17 | ".tmp": true, 18 | "build": true 19 | }, 20 | "files.eol": "\n", 21 | "editor.defaultFormatter": "esbenp.prettier-vscode", 22 | 23 | "typescript.tsdk": "./node_modules/typescript/lib" 24 | } 25 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contribution guide 2 | 3 | ## Project tools 4 | 5 | - Github for code and issues management 6 | 7 | ### Branch rules 8 | 9 | 1. Following branches should be used for primary code management 10 | 11 | 1. `main` there can be only this branch. This hold all the latest already released code. 12 | 13 | - in case we are doing tag based release, `main` can be used as stable bleeding edge releasable code. 14 | 15 | 1. `{type}/{GithubIssueNo}-issue-one-liner` should be the format for branch naming 16 | 1. See [Type](#Type) section for branch `{type}`. 17 | 1. Find `{GithubIssueNo}` in Github. 18 | 19 | ### Pull requests 20 | 21 | Pull requests are the only way to propose a value you want to add. Following is a general workflow for submitting any requests. 22 | 23 | 1. Fork the repository 24 | 1. Clone the fork and create your branch from `main`. 25 | 1. If you've added code that should be tested, Ensure that your code doesn't fail to build 26 | 1. Make pull request to `main` branch 27 | 28 | #### Type 29 | 30 | Must be one of the following: 31 | 32 | - **feat**: A new feature 33 | - **fix**: A bug fix 34 | - **docs**: Documentation only changes 35 | - **style**: Changes that do not affect the meaning of the code (white-space, formatting, missing 36 | semi-colons, etc) 37 | - **refactor**: A code change that neither fixes a bug nor adds a feature 38 | - **perf**: A code change that improves performance 39 | - **test**: Adding missing tests or correcting existing tests 40 | - **build**: Changes that affect the build system, CI configuration or external dependencies (example scopes: gulp, broccoli, npm) 41 | - **ci**: Any changes to our CI configuration files and scripts (Travis, Circle CI, BrowserStack, SauceLabs, AWS CodeBuild) 42 | - **chore**: Other changes that don't modify `src` or `test` files 43 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Admin Panel Template ReactJS 2 | 3 | [![Netlify Status](https://api.netlify.com/api/v1/badges/c5bd55a2-cf9c-46ec-8cd2-acb2222aed90/deploy-status)](https://app.netlify.com/sites/ml-admin-panel-template/deploys) 4 | 5 | ## Demo 6 | 7 | You can check out a running demo of the site here: [https://ml-admin-panel-template.netlify.app/](https://ml-admin-panel-template.netlify.app/) 8 | 9 | ## Live Playground 10 | 11 | **You’ll need to have Node v16.x on your local development machine**. You can use [nvm](https://github.com/nvm-sh/nvm#installation) (macOS/Linux) or [nvm-windows](https://github.com/coreybutler/nvm-windows#node-version-manager-nvm-for-windows) to switch Node versions between different projects. 12 | 13 | - Clone this repository 14 | - `yarn install` 15 | - `yarn start` 16 | - Visit http://localhost:3000/ 17 | 18 | ## Mission 19 | 20 | The purpose of this repo is to create a React JS template so we can standardize how we implement Admin Panels with the basic things such as auth, navigation, tables, forms, etc. 21 | 22 | The template will therefore contain a set of components and functionality, that we will maintain either in this repo or in separate repos if we see a need to reuse it in other projects. 23 | 24 | The goal is to be able to start up the project, where you can navigate to different screens that themselves are examples of how to implement cases such as fetching data for a table with sorting, ordering, pagination, etc, and modals to create/update data in the rows. 25 | 26 | As this repo will be used for different clients, we need to be able to easily theme it, and extend it for specific client needs. 27 | 28 | ## Roadmap 29 | 30 | The roadmap in the [project tab](https://github.com/monstar-lab-oss/admin-panel-template-reactjs/projects/1) will contain all the features that we need to implement, so you can pick up a task from there. 31 | 32 | ## Features 33 | 34 | - [Authentication](src/features/auth/auth.md) 35 | - [Localization](src/features//localization/) (No documentation yet) 36 | - [Page filter](src/components/molecules/PageFilter/page-filter.md) 37 | - [Permissions](src/features/permissions/permissions.md) 38 | 39 | ## How We Work 40 | 41 | This template follows **syntax**, **semantics**, and **folder structure** defined in the [web-frontend-readme repo](https://github.com/monstar-lab-oss/web-frontend-readme). 42 | 43 | ## Logins 44 | 45 | - email: george.bluth@reqres.in 46 | - password: Password is anything you like, for example Test1234 it works 47 | -------------------------------------------------------------------------------- /craco.config.js: -------------------------------------------------------------------------------- 1 | const path = require("path"); 2 | const { BundleAnalyzerPlugin } = require("webpack-bundle-analyzer"); 3 | const CracoAntDesignPlugin = require("craco-antd"); 4 | const theme = require("./src/styles/antd-theme"); 5 | 6 | module.exports = { 7 | webpack: { 8 | alias: { 9 | "@app": path.resolve(__dirname, "./src"), 10 | }, 11 | plugins: [ 12 | ...(process.env.ENABLE_ANALYZER 13 | ? [ 14 | new BundleAnalyzerPlugin({ 15 | analyzerMode: "static", 16 | openAnalyzer: true, 17 | }), 18 | ] 19 | : []), 20 | ], 21 | }, 22 | plugins: [ 23 | { 24 | plugin: CracoAntDesignPlugin, 25 | options: { 26 | customizeTheme: theme, 27 | }, 28 | lessLoaderOptions: { 29 | noIeCompat: true, 30 | }, 31 | }, 32 | ], 33 | }; 34 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "admin-panel-template-reactjs", 3 | "version": "0.1.0", 4 | "private": true, 5 | "dependencies": { 6 | "@ant-design/icons": "^4.6.2", 7 | "@craco/craco": "^6.2.0", 8 | "@nstack-io/javascript-sdk": "nstack-io/javascript-sdk#v0.3", 9 | "@reduxjs/toolkit": "^1.6.0", 10 | "@testing-library/jest-dom": "^5.11.10", 11 | "@testing-library/react": "^11.2.6", 12 | "@testing-library/user-event": "^12.1.10", 13 | "antd": "^4.16.6", 14 | "axios": "^0.21.1", 15 | "classnames": "^2.3.1", 16 | "craco-antd": "^1.19.0", 17 | "i18next": "^20.3.2", 18 | "lodash": "^4.17.21", 19 | "moment": "^2.29.1", 20 | "query-string": "^7.0.1", 21 | "react": "^17.0.2", 22 | "react-cookies": "^0.1.1", 23 | "react-dom": "^17.0.2", 24 | "react-helmet": "^6.1.0", 25 | "react-i18next": "^11.11.0", 26 | "react-redux": "^7.2.4", 27 | "react-router-dom": "^5.2.0", 28 | "react-scripts": "^4.0.3", 29 | "react-use": "^17.2.4", 30 | "redux": "^4.1.0", 31 | "sass": "^1.49.0", 32 | "typescript": "^4.5.2", 33 | "web-vitals": "^1.0.1" 34 | }, 35 | "engines": { 36 | "npm": "please use yarn", 37 | "yarn": ">= 1.19.1" 38 | }, 39 | "scripts": { 40 | "start": "craco start", 41 | "build": "craco build", 42 | "test": "craco test", 43 | "format": "prettier \"src/**/*.{ts,tsx,json,scss,css}\" --write", 44 | "lint": "eslint \"src/**/*.{ts,tsx}\" --fix --quiet", 45 | "analyze": "ENABLE_ANALYZER=true yarn build", 46 | "prepare": "husky install" 47 | }, 48 | "eslintConfig": { 49 | "extends": [ 50 | "react-app", 51 | "react-app/jest" 52 | ] 53 | }, 54 | "lint-staged": { 55 | "src/**/*.{ts,tsx}": [ 56 | "yarn lint" 57 | ], 58 | "src/**/*.{ts,tsx,scss,css}": [ 59 | "yarn format" 60 | ] 61 | }, 62 | "browserslist": { 63 | "production": [ 64 | ">0.2%", 65 | "not dead", 66 | "not op_mini all" 67 | ], 68 | "development": [ 69 | "last 1 chrome version", 70 | "last 1 firefox version", 71 | "last 1 safari version" 72 | ] 73 | }, 74 | "devDependencies": { 75 | "@babel/helper-environment-visitor": "^7.16.7", 76 | "@types/classnames": "^2.3.1", 77 | "@types/jest": "^26.0.23", 78 | "@types/lodash": "^4.14.170", 79 | "@types/node": "^12.20.15", 80 | "@types/react": "^17.0.13", 81 | "@types/react-cookies": "^0.1.0", 82 | "@types/react-dom": "^17.0.8", 83 | "@types/react-helmet": "^6.1.1", 84 | "@types/react-redux": "^7.1.16", 85 | "@types/react-router-dom": "^5.1.7", 86 | "@types/webpack-env": "^1.16.2", 87 | "eslint": "^7.1.1", 88 | "eslint-config-airbnb-typescript-prettier": "^4.2.0", 89 | "eslint-import-resolver-typescript": "^2.4.0", 90 | "eslint-plugin-filenames": "^1.3.2", 91 | "husky": "^7.0.2", 92 | "lint-staged": "^10.5.4", 93 | "prettier": "^2.4.1", 94 | "webpack-bundle-analyzer": "^4.4.2" 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /public/_redirects: -------------------------------------------------------------------------------- 1 | /* /index.html 200 -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ml-opensource/admin-panel-template-reactjs/f18ad2258871d7592ab6401868bc31a2fe2322f2/public/favicon.ico -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | Monstarlab Admin Panel 10 | 11 | 12 | 13 |
14 | 15 | 16 | -------------------------------------------------------------------------------- /public/logo192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ml-opensource/admin-panel-template-reactjs/f18ad2258871d7592ab6401868bc31a2fe2322f2/public/logo192.png -------------------------------------------------------------------------------- /public/robots.txt: -------------------------------------------------------------------------------- 1 | # https://www.robotstxt.org/robotstxt.html 2 | User-agent: * 3 | Disallow: / 4 | -------------------------------------------------------------------------------- /src/App.tsx: -------------------------------------------------------------------------------- 1 | import { lazy, Suspense, useEffect } from "react"; 2 | 3 | import { BrowserRouter as Router } from "react-router-dom"; 4 | import { useMount } from "react-use"; 5 | 6 | import LoadingSpinner from "@app/components/atoms/LoadingSpinner/LoadingSpinner"; 7 | 8 | import { getMe, getTokens } from "./features/auth/auth"; 9 | import { useLocalization } from "./features/localization/localization"; 10 | import { 11 | PermissionEnum, 12 | setPermissions, 13 | } from "./features/permissions/permissions"; 14 | import { useAppDispatch, useAppSelector } from "./redux/store"; 15 | 16 | // Routes are lazy loaded so they will access to correct permissions 17 | const Routes = lazy(() => import("./routes/Routes")); 18 | 19 | const App = () => { 20 | const { loadingTranslation } = useLocalization({ shouldCall: true }); 21 | const { accessToken } = getTokens(); 22 | const dispatch = useAppDispatch(); 23 | const { loadingUser } = useAppSelector(state => ({ 24 | isAuthenticated: state.auth.isAuthenticated, 25 | loadingUser: state.auth.loading, 26 | })); 27 | 28 | useMount(() => { 29 | if (accessToken) { 30 | dispatch(getMe()); 31 | } 32 | }); 33 | 34 | useEffect(() => { 35 | dispatch( 36 | setPermissions( 37 | Object.values(PermissionEnum).filter( 38 | x => 39 | // HACK: added here to play around with the permissions 40 | // permissions listed here will be removed from user's permissions 41 | ![PermissionEnum.USERS_DELETE].includes(x) 42 | ) 43 | ) 44 | ); 45 | }, [dispatch]); 46 | 47 | const loading = ; 48 | 49 | if (loadingUser || loadingTranslation) return loading; 50 | 51 | return ( 52 | 53 | 54 | 55 | 56 | 57 | ); 58 | }; 59 | 60 | export default App; 61 | -------------------------------------------------------------------------------- /src/api/api.ts: -------------------------------------------------------------------------------- 1 | import axios, { AxiosRequestConfig } from "axios"; 2 | import moment from "moment"; 3 | 4 | import { ENV } from "@app/constants/env"; 5 | import { AuthEndpointsEnum, getTokens } from "@app/features/auth/auth"; 6 | 7 | /** 8 | * All the endpoint that do not require an access token 9 | */ 10 | const anonymousEndpoints = [AuthEndpointsEnum.LOGIN.toString()]; 11 | 12 | /** 13 | * "Wrapper" around getTokens 14 | * can be changed to have refresh functionality if api supports it 15 | */ 16 | export const getRefreshedToken = () => { 17 | const { accessToken, expiresAt } = getTokens(); 18 | 19 | const isTokenExpired = moment().isSameOrAfter(expiresAt); 20 | 21 | return { accessToken, isTokenExpired }; 22 | }; 23 | 24 | /** 25 | * Adds authorization headers to API calls 26 | * @param {AxiosRequestConfig} request 27 | */ 28 | const authInterceptor = async (request: AxiosRequestConfig) => { 29 | const isAnonymous = anonymousEndpoints.some(endpoint => 30 | request.url?.startsWith(endpoint) 31 | ); 32 | 33 | const { accessToken } = getRefreshedToken(); 34 | 35 | if (accessToken) { 36 | request.headers.Authorization = `${accessToken}`; 37 | return request; 38 | } 39 | 40 | if (!accessToken && !isAnonymous) { 41 | // TODO: handle when UNAUTHORIZED; 42 | // return Promise.reject(ApiStatusCodes.UNAUTHORIZED); 43 | return request; 44 | } 45 | 46 | return request; 47 | }; 48 | 49 | /** Setup an API instance */ 50 | export const api = axios.create({ 51 | baseURL: ENV.API_HOST, 52 | headers: { "Content-Type": "application/json" }, 53 | }); 54 | 55 | api.interceptors.request.use(authInterceptor); 56 | -------------------------------------------------------------------------------- /src/assets/images/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /src/assets/images/no-image.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ml-opensource/admin-panel-template-reactjs/f18ad2258871d7592ab6401868bc31a2fe2322f2/src/assets/images/no-image.png -------------------------------------------------------------------------------- /src/components/atoms/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ml-opensource/admin-panel-template-reactjs/f18ad2258871d7592ab6401868bc31a2fe2322f2/src/components/atoms/.gitkeep -------------------------------------------------------------------------------- /src/components/atoms/BrowserTitle/BrowserTitle.tsx: -------------------------------------------------------------------------------- 1 | import { memo } from "react"; 2 | 3 | import { Helmet } from "react-helmet"; 4 | import { useTranslation } from "react-i18next"; 5 | 6 | interface Props { 7 | /** Meta title (optional) */ 8 | title?: string; 9 | } 10 | 11 | const BrowserTitle = ({ title }: Props) => { 12 | const { t } = useTranslation(); 13 | return ( 14 | 15 | 16 | {(!!title && `${title} | `) || ""} 17 | {t("default.title")} 18 | 19 | 20 | ); 21 | }; 22 | 23 | export default memo(BrowserTitle); 24 | -------------------------------------------------------------------------------- /src/components/atoms/Button/Button.module.scss: -------------------------------------------------------------------------------- 1 | @import "styles/colors"; 2 | @import "styles/variables"; 3 | 4 | .button { 5 | &.noPadding { 6 | padding-left: 0; 7 | padding-right: 0; 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /src/components/atoms/Button/Button.tsx: -------------------------------------------------------------------------------- 1 | import { memo } from "react"; 2 | 3 | import { Button as AntdButton } from "antd"; 4 | import { ButtonProps as AntdButtonProps } from "antd/es/button"; 5 | import classnames from "classnames/bind"; 6 | import { Link, LinkProps } from "react-router-dom"; 7 | 8 | import { isURL } from "@app/helpers/util.helper"; 9 | 10 | import styles from "./Button.module.scss"; 11 | 12 | const cx = classnames.bind(styles); 13 | 14 | interface ButtonProps extends Omit { 15 | /** 16 | * Turn button into link, accepts internal and external links (optional) 17 | */ 18 | to?: LinkProps["to"]; 19 | /** 20 | * Remove horizontal padding (optional) 21 | */ 22 | noPadding?: boolean; 23 | } 24 | 25 | const Button = memo(({ to, className, noPadding, ...rest }: ButtonProps) => { 26 | const isExternalLink = typeof to === "string" && isURL(to); 27 | 28 | const buttonContent = ( 29 | 40 | ); 41 | 42 | // Only wrap in react router link, if internal link 43 | if (!isExternalLink && to) { 44 | return {buttonContent}; 45 | } 46 | 47 | return buttonContent; 48 | }); 49 | 50 | export default Button; 51 | -------------------------------------------------------------------------------- /src/components/atoms/DebouncedInput/DebouncedInput.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useRef } from "react"; 2 | 3 | import { Input, InputProps } from "antd"; 4 | import _debounce from "lodash/debounce"; 5 | 6 | interface DebouncedInputProps extends InputProps { 7 | /** The number of milliseconds to delay the debounced event (default 500ms) */ 8 | wait?: number; 9 | /** onChange passed in from FormItem */ 10 | onChange?: React.ChangeEventHandler; 11 | /** Value passed in from FormItem */ 12 | value?: string | number | readonly string[]; 13 | } 14 | 15 | const DebouncedInput = ({ 16 | onChange, 17 | value, 18 | wait = 500, 19 | ...props 20 | }: DebouncedInputProps) => { 21 | const inputRef = useRef(null); 22 | 23 | useEffect(() => { 24 | // This will allow parent to update value 25 | if (inputRef.current) { 26 | inputRef.current.setValue(value as string); 27 | } 28 | }, [value]); 29 | 30 | // Debounce the changes to the value 31 | const debouncedOnChange = _debounce( 32 | ( 33 | debounceEvent: React.ChangeEvent, 34 | debounceOnChange: React.ChangeEventHandler 35 | ) => { 36 | debounceOnChange(debounceEvent); 37 | }, 38 | wait 39 | ); 40 | 41 | const persistedOnChange = 42 | (persistOnChange?: React.ChangeEventHandler) => (e: React.ChangeEvent) => { 43 | // persist event as it will be resolved after debounce 44 | e.persist(); 45 | persistOnChange && debouncedOnChange(e, persistOnChange); 46 | }; 47 | 48 | return ( 49 | 55 | ); 56 | }; 57 | 58 | export default DebouncedInput; 59 | -------------------------------------------------------------------------------- /src/components/atoms/Form/Form.tsx: -------------------------------------------------------------------------------- 1 | import { RefObject, useCallback, useEffect } from "react"; 2 | 3 | import { Form as AntdForm } from "antd"; 4 | import { FormProps as AntdFormProps, FormInstance } from "antd/lib/form"; 5 | import { useForm } from "antd/lib/form/Form"; 6 | import _isEqual from "lodash/isEqual"; 7 | import { usePrevious } from "react-use"; 8 | 9 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 10 | export type FormValues = any; 11 | 12 | export type FormErrors = Record | undefined; 13 | 14 | export interface FormProps extends AntdFormProps { 15 | values?: FormValues; 16 | formRef?: RefObject; 17 | } 18 | 19 | const Form = ({ 20 | values = undefined, 21 | children, 22 | formRef = undefined, 23 | form, 24 | ...props 25 | }: FormProps) => { 26 | const [formInstance] = useForm(form); 27 | 28 | const bindValues = useCallback(() => { 29 | formInstance.resetFields(); 30 | }, [formInstance]); 31 | 32 | const prevValues = usePrevious(values); 33 | 34 | useEffect(() => { 35 | if (!_isEqual(prevValues, values)) { 36 | bindValues(); 37 | } 38 | }, [bindValues, values, prevValues]); 39 | 40 | return ( 41 | 48 | {children} 49 | 50 | ); 51 | }; 52 | 53 | export const { Item, List } = AntdForm; 54 | 55 | export default Form; 56 | 57 | export { useForm }; 58 | -------------------------------------------------------------------------------- /src/components/atoms/FormModal/FormModal.module.scss: -------------------------------------------------------------------------------- 1 | @import "styles/variables"; 2 | 3 | .modal { 4 | overflow: hidden; 5 | :global { 6 | .ant-modal-body { 7 | padding: $spacing-6 $spacing-6 $spacing-4; 8 | } 9 | } 10 | } 11 | 12 | .divider { 13 | position: relative; 14 | width: calc(100% + 100px); 15 | left: -50px; 16 | margin-bottom: $spacing-4; 17 | } 18 | 19 | .submitButton { 20 | margin-left: $spacing-3; 21 | } 22 | -------------------------------------------------------------------------------- /src/components/atoms/FormModal/FormModal.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect } from "react"; 2 | 3 | import { Modal, Row, Col, Divider, ModalProps } from "antd"; 4 | import cx from "classnames"; 5 | import { useTranslation } from "react-i18next"; 6 | 7 | import Form, { FormProps } from "@app/components/atoms/Form/Form"; 8 | import useUnsavedPrompt from "@app/hooks/useUnsavedPrompt"; 9 | 10 | import Button from "../Button/Button"; 11 | import SpinWrapper from "../SpinWrapper/SpinWrapper"; 12 | import styles from "./FormModal.module.scss"; 13 | 14 | interface FormModalProps extends Omit { 15 | title?: ModalProps["title"]; 16 | visible: ModalProps["visible"]; 17 | className?: ModalProps["className"]; 18 | width?: ModalProps["width"]; 19 | children?: React.ReactNode; 20 | onClose: () => void; 21 | submitButtonText?: string; 22 | cancelButtonText?: string; 23 | destroyOnClose?: boolean; 24 | disableSubmit?: boolean; 25 | loadingSubmit?: boolean; 26 | loadingContent?: boolean; 27 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 28 | onFinish?: (values: any) => void; 29 | } 30 | 31 | const FormModal = ({ 32 | title, 33 | visible, 34 | className, 35 | width, 36 | children, 37 | onClose, 38 | submitButtonText, 39 | cancelButtonText, 40 | destroyOnClose, 41 | disableSubmit, 42 | loadingSubmit, 43 | loadingContent, 44 | form, 45 | onFinish, 46 | ...formProps 47 | }: FormModalProps) => { 48 | const { t } = useTranslation(); 49 | const { setIsSubmitting } = useUnsavedPrompt({ form, visible }); 50 | 51 | useEffect(() => { 52 | if (!visible) { 53 | setIsSubmitting(false); 54 | } 55 | }, [visible, setIsSubmitting]); 56 | 57 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 58 | const onSubmit = (values: any) => { 59 | setIsSubmitting(true); 60 | onFinish?.(values); 61 | }; 62 | 63 | // Reset fields when closing modal 64 | // necessary when modal is in ADD mode 65 | const onAfterClose = () => form?.resetFields(); 66 | 67 | const handleOnClose = () => { 68 | setIsSubmitting(false); 69 | onClose(); 70 | }; 71 | 72 | return ( 73 | 84 |
85 | 86 | {children} 87 | 88 | 89 | 90 | 91 | 94 | 95 | 104 | 105 | 106 | 107 |
108 | ); 109 | }; 110 | 111 | export default FormModal; 112 | -------------------------------------------------------------------------------- /src/components/atoms/Image/Image.tsx: -------------------------------------------------------------------------------- 1 | import { memo, useState, useEffect, FC } from "react"; 2 | 3 | import notFoundImage from "@app/assets/images/no-image.png"; 4 | 5 | import SpinWrapper from "../SpinWrapper/SpinWrapper"; 6 | 7 | interface ImageProps 8 | extends React.DetailedHTMLProps< 9 | React.ImgHTMLAttributes, 10 | HTMLImageElement 11 | > { 12 | defaultImg?: string; 13 | } 14 | 15 | const Image: FC = ({ 16 | src = notFoundImage, 17 | defaultImg, 18 | ...rest 19 | }) => { 20 | const [url, setUrl] = useState(src); 21 | const [isLoading, setIsLoading] = useState(true); 22 | 23 | useEffect(() => { 24 | setUrl(src); 25 | }, [src]); 26 | 27 | const handleError = () => { 28 | setUrl(defaultImg ?? notFoundImage); 29 | setIsLoading(false); 30 | }; 31 | 32 | const handleLoaded = () => { 33 | setIsLoading(false); 34 | }; 35 | 36 | return ( 37 | 38 | {rest.alt} 45 | 46 | ); 47 | }; 48 | 49 | export default memo(Image); 50 | -------------------------------------------------------------------------------- /src/components/atoms/LoadingSpinner/LoadingSpinner.module.scss: -------------------------------------------------------------------------------- 1 | .container { 2 | display: flex; 3 | justify-content: center; 4 | align-items: center; 5 | flex-direction: column; 6 | 7 | &.isFullscreen { 8 | height: -webkit-fill-available; 9 | height: -moz-available; 10 | height: 100vh; 11 | height: stretch; 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/components/atoms/LoadingSpinner/LoadingSpinner.tsx: -------------------------------------------------------------------------------- 1 | import { Spin, Typography } from "antd"; 2 | import cx from "classnames"; 3 | 4 | import styles from "./LoadingSpinner.module.scss"; 5 | 6 | const { Title } = Typography; 7 | 8 | interface LoadingSpinnerProps { 9 | text?: string; 10 | isFullscreen?: boolean; 11 | } 12 | 13 | const LoadingSpinner = ({ text, isFullscreen }: LoadingSpinnerProps) => { 14 | return ( 15 |
20 | {!!text && {text}} 21 | 22 | 23 |
24 | ); 25 | }; 26 | 27 | export default LoadingSpinner; 28 | -------------------------------------------------------------------------------- /src/components/atoms/ModalConfirm/ModalConfirm.tsx: -------------------------------------------------------------------------------- 1 | import { Modal, ModalFuncProps } from "antd"; 2 | import { TFunction } from "react-i18next"; 3 | 4 | const { confirm } = Modal; 5 | 6 | export const modalConfirm = ( 7 | t: TFunction<"translation">, 8 | { 9 | cancelText = t("default.cancelTitle"), 10 | okText = t("default.okTitle"), 11 | ...rest 12 | }: ModalFuncProps 13 | ) => { 14 | return confirm({ 15 | cancelText, 16 | okText, 17 | width: 456, 18 | ...rest, 19 | }); 20 | }; 21 | -------------------------------------------------------------------------------- /src/components/atoms/SpinWrapper/SpinWrapper.module.scss: -------------------------------------------------------------------------------- 1 | @import "styles/variables"; 2 | 3 | .wrapper { 4 | padding-top: $spacing-0; 5 | } 6 | -------------------------------------------------------------------------------- /src/components/atoms/SpinWrapper/SpinWrapper.tsx: -------------------------------------------------------------------------------- 1 | import { Spin } from "antd"; 2 | 3 | import styles from "./SpinWrapper.module.scss"; 4 | 5 | interface SpinWrapperProps { 6 | children: React.ReactNode; 7 | loading?: boolean; 8 | } 9 | 10 | const SpinWrapper = ({ children, loading = false }: SpinWrapperProps) => { 11 | return ( 12 | 13 | {children} 14 | 15 | ); 16 | }; 17 | 18 | export default SpinWrapper; 19 | -------------------------------------------------------------------------------- /src/components/layouts/BlankLayout/BlankLayout.tsx: -------------------------------------------------------------------------------- 1 | import { memo, ReactNode } from "react"; 2 | 3 | import { Layout } from "antd"; 4 | 5 | const { Content } = Layout; 6 | type BlankLayoutProps = { 7 | children: ReactNode; 8 | }; 9 | 10 | const BlankLayout = memo(({ children }: BlankLayoutProps) => { 11 | return ( 12 | 13 | {children} 14 | 15 | ); 16 | }); 17 | 18 | export default BlankLayout; 19 | -------------------------------------------------------------------------------- /src/components/layouts/ContentLayout/ContentLayout.module.scss: -------------------------------------------------------------------------------- 1 | @import "styles/colors"; 2 | @import "styles/variables"; 3 | 4 | .container { 5 | height: 100%; 6 | } 7 | 8 | .content { 9 | background: $bg-white; 10 | padding: $spacing-6; 11 | margin: 0 $spacing-6 $spacing-6; 12 | } 13 | 14 | .pageHeader { 15 | :global { 16 | .ant-page-header-footer .ant-tabs .ant-tabs-tab { 17 | font-size: $font-size-14; 18 | } 19 | 20 | .ant-page-header-heading { 21 | flex-wrap: wrap; 22 | } 23 | .ant-page-header-heading-title { 24 | white-space: normal; 25 | } 26 | .ant-page-header-heading-extra { 27 | margin-left: auto; 28 | } 29 | } 30 | } 31 | 32 | .pageHeader, 33 | .filters { 34 | margin-bottom: $spacing-6; 35 | } 36 | -------------------------------------------------------------------------------- /src/components/layouts/ContentLayout/ContentLayout.tsx: -------------------------------------------------------------------------------- 1 | import { memo, ReactNode } from "react"; 2 | 3 | import { Layout, PageHeader, PageHeaderProps } from "antd"; 4 | import { Route } from "antd/lib/breadcrumb/Breadcrumb"; 5 | import cx from "classnames"; 6 | import { Link } from "react-router-dom"; 7 | 8 | import styles from "./ContentLayout.module.scss"; 9 | 10 | const { Content } = Layout; 11 | 12 | type ContentLayoutProps = { 13 | header: PageHeaderProps; 14 | filters?: ReactNode; 15 | children: ReactNode; 16 | noContentStyle?: boolean; 17 | }; 18 | 19 | const ContentLayout = memo( 20 | ({ header, filters, children, noContentStyle }: ContentLayoutProps) => { 21 | // Add custom breadcrumb item render to support Link from react-router-dom 22 | const renderBreadcrumbItem = ( 23 | route: Route, 24 | params: unknown, 25 | routes: Route[] 26 | ) => { 27 | const last = routes.indexOf(route) === routes.length - 1; 28 | return last ? ( 29 | {route.breadcrumbName} 30 | ) : ( 31 | {route.breadcrumbName} 32 | ); 33 | }; 34 | 35 | return ( 36 | 37 | 46 | {filters && ( 47 |
{filters}
48 | )} 49 | 50 | {children} 51 | 52 |
53 | ); 54 | } 55 | ); 56 | 57 | export default ContentLayout; 58 | -------------------------------------------------------------------------------- /src/components/layouts/HeaderLayout/HeaderLayout.module.scss: -------------------------------------------------------------------------------- 1 | @import "styles/colors"; 2 | @import "styles/variables"; 3 | 4 | .container { 5 | min-height: 600px; 6 | } 7 | 8 | .content { 9 | min-height: calc(100vh - #{$spacing-3 * 2} - #{$navbar-height}); 10 | } 11 | -------------------------------------------------------------------------------- /src/components/layouts/HeaderLayout/HeaderLayout.tsx: -------------------------------------------------------------------------------- 1 | import { ReactNode } from "react"; 2 | 3 | import { Layout } from "antd"; 4 | 5 | import Navigation from "@app/components/molecules/Navigation/Navigation"; 6 | 7 | import styles from "./HeaderLayout.module.scss"; 8 | 9 | const { Content } = Layout; 10 | 11 | type HeaderLayoutProps = { 12 | children: ReactNode; 13 | }; 14 | 15 | const HeaderLayout = ({ children }: HeaderLayoutProps) => { 16 | return ( 17 | 18 | 19 | 20 |
{children}
21 |
22 |
23 | ); 24 | }; 25 | 26 | export default HeaderLayout; 27 | -------------------------------------------------------------------------------- /src/components/layouts/SidebarLayout/SidebarLayout.module.scss: -------------------------------------------------------------------------------- 1 | @import "styles/colors"; 2 | @import "styles/variables"; 3 | 4 | .pushContent { 5 | margin-left: $sidebar-width; 6 | } 7 | -------------------------------------------------------------------------------- /src/components/layouts/SidebarLayout/SidebarLayout.tsx: -------------------------------------------------------------------------------- 1 | import { memo, ReactNode } from "react"; 2 | 3 | import { Layout } from "antd"; 4 | 5 | import Navigation from "@app/components/molecules/Navigation/Navigation"; 6 | 7 | import styles from "./SidebarLayout.module.scss"; 8 | 9 | const { Content } = Layout; 10 | 11 | type SidebarLayoutProps = { 12 | children: ReactNode; 13 | }; 14 | 15 | const SidebarLayout = memo(({ children }: SidebarLayoutProps) => { 16 | return ( 17 | 18 | 19 | 20 | {children} 21 | 22 | 23 | ); 24 | }); 25 | 26 | export default SidebarLayout; 27 | -------------------------------------------------------------------------------- /src/components/molecules/Navigation/Navigation.module.scss: -------------------------------------------------------------------------------- 1 | @import "styles/colors"; 2 | @import "styles/variables"; 3 | 4 | .navbar { 5 | width: 100%; 6 | padding: 0; 7 | 8 | &.sticky { 9 | position: sticky; 10 | top: 0; 11 | z-index: map-get($zLayers, navigationHeader); 12 | } 13 | 14 | .navLeftContent { 15 | display: flex; 16 | } 17 | .navRightContent { 18 | display: flex; 19 | flex-direction: row-reverse; 20 | } 21 | } 22 | 23 | .sidebar { 24 | width: $sidebar-width !important; 25 | max-width: $sidebar-width !important; 26 | min-width: $sidebar-width !important; 27 | flex: 0 0 #{$sidebar-width} !important; 28 | position: fixed; 29 | overflow: auto; 30 | left: 0; 31 | height: 100vh; 32 | } 33 | -------------------------------------------------------------------------------- /src/components/molecules/Navigation/Navigation.tsx: -------------------------------------------------------------------------------- 1 | import { memo } from "react"; 2 | 3 | import { Layout, Row, Col } from "antd"; 4 | import cx from "classnames"; 5 | 6 | import styles from "./Navigation.module.scss"; 7 | import NavLeftContent from "./components/NavLeftContent/NavLeftContent"; 8 | import NavRightContent from "./components/NavRightContent/NavRightContent"; 9 | 10 | const { Header, Sider } = Layout; 11 | 12 | type NavigationProps = { 13 | sticky?: boolean; 14 | sidebar?: boolean; 15 | }; 16 | 17 | const Navigation = memo(({ sidebar, sticky = false }: NavigationProps) => { 18 | if (sidebar) { 19 | return ( 20 | 21 | 22 | 23 | ); 24 | } 25 | return ( 26 |
27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 |
36 | ); 37 | }); 38 | 39 | export default Navigation; 40 | -------------------------------------------------------------------------------- /src/components/molecules/Navigation/components/NavLeftContent/NavLeftContent.module.scss: -------------------------------------------------------------------------------- 1 | @import "styles/colors"; 2 | @import "styles/variables"; 3 | 4 | .logoContainer { 5 | display: flex; 6 | justify-content: center; 7 | align-items: center; 8 | margin: 0 $spacing-6; 9 | 10 | &.isSidebar { 11 | margin: $spacing-7; 12 | } 13 | 14 | .logo { 15 | width: auto; 16 | height: 20px; 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/components/molecules/Navigation/components/NavLeftContent/NavLeftContent.tsx: -------------------------------------------------------------------------------- 1 | import { memo } from "react"; 2 | 3 | import { MenuProps } from "antd/lib/menu"; 4 | import cx from "classnames"; 5 | 6 | import { ReactComponent as Logo } from "@app/assets/images/logo.svg"; 7 | 8 | import NavMenu from "../NavMenu/NavMenu"; 9 | import styles from "./NavLeftContent.module.scss"; 10 | 11 | interface NavLeftContentProps { 12 | mode?: MenuProps["mode"]; 13 | } 14 | 15 | const NavLeftContent = memo(({ mode = "horizontal" }: NavLeftContentProps) => { 16 | const isSidebar = mode === "inline"; 17 | 18 | return ( 19 | <> 20 |
25 | 26 |
27 | 28 | 29 | ); 30 | }); 31 | 32 | export default NavLeftContent; 33 | -------------------------------------------------------------------------------- /src/components/molecules/Navigation/components/NavLink/NavLink.tsx: -------------------------------------------------------------------------------- 1 | import { memo } from "react"; 2 | 3 | import { useTranslation } from "react-i18next"; 4 | import { Link } from "react-router-dom"; 5 | 6 | import { RouteItemDef } from "@app/types/route.types"; 7 | 8 | interface NavLinkProps { 9 | navItem: RouteItemDef; 10 | } 11 | 12 | const NavLink = memo(({ navItem }: NavLinkProps) => { 13 | const { t } = useTranslation(); 14 | 15 | return ( 16 | 17 | {navItem.navigationTitle 18 | ? t(navItem.navigationTitle) 19 | : `Missing navigationTitle for "${navItem.id}"`} 20 | 21 | ); 22 | }); 23 | 24 | export default NavLink; 25 | -------------------------------------------------------------------------------- /src/components/molecules/Navigation/components/NavMenu/NavMenu.module.scss: -------------------------------------------------------------------------------- 1 | @import "styles/variables"; 2 | 3 | .subMenuItem { 4 | .icon { 5 | margin-left: $spacing-2; 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /src/components/molecules/Navigation/components/NavMenu/NavMenu.tsx: -------------------------------------------------------------------------------- 1 | import { DownOutlined } from "@ant-design/icons"; 2 | import { Menu } from "antd"; 3 | import { MenuProps } from "antd/lib/menu"; 4 | import { useTranslation } from "react-i18next"; 5 | import { useLocation } from "react-router-dom"; 6 | 7 | import { usePermissions } from "@app/features/permissions/permissions"; 8 | import { PRIVATE_LIST } from "@app/routes/routes.config"; 9 | import { RouteGroupDef, RouteItemDef } from "@app/types/route.types"; 10 | 11 | import NavLink from "../NavLink/NavLink"; 12 | import styles from "./NavMenu.module.scss"; 13 | 14 | interface NavMenuProps { 15 | isSidebar?: boolean; 16 | mode?: MenuProps["mode"]; 17 | } 18 | 19 | const NavMenu = ({ isSidebar, mode }: NavMenuProps) => { 20 | const { t } = useTranslation(); 21 | const location = useLocation(); 22 | 23 | const { hasPermissions } = usePermissions(); 24 | 25 | const checkPermissions = (item: RouteItemDef | RouteGroupDef) => 26 | "permissions" in item ? hasPermissions(item.permissions) : true; 27 | 28 | const navLinks: RouteItemDef[] = PRIVATE_LIST.filter( 29 | route => !route.hideInNavigation && checkPermissions(route) 30 | ); 31 | 32 | const rootPathname = isSidebar 33 | ? [...location.pathname.split(/(?=\/)/g, 1)] 34 | : undefined; 35 | 36 | const highlightMenu = [ 37 | ...location.pathname.split(/(?=\/)/g, 1), // Highlight root url 38 | location.pathname.substr(0, location.pathname.lastIndexOf("/")), // Highlight parent url 39 | location.pathname, // Highlight entire url 40 | ]; 41 | 42 | /** 43 | * Ant Design has a bug, where it is NOT possible 44 | * to create custom wrapper components around the Menu's sub components. 45 | * So all AntD Menu components need to be in the same render for now 46 | */ 47 | return ( 48 | 54 | {navLinks.map(navItem => 55 | navItem.nestedRoutes?.length ? ( 56 | 61 | 62 | {navItem.navigationTitle 63 | ? t(navItem.navigationTitle) 64 | : `Missing navigationTitle for "${navItem.id}"`} 65 | 66 | {!isSidebar && } 67 | 68 | } 69 | > 70 | {navItem.nestedRoutes 71 | ?.filter(checkPermissions) 72 | .map((subItem: RouteItemDef | RouteGroupDef) => 73 | "groupTitle" in subItem ? ( 74 | 78 | {subItem.nestedRoutes 79 | ?.filter(checkPermissions) 80 | .map(subGroupItem => ( 81 | 88 | 89 | 90 | ))} 91 | 92 | ) : ( 93 | 100 | 101 | 102 | ) 103 | )} 104 | 105 | ) : ( 106 | 109 | 110 | 111 | ) 112 | )} 113 | 114 | ); 115 | }; 116 | 117 | export default NavMenu; 118 | -------------------------------------------------------------------------------- /src/components/molecules/Navigation/components/NavRightContent/NavRightContent.tsx: -------------------------------------------------------------------------------- 1 | import { memo } from "react"; 2 | 3 | import { LogoutOutlined } from "@ant-design/icons"; 4 | import { Menu, Avatar } from "antd"; 5 | import { useTranslation } from "react-i18next"; 6 | 7 | import { clearUser } from "@app/features/auth/auth"; 8 | import { getInitials } from "@app/helpers/util.helper"; 9 | import { useAppDispatch, useAppSelector } from "@app/redux/store"; 10 | 11 | const NavRightContent = memo(() => { 12 | const dispatch = useAppDispatch(); 13 | const { t } = useTranslation(); 14 | 15 | // Using the current user 16 | const { user } = useAppSelector(state => ({ 17 | user: state.auth.user, 18 | })); 19 | // TODO: update this when AUTH method/api changes 20 | const name = user?.name ?? "John Doe"; 21 | const userInitials = getInitials(name, 3); 22 | 23 | const handleLogout = () => { 24 | dispatch(clearUser()); 25 | }; 26 | 27 | return ( 28 | 29 | {userInitials}} 33 | > 34 | } 38 | onClick={handleLogout} 39 | > 40 | {t("auth.logout")} 41 | 42 | 43 | 44 | ); 45 | }); 46 | 47 | export default NavRightContent; 48 | -------------------------------------------------------------------------------- /src/components/molecules/PageFilter/PageFilter.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/ban-types */ 2 | import { Children, memo, useCallback, useEffect } from "react"; 3 | 4 | import { Form, FormProps, Row, Col, Button } from "antd"; 5 | import _mapValues from "lodash/mapValues"; 6 | import { useTranslation } from "react-i18next"; 7 | 8 | import useSearchParams from "@app/hooks/useSearchParams"; 9 | 10 | import FilterItem, { 11 | FilterItemCheckbox, 12 | } from "./components/FilterItem/FilterItem"; 13 | import { parseFilters } from "./helpers/page-filter.helpers"; 14 | import { ParseFiltersProps } from "./types/page-filter.types"; 15 | 16 | interface PageFilterProps extends FormProps, ParseFiltersProps { 17 | /** 18 | * Each child should be wrapped in a FilterItem, 19 | * or FilterItemCheckbox, component in order for 20 | * the filter to pick up on changes to a given 21 | * field. 22 | */ 23 | children: React.ReactNode; 24 | /** 25 | * The amount of columns to use on desktop, or from 26 | * the "lg" breakpoint. Alternatively if you need your 27 | * filters to be layed out in a vertical manner, you 28 | * can simply set columns to 1; 29 | */ 30 | columns?: 1 | 2 | 3 | 4 | 5 | 6; 31 | /** 32 | * Outputs a reset button that resets the filters. 33 | */ 34 | showResetButton?: boolean; 35 | /** 36 | * Outputs a submit / apply button, which means that the 37 | * filters will no longer trigger on change, but rather on 38 | * submit. 39 | */ 40 | showSubmitButton?: boolean; 41 | /** 42 | * Submit the form when form fields are changed. 43 | * When `showSubmitButton = false`, the form will be submitted by pressing `Enter` key 44 | */ 45 | submitOnChange?: boolean; 46 | /** 47 | * Function to call when all filters have been reset by a reset button. 48 | */ 49 | onReset?: () => void; 50 | /** 51 | * Function to call once filters have been submitted. This of 52 | * course depends on whether or not you have a submit / apply 53 | * button, or triggering the submit on filter change. 54 | */ 55 | onSubmit?: () => void; 56 | /** 57 | * Text for the reset button. Falls back to the default reset translation. 58 | */ 59 | resetText?: string; 60 | /** 61 | * Text for the submit / apply button. Falls back to the default apply translation. 62 | */ 63 | submitText?: string; 64 | } 65 | 66 | const PageFilter = ({ 67 | children, 68 | columns = 4, 69 | showResetButton, 70 | showSubmitButton, 71 | submitOnChange = true, 72 | onReset, 73 | onSubmit, 74 | parseDates, 75 | parseNumbers, 76 | resetText, 77 | submitText, 78 | ...rest 79 | }: PageFilterProps) => { 80 | const { t } = useTranslation(); 81 | const { search, updateSearchParams } = useSearchParams(); 82 | 83 | const [form] = Form.useForm(rest.form); 84 | 85 | const getSearch = useCallback( 86 | () => 87 | parseDates || parseNumbers 88 | ? parseFilters({ 89 | filters: search, 90 | parseDates, 91 | parseNumbers, 92 | }) 93 | : search, 94 | [parseDates, parseNumbers, search] 95 | ); 96 | 97 | // Every time the search is updated 98 | // then we reset the fields and parse in the search values 99 | // this will update the form values when going back 100 | // and forth in the navigation history 101 | useEffect(() => { 102 | const resetFields = _mapValues(form.getFieldsValue(), () => undefined); 103 | form.setFieldsValue({ ...resetFields, ...getSearch() }); 104 | }, [form, getSearch]); 105 | 106 | // Submit filters, update search params. 107 | const handleSubmit = (values: Record) => { 108 | updateSearchParams({ ...values }); 109 | onSubmit?.(); 110 | }; 111 | 112 | // Submit on field change. 113 | const handleChange = ( 114 | changedValues: Record, 115 | allValues: Record 116 | ) => { 117 | if (submitOnChange) { 118 | handleSubmit({ ...allValues }); 119 | } 120 | }; 121 | 122 | // Reset filters, and clear search params. 123 | const handleReset = () => { 124 | const resetFields = _mapValues(form.getFieldsValue(), () => undefined); 125 | updateSearchParams({ page: undefined, ...resetFields }); 126 | onReset?.(); 127 | }; 128 | 129 | return ( 130 |
136 | 137 | {Children.map(children, child => ( 138 | 1 ? 12 : 24} lg={24 / columns}> 139 | {child} 140 | 141 | ))} 142 | 143 | {(showResetButton || showSubmitButton) && ( 144 | 145 | {showResetButton && ( 146 | 147 | 148 | 151 | 152 | 153 | )} 154 | {showSubmitButton && ( 155 | 156 | 157 | 160 | 161 | 162 | )} 163 | 164 | )} 165 |
166 | ); 167 | }; 168 | 169 | export default memo(PageFilter) as typeof PageFilter; 170 | export { FilterItem, FilterItemCheckbox }; 171 | -------------------------------------------------------------------------------- /src/components/molecules/PageFilter/components/FilterItem/FilterItem.tsx: -------------------------------------------------------------------------------- 1 | import { memo } from "react"; 2 | 3 | import { Form, FormItemProps } from "antd"; 4 | 5 | const { Item } = Form; 6 | 7 | interface FilterItemProps extends FormItemProps { 8 | /** 9 | * When in vertical layout, with labels over fields, there are 10 | * scenarios where you don't necessarily want a label, for example 11 | * a checkbox with its own inline label. When not passing a label, 12 | * it would render the checkbox inline with the labels of other 13 | * fields. Therefore in order to "force" the field, the checkbox 14 | * in this scenario, to align with the other fields, we can pass 15 | * it a label with a whitespace. 16 | */ 17 | noLabel?: boolean; 18 | } 19 | 20 | const FilterItem = memo(({ noLabel, ...rest }: FilterItemProps) => ( 21 | 27 | )); 28 | 29 | /** 30 | * In order to avoid having to write out valuePropName="checked" again 31 | * and again, when you have a filter setup with many checkboxes, importing 32 | * the FilterItemCheckbox variant can improve maintainability. 33 | */ 34 | const FilterItemCheckbox = memo((props: FilterItemProps) => ( 35 | 36 | )); 37 | 38 | export default FilterItem; 39 | export { FilterItemCheckbox }; 40 | -------------------------------------------------------------------------------- /src/components/molecules/PageFilter/helpers/page-filter.helpers.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/ban-types */ 2 | import moment from "moment"; 3 | 4 | import { ParseFiltersProps } from "../types/page-filter.types"; 5 | 6 | interface ParseFilterParams extends ParseFiltersProps { 7 | filters: Record; 8 | } 9 | 10 | // Check if value is array and includes key 11 | const isArrayIncludes = (value: unknown, key: unknown) => 12 | Array.isArray(value) && value.includes(key as keyof T); 13 | 14 | // Check if value is string and can be converted to valid number 15 | const isStringNumber = (value: unknown) => 16 | typeof value === "string" && !Number.isNaN(Number(value)); 17 | 18 | export const parseFilters = ({ 19 | filters, 20 | parseDates, 21 | parseNumbers, 22 | }: ParseFilterParams) => { 23 | const parsedFilters: Record = {}; 24 | 25 | Object.entries(filters).forEach(([key, value]) => { 26 | let parsed = false; 27 | 28 | if (typeof value === "string") { 29 | if ( 30 | parseNumbers && 31 | isStringNumber(value) && 32 | (isArrayIncludes(parseNumbers, key) || !Array.isArray(parseNumbers)) 33 | ) { 34 | parsedFilters[key] = Number(value); 35 | parsed = true; 36 | } else if ( 37 | parseDates && 38 | moment(value).isValid() && 39 | (isArrayIncludes(parseDates, key) || !Array.isArray(parseDates)) 40 | ) { 41 | parsedFilters[key] = moment(value); 42 | parsed = true; 43 | } 44 | } else if (Array.isArray(value)) { 45 | if ( 46 | parseNumbers && 47 | (isArrayIncludes(parseNumbers, key) || !Array.isArray(parseNumbers)) 48 | ) { 49 | parsedFilters[key] = value.map(item => { 50 | if (isStringNumber(item)) { 51 | parsed = true; 52 | return Number(item); 53 | } 54 | return item; 55 | }); 56 | } 57 | 58 | if ( 59 | parseDates && 60 | (isArrayIncludes(parseDates, key) || !Array.isArray(parseDates)) && 61 | !parsed 62 | ) { 63 | parsedFilters[key] = value.map(item => { 64 | if (moment(item).isValid()) { 65 | parsed = true; 66 | return moment(item); 67 | } 68 | return item; 69 | }); 70 | } 71 | } 72 | 73 | if (!parsed) { 74 | parsedFilters[key] = value; 75 | } 76 | }); 77 | 78 | return parsedFilters; 79 | }; 80 | -------------------------------------------------------------------------------- /src/components/molecules/PageFilter/page-filter.md: -------------------------------------------------------------------------------- 1 | # PageFilter 2 | 3 | When displaying data in an admin panel, it is quite common to also have a way of filtering the data. This `PageFilter` component achieves this by updating the search params / query string in the URL. Which means that a user will be able to share a URL with a predefined set of filters, as well as have history of their choices. These parameters can then of course be accessed from almost anywhere that has access to the window object, or more conveniently with the use of our custom `useSearchParams` hooks. 4 | 5 | ## Basic usage 6 | 7 | In its simplest form the `PageFilter` can be used with practically no setup, and only requires that you wrap your fields in the `FilterItem` component. The reason for this, is that the `PageFilter` component makes use of the Ant Design `Form` component to detect changes and so on. This also means that the `PageFilter` can accept any props that the `Form` component can. This also applies for the `FilterItem`, which is based on the Ant Design `Form.Item` component. 8 | 9 | Let's have a look at a simple example: 10 | 11 | ```tsx 12 | import { Select } from "antd"; 13 | 14 | import PageFilter, { 15 | FilterItem, 16 | } from "@app/components/molecules/PageFilter/PageFilter"; 17 | 18 | const { Option } = Select; 19 | 20 | 21 | 22 | 27 | 28 | ; 29 | ``` 30 | 31 | With this simple setup any changes to your filters will be reflected in the URL. The property `name` on the `FilterItem` component, is what defines what the given search param will be called in the URL. 32 | 33 | In order to retrieve the search params of your filter from the URL, and have code completion, you will need to create an `interface`/`type` for your filter properties, and pass it to the `useSearchParams` hook. 34 | 35 | ```tsx 36 | import useSearchParams from "@app/hooks/useSearchParams"; 37 | 38 | interface ClientFilterProps { 39 | client?: string; 40 | } 41 | 42 | const { search } = useSearchParams(); 43 | ``` 44 | 45 | You will now have code completion, and TypeScript won't flag it is a non-exist property. 46 | 47 | ## Parsing field types 48 | 49 | By default when retrieving search params from the URL, they are all returned as a `string` or `boolean`. If you have fields that have values other than strings and booleans, then we have built in the functionality to parse `dates` and `numbers`. If you do not use these properties, then your filter will fail upon page reload, if the URL contains predefined filter params, as the value will not match up to your field's specific data type. 50 | 51 | | Property | Description | Type | Default | 52 | | -------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------- | ------- | 53 | | `parseDates` | Runs through the query string and parses strings with date values. You can pass it an array of the specific keys that should be passed, or a boolean if all strings and arrays with date values should be parsed. **Use this when using date pickers.** | `boolean` \| Array\ | - | 54 | | `parseNumbers` | Runs through the query string and parses strings with numbers. You can pass it an array of the specific keys that should be passed, or a boolean if all strings and arrays with number values should be parsed. **Use when you have fields that contain numbers for values.** | `boolean` \| Array\ | - | 55 | 56 | ### Specifying fields to parse 57 | 58 | As noted for all the parameters, you can either pass in a `boolean` or an `array`. Passing in `array` of `strings` allows you to specify exactly which fields / parameters you want parsed, and as what data type. It is also recommended to pass a type containing the properties to the PageFilter. Let's take a look at an example. 59 | 60 | ```tsx 61 | import { Select } from "antd"; 62 | 63 | import PageFilter, { 64 | FilterItem, 65 | } from "@app/components/molecules/PageFilter/PageFilter"; 66 | 67 | const { Option } = Select; 68 | 69 | interface PeriodFilterProps { 70 | period?: number; 71 | } 72 | 73 | parseNumber={["period"]}> 74 | 75 | 80 | 81 | ; 82 | ``` 83 | 84 | Passing the `interface`/`type` to the `PageFilter` component isn't strictly necessary, but it does give you code completion, when defining which property you want to have parsed. 85 | 86 | ### Debounce fields 87 | 88 | In some cases you will have a text field, which will update the url on every change unless you have a submit button. But without the submit button, you can use the `DebouncedInput` component to debounce the changes in an input and thus only update the url after a specific delay. 89 | 90 | For example: 91 | 92 | ```tsx 93 | import { Input } from "antd"; 94 | import DebouncedInput from "@app/components/atoms/DebouncedInput/DebouncedInput"; 95 | 96 | 97 | 98 | 99 | 100 | ; 101 | ``` 102 | 103 | ## Trigger reset / submit 104 | 105 | By default the filter will update whenever a field changes. We have however supplied two props that render a submit button, and a reset button. With these props set, the filter will not update until the submit button has been clicked. The reset button clears all fields. 106 | 107 | It is also possible to trigger the filter from outside of the `PageFilter` component by way of two `boolean` properties and two function properties. Let's have a look at how this is achieved. 108 | 109 | | Property | Description | Type | Default | 110 | | ------------------ | ------------------------------------------------- | ------------ | -------------------- | 111 | | `showResetButton` | Renders a reset button that clears all fields | `boolean` | `false` | 112 | | `showSubmitButton` | Renders a submit button that submits the filter | `boolean` | `false` | 113 | | `onReset` | Function to call once a reset has been triggered | `() => void` | - | 114 | | `onSubmit` | Function to call once a submit has been triggered | `() => void` | - | 115 | | `resetText` | Text for the reset button | `string` | `t("default.reset")` | 116 | | `submitText` | Text for the submit button | `string` | `t("default.apply")` | 117 | 118 | ## Layout 119 | 120 | ### Columns 121 | 122 | By default the `PageFilter` component is setup using the Ant Design grid system, and will without further setup have 4 columns on desktop, 2 columns on tablet, and 1 column on mobile. If you want an all vertical filter, you can simply set the column count to 1. 123 | 124 | | Property | Description | Type | Default | 125 | | --------- | ----------------------------------------------- | -------------------------------------- | ------- | 126 | | `columns` | Sets the amount of columns there are on desktop | `1` \| `2` \| `3` \| `4` \| `5` \| `6` | `4` | 127 | 128 | We have defined a maximum of 6 columns for now, as we do not think more will be necessary. However, if you need more, you can simply adjust the property type in the `PageFilter` component. 129 | 130 | ### Layout modes 131 | 132 | As the `PageFilter` component can accept the same props as the `Form` component from Ant Design, you can also pass it the different layout modes described [here](https://ant.design/components/form/#components-form-demo-layout). However, we have not as of yet accommodated the different layout modes in the `PageFilter` component. If you need any of these different modes, you will likely need to add your own styles to the `PageFilter` component. We might look into this in the future. 133 | -------------------------------------------------------------------------------- /src/components/molecules/PageFilter/types/page-filter.types.ts: -------------------------------------------------------------------------------- 1 | type ParseDef = boolean | Array; 2 | 3 | export interface ParseFiltersProps { 4 | /** 5 | * Runs through the query string and parses strings with date values. 6 | * If a boolean is passed, all strings and arrays with date values will be parsed. 7 | * If you want to specify which key / property to parse, you can pass 8 | * it an array of strings for keys, but you will also need to pass a 9 | * type containing the properties to the PageFilter. 10 | * 11 | * **Use this when using date pickers.** 12 | */ 13 | parseDates?: ParseDef; 14 | /** 15 | * Runs through the query string and parses strings with numbers. 16 | * If a boolean is passed, all strings and arrays with number values will be parsed. 17 | * If you want to specify which key / property to parse, you can 18 | * pass it an array of strings for keys, but you will also need 19 | * to pass a type containing the properties to the PageFilter. 20 | * 21 | * **Use when you have fields that contain numbers for values.** 22 | */ 23 | parseNumbers?: ParseDef; 24 | } 25 | -------------------------------------------------------------------------------- /src/components/molecules/TableView/TableView.module.scss: -------------------------------------------------------------------------------- 1 | .table { 2 | .actions { 3 | text-align: right !important; 4 | } 5 | :global { 6 | .ant-table-title { 7 | text-align: right; 8 | padding-right: 0; 9 | padding-left: 0; 10 | } 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/components/molecules/TableView/TableView.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/ban-types */ 2 | import { memo, ReactNode } from "react"; 3 | 4 | import { 5 | CopyOutlined, 6 | DeleteOutlined, 7 | EditOutlined, 8 | MenuOutlined, 9 | EyeOutlined, 10 | } from "@ant-design/icons"; 11 | import { Table, Space, Popconfirm, Tooltip, Menu, Dropdown } from "antd"; 12 | import { TablePaginationConfig, TableProps } from "antd/lib/table"; 13 | import { 14 | Key, 15 | SorterResult, 16 | TableCurrentDataSource, 17 | } from "antd/lib/table/interface"; 18 | import cx from "classnames"; 19 | import { useTranslation } from "react-i18next"; 20 | 21 | import Button from "@app/components/atoms/Button/Button"; 22 | import { getOrderBy } from "@app/helpers/table.helper"; 23 | import { scrollToTop } from "@app/helpers/util.helper"; 24 | import useSearchParams from "@app/hooks/useSearchParams"; 25 | 26 | import styles from "./TableView.module.scss"; 27 | 28 | const { Column } = Table; 29 | 30 | export type ActionMenuDef = { key: string; label: string }[]; 31 | 32 | export interface TableViewProps extends Omit, "columns"> { 33 | actionTitle?: string; 34 | onView?: (record: T) => void; 35 | onEdit?: (record: T) => void; 36 | onDelete?: (record: T) => void; 37 | onDuplicate?: (record: T) => void; 38 | extraActions?: (record: T) => ReactNode; 39 | actionMenu?: ActionMenuDef; 40 | onActionMenu?: (key: string, record: T) => void; 41 | actionWidth?: number | string; 42 | hideActionColumn?: boolean; 43 | disableScrollToTopOnChange?: boolean; 44 | } 45 | 46 | const TableView = ({ 47 | actionTitle, 48 | onView, 49 | onEdit, 50 | onDelete, 51 | onDuplicate, 52 | children, 53 | onChange, 54 | actionMenu, 55 | onActionMenu, 56 | extraActions, 57 | actionWidth = 150, 58 | hideActionColumn = false, 59 | className, 60 | disableScrollToTopOnChange, 61 | ...tableProps 62 | }: TableViewProps) => { 63 | const { t } = useTranslation(); 64 | const { updateSearchParams } = useSearchParams(); 65 | 66 | const handleOnChange = ( 67 | pagination: TablePaginationConfig, 68 | filters: Record, 69 | sorter: SorterResult | SorterResult[], 70 | extra: TableCurrentDataSource 71 | ) => { 72 | let orderBy: string | undefined; 73 | if (!Array.isArray(sorter)) { 74 | // Use column key or field (`dataIndex`), depending on what is set 75 | const columnKey = 76 | sorter.columnKey?.toString() ?? sorter.field?.toString(); 77 | if (sorter.order && columnKey) { 78 | orderBy = getOrderBy(columnKey, sorter.order); 79 | } 80 | } 81 | 82 | const page = pagination.current; 83 | const { pageSize } = pagination; 84 | 85 | updateSearchParams({ 86 | orderBy, 87 | page, 88 | pageSize, 89 | }); 90 | 91 | if (!disableScrollToTopOnChange) { 92 | // Scroll to top when there are changes to pagination, sorting, or filters 93 | scrollToTop(); 94 | } 95 | 96 | onChange?.(pagination, filters, sorter, extra); 97 | }; 98 | 99 | const getMenu = (record: T) => ( 100 | onActionMenu?.(e.key.toString(), record)}> 101 | {actionMenu?.map(({ key, label }) => ( 102 | {label} 103 | ))} 104 | 105 | ); 106 | 107 | return ( 108 | 114 | {children} 115 | {!hideActionColumn && ( 116 | 117 | key="action" 118 | title={actionTitle} 119 | fixed="right" 120 | width={actionWidth} 121 | className={styles.actions} 122 | render={(_, record) => ( 123 | 124 | {!!onView && ( 125 | 126 |
181 | ); 182 | }; 183 | 184 | export default memo(TableView) as typeof TableView; 185 | export { Column }; 186 | -------------------------------------------------------------------------------- /src/constants/api.constants.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * API response status codes enum 3 | */ 4 | export enum ApiStatusCodes { 5 | SUCCESS = 200, 6 | CREATED = 201, 7 | NO_CONTENT = 204, 8 | UNAUTHORIZED = 401, 9 | NOT_FOUND = 404, 10 | } 11 | -------------------------------------------------------------------------------- /src/constants/env.ts: -------------------------------------------------------------------------------- 1 | import packageJson from "../../package.json"; 2 | 3 | export const ENV = { 4 | VERSION: packageJson.version || "", 5 | NODE_ENV: process.env.NODE_ENV, 6 | API_HOST: process.env.REACT_APP_API_HOST ?? "", 7 | }; 8 | -------------------------------------------------------------------------------- /src/constants/route.constants.ts: -------------------------------------------------------------------------------- 1 | export enum ItemModalEnum { 2 | ADD = "add", 3 | EDIT = "edit", 4 | } 5 | -------------------------------------------------------------------------------- /src/features/auth/api/auth.api.ts: -------------------------------------------------------------------------------- 1 | import { AxiosResponse } from "axios"; 2 | 3 | import { api } from "@app/api/api"; 4 | 5 | import { AuthEndpointsEnum } from "../constants/auth.endpoints"; 6 | import { LoginRequestDef } from "../types/auth.types"; 7 | 8 | export const authLogin = (data: LoginRequestDef): Promise => { 9 | return api.post(AuthEndpointsEnum.LOGIN, data); 10 | }; 11 | 12 | export const authGetMe = (): Promise => { 13 | // TODO: change when AUTH method is changed => 14 | // Using a fixed user to emulate a get current user call 15 | return api.get(`${AuthEndpointsEnum.USERS}/2`); 16 | }; 17 | -------------------------------------------------------------------------------- /src/features/auth/auth.md: -------------------------------------------------------------------------------- 1 | # Authentication setup for OpenApi 2 | 3 | The template has a basic authentication setup, that authenticates a dummy user. To setup authentication with openApi, following steps can be necessary. 4 | 5 | --- 6 | 7 | ## In file /api/api.ts 8 | 9 | const isRefreshing = false; 10 | 11 | In function getRefreshedToken() 12 | 13 | /** 14 | * Refresh token 15 | */ 16 | if (isTokenExpired && !isRefreshing) { 17 | isRefreshing = true; 18 | authApi 19 | .refreshAccessToken() 20 | .then(response => { 21 | if (response.data?.token) { 22 | saveTokens(response.data.token); 23 | isRefreshing = false; 24 | onAccessTokenFetched(response.data.token.accessToken); 25 | subscribers = []; 26 | } 27 | }) 28 | .catch(() => { 29 | clearTokens(); 30 | store.dispatch(clearUser()); 31 | }); 32 | } 33 | 34 | in function authInterceptor(): 35 | 36 | const { accessToken, isTokenExpired } = getRefreshedToken(); 37 | 38 | /** 39 | use for refreshing from api => 40 | */ 41 | const isRefreshTokenRequest = request.url === AuthEndpointsEnum.REFRESH_TOKEN.toString(); 42 | 43 | if (isTokenExpired && !isRefreshTokenRequest) { 44 | const retryOriginalRequest = new Promise(resolve => { 45 | addSubscriber(token => { 46 | request.headers.Authorization = `${tokenType} ${token}`; 47 | resolve(request); 48 | }); 49 | }); 50 | return retryOriginalRequest as AxiosRequestConfig; 51 | } 52 | 53 | --- 54 | 55 | ## In file: api.constants.ts 56 | 57 | /** 58 | Approach for open api based projects: 59 | */ 60 | import { Configuration } from "@app/@generated"; 61 | import { getRefreshedToken } from "@app/features/auth/auth"; 62 | 63 | import { ENV } from "./env"; 64 | 65 | const apiClientConfiguration = new Configuration(); 66 | 67 | apiClientConfiguration.basePath = ENV.API_HOST; 68 | 69 | apiClientConfiguration.accessToken = async () => { 70 | const accessToken = await getRefreshedToken(); 71 | return accessToken ?? ""; 72 | }; 73 | 74 | export { apiClientConfiguration }; 75 | -------------------------------------------------------------------------------- /src/features/auth/auth.ts: -------------------------------------------------------------------------------- 1 | export * from "./constants/auth.paths"; 2 | export * from "./constants/auth.endpoints"; 3 | export * from "./constants/auth.keys"; 4 | export * from "./helpers/auth.helpers"; 5 | export * from "./routes/auth.routes"; 6 | export * from "./redux/auth.slice"; 7 | export * from "./types/auth.types"; 8 | -------------------------------------------------------------------------------- /src/features/auth/constants/auth.endpoints.ts: -------------------------------------------------------------------------------- 1 | export enum AuthEndpointsEnum { 2 | LOGIN = "login", 3 | LOGOUT = "logout", 4 | RESET_PASSWORD = "reset-password", 5 | REFRESH_TOKEN = "refresh-token", 6 | USERS = "users", 7 | } 8 | -------------------------------------------------------------------------------- /src/features/auth/constants/auth.keys.ts: -------------------------------------------------------------------------------- 1 | // TODO: Update to use name of project 2 | export const AUTH_ACCESS_TOKEN = "admin-panel-tokens"; 3 | export const AUTH_FEATURE_KEY = "auth"; 4 | -------------------------------------------------------------------------------- /src/features/auth/constants/auth.paths.ts: -------------------------------------------------------------------------------- 1 | export enum AuthPathsEnum { 2 | LOGIN = "/login", 3 | } 4 | -------------------------------------------------------------------------------- /src/features/auth/helpers/auth.helpers.ts: -------------------------------------------------------------------------------- 1 | import moment from "moment"; 2 | import cookie from "react-cookies"; 3 | 4 | import { AUTH_ACCESS_TOKEN } from "../constants/auth.keys"; 5 | import { TokenDef, ApiResponseDef, InitialStateDef } from "../types/auth.types"; 6 | 7 | /** 8 | * Loads token from session cookie 9 | */ 10 | export const getTokens = () => { 11 | const cookieToken: TokenDef | undefined = cookie.load(AUTH_ACCESS_TOKEN); 12 | 13 | return { 14 | accessToken: cookieToken?.accessToken, 15 | refreshToken: cookieToken?.refreshToken, 16 | expiresAt: cookieToken?.expiresAt, 17 | } as TokenDef; 18 | }; 19 | 20 | /** 21 | * Save token and refresh token to session cookie, 22 | * Default value used for demo API 23 | */ 24 | export const saveTokens = ({ expiresIn = "3600", token }: ApiResponseDef) => { 25 | const cookieToken: TokenDef = { 26 | accessToken: token, 27 | expiresAt: moment().add(expiresIn, "seconds").format(), 28 | }; 29 | cookie.save(AUTH_ACCESS_TOKEN, cookieToken, { path: "/" }); 30 | }; 31 | 32 | /** 33 | * Clear token from session cookie 34 | */ 35 | export const clearTokens = () => { 36 | return cookie.remove(AUTH_ACCESS_TOKEN, { path: "/" }); 37 | }; 38 | 39 | /** 40 | * simplify code in slice with helper 41 | */ 42 | export const authErrorHelper = (state: InitialStateDef) => { 43 | state.user = null; 44 | state.isAuthenticated = false; 45 | state.error = true; 46 | state.loading = false; 47 | }; 48 | -------------------------------------------------------------------------------- /src/features/auth/hooks/useRedirectAfterLogin.ts: -------------------------------------------------------------------------------- 1 | import { useEffect } from "react"; 2 | 3 | import qs from "query-string"; 4 | import { useHistory, useLocation } from "react-router-dom"; 5 | 6 | import { useAppSelector } from "@app/redux/store"; 7 | import { RedirectDef } from "@app/types/route.types"; 8 | 9 | function useRedirectAfterLogin() { 10 | const history = useHistory(); 11 | const location = useLocation(); 12 | const { redirect } = qs.parse(location.search); 13 | const { isAuthenticated } = useAppSelector(state => ({ 14 | isAuthenticated: state.auth?.isAuthenticated, 15 | })); 16 | 17 | useEffect(() => { 18 | if (isAuthenticated) { 19 | history.push((redirect as string) ?? "/"); 20 | } 21 | }, [redirect, history, isAuthenticated]); 22 | return null; 23 | } 24 | 25 | export default useRedirectAfterLogin; 26 | -------------------------------------------------------------------------------- /src/features/auth/redux/auth.slice.ts: -------------------------------------------------------------------------------- 1 | import { createAsyncThunk, createSlice, PayloadAction } from "@reduxjs/toolkit"; 2 | 3 | import { LoginRequestDef, AUTH_FEATURE_KEY } from "@app/features/auth/auth"; 4 | 5 | import { authGetMe, authLogin } from "../api/auth.api"; 6 | import { 7 | saveTokens, 8 | clearTokens, 9 | authErrorHelper, 10 | } from "../helpers/auth.helpers"; 11 | import { UserDef, InitialStateDef, UserResponseDef } from "../types/auth.types"; 12 | 13 | const initialState: InitialStateDef = { 14 | user: null, 15 | isAuthenticated: false, 16 | error: false, 17 | loading: false, 18 | }; 19 | 20 | export const login = createAsyncThunk( 21 | `${AUTH_FEATURE_KEY}/login`, 22 | async (values: LoginRequestDef, { rejectWithValue }) => { 23 | try { 24 | const response = await authLogin(values); 25 | return response.data; 26 | } catch (err) { 27 | return rejectWithValue(err.response.data); 28 | } 29 | } 30 | ); 31 | 32 | export const getMe = createAsyncThunk( 33 | `${AUTH_FEATURE_KEY}/getUser`, 34 | async (_, { rejectWithValue }) => { 35 | try { 36 | const response = await authGetMe(); 37 | return response.data; 38 | } catch (err) { 39 | return rejectWithValue(err.response.data); 40 | } 41 | } 42 | ); 43 | 44 | const authSlice = createSlice({ 45 | name: AUTH_FEATURE_KEY, 46 | initialState, 47 | reducers: { 48 | clearUser(state) { 49 | state.user = null; 50 | state.isAuthenticated = false; 51 | clearTokens(); 52 | }, 53 | }, 54 | extraReducers: builder => { 55 | /** 56 | * LOGIN 57 | */ 58 | builder.addCase(login.pending, state => { 59 | state.error = false; 60 | state.loading = true; 61 | }); 62 | builder.addCase(login.fulfilled, (state, action) => { 63 | const { token } = action.payload; 64 | state.loading = false; 65 | state.isAuthenticated = true; 66 | 67 | if (token) { 68 | saveTokens({ token }); 69 | } 70 | }); 71 | builder.addCase(login.rejected, state => { 72 | authErrorHelper(state); 73 | clearTokens(); 74 | }); 75 | /** 76 | * GET AUTHENTICATED USER 77 | */ 78 | 79 | builder.addCase(getMe.pending, state => { 80 | state.error = false; 81 | state.loading = true; 82 | }); 83 | builder.addCase( 84 | getMe.fulfilled, 85 | (state, action: PayloadAction) => { 86 | const { data } = action.payload; 87 | const name = `${data.first_name} ${data.last_name}`; 88 | 89 | const user: UserDef = { 90 | ...data, 91 | name: name ?? "John Doe", // <= fallback name, TODO: remove 92 | }; 93 | state.loading = false; 94 | state.user = user; 95 | state.isAuthenticated = true; 96 | } 97 | ); 98 | builder.addCase(getMe.rejected, state => { 99 | authErrorHelper(state); 100 | clearTokens(); 101 | }); 102 | }, 103 | }); 104 | 105 | export const { clearUser } = authSlice.actions; 106 | 107 | export const authReducer = authSlice.reducer; 108 | -------------------------------------------------------------------------------- /src/features/auth/routes/auth.routes.ts: -------------------------------------------------------------------------------- 1 | import { LayoutsEnum } from "@app/routes/constants/route.layouts"; 2 | import { RouteItemDef } from "@app/types/route.types"; 3 | 4 | import { AuthPathsEnum } from "../constants/auth.paths"; 5 | import LoginScreen from "../screens/LoginScreen/LoginScreen"; 6 | 7 | const LOGIN_SCREEN: RouteItemDef = { 8 | id: "login", 9 | path: AuthPathsEnum.LOGIN, 10 | component: LoginScreen, 11 | navigationTitle: "auth.loginTitle", 12 | layout: LayoutsEnum.BLANK_LAYOUT, 13 | }; 14 | 15 | export const AUTH_ROUTES = [LOGIN_SCREEN]; 16 | -------------------------------------------------------------------------------- /src/features/auth/screens/LoginScreen/LoginScreen.module.scss: -------------------------------------------------------------------------------- 1 | .container { 2 | height: 100vh; 3 | } 4 | -------------------------------------------------------------------------------- /src/features/auth/screens/LoginScreen/LoginScreen.tsx: -------------------------------------------------------------------------------- 1 | import { LockOutlined, UserOutlined } from "@ant-design/icons"; 2 | import { Card, Col, Input, message, Row } from "antd"; 3 | import { useTranslation } from "react-i18next"; 4 | 5 | import Button from "@app/components/atoms/Button/Button"; 6 | import Form, { Item, useForm } from "@app/components/atoms/Form/Form"; 7 | import { LoginRequestDef } from "@app/features/auth/auth"; 8 | import { useAppDispatch, useAppSelector } from "@app/redux/store"; 9 | 10 | import useRedirectAfterLogin from "../../hooks/useRedirectAfterLogin"; 11 | import { login } from "../../redux/auth.slice"; 12 | import styles from "./LoginScreen.module.scss"; 13 | 14 | /** 15 | * TODO: Discard this after demo phase 16 | */ 17 | const DEMO_USER_NAME = "george.bluth@reqres.in"; 18 | 19 | const LoginScreen = () => { 20 | const { t } = useTranslation(); 21 | const [form] = useForm(); 22 | const dispatch = useAppDispatch(); 23 | const loading = useAppSelector(state => state.auth.loading); 24 | useRedirectAfterLogin(); 25 | 26 | const handleFinish = async (values: LoginRequestDef) => { 27 | const response = await dispatch(login(values)); 28 | if (login.fulfilled.match(response)) { 29 | message.success(t("auth.messageSuccess")); 30 | } else { 31 | message.error(t("auth.messageError")); 32 | } 33 | }; 34 | 35 | return ( 36 | 37 | 38 | 39 |
40 | 53 | } type="text" /> 54 | 55 | 66 | } /> 67 | 68 | 69 | 72 | 73 |
74 |
75 | 76 |
77 | ); 78 | }; 79 | 80 | export default LoginScreen; 81 | -------------------------------------------------------------------------------- /src/features/auth/types/auth.types.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Types are specific for regres login => 3 | */ 4 | 5 | export type TokenDef = { 6 | /* TODO: check if TYPE needs changing when AUTH method is changed ? */ 7 | /* login system is for demo use (regres.in) */ 8 | accessToken?: string; 9 | refreshToken?: string; 10 | expiresAt?: string; 11 | tokenType?: string; 12 | }; 13 | 14 | /** 15 | * For Demo - regres - api, only token is returned from api, 16 | * but expiresIn is added in code 17 | */ 18 | export type ApiResponseDef = { 19 | token: string; 20 | expiresIn?: string; 21 | }; 22 | 23 | export type LoginRequestDef = { 24 | email: string; 25 | password: string; 26 | }; 27 | 28 | export type UserDef = { 29 | email: string; 30 | name: string; 31 | avatar: string; 32 | }; 33 | 34 | /** 35 | * For redux slice and helper function 36 | */ 37 | export interface InitialStateDef { 38 | user: UserDef | null; 39 | isAuthenticated: boolean; 40 | error: boolean; 41 | loading: boolean; 42 | } 43 | 44 | /* eslint-disable camelcase */ 45 | export type UserResponseDef = { 46 | data: { 47 | id: number; 48 | first_name: string; 49 | last_name: string; 50 | avatar: string; 51 | email: string; 52 | }; 53 | }; 54 | -------------------------------------------------------------------------------- /src/features/home/constants/home.paths.ts: -------------------------------------------------------------------------------- 1 | export enum HomePathsEnum { 2 | HOME = "/home", 3 | } 4 | -------------------------------------------------------------------------------- /src/features/home/home.ts: -------------------------------------------------------------------------------- 1 | export { HOME_ROUTES } from "./routes/home.routes"; 2 | -------------------------------------------------------------------------------- /src/features/home/routes/home.routes.ts: -------------------------------------------------------------------------------- 1 | import { PermissionEnum } from "@app/features/permissions/permissions"; 2 | import { RouteItemDef } from "@app/types/route.types"; 3 | 4 | import { HomePathsEnum } from "../constants/home.paths"; 5 | import HomeScreen from "../screens/HomeScreen/HomeScreen"; 6 | 7 | const HOME_SCREEN: RouteItemDef = { 8 | id: "home", 9 | path: HomePathsEnum.HOME, 10 | navigationTitle: "home.navigationTitle", 11 | component: HomeScreen, 12 | permissions: [PermissionEnum.DASHBOARD], 13 | }; 14 | 15 | export const HOME_ROUTES = [HOME_SCREEN]; 16 | -------------------------------------------------------------------------------- /src/features/home/screens/HomeScreen/HomeScreen.tsx: -------------------------------------------------------------------------------- 1 | import { useTranslation } from "react-i18next"; 2 | 3 | import ContentLayout from "@app/components/layouts/ContentLayout/ContentLayout"; 4 | import { getCurrentLanguage } from "@app/features/localization/localization"; 5 | 6 | const HomeScreen = () => { 7 | const { t } = useTranslation(); 8 | return ( 9 | 10 |

11 | {t("home.text")} 12 | {getCurrentLanguage()} 13 |

14 |
15 | ); 16 | }; 17 | 18 | export default HomeScreen; 19 | -------------------------------------------------------------------------------- /src/features/localization/config/localization.config.ts: -------------------------------------------------------------------------------- 1 | import { NstackInstance } from "@nstack-io/javascript-sdk"; 2 | import i18next from "i18next"; 3 | import { initReactI18next } from "react-i18next"; 4 | 5 | import { ENV } from "@app/constants/env"; 6 | 7 | import { DEFAULT_NS, INITIAL_LANG } from "../constants/localization.constants"; 8 | import { NSTACK_ENV } from "../constants/localization.env"; 9 | 10 | export const nstackClient = 11 | (NSTACK_ENV.NSTACK_APP_ID && 12 | NSTACK_ENV.NSTACK_API_KEY && 13 | new NstackInstance({ 14 | appId: NSTACK_ENV.NSTACK_APP_ID, 15 | apiKey: NSTACK_ENV.NSTACK_API_KEY, 16 | version: ENV.VERSION, 17 | initialLanguage: INITIAL_LANG, 18 | meta: `web;${ENV.NODE_ENV}`, 19 | })) || 20 | undefined; 21 | 22 | i18next.use(initReactI18next).init({ 23 | fallbackLng: INITIAL_LANG, 24 | lng: INITIAL_LANG, 25 | interpolation: { 26 | escapeValue: false, 27 | }, 28 | defaultNS: DEFAULT_NS, 29 | resources: { 30 | // TODO: Move translation to nstack 31 | "en-EN": { 32 | translation: { 33 | default: { 34 | notFoundTitle: "404 - Not found", 35 | notFoundText: "The page you were looking for was not found.", 36 | notFoundBackHomeButton: "Go to home", 37 | restrictAccessTitle: "Access denied", 38 | restrictAccessText: 39 | "Sorry! You don't have necessary permission to access this page!", 40 | columnAction: "Action", 41 | deleteTitle: "Delete", 42 | confirmDeleteTitle: "Are you sure to delete this?", 43 | confirmDeleteYes: "Yes", 44 | confirmDeleteNo: "No", 45 | duplicateTitle: "Duplicate", 46 | editTitle: "Edit", 47 | saveTitle: "Save", 48 | cancelTitle: "Cancel", 49 | okTitle: "Ok", 50 | moreTitle: "More", 51 | apply: "Apply", 52 | reset: "Reset", 53 | unsavedChangesTitle: "Are you sure you want to leave?", 54 | unsavedChangesText: 55 | "You have unsaved changes in the form, if you close it the changes will be discarded.", 56 | unsavedChangesCancelTitle: "Keep editing", 57 | unsavedChangesConfirmTitle: "Leave", 58 | inputErrorRequired: "Required field", 59 | }, 60 | auth: { 61 | loginTitle: "Sign in", 62 | inputEmailLabel: "Email", 63 | inputPasswordLabel: "Password", 64 | loginButton: "Sign in", 65 | logout: "Log out", 66 | messageSuccess: "Sign in success", 67 | }, 68 | home: { 69 | navigationTitle: "Home", 70 | title: "Home", 71 | text: "Content", 72 | }, 73 | settings: { 74 | navigationTitle: "Settings", 75 | groupUsersSettings: "Group Title", 76 | }, 77 | settingsProjects: { 78 | navigationTitle: "Projects", 79 | title: "Projects", 80 | text: "Content", 81 | }, 82 | settingsUsers: { 83 | navigationTitle: "Users", 84 | title: "Users", 85 | text: "Content", 86 | columnName: "Name", 87 | columnLastName: "Last Name", 88 | editUserTitle: "Edit user", 89 | addUserTitle: "Add user", 90 | buttonAddUser: "Add user", 91 | menuDuplicate: "Duplicate user", 92 | editUserRole: "Edit user role", 93 | buttonUserRole: "change role", 94 | filterSearchLabel: "Search", 95 | filterDatesLabel: "Dates", 96 | filterNameLabel: "Name", 97 | filterNamePlaceholder: "Select name", 98 | filterHasEmailLabel: "Has e-mail", 99 | inputFirstNameLabel: "First name", 100 | inputFirstNamePlaceholder: "Enter first name...", 101 | inputLastNameLabel: "Last name", 102 | inputLastNamePlaceholder: "Enter last name...", 103 | }, 104 | }, 105 | }, 106 | }, 107 | }); 108 | 109 | export default i18next; 110 | -------------------------------------------------------------------------------- /src/features/localization/constants/localization.constants.ts: -------------------------------------------------------------------------------- 1 | export const INITIAL_LANG = "en-EN"; 2 | export const DEFAULT_NS = "translation"; 3 | -------------------------------------------------------------------------------- /src/features/localization/constants/localization.env.ts: -------------------------------------------------------------------------------- 1 | export const NSTACK_ENV = { 2 | NSTACK_APP_ID: process.env.REACT_APP_NSTACK_APP_ID ?? "", 3 | NSTACK_API_KEY: process.env.REACT_APP_NSTACK_API_KEY ?? "", 4 | }; 5 | -------------------------------------------------------------------------------- /src/features/localization/helpers/localization.helpers.ts: -------------------------------------------------------------------------------- 1 | import i18next, { nstackClient } from "../config/localization.config"; 2 | import { DEFAULT_NS } from "../constants/localization.constants"; 3 | 4 | export const updateLocalization = async () => { 5 | if (!nstackClient) { 6 | return false; 7 | } 8 | 9 | const { translation, translationMeta, availableLanguages } = 10 | await nstackClient.appOpen(); 11 | 12 | if (translation && translationMeta) { 13 | i18next.addResourceBundle( 14 | translationMeta.language.locale, 15 | DEFAULT_NS, 16 | translation 17 | ); 18 | 19 | await i18next.changeLanguage(translationMeta.language.locale); 20 | return { availableLanguages }; 21 | } 22 | return false; 23 | }; 24 | 25 | export const changeLanguage = (locale: string) => { 26 | if (nstackClient) { 27 | nstackClient.setLanguageByString = locale; 28 | } 29 | updateLocalization(); 30 | }; 31 | 32 | export const getCurrentLanguage = () => { 33 | return i18next.language; 34 | }; 35 | 36 | export const getCountries = async () => { 37 | const { countries = [] } = 38 | (await nstackClient?.getGeographyCountries()) ?? {}; 39 | 40 | return { countries }; 41 | }; 42 | -------------------------------------------------------------------------------- /src/features/localization/hooks/useLocalization.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from "react"; 2 | 3 | import { updateLocalization } from "../helpers/localization.helpers"; 4 | 5 | interface LocalizationProps { 6 | shouldCall?: boolean; 7 | } 8 | 9 | function useLocalization({ shouldCall = true }: LocalizationProps) { 10 | const [loadingTranslation, setLoadingTranslation] = useState(shouldCall); 11 | 12 | useEffect(() => { 13 | const fetchTranslation = async () => { 14 | try { 15 | // Fetch the translations when app is started 16 | await updateLocalization(); 17 | } finally { 18 | setLoadingTranslation(false); 19 | } 20 | }; 21 | 22 | shouldCall && fetchTranslation(); 23 | }, [shouldCall]); 24 | 25 | return { 26 | loadingTranslation, 27 | }; 28 | } 29 | 30 | export default useLocalization; 31 | -------------------------------------------------------------------------------- /src/features/localization/localization.ts: -------------------------------------------------------------------------------- 1 | export { 2 | updateLocalization, 3 | getCurrentLanguage, 4 | } from "./helpers/localization.helpers"; 5 | export { default as useLocalization } from "./hooks/useLocalization"; 6 | -------------------------------------------------------------------------------- /src/features/permissions/components/Permission/Permission.tsx: -------------------------------------------------------------------------------- 1 | import { memo, ReactNode } from "react"; 2 | 3 | import { Redirect } from "react-router-dom"; 4 | 5 | import { PermissionEnum } from "../../constants/permissions.scopes"; 6 | import usePermissions from "../../hooks/permissions.hooks"; 7 | 8 | interface PermissionProps { 9 | /** 10 | * Wrapped elements to be outputted given the required 11 | * permissions are met 12 | */ 13 | children: ReactNode; 14 | /** 15 | * Element to display in case of required permissions 16 | * haven't been met 17 | */ 18 | fallback?: ReactNode; 19 | /** 20 | * Toggles whether a user must have all of the required 21 | * permissions, or default is at least one of them 22 | */ 23 | hasAll?: boolean; 24 | /** 25 | * Where to direct the user, if required permissions 26 | * aren't met. If no path is supplied, the user will 27 | * not be redirected. 28 | */ 29 | redirectTo?: string; 30 | /** 31 | * An array of required permissions 32 | */ 33 | requiredPermissions: PermissionEnum[]; 34 | } 35 | 36 | /** 37 | * The permission component allows you to only display 38 | * content to users that have the required permissions. 39 | * It can be used in two ways. First one is wrapping the 40 | * content that is restricted, and using it as a HOC. The 41 | * second way, is to use the hasPermission function, to then 42 | * enable or disable buttons, for example. 43 | */ 44 | const Permission = ({ 45 | children, 46 | fallback, 47 | hasAll, 48 | redirectTo, 49 | requiredPermissions, 50 | }: PermissionProps) => { 51 | const { hasPermissions } = usePermissions(); 52 | const allowed = hasPermissions(requiredPermissions, hasAll); 53 | 54 | /** 55 | * In case there is more than one child element, we need 56 | * to wrap the whole thing in a fragment. 57 | */ 58 | if (allowed) return <>{children}; 59 | if (redirectTo) return ; 60 | if (fallback) return <>{fallback}; 61 | return null; 62 | }; 63 | 64 | export default memo(Permission); 65 | -------------------------------------------------------------------------------- /src/features/permissions/constants/permissions.keys.ts: -------------------------------------------------------------------------------- 1 | export const PERMISSIONS_KEY = "permissions"; 2 | -------------------------------------------------------------------------------- /src/features/permissions/constants/permissions.scopes.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * All permission scopes. 3 | * The scopes should be aligned with backend 4 | */ 5 | export enum PermissionEnum { 6 | DASHBOARD = "dashboard", 7 | USERS_CREATE = "users:create", 8 | USERS_READ = "users:read", 9 | USERS_WRITE = "users:write", 10 | USERS_DELETE = "users:delete", 11 | } 12 | -------------------------------------------------------------------------------- /src/features/permissions/hooks/permissions.hooks.ts: -------------------------------------------------------------------------------- 1 | import { useAppSelector } from "@app/redux/store"; 2 | 3 | import { PermissionEnum } from "../constants/permissions.scopes"; 4 | 5 | function usePermissions() { 6 | const { permissions } = useAppSelector(state => ({ 7 | permissions: state.permissions.permissions, 8 | })); 9 | 10 | const hasPermissions = ( 11 | requiredPermissions?: PermissionEnum[], 12 | hasAll?: boolean 13 | ) => { 14 | // if no required permissions are passed in, then it's allowed 15 | if (!requiredPermissions?.length) return true; 16 | 17 | let hasPermission = false; 18 | 19 | if (permissions) { 20 | if (hasAll) { 21 | hasPermission = requiredPermissions.every(permission => 22 | permissions.includes(permission) 23 | ); 24 | } else { 25 | hasPermission = requiredPermissions.some(permission => 26 | permissions.includes(permission) 27 | ); 28 | } 29 | return hasPermission; 30 | } 31 | // eslint-disable-next-line no-console 32 | console.warn( 33 | "No user permissions detected. Did you remember to use setPermissions() to set the permissions?" 34 | ); 35 | return hasPermission; 36 | }; 37 | 38 | return { hasPermissions }; 39 | } 40 | 41 | export default usePermissions; 42 | -------------------------------------------------------------------------------- /src/features/permissions/permissions.md: -------------------------------------------------------------------------------- 1 | # Permissions 2 | 3 | In many admin panels, there will be different user roles, such as admins, and different types of restricted users. Instead of wrapping components, actions, and screens in specific user roles, we are using permission scopes to guard different parts of the application. This allows the backend to modify existing user roles to limit or get access to parts without having to make any changes in the frontend. 4 | 5 | ## Set user's permissions 6 | 7 | When the user is fetched from the API, the user object should contain a list of permission scopes. These will be set with a function from the permission component: 8 | 9 | ```jsx 10 | import { setPermissions } from "features/permissions/permissions"; 11 | 12 | // Get current user 13 | const response = await axios.get("/me"); 14 | const user = response.data; 15 | 16 | // Set user's permissions using dispatch 17 | dispatch(setPermissions(user.permissions ?? [])); 18 | ``` 19 | 20 | The Permission component will now have access to the user's permission scopes and can show/hide components. 21 | 22 | ## Guarding a feature 23 | 24 | ### Component 25 | 26 | Let's say we have screen where only administrators should be able to create new users. To solve this, we can wrap the button in the Permission component and pass in the specific permission scope that is required to create new users, so if the current user doesn't meet the requirement then the button will not be shown. 27 | 28 | ```jsx 29 | import { Permission, PermissionEnum } from "features/permissions/permissions"; 30 | 31 | const MyApp = () => { 32 | return ( 33 | 34 | 35 | 36 | ); 37 | }; 38 | ``` 39 | 40 | ### Hook 41 | 42 | As an alternative, we can use the hook `usePermissions`, so we can disable the button, if that is the case: 43 | 44 | ```jsx 45 | import { 46 | usePermissions, 47 | PermissionEnum, 48 | } from "features/permissions/permissions"; 49 | 50 | const MyApp = () => { 51 | const allowUsersCreate = usePermissions([PermissionEnum.USERS_CREATE]); 52 | 53 | return ( 54 | 57 | ); 58 | }; 59 | ``` 60 | 61 | ## Guarding a screen 62 | 63 | Each screen can have an optional permissions prop, which holds a list of different permissions required to view that screen. 64 | 65 | ```jsx 66 | import { PermissionEnum } from "features/permissions/permissions"; 67 | 68 | const homeScreen: RouteItemDef = { 69 | path: "/", 70 | component: HomeScreen, 71 | permissions: [PermissionEnum.DASHBOARD], 72 | }; 73 | ``` 74 | 75 | ### Fallback component 76 | 77 | If the user doesn't have any of the required permissions, then a fallback component can be shown, for example that the screen has restricted access: 78 | 79 | ```jsx 80 | import { Permission } from "features/permissions/permissions"; 81 | 82 | Restricted Access} 85 | > 86 | 87 | ; 88 | ``` 89 | 90 | ### Redirect 91 | 92 | We can also redirect the user to a different route when lacking the required permissions, for example to a "restricted access" screen: 93 | 94 | ```jsx 95 | import { Permission } from "features/permissions/permissions"; 96 | 97 | 101 | 102 | ; 103 | ``` 104 | -------------------------------------------------------------------------------- /src/features/permissions/permissions.ts: -------------------------------------------------------------------------------- 1 | export { default as Permission } from "./components/Permission/Permission"; 2 | export { default as usePermissions } from "./hooks/permissions.hooks"; 3 | export * from "./redux/permissions.slice"; 4 | export * from "./constants/permissions.scopes"; 5 | -------------------------------------------------------------------------------- /src/features/permissions/redux/permissions.slice.ts: -------------------------------------------------------------------------------- 1 | import { createSlice, PayloadAction } from "@reduxjs/toolkit"; 2 | 3 | import { PermissionEnum } from "../permissions"; 4 | 5 | export const PERMISSIONS_FEATURE_KEY = "permissions"; 6 | 7 | interface SliceState { 8 | permissions: PermissionEnum[]; 9 | } 10 | 11 | const initialState: SliceState = { 12 | permissions: [], 13 | }; 14 | 15 | const permissionsSlice = createSlice({ 16 | name: PERMISSIONS_FEATURE_KEY, 17 | initialState, 18 | reducers: { 19 | setPermissions: (state, action: PayloadAction) => { 20 | state.permissions = action.payload ?? []; 21 | }, 22 | clearPermissions: state => { 23 | state.permissions = []; 24 | }, 25 | }, 26 | }); 27 | 28 | export const { setPermissions, clearPermissions } = permissionsSlice.actions; 29 | 30 | export const permissionsReducer = permissionsSlice.reducer; 31 | -------------------------------------------------------------------------------- /src/features/settings/api/users.api.ts: -------------------------------------------------------------------------------- 1 | import { AxiosResponse } from "axios"; 2 | 3 | import { api } from "@app/api/api"; 4 | 5 | import { 6 | GetUsersResponseDef, 7 | GetUsersParamDef, 8 | GetUserByIdResponseDef, 9 | } from "../types/user.types"; 10 | 11 | export const getUsers = ( 12 | params?: GetUsersParamDef 13 | ): Promise> => { 14 | return api.get("/users", { params }); 15 | }; 16 | 17 | export const getUserById = ( 18 | userId: number 19 | ): Promise> => { 20 | return api.get(`/users/${userId}`); 21 | }; 22 | -------------------------------------------------------------------------------- /src/features/settings/constants/settings.paths.ts: -------------------------------------------------------------------------------- 1 | export enum SettingsPathsEnum { 2 | SETTINGS = "/settings", 3 | PROJECTS = "/settings/projects", 4 | USERS = "/settings/users", 5 | } 6 | -------------------------------------------------------------------------------- /src/features/settings/helpers/users.helper.ts: -------------------------------------------------------------------------------- 1 | import _isFinite from "lodash/isFinite"; 2 | 3 | export const isValidUserId = (userId: string | number) => { 4 | return _isFinite(userId); 5 | }; 6 | -------------------------------------------------------------------------------- /src/features/settings/redux/users.slice.ts: -------------------------------------------------------------------------------- 1 | import { 2 | createSlice, 3 | isFulfilled, 4 | isPending, 5 | isRejected, 6 | PayloadAction, 7 | } from "@reduxjs/toolkit"; 8 | 9 | import { mapPagination } from "@app/helpers/table.helper"; 10 | import { createApiAsyncThunk } from "@app/redux/api.thunk"; 11 | import { ErrorStateDef, LoadingStateDef } from "@app/types/api.types"; 12 | import { TablePaginationDef } from "@app/types/pagination.types"; 13 | 14 | import * as userApi from "../api/users.api"; 15 | import { 16 | GetUserByIdResponseDef, 17 | GetUsersParamDef, 18 | GetUsersResponseDef, 19 | UserDef, 20 | } from "../types/user.types"; 21 | 22 | export const USERS_FEATURE_KEY = "users"; 23 | 24 | interface SliceState extends LoadingStateDef, ErrorStateDef { 25 | users: UserDef[]; 26 | user: UserDef | null; 27 | pagination: TablePaginationDef; 28 | } 29 | 30 | const initialState: SliceState = { 31 | users: [], 32 | user: null, 33 | pagination: { 34 | current: 1, 35 | pageSize: 20, 36 | total: 0, 37 | }, 38 | loading: false, 39 | error: null, 40 | }; 41 | 42 | export const getUsers = createApiAsyncThunk< 43 | GetUsersResponseDef, 44 | GetUsersParamDef 45 | >("users/getUsers", userApi.getUsers); 46 | 47 | export const getUserById = createApiAsyncThunk< 48 | GetUserByIdResponseDef, 49 | UserDef["id"] 50 | >("users/getUserById", userApi.getUserById); 51 | 52 | const usersSlice = createSlice({ 53 | name: USERS_FEATURE_KEY, 54 | initialState, 55 | reducers: { 56 | clearUser: state => { 57 | state.user = null; 58 | state.loading = false; 59 | }, 60 | }, 61 | extraReducers: builder => { 62 | /** GET USERS */ 63 | builder.addCase( 64 | getUsers.fulfilled, 65 | (state, action: PayloadAction) => { 66 | state.users = action.payload.data; 67 | state.pagination = mapPagination(action.payload); 68 | } 69 | ); 70 | /** GET USER */ 71 | builder.addCase( 72 | getUserById.fulfilled, 73 | (state, action: PayloadAction) => { 74 | state.user = action.payload.data; 75 | } 76 | ); 77 | builder.addMatcher(isPending, state => { 78 | state.loading = true; 79 | state.error = null; 80 | }); 81 | builder.addMatcher(isFulfilled, state => { 82 | state.loading = false; 83 | }); 84 | builder.addMatcher(isRejected, (state, action) => { 85 | state.loading = false; 86 | state.error = action.error.message; 87 | }); 88 | }, 89 | }); 90 | 91 | export const { clearUser } = usersSlice.actions; 92 | export const usersReducer = usersSlice.reducer; 93 | -------------------------------------------------------------------------------- /src/features/settings/routes/SettingsRoutes.tsx: -------------------------------------------------------------------------------- 1 | import { flatten } from "@app/helpers/route.helper"; 2 | import NestedRouteWrapper from "@app/routes/NestedRouteWrapper"; 3 | 4 | import { SETTINGS_ROUTES } from "./settings.routes"; 5 | 6 | const SettingsRoutes = () => { 7 | const routesWithComponents = flatten(SETTINGS_ROUTES); 8 | 9 | return ; 10 | }; 11 | 12 | export default SettingsRoutes; 13 | -------------------------------------------------------------------------------- /src/features/settings/routes/settings.routes.ts: -------------------------------------------------------------------------------- 1 | import { PermissionEnum } from "@app/features/permissions/permissions"; 2 | import { RouteItemDef } from "@app/types/route.types"; 3 | 4 | import { SettingsPathsEnum } from "../constants/settings.paths"; 5 | import ProjectsScreen from "../screens/ProjectsScreen/ProjectsScreen"; 6 | import UsersScreen from "../screens/UsersScreen/UsersScreen"; 7 | import SettingsRoutes from "./SettingsRoutes"; 8 | 9 | const SETTINGS_SCREEN: RouteItemDef = { 10 | id: "settings", 11 | path: SettingsPathsEnum.SETTINGS, 12 | navigationTitle: "settings.navigationTitle", 13 | component: SettingsRoutes, 14 | nestedRoutes: [ 15 | /** 16 | * A single screen 17 | */ 18 | { 19 | id: "projects", 20 | path: SettingsPathsEnum.PROJECTS, 21 | navigationTitle: "settingsProjects.navigationTitle", 22 | component: ProjectsScreen, 23 | }, 24 | /** 25 | * A group of screens 26 | * - group title 27 | * - nested routes 28 | */ 29 | { 30 | id: "admin-settings", 31 | groupTitle: "settings.groupUsersSettings", 32 | nestedRoutes: [ 33 | { 34 | id: "users", 35 | path: SettingsPathsEnum.USERS, 36 | navigationTitle: "settingsUsers.navigationTitle", 37 | component: UsersScreen, 38 | permissions: [PermissionEnum.USERS_READ], 39 | }, 40 | ], 41 | }, 42 | ], 43 | }; 44 | 45 | export const SETTINGS_ROUTES = [SETTINGS_SCREEN]; 46 | -------------------------------------------------------------------------------- /src/features/settings/screens/ProjectsScreen/ProjectsScreen.tsx: -------------------------------------------------------------------------------- 1 | import { useTranslation } from "react-i18next"; 2 | 3 | import ContentLayout from "@app/components/layouts/ContentLayout/ContentLayout"; 4 | 5 | const ProjectsScreen = () => { 6 | const { t } = useTranslation(); 7 | return ( 8 | 9 |

{t("settingsProjects.text")}

10 |
11 | ); 12 | }; 13 | 14 | export default ProjectsScreen; 15 | -------------------------------------------------------------------------------- /src/features/settings/screens/UsersScreen/UsersScreen.tsx: -------------------------------------------------------------------------------- 1 | import { useCallback, useEffect } from "react"; 2 | 3 | import { useTranslation } from "react-i18next"; 4 | 5 | import Button from "@app/components/atoms/Button/Button"; 6 | import ContentLayout from "@app/components/layouts/ContentLayout/ContentLayout"; 7 | import * as modalAction from "@app/helpers/modal.helper"; 8 | import useSearchParams from "@app/hooks/useSearchParams"; 9 | import { useAppDispatch } from "@app/redux/store"; 10 | 11 | import { getUsers } from "../../redux/users.slice"; 12 | import { UserDef } from "../../types/user.types"; 13 | import UserRoleModal, { 14 | ENTRY_TYPE_USER_ROLE, 15 | } from "./components/UserRoleModal/UserRoleModal"; 16 | import UsersFilter, { 17 | UsersFilterProps, 18 | } from "./components/UsersFilter/UsersFilter"; 19 | import UsersModal from "./components/UsersModal/UsersModal"; 20 | import UsersTable, { 21 | UsersActionMenuEnum, 22 | } from "./components/UsersTable/UsersTable"; 23 | 24 | const UsersScreen = () => { 25 | const { t } = useTranslation(); 26 | const { search, updateSearchParams } = useSearchParams(); 27 | const dispatch = useAppDispatch(); 28 | 29 | const fetchData = useCallback(() => { 30 | dispatch(getUsers({ page: search?.page, per_page: search?.pageSize })); 31 | }, [dispatch, search?.page, search?.pageSize]); 32 | 33 | useEffect(() => { 34 | fetchData(); 35 | }, [fetchData]); 36 | 37 | const handleView = (user: UserDef) => { 38 | // eslint-disable-next-line no-console 39 | console.log("View user", user); 40 | }; 41 | 42 | const handleEdit = (user: UserDef) => 43 | updateSearchParams(modalAction.edit({ id: user.id.toString() })); 44 | 45 | const handleAdd = () => updateSearchParams(modalAction.add()); 46 | 47 | const handleUserRole = (user: UserDef) => 48 | updateSearchParams( 49 | modalAction.edit({ 50 | id: user.id.toString(), 51 | entryType: ENTRY_TYPE_USER_ROLE, 52 | }) 53 | ); 54 | 55 | const handleCloseModal = () => updateSearchParams(modalAction.close()); 56 | 57 | const handleSubmittedModal = () => { 58 | fetchData(); 59 | handleCloseModal(); 60 | }; 61 | 62 | const handleDelete = (user: UserDef) => { 63 | // TODO: API to delete user 64 | // eslint-disable-next-line no-console 65 | console.log("delete user", user); 66 | }; 67 | 68 | const handleDuplicate = (user: UserDef) => { 69 | // TODO: API to duplicate user 70 | // eslint-disable-next-line no-console 71 | console.log("duplicate user", user); 72 | }; 73 | 74 | const handleActionMenu = (key: string, user: UserDef) => { 75 | if (key === UsersActionMenuEnum.DUPLICATE) { 76 | handleDuplicate(user); 77 | } 78 | }; 79 | 80 | return ( 81 | } 84 | > 85 | [ 91 | , 99 | ]} 100 | onActionMenu={handleActionMenu} 101 | onAdd={handleAdd} 102 | /> 103 | 104 | {/* Modal to Create / Edit User */} 105 | 109 | 110 | {/* Modal to Update User Role */} 111 | 115 | 116 | ); 117 | }; 118 | 119 | export default UsersScreen; 120 | -------------------------------------------------------------------------------- /src/features/settings/screens/UsersScreen/components/UserRoleModal/UserRoleModal.tsx: -------------------------------------------------------------------------------- 1 | import { memo, useEffect } from "react"; 2 | 3 | import { Col, Input } from "antd"; 4 | import _toNumber from "lodash/toNumber"; 5 | import { useTranslation } from "react-i18next/"; 6 | 7 | import { Item, useForm } from "@app/components/atoms/Form/Form"; 8 | import FormModal from "@app/components/atoms/FormModal/FormModal"; 9 | import { ItemModalEnum } from "@app/constants/route.constants"; 10 | import { isValidUserId, UserDef } from "@app/features/settings/settings"; 11 | import useShowModal from "@app/hooks/useShowModal"; 12 | 13 | export const ENTRY_TYPE_USER_ROLE = "user-role"; 14 | 15 | interface UserRoleModalProps { 16 | onClose: () => void; 17 | onSubmitted: () => void; 18 | } 19 | 20 | const UserRoleModal = memo(({ onClose, onSubmitted }: UserRoleModalProps) => { 21 | const { t } = useTranslation(); 22 | const { showModal, action, entryId } = useShowModal({ 23 | customEntryType: ENTRY_TYPE_USER_ROLE, 24 | }); 25 | const [form] = useForm(); 26 | 27 | // Constants 28 | const userId = _toNumber(entryId); 29 | const editMode = action === ItemModalEnum.EDIT; 30 | 31 | // TODO: Get User from API 32 | useEffect(() => { 33 | if (showModal) { 34 | if (editMode && isValidUserId(userId)) { 35 | // eslint-disable-next-line no-console 36 | console.log("user id", userId); 37 | } 38 | } 39 | }, [userId, editMode, showModal]); 40 | 41 | const handleClose = () => { 42 | onClose(); 43 | }; 44 | 45 | const handleFinish = (values: Partial) => { 46 | // TODO: Update user role 47 | // eslint-disable-next-line no-console 48 | console.log(values); 49 | onSubmitted(); 50 | }; 51 | 52 | return ( 53 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | ); 67 | }); 68 | 69 | export default UserRoleModal; 70 | -------------------------------------------------------------------------------- /src/features/settings/screens/UsersScreen/components/UsersFilter/UsersFilter.tsx: -------------------------------------------------------------------------------- 1 | import { memo } from "react"; 2 | 3 | import { Select, Checkbox, DatePicker } from "antd"; 4 | import moment from "moment"; 5 | import { useTranslation } from "react-i18next"; 6 | 7 | import DebouncedInput from "@app/components/atoms/DebouncedInput/DebouncedInput"; 8 | import PageFilter, { 9 | FilterItem, 10 | FilterItemCheckbox, 11 | } from "@app/components/molecules/PageFilter/PageFilter"; 12 | import { useAppSelector } from "@app/redux/store"; 13 | 14 | const { Option } = Select; 15 | const { RangePicker } = DatePicker; 16 | 17 | export interface UsersFilterProps { 18 | dates?: [moment.Moment, moment.Moment]; 19 | name?: string; 20 | hasEmail?: boolean; 21 | } 22 | 23 | const UsersFilter = () => { 24 | const { t } = useTranslation(); 25 | const { users, loading } = useAppSelector(state => ({ 26 | users: state.users.users, 27 | loading: state.users.loading, 28 | })); 29 | 30 | return ( 31 | 32 | showSubmitButton 33 | showResetButton 34 | parseDates 35 | parseNumbers={["name"]} 36 | > 37 | 38 | {/* Remove `showSubmitButton` to see the difference */} 39 | 40 | 41 | 42 | 43 | 44 | 45 | 56 | 57 | 58 | {t("settingsUsers.filterHasEmailLabel")} 59 | 60 | 61 | ); 62 | }; 63 | 64 | export default memo(UsersFilter); 65 | -------------------------------------------------------------------------------- /src/features/settings/screens/UsersScreen/components/UsersModal/UsersModal.tsx: -------------------------------------------------------------------------------- 1 | import { memo, useEffect } from "react"; 2 | 3 | import { Col, Input } from "antd"; 4 | import _toNumber from "lodash/toNumber"; 5 | import { useTranslation } from "react-i18next/"; 6 | 7 | import { Item, useForm } from "@app/components/atoms/Form/Form"; 8 | import FormModal from "@app/components/atoms/FormModal/FormModal"; 9 | import { ItemModalEnum } from "@app/constants/route.constants"; 10 | import { 11 | clearUser, 12 | getUserById, 13 | isValidUserId, 14 | } from "@app/features/settings/settings"; 15 | import useShowModal from "@app/hooks/useShowModal"; 16 | import { useAppDispatch, useAppSelector } from "@app/redux/store"; 17 | 18 | import { UserDef } from "../../../../types/user.types"; 19 | 20 | interface UsersModalProps { 21 | onClose: () => void; 22 | onSubmitted: () => void; 23 | } 24 | 25 | const UsersModal = memo(({ onClose, onSubmitted }: UsersModalProps) => { 26 | // Hooks 27 | const { t } = useTranslation(); 28 | const { showModal, action, entryId } = useShowModal(); 29 | const [form] = useForm(); 30 | const dispatch = useAppDispatch(); 31 | const { user, loading } = useAppSelector(state => ({ 32 | user: state.users.user, 33 | loading: state.users.loading, 34 | })); 35 | 36 | // Constants 37 | const userId = _toNumber(entryId); 38 | const editMode = action === ItemModalEnum.EDIT; 39 | 40 | useEffect(() => { 41 | if (showModal) { 42 | if (editMode && isValidUserId(userId)) { 43 | dispatch(getUserById(userId)); 44 | } else { 45 | dispatch(clearUser()); 46 | } 47 | } 48 | }, [showModal, editMode, userId, dispatch]); 49 | 50 | const handleClose = () => onClose(); 51 | 52 | const handleFinish = (values: Partial) => { 53 | // TODO: Create / Update user 54 | // eslint-disable-next-line no-console 55 | console.log(values); 56 | onSubmitted(); 57 | }; 58 | 59 | const getFormValues = () => user; 60 | 61 | return ( 62 | 76 | 77 | 78 | 82 | 83 | 84 | 85 | 86 | 90 | 91 | 92 | 93 | ); 94 | }); 95 | 96 | export default UsersModal; 97 | -------------------------------------------------------------------------------- /src/features/settings/screens/UsersScreen/components/UsersTable/UsersTable.tsx: -------------------------------------------------------------------------------- 1 | import { useMemo } from "react"; 2 | 3 | import { Table } from "antd"; 4 | import { useTranslation } from "react-i18next"; 5 | 6 | import Button from "@app/components/atoms/Button/Button"; 7 | import TableView, { 8 | ActionMenuDef, 9 | TableViewProps, 10 | } from "@app/components/molecules/TableView/TableView"; 11 | import useSearchParams from "@app/hooks/useSearchParams"; 12 | import { useAppSelector } from "@app/redux/store"; 13 | 14 | import { UserDef } from "../../../../types/user.types"; 15 | 16 | interface UsersTableProps extends TableViewProps { 17 | onAdd?: () => void; 18 | } 19 | 20 | export enum UsersActionMenuEnum { 21 | DUPLICATE = "duplicate", 22 | } 23 | 24 | const UsersTable = ({ onAdd, ...props }: UsersTableProps) => { 25 | const { t } = useTranslation(); 26 | const { users, loading, pagination } = useAppSelector(state => ({ 27 | users: state.users.users, 28 | loading: state.users.loading, 29 | pagination: state.users.pagination, 30 | })); 31 | const { getOrderByDirection } = useSearchParams(); 32 | 33 | const menu: ActionMenuDef = useMemo( 34 | () => [ 35 | { 36 | key: UsersActionMenuEnum.DUPLICATE, 37 | label: t("settingsUsers.menuDuplicate"), 38 | }, 39 | ], 40 | [t] 41 | ); 42 | 43 | return ( 44 | ( 55 | 58 | )} 59 | {...props} 60 | > 61 | firstName} 66 | sorter 67 | sortOrder={getOrderByDirection("name")} 68 | /> 69 | lastName} 74 | sorter 75 | sortOrder={getOrderByDirection("last_name")} 76 | /> 77 | 78 | ); 79 | }; 80 | 81 | export default UsersTable; 82 | -------------------------------------------------------------------------------- /src/features/settings/settings.ts: -------------------------------------------------------------------------------- 1 | // CONSTANTS 2 | export * from "./constants/settings.paths"; 3 | 4 | // HELPERS 5 | export * from "./helpers/users.helper"; 6 | 7 | // REDUX 8 | export * from "./redux/users.slice"; 9 | 10 | // ROUTES 11 | export * from "./routes/settings.routes"; 12 | 13 | // TYPES 14 | export * from "./types/user.types"; 15 | -------------------------------------------------------------------------------- /src/features/settings/types/user.types.ts: -------------------------------------------------------------------------------- 1 | import { ResponsePaginationDef } from "@app/types/pagination.types"; 2 | 3 | /* eslint-disable camelcase */ 4 | export type UserDef = { 5 | id: number; 6 | first_name: string; 7 | last_name: string; 8 | }; 9 | 10 | export type GetUsersResponseDef = { 11 | data: UserDef[]; 12 | } & ResponsePaginationDef; 13 | 14 | export type GetUsersParamDef = { 15 | page?: ResponsePaginationDef["page"]; 16 | per_page?: ResponsePaginationDef["per_page"]; 17 | }; 18 | 19 | export type GetUserByIdResponseDef = { 20 | data: UserDef; 21 | }; 22 | -------------------------------------------------------------------------------- /src/helpers/file.helper.ts: -------------------------------------------------------------------------------- 1 | import { delay } from "./util.helper"; 2 | 3 | /** 4 | * Function to download a file. 5 | * 6 | * Adds `a` html element with link to file, 7 | * and triggers a click to download the file, 8 | * and finally removes the element from the DOM 9 | * @param url Path to the file 10 | * @param filename Name of the file 11 | */ 12 | export const download = async (url: string, filename: string) => { 13 | const link = document.createElement("a"); 14 | link.download = filename; 15 | link.href = url; 16 | link.style.display = "none"; 17 | document.body.appendChild(link); 18 | link.click(); 19 | 20 | // Chrome requires the timeout 21 | await delay(100); 22 | document.body.removeChild(link); 23 | }; 24 | 25 | /** 26 | * Function to initiate download of multiple files 27 | * @param files Array of path to files 28 | */ 29 | export const downloadMultipleFiles = (files: string[]) => { 30 | files.forEach(async (file, index) => { 31 | const filename = file.substring(file.lastIndexOf("/") + 1); 32 | await delay(index * 1000); 33 | download(file, filename); 34 | }); 35 | }; 36 | 37 | /** 38 | * Convert blob file to csv file, and initiates a download of the file 39 | * @param blobData Data from api 40 | * @param fileName Name of file 41 | */ 42 | export const processBlobToCSV = (blobData: string, fileName: string) => { 43 | const contentType = "text/csv"; 44 | const blob = new Blob([blobData], { type: contentType }); 45 | const url = window.URL.createObjectURL(blob); 46 | const filename = `${fileName}.csv`; 47 | download(url, filename); 48 | }; 49 | -------------------------------------------------------------------------------- /src/helpers/modal.helper.ts: -------------------------------------------------------------------------------- 1 | import { ItemModalEnum } from "@app/constants/route.constants"; 2 | import { SearchParamDef } from "@app/hooks/useSearchParams"; 3 | 4 | interface ModalAddDef { 5 | entryType?: string; 6 | } 7 | 8 | // Open a modal with an optional entry type 9 | export const add = (config?: ModalAddDef): SearchParamDef => { 10 | const { entryType } = config ?? {}; 11 | return { 12 | action: ItemModalEnum.ADD, 13 | entryId: undefined, 14 | entryType, 15 | }; 16 | }; 17 | 18 | interface ModalEditDef { 19 | id: string; 20 | entryType?: string; 21 | } 22 | 23 | // Edit a modal with an entry id and an optional entry type 24 | export const edit = (config: ModalEditDef): SearchParamDef => { 25 | const { id, entryType } = config ?? {}; 26 | return { 27 | action: ItemModalEnum.EDIT, 28 | entryId: id, 29 | entryType, 30 | }; 31 | }; 32 | 33 | // Close the modal 34 | export const close = (): SearchParamDef => { 35 | return { 36 | action: undefined, 37 | entryId: undefined, 38 | entryType: undefined, 39 | }; 40 | }; 41 | -------------------------------------------------------------------------------- /src/helpers/route.helper.ts: -------------------------------------------------------------------------------- 1 | import { RouteGroupDef, RouteItemDef } from "@app/types/route.types"; 2 | 3 | export function flatten(items: Array) { 4 | const flat: RouteItemDef[] = []; 5 | 6 | items.forEach(item => { 7 | if (Array.isArray(item.nestedRoutes)) { 8 | flat.push(...flatten(item.nestedRoutes)); 9 | } else { 10 | flat.push(item as RouteItemDef); 11 | } 12 | }); 13 | 14 | return flat; 15 | } 16 | -------------------------------------------------------------------------------- /src/helpers/table.helper.ts: -------------------------------------------------------------------------------- 1 | import { SortOrder } from "antd/lib/table/interface"; 2 | 3 | import { 4 | TablePaginationDef, 5 | ResponsePaginationDef, 6 | } from "@app/types/pagination.types"; 7 | import { OrderByDef } from "@app/types/table.types"; 8 | 9 | /** The divider used in the url for the orderBy search param */ 10 | const ORDER_BY_DIVIDER = "_"; 11 | 12 | /** 13 | * Takes the raw orderBy string from the search query 14 | * and splits it and extracts key and direction 15 | */ 16 | export const getOrderByExtraction = (orderBy: string): OrderByDef => { 17 | const orderBySplit = orderBy.split(ORDER_BY_DIVIDER); 18 | const orderByKey = orderBySplit?.[0] || ""; 19 | const orderByDirection = (orderBySplit?.[1] || undefined) as 20 | | SortOrder 21 | | undefined; 22 | 23 | const orderByExtraction: OrderByDef = { 24 | key: orderByKey, 25 | direction: orderByDirection, 26 | }; 27 | 28 | return orderByExtraction; 29 | }; 30 | 31 | /** 32 | * Takes the orderBy key and direction 33 | * and combines them to a single string for the search query 34 | */ 35 | export const getOrderBy = (orderByKey: string, orderByDirection: SortOrder) => { 36 | return `${orderByKey}${ORDER_BY_DIVIDER}${orderByDirection}`; 37 | }; 38 | 39 | /** 40 | * Map pagination from API to Ant Design pagination 41 | */ 42 | export const mapPagination = ( 43 | pagination: ResponsePaginationDef 44 | ): TablePaginationDef => { 45 | return { 46 | current: pagination?.page ?? undefined, 47 | pageSize: pagination?.per_page ?? undefined, 48 | total: pagination?.total ?? undefined, 49 | }; 50 | }; 51 | -------------------------------------------------------------------------------- /src/helpers/util.helper.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Check if a string looks like an external URL 3 | */ 4 | export const isURL = (str: string) => { 5 | return /http|www/.test(str); 6 | }; 7 | 8 | /** 9 | * A promise to delay an async function 10 | * @param ms how many milliseconds to wait 11 | */ 12 | export const delay = (ms: number) => 13 | new Promise(resolve => setTimeout(resolve, ms)); 14 | 15 | export const getInitials = (name: string, maxChar: number) => { 16 | return name 17 | .split(/\s/) 18 | .map(word => word[0]) 19 | .join("") 20 | .substr(0, maxChar) 21 | .toUpperCase(); 22 | }; 23 | 24 | /** 25 | * Scroll to top of screen smoothly, 26 | * or fallback to instant scroll to top 27 | */ 28 | export const scrollToTop = () => { 29 | try { 30 | // trying to use new API - https://developer.mozilla.org/en-US/docs/Web/API/Window/scrollTo 31 | window.scrollTo({ 32 | top: 0, 33 | left: 0, 34 | behavior: "smooth", 35 | }); 36 | } catch (error) { 37 | // fallback for older browsers 38 | window.scrollTo(0, 0); 39 | document.body.scrollTop = 0; // For Safari 40 | document.documentElement.scrollTop = 0; 41 | } 42 | }; 43 | -------------------------------------------------------------------------------- /src/hooks/useSearchParams.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/ban-types */ 2 | import { useCallback, useEffect, useState } from "react"; 3 | 4 | import _toInteger from "lodash/toInteger"; 5 | import qs, { ParseOptions, StringifyOptions } from "query-string"; 6 | import { useLocation, useHistory } from "react-router-dom"; 7 | 8 | import { ItemModalEnum } from "@app/constants/route.constants"; 9 | import { getOrderByExtraction } from "@app/helpers/table.helper"; 10 | import { OrderByDef } from "@app/types/table.types"; 11 | 12 | const ARRAY_FORMAT: StringifyOptions["arrayFormat"] = "bracket"; 13 | const QUERY_OPTIONS: StringifyOptions = { 14 | arrayFormat: ARRAY_FORMAT, 15 | skipEmptyString: true, 16 | }; 17 | const PARSE_OPTIONS: ParseOptions = { 18 | arrayFormat: ARRAY_FORMAT, 19 | parseBooleans: true, 20 | }; 21 | 22 | /** 23 | * The reason for the generic type being wrapped in Partial, 24 | * is that we want to be able to update the search params one 25 | * parameter at a time. As we have no other way of forcing generics 26 | * passed to the hook to always have optional properties, we can wrap 27 | * it in Partial, and declare that we do not "care" if a property 28 | * in the generic type passed in contains a mandatory property. 29 | */ 30 | export type SearchParamDef = Partial & { 31 | action?: ItemModalEnum; 32 | entryId?: string; 33 | entryType?: string; 34 | orderBy?: string; 35 | orderByExtracted?: OrderByDef; 36 | page?: number; 37 | pageSize?: number; 38 | }; 39 | 40 | const useSearchParams = () => { 41 | const location = useLocation(); 42 | const history = useHistory(); 43 | 44 | const getCurrentSearch = useCallback(() => { 45 | const currentSearch = qs.parse( 46 | location.search, 47 | PARSE_OPTIONS 48 | ) as SearchParamDef as SearchParamDef; 49 | currentSearch.orderByExtracted = getOrderByExtraction( 50 | (currentSearch.orderBy as string) || "" 51 | ); 52 | currentSearch.page = _toInteger(currentSearch.page) || 1; 53 | currentSearch.pageSize = _toInteger(currentSearch.pageSize) || undefined; 54 | return currentSearch; 55 | }, [location.search]); 56 | 57 | const [search, setSearch] = useState>(getCurrentSearch()); 58 | 59 | useEffect(() => { 60 | setSearch(getCurrentSearch()); 61 | }, [getCurrentSearch]); 62 | 63 | /** 64 | * get direction if order by key is present in search params 65 | */ 66 | const getOrderByDirection = useCallback( 67 | (orderByKey: string) => { 68 | return ( 69 | (search?.orderByExtracted?.key === orderByKey && 70 | search?.orderByExtracted?.direction) || 71 | undefined 72 | ); 73 | }, 74 | [search] 75 | ); 76 | 77 | /** 78 | * Clear search params with new params 79 | */ 80 | const setSearchParams = useCallback( 81 | (filters: SearchParamDef) => { 82 | history.push({ 83 | pathname: location.pathname, 84 | search: qs.stringify(filters, QUERY_OPTIONS), 85 | }); 86 | }, 87 | [history, location.pathname] 88 | ); 89 | 90 | /** 91 | * Update existing search params with new params 92 | */ 93 | const updateSearchParams = useCallback( 94 | (filters: SearchParamDef) => { 95 | // Keep current search params 96 | const currentSearch = qs.parse(location.search, PARSE_OPTIONS); 97 | 98 | history.push({ 99 | pathname: location.pathname, 100 | search: qs.stringify( 101 | { 102 | ...currentSearch, 103 | ...filters, 104 | }, 105 | QUERY_OPTIONS 106 | ), 107 | }); 108 | }, 109 | [history, location.pathname, location.search] 110 | ); 111 | 112 | return { 113 | search, 114 | setSearchParams, 115 | updateSearchParams, 116 | getOrderByDirection, 117 | }; 118 | }; 119 | 120 | export default useSearchParams; 121 | -------------------------------------------------------------------------------- /src/hooks/useShowModal.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from "react"; 2 | 3 | import { ItemModalEnum } from "@app/constants/route.constants"; 4 | 5 | import useSearchParams from "./useSearchParams"; 6 | 7 | interface ShowModalConfig { 8 | customEntryType?: string; 9 | } 10 | 11 | function useShowModal(config?: ShowModalConfig) { 12 | const { customEntryType } = config ?? {}; 13 | const [showModal, setShowModal] = useState(false); 14 | 15 | const { search } = useSearchParams(); 16 | const action = search?.action; 17 | const entryId = search?.entryId; 18 | const entryType = search?.entryType; 19 | 20 | useEffect(() => { 21 | setShowModal( 22 | (action === ItemModalEnum.EDIT || action === ItemModalEnum.ADD) && 23 | entryType === customEntryType 24 | ); 25 | }, [action, customEntryType, entryType]); 26 | 27 | return { showModal, action, entryId, entryType }; 28 | } 29 | 30 | export default useShowModal; 31 | -------------------------------------------------------------------------------- /src/hooks/useUnsavedPrompt.tsx: -------------------------------------------------------------------------------- 1 | import { useCallback, useEffect, useState } from "react"; 2 | 3 | import { FormInstance } from "antd/lib/form/Form"; 4 | import { useTranslation } from "react-i18next"; 5 | import { useHistory } from "react-router-dom"; 6 | 7 | import { modalConfirm } from "@app/components/atoms/ModalConfirm/ModalConfirm"; 8 | 9 | interface UnsavedPromptProps { 10 | form?: FormInstance; 11 | visible?: boolean; 12 | title?: string; 13 | text?: string; 14 | } 15 | 16 | /** 17 | * A hook to prompt the user when leaving an unsaved form 18 | */ 19 | const useUnsavedPrompt = ({ 20 | form, 21 | title, 22 | text, 23 | visible, 24 | }: UnsavedPromptProps) => { 25 | const [isSubmitting, setIsSubmitting] = useState(false); 26 | const history = useHistory(); 27 | const { t } = useTranslation(); 28 | 29 | /** 30 | * This will prompt a native browser dialog to confirm 31 | * if the user wants to leave this page without saving the changes 32 | * It will prompt the confirm dialog when the user: 33 | * - Clicks 'reload' in browser 34 | * - Clicks 'back' in browser 35 | * - Enters a url in browser 36 | * - Closes the browser/tab 37 | */ 38 | const prompt = useCallback( 39 | (event: BeforeUnloadEvent) => { 40 | if (visible && form?.isFieldsTouched()) { 41 | const e = event || window.event; 42 | e.preventDefault(); 43 | if (e) { 44 | e.returnValue = ""; 45 | } 46 | return ""; 47 | } 48 | return undefined; 49 | }, 50 | [form, visible] 51 | ); 52 | 53 | useEffect(() => { 54 | window.addEventListener("beforeunload", prompt); 55 | return () => { 56 | window.removeEventListener("beforeunload", prompt); 57 | }; 58 | }, [prompt]); 59 | 60 | /** 61 | * Runs when there is a history change, 62 | * and shows a modal confirmation to discard form changes if form is touched. 63 | */ 64 | useEffect(() => { 65 | if (visible) { 66 | const unblock = history.block(tx => { 67 | if (form?.isFieldsTouched() && !isSubmitting) { 68 | modalConfirm(t, { 69 | title: title ?? t("default.unsavedChangesTitle"), 70 | content: text ?? t("default.unsavedChangesText"), 71 | cancelText: t("default.unsavedChangesCancelTitle"), 72 | okText: t("default.unsavedChangesConfirmTitle"), 73 | onOk: () => { 74 | unblock(); 75 | history.push(tx.pathname); 76 | }, 77 | }); 78 | 79 | return false; 80 | } 81 | return unblock(); 82 | }); 83 | return () => { 84 | unblock(); 85 | }; 86 | } 87 | return undefined; // return if not visible 88 | }, [visible, form, history, history.location, isSubmitting, t, text, title]); 89 | 90 | return { setIsSubmitting }; 91 | }; 92 | 93 | export default useUnsavedPrompt; 94 | -------------------------------------------------------------------------------- /src/index.scss: -------------------------------------------------------------------------------- 1 | @import "styles/colors"; 2 | 3 | body { 4 | background-color: $bg-gray; 5 | -webkit-font-smoothing: antialiased; 6 | -moz-osx-font-smoothing: grayscale; 7 | } 8 | 9 | /* Force ant input number to take up all width when used inside col */ 10 | .ant-col { 11 | .ant-picker, 12 | .ant-input-number { 13 | width: 100%; 14 | } 15 | } 16 | 17 | // Fix for blurry images on retina https://stackoverflow.com/questions/13347947/css-filter-on-retina-display-fuzzy-images 18 | img { 19 | -webkit-transform: translateZ(0); 20 | -webkit-backface-visibility: hidden; 21 | } 22 | -------------------------------------------------------------------------------- /src/index.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable global-require */ 2 | /* eslint-disable @typescript-eslint/no-var-requires */ 3 | import React from "react"; 4 | 5 | import ReactDOM from "react-dom"; 6 | import { Provider } from "react-redux"; 7 | 8 | import store from "@app/redux/store"; 9 | 10 | import "@app/features/localization/localization"; 11 | import reportWebVitals from "./reportWebVitals"; 12 | import "./index.scss"; 13 | 14 | const render = () => { 15 | const App = require("./App").default; 16 | 17 | ReactDOM.render( 18 | 19 | 20 | 21 | 22 | , 23 | document.getElementById("root") 24 | ); 25 | }; 26 | 27 | render(); 28 | 29 | if (process.env.NODE_ENV === "development" && module.hot) { 30 | module.hot.accept("./App", render); 31 | } 32 | 33 | // If you want to start measuring performance in your app, pass a function 34 | // to log results (for example: reportWebVitals(console.log)) 35 | // or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals 36 | reportWebVitals(); 37 | -------------------------------------------------------------------------------- /src/react-app-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /src/redux/api.thunk.ts: -------------------------------------------------------------------------------- 1 | import { createAsyncThunk } from "@reduxjs/toolkit"; 2 | import { AxiosError, AxiosPromise } from "axios"; 3 | 4 | export type ErrorType = AxiosError; 5 | 6 | export const errorHandler = (error: ErrorType) => { 7 | const { request, response } = error; 8 | if (response) { 9 | // process error to based on type 10 | const message = response.data; 11 | return message; 12 | } 13 | if (request) { 14 | // request sent but no response received 15 | return "network.noInternet"; 16 | } 17 | // Something happened in setting up the request that triggered an Error 18 | return "network.unknown"; 19 | }; 20 | 21 | export function createApiAsyncThunk( 22 | key: string, 23 | action: (arg: Arg) => AxiosPromise 24 | ) { 25 | return createAsyncThunk( 26 | key, 27 | async (arg, { rejectWithValue }) => { 28 | try { 29 | const { data } = await action(arg); 30 | 31 | return data; 32 | } catch (error) { 33 | return rejectWithValue(errorHandler(error)); 34 | } 35 | } 36 | ); 37 | } 38 | -------------------------------------------------------------------------------- /src/redux/root-reducer.ts: -------------------------------------------------------------------------------- 1 | import { combineReducers } from "@reduxjs/toolkit"; 2 | 3 | import { authReducer, AUTH_FEATURE_KEY } from "@app/features/auth/auth"; 4 | import { 5 | permissionsReducer, 6 | PERMISSIONS_FEATURE_KEY, 7 | } from "@app/features/permissions/permissions"; 8 | import { 9 | usersReducer, 10 | USERS_FEATURE_KEY, 11 | } from "@app/features/settings/settings"; 12 | 13 | const rootReducer = combineReducers({ 14 | [USERS_FEATURE_KEY]: usersReducer, 15 | [PERMISSIONS_FEATURE_KEY]: permissionsReducer, 16 | [AUTH_FEATURE_KEY]: authReducer, 17 | }); 18 | 19 | export type RootState = ReturnType; 20 | 21 | export default rootReducer; 22 | -------------------------------------------------------------------------------- /src/redux/store.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-var-requires */ 2 | /* eslint-disable global-require */ 3 | import { Action, configureStore, ThunkAction } from "@reduxjs/toolkit"; 4 | import { TypedUseSelectorHook, useDispatch, useSelector } from "react-redux"; 5 | 6 | import rootReducer, { RootState } from "./root-reducer"; 7 | 8 | const store = configureStore({ 9 | reducer: rootReducer, 10 | devTools: process.env.NODE_ENV !== "production", 11 | }); 12 | 13 | if (process.env.NODE_ENV === "development" && module.hot) { 14 | module.hot.accept("./root-reducer", () => { 15 | const newRootReducer = require("./root-reducer").default; 16 | store.replaceReducer(newRootReducer); 17 | }); 18 | } 19 | 20 | export type AppDispatch = typeof store.dispatch; 21 | export type AppThunk = ThunkAction>; 22 | export const useAppDispatch = () => useDispatch(); 23 | export const useAppSelector: TypedUseSelectorHook = useSelector; 24 | 25 | export default store; 26 | -------------------------------------------------------------------------------- /src/reportWebVitals.ts: -------------------------------------------------------------------------------- 1 | import { ReportHandler } from "web-vitals"; 2 | 3 | const reportWebVitals = (onPerfEntry?: ReportHandler) => { 4 | if (onPerfEntry && onPerfEntry instanceof Function) { 5 | import("web-vitals").then(({ getCLS, getFID, getFCP, getLCP, getTTFB }) => { 6 | getCLS(onPerfEntry); 7 | getFID(onPerfEntry); 8 | getFCP(onPerfEntry); 9 | getLCP(onPerfEntry); 10 | getTTFB(onPerfEntry); 11 | }); 12 | } 13 | }; 14 | 15 | export default reportWebVitals; 16 | -------------------------------------------------------------------------------- /src/routes/NestedRouteWrapper.tsx: -------------------------------------------------------------------------------- 1 | import { Switch, Route } from "react-router-dom"; 2 | 3 | import { Permission } from "@app/features/permissions/permissions"; 4 | import RestrictAccess from "@app/routes/components/RestrictAccess/RestrictAccess"; 5 | import { RouteComponentDef, RouteItemDef } from "@app/types/route.types"; 6 | 7 | import NotFound from "./components/NotFound/NotFound"; 8 | 9 | interface NestedRouteWrapperProps { 10 | routesWithComponents: RouteItemDef[]; 11 | } 12 | 13 | const NestedRouteWrapper = ({ 14 | routesWithComponents, 15 | }: NestedRouteWrapperProps) => { 16 | return ( 17 | 18 | {routesWithComponents.map(route => ( 19 | { 24 | const Component = route.component as RouteComponentDef; 25 | return ( 26 | (route.permissions && ( 27 | } 29 | requiredPermissions={route.permissions} 30 | > 31 | 32 | 33 | )) || 34 | ); 35 | }} 36 | /> 37 | ))} 38 | } /> 39 | 40 | ); 41 | }; 42 | 43 | export default NestedRouteWrapper; 44 | -------------------------------------------------------------------------------- /src/routes/Routes.tsx: -------------------------------------------------------------------------------- 1 | import { ElementType, memo, useMemo, useRef } from "react"; 2 | 3 | import { Switch, Route, Redirect, useLocation } from "react-router-dom"; 4 | 5 | import BlankLayout from "@app/components/layouts/BlankLayout/BlankLayout"; 6 | import HeaderLayout from "@app/components/layouts/HeaderLayout/HeaderLayout"; 7 | import SidebarLayout from "@app/components/layouts/SidebarLayout/SidebarLayout"; 8 | import { Permission } from "@app/features/permissions/permissions"; 9 | import { flatten } from "@app/helpers/route.helper"; 10 | import { useAppSelector } from "@app/redux/store"; 11 | import { 12 | RouteComponentDef, 13 | RouteItemDef, 14 | RouteWrapperConfigDef, 15 | } from "@app/types/route.types"; 16 | 17 | import LoginRedirect from "./components/LoginRedirect/LoginRedirect"; 18 | import NotFound from "./components/NotFound/NotFound"; 19 | import RestrictAccess from "./components/RestrictAccess/RestrictAccess"; 20 | import { LayoutsEnum } from "./constants/route.layouts"; 21 | import { PRIVATE_LIST, PUBLIC_LIST } from "./routes.config"; 22 | 23 | /** 24 | * All Layouts 25 | */ 26 | const LAYOUTS = { 27 | [LayoutsEnum.HEADER_LAYOUT]: HeaderLayout, 28 | [LayoutsEnum.SIDEBAR_LAYOUT]: SidebarLayout, 29 | [LayoutsEnum.BLANK_LAYOUT]: BlankLayout, 30 | }; 31 | 32 | /** 33 | * Change the default layout to: 34 | * - HeaderLayout 35 | * - SidebarLayout 36 | */ 37 | const DefaultLayoutEnum = LayoutsEnum.HEADER_LAYOUT; 38 | 39 | const Routes = () => { 40 | const { isAuthenticated } = useAppSelector(state => ({ 41 | isAuthenticated: state.auth?.isAuthenticated, 42 | })); 43 | 44 | const location = useLocation(); 45 | const layoutRef = useRef(LAYOUTS[DefaultLayoutEnum]); 46 | 47 | const matchedRoute: RouteItemDef | undefined = useMemo( 48 | () => 49 | flatten([...PUBLIC_LIST, ...PRIVATE_LIST]).filter( 50 | route => route.path === location.pathname 51 | )[0], 52 | [location.pathname] 53 | ); 54 | 55 | layoutRef.current = isAuthenticated 56 | ? LAYOUTS[matchedRoute?.layout ?? DefaultLayoutEnum] 57 | : LAYOUTS[LayoutsEnum.BLANK_LAYOUT]; 58 | 59 | const routeWrapper = ( 60 | { id, path, component, permissions }: RouteItemDef, 61 | { isProtectedRoute }: RouteWrapperConfigDef | undefined = {} 62 | ) => { 63 | return ( 64 | { 68 | if (isProtectedRoute && !isAuthenticated) { 69 | return ; 70 | } 71 | const Component = component as RouteComponentDef; 72 | const renderContent = ; 73 | 74 | return ( 75 | (permissions && ( 76 | } 78 | requiredPermissions={permissions} 79 | > 80 | {renderContent} 81 | 82 | )) || 83 | renderContent 84 | ); 85 | }} 86 | /> 87 | ); 88 | }; 89 | 90 | const Layout = layoutRef.current; 91 | 92 | return ( 93 | 94 | 95 | 96 | 97 | {PRIVATE_LIST.map(route => 98 | routeWrapper(route, { isProtectedRoute: true }) 99 | )} 100 | {PUBLIC_LIST.map(route => routeWrapper(route))} 101 | } /> 102 | 103 | 104 | ); 105 | }; 106 | 107 | export default memo(Routes); 108 | -------------------------------------------------------------------------------- /src/routes/components/LoginRedirect/LoginRedirect.tsx: -------------------------------------------------------------------------------- 1 | import qs from "query-string"; 2 | import { Redirect, useLocation } from "react-router-dom"; 3 | 4 | import { AuthPathsEnum } from "@app/features/auth/auth"; 5 | 6 | const LoginRedirect = () => { 7 | const location = useLocation(); 8 | 9 | return ( 10 | 18 | ); 19 | }; 20 | 21 | export default LoginRedirect; 22 | -------------------------------------------------------------------------------- /src/routes/components/NotFound/NotFound.tsx: -------------------------------------------------------------------------------- 1 | import { QuestionCircleOutlined } from "@ant-design/icons"; 2 | import { Button, Result } from "antd"; 3 | import { useTranslation } from "react-i18next"; 4 | import { Link } from "react-router-dom"; 5 | 6 | const NotFound = () => { 7 | const { t } = useTranslation(); 8 | return ( 9 | } 11 | title={t("default.notFoundTitle")} 12 | subTitle={t("default.notFoundText")} 13 | extra={ 14 | 15 | 16 | 17 | } 18 | /> 19 | ); 20 | }; 21 | 22 | export default NotFound; 23 | -------------------------------------------------------------------------------- /src/routes/components/RestrictAccess/RestrictAccess.tsx: -------------------------------------------------------------------------------- 1 | import { Button, Result } from "antd"; 2 | import { useTranslation } from "react-i18next"; 3 | import { Link } from "react-router-dom"; 4 | 5 | const RestrictAccess = () => { 6 | const { t } = useTranslation(); 7 | return ( 8 | 14 | 15 | 16 | } 17 | /> 18 | ); 19 | }; 20 | 21 | export default RestrictAccess; 22 | -------------------------------------------------------------------------------- /src/routes/constants/route.layouts.ts: -------------------------------------------------------------------------------- 1 | export enum LayoutsEnum { 2 | SIDEBAR_LAYOUT = "sidebarLayout", 3 | HEADER_LAYOUT = "headerLayout", 4 | BLANK_LAYOUT = "blankLayout", 5 | } 6 | -------------------------------------------------------------------------------- /src/routes/routes.config.ts: -------------------------------------------------------------------------------- 1 | import { AUTH_ROUTES } from "@app/features/auth/auth"; 2 | import { HOME_ROUTES } from "@app/features/home/home"; 3 | import { SETTINGS_ROUTES } from "@app/features/settings/settings"; 4 | 5 | export const ROOT_ROUTE = "/"; 6 | 7 | export const PUBLIC_LIST = [...AUTH_ROUTES]; 8 | export const PRIVATE_LIST = [...HOME_ROUTES, ...SETTINGS_ROUTES]; 9 | -------------------------------------------------------------------------------- /src/setupTests.ts: -------------------------------------------------------------------------------- 1 | // jest-dom adds custom jest matchers for asserting on DOM nodes. 2 | // allows you to do things like: 3 | // expect(element).toHaveTextContent(/react/i) 4 | // learn more: https://github.com/testing-library/jest-dom 5 | import "@testing-library/jest-dom"; 6 | -------------------------------------------------------------------------------- /src/styles/_colors.scss: -------------------------------------------------------------------------------- 1 | $text-white: #fff; 2 | 3 | $bg-white: #fff; 4 | $bg-gray: #f0f2f5; 5 | -------------------------------------------------------------------------------- /src/styles/_variables.scss: -------------------------------------------------------------------------------- 1 | // Spacing 2 | $spacing-0: 0; 3 | $spacing-1: 4px; 4 | $spacing-2: 8px; 5 | $spacing-3: 12px; 6 | $spacing-4: 16px; 7 | $spacing-5: 20px; 8 | $spacing-6: 24px; 9 | $spacing-7: 32px; 10 | $spacing-8: 48px; 11 | $spacing-9: 64px; 12 | 13 | // Font sizes 14 | $font-size-10: 10px; 15 | $font-size-12: 12px; 16 | $font-size-14: 14px; 17 | $font-size-16: 16px; 18 | $font-size-18: 18px; 19 | $font-size-20: 20px; 20 | $font-size-24: 24px; 21 | $font-size-32: 32px; 22 | $font-size-40: 40px; 23 | 24 | // Font weight 25 | $font-weight-100: 100; 26 | $font-weight-200: 200; 27 | $font-weight-300: 300; 28 | $font-weight-400: 400; 29 | $font-weight-500: 500; 30 | $font-weight-600: 600; 31 | $font-weight-700: 700; 32 | $font-weight-800: 800; 33 | 34 | // Navigation 35 | $navbar-height: 64px; 36 | $sidebar-width: 256px; 37 | 38 | // Basic setup for handling of z-layers, 39 | // Layers can be extended if needed 40 | // use like: 41 | // z-index: map-get($zLayers, navigationHeader); 42 | $zLayers: ( 43 | navigationHeader: 500, 44 | ); 45 | -------------------------------------------------------------------------------- /src/styles/antd-theme.js: -------------------------------------------------------------------------------- 1 | // https://github.com/ant-design/ant-design/blob/master/components/style/themes/default.less 2 | module.exports = { 3 | // "@primary-color": "", 4 | // "@layout-header-background": "", 5 | // "@layout-header-height": "auto", 6 | "@layout-body-background": "#f0f2f5", 7 | }; 8 | -------------------------------------------------------------------------------- /src/types/api.types.ts: -------------------------------------------------------------------------------- 1 | export type LoadingStateDef = { 2 | loading: boolean; 3 | }; 4 | 5 | export type ErrorStateDef = { 6 | error: string | undefined | null; 7 | }; 8 | -------------------------------------------------------------------------------- /src/types/pagination.types.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable camelcase */ 2 | import { PaginationProps } from "antd"; 3 | 4 | export type ResponsePaginationDef = { 5 | page: number; 6 | per_page: number; 7 | total: number; 8 | total_pages: number; 9 | }; 10 | 11 | export type TablePaginationDef = Pick< 12 | PaginationProps, 13 | "current" | "pageSize" | "total" 14 | >; 15 | -------------------------------------------------------------------------------- /src/types/route.types.ts: -------------------------------------------------------------------------------- 1 | import { ComponentType } from "react"; 2 | 3 | import { RouteComponentProps } from "react-router-dom"; 4 | 5 | import { PermissionEnum } from "@app/features/permissions/permissions"; 6 | import { LayoutsEnum } from "@app/routes/constants/route.layouts"; 7 | 8 | export type RouteGroupDef = { 9 | id: string; 10 | groupTitle: string; 11 | /** Nested Routes */ 12 | nestedRoutes?: RouteItemDef[]; 13 | }; 14 | 15 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 16 | export type RouteComponentDef = ComponentType>; 17 | 18 | export type RouteItemDef = { 19 | /** 20 | * Unique id for the path 21 | * The id should be the same for paths that are showing the same component 22 | */ 23 | id: string; 24 | /** 25 | * The URL path for when 26 | * the component should be rendered 27 | */ 28 | path: string | string[]; 29 | /** Navigation title of menu item for navbar */ 30 | navigationTitle?: string; 31 | /** 32 | * Screen (or component) to show 33 | * when navigating to the menu item 34 | */ 35 | component: RouteComponentDef; 36 | /** Layout used for this route */ 37 | layout?: LayoutsEnum; 38 | /** Nested Routes either by array of routes or group of routes */ 39 | nestedRoutes?: Array; 40 | /** Flag for hide/show in navigation bar */ 41 | hideInNavigation?: boolean; 42 | /** The required permissions to view this route (optional) */ 43 | permissions?: PermissionEnum[]; 44 | }; 45 | 46 | export type RouteWrapperConfigDef = { 47 | isProtectedRoute?: boolean; 48 | }; 49 | 50 | export type RedirectDef = { 51 | redirect: string; 52 | }; 53 | -------------------------------------------------------------------------------- /src/types/table.types.ts: -------------------------------------------------------------------------------- 1 | import { SortOrder } from "antd/lib/table/interface"; 2 | 3 | export type OrderByDef = { 4 | key: string | undefined; 5 | direction: SortOrder | undefined; 6 | }; 7 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.paths.json", 3 | "compilerOptions": { 4 | "target": "es5", 5 | "lib": [ 6 | "dom", 7 | "dom.iterable", 8 | "esnext" 9 | ], 10 | "allowJs": true, 11 | "skipLibCheck": true, 12 | "esModuleInterop": true, 13 | "allowSyntheticDefaultImports": true, 14 | "noImplicitAny": true, 15 | "noImplicitThis": true, 16 | "strictNullChecks": true, 17 | "strict": true, 18 | "forceConsistentCasingInFileNames": true, 19 | "noFallthroughCasesInSwitch": true, 20 | "downlevelIteration": true, 21 | "module": "esnext", 22 | "moduleResolution": "node", 23 | "resolveJsonModule": true, 24 | "isolatedModules": true, 25 | "noEmit": true, 26 | "jsx": "react-jsx", 27 | "noUnusedLocals": true, 28 | "baseUrl": ".", 29 | "useUnknownInCatchVariables": false 30 | }, 31 | "include": [ 32 | "src" 33 | ] 34 | } 35 | -------------------------------------------------------------------------------- /tsconfig.paths.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "baseUrl": ".", 4 | "paths": { 5 | "@app/*": ["./src/*"] 6 | } 7 | } 8 | } 9 | --------------------------------------------------------------------------------