Магазин на JavaScript, часть 19 из 19. Редактирование характеристик и рефакторинг приложения

07.03.2022

Теги: BackendExpress.jsFrontendJavaScriptNode.jsORMReact.jsWeb-разработкаБазаДанныхИнтернетМагазинКаталогТоваровКорзинаФреймворк

При редактировании товара нужно иметь возможность редактировать и характеристики. В принципе, мы могли бы поступить просто — отправить все характаристики на сервер вместе с остальными данными товара. В этом случае сервер удалит все существующие характеристики и создаст новые из полученных. Но это не слишком изящное решение — возможно, только одна характеристика была исправлена, а мы удаляем все подряд и создаем заново. Так что будем работать с каждой характеристикой по отдельности, тем более, что на сервере мы создали модель для работы с характеристиками. Для этого на клиенте для каждой характеристики добавим свойства append, change и remove.

  • при добавлении новой хар-ки свойство append принимает значение true
  • при изменении старой хар-ки свойство change принимает значение true
  • при удалении старой хар-ки свойство remove принимает значение true

При обновлении товара мы можем для каждой хар-ки выполнить подходящий http-запрос на сервер — чтобы добавить, изменить или удалить характеристику. Но, прежде чем начинать работать с хар-ками, нужно добавить в http/catalogAPI.js функции для работы с хар-ками.

/*
 * Создание, обновление и удаление характеристик товара
 */
export const createProperty = async (productId, property) => {
    const { data } = await authInstance.post(`product/${productId}/property/create`, property)
    return data
}

export const updateProperty = async (productId, id, property) => {
    const { data } = await authInstance.put(`product/${productId}/property/update/${id}`, property)
    return data
}

export const deleteProperty = async (productId, id) => {
    const { data } = await authInstance.delete(`product/${productId}/property/delete/${id}`)
    return data
}

При добавлении нового товара, чтобы обращаться к нужной хар-ке, мы в качестве идентификатора использовали timestamp, но лучше установим пакет react-uuid:

> npm install react-uuid

Компонент UpdateProperties.js получился не сложным, он просто получает от родителя массив хар-тик и функцию для изменения этого массива:

import { useEffect, useState } from 'react'
import { Row, Col, Button, Form } from 'react-bootstrap'
import { createProperty, updateProperty, deleteProperty } from '../http/catalogAPI.js'
import uuid from 'react-uuid'

const UpdateProperties = (props) => {
    const { properties, setProperties } = props

    const append = () => {
        setProperties([...properties, {id: null, name: '', value: '', unique: uuid(), append: true}])
    }
    const remove = (unique) => {
        // новую хар-ку надо просто удалить из массива properties, а старую — оставить, но
        // изменить remove на true, чтобы потом выполнить http-запрос на сервер для удаления
        const item = properties.find(elem => elem.unique === unique)
        if (item.id) { // старая хар-ка
            setProperties(properties.map(
                elem => elem.unique === unique ? {...elem, change: false, remove: true} : elem
            ))
        } else { // новая хар-ка
            setProperties(properties.filter(elem => elem.unique === unique))
        }
    }
    const change = (key, value, unique) => {
        setProperties(properties.map(
            item => item.unique === unique ? {...item, [key]: value, change: !item.append} : item
        ))
    }

    return (
        <>
            <h5>Характеристики</h5>
            <Button onClick={append} variant="outline-primary" size="sm" className="mb-2">
                Добавить
            </Button>
            {properties.map(item =>
                <Row key={item.unique} className="mb-2" style={{display: item.remove ? 'none': 'flex'}}>
                    <Col>
                        <Form.Control
                            name={'name_' + item.unique}
                            value={item.name}
                            onChange={e => change('name', e.target.value, item.unique)}
                            placeholder="Название..."
                            size="sm"
                        />
                    </Col>
                    <Col>
                        <Form.Control
                            name={'value_' + item.unique}
                            value={item.value}
                            onChange={e => change('value', e.target.value, item.unique)}
                            placeholder="Значение..."
                            size="sm"
                        />
                    </Col>
                    <Col>
                        <Button onClick={() => remove(item.unique)} size="sm" variant="outline-danger">
                            Удалить
                        </Button>
                        {item.change && ' *'}
                    </Col>
                </Row>
            )}
        </>
    )
}

