From 4818d246af76bfd52c32114ea1e9555f62d9e583 Mon Sep 17 00:00:00 2001 From: Egor Kliuchantsev Date: Tue, 13 Aug 2024 19:36:29 +0200 Subject: [PATCH 1/2] Add mobx models --- package-lock.json | 66 +++++++++- package.json | 3 + src/components/App/App.tsx | 2 +- src/components/common/Loader/Loader.tsx | 9 +- src/components/pages/ItemPage/ItemPage.tsx | 133 +++++++++++--------- src/components/pages/ListPage/ListPage.tsx | 106 +++++++++------- src/hooks/useLoader.tsx | 47 ------- src/hooks/useLoaderModel/LoaderModel.ts | 25 ++++ src/hooks/useLoaderModel/index.ts | 1 + src/hooks/useLoaderModel/useLoaderModel.tsx | 27 ++++ src/models/ItemListModel.ts | 61 +++++++++ src/models/ItemModel.ts | 49 ++++++++ 12 files changed, 369 insertions(+), 160 deletions(-) delete mode 100644 src/hooks/useLoader.tsx create mode 100644 src/hooks/useLoaderModel/LoaderModel.ts create mode 100644 src/hooks/useLoaderModel/index.ts create mode 100644 src/hooks/useLoaderModel/useLoaderModel.tsx create mode 100644 src/models/ItemListModel.ts create mode 100644 src/models/ItemModel.ts diff --git a/package-lock.json b/package-lock.json index ca9a4c4..6d50d67 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,6 +11,7 @@ "@babel/core": "^7.16.0", "@pmmmwh/react-refresh-webpack-plugin": "^0.5.3", "@svgr/webpack": "^5.5.0", + "@tanstack/react-query": "^5.51.23", "@testing-library/jest-dom": "^5.17.0", "@testing-library/react": "^13.4.0", "@testing-library/user-event": "^13.5.0", @@ -44,6 +45,8 @@ "jest-watch-typeahead": "^1.0.0", "lodash": "^4.17.21", "mini-css-extract-plugin": "^2.4.5", + "mobx": "^6.13.1", + "mobx-react-lite": "^4.0.7", "postcss": "^8.4.4", "postcss-flexbugs-fixes": "^5.0.2", "postcss-loader": "^6.2.1", @@ -2015,9 +2018,9 @@ "integrity": "sha512-x/rqGMdzj+fWZvCOYForTghzbtqPDZ5gPwaoNGHdgDfF2QA/XZbCBp4Moo5scrkAMPhB7z26XM/AaHuIJdgauA==" }, "node_modules/@babel/runtime": { - "version": "7.23.7", - "resolved": "https://artifactory.nebius.dev/artifactory/api/npm/npm/@babel/runtime/-/runtime-7.23.7.tgz", - "integrity": "sha512-w06OXVOFso7LcbzMiDGt+3X7Rh7Ho8MmgPoWU3rarH+8upf+wSU/grlGbWzQyr3DkdN6ZeuMFjpdwW0Q+HxobA==", + "version": "7.25.0", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.25.0.tgz", + "integrity": "sha512-7dRy4DwXwtzBrPbZflqxnvfxLF8kdZXPkhymtDeFoFqE6ldzjQFgYTtYIFARcLEYDrqfBfYcZt1WqFxRoyC9Rw==", "dependencies": { "regenerator-runtime": "^0.14.0" }, @@ -3628,6 +3631,30 @@ "resolved": "https://artifactory.nebius.dev/artifactory/api/npm/npm/tslib/-/tslib-2.6.2.tgz?rbtorrent=7f559dc3bff22dd83af38b51fbaa319f7b159646", "integrity": "sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==" }, + "node_modules/@tanstack/query-core": { + "version": "5.51.21", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.51.21.tgz", + "integrity": "sha512-POQxm42IUp6n89kKWF4IZi18v3fxQWFRolvBA6phNVmA8psdfB1MvDnGacCJdS+EOX12w/CyHM62z//rHmYmvw==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/react-query": { + "version": "5.51.23", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.51.23.tgz", + "integrity": "sha512-CfJCfX45nnVIZjQBRYYtvVMIsGgWLKLYC4xcUiYEey671n1alvTZoCBaU9B85O8mF/tx9LPyrI04A6Bs2THv4A==", + "dependencies": { + "@tanstack/query-core": "5.51.21" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^18.0.0" + } + }, "node_modules/@testing-library/dom": { "version": "8.20.1", "resolved": "https://artifactory.nebius.dev/artifactory/api/npm/npm/@testing-library%2fdom/-/dom-8.20.1.tgz?rbtorrent=bc69850478697bc1402885b8a3783549da0fbb55", @@ -11786,6 +11813,39 @@ "mkdirp": "bin/cmd.js" } }, + "node_modules/mobx": { + "version": "6.13.1", + "resolved": "https://registry.npmjs.org/mobx/-/mobx-6.13.1.tgz", + "integrity": "sha512-ekLRxgjWJr8hVxj9ZKuClPwM/iHckx3euIJ3Np7zLVNtqJvfbbq7l370W/98C8EabdQ1pB5Jd3BbDWxJPNnaOg==", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mobx" + } + }, + "node_modules/mobx-react-lite": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/mobx-react-lite/-/mobx-react-lite-4.0.7.tgz", + "integrity": "sha512-RjwdseshK9Mg8On5tyJZHtGD+J78ZnCnRaxeQDSiciKVQDUbfZcXhmld0VMxAwvcTnPEHZySGGewm467Fcpreg==", + "dependencies": { + "use-sync-external-store": "^1.2.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mobx" + }, + "peerDependencies": { + "mobx": "^6.9.0", + "react": "^16.8.0 || ^17 || ^18" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + }, + "react-native": { + "optional": true + } + } + }, "node_modules/ms": { "version": "2.1.2", "resolved": "https://artifactory.nebius.dev/artifactory/api/npm/npm/ms/-/ms-2.1.2.tgz?rbtorrent=269d2b1e4b633a2572e485adbd93b4085872ffe0", diff --git a/package.json b/package.json index fc1570a..205d11c 100644 --- a/package.json +++ b/package.json @@ -6,6 +6,7 @@ "@babel/core": "^7.16.0", "@pmmmwh/react-refresh-webpack-plugin": "^0.5.3", "@svgr/webpack": "^5.5.0", + "@tanstack/react-query": "^5.51.23", "@testing-library/jest-dom": "^5.17.0", "@testing-library/react": "^13.4.0", "@testing-library/user-event": "^13.5.0", @@ -39,6 +40,8 @@ "jest-watch-typeahead": "^1.0.0", "lodash": "^4.17.21", "mini-css-extract-plugin": "^2.4.5", + "mobx": "^6.13.1", + "mobx-react-lite": "^4.0.7", "postcss": "^8.4.4", "postcss-flexbugs-fixes": "^5.0.2", "postcss-loader": "^6.2.1", diff --git a/src/components/App/App.tsx b/src/components/App/App.tsx index 609ed42..32c1814 100644 --- a/src/components/App/App.tsx +++ b/src/components/App/App.tsx @@ -4,7 +4,7 @@ import { } from "react-router-dom"; import {ListPage} from "../pages/ListPage/ListPage"; import {ItemPage} from "../pages/ItemPage/ItemPage"; -import {LoaderProvider} from "../../hooks/useLoader"; +import {LoaderProvider} from "../../hooks/useLoaderModel/useLoaderModel"; import {Loader} from "../common/Loader/Loader"; const router = createBrowserRouter([ diff --git a/src/components/common/Loader/Loader.tsx b/src/components/common/Loader/Loader.tsx index 5c8ba1b..3990153 100644 --- a/src/components/common/Loader/Loader.tsx +++ b/src/components/common/Loader/Loader.tsx @@ -1,10 +1,11 @@ import {FC} from "react"; import {Spinner} from "react-bootstrap"; import './Loader.css'; -import {useLoader} from "../../../hooks/useLoader"; +import {useLoaderModel} from "../../../hooks/useLoaderModel"; +import {observer} from "mobx-react-lite"; -export const Loader: FC = () => { - const loader = useLoader(); +export const Loader: FC = observer(() => { + const loader = useLoaderModel(); if (!loader.isLoading) { return null; @@ -17,4 +18,4 @@ export const Loader: FC = () => { ) -} \ No newline at end of file +}) diff --git a/src/components/pages/ItemPage/ItemPage.tsx b/src/components/pages/ItemPage/ItemPage.tsx index c5987e7..2ac25d3 100644 --- a/src/components/pages/ItemPage/ItemPage.tsx +++ b/src/components/pages/ItemPage/ItemPage.tsx @@ -1,94 +1,65 @@ -import {FC, useCallback, useEffect, useState} from "react"; +import {FC, useEffect, useState} from "react"; import {useNavigate, useParams} from "react-router-dom"; import {Item} from "../../../types/Item"; import {api} from "../../../api"; import {Button, Container, Form} from "react-bootstrap"; -import {useLoader} from "../../../hooks/useLoader"; +import {useLoaderModel} from "../../../hooks/useLoaderModel"; import {ConfirmModal} from "../../common/ConfirmModal/ConfirmModal"; +import {observer} from "mobx-react-lite"; +import {ItemModel} from "../../../models/ItemModel"; +import {useQuery} from "@tanstack/react-query"; -export const ItemPage: FC = () => { - const { id } = useParams<{ id: string }>(); - const navigate = useNavigate(); - - const loader = useLoader(); - - // Не использовать локальный стейт для демонстрации - const [item, setItem] = useState(null); - const [error, setError] = useState(null); - const [removeConfirmationOpened, setRemoveConfirmationOpened] = useState(false); +type ItemResponseError = {error: string}; - const fetch = useCallback(() => { - const hideLoader = loader.show(); - // Специально не передал пропсом - // В демо тоже должен лежать отдельно в стейте (не в стейте списка для главной страницы) - // что бы проверить инвалидацию при переходе между страницами - api.getItem(Number(id)) - .then((response) => { - if ('error' in response) { - setError(response.error); - return; - } +function isErrorItem(data: Item | ItemResponseError | undefined): data is ItemResponseError { + return Boolean(data && 'error' in data); +} - setItem(response); - }) - .finally(hideLoader); - }, []); +type ViewProps = { + itemModel: ItemModel; + refetchItem(): void; +} - useEffect(() => { - fetch(); - }, []); - - function renderContent() { - if (error) { - return ( -
{error}
- ) - } +const ItemPageView: FC = observer((props) => { + const { itemModel } = props; + const navigate = useNavigate(); - if (!item) { - return null; - } + const loader = useLoaderModel(); - return ( + return ( + +

Item

setItem({ ...item, checked: !item.checked })} + checked={itemModel.checked} + onChange={() => itemModel.toggle()} /> Text setItem({ ...item, text: e.target.value })} + value={itemModel.text} + onChange={(e) => itemModel.text = e.target.value} />
- ); - } - - return ( - -

Item

- {renderContent()}
@@ -100,17 +71,57 @@ export const ItemPage: FC = () => {
  • Come back here and check that data will update
  • { - if (!item) return; const hideLoader = loader.show(); - api.removeItem(item.id) + api.removeItem(itemModel.id) .then(() => navigate('/')) .finally(hideLoader); }} - onCancel={() => setRemoveConfirmationOpened(false)} + onCancel={() => itemModel.isRemoving = false} />
    ) -}; +}); + +export const ItemPage: FC = observer(() => { + const { id } = useParams<{ id: string }>(); + const loader = useLoaderModel(); + + const {data, refetch, isFetching, isError: isItemError} = useQuery({ + queryKey: ['item', id], + queryFn: () => api.getItem(Number(id)), + }); + + useEffect(() => { + if (isFetching) { + return loader.show(); + } + }, [isFetching, loader]); + + const [itemModel, setItemModel] = useState(null); + const [error, setError] = useState(null); + + useEffect(() => { + if(isItemError || isErrorItem(data)) { + setError((data as ItemResponseError)?.error || 'Item data loading error'); + } else if(data) { + setItemModel(new ItemModel({ item: data })) + } + },[data, isItemError]) + + if (error) { + return ( +
    {error}
    + ) + } + + if (!itemModel) { + return null; + } + + return ( + + ) +}) diff --git a/src/components/pages/ListPage/ListPage.tsx b/src/components/pages/ListPage/ListPage.tsx index 66c2102..46b49f8 100644 --- a/src/components/pages/ListPage/ListPage.tsx +++ b/src/components/pages/ListPage/ListPage.tsx @@ -3,35 +3,32 @@ import {Button, Container, Form} from "react-bootstrap"; import {api} from "../../../api"; import {Item, Item as ItemType} from '../../../types/Item'; import './ListPage.css'; -import {useLoader} from "../../../hooks/useLoader"; +import {useLoaderModel} from "../../../hooks/useLoaderModel"; import {ConfirmModal} from "../../common/ConfirmModal/ConfirmModal"; +import {observer} from "mobx-react-lite"; +import {useQuery} from "@tanstack/react-query"; +import {ItemListModel} from "../../../models/ItemListModel"; +import {ItemModel} from "../../../models/ItemModel"; -export const ListPage: FC = () => { - const loader = useLoader(); +type ViewProps = { + itemListModel: ItemListModel, + refetch(): void, + hasError: boolean +} - // Не использовать локальный стейт для демонстрации - const [items, setItems] = useState([]); - const [onlyChecked, setOnlyChecked] = useState(false); - const [removingItem, setRemovingItem] = useState(null); +const notExistsItem = new ItemModel({ + item: { + id: 0, + text: 'Not exists item (for error testing)', + checked: true, + }, +}); - // Для демонстрации аля селекторов добавил фильтрацию - const filteredItems = useMemo(() => { - if (!onlyChecked) return items; - return items.filter((item) => item.checked); - }, [items, onlyChecked]); +const ListPageView: FC = observer((props) => { + const { itemListModel } = props; + const loader = useLoaderModel(); - const fetch = useCallback(() => { - const hideLoader = loader.show(); - api.getItemList() - .then((items) => setItems(items)) - .finally(hideLoader); - }, []); - - useEffect(() => { - fetch(); - }, []); - - function renderItem(item: Item) { + function renderItem(item: ItemModel) { return (
    { checked={item.checked} onChange={() => { if (!item.id) return; - const newItem = {...item, checked: !item.checked}; - setItems(items.map((item) => item.id === newItem.id ? newItem : item)); + item.toggle(); }} /> @@ -48,7 +44,7 @@ export const ListPage: FC = () => { )}
    @@ -61,16 +57,13 @@ export const ListPage: FC = () => { setOnlyChecked(!onlyChecked)} + checked={itemListModel.onlyChecked} + onChange={() => itemListModel.toggleOnlyChecked()} />
    - {renderItem({ - id: 0, - text: 'Not exists item (for error testing)', - checked: true, - })} - {filteredItems.map(renderItem)} + {renderItem(notExistsItem)} + {itemListModel.filteredList.map(renderItem)} + {props.hasError &&

    Fetching items error

    }
    @@ -100,15 +93,40 @@ export const ListPage: FC = () => {
  • Come back here and check that data will update
  • { - if (!removingItem) return; - setItems(items.filter(({ id }) => id !== removingItem.id)); - setRemovingItem(null); + if (!itemListModel.removingId) return; + itemListModel.confirmRemoving(); }} - onCancel={() => setRemovingItem(null)} + onCancel={() => itemListModel.cancelRemoving()} />
    ); -}; +}); + +export const ListPage: FC = observer(() => { + const loader = useLoaderModel(); + const {data: items, refetch, isFetching, isError} = useQuery({ + queryKey: ['items'], + queryFn: api.getItemList, + }); + + useEffect(() => { + if(isFetching) { + return loader.show() + } + },[isFetching, loader]); + + const itemListModel = useMemo(() => { + return new ItemListModel({ items: items || [] }); + }, [items]); + + return ( + + ) +}) diff --git a/src/hooks/useLoader.tsx b/src/hooks/useLoader.tsx deleted file mode 100644 index 5941e82..0000000 --- a/src/hooks/useLoader.tsx +++ /dev/null @@ -1,47 +0,0 @@ -import {createContext, FC, ReactNode, useContext, useMemo, useState} from "react"; -import _ from "lodash"; - -type Hide = () => void; - -type Api = { - show(): Hide; - isLoading: boolean; -} - -const loaderContext = createContext(null); -const { Provider } = loaderContext; - -export function useLoader() { - const api = useContext(loaderContext); - - if (!api) throw new Error('Please, use LoaderProvider'); - - return api; -} - -type Props = { - children: ReactNode -}; - -export const LoaderProvider: FC = (props) => { - const [ids, setIds] = useState([]); - - const isLoading = ids.length > 0; - - const api = useMemo((): Api => { - return { - show() { - const id = _.uniqueId(); - setIds((ids) => [...ids, id]); - return () => { - setIds((ids) => ids.filter((checkingId) => checkingId !== id)); - } - }, - isLoading - }; - }, [isLoading]); - - return ( - {props.children} - ) -}; diff --git a/src/hooks/useLoaderModel/LoaderModel.ts b/src/hooks/useLoaderModel/LoaderModel.ts new file mode 100644 index 0000000..2a88305 --- /dev/null +++ b/src/hooks/useLoaderModel/LoaderModel.ts @@ -0,0 +1,25 @@ +import _ from "lodash"; +import {makeAutoObservable} from "mobx"; + +type Hide = () => void; + +export class LoaderModel { + private _ids: string[] = []; + + constructor() { + makeAutoObservable(this); + } + + show(): Hide { + const id = _.uniqueId(); + this._ids = [...this._ids, id]; + + return () => { + this._ids = this._ids.filter((checkingId) => checkingId !== id); + } + } + + get isLoading() { + return this._ids.length > 0; + } +} diff --git a/src/hooks/useLoaderModel/index.ts b/src/hooks/useLoaderModel/index.ts new file mode 100644 index 0000000..bc9e413 --- /dev/null +++ b/src/hooks/useLoaderModel/index.ts @@ -0,0 +1 @@ +export * from './useLoaderModel'; diff --git a/src/hooks/useLoaderModel/useLoaderModel.tsx b/src/hooks/useLoaderModel/useLoaderModel.tsx new file mode 100644 index 0000000..a1a3260 --- /dev/null +++ b/src/hooks/useLoaderModel/useLoaderModel.tsx @@ -0,0 +1,27 @@ +import {createContext, FC, ReactNode, useContext, useMemo} from "react"; +import {LoaderModel} from "./LoaderModel"; + +const loaderContext = createContext(null); +const { Provider } = loaderContext; + +export function useLoaderModel() { + const api = useContext(loaderContext); + + if (!api) throw new Error('Please, use LoaderProvider'); + + return api; +} + +type Props = { + children: ReactNode +}; + +export const LoaderProvider: FC = (props) => { + const loaderModel = useMemo(() => { + return new LoaderModel(); + }, []); + + return ( + {props.children} + ) +}; diff --git a/src/models/ItemListModel.ts b/src/models/ItemListModel.ts new file mode 100644 index 0000000..7aea8ef --- /dev/null +++ b/src/models/ItemListModel.ts @@ -0,0 +1,61 @@ +import {makeAutoObservable} from "mobx"; +import {Item} from "../types/Item"; +import {ItemModel} from "./ItemModel"; + +type Opts = { + items: Item[]; +} + +export class ItemListModel { + protected _items: ItemModel[]; + protected _onlyChecked: boolean = false; + protected _removingId: number | null = null; + + constructor(opts: Opts) { + this._items = opts.items.map((item) => new ItemModel({ item })); + + makeAutoObservable(this); + } + + get onlyChecked() { + return this._onlyChecked + } + + get items() { + return this._items; + } + + get filteredList() { + if (!this.onlyChecked) return this.items; + return this._items.filter((item) => item.checked); + } + + toggleOnlyChecked() { + this._onlyChecked = !this.onlyChecked; + } + + add(item: Item) { + this._items = [...this._items, new ItemModel({ item })]; + } + + startRemoving(id: number) { + this._removingId = id; + } + + confirmRemoving() { + this._items = this._items.filter((item) => item.id !== this._removingId); + this._removingId = null; + } + + cancelRemoving() { + this._removingId = null; + } + + get removingId() { + return this._removingId; + } + + toJs() { + return this.items.map((item) => item.toJs()); + } +} diff --git a/src/models/ItemModel.ts b/src/models/ItemModel.ts new file mode 100644 index 0000000..6c3761c --- /dev/null +++ b/src/models/ItemModel.ts @@ -0,0 +1,49 @@ +import {Item} from "../types/Item"; +import {makeAutoObservable, toJS} from "mobx"; + +type Opts = { + item: Item +} + +export class ItemModel{ + private _item: Item; + private _isRemoving = false; + + constructor(opts: Opts) { + this._item = opts.item; + + makeAutoObservable(this); + } + + get id() { + return this._item.id; + } + + set text(text) { + this._item.text = text; + } + + get text() { + return this._item.text; + } + + toggle() { + this._item.checked = !this.checked; + } + + get checked() { + return this._item.checked; + } + + toJs() { + return toJS(this._item); + } + + get isRemoving() { + return this._isRemoving + } + + set isRemoving(isRemoving) { + this._isRemoving = isRemoving; + } +} From f6149fd92e15ed9de0ff0d7752413c04f59d8e91 Mon Sep 17 00:00:00 2001 From: Egor Kliuchantsev Date: Tue, 20 Aug 2024 12:20:03 +0200 Subject: [PATCH 2/2] Changes after review and refactoring --- src/components/App/App.tsx | 13 +++- src/components/pages/ItemPage/ItemPage.tsx | 75 ++++--------------- .../pages/ItemPage/hooks/useItemModel.ts | 56 ++++++++++++++ src/components/pages/ListPage/ListPage.tsx | 43 ++--------- .../pages/ListPage/hooks/useItemListModel.ts | 36 +++++++++ src/hooks/useLoaderModel/LoaderModel.ts | 11 ++- src/models/ItemListModel.ts | 34 +++++---- src/models/ItemModel.ts | 39 ++++++---- 8 files changed, 169 insertions(+), 138 deletions(-) create mode 100644 src/components/pages/ItemPage/hooks/useItemModel.ts create mode 100644 src/components/pages/ListPage/hooks/useItemListModel.ts diff --git a/src/components/App/App.tsx b/src/components/App/App.tsx index 32c1814..3910683 100644 --- a/src/components/App/App.tsx +++ b/src/components/App/App.tsx @@ -6,6 +6,7 @@ import {ListPage} from "../pages/ListPage/ListPage"; import {ItemPage} from "../pages/ItemPage/ItemPage"; import {LoaderProvider} from "../../hooks/useLoaderModel/useLoaderModel"; import {Loader} from "../common/Loader/Loader"; +import {QueryClient, QueryClientProvider} from "@tanstack/react-query"; const router = createBrowserRouter([ { @@ -18,12 +19,16 @@ const router = createBrowserRouter([ } ]); +const client = new QueryClient(); + function App() { return ( - - - {/* Логичней в LoaderProvider вынести - но для тестирования глобального стейта тут ;) */} - + + + + {/* Логичней в LoaderProvider вынести - но для тестирования глобального стейта тут ;) */} + + ); } diff --git a/src/components/pages/ItemPage/ItemPage.tsx b/src/components/pages/ItemPage/ItemPage.tsx index 2ac25d3..fc1c5e5 100644 --- a/src/components/pages/ItemPage/ItemPage.tsx +++ b/src/components/pages/ItemPage/ItemPage.tsx @@ -1,31 +1,29 @@ -import {FC, useEffect, useState} from "react"; -import {useNavigate, useParams} from "react-router-dom"; -import {Item} from "../../../types/Item"; +import {FC} from "react"; +import {useNavigate} from "react-router-dom"; import {api} from "../../../api"; import {Button, Container, Form} from "react-bootstrap"; import {useLoaderModel} from "../../../hooks/useLoaderModel"; import {ConfirmModal} from "../../common/ConfirmModal/ConfirmModal"; import {observer} from "mobx-react-lite"; -import {ItemModel} from "../../../models/ItemModel"; -import {useQuery} from "@tanstack/react-query"; +import {useItemModel} from "./hooks/useItemModel"; -type ItemResponseError = {error: string}; - -function isErrorItem(data: Item | ItemResponseError | undefined): data is ItemResponseError { - return Boolean(data && 'error' in data); -} - -type ViewProps = { - itemModel: ItemModel; - refetchItem(): void; -} +export const ItemPage: FC = observer(() => { + const { model: itemModel, error, refetch } = useItemModel(); -const ItemPageView: FC = observer((props) => { - const { itemModel } = props; const navigate = useNavigate(); const loader = useLoaderModel(); + if (error) { + return ( +
    {error}
    + ) + } + + if (!itemModel) { + return null; + } + return (

    Item

    @@ -52,7 +50,7 @@ const ItemPageView: FC = observer((props) => { onClick={() => { const hideLoader = loader.show(); api.updateItem(itemModel.toJs()) - .then(props.refetchItem) + .then(refetch) .finally(hideLoader); }} >Save @@ -84,44 +82,3 @@ const ItemPageView: FC = observer((props) => {
    ) }); - -export const ItemPage: FC = observer(() => { - const { id } = useParams<{ id: string }>(); - const loader = useLoaderModel(); - - const {data, refetch, isFetching, isError: isItemError} = useQuery({ - queryKey: ['item', id], - queryFn: () => api.getItem(Number(id)), - }); - - useEffect(() => { - if (isFetching) { - return loader.show(); - } - }, [isFetching, loader]); - - const [itemModel, setItemModel] = useState(null); - const [error, setError] = useState(null); - - useEffect(() => { - if(isItemError || isErrorItem(data)) { - setError((data as ItemResponseError)?.error || 'Item data loading error'); - } else if(data) { - setItemModel(new ItemModel({ item: data })) - } - },[data, isItemError]) - - if (error) { - return ( -
    {error}
    - ) - } - - if (!itemModel) { - return null; - } - - return ( - - ) -}) diff --git a/src/components/pages/ItemPage/hooks/useItemModel.ts b/src/components/pages/ItemPage/hooks/useItemModel.ts new file mode 100644 index 0000000..371c8c3 --- /dev/null +++ b/src/components/pages/ItemPage/hooks/useItemModel.ts @@ -0,0 +1,56 @@ +import {useParams} from "react-router-dom"; +import {useLoaderModel} from "../../../../hooks/useLoaderModel"; +import {useQuery} from "@tanstack/react-query"; +import {api} from "../../../../api"; +import {useEffect, useState} from "react"; +import {ItemModel} from "../../../../models/ItemModel"; +import {Item} from "../../../../types/Item"; + +type Result = { + model: ItemModel | null, + error: string | null, + refetch(): void, +} + +type ItemResponseError = {error: string}; + +function isErrorItem(data: Item | ItemResponseError | undefined): data is ItemResponseError { + return Boolean(data && 'error' in data); +} + +export const useItemModel = (): Result => { + const { id } = useParams<{ id: string }>(); + const loader = useLoaderModel(); + + const {data, refetch, isFetching, isError: isItemError} = useQuery({ + queryKey: ['item', id], + queryFn: () => api.getItem(Number(id)), + }); + + useEffect(() => { + if (isFetching) { + return loader.show(); + } + }, [isFetching, loader]); + + const [model, setModel] = useState(null); + const [error, setError] = useState(null); + + useEffect(() => { + if(isItemError || isErrorItem(data)) { + setError((data as ItemResponseError)?.error || 'Item data loading error'); + return + } + + if (data) { + if (model) { + model.update(data); + return; + } + + setModel(new ItemModel({ item: data })) + } + },[data, isItemError, model, isFetching]); + + return { model, error, refetch }; +} diff --git a/src/components/pages/ListPage/ListPage.tsx b/src/components/pages/ListPage/ListPage.tsx index 46b49f8..fddf9b8 100644 --- a/src/components/pages/ListPage/ListPage.tsx +++ b/src/components/pages/ListPage/ListPage.tsx @@ -1,20 +1,12 @@ import {FC, useCallback, useEffect, useMemo, useState} from "react"; import {Button, Container, Form} from "react-bootstrap"; import {api} from "../../../api"; -import {Item, Item as ItemType} from '../../../types/Item'; import './ListPage.css'; import {useLoaderModel} from "../../../hooks/useLoaderModel"; import {ConfirmModal} from "../../common/ConfirmModal/ConfirmModal"; import {observer} from "mobx-react-lite"; -import {useQuery} from "@tanstack/react-query"; -import {ItemListModel} from "../../../models/ItemListModel"; import {ItemModel} from "../../../models/ItemModel"; - -type ViewProps = { - itemListModel: ItemListModel, - refetch(): void, - hasError: boolean -} +import {useItemListModel} from "./hooks/useItemListModel"; const notExistsItem = new ItemModel({ item: { @@ -24,8 +16,8 @@ const notExistsItem = new ItemModel({ }, }); -const ListPageView: FC = observer((props) => { - const { itemListModel } = props; +export const ListPage: FC = observer(() => { + const { model: itemListModel, error, refetch } = useItemListModel(); const loader = useLoaderModel(); function renderItem(item: ItemModel) { @@ -63,7 +55,7 @@ const ListPageView: FC = observer((props) => {
    {renderItem(notExistsItem)} {itemListModel.filteredList.map(renderItem)} - {props.hasError &&

    Fetching items error

    } + {error &&

    Fetching items error

    }
    @@ -105,28 +97,3 @@ const ListPageView: FC = observer((props) => { ); }); -export const ListPage: FC = observer(() => { - const loader = useLoaderModel(); - const {data: items, refetch, isFetching, isError} = useQuery({ - queryKey: ['items'], - queryFn: api.getItemList, - }); - - useEffect(() => { - if(isFetching) { - return loader.show() - } - },[isFetching, loader]); - - const itemListModel = useMemo(() => { - return new ItemListModel({ items: items || [] }); - }, [items]); - - return ( - - ) -}) diff --git a/src/components/pages/ListPage/hooks/useItemListModel.ts b/src/components/pages/ListPage/hooks/useItemListModel.ts new file mode 100644 index 0000000..f91a7c8 --- /dev/null +++ b/src/components/pages/ListPage/hooks/useItemListModel.ts @@ -0,0 +1,36 @@ +import {useLoaderModel} from "../../../../hooks/useLoaderModel"; +import {useQuery} from "@tanstack/react-query"; +import {api} from "../../../../api"; +import {useEffect, useMemo} from "react"; +import {ItemListModel} from "../../../../models/ItemListModel"; + +type Result = { + model: ItemListModel, + error: Error | null, + refetch(): void, +} + +export const useItemListModel = (): Result => { + const loader = useLoaderModel(); + const {data: items, refetch, isFetching, error} = useQuery({ + queryKey: ['items'], + queryFn: api.getItemList, + }); + + useEffect(() => { + if(isFetching) { + return loader.show() + } + },[isFetching, loader]); + + const itemListModel = useMemo(() => { + return new ItemListModel({ items: [] }); + }, []); + + useEffect(() => { + if (!items) return; + itemListModel.update(items); + }, [items, itemListModel, isFetching]); + + return { model: itemListModel, error, refetch }; +}; diff --git a/src/hooks/useLoaderModel/LoaderModel.ts b/src/hooks/useLoaderModel/LoaderModel.ts index 2a88305..f92653c 100644 --- a/src/hooks/useLoaderModel/LoaderModel.ts +++ b/src/hooks/useLoaderModel/LoaderModel.ts @@ -1,8 +1,6 @@ import _ from "lodash"; import {makeAutoObservable} from "mobx"; -type Hide = () => void; - export class LoaderModel { private _ids: string[] = []; @@ -10,13 +8,14 @@ export class LoaderModel { makeAutoObservable(this); } - show(): Hide { + show() { const id = _.uniqueId(); this._ids = [...this._ids, id]; + return () => this.hide(id); + } - return () => { - this._ids = this._ids.filter((checkingId) => checkingId !== id); - } + hide(id: string) { + this._ids = this._ids.filter((checkingId) => checkingId !== id); } get isLoading() { diff --git a/src/models/ItemListModel.ts b/src/models/ItemListModel.ts index 7aea8ef..ccc8f41 100644 --- a/src/models/ItemListModel.ts +++ b/src/models/ItemListModel.ts @@ -7,35 +7,28 @@ type Opts = { } export class ItemListModel { - protected _items: ItemModel[]; - protected _onlyChecked: boolean = false; + public onlyChecked: boolean = false; + public items: ItemModel[]; + protected _removingId: number | null = null; constructor(opts: Opts) { - this._items = opts.items.map((item) => new ItemModel({ item })); + this.items = opts.items.map((item) => new ItemModel({ item })); makeAutoObservable(this); } - get onlyChecked() { - return this._onlyChecked - } - - get items() { - return this._items; - } - get filteredList() { if (!this.onlyChecked) return this.items; - return this._items.filter((item) => item.checked); + return this.items.filter((item) => item.checked); } toggleOnlyChecked() { - this._onlyChecked = !this.onlyChecked; + this.onlyChecked = !this.onlyChecked; } add(item: Item) { - this._items = [...this._items, new ItemModel({ item })]; + this.items = [...this.items, new ItemModel({ item })]; } startRemoving(id: number) { @@ -43,7 +36,7 @@ export class ItemListModel { } confirmRemoving() { - this._items = this._items.filter((item) => item.id !== this._removingId); + this.items = this.items.filter((item) => item.id !== this._removingId); this._removingId = null; } @@ -58,4 +51,15 @@ export class ItemListModel { toJs() { return this.items.map((item) => item.toJs()); } + + update(items: Item[]) { + this.items = items.map((item) => { + const oldItem = this.items.find(({ id }) => id === item.id); + if (oldItem) { + oldItem.update(item); + return oldItem; + } + return new ItemModel({ item }); + }); + } } diff --git a/src/models/ItemModel.ts b/src/models/ItemModel.ts index 6c3761c..94d4f99 100644 --- a/src/models/ItemModel.ts +++ b/src/models/ItemModel.ts @@ -5,45 +5,52 @@ type Opts = { item: Item } -export class ItemModel{ - private _item: Item; - private _isRemoving = false; +export class ItemModel { + public isRemoving = false; + + protected _source: Item; + // Обычно работа с изменениями под капотом библиотек форм. В реальном приложении нет смысла хранить в Mobx + // Тут для примера оставил в модели + protected _changes: Partial; constructor(opts: Opts) { - this._item = opts.item; + this._source = opts.item; + this._changes = {}; makeAutoObservable(this); } get id() { - return this._item.id; + return this._source.id; } set text(text) { - this._item.text = text; + this._changes.text = text; } get text() { - return this._item.text; + return this._changes.text == null ? this._source.text : this._changes.text; } toggle() { - this._item.checked = !this.checked; + this._changes.checked = !this.checked; } get checked() { - return this._item.checked; + return this._changes.checked == null ? this._source.checked : this._changes.checked; } toJs() { - return toJS(this._item); - } - - get isRemoving() { - return this._isRemoving + return toJS({ + ...this._source, + ...this._changes + }); } - set isRemoving(isRemoving) { - this._isRemoving = isRemoving; + update(item: Item) { + if (item.id !== this.id) { + console.warn('ItemModel.update: you should update the same item'); + } + this._source = item; } }