├── .env
├── .gitignore
├── .prettierrc.js
├── .run
├── generate-graphql-types-watch.run.xml
├── start-all.run.xml
├── start-backend.run.xml
└── start-frontend.run.xml
├── README.md
├── backend
├── schema
│ ├── _schema.gql
│ ├── dashboard.gql
│ └── user.gql
└── server
│ ├── index.mjs
│ ├── server-graphql.mjs
│ ├── server-rest.mjs
│ └── server-state.mjs
├── codegen.yml
├── craco.config.js
├── package.json
├── public
├── favicon.ico
├── index.html
├── logo192.png
├── logo512.png
├── manifest.json
└── robots.txt
├── src
├── app
│ ├── _common
│ │ ├── components
│ │ │ ├── full-page-fallback-progress
│ │ │ │ └── full-page-fallback-progress.tsx
│ │ │ ├── location-store-provider
│ │ │ │ └── location-store-provider.tsx
│ │ │ ├── page-layout
│ │ │ │ ├── page-layout.tsx
│ │ │ │ └── page-layout.view-store.tsx
│ │ │ └── theme
│ │ │ │ └── theme.tsx
│ │ ├── graphql
│ │ │ ├── graphql-base.data-store.ts
│ │ │ └── graphql-client.ts
│ │ ├── http
│ │ │ └── http-client.service.ts
│ │ ├── ioc
│ │ │ └── injection-token.ts
│ │ ├── navigation
│ │ │ ├── path-resolver.ts
│ │ │ └── root-paths.ts
│ │ └── stores
│ │ │ ├── app-toast.view-store.ts
│ │ │ ├── location.store.ts
│ │ │ └── theme.data-store.ts
│ ├── _components
│ │ └── app-toast
│ │ │ └── app-toast.tsx
│ ├── app.module.tsx
│ ├── dashboard
│ │ ├── _common
│ │ │ └── navigation
│ │ │ │ └── dashboard.paths.ts
│ │ ├── _components
│ │ │ └── dashboard-page
│ │ │ │ └── dashboard-page.tsx
│ │ └── dashboard.module.tsx
│ └── users
│ │ ├── _common
│ │ ├── navigation
│ │ │ └── users.paths.ts
│ │ ├── remote-api
│ │ │ ├── jto
│ │ │ │ └── users.jto.ts
│ │ │ └── users.http-service.ts
│ │ └── stores
│ │ │ ├── users.data-store.ts
│ │ │ └── users.queries.ts
│ │ ├── _components
│ │ └── user-modal
│ │ │ ├── user-modal.tsx
│ │ │ └── user-modal.view-store.ts
│ │ ├── user-details
│ │ ├── user-details.tsx
│ │ └── user-details.view-store.ts
│ │ ├── users-list
│ │ ├── users-list.tsx
│ │ └── users-list.view-store.ts
│ │ └── users.module.tsx
├── browser.module.tsx
├── generated
│ └── graphql.d.ts
├── index.tsx
├── react-app-env.d.ts
├── reportWebVitals.ts
└── setupTests.ts
├── tsconfig.json
├── tsconfig.paths.json
└── yarn.lock
/.env:
--------------------------------------------------------------------------------
1 | FAST_REFRESH=false
--------------------------------------------------------------------------------
/.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 | #ide
15 | .idea
16 |
17 | # misc
18 | .DS_Store
19 | .env.local
20 | .env.development.local
21 | .env.test.local
22 | .env.production.local
23 |
24 | npm-debug.log*
25 | yarn-debug.log*
26 | yarn-error.log*
27 |
--------------------------------------------------------------------------------
/.prettierrc.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | semi: true,
3 | trailingComma: 'all',
4 | singleQuote: true,
5 | printWidth: 80,
6 | endOfLine: 'auto',
7 | };
8 |
--------------------------------------------------------------------------------
/.run/generate-graphql-types-watch.run.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
--------------------------------------------------------------------------------
/.run/start-all.run.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
--------------------------------------------------------------------------------
/.run/start-backend.run.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
--------------------------------------------------------------------------------
/.run/start-frontend.run.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Getting Started with Create React App
2 |
3 | This project was bootstrapped with [Create React App](https://github.com/facebook/create-react-app).
4 |
5 | ## Available Scripts
6 |
7 | In the project directory, you can run:
8 |
9 | ### `yarn start-all`
10 |
11 | Runs the app in the development mode.\
12 | Open [http://localhost:3000](http://localhost:3000) to view it in the browser.
13 |
14 | The page will reload if you make edits.\
15 | You will also see any lint errors in the console.
16 |
--------------------------------------------------------------------------------
/backend/schema/_schema.gql:
--------------------------------------------------------------------------------
1 | type Query {
2 | me: User!
3 | dashboard: Dashboard!
4 | allUsers: [User!]!
5 | allDashboards: [Dashboard!]!
6 | }
7 |
8 | type Mutation {
9 | createUser(firstName: String!, lastName: String!, email: String!): User!
10 | deleteUsers(ids: [ID!]!): Boolean
11 | }
12 |
--------------------------------------------------------------------------------
/backend/schema/dashboard.gql:
--------------------------------------------------------------------------------
1 | type Dashboard {
2 | id: ID!
3 | name: String!
4 | owner: User!
5 | }
--------------------------------------------------------------------------------
/backend/schema/user.gql:
--------------------------------------------------------------------------------
1 | type User {
2 | id: ID!
3 | firstName: String!
4 | lastName: String!
5 | email: String!
6 | }
7 |
--------------------------------------------------------------------------------
/backend/server/index.mjs:
--------------------------------------------------------------------------------
1 | import './server-graphql.mjs'
2 | import './server-rest.mjs'
3 |
4 |
5 |
6 |
7 |
8 |
--------------------------------------------------------------------------------
/backend/server/server-graphql.mjs:
--------------------------------------------------------------------------------
1 | import { ApolloServer } from "apollo-server";
2 | import { fileLoader, mergeTypes } from "merge-graphql-schemas";
3 | import { resolve } from "path";
4 | import { serverState } from "./server-state.mjs";
5 |
6 | const typeDefs = mergeTypes(fileLoader(resolve('./backend/schema')));
7 |
8 | const resolvers = {
9 | Query: {
10 | me: () => serverState.users[0],
11 | dashboard: () => serverState.dashboards[0],
12 | allUsers: () => serverState.users,
13 | allDashboards: () => serverState.dashboards,
14 | },
15 | Mutation: {
16 | createUser: (parent, user) => {
17 | const newUser = { ...user, id: serverState.nextUserId() };
18 | serverState.users.push(newUser);
19 | return newUser;
20 | },
21 | deleteUsers: (parent, { ids }) => {
22 | serverState.users = serverState.users.filter((user) => !ids.includes(user.id.toString()));
23 | return true;
24 | },
25 | },
26 | Dashboard: {
27 | owner: (parent) => serverState.users.find((user) => user.id === parent.ownedBy),
28 | },
29 | };
30 |
31 | const server = new ApolloServer({
32 | typeDefs,
33 | resolvers,
34 | });
35 |
36 | server.listen().then(({ url }) => {
37 | console.log(`🚀 GraphQL Server ready at ${url}`);
38 | });
--------------------------------------------------------------------------------
/backend/server/server-rest.mjs:
--------------------------------------------------------------------------------
1 | import express from 'express';
2 | import bodyParser from 'body-parser';
3 | import { serverState } from "./server-state.mjs";
4 |
5 | const app = express();
6 | app.use(bodyParser.urlencoded({ extended: false }));
7 | app.use(bodyParser.json());
8 | const expressPort = 4001;
9 |
10 | app.get('/api/users', (req, res) => {
11 | res.json(serverState.users);
12 | });
13 |
14 | app.post('/api/users', (req, res) => {
15 | const newUser = {...req.body, id: serverState.nextUserId() };
16 | serverState.users.push(newUser);
17 | res.json(newUser);
18 | });
19 |
20 | app.put('/api/users/{id}', (req, res) => {
21 | const userId = req.params.id;
22 | const user = serverState.users.find(u=>u.id === userId);
23 | if (user) {
24 | Object.assign(user, req.body);
25 | res.json(user);
26 | } else {
27 | res.sendStatus(404);
28 | }
29 | });
30 |
31 | app.delete('/api/users', (req, res) => {
32 | const ids = req.query.ids?.split(',');
33 | serverState.users = serverState.users.filter((user) => !ids.includes(user.id.toString()));
34 | res.sendStatus(200);
35 | });
36 |
37 | app.listen(expressPort, () => {
38 | console.log(`🚀 REST Server ready at http://localhost:${expressPort}`);
39 | });
40 |
--------------------------------------------------------------------------------
/backend/server/server-state.mjs:
--------------------------------------------------------------------------------
1 |
2 | export const serverState = {
3 | users: [
4 | { id: '1', firstName: 'John', lastName: 'Doe', email: 'john.doe@foo.local' },
5 | { id: '2', firstName: 'Mary', lastName: 'Smith', email: 'mary.smith@foo.local' },
6 | ],
7 |
8 | dashboards: [
9 | {
10 | id: '1',
11 | ownedBy: '1',
12 | name: 'Home Dashboard',
13 | },
14 | {
15 | id: '2',
16 | ownedBy: '2',
17 | name: 'Home Dashboard',
18 | },
19 | ],
20 |
21 | nextUserId() {
22 | return `${Math.max(...this.users.map(u => parseInt(u.id))) + 1}`;
23 | }
24 | }
--------------------------------------------------------------------------------
/codegen.yml:
--------------------------------------------------------------------------------
1 | schema: ./backend/schema/**.gql
2 | documents: ./src/**/*.ts
3 | generates:
4 | ./src/generated/graphql.d.ts:
5 | plugins:
6 | - typescript
7 | - typescript-operations
--------------------------------------------------------------------------------
/craco.config.js:
--------------------------------------------------------------------------------
1 | // eslint-disable-next-line @typescript-eslint/no-var-requires
2 | const path = require('path');
3 |
4 | module.exports = {
5 | webpack: {
6 | alias: {
7 | '@': path.resolve(__dirname, 'src'),
8 | },
9 | },
10 | devServer: (devServerConfig) => {
11 | return {
12 | ...devServerConfig,
13 | proxy: {
14 | '/api': 'http://localhost:4001',
15 | },
16 | };
17 | },
18 | };
19 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "mobx-in-react-scalable-state-management",
3 | "version": "0.1.0",
4 | "private": true,
5 | "dependencies": {
6 | "@apollo/client": "^3.5.8",
7 | "@craco/craco": "^6.4.3",
8 | "@emotion/react": "^11.7.1",
9 | "@emotion/styled": "^11.6.0",
10 | "@material-ui/core": "^4.12.3",
11 | "@mui/icons-material": "^5.3.1",
12 | "@mui/material": "^5.4.0",
13 | "@mui/styles": "^5.3.0",
14 | "@mui/x-data-grid": "^5.4.0",
15 | "@testing-library/jest-dom": "^5.16.1",
16 | "@testing-library/react": "^12.1.2",
17 | "@testing-library/user-event": "^13.5.0",
18 | "@types/jest": "^27.4.0",
19 | "@types/lodash": "^4.14.178",
20 | "@types/node": "^17.0.14",
21 | "@types/react": "^17.0.38",
22 | "@types/react-dom": "^17.0.11",
23 | "apollo-client-preset": "^1.0.8",
24 | "apollo-server": "^3.6.2",
25 | "axios": "^0.25.0",
26 | "body-parser": "^1.19.1",
27 | "graphql": "^15.7.2",
28 | "lodash": "^4.17.21",
29 | "merge-graphql-schemas": "^1.7.8",
30 | "mobx": "^6.3.13",
31 | "mobx-persist-store": "^1.0.4",
32 | "mobx-react-lite": "^3.2.3",
33 | "mobx-remotedev": "^0.3.6",
34 | "qs": "^6.10.3",
35 | "querystring": "^0.2.1",
36 | "react": "^17.0.2",
37 | "react-dom": "^17.0.2",
38 | "react-hook-form": "^7.27.0",
39 | "react-ioc": "^1.0.0",
40 | "react-router-dom": "^6.2.1",
41 | "react-scripts": "5.0.0",
42 | "type-fest": "^2.11.1",
43 | "typescript": "^4.5.5",
44 | "web-vitals": "^2.1.4"
45 | },
46 | "scripts": {
47 | "start-all": "concurrently --kill-others \"npm run start-frontend\" \"npm run start-backend\" \"npm run generate-graphql-types-watch\"",
48 | "start-backend": "nodemon --watch ./backend/server backend/server/index.mjs",
49 | "start-frontend": "craco start",
50 | "build": "craco build",
51 | "test": "craco test",
52 | "eject": "react-scripts eject",
53 | "lint": "eslint \"**/*.{js,ts,tsx}\" --max-warnings=0",
54 | "lint:fix": "eslint \"**/*.{js,ts,tsx}\" --fix",
55 | "generate-graphql-types": "graphql-codegen",
56 | "generate-graphql-types-watch": "graphql-codegen --watch",
57 | "prettier": "prettier --write \"src/**/*.{js,jsx,ts,tsx,json,css,scss,md}\""
58 | },
59 | "eslintConfig": {
60 | "extends": [
61 | "react-app",
62 | "react-app/jest",
63 | "plugin:@typescript-eslint/recommended",
64 | "plugin:prettier/recommended"
65 | ],
66 | "rules": {
67 | "no-console": "warn",
68 | "@typescript-eslint/explicit-module-boundary-types": "off",
69 | "@typescript-eslint/ban-ts-comment": "off",
70 | "@typescript-eslint/no-empty-interface": "off",
71 | "prettier/prettier": "off"
72 | }
73 | },
74 | "browserslist": {
75 | "production": [
76 | ">0.2%",
77 | "not dead",
78 | "not op_mini all"
79 | ],
80 | "development": [
81 | "last 1 chrome version",
82 | "last 1 firefox version",
83 | "last 1 safari version"
84 | ]
85 | },
86 | "devDependencies": {
87 | "@graphql-codegen/cli": "^2.4.0",
88 | "@graphql-codegen/typescript": "^2.4.2",
89 | "@graphql-codegen/typescript-operations": "^2.2.3",
90 | "@types/qs": "^6.9.7",
91 | "@types/react-router-dom": "^5.3.3",
92 | "concurrently": "^7.0.0",
93 | "eslint-config-prettier": "^8.3.0",
94 | "eslint-plugin-prettier": "^4.0.0",
95 | "express": "^4.17.2",
96 | "nodemon": "^2.0.15",
97 | "prettier": "^2.5.1"
98 | }
99 | }
100 |
--------------------------------------------------------------------------------
/public/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/codilime/mobx-in-react-scalable-state-management/cf7a3ac37afda95dcf803843b62b87229d2ec447/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/logo192.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/codilime/mobx-in-react-scalable-state-management/cf7a3ac37afda95dcf803843b62b87229d2ec447/public/logo192.png
--------------------------------------------------------------------------------
/public/logo512.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/codilime/mobx-in-react-scalable-state-management/cf7a3ac37afda95dcf803843b62b87229d2ec447/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/_common/components/full-page-fallback-progress/full-page-fallback-progress.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { CircularProgress, Container } from '@mui/material';
3 |
4 | export const FullPageFallbackProgress = () => (
5 |
14 |
15 |
16 | );
17 |
--------------------------------------------------------------------------------
/src/app/_common/components/location-store-provider/location-store-provider.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { provider, useInstance } from 'react-ioc';
3 | import { observer } from 'mobx-react-lite';
4 | import { InjectionToken } from '@/app/_common/ioc/injection-token';
5 | import {
6 | LocationStore,
7 | useSyncLocationStore,
8 | } from '@/app/_common/stores/location.store';
9 |
10 | export function withLocationStoreProviderHOC(
11 | locationStoreToken: InjectionToken,
12 | Component: React.ComponentType,
13 | ) {
14 | return provider(
15 | [locationStoreToken, LocationStore], // provide LocationStore by InjectionToken
16 | //
17 | )(
18 | observer(() => {
19 | const locationStore = useInstance(locationStoreToken);
20 | useSyncLocationStore(locationStore); // sync LocationStore using useLocation() and useRouteMatch()
21 | return ;
22 | }),
23 | );
24 | }
25 |
--------------------------------------------------------------------------------
/src/app/_common/components/page-layout/page-layout.tsx:
--------------------------------------------------------------------------------
1 | import React, { useCallback } from 'react';
2 | import Box from '@mui/material/Box';
3 | import Drawer from '@mui/material/Drawer';
4 | import List from '@mui/material/List';
5 | import ListItem from '@mui/material/ListItem';
6 | import ListItemIcon from '@mui/material/ListItemIcon';
7 | import ListItemText from '@mui/material/ListItemText';
8 | import Toolbar from '@mui/material/Toolbar';
9 | import Typography from '@mui/material/Typography';
10 | import IconButton from '@mui/material/IconButton';
11 | import MenuIcon from '@mui/icons-material/Menu';
12 | import AppBar from '@mui/material/AppBar';
13 | import Brightness7Icon from '@mui/icons-material/Brightness7';
14 | import Brightness4Icon from '@mui/icons-material/Brightness4';
15 | import { observer } from 'mobx-react-lite';
16 | import { provider, useInstance } from 'react-ioc';
17 | import { Dashboard, Person } from '@mui/icons-material';
18 | import { PageLayoutViewStore } from './page-layout.view-store';
19 | import { useNavigate } from 'react-router-dom';
20 | import { RootPaths } from '@/app/_common/navigation/root-paths';
21 |
22 | interface PageLayoutProps {
23 | title: React.ReactNode;
24 | }
25 |
26 | export const PageLayout: React.FC = provider(
27 | PageLayoutViewStore,
28 | //
29 | )(
30 | observer(({ title, children }) => {
31 | const store = useInstance(PageLayoutViewStore);
32 | const navigate = useNavigate();
33 |
34 | const goToDashboard = useCallback(
35 | () => navigate(RootPaths.DASHBOARD),
36 | [navigate],
37 | );
38 | const goToUsers = useCallback(() => navigate(RootPaths.USERS), [navigate]);
39 |
40 | return (
41 |
42 |
43 |
44 |
45 |
53 |
54 |
55 |
56 | {title}
57 |
58 |
63 | {store.theme === 'dark' ? (
64 |
65 | ) : (
66 |
67 | )}
68 |
69 |
70 |
71 | {children}
72 |
73 |
78 |
83 |
84 |
85 |
86 |
87 |
88 |
89 |
90 |
91 |
92 |
93 |
94 |
95 |
96 |
97 |
98 |
99 |
100 | );
101 | }),
102 | );
103 |
--------------------------------------------------------------------------------
/src/app/_common/components/page-layout/page-layout.view-store.tsx:
--------------------------------------------------------------------------------
1 | import { makeAutoObservable } from 'mobx';
2 | import { ThemeDataStore } from '@/app/_common/stores/theme.data-store';
3 | import { inject } from 'react-ioc';
4 |
5 | export class PageLayoutViewStore {
6 | private themeDataStore = inject(this, ThemeDataStore);
7 |
8 | private state: State = {
9 | drawerOpened: false,
10 | };
11 |
12 | constructor() {
13 | makeAutoObservable(this, undefined, { autoBind: true });
14 | }
15 |
16 | get drawerOpened() {
17 | return this.state.drawerOpened;
18 | }
19 |
20 | get theme() {
21 | return this.themeDataStore.theme;
22 | }
23 |
24 | openDrawer() {
25 | this.state.drawerOpened = true;
26 | }
27 |
28 | closeDrawer() {
29 | this.state.drawerOpened = false;
30 | }
31 |
32 | toggleTheme() {
33 | this.themeDataStore.toggle();
34 | }
35 | }
36 |
37 | interface State {
38 | drawerOpened: boolean;
39 | }
40 |
--------------------------------------------------------------------------------
/src/app/_common/components/theme/theme.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { useInstance } from 'react-ioc';
3 | import { observer } from 'mobx-react-lite';
4 | import { createTheme, CssBaseline, ThemeProvider } from '@mui/material';
5 | import darkScrollbar from '@mui/material/darkScrollbar';
6 | import { ThemeDataStore } from '@/app/_common/stores/theme.data-store';
7 |
8 | export const Theme: React.FC = observer(({ children }) => {
9 | const themeStore = useInstance(ThemeDataStore);
10 | return (
11 |
12 |
13 | {children}
14 |
15 | );
16 | });
17 |
18 | const darkTheme = createTheme({
19 | palette: {
20 | mode: 'dark',
21 | },
22 | components: {
23 | MuiCssBaseline: {
24 | styleOverrides: {
25 | body: darkScrollbar(),
26 | },
27 | },
28 | },
29 | });
30 |
31 | const lightTheme = createTheme({
32 | palette: {
33 | mode: 'light',
34 | },
35 | });
36 |
--------------------------------------------------------------------------------
/src/app/_common/graphql/graphql-base.data-store.ts:
--------------------------------------------------------------------------------
1 | import { isEmpty, isNil } from 'lodash';
2 | import remotedev from 'mobx-remotedev';
3 | import { inject } from 'react-ioc';
4 | import { GraphqlClient } from '@/app/_common/graphql/graphql-client';
5 | import { action, computed, makeObservable, observable } from 'mobx';
6 | import {
7 | ApolloCurrentResult,
8 | MutationOptions,
9 | ObservableQuery,
10 | WatchQueryOptions,
11 | } from 'apollo-client';
12 |
13 | export class GraphqlBaseDataStore {
14 | private readonly client!: GraphqlClient;
15 | private queryWatcher?: ObservableQuery;
16 | private subscription?: ZenObservable.Subscription;
17 |
18 | private result: Result = {
19 | loading: false,
20 | error: undefined,
21 | data: undefined,
22 | };
23 |
24 | constructor() {
25 | // Do not show client in Redux DevTools
26 | Object.defineProperty(this, 'client', {
27 | value: inject(this, GraphqlClient),
28 | enumerable: false,
29 | });
30 |
31 | makeObservable(
32 | this,
33 | {
34 | loading: computed,
35 | error: computed,
36 | data: computed,
37 | // @ts-ignore - for protected/private fields
38 | result: observable,
39 | query: action,
40 | onSuccess: action,
41 | onFailure: action,
42 | },
43 | { autoBind: true },
44 | );
45 | remotedev(this, { name: this.constructor.name });
46 | }
47 |
48 | get loading() {
49 | return this.result.loading;
50 | }
51 |
52 | get error() {
53 | return this.result.error;
54 | }
55 |
56 | get data() {
57 | return this.result.data;
58 | }
59 |
60 | protected query(queryOptions: WatchQueryOptions) {
61 | this.subscription?.unsubscribe();
62 |
63 | // Do not show queryWatcher in Redux DevTools
64 | Object.defineProperty(this, 'queryWatcher', {
65 | value: this.client.watchQuery(queryOptions),
66 | enumerable: false,
67 | });
68 | assertValue(this.queryWatcher);
69 |
70 | const currentResult = observable(this.queryWatcher.currentResult());
71 | this.result.loading = currentResult.loading;
72 | this.result.error = currentResult.error;
73 | this.result.data = isEmpty(currentResult.data)
74 | ? undefined
75 | : (currentResult.data as QUERY_RESULT);
76 |
77 | // Do not show subscription in Redux DevTools
78 | Object.defineProperty(this, 'subscription', {
79 | value: this.queryWatcher.subscribe({
80 | next: (value) => this.onSuccess(value.data, value.loading),
81 | error: (error) => this.onFailure(error),
82 | }),
83 | enumerable: false,
84 | });
85 | }
86 |
87 | protected async mutate(
88 | options: MutationOptions,
89 | ) {
90 | return await this.client.mutate(options);
91 | }
92 |
93 | private onSuccess(data: QUERY_RESULT, loading: boolean) {
94 | this.result.error = undefined;
95 | this.result.loading = loading;
96 | this.result.data = data;
97 | }
98 |
99 | private onFailure(error: Result['error']) {
100 | this.result.error = error;
101 | this.result.loading = false;
102 | this.result.data = undefined;
103 | }
104 |
105 | dispose() {
106 | this.subscription?.unsubscribe();
107 | }
108 | }
109 |
110 | interface Result {
111 | loading: boolean;
112 | error?: ApolloCurrentResult['error'];
113 | data?: QUERY_RESULT;
114 | }
115 |
116 | function assertValue(value: unknown): asserts value {
117 | if (isNil(value)) throw new Error('Value is invalid');
118 | }
119 |
--------------------------------------------------------------------------------
/src/app/_common/graphql/graphql-client.ts:
--------------------------------------------------------------------------------
1 | import { ApolloClient, HttpLink, InMemoryCache } from 'apollo-client-preset';
2 |
3 | export class GraphqlClient extends ApolloClient> {
4 | constructor() {
5 | super({
6 | link: new HttpLink({ uri: 'http://localhost:4000/graphql' }),
7 | cache: new InMemoryCache(),
8 | });
9 | }
10 | }
11 |
--------------------------------------------------------------------------------
/src/app/_common/http/http-client.service.ts:
--------------------------------------------------------------------------------
1 | import axios from 'axios';
2 |
3 | export class HttpClientService {
4 | // You may want to configure interceptors here for Authorization: "Bearer ..."
5 | private axiosInstance = axios.create({ baseURL: '/api' });
6 |
7 | async get(path: string, request?: REQ): Promise {
8 | return this.axiosInstance
9 | .get(path, { params: request })
10 | .then((r) => r.data);
11 | }
12 |
13 | async post(path: string, request: REQ): Promise {
14 | return this.axiosInstance.post(path, request).then((r) => r.data);
15 | }
16 |
17 | async delete(path: string): Promise {
18 | return this.axiosInstance.delete(path).then((r) => r.data);
19 | }
20 |
21 | //... other HTTP methods
22 | }
23 |
--------------------------------------------------------------------------------
/src/app/_common/ioc/injection-token.ts:
--------------------------------------------------------------------------------
1 | import { Class } from 'type-fest';
2 |
3 | const ALL_TOKENS: Record = {};
4 |
5 | export type InjectionToken = Class;
6 |
7 | export function createInjectionToken(tokenName: string): Class {
8 | if (!tokenName) {
9 | throw new Error('Please provide token name for easier debug.');
10 | }
11 | if (ALL_TOKENS[tokenName]) {
12 | throw new Error(
13 | `Token with name ${tokenName} has been already created. Please use unique names.`,
14 | );
15 | }
16 | ALL_TOKENS[tokenName] = true;
17 | // @ts-ignore
18 | // eslint-disable-next-line @typescript-eslint/no-empty-function
19 | const token = (): T => {}; // token must be a function to proper working of `inject(this, TOKEN)` (react-ioc limitation)
20 | token.displayName = tokenName;
21 | // @ts-ignore
22 | return token;
23 | }
24 |
--------------------------------------------------------------------------------
/src/app/_common/navigation/path-resolver.ts:
--------------------------------------------------------------------------------
1 | import qs from 'qs';
2 | import { generatePath } from 'react-router-dom';
3 | import { LocationProps } from '@/app/_common/stores/location.store';
4 |
5 | export const pathResolver = (
6 | parentPath = '',
7 | ) => {
8 | return ({
9 | path,
10 | params,
11 | search,
12 | hash,
13 | }: { path: ROUTE_PROPS_MAPPING['path'] } & Partial) => {
14 | // @ts-ignore
15 | const generatedPath = params ? generatePath(path || '', params) : path;
16 | const searchString = search
17 | ? qs.stringify(search, { addQueryPrefix: true })
18 | : '';
19 | const hashString = hash ? '#' + qs.stringify(hash) : '';
20 | return `${parentPath}${generatedPath}${searchString}${hashString}`;
21 | };
22 | };
23 |
24 | interface RouteProps extends LocationProps {
25 | path: string;
26 | }
27 |
--------------------------------------------------------------------------------
/src/app/_common/navigation/root-paths.ts:
--------------------------------------------------------------------------------
1 | export enum RootPaths {
2 | DASHBOARD = '/',
3 | USERS = '/users',
4 | }
5 |
6 | export function moduleRootPath(rootPath: RootPaths) {
7 | return rootPath + '/*';
8 | }
9 |
--------------------------------------------------------------------------------
/src/app/_common/stores/app-toast.view-store.ts:
--------------------------------------------------------------------------------
1 | import { makeAutoObservable } from 'mobx';
2 | import { AlertColor } from '@mui/material/Alert/Alert';
3 |
4 | export class AppToastViewStore {
5 | private state: State = {
6 | opened: false,
7 | message: '',
8 | severity: 'info',
9 | };
10 |
11 | constructor() {
12 | makeAutoObservable(this, undefined, { autoBind: true });
13 | }
14 |
15 | get opened() {
16 | return this.state.opened;
17 | }
18 |
19 | get message() {
20 | return this.state.message;
21 | }
22 |
23 | get severity() {
24 | return this.state.severity;
25 | }
26 |
27 | open(message: State['message'], severity: State['severity'] = 'info') {
28 | this.state.opened = true;
29 | this.state.message = message;
30 | this.state.severity = severity;
31 | }
32 |
33 | close() {
34 | this.state.opened = false;
35 | }
36 | }
37 |
38 | interface State {
39 | opened: boolean;
40 | message: string;
41 | severity: AlertColor;
42 | }
43 |
--------------------------------------------------------------------------------
/src/app/_common/stores/location.store.ts:
--------------------------------------------------------------------------------
1 | import qs from 'qs';
2 | import { useCallback, useEffect, useRef } from 'react';
3 | import { makeAutoObservable } from 'mobx';
4 | import { useLocation, useParams } from 'react-router-dom';
5 |
6 | export class LocationStore {
7 | private state: State = {
8 | params: {},
9 | pathname: '',
10 | search: '',
11 | hash: '',
12 | };
13 |
14 | constructor() {
15 | makeAutoObservable(this, undefined, { autoBind: true });
16 | }
17 |
18 | setState({ hash, params, pathname, search }: State) {
19 | this.state.params = params as PROPS['params'];
20 | this.state.pathname = pathname;
21 | this.state.search = search;
22 | this.state.hash = hash;
23 | }
24 |
25 | get params(): PROPS['params'] {
26 | return this.state.params;
27 | }
28 |
29 | get search(): PROPS['search'] {
30 | return qs.parse(this.state.search || '', {
31 | ignoreQueryPrefix: true,
32 | }) as PROPS['search'];
33 | }
34 |
35 | get hash(): PROPS['hash'] {
36 | return qs.parse((this.state.hash || '').replace(/^#/, '')) as PROPS['hash'];
37 | }
38 | }
39 |
40 | /**
41 | * Seamless synchronization of useLocation() and useRouteMatch() with LocationStore state
42 | */
43 | export const useSyncLocationStore = (store: LocationStore) => {
44 | const { pathname, search, hash } = useLocation();
45 | const params = useParams();
46 | const firstTime = useRef(true);
47 |
48 | const sync = useCallback(() => {
49 | store.setState({ params, pathname, search, hash });
50 | }, [hash, pathname, params, search, store]);
51 |
52 | useEffect(() => {
53 | if (!firstTime.current) {
54 | sync();
55 | }
56 | }, [sync]);
57 |
58 | if (firstTime.current) {
59 | sync();
60 | firstTime.current = false;
61 | }
62 | };
63 |
64 | export interface LocationProps {
65 | params?: AnyObject;
66 | hash?: AnyObject;
67 | search?: AnyObject;
68 | }
69 |
70 | interface State {
71 | params: PROPS['params'];
72 | pathname: string;
73 | search: string;
74 | hash: string;
75 | }
76 |
77 | type AnyObject = Record;
78 |
--------------------------------------------------------------------------------
/src/app/_common/stores/theme.data-store.ts:
--------------------------------------------------------------------------------
1 | import { makeAutoObservable } from 'mobx';
2 | import { makePersistable, stopPersisting } from 'mobx-persist-store';
3 |
4 | export class ThemeDataStore {
5 | private state: State = {
6 | theme: 'dark',
7 | };
8 |
9 | constructor() {
10 | makeAutoObservable(this);
11 | makePersistable(this.state, {
12 | name: 'ThemeDataStore',
13 | properties: ['theme'],
14 | storage: window.localStorage,
15 | });
16 | }
17 |
18 | get theme() {
19 | return this.state.theme;
20 | }
21 |
22 | setTheme(value: ThemeValue) {
23 | this.state.theme = value;
24 | }
25 |
26 | toggle() {
27 | this.setTheme(this.theme === 'dark' ? 'light' : 'dark');
28 | }
29 |
30 | dispose() {
31 | stopPersisting(this.state);
32 | }
33 | }
34 |
35 | type ThemeValue = 'dark' | 'light';
36 |
37 | interface State {
38 | theme: ThemeValue;
39 | }
40 |
--------------------------------------------------------------------------------
/src/app/_components/app-toast/app-toast.tsx:
--------------------------------------------------------------------------------
1 | import { useInstance } from 'react-ioc';
2 | import { observer } from 'mobx-react-lite';
3 | import { Alert, Snackbar } from '@mui/material';
4 | import { AppToastViewStore } from '@/app/_common/stores/app-toast.view-store';
5 |
6 | export const AppToast = observer(() => {
7 | const store = useInstance(AppToastViewStore);
8 |
9 | return (
10 |
11 |
16 | {store.message}
17 |
18 |
19 | );
20 | });
21 |
--------------------------------------------------------------------------------
/src/app/app.module.tsx:
--------------------------------------------------------------------------------
1 | import React, { lazy, Suspense } from 'react';
2 | import { provider } from 'react-ioc';
3 | import { BrowserRouter as Router, Route, Routes } from 'react-router-dom';
4 | import { FullPageFallbackProgress } from '@/app/_common/components/full-page-fallback-progress/full-page-fallback-progress';
5 |
6 | const DashboardModule = lazy(() => import('./dashboard/dashboard.module'));
7 | const UsersModule = lazy(() => import('./users/users.module'));
8 |
9 | export const AppModule = provider()(() => {
10 | return (
11 |
12 | }>
13 |
14 | } />
15 | } />
16 |
17 |
18 |
19 | );
20 | });
21 |
--------------------------------------------------------------------------------
/src/app/dashboard/_common/navigation/dashboard.paths.ts:
--------------------------------------------------------------------------------
1 | import { RootPaths } from '@/app/_common/navigation/root-paths';
2 | import { pathResolver } from '@/app/_common/navigation/path-resolver';
3 |
4 | export enum DashboardPath {
5 | MAIN = '',
6 | }
7 |
8 | export const toDashboardPath = pathResolver(RootPaths.DASHBOARD);
9 |
10 | type PathMain = {
11 | path: DashboardPath.MAIN;
12 | };
13 |
14 | type Paths = PathMain;
15 |
--------------------------------------------------------------------------------
/src/app/dashboard/_components/dashboard-page/dashboard-page.tsx:
--------------------------------------------------------------------------------
1 | import Box from '@mui/material/Box';
2 | import { PageLayout } from '@/app/_common/components/page-layout/page-layout';
3 |
4 | export const DashboardPage = () => {
5 | return (
6 |
7 | My dashboard
8 |
9 | );
10 | };
11 |
--------------------------------------------------------------------------------
/src/app/dashboard/dashboard.module.tsx:
--------------------------------------------------------------------------------
1 | import { provider } from 'react-ioc';
2 | import { DashboardPage } from './_components/dashboard-page/dashboard-page';
3 | import { observer } from 'mobx-react-lite';
4 |
5 | const DashboardModule = provider()(
6 | //
7 | // Services available only within DashboardModule should be provided here
8 | //
9 | observer(() => {
10 | // Dashboard routes should be provided here
11 | return ;
12 | }),
13 | );
14 | export default DashboardModule;
15 |
--------------------------------------------------------------------------------
/src/app/users/_common/navigation/users.paths.ts:
--------------------------------------------------------------------------------
1 | import { RootPaths } from '@/app/_common/navigation/root-paths';
2 | import { pathResolver } from '@/app/_common/navigation/path-resolver';
3 | import { createInjectionToken } from '@/app/_common/ioc/injection-token';
4 | import { LocationStore } from '@/app/_common/stores/location.store';
5 |
6 | export enum UsersPath {
7 | MAIN = '',
8 | DETAILS = '/:id',
9 | }
10 |
11 | export const toUsersPath = pathResolver(RootPaths.USERS);
12 |
13 | export const UserDetailsLocationStore = createInjectionToken<
14 | LocationStore
15 | >('UserDetailsLocationStore');
16 |
17 | type PathMain = {
18 | path: UsersPath.MAIN;
19 | };
20 |
21 | type PathDetails = {
22 | path: UsersPath.DETAILS;
23 | params: { id: string };
24 | };
25 |
26 | type Paths = PathMain | PathDetails;
27 |
--------------------------------------------------------------------------------
/src/app/users/_common/remote-api/jto/users.jto.ts:
--------------------------------------------------------------------------------
1 | import { User } from '@/generated/graphql';
2 |
3 | export type UserJTO = Omit;
4 |
5 | export type GetUsersResponseJTO = Array;
6 |
7 | export type PostUserRequestJTO = Omit;
8 | export type PostUserResponseJTO = UserJTO;
9 |
10 | export type DeleteUsersRequestJTO = { ids: Array };
11 |
--------------------------------------------------------------------------------
/src/app/users/_common/remote-api/users.http-service.ts:
--------------------------------------------------------------------------------
1 | import { HttpClientService } from '@/app/_common/http/http-client.service';
2 | import {
3 | DeleteUsersRequestJTO,
4 | GetUsersResponseJTO,
5 | PostUserRequestJTO,
6 | PostUserResponseJTO,
7 | } from '@/app/users/_common/remote-api/jto/users.jto';
8 |
9 | export class UsersHttpService {
10 | private httpClient = new HttpClientService();
11 |
12 | async getUsers() {
13 | return this.httpClient.get('/users');
14 | }
15 |
16 | async postUser(user: PostUserRequestJTO) {
17 | return this.httpClient.post('/users', user);
18 | }
19 |
20 | async deleteUsers({ ids }: DeleteUsersRequestJTO) {
21 | return this.httpClient.delete(`/users?ids=${ids.join(',')}`);
22 | }
23 | }
24 |
--------------------------------------------------------------------------------
/src/app/users/_common/stores/users.data-store.ts:
--------------------------------------------------------------------------------
1 | import { inject } from 'react-ioc';
2 | import { makeAutoObservable, runInAction } from 'mobx';
3 | import { UsersHttpService } from '@/app/users/_common/remote-api/users.http-service';
4 | import {
5 | DeleteUsersRequestJTO,
6 | GetUsersResponseJTO,
7 | PostUserRequestJTO,
8 | } from '@/app/users/_common/remote-api/jto/users.jto';
9 | import { User } from '@/generated/graphql';
10 |
11 | export class UsersDataStore {
12 | private usersHttpService = inject(this, UsersHttpService);
13 |
14 | private state: State = {
15 | data: [] as GetUsersResponseJTO,
16 | loading: false,
17 | };
18 |
19 | constructor() {
20 | makeAutoObservable(this, undefined, { autoBind: true });
21 | this.read(); // fetch data once the UsersDataStore has been created
22 | }
23 |
24 | get users() {
25 | return this.state.data;
26 | }
27 |
28 | get loading() {
29 | return this.state.loading;
30 | }
31 |
32 | get error() {
33 | return this.state.error;
34 | }
35 |
36 | findUserById(userId: User['id']) {
37 | return this.users?.find((user) => user.id === userId) || null;
38 | }
39 |
40 | async read() {
41 | this.state.loading = true;
42 | try {
43 | const response = await this.usersHttpService.getUsers();
44 | runInAction(() => {
45 | this.state.data = response;
46 | this.state.error = undefined;
47 | this.state.loading = false;
48 | });
49 | } catch (e) {
50 | runInAction(() => {
51 | this.state.error = 'Connection error';
52 | this.state.loading = false;
53 | });
54 | }
55 | }
56 |
57 | async create(user: PostUserRequestJTO) {
58 | this.state.loading = true;
59 | try {
60 | const response = await this.usersHttpService.postUser(user);
61 | runInAction(() => {
62 | this.state.data.push(response);
63 | this.state.error = undefined;
64 | this.state.loading = false;
65 | });
66 | return true;
67 | } catch (e) {
68 | runInAction(() => {
69 | this.state.error = 'Connection error';
70 | this.state.loading = false;
71 | });
72 | }
73 | return false;
74 | }
75 |
76 | async delete(request: DeleteUsersRequestJTO) {
77 | this.state.loading = true;
78 | try {
79 | await this.usersHttpService.deleteUsers(request);
80 | runInAction(() => {
81 | this.state.data = this.state.data.filter(
82 | (u) => !request.ids.includes(u.id),
83 | );
84 | this.state.error = undefined;
85 | this.state.loading = false;
86 | });
87 | return true;
88 | } catch (e) {
89 | runInAction(() => {
90 | this.state.error = 'Connection error';
91 | this.state.loading = false;
92 | });
93 | }
94 | return false;
95 | }
96 | }
97 |
98 | type State = {
99 | data: GetUsersResponseJTO;
100 | loading: boolean;
101 | error?: string;
102 | };
103 |
--------------------------------------------------------------------------------
/src/app/users/_common/stores/users.queries.ts:
--------------------------------------------------------------------------------
1 | import { gql } from '@apollo/client';
2 |
3 | export const GetAllUsers = gql`
4 | query GetAllUsers {
5 | allUsers {
6 | id
7 | firstName
8 | lastName
9 | email
10 | }
11 | }
12 | `;
13 | export const CreateUser = gql`
14 | mutation CreateUser(
15 | $firstName: String!
16 | $lastName: String!
17 | $email: String!
18 | ) {
19 | createUser(firstName: $firstName, lastName: $lastName, email: $email) {
20 | id
21 | firstName
22 | lastName
23 | email
24 | }
25 | }
26 | `;
27 |
28 | export const DeleteUsers = gql`
29 | mutation DeleteUsers($ids: [ID!]!) {
30 | deleteUsers(ids: $ids)
31 | }
32 | `;
33 |
--------------------------------------------------------------------------------
/src/app/users/_components/user-modal/user-modal.tsx:
--------------------------------------------------------------------------------
1 | import { useCallback } from 'react';
2 | import { useInstance } from 'react-ioc';
3 | import { Controller, useForm } from 'react-hook-form';
4 | import { observer } from 'mobx-react-lite';
5 | import { Dialog } from '@mui/material';
6 | import TextField from '@material-ui/core/TextField';
7 | import {
8 | UserFormData,
9 | UserModalViewStore,
10 | } from '@/app/users/_components/user-modal/user-modal.view-store';
11 | import {
12 | Button,
13 | DialogActions,
14 | DialogContent,
15 | DialogTitle,
16 | } from '@material-ui/core';
17 |
18 | export const UserModal = observer(() => {
19 | const store = useInstance(UserModalViewStore);
20 | return (
21 |
24 | );
25 | });
26 |
27 | const UserForm = observer(() => {
28 | const store = useInstance(UserModalViewStore);
29 |
30 | const { control, handleSubmit, reset } = useForm({
31 | defaultValues: store.defaultValues,
32 | });
33 |
34 | const onSubmit = useCallback(
35 | async (data) => {
36 | await store.submit(data);
37 | },
38 | [store],
39 | );
40 |
41 | const title =
42 | store.mode === 'create' ? 'Add new user' : 'Edit ' + store.user?.email;
43 | return (
44 | <>
45 | {title}
46 |
54 | (
59 |
70 | )}
71 | />
72 | (
77 |
87 | )}
88 | />
89 | (
94 |
104 | )}
105 | />
106 |
107 |
108 |
109 |
110 |
111 | >
112 | );
113 | });
114 |
--------------------------------------------------------------------------------
/src/app/users/_components/user-modal/user-modal.view-store.ts:
--------------------------------------------------------------------------------
1 | import { inject } from 'react-ioc';
2 | import { makeAutoObservable } from 'mobx';
3 | import { User } from '@/generated/graphql';
4 | import { UsersDataStore } from '@/app/users/_common/stores/users.data-store';
5 | import { omit } from 'lodash';
6 |
7 | export class UserModalViewStore {
8 | private usersDataStore = inject(this, UsersDataStore);
9 |
10 | private state: State = {
11 | userId: null,
12 | opened: false,
13 | };
14 |
15 | constructor() {
16 | makeAutoObservable(this, undefined, { autoBind: true });
17 | }
18 |
19 | get mode() {
20 | return this.state.userId === null ? 'create' : 'edit';
21 | }
22 |
23 | get opened() {
24 | return this.state.opened;
25 | }
26 |
27 | get user() {
28 | if (this.state.userId === null) {
29 | return null;
30 | } else {
31 | return this.usersDataStore.findUserById(this.state.userId);
32 | }
33 | }
34 |
35 | get defaultValues(): UserFormData {
36 | if (this.mode === 'edit' && this.user) {
37 | return omit(this.user, 'id', '__typename');
38 | } else {
39 | return { firstName: '', lastName: '', email: '' };
40 | }
41 | }
42 |
43 | open(userId: State['userId'] = null) {
44 | this.state.userId = userId;
45 | this.state.opened = true;
46 | }
47 |
48 | close() {
49 | this.state.userId = null;
50 | this.state.opened = false;
51 | }
52 |
53 | async submit(data: UserFormData) {
54 | if (this.mode === 'create') {
55 | const success = await this.usersDataStore.create(data);
56 | success && this.close();
57 | } else {
58 | // this.usersDataStore.update(data);
59 | }
60 | }
61 | }
62 |
63 | interface State {
64 | userId: User['id'] | null;
65 | opened: boolean;
66 | }
67 |
68 | export interface UserFormData extends Omit {}
69 |
--------------------------------------------------------------------------------
/src/app/users/user-details/user-details.tsx:
--------------------------------------------------------------------------------
1 | import { PageLayout } from '@/app/_common/components/page-layout/page-layout';
2 | import { observer } from 'mobx-react-lite';
3 | import { Link } from 'react-router-dom';
4 | import {
5 | toUsersPath,
6 | UsersPath,
7 | } from '@/app/users/_common/navigation/users.paths';
8 | import { provider, useInstance } from 'react-ioc';
9 | import { UserDetailsViewStore } from '@/app/users/user-details/user-details.view-store';
10 | import { UserModalViewStore } from '@/app/users/_components/user-modal/user-modal.view-store';
11 | import { Button } from '@material-ui/core';
12 |
13 | export const UserDetails = provider(
14 | UserDetailsViewStore,
15 | //
16 | )(
17 | observer(() => {
18 | const store = useInstance(UserDetailsViewStore);
19 | const modalStore = useInstance(UserModalViewStore);
20 |
21 | const editUser = () => modalStore.open(store.userId);
22 |
23 | return (
24 |
25 |
28 |
31 | Go to user with ID 2
32 |
33 |
34 | );
35 | }),
36 | );
37 |
--------------------------------------------------------------------------------
/src/app/users/user-details/user-details.view-store.ts:
--------------------------------------------------------------------------------
1 | import { autorun, makeAutoObservable } from 'mobx';
2 | import { inject } from 'react-ioc';
3 | import { UserDetailsLocationStore } from '@/app/users/_common/navigation/users.paths';
4 |
5 | export class UserDetailsViewStore {
6 | private readonly locationStore = inject(this, UserDetailsLocationStore);
7 | private readonly autorunDisposer?: ReturnType;
8 |
9 | constructor() {
10 | makeAutoObservable(this, undefined, { autoBind: true });
11 | this.autorunDisposer = autorun(() => {
12 | // eslint-disable-next-line no-console
13 | console.log(`Query for details of ${this.userId}...`);
14 | });
15 | }
16 |
17 | get userId() {
18 | return this.locationStore.params.id;
19 | }
20 |
21 | dispose() {
22 | this.autorunDisposer?.();
23 | }
24 | }
25 |
--------------------------------------------------------------------------------
/src/app/users/users-list/users-list.tsx:
--------------------------------------------------------------------------------
1 | import { useCallback } from 'react';
2 | import { observer } from 'mobx-react-lite';
3 | import { Link } from 'react-router-dom';
4 | import { provider, useInstance } from 'react-ioc';
5 | import { DataGrid, GridColDef } from '@mui/x-data-grid';
6 | import { PageLayout } from '@/app/_common/components/page-layout/page-layout';
7 | import {
8 | UserRow,
9 | UsersListViewStore,
10 | } from '@/app/users/users-list/users-list.view-store';
11 | import {
12 | toUsersPath,
13 | UsersPath,
14 | } from '@/app/users/_common/navigation/users.paths';
15 | import { UserModalViewStore } from '@/app/users/_components/user-modal/user-modal.view-store';
16 | import { Box, Button } from '@material-ui/core';
17 |
18 | export const UsersList = provider(
19 | UsersListViewStore,
20 | //
21 | )(
22 | observer(() => {
23 | const store = useInstance(UsersListViewStore);
24 | const userModalViewStore = useInstance(UserModalViewStore);
25 |
26 | const addNewUser = useCallback(async () => {
27 | userModalViewStore.open();
28 | }, [userModalViewStore]);
29 |
30 | const refresh = useCallback(() => store.refresh(), [store]);
31 |
32 | return (
33 |
34 |
35 |
38 |
45 |
48 |
49 |
50 |
59 |
60 | );
61 | }),
62 | );
63 |
64 | const columns: GridColDef[] = [
65 | {
66 | field: 'firstName',
67 | headerName: 'First name',
68 | width: 150,
69 | },
70 | {
71 | field: 'lastName',
72 | headerName: 'Last name',
73 | width: 150,
74 | },
75 | {
76 | field: 'email',
77 | headerName: 'Email',
78 | width: 200,
79 | renderCell: ({ row, value }) => (
80 |
86 | {value}
87 |
88 | ),
89 | },
90 | ];
91 |
--------------------------------------------------------------------------------
/src/app/users/users-list/users-list.view-store.ts:
--------------------------------------------------------------------------------
1 | import { makeAutoObservable } from 'mobx';
2 | import { inject } from 'react-ioc';
3 | import { UsersDataStore } from '@/app/users/_common/stores/users.data-store';
4 | import { CreateUserMutationVariables } from '@/generated/graphql';
5 | import { AppToastViewStore } from '@/app/_common/stores/app-toast.view-store';
6 | import { GridSelectionModel } from '@mui/x-data-grid';
7 |
8 | export class UsersListViewStore {
9 | private usersDataStore = inject(this, UsersDataStore);
10 | private appToastViewStore = inject(this, AppToastViewStore);
11 |
12 | private state: State = {
13 | selectionModel: [],
14 | };
15 |
16 | constructor() {
17 | makeAutoObservable(this, undefined, { autoBind: true });
18 | }
19 |
20 | get selectionModel() {
21 | return this.state.selectionModel;
22 | }
23 |
24 | get users() {
25 | return [...(this.usersDataStore.users || [])];
26 | }
27 |
28 | setSelectionModel(selectionModel: State['selectionModel']) {
29 | this.state.selectionModel = selectionModel;
30 | }
31 |
32 | refresh() {
33 | this.usersDataStore.read();
34 | }
35 |
36 | async create(user: CreateUserMutationVariables) {
37 | const success = await this.usersDataStore.create(user);
38 | if (success) {
39 | this.appToastViewStore.open('User has been created', 'success');
40 | } else {
41 | this.appToastViewStore.open(
42 | 'User creation failed. Please try again.',
43 | 'error',
44 | );
45 | }
46 | return success;
47 | }
48 |
49 | async delete() {
50 | const success = await this.usersDataStore.delete({
51 | ids: this.selectionModel.map((id) => id.toString()),
52 | });
53 | if (success) {
54 | this.appToastViewStore.open('Users have been deleted', 'success');
55 | } else {
56 | this.appToastViewStore.open('Users deletion failed', 'error');
57 | }
58 | return success;
59 | }
60 | }
61 |
62 | interface State {
63 | selectionModel: GridSelectionModel;
64 | }
65 |
66 | export type UserRow = UsersListViewStore['users'][0];
67 |
--------------------------------------------------------------------------------
/src/app/users/users.module.tsx:
--------------------------------------------------------------------------------
1 | import { observer } from 'mobx-react-lite';
2 | import { UsersList } from '@/app/users/users-list/users-list';
3 | import { Route, Routes } from 'react-router-dom';
4 | import {
5 | UserDetailsLocationStore,
6 | UsersPath,
7 | } from './_common/navigation/users.paths';
8 | import { UserDetails } from '@/app/users/user-details/user-details';
9 | import { withLocationStoreProviderHOC } from '@/app/_common/components/location-store-provider/location-store-provider';
10 | import { provider } from 'react-ioc';
11 | import { UserModal } from '@/app/users/_components/user-modal/user-modal';
12 | import { UserModalViewStore } from '@/app/users/_components/user-modal/user-modal.view-store';
13 | import { AppModule } from '@/app/app.module';
14 | import { UsersHttpService } from '@/app/users/_common/remote-api/users.http-service';
15 | import { UsersDataStore } from '@/app/users/_common/stores/users.data-store';
16 |
17 | // We use AppModule.register(...) here to use tree shaking (code splitting)
18 | // for any src/app/users/**.* files
19 |
20 | AppModule.register(UsersHttpService);
21 | AppModule.register(UsersDataStore);
22 |
23 | // Convenient way to provide MobX *LocationStore with useSyncLocationStore() for component which depends on *LocationStore
24 | const UserDetailsWithLocation = withLocationStoreProviderHOC(
25 | UserDetailsLocationStore,
26 | UserDetails,
27 | );
28 |
29 | const UsersModule = provider(
30 | UserModalViewStore,
31 | //
32 | )(
33 | observer(() => {
34 | return (
35 | <>
36 |
37 | } />
38 | }
41 | />
42 |
43 |
44 |
45 | >
46 | );
47 | }),
48 | );
49 |
50 | export default UsersModule;
51 |
--------------------------------------------------------------------------------
/src/browser.module.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { provider } from 'react-ioc';
3 | import { ThemeDataStore } from '@/app/_common/stores/theme.data-store';
4 | import { Theme } from '@/app/_common/components/theme/theme';
5 | import { AppToast } from '@/app/_components/app-toast/app-toast';
6 | import { AppToastViewStore } from '@/app/_common/stores/app-toast.view-store';
7 | import { AppModule } from '@/app/app.module';
8 |
9 | export const BrowserModule = provider(
10 | ThemeDataStore,
11 | AppToastViewStore,
12 | )(() => (
13 |
14 |
15 |
16 |
17 | ));
18 |
--------------------------------------------------------------------------------
/src/generated/graphql.d.ts:
--------------------------------------------------------------------------------
1 | export type Maybe = T | null;
2 | export type InputMaybe = Maybe;
3 | export type Exact = { [K in keyof T]: T[K] };
4 | export type MakeOptional = Omit & { [SubKey in K]?: Maybe };
5 | export type MakeMaybe = Omit & { [SubKey in K]: Maybe };
6 | /** All built-in and custom scalars, mapped to their actual values */
7 | export type Scalars = {
8 | ID: string;
9 | String: string;
10 | Boolean: boolean;
11 | Int: number;
12 | Float: number;
13 | };
14 |
15 | export type Dashboard = {
16 | __typename?: 'Dashboard';
17 | id: Scalars['ID'];
18 | name: Scalars['String'];
19 | owner: User;
20 | };
21 |
22 | export type Mutation = {
23 | __typename?: 'Mutation';
24 | createUser: User;
25 | deleteUsers?: Maybe;
26 | };
27 |
28 |
29 | export type MutationCreateUserArgs = {
30 | email: Scalars['String'];
31 | firstName: Scalars['String'];
32 | lastName: Scalars['String'];
33 | };
34 |
35 |
36 | export type MutationDeleteUsersArgs = {
37 | ids: Array;
38 | };
39 |
40 | export type Query = {
41 | __typename?: 'Query';
42 | allDashboards: Array;
43 | allUsers: Array;
44 | dashboard: Dashboard;
45 | me: User;
46 | };
47 |
48 | export type User = {
49 | __typename?: 'User';
50 | email: Scalars['String'];
51 | firstName: Scalars['String'];
52 | id: Scalars['ID'];
53 | lastName: Scalars['String'];
54 | };
55 |
56 | export type GetAllUsersQueryVariables = Exact<{ [key: string]: never; }>;
57 |
58 |
59 | export type GetAllUsersQuery = { __typename?: 'Query', allUsers: Array<{ __typename?: 'User', id: string, firstName: string, lastName: string, email: string }> };
60 |
61 | export type CreateUserMutationVariables = Exact<{
62 | firstName: Scalars['String'];
63 | lastName: Scalars['String'];
64 | email: Scalars['String'];
65 | }>;
66 |
67 |
68 | export type CreateUserMutation = { __typename?: 'Mutation', createUser: { __typename?: 'User', id: string, firstName: string, lastName: string, email: string } };
69 |
70 | export type DeleteUsersMutationVariables = Exact<{
71 | ids: Array | Scalars['ID'];
72 | }>;
73 |
74 |
75 | export type DeleteUsersMutation = { __typename?: 'Mutation', deleteUsers?: boolean | null };
76 |
--------------------------------------------------------------------------------
/src/index.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import ReactDOM from 'react-dom';
3 | import reportWebVitals from './reportWebVitals';
4 | import { BrowserModule } from '@/browser.module';
5 |
6 | ReactDOM.render(, document.getElementById('root'));
7 |
8 | // If you want to start measuring performance in your app, pass a function
9 | // to log results (for example: reportWebVitals(console.log))
10 | // or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals
11 | reportWebVitals();
12 |
--------------------------------------------------------------------------------
/src/react-app-env.d.ts:
--------------------------------------------------------------------------------
1 | ///
2 |
--------------------------------------------------------------------------------
/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/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 |
--------------------------------------------------------------------------------
/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 | "strict": true,
15 | "forceConsistentCasingInFileNames": true,
16 | "noFallthroughCasesInSwitch": true,
17 | "module": "esnext",
18 | "moduleResolution": "node",
19 | "resolveJsonModule": true,
20 | "isolatedModules": true,
21 | "noEmit": true,
22 | "jsx": "react-jsx"
23 | },
24 | "include": [
25 | "src"
26 | ]
27 | }
28 |
--------------------------------------------------------------------------------
/tsconfig.paths.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "baseUrl": ".",
4 | "paths": {
5 | "@/*": ["src/*"],
6 | }
7 | }
8 | }
9 |
--------------------------------------------------------------------------------