export default UpdateProperties

А вот компонент UpdateProduct.js стал немного сложнее. Добавилась функция updateProperties, которая проходит по всему массиву properties и для каждой хар-ки выполняет подходящий http-запрос. Причем, для каждого http-запроса мы ждем ответ, так что к моменту возврата из функции все хар-ки уже обновились на сервере. И когда мы выполним еще один запрос, чтобы обновить название, цену, категорию и бренд товара — то в ответе получим уже обновленные хар-ки.

import { Modal, Button, Form, Row, Col } from 'react-bootstrap'
import { fetchOneProduct, updateProduct, fetchCategories, fetchBrands } from '../http/catalogAPI.js'
import { useState, useEffect } from 'react'
import uuid from 'react-uuid'
import UpdateProperties from './UpdateProperties.js'
import { createProperty, updateProperty, deleteProperty } from '../http/catalogAPI.js'

const defaultValue = {name: '', price: '', category: '', brand: ''}
const defaultValid = {name: null, price: null, category: null, brand: null}

const isValid = (value) => {
    const result = {}
    const pattern = /^[1-9][0-9]*$/
    for (let key in value) {
        if (key === 'name') result.name = value.name.trim() !== ''
        if (key === 'price') result.price = pattern.test(value.price.trim())
        if (key === 'category') result.category = pattern.test(value.category)
        if (key === 'brand') result.brand = pattern.test(value.brand)
    }
    return result
}

const updateProperties = async (properties, productId) => {
    for (const prop of properties) {
        const empty = prop.name.trim() === '' || prop.value.trim() === ''
        // если вдруг старая хар-ка оказалась пустая — удалим ее на сервере
        if (empty && prop.id) {
            try {
                await deleteProperty(productId, prop)
            } catch(error) {
                alert(error.response.data.message)
            }
            continue
        }
        /*
         * Если у объекта prop свойство append равно true — это новая хар-ка, ее надо создать.
         * Если у объекта prop свойство change равно true — хар-ка изменилась, ее надо обновить.
         * Если у объекта prop свойство remove равно true — хар-ку удалили, ее надо удалить.
         */
        if (prop.append && !empty) {
            try {
                await createProperty(productId, prop)
            } catch(error) {
                alert(error.response.data.message)
            }
            continue
        }
        if (prop.change && !prop.remove) {
            try {
                await updateProperty(productId, prop.id, prop)
            } catch(error) {
                alert(error.response.data.message)
            }
            continue
        }
        if (prop.remove) {
            try {
                await deleteProperty(productId, prop.id)
            } catch(error) {
                alert(error.response.data.message)
            }
            continue
        }
    }
}

