001 Компонент отзыва
Сейчас нужно реализовать компонент отзыва.
Он будет в себя принимать те же пропсы, что описаны в ReviewModel
components / Review / Review.props.ts
import { DetailedHTMLProps, HTMLAttributes } from 'react';
import { ReviewModel } from '../../interfaces/product.interface';
export interface ReviewProps
extends DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement> {
review: ReviewModel;
}
Сам компонент обзора:
components / Review / Review.tsx
import React from 'react';
import cn from 'classnames';
import { format } from 'date-fns';
import { ru } from 'date-fns/locale';
import styles from './Review.module.css';
import UserIcon from './user.svg';
import { ReviewProps } from './Review.props';
import { Rating } from '../Rating/Rating';
export const Review = ({ review, className, ...props }: ReviewProps): JSX.Element => {
const { name, title, rating, description, createdAt } = review;
return (
<div className={cn(styles.review, className)} {...props}>
<UserIcon className={styles.user} />
<div className={styles.title}>
<span className={styles.name}>{name}:</span>
<span>{title}</span>
</div>
<div className={styles.date}>
{/*
* создаём новую дату из createdAt,
* далее указываем дату (заглавные - пропись)
* и потом указываем язык
*/}
{format(new Date(createdAt), 'dd MMMM yyyy', { locale: ru })}
</div>
<div className={styles.rating}>
<Rating rating={rating} />
</div>
<div className={styles.description}>{description}</div>
</div>
);
};
Стили компонента обзора:
components / Review / Review.module.css
.review {
display: grid;
grid-template-columns: [start] auto 1fr auto auto [end];
align-items: center;
gap: 10px;
font-size: 14px;
line-height: 24px;
}
.description {
grid-column: start / end;
}
.date {
margin-right: 10px;
}
.name {
font-weight: bold;
}
@media (max-width: 640px) {
.review {
grid-template-columns: [start] 30px [titlestart] auto [dateend] 1fr [end];
}
.title {
grid-column: titlestart / end;
}
.date {
grid-column: start / dateend;
}
}
@media (max-width: 480px) {
.rating {
grid-column: start / end;
}
.date {
grid-column: start / end;
}
}
Сокращаем путь до компонента
components / index.ts
export * from './Review/Review';
Компонент отзыва будет выводить в отдельной карточке, которая будет изначально закрыта. Для открытия и закрытия будем использовать useState
, который будет изменяться при нажатии на кнопку в первой карточке
components / Product / Product.tsx
export const Product = ({ product }: ProductProps): JSX.Element => {
const [isReviewOpened, setIsReviewOpened] = useState<boolean>(false);
return (
<>
{/* обернём продукт в карточку */}
<Card className={styles.product}>
/// CODE ....
{/* тут будут находиться кнопки */}
<div className={styles.actions}>
<Button appearance={'primary'}>Узнать подробнее</Button>
<Button
appearance={'ghost'}
arrow={isReviewOpened ? 'down' : 'right'}
className={styles.review}
{/* меняем состояние отзывов */}
onClick={() => setIsReviewOpened(!isReviewOpened)}
>
Читать отзывы
</Button>
</div>
</Card>
<Card
color={'blue'}
className={cn(styles.reviews, {
[styles.opened]: isReviewOpened,
[styles.closed]: !isReviewOpened,
})}
>
{product.reviews.map(r => (
<>
<Review key={r._id} review={r} />
<Divider />
</>
))}
</Card>
</>
);
};
Стили для открытого и закрытого меню с отзывами
components / Product / Product.module.css
/*
* Вторая карточка
*/
.opened {
max-height: auto;
padding: 30px;
}
/* чтобы нормально заанимировать изменение высоты, нам нельзя использовать display: none */
.closed {
max-height: 0;
padding: 0;
overflow: hidden;
}
Итог:
002 Форма отзыва
Компонент формы будет на вход принимать в себя id
продукта, к которому он относится
components / ReviewForm / ReviewForm.props.ts
import { DetailedHTMLProps, HTMLAttributes } from 'react';
export interface ReviewFormProps
extends DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement> {
productId: string;
}
Сама форма добавления нового отзыва состоит из уже подготовленных компонентов ввода Input
с Textarea
и выглядит следующим образом:
components / ReviewForm / ReviewForm.tsx
import React from 'react';
import cn from 'classnames';
import styles from './ReviewForm.module.css';
import { ReviewFormProps } from './ReviewForm.props';
import { Input } from '../Input/Input';
import { Rating } from '../Rating/Rating';
import { Textarea } from '../Textarea/Textarea';
import { Button } from '../Button/Button';
import CloseIcon from './close.svg';
export const ReviewForm = ({ productId, className, ...props }: ReviewFormProps): JSX.Element => {
return (
<>
<div className={cn(styles.reviewForm, className)} {...props}>
<Input placeholder={'Имя'} />
<Input placeholder={'Заголовок'} className={styles.title} />
<div className={styles.rating}>
<span>Оценка:</span>
<Rating isEditable={true} rating={0} />
</div>
<Textarea placeholder={'Текст отзыва'} className={styles.description} />
<div className={styles.submit}>
<Button className={styles.button} appearance={'primary'}>
Отправить
</Button>
<span className={styles.info}>
* Перед публикацией отзыв пройдет предварительную модерацию и проверку
</span>
</div>
</div>
{/* это оповещение, которое отправится после сабмита нового отзыва */}
<div className={styles.success}>
<div className={styles.successTitle}>Ваш отзыв отправлен!</div>
<div className={styles.successDescription}>
Спасибо, ваш отзыв будет опубликован после проверки.
</div>
<CloseIcon className={styles.close} />
</div>
</>
);
};
Сокращаем путь до компонента
components / index.ts
export * from './ReviewForm/ReviewForm';
Стили компонента с адаптивом:
components / ReviewForm / ReviewForm.module.css
.reviewForm {
display: grid;
grid-template-columns: [start] auto 1fr auto [end];
align-items: center;
gap: 20px 30px;
margin-bottom: 20px; /* отбиваем снизу уведомление об отправке */
font-size: 14px;
line-height: 24px;
}
.description, .submit {
grid-column: start / end;
}
.title {
/* инпут с заголовком прибился к левому краю */
justify-self: left;
}
.button {
margin-right: 20px;
}
.rating {
display: grid;
grid-template-columns: auto 1fr;
gap: 20px;
}
.success {
position: relative;
padding: 20px;
border-radius: 5px;
background: var(--green-light);
}
.successTitle {
font-weight: bold;
}
.close {
position: absolute;
top: 20px;
right: 20px;
cursor: pointer;
}
@media (max-width: 1100px) {
.reviewForm {
grid-template-columns: [start] 1fr 1fr [end];
}
.title {
/* чтобы инпут с заголовком не прибивался к левому краю */
justify-self: normal;
}
.rating {
grid-column: start / end;
}
}
@media (max-width: 640px) {
.reviewForm {
grid-template-columns: [start] 1fr [end];
}
}
И тут мы выводим форму добавления обзора ReviewForm
components / Product / Product.tsx
<Card
color={'blue'}
className={cn(styles.reviews, {
[styles.opened]: isReviewOpened,
[styles.closed]: !isReviewOpened,
})}
>
{product.reviews.map(r => (
<>
<Review key={r._id} review={r} />
<Divider />
</>
))}
{/* форма написания обзора */}
<ReviewForm productId={product._id} />
</Card>
Итог:
003 useForm
Первым делом нужно сказать, что события реакта и события в DOM - это отдельные вещи. Между обоими деревьями есть прослойка, которая проксирует их взаимодействия (ветка реакта / ветка html и между ними сравнение)
При работе с формами внутри реакта, мы можем работать собственными силами или использовать готовые библиотеки, которые воспроизводят нужный нам функционал. Минусом второго подхода является то, что библиотека несёт за собой дополнительную зависимость и дополнительный вес на страницу, а так же эту зависимость нужно поддерживать и своевременно обновлять.
Если:
- Формы присутствуют только на регистрации или они простые, то самым оптимальным решением будет самому реализовать формы
- Формы используются в большом количестве (работа с паспортными данными или CRM-система), то тут уже можно использовать библиотеку
Управляемый компонент - это компонент, значения которого управляются в стейте этого же компонента.
Если мы возьмём обычный <input>
, то его значение хранится в DOM-дереве. Если мы берём тот же компонент <Rating />
, то его состояние (значение рейтинга) хранится в самом компоненте рейтинга.
В проекте будет использоваться React Hook Form
Параметры, которые возвращает хук формы useForm
:
Оформление неуправляемых компонентов:
Пример оформления управляемого компонента: контроллер указывает на то, чем он будет управлять
004 Работа с формами
Прежде всего нужно поправить старые ошибки. Если мы используем списки и выводим повторяющиеся элементы, то нам нужно атрибут key
переносить на самый внешний элемент
Далее нужно установить модуль работы с формами внутри реакта
npm i react-hook-form
Далее нужно определить, что будет получать форма. В неё будет попадать имя пользователя, заголовок, рейтинг и описание
components / ReviewForm / ReviewForm.interface.ts
export interface IReviewForm {
name: string;
title: string;
description: string;
rating: number;
}
Далее мы создаём хук формы, который принимает в себя вышеописанный интерфейс, тем самым определяя, какие поля в форме будут
Чтобы привязать неуправляемые компоненты к форме, придётся воспользоваться привязкой через {...register('name')}
деструктуризацию объекта функции регистрации, куда мы передаём имя формы
Далее, чтобы сделать рейтинг управляемым, его нужно сложить в контроллер, который привязан к нашему useForm
. Стейт хранить будет сам рейтинг, а управлять стейтом будет контроллер, в который вложен рейтинг.
components / ReviewForm / ReviewForm.tsx
export const ReviewForm = ({ productId, className, ...props }: ReviewFormProps): JSX.Element => {
// register - регистрирует форму
// control - регистрирует управляемые формы
// handleSubmit - функция хэндлинга сабмита
const { register, control, handleSubmit } = useForm<IReviewForm>();
const onSubmit = (data: IReviewForm) => {
console.log(data);
};
return (
// при отправке формы, нужно вложить функцию, которую мы получаем из useForm, и вложить в неё функцию, которая вызовется после сабмита
<form onSubmit={handleSubmit(onSubmit)}>
<div className={cn(styles.reviewForm, className)}
{...props}
>
<Input {...register('name')} placeholder='Имя' />
<Input {...register('title')} placeholder='Заголовок отзыва' className={styles.title} />
<div className={styles.rating}>
<span>Оценка:</span>
<Controller
control={control}
name='rating'
render={({ field }) => (
<Rating
isEditable
ref={field.ref}
rating={field.value}
setRating={field.onChange}
/>
)}
/>
</div>
<Textarea {...register('description')} placeholder='Текст отзыва' className={styles.description} />
<div className={styles.submit}>
<Button appearance="primary">Отправить</Button>
<span className={styles.info}>* Перед публикацией отзыв пройдет предварительную модерацию и проверку</span>
</div>
</div>
{/* это оповещение, которое отправится после сабмита нового отзыва */}
<div className={styles.success}>
<div className={styles.successTitle}>Ваш отзыв отправлен</div>
<div>
Спасибо, ваш отзыв будет опубликован после проверки.
</div>
<CloseIcon className={styles.close} />
</div>
</form>
);
};
Осталось только решить проблему с пробросом ref и мы будем получать все данные с форм
005 Проброс ref
reference определённого элемента позволяет нам:
Чтобы передать ref
в компонент, нужно саму функцию компонента обернуть в функцию forwardRef
, которая позволит передать ему этот пропс ref
внутрь компонента в качестве атрибута
Прокидываем ref
через forwardRef
в Textarea
components / Textarea / Textarea.tsx
export const Textarea = forwardRef(
(
{ className, ...props }: TextareaProps,
ref: ForwardedRef<HTMLTextAreaElement>,
): JSX.Element => {
return <textarea ref={ref} className={cn(className, styles.input)} {...props} />;
},
);
Прокидываем ref
через forwardRef
в Input
components / Input / Input.tsx
export const Input = forwardRef(
({ className, ...props }: InputProps,
ref: ForwardedRef<HTMLInputElement>
): JSX.Element => {
return <input ref={ref} className={cn(className, styles.input)} {...props} />;
},
);
Прокидываем ref
через forwardRef
в компонент рейтинга
components / Rating / Rating.tsx
export const Rating = forwardRef(
(
{ isEditable = false, rating, setRating, ...props }: RatingProps,
ref: ForwardedRef<HTMLDivElement>,
): JSX.Element => {
const [ratingArray, setRatingArray] = useState<JSX.Element[]>(new Array(5).fill(<></>));
/// CODE ...
return (
<div {...props} ref={ref}>
{ratingArray.map((r, i) => (
<span key={i}>{r}</span>
))}
</div>
);
},
);
И сейчас мы можем получить данные из неуправляемых компонентов
006 Обработка ошибок
Компонент useForm
может вернуть нам состояние формы formState
. Из этого состояния нам нужно только одно свойство { errors }
, которое мы получаем, когда формы встретились с поведением, которое приводит к ошибке (нам нужно, чтобы формы всегда были заполнены данными).
Конкретно в каждом неуправляемом компоненте формы мы добавляем в функцию register
объект с опциями { required: { value: true, message: 'Заполните имя' } }
, в которой передаём требования данных формы required
, где указываем обязательным, чтобы в формы обязательно попадало значение value
. Если value
нет, то выводим ошибку, которую укажем в message
.
Если нам нужно накинуть данные требования внутрь компонента, который находится внутри <Controller>
, то эти требования нужно внести в атрибут rules
.
Далее нам нужно передать ошибки errors
, которые мы получили из состояния formState
, внутрь компонентов через атрибут error
.
components / ReviewForm / ReviewForm.tsx
export const ReviewForm = ({ productId, className, ...props }: ReviewFormProps): JSX.Element => {
const {
register,
control,
handleSubmit,
// тут получаем ошибки из состояния формы
formState: { errors },
} = useForm<IReviewForm>();
const onSubmit = (data: IReviewForm) => {
console.log(data);
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
<div className={cn(styles.reviewForm, className)} {...props}>
<Input
{...register('name',
{ required:
{ value: true, message: 'Заполните имя' }
})}
placeholder='Имя'
error={errors.name}
/>
<Input
// Указываем требования по обязательным условиям полей
{...register('title', {
required: { value: true, message: 'Заполните заголовок' },
})}
placeholder='Заголовок отзыва'
className={styles.title}
//передаём ошибку внутрь компонента
error={errors.title}
/>
<div className={styles.rating}>
<span>Оценка:</span>
<Controller
control={control}
name='rating'
// аналог атрибута опций функции register
rules={{
required: { value: true, message: 'Укажите рейтинг' },
}}
render={({ field }) => (
<Rating
isEditable
ref={field.ref}
rating={field.value}
setRating={field.onChange}
error={errors.rating}
/>
)}
/>
</div>
<Textarea
{...register('description', {
required: { value: true, message: 'Заполните заголовок' },
})}
placeholder='Текст отзыва'
className={styles.description}
error={errors.description}
/>
<div className={styles.submit}>
<Button appearance='primary'>Отправить</Button>
<span className={styles.info}>
* Перед публикацией отзыв пройдет предварительную модерацию и проверку
</span>
</div>
</div>
<div className={styles.success}>
<div className={styles.successTitle}>Ваш отзыв отправлен</div>
<div>Спасибо, ваш отзыв будет опубликован после проверки.</div>
<CloseIcon className={styles.close} />
</div>
</form>
);
};
Далее нужно в интерфейсах компонентов добавить пропс error
, который мы передаём из родительского компонента, чтобы иметь возможность написать логику реагирования компонента на попадение в него ошибки
components / Textarea / Textarea.props.ts
import { DetailedHTMLProps, TextareaHTMLAttributes } from 'react';
import { FieldError } from 'react-hook-form';
export interface TextareaProps
extends DetailedHTMLProps<TextareaHTMLAttributes<HTMLTextAreaElement>, HTMLTextAreaElement> {
error?: FieldError;
}
components / Input / Input.props.ts
export interface InputProps
extends DetailedHTMLProps<InputHTMLAttributes<HTMLInputElement>, HTMLInputElement> {
error?: FieldError;
}
components / Rating / Rating.props.ts
export interface RatingProps
extends DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement> {
isEditable?: boolean;
rating: number;
setRating?: (rating: number) => void;
error?: FieldError;
}
Далее мы должны задать определённый стиль для самих input
, Rating
и textarea
, если ошибка в принципе была передана, а так же вывести саму ошибку под компонентом формы
Инпут:
components / Input / Input.tsx
export const Input = forwardRef(
(
{ className, error, ...props }: InputProps,
ref: ForwardedRef<HTMLInputElement>,
): JSX.Element => {
return (
<div className={cn(className, styles.inputWrapper)}>
<input
ref={ref}
className={cn(styles.input, {
[styles.error]: error,
})}
{...props}
/>
{error && <span className={styles.errorMessage}>{error.message}</span>}
</div>
);
},
);
components / Input / Input.module.css
.inputWrapper {
position: relative;
}
.input {
padding: 7px 15px;
color: var(--black);
border: none;
outline-color: var(--primary);
background: var(--white);
box-shadow: 0px 4px 4px rgba(0, 0, 0, 0.05);
border-radius: 5px;
font-size: 16px;
line-height: 22px;
font-family: var(--font-family);
}
.input::placeholder {
color: var(--gray);
}
.error {
border: 1px solid var(--red);
}
.errorMessage {
position: absolute;
bottom: -20px;
left: 12px;
color: var(--red);
}
Текстареа:
components / Textarea / Textarea.tsx
export const Textarea = forwardRef(
(
{ error, className, ...props }: TextareaProps,
ref: ForwardedRef<HTMLTextAreaElement>,
): JSX.Element => {
return (
<div className={cn(styles.textareaWrapper, className)}>
<textarea
ref={ref}
className={cn(styles.textarea, {
[styles.error]: error,
})}
{...props}
/>
{error && <span className={styles.errorMessage}>{error.message}</span>}
</div>
);
},
);
components / Textarea / Textarea.module.css
.textarea {
padding: 7px 15px;
width: 100%;
color: var(--black);
border: none;
outline-color: var(--primary);
background: var(--white);
box-shadow: 0px 4px 4px rgba(0, 0, 0, 0.05);
border-radius: 5px;
font-size: 16px;
line-height: 22px;
font-family: var(--font-family);
}
.textarea::placeholder {
color: var(--gray);
}
.textareaWrapper {
position: relative;
}
.error {
border: 1px solid var(--red);
}
.errorMessage {
position: absolute;
bottom: -15px;
left: 12px;
color: var(--red);
}
Компонент рейтинга:
components / Rating / Rating.tsx
export const Rating = forwardRef(
(
{ isEditable = false, className, error, rating, setRating, ...props }: RatingProps,
ref: ForwardedRef<HTMLDivElement>,
): JSX.Element => {
const [ratingArray, setRatingArray] = useState<JSX.Element[]>(new Array(5).fill(<></>));
/// CODE ...
return (
<div
className={cn(className, styles.ratingWrapper, {
[styles.error]: error,
})}
{...props}
ref={ref}
> {ratingArray.map((r, i) => (
<span key={i}>{r}</span>
))}
{error && <span className={styles.errorMessage}>{error.message}</span>}
</div>
);
},
);
components / Rating / Rating.module.css
.filled svg {
fill: var(--primary);
}
.star svg {
margin-right: 5px;
}
.editable {
cursor: pointer;
}
.ratingWrapper {
position: relative;
}
.error {
border: 1px solid var(--red);
border-radius: 5px;
}
.errorMessage {
position: absolute;
bottom: -20px;
left: 5px;
color: var(--red);
}
Итог: теперь при нажатии на кнопку отправки формы у нас появляются сообщения у пустых форм и указатель сразу попадает на первую пустую форму, чтобы пользователь смог сразу начать её вводить
008 Отправка запроса со страницы
Чтобы упростить себе жизнь и не прописывать пути для запросов в axios
, можно реализовать отдельный объект, который будет хранить все нужные пути для общения с сервером
helpers / api.ts
export const API = {
topPage: {
find: process.env.NEXT_PUBLIC_DOMAIN + '/api/top-page/find',
byAlias: process.env.NEXT_PUBLIC_DOMAIN + '/api/top-page/byAlias/',
},
product: {
find: process.env.NEXT_PUBLIC_DOMAIN + '/api/product/find/',
},
review: {
createDemo: process.env.NEXT_PUBLIC_DOMAIN + '/api/review/create-demo',
},
};
Далее нам нужно заменить всю логику с хардкожеными путями запросов на наш API
объект:
pages / search.tsx
export const getStaticProps: GetStaticProps<HomeProps> = async () => {
const firstCategory = 0;
const { data: menu } = await axios.post<MenuItem[]>(API.topPage.find, {
firstCategory,
});
return {
props: {
menu,
firstCategory,
},
};
};
pages / index.tsx
export const getStaticProps: GetStaticProps<HomeProps> = async () => {
const firstCategory = 0;
const { data: menu } = await axios.post<MenuItem[]>(API.topPage.find, {
firstCategory,
});
return {
props: {
menu,
firstCategory,
},
};
};
pages / [type] / index.tsx
export const getStaticProps: GetStaticProps<TypeProps> = async ({
params,
}: GetStaticPropsContext<ParsedUrlQuery>) => {
if (!params) {
return {
notFound: true,
};
}
const firstCategoryItem = firstLevelMenu.find(m => m.route == params.type);
if (!firstCategoryItem) {
return {
notFound: true,
};
}
const { data: menu } = await axios.post<MenuItem[]>(API.topPage.find, {
firstCategory: firstCategoryItem.id,
});
return {
props: {
menu,
firstCategory: firstCategoryItem.id,
},
};
};
pages / [type] / [alias].tsx
export const getStaticPaths: GetStaticPaths = async () => {
let paths: string[] = [];
for (const m of firstLevelMenu) {
const { data: menu } = await axios.post<MenuItem[]>(API.topPage.find, {
firstCategory: m.id,
});
paths = paths.concat(menu.flatMap(s => s.pages.map(p => `/${m.route}/${p.alias}`)));
}
return {
paths,
fallback: true,
};
};
export const getStaticProps: GetStaticProps<TopPageProps> = async ({
params,
}: GetStaticPropsContext<ParsedUrlQuery>) => {
if (!params) {
return {
notFound: true,
};
}
const firstCategoryItem = firstLevelMenu.find(m => m.route == params.type);
if (!firstCategoryItem) {
return {
notFound: true,
};
}
try {
const { data: menu } = await axios.post<MenuItem[]>(API.topPage.find, {
firstCategory: firstCategoryItem.id,
});
if (menu.length == 0) {
return {
notFound: true,
};
}
const { data: page } = await axios.get<TopPageModel>(API.topPage.byAlias + params.alias);
const { data: products } = await axios.post<ProductModel[]>(API.product.find, {
category: page.category,
limit: 10,
});
return {
props: {
menu,
firstCategory: firstCategoryItem.id,
page,
products,
},
};
} catch {
return {
notFound: true,
};
}
};
Далее опишем интерфейс, который определит получаемый ответ от сервера. Его мы будем использовать в axios
components / ReviewForm / ReviewForm.interface.ts
export interface IReviewSentResponse {
message: string;
}
И сейчас мы реализуем логику отправки данных на сервер. Первым делом, расширим функционал onSubmit
, которая сейчас принимает в себя объект данных с формы и через axios
отправляет по нашей описанной апишке эти данные на сервер.
Если сервер прислал ответ, что сообщение отправлено, то мы проверяем по условию наличие этого ответа. В зависимости от наличия ответа нужно либо очистить форму и уведомить пользователя об успешной отправке данных (через стейт успеха), либо записать ошибку в стейт ошибки и вывести её.
Из хука useForm
мы так же можем получить функцию reset
, которая очистит все поля формы
Примечание: сейчас кнопка закрытия уведомления так же работает
components / ReviewForm / ReviewForm.tsx
export const ReviewForm = ({ productId, className, ...props }: ReviewFormProps): JSX.Element => {
const {
register,
control,
handleSubmit,
formState: { errors },
// функция очистки данных формы
reset,
} = useForm<IReviewForm>();
// состояние отправленности данных формы
const [isSuccess, setIsSuccess] = useState<boolean>();
// состояние наличия ошибки отправки данных
const [error, setError] = useState<string>();
// тут будет совершаться отправка данных с формы на сервер
const onSubmit = async (formData: IReviewForm) => {
try {
// будем отправлять отзыв на сервер, обогащая запрос от форм данными об ID продукта
const { data } = await axios.post<IReviewSentResponse>(API.review.createDemo, {
...formData,
productId,
});
// если мы получили ответ от сервера
if (data.message) {
setIsSuccess(true);
reset();
} else {
setError('Что-то пошло не так...');
}
} catch (e) {
setError(e.message);
}
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
{/* CODE ... */}
{isSuccess && (
<div className={cn(styles.panel, styles.success)}>
<div className={styles.successTitle}>Ваш отзыв отправлен</div>
<div>Спасибо, ваш отзыв будет опубликован после проверки.</div>
<CloseIcon className={styles.close} onClick={() => setIsSuccess(false)} />
</div>
)}
{error && (
<div className={cn(styles.panel, styles.error)}>
Что-то пошло не так. Пожалуйста, обновите страницу.
<CloseIcon className={styles.close} onClick={() => setError(undefined)} />
</div>
)}
</form>
);
};
Стили для панелек уведомления и кнопок:
components / ReviewForm / ReviewForm.module.css
.success {
background: var(--green-light);
}
.error {
background: var(--red-light);
}
.error .close path {
stroke: var(--red);
}
Итог: теперь форма может вернуть ошибку пользователю
009 useRef
Хук useRef
создаёт контейнер, который позволяет нам использовать все атрибуты элемента, который в нём находится
Если мы хотим навесить ref
на стандартный элемент, то это можно сделать просто через атрибут ref={refElement}
. Если нам нужно накинуть его на функциональный компонент, то нам уже будет нужно обернуть сам компонент внутри в forwardRef()
В компоненте продукта создадим useRef
, в который присвоим нашу ссылку из отзывов. Далее реализуем функцию scrollToReview
, которая сначала откроет список отзывов, а затем вызовет скролл до этого элемента
components / Product / Product.tsx
export const Product = ({ product, className, ...props }: ProductProps): JSX.Element => {
const [isReviewOpened, setIsReviewOpened] = useState<boolean>(false);
// создаём контейнер рефа
const reviewRef = useRef<HTMLDivElement>(null);
const scrollToReview = () => {
setIsReviewOpened(true);
// вызываем АПИ-функцию, которая скроллит до элемента
reviewRef.current?.scrollIntoView({
behavior: 'smooth', // поведение
block: 'center', // докуда нужно скроллить
});
};
return (
<div className={className} {...props}>
{/* CODE ... */}
<div className={styles.rateTitle}>
<a href='#ref' onClick={scrollToReview}>
{product.reviewCount}{' '}
{declOfNum(product.reviewCount, ['отзыв', 'отзыва', 'отзывов'])}
</a>
</div>
{/* CODE ... */}
</div>
);
};
Поменяем цвет ссылки
components / Product / Product.module.css
.rateTitle a {
color: var(--primary);
}
Далее обернём карточку в forwardRef
, чтобы прокинуть ref
components / Card / Card.tsx
import { CardProps } from './Card.props';
import styles from './Card.module.css';
import cn from 'classnames';
import { ForwardedRef, forwardRef } from 'react';
export const Card = forwardRef(
(
{ color = 'white', children, className, ...props }: CardProps,
ref: ForwardedRef<HTMLDivElement>,
): JSX.Element => {
return (
<div
ref={ref}
className={cn(styles.card, className, {
[styles.blue]: color == 'blue',
})}
{...props}
> {children}
</div>
);
},
);
010 Упражнение - Исправление бага useReducer
И тут был замечен баг: при переходе на другую страницу у нас обновляется ссылка, но не обновляется список продуктов. Проблема сейчас заключается в том, что мы используем список продуктов из кеша сайта.
Некст на клиенте использует клиентский роутинг, да и работает как обычное реакт-приложение. Если посмотреть на страницу продуктов, то тут можно увидеть, что useReducer
проинициализировался один раз и в нём были использованы значения одних продуктов, которые в него поступили - и больше он не обновлялся!
Поэтому нужно реализовать подписку на изменение продуктов (через экшен, который будет за этим следить), чтобы обновлять стейт у useReducer
. Сейчас этого не происходит, так как у нас произошёл один Initial State в рамках продуктов.
Чтобы добавить ещё один экшен, который будет выполняться под наши действия, нужно в SortActions
добавить ещё один тайп, который будет принимать тип сброса и начальный массив объектов. Далее в sortReducer
мы указываем действие под выбранный тайп, где указываем тип сортировки и обновлённый массив продуктов
sort.reducer.ts
export type SortActions =
| { type: SortEnum }
| { type: SortEnum.Rating }
| { type: 'reset'; initialState: ProductModel[] };
export const sortReducer = (state: SortReducerState, action: SortActions): SortReducerState => {
switch (action.type) {
case SortEnum.Rating:
return {
sort: SortEnum.Rating,
products: state.products.sort((a, b) =>
a.initialRating > b.initialRating ? -1 : 1,
),
};
case SortEnum.Price:
return {
sort: SortEnum.Price,
products: state.products.sort((a, b) => (a.price > b.price ? 1 : -1)),
};
case 'reset':
return {
sort: SortEnum.Price,
products: action.initialState,
};
default:
throw new Error('Неверный тип сортировки');
}
};
Теперь нам нужно в компоненте страниц добавить useEffect
, который будет сбрасывать диспетч при изменении пропса продукта