Магазин на JavaScript, часть 13 из 19. Хранилище каталога, компонент витрины, кнопка «Назад»
05.01.2022
Теги: Backend • Express.js • Frontend • JavaScript • Node.js • ORM • React.js • Web-разработка • БазаДанных • ИнтернетМагазин • КаталогТоваров • Корзина • Фреймворк
Теперь самая главная часть магазина — каталог товаров на главной странице. Нам надо создать хранилище товаров с использованием MobX. Организовать загрузку с сервера списка категорий, брендов и товаров. Наконец, фильтровать список товаров при клике на категорию и/или бренд.
HTTP-запросы для каталога
По аналогии с файлом http/userAPI.js
для работы с пользователями создаем файл http/catalogAPI.js
для работы с каталогом товаров:
import { guestInstance, authInstance } from './index.js' /* * Создание, обновление и удаление категории, получение списка всех категорий */ export const createCategory = async (category) => { const { data } = await authInstance.post('category/create', category) return data } export const updateCategory = async (id, category) => { const { data } = await authInstance.put(`category/update/${id}`, category) return data } export const deleteCategory = async (id) => { const { data } = await authInstance.delete(`category/delete/${id}`) return data } export const fetchCategories = async () => { const { data } = await guestInstance.get('category/getall') return data } /* * Создание, обновление и удаление бренда, получение списка всех брендов */ export const createBrand = async (brand) => { const { data } = await authInstance.post('brand/create', brand) return data } export const updateBrand = async (id, brand) => { const { data } = await authInstance.put(`brand/update/${id}`, brand) return data } export const deleteBrand = async (id) => { const { data } = await authInstance.delete(`brand/delete/${id}`) return data } export const fetchBrands = async () => { const { data } = await guestInstance.get('brand/getall') return data } /* * Создание, обновление и удаление товара, получение списка всех товаров */ export const createProduct = async (product) => { const { data } = await authInstance.post('product/create', product) return data } export const updateProduct = async (id, product) => { const { data } = await authInstance.put(`product/update/${id}`, product) return data } export const deleteProduct = async (id) => { const { data } = await authInstance.delete(`product/delete/${id}`) return data } export const fetchAllProducts = async (categoryId, brandId, page, limit) => { let url = 'product/getall' // фильтрация товаров по категории и/или бренду if (categoryId) url = url + '/categoryId/' + categoryId if (brandId) url = url + '/brandId/' + brandId const { data } = await guestInstance.get( url, {params: { // GET-параметры для постраничной навигации page, limit } }) return data } export const fetchOneProduct = async (id) => { const { data } = await guestInstance.get(`product/getone/${id}`) return data }
Хранилище для каталога
По аналогии с хранилищем состояния пользователей сайта store/UserStore.js
создаем хранилище состояния каталога store/CatalogStore.js
:
import { makeAutoObservable } from 'mobx' class CatalogStore { _categories = [] _brands = [] _products = [] _category = null // выбранная категория _brand = null // выбранный бренд _page = 1 // текущая страница _count = 0 // сколько всего товаров _limit = 3 // товаров на страницу constructor() { makeAutoObservable(this) } get categories() { return this._categories } get brands() { return this._brands } get products() { return this._products } get category() { return this._category } get brand() { return this._brand } get page() { return this._page } get count() { return this._count } get limit() { return this._limit } get pages() { // всего страниц return Math.ceil(this.count / this.limit) } set categories(categories) { this._categories = categories } set brands(brands) { this._brands = brands } set products(products) { this._products = products } set category(id) { this.page = 1 this._category = id } set brand(id) { this.page = 1 this._brand = id } set page(page) { this._page = page } set count(count) { this._count = count } set limit(limit) { this._limit = limit } } export default CatalogStore
Компонент витрины Shop
Когда на сайт заходит посетитель мы должны выполнить три запроса на сервер — для получения категорий, брендов и списка товаров. При клике на категорию и/или бренд — нужно выполнить запрос на сервер для получения нового списка товаров с учетом фильтра по категории и/или бренду.
import { Container, Row, Col, Spinner } from 'react-bootstrap' import CategoryBar from '../components/CategoryBar.js' import BrandBar from '../components/BrandBar.js' import ProductList from '../components/ProductList.js' import { useContext, useEffect, useState } from 'react' import { AppContext } from '../components/AppContext.js' import { fetchCategories, fetchBrands, fetchAllProducts } from '../http/catalogAPI.js' import { observer } from 'mobx-react-lite' const Shop = observer(() => { const { catalog } = useContext(AppContext) const [categoriesFetching, setCategoriesFetching] = useState(true) const [brandsFetching, setBrandsFetching] = useState(true) const [productsFetching, setProductsFetching] = useState(true) useEffect(() => { fetchCategories() .then(data => catalog.categories = data) .finally(() => setCategoriesFetching(false)) fetchBrands() .then(data => catalog.brands = data) .finally(() => setBrandsFetching(false)) fetchAllProducts(null, null, 1, catalog.limit) .then(data => { catalog.products = data.rows catalog.count = data.count }) .finally(() => setProductsFetching(false)) }, []) useEffect(() => { setProductsFetching(true) fetchAllProducts(catalog.category, catalog.brand, catalog.page, catalog.limit) .then(data => { catalog.products = data.rows catalog.count = data.count }) .finally(() => setProductsFetching(false)) }, [catalog.category, catalog.brand, catalog.page]) return ( <Container> <Row className="mt-2"> <Col md={3} className="mb-3"> {categoriesFetching ? ( <Spinner animation="border" /> ) : ( <CategoryBar /> )} </Col> <Col md={9}> <div> {brandsFetching ? ( <Spinner animation="border" /> ) : ( <BrandBar /> )} </div> <div> {productsFetching ? ( <Spinner animation="border" /> ) : ( <ProductList /> )} </div> </Col> </Row> </Container> ) }) export default Shop
Компоненты CategoryBar и BrandBar
При клике по категории и/или бренду — нужно изменить состояние хранилища, что приводит к рендеру компонента Shop
:
import { ListGroup } from 'react-bootstrap' import { useContext } from 'react' import { AppContext } from './AppContext.js' import { observer } from 'mobx-react-lite' const CategoryBar = observer(() => { const { catalog } = useContext(AppContext) const handleClick = (id) => { if (id === catalog.category) { catalog.category = null } else { catalog.category = id } } return ( <ListGroup> {catalog.categories.map(item => <ListGroup.Item key={item.id} active={item.id === catalog.category} onClick={() => handleClick(item.id)} style={{cursor: 'pointer'}} > {item.name} </ListGroup.Item> )} </ListGroup> ) }) export default CategoryBar
import { ListGroup } from 'react-bootstrap' import { useContext } from 'react' import { AppContext } from './AppContext.js' import { observer } from 'mobx-react-lite' const BrandBar = observer(() => { const { catalog } = useContext(AppContext) const handleClick = (id) => { if (id === catalog.brand) { catalog.brand = null } else { catalog.brand = id } } return ( <ListGroup horizontal> {catalog.brands.map(item => <ListGroup.Item key={item.id} active={item.id === catalog.brand} onClick={() => handleClick(item.id)} style={{cursor: 'pointer'}} > {item.name} </ListGroup.Item> )} </ListGroup> ) }) export default BrandBar
Компоненты ProductList и ProductItem
Нужно доработать компонент ProductList
и добавить постраничную навигацию — при клике на номер страницы изменяем состояние, что вызывает новый рендер Shop
.
import { Row, Pagination } from 'react-bootstrap' import ProductItem from './ProductItem.js' import { useContext } from 'react' import { AppContext } from './AppContext.js' import { observer } from 'mobx-react-lite' const ProductList = observer(() => { const { catalog } = useContext(AppContext) const handleClick = (page) => { catalog.page = page } const pages = [] for (let page = 1; page <= catalog.pages; page++) { pages.push( <Pagination.Item key={page} active={page === catalog.page} activeLabel="" onClick={() => handleClick(page)} > {page} </Pagination.Item> ) } return ( <> <Row className="mb-3"> {catalog.products.length ? ( catalog.products.map(item => <ProductItem key={item.id} data={item} /> ) ) : ( <p className="m-3">По вашему запросу ничего не найдено</p> )} </Row> {catalog.pages > 1 && <Pagination>{pages}</Pagination>} </> ) }) export default ProductList
import { Card, Col } from 'react-bootstrap' const ProductItem = ({data}) => { return ( <Col xl={3} lg={4} sm={6} className="mt-3" onClick={() => alert('Переход на страницу товара')}> <Card style={{width: 200, cursor: 'pointer'}}> {data.image ? ( <Card.Img variant="top" src={process.env.REACT_APP_IMG_URL + data.image} /> ) : ( <Card.Img variant="top" src="http://via.placeholder.com/200" /> )} <Card.Body style={{height: 100, overflow: 'hidden'}}> <p>Бренд: {data.brand.name}</p> <strong>{data.name}</strong> </Card.Body> </Card> </Col> ) } export default ProductItem
Пришлось еще доработать модель на сервере server/model/Product.js
, чтобы при получении списка товаров получать еще и бренд для каждого товара:
class Product { async getAll(options) { const {categoryId, brandId, limit, page} = options const offset = (page - 1) * limit const where = {} if (categoryId) where.categoryId = categoryId if (brandId) where.brandId = brandId const products = await ProductMapping.findAndCountAll({ where, limit, offset, // для каждого товара получаем бренд и категорию include: [ {model: BrandMapping, as: 'brand'}, {model: CategoryMapping, as: 'category'} ] }) return products } /* .......... */ }
Кнопки «Назад» и «Вперед» браузера
Все работает неплохо, но после выбора категории и/или бренда, перехода на вторую, третью страницу списка товаров — не работает кнопка «Назад» браузера. При каждом из этих действий нужно записывать текущее состояние приложения в историю браузера. Есть несколько способов это сделать, рассмотрим два из них.
Первый способ
При выборе категории, бренда или страницы мы можем записать в объект state истории браузера текущую категорию, бренд и страницу с помощью useNavigate
. При нажатии кнопки «Назад» браузера — извлекать из state
категорию, бренд и страницу с помощью useLocation
и выполнять запрос на сервер для получения списка товаров.
Нам потребуется пакет для работы с историей браузера:
> npm install history
При клике на категорию, бренд или страницу — добавляем в историю браузера новый state
:
import { ListGroup } from 'react-bootstrap' import { useContext } from 'react' import { AppContext } from './AppContext.js' import { observer } from 'mobx-react-lite' import { useNavigate } from 'react-router-dom' const CategoryBar = observer(() => { const { catalog } = useContext(AppContext) const navigate = useNavigate() const handleClick = (id) => { if (id === catalog.category) { catalog.category = null } else { catalog.category = id } // при каждом клике добавляем в историю браузера новый элемент navigate('/', {state: { category: catalog.category, brand: catalog.brand, page: catalog.page }}) } return ( <ListGroup> {catalog.categories.map(item => <ListGroup.Item key={item.id} active={item.id === catalog.category} onClick={() => handleClick(item.id)} style={{cursor: 'pointer'}} > {item.name} </ListGroup.Item> )} </ListGroup> ) }) export default CategoryBar
import { ListGroup } from 'react-bootstrap' import { useContext } from 'react' import { AppContext } from './AppContext.js' import { observer } from 'mobx-react-lite' import { useNavigate } from 'react-router-dom' const BrandBar = observer(() => { const { catalog } = useContext(AppContext) const navigate = useNavigate() const handleClick = (id) => { if (id === catalog.brand) { catalog.brand = null } else { catalog.brand = id } // при каждом клике добавляем в историю браузера новый элемент navigate('/', {state: { category: catalog.category, brand: catalog.brand, page: catalog.page }}) } return ( <ListGroup horizontal> {catalog.brands.map(item => <ListGroup.Item key={item.id} active={item.id === catalog.brand} onClick={() => handleClick(item.id)} style={{cursor: 'pointer'}} > {item.name} </ListGroup.Item> )} </ListGroup> ) }) export default BrandBar
import { Row, Pagination } from 'react-bootstrap' import ProductItem from './ProductItem.js' import { useContext } from 'react' import { AppContext } from './AppContext.js' import { observer } from 'mobx-react-lite' import { useNavigate } from 'react-router-dom' const ProductList = observer(() => { const { catalog } = useContext(AppContext) const navigate = useNavigate() const handleClick = (page) => { catalog.page = page // при каждом клике добавляем в историю браузера новый элемент navigate('/', {state: { category: catalog.category, brand: catalog.brand, page: catalog.page }}) } const pages = [] for (let page = 1; page <= catalog.pages; page++) { pages.push( <Pagination.Item key={page} active={page === catalog.page} activeLabel="" onClick={() => handleClick(page)} > {page} </Pagination.Item> ) } return ( <> <Row className="mb-3"> {catalog.products.length ? ( catalog.products.map(item => <ProductItem key={item.id} data={item} /> ) ) : ( <p className="m-3">По вашему запросу ничего не найдено</p> )} </Row> {catalog.pages > 1 && <Pagination>{pages}</Pagination>} </> ) }) export default ProductList
При нажатии кнопки «Назад» браузера — обновляем observable-значения хранилища:
import { Container, Row, Col, Spinner } from 'react-bootstrap' import CategoryBar from '../components/CategoryBar.js' import BrandBar from '../components/BrandBar.js' import ProductList from '../components/ProductList.js' import { useContext, useEffect, useState } from 'react' import { AppContext } from '../components/AppContext.js' import { fetchCategories, fetchBrands, fetchAllProducts } from '../http/catalogAPI.js' import { observer } from 'mobx-react-lite' import { createBrowserHistory } from 'history' const Shop = observer(() => { const { catalog } = useContext(AppContext) const [categoriesFetching, setCategoriesFetching] = useState(true) const [brandsFetching, setBrandsFetching] = useState(true) const [productsFetching, setProductsFetching] = useState(true) useEffect(() => { fetchCategories() .then(data => catalog.categories = data) .finally(() => setCategoriesFetching(false)) fetchBrands() .then(data => catalog.brands = data) .finally(() => setBrandsFetching(false)) fetchAllProducts(null, null, 1, catalog.limit) .then(data => { catalog.products = data.rows catalog.count = data.count }) .finally(() => setProductsFetching(false)) // eslint-disable-next-line }, []) useEffect(() => { const browserHistory = createBrowserHistory() const unlisten = browserHistory.listen(({ location, action }) => { if (action === 'POP') { catalog.category = location.state?.category ?? null catalog.brand = location.state?.brand ?? null catalog.page = location.state?.page ?? 1 } }) return () => unlisten() // eslint-disable-next-line }, []) useEffect(() => { setProductsFetching(true) fetchAllProducts(catalog.category, catalog.brand, catalog.page, catalog.limit) .then(data => { catalog.products = data.rows catalog.count = data.count }) .finally(() => setProductsFetching(false)) // eslint-disable-next-line }, [catalog.category, catalog.brand, catalog.page]) return ( <Container> <Row className="mt-2"> <Col md={3} className="mb-3"> {categoriesFetching ? ( <Spinner animation="border" /> ) : ( <CategoryBar /> )} </Col> <Col md={9}> <div> {brandsFetching ? ( <Spinner animation="border" /> ) : ( <BrandBar /> )} </div> <div> {productsFetching ? ( <Spinner animation="border" /> ) : ( <ProductList /> )} </div> </Col> </Row> </Container> ) }) export default Shop
Вообще, разработчники React Router говорят, что для работы с историей браузера достаточно useLocation
. Сначала не понял, как отследить событие POP
, но после некоторых размышлений — сумел все-таки реализовать. Так что пакет history
можно не устанавливать.
import { Container, Row, Col, Spinner } from 'react-bootstrap' import CategoryBar from '../components/CategoryBar.js' import BrandBar from '../components/BrandBar.js' import ProductList from '../components/ProductList.js' import { useContext, useEffect, useState } from 'react' import { AppContext } from '../components/AppContext.js' import { fetchCategories, fetchBrands, fetchAllProducts } from '../http/catalogAPI.js' import { observer } from 'mobx-react-lite' import { useLocation } from 'react-router-dom' const Shop = observer(() => { const { catalog } = useContext(AppContext) const [categoriesFetching, setCategoriesFetching] = useState(true) const [brandsFetching, setBrandsFetching] = useState(true) const [productsFetching, setProductsFetching] = useState(true) useEffect(() => { fetchCategories() .then(data => catalog.categories = data) .finally(() => setCategoriesFetching(false)) fetchBrands() .then(data => catalog.brands = data) .finally(() => setBrandsFetching(false)) fetchAllProducts(null, null, 1, catalog.limit) .then(data => { catalog.products = data.rows catalog.count = data.count }) .finally(() => setProductsFetching(false)) // eslint-disable-next-line }, []) const location = useLocation() useEffect(() => { if (location.state) { if (location.state.category !== catalog.category) { catalog.category = location.state.category } if (location.state.brand !== catalog.brand) { catalog.brand = location.state.brand } if (location.state.page !== catalog.page) { catalog.page = location.state.page } } else { catalog.category = null catalog.brand = null catalog.page = 1 } // eslint-disable-next-line }, [location.state]) useEffect(() => { setProductsFetching(true) fetchAllProducts(catalog.category, catalog.brand, catalog.page, catalog.limit) .then(data => { catalog.products = data.rows catalog.count = data.count }) .finally(() => setProductsFetching(false)) // eslint-disable-next-line }, [catalog.category, catalog.brand, catalog.page]) return ( <Container> <Row className="mt-2"> <Col md={3} className="mb-3"> {categoriesFetching ? ( <Spinner animation="border" /> ) : ( <CategoryBar /> )} </Col> <Col md={9}> <div> {brandsFetching ? ( <Spinner animation="border" /> ) : ( <BrandBar /> )} </div> <div> {productsFetching ? ( <Spinner animation="border" /> ) : ( <ProductList /> )} </div> </Col> </Row> </Container> ) }) export default Shop
- Магазин на JavaScript, часть 19 из 19. Редактирование характеристик и рефакторинг приложения
- Магазин на JavaScript, часть 18 из 19. Панель управления: редактирование категорий и брендов
- Магазин на JavaScript, часть 17 из 19. Панель управления: список заказов, категорий и брендов
- Магазин на JavaScript, часть 15 из 19. Работа с заказами на сервере, оформление заказа
- Магазин на JavaScript, часть 14 из 19. Кнопка «Назад», страница товара, корзина покупателя
- Магазин на JavaScript, часть12 из 19. Запросы на сервер, состояние приложения, Signup и Login
- Магазин на JavaScript, часть 11 из 19. Компоненты Signup и Login, компоненты Shop и Basket
Поиск: Backend • Express.js • Frontend • JavaScript • Node.js • ORM • React.js • Web-разработка • База данных • Интернет магазин • Каталог товаров • Корзина • Фреймворк