const UpdateProduct = (props) => {
    const { id, show, setShow, setChange } = props

    const [value, setValue] = useState(defaultValue)
    const [valid, setValid] = useState(defaultValid)

    // список категорий и список брендов для возможности выбора
    const [categories, setCategories] = useState(null)
    const [brands, setBrands] = useState(null)

    // выбранное для загрузки изображение товара
    const [image, setImage] = useState(null)

    // список характеристик товара
    const [properties, setProperties] = useState([])

    useEffect(() => {
        if(id) {
            // нужно получить с сервера данные товара для редактирования
            fetchOneProduct(id)
                .then(
                    data => {
                        const prod = {
                            name: data.name,
                            price: data.price.toString(),
                            category: data.categoryId.toString(),
                            brand: data.brandId.toString()
                        }
                        setValue(prod)
                        setValid(isValid(prod))
                        // для удобства работы с хар-ми зададим для каждой уникальный идентификатор
                        // и доп.свойства, которые подскажут нам, какой http-запрос на сервер нужно
                        // выполнить — добавления, обновления или удаления характеристики
                        setProperties(data.props.map(item => {
                            // при добавлении новой хар-ки свойство append принимает значение true
                            // при изменении старой хар-ки свойство change принимает значение true
                            // при удалении старой хар-ки свойство remove принимает значение true
                            return {...item, unique: uuid(), append: false, remove: false, change: false}
                        }))
                    }
                )
                .catch(
                    error => alert(error.response.data.message)
                )
            // нужно получить с сервера список категорий и список брендов
            fetchCategories()
                .then(
                    data => setCategories(data)
                )
            fetchBrands()
                .then(
                    data => setBrands(data)
                )
        }
    }, [id])

    const handleInputChange = (event) => {
        const data = {...value, [event.target.name]: event.target.value}
        setValue(data)
        setValid(isValid(data))
    }

    const handleImageChange = (event) => {
        setImage(event.target.files[0])
    }

    const handleSubmit = async (event) => {
        event.preventDefault()

        /*
         * На первый взгляд кажется, что переменная correct не нужна, можно обойтись valid, но это
         * не так. Нельзя использовать значение valid сразу после изменения этого значения — ф-ция
         * setValid не изменяет значение состояния мгновенно. Вызов функции лишь означает — React
         * «принял к сведению» наше сообщение, что состояние нужно изменить.
         */
        const correct = isValid(value)
        setValid(correct)

        // если введенные данные прошли проверку — можно отправлять их на сервер
        if (correct.name && correct.price && correct.category && correct.brand) {
            const data = new FormData()
            data.append('name', value.name.trim())
            data.append('price', value.price.trim())
            data.append('categoryId', value.category)
            data.append('brandId', value.brand)
            if (image) data.append('image', image, image.name)

            // нужно обновить, добавить или удалить характеристики и обязательно дождаться
            // ответа сервера — поэтому функция updateProperties() объявлена как async, а
            // в теле функции для выполнения действия с каждой хар-кой используется await
            if (properties.length) {
                await updateProperties(properties, id)
            }

            updateProduct(id, data)
                .then(
                    data => {
                        // изменяем состояние, чтобы обновить список товаров
                        setChange(state => !state)
                        // сбрасываем поле загрузки изображения, чтобы при сохранении товара,
                        // когда новое изображение не выбрано, не загружать старое повтороно
                        event.target.image.value = ''
                        // в принципе, мы могли бы сбросить все поля формы на дефолтные значения, но
                        // если пользователь решит отредатировать тот же товар повтороно, то увидит
                        // пустые поля формы — http-запрос на получение данных для редактирования мы
                        // выполняем только тогда, когда выбран новый товар (изменился id товара)
                        const prod = {
                            name: data.name,
                            price: data.price.toString(),
                            category: data.categoryId.toString(),
                            brand: data.brandId.toString()
                        }
                        setValue(prod)
                        setValid(isValid(prod))
                        // мы получим актуальные значения хар-тик с сервера, потому что обновление
                        // хар-тик завершилось еще до момента отправки этого http-запроса на сервер
                        setProperties(data.props.map(item => {
                            return {...item, unique: uuid(), append: false, remove: false, change: false}
                        }))
                        // закрываем модальное окно редактирования товара
                        setShow(false)
                    }
                )
                .catch(
                    error => alert(error.response.data.message)
                )
        }
    }

    return (
        <Modal show={show} onHide={() => setShow(false)} size="lg">
            <Modal.Header closeButton>
                <Modal.Title>Редактирование товара</Modal.Title>
            </Modal.Header>

            <Modal.Body>
                <Form noValidate onSubmit={handleSubmit}>
                    <Form.Control
                        name="name"
                        value={value.name}
                        onChange={e => handleInputChange(e)}
                        isValid={valid.name === true}
                        isInvalid={valid.name === false}
                        placeholder="Название товара..."
                        className="mb-3"
                    />
                    <Row className="mb-3">
                        <Col>
                            <Form.Select
                                name="category"
                                value={value.category}
                                onChange={e => handleInputChange(e)}
                                isValid={valid.category === true}
                                isInvalid={valid.category === false}
                            >
                                <option value="">Категория</option>
                                {categories && categories.map(item =>
                                    <option key={item.id} value={item.id}>{item.name}</option>
                                )}
                            </Form.Select>
                        </Col>
                        <Col>
                            <Form.Select
                                name="brand"
                                value={value.brand}
                                onChange={e => handleInputChange(e)}
                                isValid={valid.brand === true}
                                isInvalid={valid.brand === false}
                            >
                                <option value="">Бренд</option>
                                {brands && brands.map(item =>
                                    <option key={item.id} value={item.id}>{item.name}</option>
                                )}
                            </Form.Select>
                        </Col>
                    </Row>
                    <Row className="mb-3">
                        <Col>
                            <Form.Control
                                name="price"
                                value={value.price}
                                onChange={e => handleInputChange(e)}
                                isValid={valid.price === true}
                                isInvalid={valid.price === false}
                                placeholder="Цена товара..."
                            />
                        </Col>
                        <Col>
                            <Form.Control
                                name="image"
                                type="file"
                                onChange={e => handleImageChange(e)}
                                placeholder="Фото товара..."
                            />
                        </Col>
                    </Row>
                    <UpdateProperties properties={properties} setProperties={setProperties} />
                    <Row>
                        <Col>
                            <Button type="submit">Сохранить</Button>
                        </Col>
                    </Row>
                </Form>
            </Modal.Body>
        </Modal>
    )
}

