├── .eslintignore ├── .gitignore ├── .nvmrc ├── .prettierrc ├── .vscode ├── extensions.json └── settings.json ├── README.md ├── config-overrides.js ├── config └── jest │ ├── babelTransform.js │ └── cssTransform.js ├── jest.config.js ├── package.json ├── public ├── favicon.ico ├── index.html ├── locales │ ├── en │ │ └── common.json │ └── index.js ├── logo192.png ├── logo512.png ├── manifest.json └── robots.txt ├── src ├── App.test.tsx ├── App.tsx ├── __mocks__ │ └── lib │ │ └── i18n.ts ├── assets │ ├── index.ts │ └── svg │ │ ├── account.svg │ │ └── index.ts ├── components │ ├── Button.tsx │ ├── Icons.tsx │ ├── Loader.tsx │ └── theme.ts ├── emotion.d.ts ├── index.css ├── index.tsx ├── lib │ ├── api │ │ ├── api.ts │ │ ├── errors.ts │ │ └── models.ts │ ├── asyncSuspense.ts │ ├── i18n.ts │ ├── sleep.ts │ └── tests │ │ ├── mockApi.ts │ │ └── testUtils.tsx ├── logo.svg ├── modules │ ├── .eslintrc.js │ ├── CV.tsx │ ├── ErrorFallback.tsx │ └── layouts │ │ └── MainLayout.tsx ├── public │ └── en │ │ ├── cv.json │ │ └── index.js ├── react-app-env.d.ts ├── react-i18next.d.ts ├── routes │ ├── IndexRedirect.tsx │ ├── PrivateRoute.tsx │ ├── index.tsx │ ├── types.ts │ └── useNavigation.ts ├── setupTests.ts └── stores │ ├── Store.ts │ └── UserStore.ts ├── tsconfig.json └── yarn.lock /.eslintignore: -------------------------------------------------------------------------------- 1 | coverage 2 | build 3 | .vscode 4 | .yarn 5 | node_modules 6 | config/**/*.js 7 | scripts/**/*.js 8 | jest.config.js 9 | 10 | .config-overrides.js 11 | config-overrides.js -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | v18.7.0 -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "endOfLine": "auto", 3 | "printWidth": 120 4 | } 5 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "arcanis.vscode-zipfs", 4 | "dbaeumer.vscode-eslint", 5 | "esbenp.prettier-vscode" 6 | ] 7 | } 8 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.formatOnSave": true, 3 | "editor.defaultFormatter": "esbenp.prettier-vscode", 4 | "eslint.validate": ["javascript", "javascriptreact", "typescript", "typescriptreact"], 5 | "editor.codeActionsOnSave": { 6 | "source.fixAll.eslint": true 7 | }, 8 | "editor.showFoldingControls": "always", 9 | "editor.folding": true, 10 | "editor.foldingStrategy": "indentation", 11 | "search.exclude": { 12 | "**/.yarn": true, 13 | "**/.pnp.*": true 14 | }, 15 | "typescript.tsdk": "node_modules/typescript/lib", 16 | "typescript.enablePromptUseWorkspaceTsdk": true, 17 | "i18n-ally.localesPaths": "public/locales", 18 | "i18n-ally.keystyle": "nested", 19 | "files.autoSave": "onFocusChange", 20 | "eslint.alwaysShowStatus": true, 21 | "cSpell.words": [] 22 | } 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # React TypeScript Skeleton 2 | 3 | ## Installation 4 | 5 | ```shell 6 | yarn install 7 | ``` 8 | 9 | ## IDE 10 | 11 | Recommended IDE is Visual Studio Code: 12 | 13 | Required plugin set: 14 | 15 | - EditorConfig for VS Code 16 | - ESLint 17 | - Code Spell Checker 18 | - Prettier 19 | -------------------------------------------------------------------------------- /config-overrides.js: -------------------------------------------------------------------------------- 1 | const { override, addBabelPreset } = require("customize-cra"); 2 | 3 | module.exports = override( 4 | addBabelPreset("@emotion/babel-preset-css-prop"), 5 | addBabelPreset([ 6 | "@babel/preset-react", 7 | { 8 | runtime: "automatic", 9 | importSource: "@emotion/react", 10 | }, 11 | ]) 12 | ); 13 | -------------------------------------------------------------------------------- /config/jest/babelTransform.js: -------------------------------------------------------------------------------- 1 | // eslint-disable-next-line import/no-extraneous-dependencies 2 | const babelJest = require("babel-jest").default; 3 | 4 | const hasJsxRuntime = (() => { 5 | if (process.env.DISABLE_NEW_JSX_TRANSFORM === "true") { 6 | return false; 7 | } 8 | 9 | try { 10 | require.resolve("react/jsx-runtime"); 11 | 12 | return true; 13 | } catch (error) { 14 | return false; 15 | } 16 | })(); 17 | 18 | module.exports = babelJest.createTransformer({ 19 | presets: [ 20 | [ 21 | require.resolve("babel-preset-react-app"), 22 | { 23 | runtime: hasJsxRuntime ? "automatic" : "classic", 24 | }, 25 | ], 26 | ], 27 | plugins: [["@babel/plugin-proposal-private-property-in-object", { loose: true }]], 28 | babelrc: false, 29 | configFile: false, 30 | }); 31 | -------------------------------------------------------------------------------- /config/jest/cssTransform.js: -------------------------------------------------------------------------------- 1 | // This is a custom Jest transformer turning style imports into empty objects. 2 | // http://facebook.github.io/jest/docs/en/webpack.html 3 | 4 | module.exports = { 5 | process() { 6 | return "module.exports = {};"; 7 | }, 8 | getCacheKey() { 9 | // The output is always the same. 10 | return "cssTransform"; 11 | }, 12 | }; 13 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-var-requires,@typescript-eslint/no-unsafe-assignment,@typescript-eslint/no-unsafe-call */ 2 | 3 | const jestConfig = { 4 | roots: ["/src"], 5 | collectCoverage: true, 6 | coverageReporters: ["lcov"], 7 | coverageDirectory: "coverage", 8 | coverageThreshold: { 9 | global: { 10 | branches: 0, 11 | functions: 0, 12 | lines: 0, 13 | statements: 0, 14 | }, 15 | }, 16 | collectCoverageFrom: [ 17 | "src/**/*.{ts,tsx}", 18 | "!src/**/*.d.ts", 19 | "!/src/modules/settings/**/*.{ts,tsx}", 20 | "!/src/**/*.stories.{ts,tsx}", 21 | "!/src/index.tsx", 22 | "!/src/react-app-env.d.ts", 23 | "!/src/reportWebVitals.ts", 24 | "!/src/**/types/*", 25 | "!/src/components/form/types.ts", 26 | "!/src/lib/api/*", 27 | "!/src/routes/*", 28 | "!/src/lib/other/i18n.ts", 29 | "!/src/__mocks__", 30 | ], 31 | setupFiles: ["react-app-polyfill/jsdom"], 32 | setupFilesAfterEnv: ["/src/setupTests.ts"], 33 | testMatch: ["/src/**/__tests__/**/*.{js,jsx,ts,tsx}", "/src/**/*.{spec,test}.{js,jsx,ts,tsx}"], 34 | testEnvironment: "jsdom", 35 | transform: { 36 | "^.+\\.(js|jsx|mjs|cjs|ts|tsx)$": [ 37 | "esbuild-jest", 38 | { 39 | jsxFactory: "jsx", 40 | sourcemap: true, 41 | loaders: { 42 | ".spec.js": "js", 43 | ".spec.jsx": "jsx", 44 | ".spec.ts": "ts", 45 | ".spec.tsx": "tsx", 46 | }, 47 | }, 48 | ], 49 | // "^.+\\.(js|jsx|mjs|cjs|ts|tsx)$": "babel-jest", 50 | "^.+\\.css$": "/config/jest/cssTransform.js", 51 | "^(?!.*\\.(js|jsx|mjs|cjs|ts|tsx|css|json)$)": "/config/jest/fileTransform.js", 52 | }, 53 | transformIgnorePatterns: [ 54 | "[/\\\\]node_modules[/\\\\](?!@antv).+\\.(js|jsx|mjs|cjs|ts|tsx)$", 55 | "^.+\\.module\\.(css|sass|scss)$", 56 | ], 57 | modulePaths: ["/Users/bo/dev/tempusforce/mono/frontend/app/src"], 58 | moduleNameMapper: { 59 | "^react-native$": "react-native-web", 60 | "^.+\\.module\\.(css|sass|scss)$": "identity-obj-proxy", 61 | }, 62 | moduleFileExtensions: ["web.js", "js", "web.ts", "ts", "web.tsx", "tsx", "json", "web.jsx", "jsx", "node"], 63 | watchPlugins: ["jest-watch-typeahead/filename", "jest-watch-typeahead/testname"], 64 | resetMocks: true, 65 | }; 66 | 67 | if (process.env.CI) { 68 | jestConfig.testResultsProcessor = "jest-junit"; 69 | jestConfig.coverageReporters = ["cobertura"]; 70 | } 71 | 72 | module.exports = jestConfig; 73 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "my-app", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "start": "react-app-rewired start", 7 | "build": "react-app-rewired build", 8 | "test": "react-app-rewired test", 9 | "lint": "eslint . --ext .ts" 10 | }, 11 | "dependencies": { 12 | "@emotion/react": "^11.11.1", 13 | "@emotion/styled": "^11.11.0", 14 | "@types/node": "^20.9.0", 15 | "@types/react": "^18.2.37", 16 | "@types/react-dom": "^18.2.15", 17 | "antd": "4.23.5", 18 | "customize-cra": "^1.0.0", 19 | "react-router": "6.9.0", 20 | "react-router-dom": "6.9.0", 21 | "type-fest": "4.6.0", 22 | "types": "link:./src/types", 23 | "lib": "link:./src/lib", 24 | "typescript": "^5.2.2", 25 | "@mui/material": "^5.9.0", 26 | "@testing-library/react": "^13.0.0", 27 | "@testing-library/user-event": "^13.2.1", 28 | "axios": "^0.27.2", 29 | "gh-pages": "^4.0.0", 30 | "history": "^5.3.0", 31 | "i18next": "^19.9.0", 32 | "i18next-browser-languagedetector": "^6.1.0", 33 | "locales": "link:./public/locales", 34 | "components": "link:./src/components", 35 | "lodash": "^4.17.21", 36 | "mobx": "^6.3.9", 37 | "mobx-devtools-mst": "^0.9.30", 38 | "mobx-react": "^7.2.1", 39 | "mobx-react-devtools": "^6.1.1", 40 | "mobx-react-lite": "^3.2.2", 41 | "mobx-state-tree": "^5.1.0", 42 | "prettier": "^2.7.1", 43 | "qs": "^6.11.0", 44 | "react": "^18.0.0", 45 | "react-app-rewired": "^2.2.1", 46 | "react-dom": "^18.0.0", 47 | "react-error-boundary": "^3.1.4", 48 | "react-hook-form": "^7.33.1", 49 | "react-i18next": "11.8.15", 50 | "react-query": "^3.39.1", 51 | "react-scripts": "5.0.1" 52 | }, 53 | "devDependencies": { 54 | "@testing-library/dom": "^8.11.1", 55 | "@testing-library/react-hooks": "^7.0.2", 56 | "@types/jest": "^27.4.0", 57 | "@typescript-eslint/eslint-plugin": "^6.10.0", 58 | "@typescript-eslint/parser": "^6.10.0", 59 | "@emotion/babel-preset-css-prop": "^11.11.0", 60 | "eslint-config-airbnb": "^19.0.2", 61 | "eslint-config-airbnb-typescript": "^17.1.0", 62 | "eslint-config-prettier": "^9.0.0", 63 | "eslint-plugin-eslint-comments": "^3.2.0", 64 | "eslint-plugin-i18next": "^6.0.0-4", 65 | "eslint": "^8.53.0", 66 | "eslint-plugin-import": "^2.29.0", 67 | "eslint-plugin-prettier": "^5.0.1", 68 | "eslint-plugin-react": "^7.33.2", 69 | "eslint-plugin-react-hooks": "^4.6.0", 70 | "eslint-plugin-simple-import-sort": "^10.0.0", 71 | "eslint-plugin-sonarjs": "^0.23.0", 72 | "eslint-plugin-jest": "26.8.7", 73 | "eslint-plugin-unicorn": "^39.0.0", 74 | "jest": "^28.1.3", 75 | "@alienfast/i18next-loader": "^1.1.4", 76 | "@testing-library/jest-dom": "latest", 77 | "@testing-library/react": "latest", 78 | "@testing-library/user-event": "latest", 79 | "@types/lodash": "^4.14.182", 80 | "eslint-plugin-unused-imports": "^1.1.2" 81 | }, 82 | "resolutions": { 83 | "react-i18next": ">=11.16.4" 84 | }, 85 | "eslintConfig": { 86 | "extends": [ 87 | "react-app", 88 | "react-app/jest" 89 | ] 90 | }, 91 | "browserslist": { 92 | "production": [ 93 | ">0.2%", 94 | "not dead", 95 | "not op_mini all" 96 | ], 97 | "development": [ 98 | "last 1 chrome version", 99 | "last 1 firefox version", 100 | "last 1 safari version" 101 | ] 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EgorPashko/react-ts-skeleton/072d4cac68ef213a4a78ef7b6fd4bec710f2cb79/public/favicon.ico -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 12 | 13 | 17 | 18 | 27 | React App 28 | 29 | 30 | 31 |
32 | 42 | 43 | 44 | -------------------------------------------------------------------------------- /public/locales/en/common.json: -------------------------------------------------------------------------------- 1 | { 2 | "ticketsInformation": "Tickets Information", 3 | "forbidden": "Forbidden", 4 | "clickMe": "Click Me" 5 | } 6 | -------------------------------------------------------------------------------- /public/locales/index.js: -------------------------------------------------------------------------------- 1 | export { default as common } from "./en/common.json"; 2 | -------------------------------------------------------------------------------- /public/logo192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EgorPashko/react-ts-skeleton/072d4cac68ef213a4a78ef7b6fd4bec710f2cb79/public/logo192.png -------------------------------------------------------------------------------- /public/logo512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EgorPashko/react-ts-skeleton/072d4cac68ef213a4a78ef7b6fd4bec710f2cb79/public/logo512.png -------------------------------------------------------------------------------- /public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "React App", 3 | "name": "Create React App Sample", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "64x64 32x32 24x24 16x16", 8 | "type": "image/x-icon" 9 | }, 10 | { 11 | "src": "logo192.png", 12 | "type": "image/png", 13 | "sizes": "192x192" 14 | }, 15 | { 16 | "src": "logo512.png", 17 | "type": "image/png", 18 | "sizes": "512x512" 19 | } 20 | ], 21 | "start_url": ".", 22 | "display": "standalone", 23 | "theme_color": "#000000", 24 | "background_color": "#ffffff" 25 | } 26 | -------------------------------------------------------------------------------- /public/robots.txt: -------------------------------------------------------------------------------- 1 | # https://www.robotstxt.org/robotstxt.html 2 | User-agent: * 3 | Disallow: 4 | -------------------------------------------------------------------------------- /src/App.test.tsx: -------------------------------------------------------------------------------- 1 | import { cleanup, render } from "@testing-library/react"; 2 | import type { ReactNode } from "react"; 3 | 4 | import App, { AppInner } from "./App"; 5 | import type { User } from "./lib/api/models"; 6 | import { mockApi } from "./lib/tests/mockApi"; 7 | import { act } from "./lib/tests/testUtils"; 8 | import { useStore } from "./stores/Store"; 9 | 10 | // jest.mock("./routes", () => () =>
TestApp
); 11 | 12 | beforeEach(() => { 13 | jest.resetAllMocks(); 14 | cleanup(); 15 | const store = useStore(); 16 | 17 | store.setUser(undefined); 18 | }); 19 | 20 | jest.mock("react", () => { 21 | const React = jest.requireActual("react"); 22 | 23 | React.Suspense = ({ children }: { children: ReactNode }) =>
{children}
; 24 | 25 | return React; 26 | }); 27 | it("Should get user from BE", async () => { 28 | mockApi({ 29 | getUserById: { id: 123 } as User, 30 | }); 31 | const store = useStore(); 32 | 33 | store.loadUser(); 34 | 35 | const { findByText } = render( store} />); // @todo: fix params 36 | 37 | expect(await findByText("Click Me")).toBeTruthy(); 38 | }); 39 | 40 | it("Should return Forbidden page when user is not authenticated", async () => { 41 | mockApi({ 42 | getUserById: undefined, 43 | }); 44 | const store = useStore(); 45 | 46 | const { findByText } = render( store} />); // @todo: fix params 47 | 48 | expect(await findByText("Forbidden")).toBeTruthy(); 49 | }); 50 | -------------------------------------------------------------------------------- /src/App.tsx: -------------------------------------------------------------------------------- 1 | import { ThemeProvider } from "@emotion/react"; 2 | import { MobXProviderContext } from "mobx-react"; 3 | import { Suspense, useMemo } from "react"; 4 | import { I18nextProvider } from "react-i18next"; 5 | import { BrowserRouter } from "react-router-dom"; 6 | 7 | import { Loader } from "./components/Loader"; 8 | import { theme } from "./components/theme"; 9 | import i18n from "./lib/i18n"; 10 | import Routes from "./routes"; 11 | import { initializeStore } from "./stores/Store"; 12 | 13 | type Props = { storeLoader: ReturnType }; 14 | 15 | export const AppInner = ({ storeLoader }: Props) => { 16 | const stores = useMemo(() => ({ store: storeLoader() }), [storeLoader]); 17 | 18 | return ( 19 | 20 | 21 | {/* eslint-disable-next-line @typescript-eslint/ban-ts-comment */} 22 | {/* @ts-expect-error */} 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | ); 31 | }; 32 | 33 | const App = () => ( 34 | }> 35 | 36 | 37 | ); 38 | 39 | export default App; 40 | -------------------------------------------------------------------------------- /src/__mocks__/lib/i18n.ts: -------------------------------------------------------------------------------- 1 | import i18nTesting from "i18next"; 2 | import * as translations from "locales"; 3 | import { initReactI18next } from "react-i18next"; 4 | 5 | i18nTesting.use(initReactI18next).init({ 6 | lng: "en", 7 | fallbackLng: "en", 8 | 9 | // have a common namespace used around the full app 10 | ns: ["common"], 11 | defaultNS: "common", 12 | 13 | // debug: true, 14 | 15 | interpolation: { 16 | escapeValue: false, // not needed for react!! 17 | }, 18 | 19 | resources: { en: translations }, 20 | }); 21 | 22 | export default i18nTesting; 23 | -------------------------------------------------------------------------------- /src/assets/index.ts: -------------------------------------------------------------------------------- 1 | export * as images from "./svg"; 2 | -------------------------------------------------------------------------------- /src/assets/svg/account.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /src/assets/svg/index.ts: -------------------------------------------------------------------------------- 1 | export { ReactComponent as Account } from "./account.svg"; 2 | -------------------------------------------------------------------------------- /src/components/Button.tsx: -------------------------------------------------------------------------------- 1 | import type { ButtonProps } from "@mui/material"; 2 | import { Button as MaterialUiButton } from "@mui/material"; 3 | 4 | type Props = ButtonProps & { 5 | text: string; 6 | }; 7 | 8 | // if you want to customize component with theme, 9 | // see https://mui.com/material-ui/customization/how-to-customize/ 10 | export const Button = ({ text, ...args }: Props) => { 11 | return {text}; 12 | }; 13 | -------------------------------------------------------------------------------- /src/components/Icons.tsx: -------------------------------------------------------------------------------- 1 | import { images } from "../assets"; 2 | 3 | export const iconMapper = { 4 | account: images.Account, 5 | }; 6 | 7 | export type IconType = keyof typeof iconMapper; 8 | 9 | type IconProps = { 10 | className?: string; 11 | type: IconType; 12 | onClick?: (e: React.MouseEvent) => void; 13 | }; 14 | 15 | const Icon = (props: IconProps) => { 16 | const { className, type, onClick } = props; 17 | 18 | if (!type) return null; 19 | const IconSource = iconMapper[type]; 20 | 21 | return ( 22 |
onClick?.(e)}> 23 | 24 |
25 | ); 26 | }; 27 | 28 | export default Icon; 29 | -------------------------------------------------------------------------------- /src/components/Loader.tsx: -------------------------------------------------------------------------------- 1 | import { CircularProgress } from "@mui/material"; 2 | 3 | export const Loader = () => { 4 | return ; 5 | }; 6 | -------------------------------------------------------------------------------- /src/components/theme.ts: -------------------------------------------------------------------------------- 1 | import { createTheme } from "@mui/material/styles"; 2 | 3 | const colors = { 4 | black100: "#000000", 5 | }; 6 | 7 | export const theme = createTheme({ 8 | palette: { 9 | primary: { 10 | main: colors.black100, 11 | }, 12 | }, 13 | }); 14 | 15 | export type Theme = typeof theme 16 | -------------------------------------------------------------------------------- /src/emotion.d.ts: -------------------------------------------------------------------------------- 1 | import "@emotion/react"; 2 | 3 | import type { Theme as MainTheme } from "components/theme"; 4 | 5 | declare module "@emotion/react" { 6 | // eslint-disable-next-line @typescript-eslint/no-empty-interface 7 | export interface Theme extends MainTheme {} 8 | } 9 | -------------------------------------------------------------------------------- /src/index.css: -------------------------------------------------------------------------------- 1 | body { 2 | margin: 0; 3 | font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 4 | 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', 5 | sans-serif; 6 | -webkit-font-smoothing: antialiased; 7 | -moz-osx-font-smoothing: grayscale; 8 | } 9 | 10 | code { 11 | font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New', 12 | monospace; 13 | } 14 | -------------------------------------------------------------------------------- /src/index.tsx: -------------------------------------------------------------------------------- 1 | import "./index.css"; 2 | 3 | import React from "react"; 4 | import ReactDOM from "react-dom/client"; 5 | 6 | import App from "./App"; 7 | 8 | const root = ReactDOM.createRoot(document.getElementById("root") as HTMLElement); 9 | 10 | root.render( 11 | 12 | 13 | 14 | ); 15 | -------------------------------------------------------------------------------- /src/lib/api/api.ts: -------------------------------------------------------------------------------- 1 | import type { User } from "./models"; 2 | 3 | export const api = { 4 | getUserById: (id: number): Promise => { 5 | // make call to get user by id 6 | return Promise.resolve({ id }); 7 | }, 8 | }; 9 | 10 | export type API = typeof api; 11 | -------------------------------------------------------------------------------- /src/lib/api/errors.ts: -------------------------------------------------------------------------------- 1 | import type { AxiosError } from "axios"; 2 | 3 | type NewType = AxiosError; 4 | 5 | export function isAxiosError(error: unknown): error is NewType { 6 | return (error as AxiosError)?.isAxiosError; 7 | } 8 | -------------------------------------------------------------------------------- /src/lib/api/models.ts: -------------------------------------------------------------------------------- 1 | export type User = { 2 | id: number; 3 | }; 4 | -------------------------------------------------------------------------------- /src/lib/asyncSuspense.ts: -------------------------------------------------------------------------------- 1 | export const asyncSuspense = 2 | (func: () => Promise, name = "suspense") => 3 | () => { 4 | let status: "init" | "done" | "error" = "init"; 5 | let result: T; 6 | 7 | const loading = func() 8 | .then((r) => { 9 | result = r; 10 | status = "done"; 11 | }) 12 | .catch(() => { 13 | // add error logger? 14 | status = "error"; 15 | }); 16 | 17 | return () => { 18 | if (status === "init") { 19 | // eslint-disable-next-line @typescript-eslint/no-throw-literal 20 | throw loading; 21 | } else if (status === "error") { 22 | throw new Error(`${name} loading failed`); 23 | } 24 | 25 | return result; 26 | }; 27 | }; 28 | -------------------------------------------------------------------------------- /src/lib/i18n.ts: -------------------------------------------------------------------------------- 1 | import i18n from "i18next"; 2 | import * as resources from "locales"; 3 | 4 | i18n 5 | // detect user language 6 | // learn more: https://github.com/i18next/i18next-browser-languageDetector 7 | // .use(LanguageDetector) 8 | // pass the i18n instance to react-i18next. 9 | // init i18next 10 | // for all options read: https://www.i18next.com/overview/configuration-options 11 | .init({ 12 | fallbackLng: "en", 13 | lng: "en", 14 | ns: ["common", "validation"], 15 | preload: ["en"], 16 | defaultNS: "common", 17 | partialBundledLanguages: true, 18 | 19 | saveMissing: true, 20 | missingKeyHandler: (lng, ns, key) => { 21 | throw new Error(`Missing i18n key ${[lng].flat().join(",")}:${ns}:${key}`); 22 | }, 23 | // @todo: lazy-loading for non-english 24 | resources: { 25 | en: resources, 26 | }, 27 | }); 28 | 29 | export default i18n; 30 | -------------------------------------------------------------------------------- /src/lib/sleep.ts: -------------------------------------------------------------------------------- 1 | export const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); 2 | -------------------------------------------------------------------------------- /src/lib/tests/mockApi.ts: -------------------------------------------------------------------------------- 1 | import type { AsyncReturnType } from "type-fest"; 2 | 3 | import type { API } from "../api/api"; 4 | import { api } from "../api/api"; 5 | 6 | export function mockApi(responses: { [K in keyof API]?: AsyncReturnType } = {}) { 7 | (Object.keys(responses) as Array).forEach((k) => { 8 | // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-return 9 | api[k] = async () => responses[k] as any; 10 | }); 11 | } 12 | -------------------------------------------------------------------------------- /src/lib/tests/testUtils.tsx: -------------------------------------------------------------------------------- 1 | import { configure, render as testRender } from "@testing-library/react"; 2 | import { createMemoryHistory } from "history"; 3 | import { Provider as MobXProvider } from "mobx-react"; 4 | import type { PropsWithChildren, ReactNode } from "react"; 5 | import { Suspense } from "react"; 6 | import { I18nextProvider } from "react-i18next"; 7 | import { BrowserRouter } from "react-router-dom"; 8 | 9 | import { useStore } from "../../stores/Store"; 10 | import { api } from "../api/api"; 11 | import i18nTesting from "../i18n"; 12 | import { mockApi } from "./mockApi"; 13 | 14 | export { act } from "@testing-library/react"; 15 | 16 | configure({ testIdAttribute: "data-test" }); 17 | 18 | const history = createMemoryHistory(); 19 | 20 | jest.mock("react", () => { 21 | const React = jest.requireActual("react"); 22 | 23 | React.Suspense = ({ children }: { children: ReactNode }) => children; 24 | 25 | return React; 26 | }); 27 | 28 | const Wrapper = ({ children }: PropsWithChildren) => ( 29 | 30 | 31 | {/* eslint-disable-next-line @typescript-eslint/ban-ts-comment */} 32 | {/* @ts-expect-error */} 33 | 34 | {children} 35 | 36 | 37 | 38 | ); 39 | 40 | export const render = (ui: Parameters[0], options?: Parameters[1]) => 41 | testRender(ui, { wrapper: Wrapper, ...options }); 42 | 43 | export { history }; 44 | 45 | export const mockAllApi = () => { 46 | mockApi( 47 | Object.keys(api).reduce((acc, key) => { 48 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 49 | acc[key as keyof typeof acc] = [] as any; 50 | 51 | return acc; 52 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 53 | }, {} as any) 54 | ); 55 | }; 56 | -------------------------------------------------------------------------------- /src/logo.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/modules/.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extends: [ 3 | "airbnb-typescript", 4 | "airbnb/hooks", 5 | "eslint:recommended", 6 | "plugin:@typescript-eslint/eslint-recommended", 7 | "plugin:@typescript-eslint/recommended", 8 | "plugin:eslint-comments/recommended", 9 | "plugin:jest/recommended", 10 | "plugin:react/recommended", 11 | "plugin:import/warnings", 12 | "plugin:import/typescript", 13 | "prettier", 14 | ], 15 | plugins: ["simple-import-sort", "import", "jest", "unused-imports", "i18next", "sonarjs"], 16 | env: { 17 | browser: true, 18 | es6: true, 19 | jest: true, 20 | }, 21 | parser: "@typescript-eslint/parser", 22 | parserOptions: { 23 | sourceType: "module", 24 | project: "./tsconfig.json", 25 | }, 26 | rules: { 27 | "no-plusplus": "off", 28 | "func-names": "off", 29 | "eslint-disable-next-line no-param-reassign": "off", 30 | "no-param-reassign": [2, { props: false }], 31 | "simple-import-sort/imports": "error", 32 | "simple-import-sort/exports": "error", 33 | "import/first": "error", 34 | "import/newline-after-import": "error", 35 | "import/no-duplicates": "error", 36 | "consistent-return": "off", 37 | "@typescript-eslint/no-implied-eval": "off", 38 | "import/no-named-as-default-member": "off", 39 | "import/no-cycle": "off", 40 | "sort-imports": "off", 41 | "import/order": "off", 42 | "linebreak-style": "off", 43 | "react/no-unknown-property": ["error", { ignore: ["css"] }], 44 | "react/prop-types": "off", 45 | "react/jsx-props-no-spreading": "off", 46 | "jsx-a11y/anchor-is-valid": "off", 47 | "import/prefer-default-export": "off", 48 | "react/display-name": "off", 49 | "no-void": "off", 50 | "jest/no-disabled-tests": "off", 51 | "react/react-in-jsx-scope": "off", 52 | "react/require-default-props": "off", 53 | "import/no-named-as-default": "off", 54 | "jsx-a11y/no-static-element-interactions": "off", 55 | "jsx-a11y/click-events-have-key-events": "off", 56 | "@typescript-eslint/no-non-null-assertion": "off", 57 | "@typescript-eslint/explicit-module-boundary-types": "off", 58 | "import/no-useless-path-segments": [ 59 | "error", 60 | { 61 | noUselessIndex: true, 62 | }, 63 | ], 64 | "no-restricted-imports": [ 65 | "error", 66 | { 67 | paths: [ 68 | "antd", 69 | "lib/swagger/generated", 70 | { 71 | name: "react", 72 | importNames: ["FC"], 73 | message: "Write Function Components as regular functions with props.", 74 | }, 75 | { 76 | name: "react-hook-form", 77 | importNames: ["useForm"], 78 | message: "Import from components/Form", 79 | }, 80 | { 81 | name: "@emotion/css", 82 | importNames: ["css"], 83 | message: "Import from @emotion/react.", 84 | }, 85 | ], 86 | patterns: [ 87 | "antd/*", 88 | "rc-table/*", 89 | "components/Form/*", 90 | "models/*", 91 | "components/Table/*", 92 | "components/Calendar/*", 93 | "lib/api/responses", 94 | "../../*", 95 | ], 96 | }, 97 | ], 98 | "@typescript-eslint/no-explicit-any": "error", 99 | "padding-line-between-statements": [ 100 | "error", 101 | { blankLine: "always", prev: "*", next: "return" }, 102 | { blankLine: "always", prev: ["const", "let", "var"], next: "*" }, 103 | { blankLine: "any", prev: ["const", "let", "var"], next: ["const", "let", "var"] }, 104 | ], 105 | "@typescript-eslint/consistent-type-imports": ["error", { prefer: "type-imports" }], 106 | "@typescript-eslint/array-type": ["error", { default: "array-simple" }], 107 | "@typescript-eslint/prefer-ts-expect-error": "error", 108 | "@typescript-eslint/prefer-as-const": "error", 109 | "@typescript-eslint/no-unused-vars": [ 110 | "warn", 111 | { vars: "all", varsIgnorePattern: "^_", args: "after-used", argsIgnorePattern: "^_" }, 112 | ], 113 | "react/destructuring-assignment": "off", 114 | "react/forbid-dom-props": ["error", { forbid: ["style"] }], 115 | "react/jsx-sort-props": [ 116 | "error", 117 | { 118 | callbacksLast: true, 119 | shorthandFirst: true, 120 | ignoreCase: true, 121 | reservedFirst: true, 122 | }, 123 | ], 124 | "react/jsx-no-useless-fragment": "error", 125 | "react/jsx-fragments": "error", 126 | "no-nested-ternary": "off", 127 | "react/function-component-definition": [ 128 | "error", 129 | { namedComponents: "arrow-function", unnamedComponents: "arrow-function" }, 130 | ], 131 | 132 | "@typescript-eslint/ban-ts-comment": [ 133 | "error", 134 | { "ts-expect-error": "allow-with-description", minimumDescriptionLength: 5 }, 135 | ], 136 | "spaced-comment": ["error", "always", { markers: ["#region"], exceptions: ["#endregion"] }], 137 | 138 | "no-console": "warn", 139 | "eslint-comments/no-unlimited-disable": "error", 140 | "eslint-comments/disable-enable-pair": "off", 141 | "import/no-extraneous-dependencies": ["error", { devDependencies: ["**/*.spec.ts*"] }], 142 | "arrow-body-style": "off", 143 | "prefer-arrow-callback": "off", 144 | }, 145 | }; 146 | -------------------------------------------------------------------------------- /src/modules/CV.tsx: -------------------------------------------------------------------------------- 1 | import { useTranslation } from "react-i18next"; 2 | 3 | import type { Theme } from "../components/theme"; 4 | import { css } from "@emotion/react"; 5 | 6 | const styles = { 7 | container: (theme: Theme) => 8 | css({ 9 | display: "flex", 10 | height: "100vh", 11 | background: "linear-gradient(rgb(241, 135, 79) 0%, rgb(194, 58, 134) 100%)", 12 | // 13 | }), 14 | }; 15 | 16 | export const CV = () => { 17 | const { t } = useTranslation("common"); 18 | 19 | return
//
; 20 | }; 21 | -------------------------------------------------------------------------------- /src/modules/ErrorFallback.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import type { FallbackProps } from "react-error-boundary"; 3 | 4 | import { Button } from "../components/Button"; 5 | import { isAxiosError } from "../lib/api/errors"; 6 | 7 | const ErrorFallback = ({ error, resetErrorBoundary }: FallbackProps) => { 8 | let message =

Something went wrong.

; 9 | 10 | if (isAxiosError(error) && Number(error.response?.status) === 403) { 11 | message = ( 12 |
13 |

You don't have permission to access this page.

14 |
15 | ); 16 | 17 | return
{message}
; 18 | } 19 | 20 | return isAxiosError(error) ? ( 21 |
22 |

Something went wrong:

23 |

{error.message}

24 | 25 |
26 | ) : ( 27 |
{message}
28 | ); 29 | }; 30 | 31 | export default ErrorFallback; 32 | -------------------------------------------------------------------------------- /src/modules/layouts/MainLayout.tsx: -------------------------------------------------------------------------------- 1 | import { Suspense } from "react"; 2 | import { ErrorBoundary } from "react-error-boundary"; 3 | import { QueryErrorResetBoundary } from "react-query"; 4 | 5 | import { Loader } from "../../components/Loader"; 6 | import type { ChildrenProps } from "../../routes/types"; 7 | import ErrorFallback from "../ErrorFallback"; 8 | 9 | export const MainLayout = ({ children }: ChildrenProps) => { 10 | return ( 11 | 12 | {({ reset }) => ( 13 | 14 | }> 15 |
{children}
16 |
17 |
18 | )} 19 |
20 | ); 21 | }; 22 | -------------------------------------------------------------------------------- /src/public/en/cv.json: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EgorPashko/react-ts-skeleton/072d4cac68ef213a4a78ef7b6fd4bec710f2cb79/src/public/en/cv.json -------------------------------------------------------------------------------- /src/public/en/index.js: -------------------------------------------------------------------------------- 1 | export { default as validation } from "./en/cv.json"; 2 | -------------------------------------------------------------------------------- /src/react-app-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | // eslint-disable-next-line @typescript-eslint/triple-slash-reference 3 | /// 4 | /// 5 | /// 6 | /// 7 | /// 8 | 9 | declare namespace NodeJS { 10 | interface ProcessEnv { 11 | readonly NODE_ENV: "development" | "production" | "test"; 12 | readonly PUBLIC_URL: string; 13 | } 14 | } 15 | 16 | declare module "*.avif" { 17 | const src: string; 18 | export default src; 19 | } 20 | 21 | declare module "*.bmp" { 22 | const src: string; 23 | export default src; 24 | } 25 | 26 | declare module "*.gif" { 27 | const src: string; 28 | export default src; 29 | } 30 | 31 | declare module "*.jpg" { 32 | const src: string; 33 | export default src; 34 | } 35 | 36 | declare module "*.jpeg" { 37 | const src: string; 38 | export default src; 39 | } 40 | 41 | declare module "*.png" { 42 | const src: string; 43 | export default src; 44 | } 45 | 46 | declare module "*.webp" { 47 | const src: string; 48 | export default src; 49 | } 50 | 51 | declare module "*.svg" { 52 | // eslint-disable-next-line no-restricted-imports 53 | import type * as React from "react"; 54 | 55 | export const ReactComponent: React.FunctionComponent & { title?: string }>; 56 | 57 | const src: string; 58 | export default src; 59 | } 60 | 61 | declare module "*.module.css" { 62 | const classes: { readonly [key: string]: string }; 63 | export default classes; 64 | } 65 | 66 | declare module "*.module.scss" { 67 | const classes: { readonly [key: string]: string }; 68 | export default classes; 69 | } 70 | 71 | declare module "*.module.sass" { 72 | const classes: { readonly [key: string]: string }; 73 | export default classes; 74 | } 75 | -------------------------------------------------------------------------------- /src/react-i18next.d.ts: -------------------------------------------------------------------------------- 1 | import "react-i18next"; 2 | 3 | // import type { i18n, StringMap, TFunctionKeys, TFunctionResult, TOptions } from "i18next"; 4 | import type * as translations from "locales"; 5 | 6 | declare module "react-i18next" { 7 | type DefaultResources = typeof translations["en"]; 8 | // eslint-disable-next-line @typescript-eslint/no-empty-interface 9 | interface Resources extends DefaultResources {} 10 | } 11 | 12 | // react-i18next versions higher than 11.11.0 13 | declare module "react-i18next" { 14 | interface CustomTypeOptions { 15 | defaultNS: typeof defaultNS; 16 | resources: typeof translations["en"]; 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/routes/IndexRedirect.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Navigate } from "react-router-dom"; 3 | 4 | import { NavigationUrls } from "./useNavigation"; 5 | 6 | const IndexRedirect = () => ; 7 | 8 | export default IndexRedirect; 9 | -------------------------------------------------------------------------------- /src/routes/PrivateRoute.tsx: -------------------------------------------------------------------------------- 1 | import { observer } from "mobx-react-lite"; 2 | import { useTranslation } from "react-i18next"; 3 | 4 | import { useStore } from "../stores/Store"; 5 | import type { RouteProps } from "./types"; 6 | 7 | export const PrivateRoute = observer(({ component: Component, layout: Layout }: RouteProps) => { 8 | const { user } = useStore(); 9 | const { t } = useTranslation("common"); 10 | 11 | if (!user) { 12 | return
{t("forbidden") as string}
; 13 | } 14 | 15 | return ( 16 | 17 | 18 | 19 | ); 20 | }); 21 | -------------------------------------------------------------------------------- /src/routes/index.tsx: -------------------------------------------------------------------------------- 1 | import { observer } from "mobx-react-lite"; 2 | import { Route, Routes as BaseRoutes } from "react-router-dom"; 3 | 4 | import { CV } from "../modules/CV"; 5 | import { MainLayout } from "../modules/layouts/MainLayout"; 6 | import IndexRedirect from "./IndexRedirect"; 7 | import { PrivateRoute } from "./PrivateRoute"; 8 | import { NavigationUrls } from "./useNavigation"; 9 | 10 | const Routes = observer(() => ( 11 | 12 | } path="/" /> 13 | } path={NavigationUrls.home} /> 14 | 15 | )); 16 | 17 | export default Routes; 18 | -------------------------------------------------------------------------------- /src/routes/types.ts: -------------------------------------------------------------------------------- 1 | import type { ReactNode } from "react"; 2 | 3 | export type ChildrenProps = { children?: ReactNode }; 4 | export type BasicComponent = (props: ChildrenProps) => JSX.Element; 5 | export type RouteProps = { 6 | component: BasicComponent; 7 | layout: BasicComponent; 8 | visible?: boolean; 9 | }; 10 | -------------------------------------------------------------------------------- /src/routes/useNavigation.ts: -------------------------------------------------------------------------------- 1 | import { useNavigate } from "react-router"; 2 | 3 | export enum NavigationUrls { 4 | "home" = "/home", 5 | } 6 | 7 | export const useNavigation = () => { 8 | const navigate = useNavigate(); 9 | 10 | return { 11 | goHome: () => navigate(NavigationUrls.home), 12 | }; 13 | }; 14 | -------------------------------------------------------------------------------- /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/stores/Store.ts: -------------------------------------------------------------------------------- 1 | import { cast, flow, types } from "mobx-state-tree"; 2 | 3 | import { api } from "../lib/api/api"; 4 | import type { User } from "../lib/api/models"; 5 | import { asyncSuspense } from "../lib/asyncSuspense"; 6 | import { sleep } from "../lib/sleep"; 7 | import { UserStore } from "./UserStore"; 8 | 9 | export const Store = types 10 | .model("Store", { 11 | user: types.maybeNull(UserStore), 12 | }) 13 | .actions((self) => ({ 14 | setUser: (user: User | undefined) => { 15 | self.user = cast(user); 16 | }, 17 | })) 18 | .actions((self) => ({ 19 | loadUser: flow(function* () { 20 | // imitate real work with BE 21 | yield sleep(1000); 22 | const user: User = yield api.getUserById(333); 23 | 24 | if (user) { 25 | self.setUser(user); 26 | } 27 | }), 28 | })); 29 | 30 | const store = Store.create({}); 31 | 32 | export const useStore = () => store; 33 | 34 | export const initializeStore = asyncSuspense(async () => { 35 | await store.loadUser(); 36 | 37 | return store; 38 | }, "store"); 39 | -------------------------------------------------------------------------------- /src/stores/UserStore.ts: -------------------------------------------------------------------------------- 1 | import { types } from "mobx-state-tree"; 2 | 3 | export const UserStore = types 4 | .model("User", { 5 | id: types.number, 6 | }) 7 | .views((self) => ({ 8 | get isAuthenticated() { 9 | return !!self.id; 10 | }, 11 | })); 12 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "noImplicitAny": true, 6 | "noImplicitThis": true, 7 | "strictNullChecks": true, 8 | "allowJs": true, 9 | "skipLibCheck": true, 10 | "esModuleInterop": true, 11 | "allowSyntheticDefaultImports": true, 12 | "strict": true, 13 | "forceConsistentCasingInFileNames": true, 14 | "noFallthroughCasesInSwitch": true, 15 | "module": "esnext", 16 | "moduleResolution": "node", 17 | "resolveJsonModule": true, 18 | "isolatedModules": true, 19 | "noEmit": true, 20 | "jsx": "react-jsx" 21 | }, 22 | "include": ["src", "public/locales", "src/modules/.eslintrc.js"] 23 | } 24 | --------------------------------------------------------------------------------