Сайт визитка на Angular. Модуль товаров.
В данной статье будет представлен обзор модуля товаров с добавлением Ngrx feature state
, а также разработкой компонентов для отображения списка товаров и карточки товара.
Базовые интерфейсы
Создадим базовые интерфейсы для товаров, в которых опишем интерфейс самого товара, а также вспомогательные интерфейсы для загрузки данных по API.
Добавим новую библиотеку:
nx g lib products/common
Создадим новый файл product.interface.ts
:
ProductKeys
— список ключей для работы сlocalStorage
. В данном случае будем сохранять в локальное хранилище список товаров.PRODUCTS_META
— специальный ключ для обмена данными между платформами Angular, в частности между серверной и браузерной версиями Angular.Product
— интерфейс товара, который включает следующие поля:id
,title
,subtitle
,price
,sizes
,description
,photos
,slug
.ProductsResponse
— формат ответа от сервера, в частности ответ при запросе листа в Google Sheets.ProductField
— набор полей формы продуктов. Иногда удобно иметьenum
со списком доступных полей и.
Product Api Service
Далее создадим сервис, который будет загружать данные из Google Sheets.
Создадим библиотеку для ProductApiService
:
nx g lib products/api
Сгенерируем сервис:
nx g s product-api --project=products-api
В данном случае реализация будет следующей:
В сервисе имеется один метод — load
, который и загружает все данные:
load(): Observable<Product[]> {
if (!this.environmentService.environments.google?.key) {
console.warn('Google Sheet was not loaded. Check your envs.');
return of([]);
}
return this.apiService.get<ProductsResponse>(PRODUCT_API_ROUTES.load(this.environmentService.environments.google)).pipe(
map((response: ProductsResponse) =>
response.values.map(([slug, title, subtitle, price, sizes, description, photos], index) => ({
id: index + 1,
slug: slug.trim(),
title: title.trim(),
subtitle: subtitle.trim(),
price: Number(price.trim()),
sizes: sizes.split(',').map((size) => Number(size.trim())),
description: description.trim(),
photos: photos.split('\n').map((photo) => photo.trim()),
}))
)
);
}
Конечно, каст ответа можно вынести в отдельную функцию, но явной потребности в этом нет.
export function castProducts(response: ProductsResponse): Product[] {
return response.values.map(([slug, title, subtitle, price, sizes, description, photos], index) => ({
id: index + 1,
slug: slug.trim(),
title: title.trim(),
subtitle: subtitle.trim(),
price: Number(price.trim()),
sizes: sizes.split(',').map((size) => Number(size.trim())),
description: description.trim(),
photos: photos.split('\n').map((photo) => photo.trim()),
}));
}
Тогда метод будет следующим:
load(): Observable<Product[]> {
if (!this.environmentService.environments.google?.key) {
console.warn('Google Sheet was not loaded. Check your envs.');
return of([]);
}
return this.apiService
.get<ProductsResponse>(PRODUCT_API_ROUTES.load(this.environmentService.environments.google))
.pipe(map((response) => castProducts(response)));
}
В данном случае имеем запрос вида:
`https://sheets.googleapis.com/v4/spreadsheets/${payload.id}/values/${payload.name}?key=${payload.key}`,
где свойства payload
:
id
— ID таблицы;name
— Имя листа в таблице;key
— ключ доступа к Google Sheets.
Если пришел успешный ответ от сервера, то все данные таблицы лежат в виде строк, поэтому зная порядок, можно вытащить все значения.
Если просмотреть на маппинг, то там можно заметить вызов trim, для всех текстовых полей. Это необходимо для устранения ошибок связанных с лишними пробелами.
ApiService
это всего лишь обертка над стандартным HttpClient
’ом:
get<T = void>(url: string, options?: Partial<ApiRequestOptions>): Observable<T> {
return this.httpClient.get<T>(this.makeUrl(url), getApiRequestOptions(options));
}
Products State Module
Добавим Ngrx
feature state в приложение.
Для этого сначала сгенерируем новую библиотеку:
nx g lib products/state
Затем сгенерируем новый feature state:
nx g @nrwl/angular:ngrx product --module=libs/products/state/src/lib/products-state.module.ts
По умолчанию Nx все state генерирует в папке +state
.
Это актуально, когда у вас в библиотеке много разного кода и нужно чтобы state всегда был вверху иерархии.
Так как в feature state
будет располагаться только state
, упростим структуру и перенесем файлы из +state
на уровень выше.
Теперь изменим product.actions.ts
:
init
— экшен, который будет запускаться при созданииfeature state
. В данном случае его назначение вызвать цепочку событий, для загрузки данных изlocalStorage
с помощью эффектов.restore
— экшен, который будет устанавливать восстановленное значение для товаров. В данном случае, если товары раньше загружались и они есть вlocalStorage
, то в приложение передается это значение. Это необходимо для того, чтобыUI
не ждал реального запроса от сервера с данными.load
— экшен, который инициирует загрузку продуктов.loadSuccess
— экшен, который вызывается, если пришел успешный ответ от сервера.loadFailure
— экшен, который вызывается, если пришла ошибка от сервера.
Далее изменим product.reducer.ts
:
Правки, которые были внесены:
- переименован интерфейс
State
вProductState
; - переименована переменная
initialState
вproductInitialState
; - в reducer’е были оставлены и добавлены следующие события:
restore
,loadSuccess
иloadFailure
.
Экшены restore
и loadSuccess
приводят к установки значений товаров в state, а также все перечисленные экшены в reducer’е добавляют флаг, что товары загружены.
Подкорректируем product.selectors.ts
:
В соответствии с рекомендациями eslint-plugin-ngrx
, переименовали все selector’ы, и добавили ряд новых.
selectProducts
— селектор, который возвращает список товаров;selectProductsEntities
— селектор, который возвращает словарь с товарами, где ключом являетсяID
;selectLoaded
— селектор, который возвращает флаг загрузки товаров;selectSelectedId
— селектор, который возвращаетID
выбранного товара, но в данной реализации, оно не используется;selectSelectedProduct
— селектор, который возвращает выбранный товарselectProduct
— селектор, который возвращает товар поID
;selectProductBySlug
— селектор, который возвращает товар поslug
.
Переопределим эффекты в product.effects.ts
:
В классе содержатся 2 эффекта:
init$
— эффект, который берет товары изlocalStorage
и сохраняет их в state;load$
— эффект, который реализует загрузку данных из Google Sheets, вызывая соответствующий метод вProductApiService
.
В случае, если был получен успешный ответ от сервера, то товары сохраняются в localStorage
, а также если это был server, данные пробрасываются в браузерную версию приложения:
this.productApiService.load().pipe(
map((products) => {
this.localAsyncStorage.setItem(ProductKeys.Products, products);
if (this.platformService.isServer && products.length) {
this.transferState.set<Product[]>(PRODUCTS_META, products);
}
return ProductActions.loadSuccess({ products });
})
),
В конце вызывается соответствующий экшен, который обновляет товары в хранилище.
И в конце обновим product.facade.ts
:
В фасаде предоставлен доступ к экшенам и селекторам.
Немного изменим экспорты в созданной библиотеке libs/products/state/src/index.ts
, так как экспортировать все не нужно:
export * from './lib/product.facade';
export { selectProducts } from './lib/product.selectors';
export * from './lib/products-state.module';
В экспортах остается модуль, фасад и те экшены и селекторы, которые используются в других библиотеках.
Как видно из примера, в данном случае экспортируется только один селектор, а все остальные остаются сокрытыми.
Product UI
После того, как реализована основная часть связанная с интерфейсами и хранилищем, можно реализовывать компоненты.
Вопрос: Как понять, какие компоненты нужно реализовывать.
Ответ: Обычно в ТЗ есть список всего того, что должно быть реализовано. Однако, никто не запрещает добавлять компоненты по мере необходимости. Это убережет вас от создания ненужного кода.
Product Card
Создадим карточку товара, на которой будет выведена основная информация о товаре.
Создадим библиотеку для компонента:
nx g lib products/ui/card
Добавим компонент:
nx g c product-card --project=products-ui-card
Переименуем модуль products-ui-card.module.ts
в product-card.module.ts
.
Большого смысла тащить всю иерархию в названиях модулей нет. Обычно достаточно 2–3 составляющих, так как в импорте уже будет требуемая вложенность. Иначе, при достаточно сложных структурах, это приведет к безумию. Например, монорепо для пары стран:
libs/russia/application/feature/sub-feature/ui/card
будет иметь модульrussia-feature-sub-feature-ui-card.module.ts
. Хотя если использовать этот модуль, придется импортировать модульimport { RussiaFeatureSubFeatureUiCardModule } form @workspace/russia/application/feature/sub-feature/ui/card
.
В результате получим:
В качестве основы используется MatCard
модуль, который выводит данные в виде карточки:
<mat-card>
<mat-card-header>
<mat-card-title automation-id="title">{{ product.title }}</mat-card-title>
<mat-card-subtitle automation-id="subtitle">{{ product.subtitle }}</mat-card-subtitle>
</mat-card-header>
<banshop-carousel
automation-id="carousel"
[images]="product.photos"
(clicked)="detailsLink._elementRef.nativeElement.click()"
></banshop-carousel>
<mat-card-content>
<banshop-price automation-id="price">{{ product.price | currency }}</banshop-price>
<p automation-id="description">{{ product.description }}</p>
</mat-card-content>
<mat-card-actions align="end">
<a #detailsLink automation-id="details" mat-button i18n="Product card|Details link" [routerLink]="product | productPath">Details</a>
<button automation-id="buy" mat-button i18n="Product card|Buy link" (click)="onAdd()">Buy</button>
</mat-card-actions>
</mat-card>
Отмечу, что используется “грязное” решение с вызовом клика, так mat-button директива добавляет “специфичный” тип для элемента.
Компонент принимает входящим аргументом product
.
export class ProductCardComponent {
@Input() product!: Product;
constructor(private readonly cartAddService: CartAddService) {}
onAdd(): void {
this.cartAddService.add(this.product);
}
}
При клике на кнопку добавить происходит вызов метода add
в сервисе CartAddService
, который в свою очередь покажет диалоговое окно, в котором можно выбрать размер и добавить в корзину.
После успешного добавление в корзину, будет предложено либо продолжить покупки, либо перейти в корзину.
Реализация компонента и сервиса добавления в корзину, будет рассмотрено позже в статьях.
Product List
Добавим компонент вывода списка товаров.
Создадим новую библиотеку, в котором размести компонент вывода списка товаров:
nx g lib products/ui/list
Добавим новый компонент:
nx g c product-list --project=products-ui-list
Получим список товаров из state
и с помощью ProductCard
и выведем список:
В компоненте получаем список товаров:
export class ProductListComponent implements OnInit {
products$!: Observable<Product[]>;
constructor(private readonly productFacade: ProductFacade) {}
ngOnInit(): void {
this.products$ = this.productFacade.products$.pipe(isNotNullOrUndefined());
}
trackByFn(index: number, product: Product): number {
return product.id;
}
}
В шаблоне с помощью AsyncPipe
подписываемся на список товаров и выводим его с помощью карточки товара:
<banshop-row tablet *ngIf="products$ | async as products">
<banshop-column tablet="6" web="4" *ngFor="let product of products; trackBy: trackByFn">
<banshop-product-card [product]="product"></banshop-product-card>
</banshop-column>
</banshop-row>
В результате получим:
Product Box
Создадим еще один компонент, в котором будет помимо информации о товаре, еще информация о размере и количестве.
Создадим библиотеку:
nx g lib products/ui/box
Добавим компонент:
nx g c product-box --project=products-ui-box
Реализация будет следующей:
Как видно из примера, шаблон имеет вид:
<banshop-row tablet>
<banshop-column tablet="6" web="5">
<banshop-carousel automation-id="carousel" [images]="product.photos"></banshop-carousel>
</banshop-column>
<banshop-column tablet="6" web="7">
<h3 automation-id="title">{{ product.title }}</h3>
<h4 automation-id="subtitle">{{ product.subtitle }}</h4>
<p automation-id="price">{{ product.price | currency }}</p>
<p automation-id="description">{{ product.description }}</p>
<ng-content></ng-content>
</banshop-column>
</banshop-row>
Как и в случае с карточкой, компонент принимает только товар:
export class ProductBoxComponent {
@Input() product!: Product;
}
В итоге получим:
Форма будет реализована ниже.
Product Form
Добавим форму, где можно будет выбрать размер и количество.
Создадим библиотеку:
nx g lib products/ui/form
Добавим компонент:
nx g c product-form --project=products-ui-form
Форма будет включать в себя два select’а, которые будут использовать Angular Material.
Форма представляет собой FormGroup
с двумя полями:
export class ProductFormComponent implements OnInit {
@Input() product!: Product;
@Output() created = new EventEmitter<FormGroup>();
readonly fields = ProductField;
form!: FormGroup;
ngOnInit(): void {
this.form = new FormGroup({
productId: new FormControl(this.product.id, [Validators.required]),
size: new FormControl(null, [Validators.required]),
count: new FormControl(1, [Validators.required]),
});
this.created.emit(this.form);
}
}
Компоненты формы же являются стандартными select’ами:
<mat-form-field automation-id="form-field" appearance="fill" banshop-full-width banshopExtractTouched [control]="control">
<mat-label automation-id="label" i18n="Product form|Product count label">Counts</mat-label>
<mat-select automation-id="select" [formControl]="control">
<mat-option *ngFor="let count of counts" [value]="count">
{{ count }}
</mat-option>
</mat-select>
</mat-form-field>
Так как FormGroup
это часть ReactiveForms
, то нет явной необходимости задавать или обновлять форму, так как это все работает из коробки.
Product Portlet
Наверное, самым сложным элементом будет часть страницы товара:
В данном случае имеем адаптивный блок с каруселью и блоком с информацией о товаре, с выбором размера.
Создадим библиотеку:
nx g lib products/ui/portlet
Добавим компонент:
nx g c product-portlet --project=products-ui-portlet
Сам компонент будет лишь выводить другие компоненты:
Как видно из макета, выводятся следующие компоненты:
<banshop-row tablet="" [noPadding]="true" *ngIf="product$ | async; let product">
<banshop-column tablet="7" web="8">
<banshop-carousel automation-id="carousel" [images]="product.photos"></banshop-carousel>
</banshop-column>
<banshop-column tablet="5" web="4">
<banshop-container mode="fluid">
<banshop-product-title automation-id="title">{{ product.title }}</banshop-product-title>
<banshop-product-subtitle automation-id="subtitle">{{ product.subtitle }}</banshop-product-subtitle>
<banshop-product-price automation-id="price">{{ product.price | currency }}</banshop-product-price>
<banshop-product-promo automation-id="promo">
<strong i18n="Product portlet|Product promo">This product is excluded from all promotional discounts and offers.</strong>
</banshop-product-promo>
<banshop-product-sizes automation-id="sizes" [sizes]="product.sizes" [control]="control"></banshop-product-sizes>
<banshop-product-add-to-bag automation-id="add-to-bag" [product]="product" [control]="control"></banshop-product-add-to-bag>
</banshop-container>
</banshop-column>
</banshop-row>
Единственное, что сделано не типично — получение товара из пути:
export class ProductPortletComponent implements OnInit {
product$!: Observable<Product>;
readonly control = new FormControl(null, [Validators.required]);
constructor(private readonly activatedRoute: ActivatedRoute, private readonly productFacade: ProductFacade) {}
ngOnInit(): void {
const { slug } = this.activatedRoute.snapshot.params;
if (slug) {
this.product$ = this.productFacade.productBySlug$(slug).pipe(isNotNullOrUndefined());
}
}
}
В примере получаем slug
из url
, а уже затем ищем товар по slug
и отрисовываем информацию.
Теперь добавим сами компоненты:
Как можно убедиться, большинство компонентов является пустыми, то есть являются обертками и содержат только стили для host
. Это делается для того, чтобы уменьшить родительский компонент.
Единственный сложный компонент — это кнопка добавления в корзину, так как она вызывает метод сервиса, который показывает диалоговое окно.
В шаблоне выводится кнопка:
<button automation-id="button" mat-raised-button color="primary" i18n="Product portlet|Added to bag" (click)="onClick()">Add to bag</button>
Если форма валидна, то тогда товар добавляется в корзину и открывается диалоговое окно, в котором предлагается перейти в корзину или продолжить покупки.
export class ProductAddToBagComponent {
@Input() product!: Product;
@Input() control!: FormControl;
constructor(private readonly cartFacade: CartFacade, private readonly productAddToBagService: ProductAddToBagService) {}
onClick(): void {
this.control.markAllAsTouched();
if (this.control.valid) {
this.cartFacade.addProduct({
size: this.control.value,
productId: this.product.id,
count: 1,
});
this.productAddToBagService.open(this.product);
}
}
}
В результате получим:
Если выбрать размер и кликнуть:
Product Page
Так как основные компоненты для отображения списка товаров и карточек товаров созданы, можно добавить страницу с отображением товара.
Создадим библиотеку для отображения страницы в приложении:
nx g lib products/page
Добавим компонент:
nx g c product-page --project=products-page
Сгенерируем модуль роутинга:
nx g m product-page-routing --project=products-page
Сам компонент достаточно прост, который выводит текст в табах.
По идее, нужно было бы в таблицу с товарами добавить больше информации, но я уже
мухожук
и решил просто вывести статичный текст.
Вопрос, где же будет ProductPortlet
?
Вся хитрость кроется в реализации роутинга. Из-за того, что можно в макете определить две области видимости, то тогда в эти области можно расположить разные компоненты, например, как это сделано ниже:
const routes: Routes = [
{
path: '',
outlet: 'top',
component: ProductPortletComponent,
},
{
path: '',
component: ProductPageComponent,
// Note: Sitemap and meta tags will be generated automatically
},
];
@NgModule({
imports: [RouterModule.forChild(routes), ProductPortletModule],
exports: [RouterModule],
})
export class ProductPageRoutingModule {}
Данный хук, позволяет сделать несколько приложений в одном.
Итоговая страница товара будет выглядеть следующим образом:
Product Guards
Последним модулем, который необходимо реализовать — это product-guards
.
Модуль, который будет проверять доступ к странице товара. Так как slug
может быть любой, перед тем как пустить пользователя на страницу, необходимо убедиться, что товар с выбранным slug
есть в списке товаров.
Создадим библиотеку для гуардов:
nx g lib products/guards
Создадим product.guard.ts
:
Как видно из реализации, в просто запрашивается список товаров и если товара с данным slug нет, то идет редирект на главную страницу:
@Injectable()
export class ProductGuard implements CanActivate {
private readonly redirectTree = this.navigationService.createUrlTree(this.navigationService.getPaths().home);
constructor(private readonly navigationService: NavigationService, private readonly productFacade: ProductFacade) {}
canActivate(route: ActivatedRouteSnapshot): Observable<boolean | UrlTree> {
const { slug } = route.params;
if (!slug) {
return of(this.redirectTree);
}
return this.productFacade.productBySlugLoaded$(slug).pipe(map((product) => !!product || this.redirectTree));
}
}
Ссылки
Предыдущая статья — Создание UI KIT.
Следующая статья — Модуль корзины.
Все исходники находятся на github, в репозитории:
Для того, чтобы посмотреть состояние проекта на момент написания статьи, нужно выбрать соответствующий тег — article.
Подписывайтесь на блог, чтобы не пропустить новые статьи про Angular, и веб-разработку. Medium | Telegram| VK |Tw| Ln