export default UpdateProduct

Рефакторинг

В какой-то момент свернул на кривую дорожку и долго откладывал возвращение на правильный путь. Так что будет новая версия клиента в директории client.v2 — с исправлением допущенных ошибок.

Первый этап

Во-первых, данные о пользователе следует получать в компоненте App. Потому что эти данные требуются компоненту NavBar, чтобы показать нужные ссылки — авторизации или личного кабинета. Чтобы получить эти данные — пришлось создавать HOC-компонент CheckAuth, который показывает loader, пока выполняется запрос на сервер. Если данные будут получены в App и сохранены в хранилище — HOC-компонент CheckAuth будет не нужен.

import { BrowserRouter } from 'react-router-dom'
import AppRouter from './components/AppRouter.js'
import NavBar from './components/NavBar.js'
import 'bootstrap/dist/css/bootstrap.min.css'

import { AppContext } from './components/AppContext.js'
import { check as checkAuth } from './http/userAPI.js'
import { useState, useContext, useEffect } from 'react'
import { observer } from 'mobx-react-lite'
import Loader from './components/Loader.js'

const App = observer(() => {
    const { user } = useContext(AppContext)
    const [loading, setLoading] = useState(true)

    useEffect(() => {
        checkAuth()
            .then(data => {
                if (data) {
                    user.login(data)
                }
            })
            .finally(
                () => setLoading(false)
            )
    }, [])

    // показываем loader, пока получаем с сервера данные пользователя
    if (loading) {
        return <Loader />
    }

    return (
        <BrowserRouter>
            <NavBar />
            <AppRouter />
        </BrowserRouter>
    )
})

export default App
import { Container, Spinner } from 'react-bootstrap'

const Loader = () => {
    const style = {
        width: '100%',
        height: '100vh',
        display: 'flex',
        justifyContent: 'center',
        alignItems: 'center'
    }
    return (
        <div style={style}>
            <Spinner animation="grow" variant="primary" />
        </div>
    )
}

export default Loader

Теперь можно удалить как сам HOC-компонент CheckAuth, так и его использование в компоненте NavBar:

import { Container, Navbar, Nav } from 'react-bootstrap'
import { NavLink } from 'react-router-dom'
import { AppContext } from './AppContext.js'
import { useContext } from 'react'
import { observer } from 'mobx-react-lite'
import FetchBasket from './FetchBasket.js'

const NavBar = observer(() => {
    const { user, basket } = useContext(AppContext)
    return (
        <Navbar bg="dark" variant="dark">
            <Container>
                <NavLink to="/" className="navbar-brand">Магазин</NavLink>
                <Nav className="ml-auto">
                    <NavLink to="/delivery" className="nav-link">Доставка</NavLink>
                    <NavLink to="/contacts" className="nav-link">Контакты</NavLink>
                    {user.isAuth ? (
                        <NavLink to="/user" className="nav-link">Личный кабинет</NavLink>
                    ) : (
                        <>
                            <NavLink to="/login" className="nav-link">Войти</NavLink>
                            <NavLink to="/signup" className="nav-link">Регистрация</NavLink>
                        </>
                    )}
                    {user.isAdmin && (
                        <NavLink to="/admin" className="nav-link">Панель управления</NavLink>
                    )}
                    <FetchBasket>
                        <NavLink to="/basket" className="nav-link">
                            Корзина
                            {!!basket.count && <span>({basket.count})</span>}
                        </NavLink>
                    </FetchBasket>
                </Nav>
            </Container>
        </Navbar>
    )
})

