Сайт визитка на Angular. Модуль товаров.

Aleksandr Serenko
F.A.F.N.U.R
Published in
11 min readMar 27, 2022
Сайт визитка на 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

--

--

Aleksandr Serenko
F.A.F.N.U.R

Senior Front-end Developer, Angular evangelist, Nx apologist, NodeJS warlock