export default NavBar

Второй этап

Во-вторых, аналогичная ситуация с корзиной. Чтобы правильно ее показать, используется HOC-компонент FetchBasket, который показывает loader, пока идет запрос на сервер. Так что содержимое корзины тоже будем получать в компоненте App — причем, нам надо дождаться от сервера как данных пользователя, так и данных корзины.

import { BrowserRouter } from 'react-router-dom'
import AppRouter from './components/AppRouter.js'
import NavBar from './components/NavBar.js'
import 'bootstrap/dist/css/bootstrap.min.css'

import { AppContext } from './components/AppContext.js'
import { check as checkAuth } from './http/userAPI.js'
import { useState, useContext, useEffect } from 'react'
import { observer } from 'mobx-react-lite'
import Loader from './components/Loader.js'

import { fetchBasket } from './http/basketAPI.js'

const App = observer(() => {
    const { user, basket } = useContext(AppContext)
    const [userAuthLoading, setUserAuthLoading] = useState(true)
    const [basketLoading, setBasketLoading] = useState(true)

    useEffect(() => {
        checkAuth()
            .then(data => {
                if (data) {
                    user.login(data)
                }
            })
            .finally(
                () => setUserAuthLoading(false)
            )
        fetchBasket()
            .then(
                data => basket.products = data.products
            )
            .finally(
                () => setBasketLoading(false)
            )
    }, [])

    // показываем loader, пока получаем пользователя и корзину
    if (userAuthLoading || basketLoading) {
        return <Loader />
    }

    return (
        <BrowserRouter>
            <NavBar />
            <AppRouter />
        </BrowserRouter>
    )
})

export default App

Теперь можно удалить как сам HOC-компонент FetchBasket, так и его использование в компоненте NavBar. Но пока получилось не слишком изящно, так что можно немного доработать, используя Promise.all. Будем отправлять сразу два http-запроса и ждать, пока будут получены ответы сервера на оба.

import { BrowserRouter } from 'react-router-dom'
import AppRouter from './components/AppRouter.js'
import NavBar from './components/NavBar.js'
import 'bootstrap/dist/css/bootstrap.min.css'

import { AppContext } from './components/AppContext.js'
import { check as checkAuth } from './http/userAPI.js'
import { useState, useContext, useEffect } from 'react'
import { observer } from 'mobx-react-lite'
import Loader from './components/Loader.js'

import { fetchBasket } from './http/basketAPI.js'

import axios from 'axios'

const App = observer(() => {
    const { user, basket } = useContext(AppContext)
    const [loading, setLoading] = useState(true)

    useEffect(() => {
        Promise.all([checkAuth(), fetchBasket()])
            .then(
                axios.spread((userData, basketData) => {
                    if (userData) {
                        user.login(userData)
                    }
                    basket.products = basketData.products
                })
            )
            .finally(
                () => setLoading(false)
            )
    }, [])

    // показываем loader, пока получаем пользователя и корзину
    if (loading) {
        return <Loader />
    }

    return (
        <BrowserRouter>
            <NavBar />
            <AppRouter />
        </BrowserRouter>
    )
})

export default App

Третий этап

Этот баг заметил еще в 14-ой части. Если перезагрузить страницу корзины кнопкой F5, то на какое-то мгновение было видно сообщение «Ваша корзина пуста». Это происходило потому, что изначально при создании экземпляра BasketStore масссив _products пустой. Потом HOC-компонент FetchBasket получал с сервера содержимое корзины, массив _products изменялся, а поскольку это observable-значение и BasketList обернут в observer — это вызывало новый рендер.

Чтобы избавиться от этого бага — пришлось добавить в компонент BasketList код, который отправлял на сервер запрос для получения корзины. А до тех пор, пока не получен ответ — компонент показывал loader. Теперь, когда корзину мы получаем в компоненте App — нам этот код не нужен. К моменту рендера компонента BasketList данные корзины уже получены с сервера и сохранены в хранилище — так что можно удалить лишний код.

import { useContext, useState } from 'react'
import { AppContext } from './AppContext.js'
import { increment, decrement, remove } from '../http/basketAPI.js'
import { Table, Spinner, Button } from 'react-bootstrap'
import { useNavigate } from 'react-router-dom'
import BasketItem from './BasketItem.js'
import { observer } from 'mobx-react-lite'

const BasketList = observer(() => {
    const { basket } = useContext(AppContext)
    const [fetching, setFetching] = useState(false)

    const navigate = useNavigate()

    const handleIncrement = (id) => {
        setFetching(true)
        increment(id)
            .then(
                data => basket.products = data.products
            )
            .finally(
                () => setFetching(false)
            )
    }

    const handleDecrement = (id) => {
        setFetching(true)
        decrement(id)
            .then(
                data => basket.products = data.products
            )
            .finally(
                () => setFetching(false)
            )
    }

    const handleRemove = (id) => {
        setFetching(true)
        remove(id)
            .then(
                data => basket.products = data.products
            )
            .finally(
                () => setFetching(false)
            )
    }

    if (fetching) {
        return <Spinner animation="border" />
    }

    return (
        <>
            {basket.count ? (
                <>
                    <Table bordered hover size="sm" className="mt-3">
                        <thead>
                            <tr>
                                <th>Наименование</th>
                                <th>Количество</th>
                                <th>Цена</th>
                                <th>Сумма</th>
                                <th>Удалить</th>
                            </tr>
                        </thead>
                        <tbody>
                            {basket.products.map(item => 
                                <BasketItem
                                    key={item.id}
                                    increment={handleIncrement}
                                    decrement={handleDecrement}
                                    remove={handleRemove}
                                    {...item}
                                />
                            )}
                            <tr>
                                <th colSpan="3">Итого</th>
                                <th>{basket.sum}</th>
                                <th>руб.</th>
                            </tr>
                        </tbody>
                    </Table>
                    <Button onClick={() => navigate('/checkout')}>Оформить заказ</Button>
                </>
            ) : (
                <p>Ваша корзина пуста</p>
            )}
        </>
    )
})

export default BasketList

Четвертый этап

Для создания и редактирования категории предназначены компоненты CreateCategory и UpdateCategory, но эти компоненты очень похожи, так что есть смысл объединить их в один компонент EditCategory.

import { useState, useEffect } from 'react'
import { fetchCategories, deleteCategory } from '../http/catalogAPI.js'
import { Button, Container, Spinner, Table } from 'react-bootstrap'
import EditCategory from '../components/EditCategory.js'

const AdminCategories = () => {
    const [categories, setCategories] = useState(null) // список загруженных категорий
    const [fetching, setFetching] = useState(true) // загрузка списка категорий с сервера
    const [show, setShow] = useState(false) // модальное окно создания-редактирования
    // для обновления списка после добавления, редактирования, удаления — изменяем состояние
    const [change, setChange] = useState(false)
    // id категории, которую будем редактировать — для передачи в <EditCategory id={…} />
    const [categoryId, setCategoryId] = useState(null)

    const handleCreateClick = () => {
        setCategoryId(0)
        setShow(true)
    }

    const handleUpdateClick = (id) => {
        setCategoryId(id)
        setShow(true)
    }

    const handleDeleteClick = (id) => {
        deleteCategory(id)
            .then(
                data => {
                    setChange(!change)
                    alert(`Категория «${data.name}» удалена`)
                }
            )
            .catch(
                error => alert(error.response.data.message)
            )
    }

    useEffect(() => {
        fetchCategories()
            .then(
                data => setCategories(data)
            )
            .finally(
                () => setFetching(false)
            )
    }, [change])

    if (fetching) {
        return <Spinner animation="border" />
    }

    return (
        <Container>
            <h1>Категории</h1>
            <Button onClick={() => handleCreateClick()}>Создать категорию</Button>
            <EditCategory id={categoryId} show={show} setShow={setShow} setChange={setChange} />
            {categories.length > 0 ? (
                <Table bordered hover size="sm" className="mt-3">
                    <thead>
                        <tr>
                            <th>Название</th>
                            <th>Редактировать</th>
                            <th>Удалить</th>
                        </tr>
                    </thead>
                    <tbody>
                        {categories.map(item => 
                            <tr key={item.id}>
                                <td>{item.name}</td>
                                <td>
                                    <Button variant="success" size="sm" onClick={() => handleUpdateClick(item.id)}>
                                        Редактировать
                                    </Button>
                                </td>
                                <td>
                                    <Button variant="danger" size="sm" onClick={() => handleDeleteClick(item.id)}>
                                        Удалить
                                    </Button>
                                </td>
                            </tr>
                        )}
                    </tbody>
                </Table>
            ) : (
                <p>Список категорий пустой</p>
            )}
        </Container>
    )
}

export default AdminCategories
import { Modal, Button, Form } from 'react-bootstrap'
import { createCategory, fetchCategory, updateCategory } from '../http/catalogAPI.js'
import { useState, useEffect } from 'react'

const EditCategory = (props) => {
    const { id, show, setShow, setChange } = props

    const [name, setName] = useState('')
    const [valid, setValid] = useState(null)

    useEffect(() => {
        if(id) {
            fetchCategory(id)
                .then(
                    data => {
                        setName(data.name)
                        setValid(data.name !== '')
                    }
                )
                .catch(
                    error => alert(error.response.data.message)
                )
        } else {
            setName('')
            setValid(null)
        }
    }, [id])

    const handleChange = (event) => {
        setName(event.target.value)
        setValid(event.target.value.trim() !== '')
    }

    const handleSubmit = (event) => {
        event.preventDefault()
        /*
         * На первый взгляд кажется, что переменная correct не нужна, можно обойтись valid, но это
         * не так. Нельзя использовать значение valid сразу после изменения этого значения — ф-ция
         * setValid не изменяет значение состояния мгновенно. Вызов функции лишь означает — React
         * «принял к сведению» наше сообщение, что состояние нужно изменить.
         */
        const correct = name.trim() !== ''
        setValid(correct)
        if (correct) {
            const data = {
                name: name.trim()
            }
            const success = (data) => {
                // закрываем модальное окно создания-редактирования категории
                setShow(false)
                // изменяем состояние родителя, чтобы обновить список категорий
                setChange(state => !state)
            }
            const error = (error) => alert(error.response.data.message)
            id ? updateCategory(id, data).then(success).catch(error) : createCategory(data).then(success).catch(error)
        }
    }

    return (
        <Modal show={show} onHide={() => setShow(false)}>
            <Modal.Header closeButton>
                <Modal.Title>{id ? 'Редактирование' : 'Создание'} категории</Modal.Title>
            </Modal.Header>
            <Modal.Body>
                <Form noValidate onSubmit={handleSubmit}>
                    <Form.Control
                        name="name"
                        value={name}
                        onChange={e => handleChange(e)}
                        isValid={valid === true}
                        isInvalid={valid === false}
                        placeholder="Название категории..."
                        className="mb-3"
                    />
                    <Button type="submit">Сохранить</Button>
                </Form>
            </Modal.Body>
        </Modal>
    )
}

export default EditCategory

Пятый этап

Для создания и редактирования бренда предназначены компоненты CreateBrand и UpdateBrand, но эти компоненты очень похожи, так что есть смысл объединить их в один компонент EditBrand.

import { useState, useEffect } from 'react'
import { fetchBrands, deleteBrand } from '../http/catalogAPI.js'
import { Button, Container, Spinner, Table } from 'react-bootstrap'
import EditBrand from '../components/EditBrand.js'

const AdminBrands = () => {
    const [brands, setBrands] = useState(null) // список загруженных брендов
    const [fetching, setFetching] = useState(true) // загрузка списка брендов с сервера
    const [show, setShow] = useState(false) // модальное окно создания-редактирования
    // для обновления списка после добавления, редактирования, удаления — изменяем состояние
    const [change, setChange] = useState(false)
    // id бренда, который будем редактировать — для передачи в <EditBrand id={…} />
    const [brandId, setBrandId] = useState(0)

    const handleCreateClick = () => {
        setBrandId(0)
        setShow(true)
    }

    const handleUpdateClick = (id) => {
        setBrandId(id)
        setShow(true)
    }

    const handleDeleteClick = (id) => {
        deleteBrand(id)
            .then(
                data => {
                    setChange(!change)
                    alert(`Бренд «${data.name}» удален`)
                }
            )
            .catch(
                error => alert(error.response.data.message)
            )
    }

    useEffect(() => {
        fetchBrands()
            .then(
                data => setBrands(data)
            )
            .finally(
                () => setFetching(false)
            )
    }, [change])

    if (fetching) {
        return <Spinner animation="border" />
    }

    return (
        <Container>
            <h1>Бренды</h1>
            <Button onClick={() => handleCreateClick()}>Создать бренд</Button>
            <EditBrand id={brandId} show={show} setShow={setShow} setChange={setChange} />
            {brands.length > 0 ? (
                <Table bordered hover size="sm" className="mt-3">
                <thead>
                    <tr>
                        <th>Название</th>
                        <th>Редактировать</th>
                        <th>Удалить</th>
                    </tr>
                </thead>
                <tbody>
                    {brands.map(item => 
                        <tr key={item.id}>
                            <td>{item.name}</td>
                            <td>
                                <Button variant="success" size="sm" onClick={() => handleUpdateClick(item.id)}>
                                    Редактировать
                                </Button>
                            </td>
                            <td>
                                <Button variant="danger" size="sm" onClick={() => handleDeleteClick(item.id)}>
                                    Удалить
                                </Button>
                            </td>
                        </tr>
                    )}
                </tbody>
                </Table>
            ) : (
                <p>Список брендов пустой</p>
            )}
        </Container>
    )
}

export default AdminBrands
import { Modal, Button, Form } from 'react-bootstrap'
import { createBrand, fetchBrand, updateBrand } from '../http/catalogAPI.js'
import { useState, useEffect } from 'react'

const EditBrand = (props) => {
    const { id, show, setShow, setChange } = props

    const [name, setName] = useState('')
    const [valid, setValid] = useState(null)

    useEffect(() => {
        if(id) {
            fetchBrand(id)
                .then(
                    data => {
                        setName(data.name)
                        setValid(data.name !== '')
                    }
                )
                .catch(
                    error => alert(error.response.data.message)
                )
        } else {
            setName('')
            setValid(null)
        }
    }, [id])

    const handleChange = (event) => {
        setName(event.target.value)
        setValid(event.target.value.trim() !== '')
    }

    const handleSubmit = (event) => {
        event.preventDefault()
        /*
         * На первый взгляд кажется, что переменная correct не нужна, можно обойтись valid, но это
         * не так. Нельзя использовать значение valid сразу после изменения этого значения — ф-ция
         * setValid не изменяет значение состояния мгновенно. Вызов функции лишь означает — React
         * «принял к сведению» наше сообщение, что состояние нужно изменить.
         */
        const correct = name.trim() !== ''
        setValid(correct)
        if (correct) {
            const data = {
                name: name.trim()
            }
            const success = (data) => {
                // закрываем модальное окно создания-редактирования бренда
                setShow(false)
                // изменяем состояние родителя, чтобы обновить список брендов
                setChange(state => !state)
            }
            const error = (error) => alert(error.response.data.message)
            id ? updateBrand(id, data).then(success).catch(error) : createBrand(data).then(success).catch(error)
        }
    }

    return (
        <Modal show={show} onHide={() => setShow(false)}>
            <Modal.Header closeButton>
                <Modal.Title>{id ? 'Редактирование' : 'Создание'} бренда</Modal.Title>
            </Modal.Header>
            <Modal.Body>
                <Form noValidate onSubmit={handleSubmit}>
                    <Form.Control
                        name="name"
                        value={name}
                        onChange={e => handleChange(e)}
                        isValid={valid === true}
                        isInvalid={valid === false}
                        placeholder="Название бренда..."
                        className="mb-3"
                    />
                    <Button type="submit">Сохранить</Button>
                </Form>
            </Modal.Body>
        </Modal>
    )
}

export default EditBrand

Шестой этап

Надо бы и компоненты создания-редактирования товаров и характеристик переделать, но честно говоря, уже устал — никак не ожидал, что разработка учебного проекта так затянется. Так что на этом — все, game over.

Вместо заключения

Исходные коды — здесь, демо работы приложения — ниже:

Поиск: Backend • Express.js • Frontend • JavaScript • Node.js • ORM • React.js • Web-разработка • База данных • Интернет магазин • Каталог товаров • Корзина • Фреймворк

Каталог оборудования
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.
Производители
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.
Функциональные группы
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.