Создание пользовательского интерфейса для модуля Webhook с помощью Angular

от автора

Предыдущая статья: Создание конфигурируемого Webhook-модуля для NestJS-приложении

В этой статье я опишу создание таблички отображающей данные и формы для ее заполнения, интерфейсы строятся на компонентах от https://ng.ant.design, формы создаются и управляются с помощью https://formly.dev, для стилей используется https://tailwindcss.com, стейт машины нет.

1. Создаем пустую Angular библиотеку

В этой библиотеке лежат компоненты для отображения и работы с данными сущности Webhook.

Commands

# Create Angular library ./node_modules/.bin/nx g @nx/angular:library webhook-angular --buildable --publishable --directory=libs/feature/webhook-angular --simpleName=true --projectNameAndRootFormat=as-provided --strict=true --prefix= --standalone=true --selector= --changeDetection=OnPush --importPath=@nestjs-mod-fullstack/webhook-angular  # Change file with test options rm -rf libs/feature/webhook-angular/src/test-setup.ts cp apps/client/src/test-setup.ts libs/feature/webhook-angular/src/test-setup.ts 
Вывод консоли
$ ./node_modules/.bin/nx g @nx/angular:library webhook-angular --buildable --publishable --directory=libs/feature/webhook-angular --simpleName=true --projectNameAndRootFormat=as-provided --strict=true --prefix= --standalone=true --selector= --changeDetection=OnPush --importPath=@nestjs-mod-fullstack/webhook-angular   NX  Generating @nx/angular:library  CREATE libs/feature/webhook-angular/project.json CREATE libs/feature/webhook-angular/README.md CREATE libs/feature/webhook-angular/ng-package.json CREATE libs/feature/webhook-angular/package.json CREATE libs/feature/webhook-angular/tsconfig.json CREATE libs/feature/webhook-angular/tsconfig.lib.json CREATE libs/feature/webhook-angular/tsconfig.lib.prod.json CREATE libs/feature/webhook-angular/src/index.ts CREATE libs/feature/webhook-angular/jest.config.ts CREATE libs/feature/webhook-angular/src/test-setup.ts CREATE libs/feature/webhook-angular/tsconfig.spec.json CREATE libs/feature/webhook-angular/src/lib/webhook-angular/webhook-angular.component.css CREATE libs/feature/webhook-angular/src/lib/webhook-angular/webhook-angular.component.html CREATE libs/feature/webhook-angular/src/lib/webhook-angular/webhook-angular.component.spec.ts CREATE libs/feature/webhook-angular/src/lib/webhook-angular/webhook-angular.component.ts CREATE libs/feature/webhook-angular/.eslintrc.json UPDATE package.json UPDATE tsconfig.base.json  > @nestjs-mod-fullstack/source@0.0.9 prepare > npx -y husky install  install command is DEPRECATED  removed 2 packages, changed 5 packages, and audited 2726 packages in 13s  332 packages are looking for funding   run `npm fund` for details  33 vulnerabilities (4 low, 12 moderate, 17 high)  To address issues that do not require attention, run:   npm audit fix  To address all issues possible (including breaking changes), run:   npm audit fix --force  Some issues need review, and may require choosing a different dependency.  Run `npm audit` for details.   NX   👀 View Details of webhook-angular  Run "nx show project webhook-angular" to view details about this project. 

2. Создаем общую Angular библиотеку

Общая библиотека содержит функции и классы которые используются другими Angular-библиотеками.

Commands

# Create Angular library ./node_modules/.bin/nx g @nx/angular:library common-angular --buildable --publishable --directory=libs/common-angular --simpleName=true --projectNameAndRootFormat=as-provided --strict=true --prefix= --standalone=true --selector= --changeDetection=OnPush --importPath=@nestjs-mod-fullstack/common-angular  # Change file with test options rm -rf libs/common-angular/src/test-setup.ts cp apps/client/src/test-setup.ts libs/common-angular/src/test-setup.ts 
Вывод консоли
$ ./node_modules/.bin/nx g @nx/angular:library common-angular --buildable --publishable --directory=libs/common-angular --simpleName=true --projectNameAndRootFormat=as-provided --strict=true --prefix= --standalone=true --selector= --changeDetection=OnPush --importPath=@nestjs-mod-fullstack/common-angular   NX  Generating @nx/angular:library  CREATE libs/common-angular/project.json CREATE libs/common-angular/README.md CREATE libs/common-angular/ng-package.json CREATE libs/common-angular/package.json CREATE libs/common-angular/tsconfig.json CREATE libs/common-angular/tsconfig.lib.json CREATE libs/common-angular/tsconfig.lib.prod.json CREATE libs/common-angular/src/index.ts CREATE libs/common-angular/jest.config.ts CREATE libs/common-angular/src/test-setup.ts CREATE libs/common-angular/tsconfig.spec.json CREATE libs/common-angular/src/lib/common-angular/common-angular.component.css CREATE libs/common-angular/src/lib/common-angular/common-angular.component.html CREATE libs/common-angular/src/lib/common-angular/common-angular.component.spec.ts CREATE libs/common-angular/src/lib/common-angular/common-angular.component.ts CREATE libs/common-angular/.eslintrc.json UPDATE tsconfig.base.json   NX   👀 View Details of common-angular  Run "nx show project common-angular" to view details about this project. 

3. Устанавливаем дополнительные библиотеки

Устанавливаем библиотеку визуальных компонентов ng-zorro-antd, библиотеку для работы с формами @ngx-formly/core @ngx-formly/ng-zorro-antd, утилиту для авто-отписки @ngneat/until-destroy и колекцию утилит lodash.

Commands

npm install --save ng-zorro-antd @ngx-formly/core @ngx-formly/ng-zorro-antd @ngneat/until-destroy lodash 
Вывод консоли
$ npm install --save ng-zorro-antd @ngx-formly/core @ngx-formly/ng-zorro-antd @ngneat/until-destroy  added 8 packages, removed 2 packages, and audited 2794 packages in 25s  343 packages are looking for funding   run `npm fund` for details  38 vulnerabilities (8 low, 12 moderate, 18 high)  To address issues that do not require attention, run:   npm audit fix  To address all issues (including breaking changes), run:   npm audit fix --force  Run `npm audit` for details. 

4. Так как для работы модуля нужен идентификаторы пользователя и компании, то нужно создать интерфейсы для передачи этих данных

Создаем форму и сервис авторизации в Webhook — модуле.

Сервис имеет методы для получения профиля пользователя по переданным xExternalUserId и xExternalTenantId, а также хранить их значения и данные по профилю пользователя.

Идентификатор администратора прокидывается из переменных окружения CI/CD.

Для защиты страниц создадим специальный Guard.

Создаем сервис libs/feature/webhook-angular/src/lib/services/webhook-auth.service.ts

import { Injectable } from '@angular/core'; import { WebhookErrorInterface, WebhookRestService, WebhookUserObjectInterface } from '@nestjs-mod-fullstack/app-angular-rest-sdk'; import { UntilDestroy, untilDestroyed } from '@ngneat/until-destroy'; import { BehaviorSubject, catchError, of, tap, throwError } from 'rxjs';  export type WebhookAuthCredentials = {   xExternalUserId?: string;   xExternalTenantId?: string; };  @UntilDestroy() @Injectable({ providedIn: 'root' }) export class WebhookAuthService {   private webhookAuthCredentials$ = new BehaviorSubject<WebhookAuthCredentials>({});   private webhookUser$ = new BehaviorSubject<WebhookUserObjectInterface | null>(null);    constructor(private readonly webhookRestService: WebhookRestService) {}    getWebhookAuthCredentials() {     return this.webhookAuthCredentials$.value;   }    getWebhookUser() {     return this.webhookUser$.value;   }    setWebhookAuthCredentials(webhookAuthCredentials: WebhookAuthCredentials) {     this.webhookAuthCredentials$.next(webhookAuthCredentials);     this.loadWebhookUser().pipe(untilDestroyed(this)).subscribe();   }    loadWebhookUser() {     return this.webhookRestService.webhookControllerProfile(this.getWebhookAuthCredentials().xExternalUserId, this.getWebhookAuthCredentials().xExternalTenantId).pipe(       tap((profile) => this.webhookUser$.next(profile)),       catchError((err: { error?: WebhookErrorInterface }) => {         if (err.error?.code === 'WEBHOOK-002') {           return of(null);         }         return throwError(() => err);       })     );   }    webhookAuthCredentialsUpdates() {     return this.webhookAuthCredentials$.asObservable();   }    webhookUserUpdates() {     return this.webhookUser$.asObservable();   } } 

Псевдо форма авторизации имеет два поля xExternalUserId и xExternalTenantId, построение и валидация формы происходит через библиотеку https://formly.dev.

Кроме кнопки войти, на форме также есть еще две кнопки:

  1. Заполнить данные пользователя — подставляет в xExternalUserId и xExternalTenantId захардкоженные случайные uuid-идентификаторы;

  2. Заполнить данные администратора — подставляет в xExternalUserId идентификатор пользователя с ролью Admin, бэкенд при старте создает этого пользователя, а идентификатор вставляется во фронтенд при сборке его в CI\CD.

Создаем файл libs/feature/webhook-angular/src/lib/forms/webhook-auth-form/webhook-auth-form.component.ts

import { AsyncPipe, NgIf } from '@angular/common'; import { ChangeDetectionStrategy, Component, EventEmitter, Inject, Input, OnInit, Optional, Output } from '@angular/core'; import { FormsModule, ReactiveFormsModule, UntypedFormGroup } from '@angular/forms'; import { FormlyFieldConfig, FormlyModule } from '@ngx-formly/core'; import { NzButtonModule } from 'ng-zorro-antd/button'; import { NzFormModule } from 'ng-zorro-antd/form'; import { NzInputModule } from 'ng-zorro-antd/input'; import { NzMessageService } from 'ng-zorro-antd/message'; import { NZ_MODAL_DATA } from 'ng-zorro-antd/modal'; import { BehaviorSubject } from 'rxjs'; import { WebhookAuthCredentials, WebhookAuthService } from '../../services/webhook-auth.service'; import { WEBHOOK_CONFIGURATION_TOKEN, WebhookConfiguration } from '../../services/webhook.configuration';  @Component({   standalone: true,   imports: [FormlyModule, NzFormModule, NzInputModule, NzButtonModule, FormsModule, ReactiveFormsModule, AsyncPipe, NgIf],   selector: 'webhook-auth-form',   templateUrl: './webhook-auth-form.component.html',   changeDetection: ChangeDetectionStrategy.OnPush, }) export class WebhookAuthFormComponent implements OnInit {   @Input()   hideButtons?: boolean;    @Output()   afterSignIn = new EventEmitter<WebhookAuthCredentials>();    form = new UntypedFormGroup({});   formlyModel$ = new BehaviorSubject<object | null>(null);   formlyFields$ = new BehaviorSubject<FormlyFieldConfig[] | null>(null);    constructor(     @Optional()     @Inject(NZ_MODAL_DATA)     private readonly nzModalData: WebhookAuthFormComponent,     @Inject(WEBHOOK_CONFIGURATION_TOKEN)     private readonly webhookConfiguration: WebhookConfiguration,     private readonly webhookAuthService: WebhookAuthService,     private readonly nzMessageService: NzMessageService   ) {}    ngOnInit(): void {     Object.assign(this, this.nzModalData);     this.setFieldsAndModel(this.webhookAuthService.getWebhookAuthCredentials());   }    setFieldsAndModel(     data: Partial<WebhookAuthCredentials> = {},     options: { xExternalTenantIdIsRequired: boolean } = {       xExternalTenantIdIsRequired: true,     }   ) {     this.formlyFields$.next([       {         key: 'xExternalUserId',         type: 'input',         validation: {           show: true,         },         props: {           label: `webhook.form.xExternalUserId`,           placeholder: 'xExternalUserId',           required: true,         },       },       {         key: 'xExternalTenantId',         type: 'input',         validation: {           show: true,         },         props: {           label: `webhook.form.xExternalTenantId`,           placeholder: 'xExternalTenantId',           required: options.xExternalTenantIdIsRequired,         },       },     ]);     this.formlyModel$.next(this.toModel(data));   }    submitForm(): void {     if (this.form.valid) {       const value = this.toJson(this.form.value);       this.afterSignIn.next(value);       this.webhookAuthService.setWebhookAuthCredentials(value);       this.nzMessageService.success('Success');     } else {       console.log(this.form.controls);       this.nzMessageService.warning('Validation errors');     }   }    fillUserCredentials() {     this.setFieldsAndModel({       xExternalTenantId: '2079150a-f133-405c-9e77-64d3ab8aff77',       xExternalUserId: '3072607c-8c59-4fc4-9a37-916825bc0f99',     });   }    fillAdminCredentials() {     this.setFieldsAndModel(       {         xExternalTenantId: '',         xExternalUserId: this.webhookConfiguration.webhookSuperAdminExternalUserId,       },       { xExternalTenantIdIsRequired: false }     );   }    private toModel(data: Partial<WebhookAuthCredentials>): object | null {     return {       xExternalUserId: data['xExternalUserId'],       xExternalTenantId: data['xExternalTenantId'],     };   }    private toJson(data: Partial<WebhookAuthCredentials>) {     return {       xExternalUserId: data['xExternalUserId'],       xExternalTenantId: data['xExternalTenantId'],     };   } } 

Создаем файл libs/feature/webhook-angular/src/lib/forms/webhook-auth-form/webhook-auth-form.component.html

@if (formlyFields$ | async; as formlyFields) { <form nz-form [formGroup]="form" (ngSubmit)="submitForm()">   <formly-form [model]="formlyModel$ | async" [fields]="formlyFields" [form]="form"> </formly-form>   @if (!hideButtons) {   <nz-form-control>     <div class="flex justify-between">       <div>         <button nz-button type="button" (click)="fillUserCredentials()">Fill user credentials</button>         <button nz-button type="button" (click)="fillAdminCredentials()">Fill admin credentials</button>       </div>       <button nz-button nzType="primary" type="submit" [disabled]="!form.valid">Sign-in</button>     </div>   </nz-form-control>   } </form> } 

Идентификатор администратора передается через конфигурацию и переменные окружения.

Обновляем файл apps/client/src/environments/environment.prod.ts

export const serverUrl = ''; export const webhookSuperAdminExternalUserId = '___CLIENT_WEBHOOK_SUPER_ADMIN_EXTERNAL_USER_ID___'; 

Обновляем файл apps/client/src/app/app.config.ts

import { provideHttpClient } from '@angular/common/http'; import { ApplicationConfig, ErrorHandler, importProvidersFrom, provideZoneChangeDetection } from '@angular/core'; import { provideClientHydration } from '@angular/platform-browser'; import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; import { provideRouter } from '@angular/router'; import { RestClientApiModule, RestClientConfiguration } from '@nestjs-mod-fullstack/app-angular-rest-sdk'; import { WEBHOOK_CONFIGURATION_TOKEN, WebhookConfiguration } from '@nestjs-mod-fullstack/webhook-angular'; import { FormlyModule } from '@ngx-formly/core'; import { FormlyNgZorroAntdModule } from '@ngx-formly/ng-zorro-antd'; import { en_US, provideNzI18n } from 'ng-zorro-antd/i18n'; import { serverUrl, webhookSuperAdminExternalUserId } from '../environments/environment'; import { AppErrorHandler } from './app.error-handler'; import { appRoutes } from './app.routes';  export const appConfig: ApplicationConfig = {   providers: [     provideClientHydration(),     provideZoneChangeDetection({ eventCoalescing: true }),     provideRouter(appRoutes),     provideHttpClient(),     provideNzI18n(en_US),     {       provide: WEBHOOK_CONFIGURATION_TOKEN,       useValue: new WebhookConfiguration({ webhookSuperAdminExternalUserId }), // <-- update     },     importProvidersFrom(       BrowserAnimationsModule,       RestClientApiModule.forRoot(         () =>           new RestClientConfiguration({             basePath: serverUrl,           })       ),       FormlyModule.forRoot(),       FormlyNgZorroAntdModule     ),     { provide: ErrorHandler, useClass: AppErrorHandler },   ], }; 

Страницу с формой входа создаем на уровне приложения, так как у нас нет необходимости ее переиспользовать.

Создаем файл apps/client/src/app/pages/sign-in/sign-in.component.ts

import { ChangeDetectionStrategy, Component } from '@angular/core'; import { Router } from '@angular/router'; import { WebhookAuthFormComponent } from '@nestjs-mod-fullstack/webhook-angular'; import { NzBreadCrumbModule } from 'ng-zorro-antd/breadcrumb';  @Component({   standalone: true,   selector: 'app-sign-in',   templateUrl: './sign-in.component.html',   imports: [NzBreadCrumbModule, WebhookAuthFormComponent],   changeDetection: ChangeDetectionStrategy.OnPush, }) export class SignInComponent {   constructor(private readonly router: Router) {}   onAfterSignIn() {     this.router.navigate(['/webhook']);   } } 

Создаем файл apps/client/src/app/pages/sign-in/sign-in.component.html

<nz-breadcrumb>   <nz-breadcrumb-item>Sign-in</nz-breadcrumb-item> </nz-breadcrumb> <div class="inner-content">   <webhook-auth-form (afterSignIn)="onAfterSignIn()"></webhook-auth-form> </div> 

Страница авторизации должна быть доступна только когда пользователь не ввел авторизационные данные, для этого напишем Guard и закроем им наши страницы.

Создаем файл libs/feature/webhook-angular/src/lib/services/webhook-guard.service.ts

import { Injectable } from '@angular/core'; import { ActivatedRouteSnapshot, CanActivate } from '@angular/router'; import { WebhookRoleInterface } from '@nestjs-mod-fullstack/app-angular-rest-sdk'; import { map, of } from 'rxjs'; import { WebhookAuthService } from './webhook-auth.service';  export const WEBHOOK_GUARD_DATA_ROUTE_KEY = 'webhookGuardData';  export class WebhookGuardData {   roles?: WebhookRoleInterface[];    constructor(options?: WebhookGuardData) {     Object.assign(this, options);   } }  @Injectable({ providedIn: 'root' }) export class WebhookGuardService implements CanActivate {   constructor(private readonly webhookAuthService: WebhookAuthService) {}   canActivate(route: ActivatedRouteSnapshot) {     if (route.data[WEBHOOK_GUARD_DATA_ROUTE_KEY] instanceof WebhookGuardData) {       const webhookGuardData = route.data[WEBHOOK_GUARD_DATA_ROUTE_KEY];       return this.webhookAuthService.loadWebhookUser().pipe(         map((webhookUser) => {           return Boolean((webhookGuardData.roles && webhookUser && webhookGuardData.roles.length > 0 && webhookGuardData.roles.includes(webhookUser.userRole)) || ((webhookGuardData.roles || []).length === 0 && !webhookUser?.userRole));         })       );     }     return of(true);   } } 

Обновляем файл apps/client/src/app/app.routes.ts

import { Route } from '@angular/router'; import { WEBHOOK_GUARD_DATA_ROUTE_KEY, WebhookGuardData, WebhookGuardService } from '@nestjs-mod-fullstack/webhook-angular'; import { HomeComponent } from './pages/home/home.component'; import { SignInComponent } from './pages/sign-in/sign-in.component'; import { WebhookComponent } from './pages/webhook/webhook.component'; import { DemoComponent } from './pages/demo/demo.component';  export const appRoutes: Route[] = [   { path: '', redirectTo: '/home', pathMatch: 'full' },   { path: 'home', component: HomeComponent },   { path: 'demo', component: DemoComponent },   {     path: 'webhook',     component: WebhookComponent,     canActivate: [WebhookGuardService],     data: {       [WEBHOOK_GUARD_DATA_ROUTE_KEY]: new WebhookGuardData({         roles: ['Admin', 'User'],       }),     },   },   {     path: 'sign-in',     component: SignInComponent,     canActivate: [WebhookGuardService],     data: {       [WEBHOOK_GUARD_DATA_ROUTE_KEY]: new WebhookGuardData({ roles: [] }),     },   }, ]; 

5. Описываем компоненту с формой и сервис для создания и редактирования сущности Webhook

Так как метод для работы с Webhook-сущностью требуют авторизационные данные, мы подключаем WebhookAuthService в сервис для работы с бэкенд сущности Webhook.

Создаем сервис libs/feature/webhook-angular/src/lib/services/webhook.service.ts

import { Injectable } from '@angular/core'; import { CreateWebhookArgsInterface, UpdateWebhookArgsInterface, WebhookRestService } from '@nestjs-mod-fullstack/app-angular-rest-sdk'; import { RequestMeta } from '@nestjs-mod-fullstack/common-angular'; import { WebhookAuthService } from './webhook-auth.service';  @Injectable({ providedIn: 'root' }) export class WebhookService {   constructor(private readonly webhookAuthService: WebhookAuthService, private readonly webhookRestService: WebhookRestService) {}    findOne(id: string) {     return this.webhookRestService.webhookControllerFindOne(id, this.webhookAuthService.getWebhookAuthCredentials().xExternalUserId, this.webhookAuthService.getWebhookAuthCredentials().xExternalTenantId);   }    findMany({ filters, meta }: { filters: Record<string, string>; meta?: RequestMeta }) {     return this.webhookRestService.webhookControllerFindMany(       this.webhookAuthService.getWebhookAuthCredentials().xExternalUserId,       this.webhookAuthService.getWebhookAuthCredentials().xExternalTenantId,       meta?.curPage,       meta?.perPage,       filters['search'],       meta?.sort         ? Object.entries(meta?.sort)             .map(([key, value]) => `${key}:${value}`)             .join(',')         : undefined     );   }    updateOne(id: string, data: UpdateWebhookArgsInterface) {     return this.webhookRestService.webhookControllerUpdateOne(id, data, this.webhookAuthService.getWebhookAuthCredentials().xExternalUserId, this.webhookAuthService.getWebhookAuthCredentials().xExternalTenantId);   }    deleteOne(id: string) {     return this.webhookRestService.webhookControllerDeleteOne(id, this.webhookAuthService.getWebhookAuthCredentials().xExternalUserId, this.webhookAuthService.getWebhookAuthCredentials().xExternalTenantId);   }    createOne(data: CreateWebhookArgsInterface) {     return this.webhookRestService.webhookControllerCreateOne(data, this.webhookAuthService.getWebhookAuthCredentials().xExternalUserId, this.webhookAuthService.getWebhookAuthCredentials().xExternalTenantId);   } } 

Цель данного поста создать простой пример CRUD на Angular, форма состоит из стандартных типов контролов (checkbox, input, select, textarea), а логика по трансформированию данных в formly и обратно лежит в этой же компоненте.

В дальнейших статьях будут созданы дополнительные кастомные типы контролов для formly с собственными логиками трансформации.

Создаем класс формы libs/feature/webhook-angular/src/lib/forms/webhook-form/webhook-form.component.ts

import { AsyncPipe, NgIf } from '@angular/common'; import { ChangeDetectionStrategy, Component, EventEmitter, Inject, Input, OnInit, Optional, Output } from '@angular/core'; import { FormsModule, ReactiveFormsModule, UntypedFormGroup } from '@angular/forms'; import { WebhookEventInterface, WebhookObjectInterface } from '@nestjs-mod-fullstack/app-angular-rest-sdk'; import { safeParseJson } from '@nestjs-mod-fullstack/common-angular'; import { UntilDestroy, untilDestroyed } from '@ngneat/until-destroy'; import { FormlyFieldConfig, FormlyModule } from '@ngx-formly/core'; import { NzButtonModule } from 'ng-zorro-antd/button'; import { NzFormModule } from 'ng-zorro-antd/form'; import { NzInputModule } from 'ng-zorro-antd/input'; import { NzMessageService } from 'ng-zorro-antd/message'; import { NZ_MODAL_DATA } from 'ng-zorro-antd/modal'; import { BehaviorSubject, tap } from 'rxjs'; import { WebhookEventsService } from '../../services/webhook-events.service'; import { WebhookService } from '../../services/webhook.service';  @UntilDestroy() @Component({   standalone: true,   imports: [FormlyModule, NzFormModule, NzInputModule, NzButtonModule, FormsModule, ReactiveFormsModule, AsyncPipe, NgIf],   selector: 'webhook-form',   templateUrl: './webhook-form.component.html',   changeDetection: ChangeDetectionStrategy.OnPush, }) export class WebhookFormComponent implements OnInit {   @Input()   id?: string;    @Input()   hideButtons?: boolean;    @Output()   afterFind = new EventEmitter<WebhookObjectInterface>();    @Output()   afterCreate = new EventEmitter<WebhookObjectInterface>();    @Output()   afterUpdate = new EventEmitter<WebhookObjectInterface>();    form = new UntypedFormGroup({});   formlyModel$ = new BehaviorSubject<object | null>(null);   formlyFields$ = new BehaviorSubject<FormlyFieldConfig[] | null>(null);    events: WebhookEventInterface[] = [];    constructor(     @Optional()     @Inject(NZ_MODAL_DATA)     private readonly nzModalData: WebhookFormComponent,     private readonly webhookService: WebhookService,     private readonly webhookEventsService: WebhookEventsService,     private readonly nzMessageService: NzMessageService   ) {}    ngOnInit(): void {     Object.assign(this, this.nzModalData);     this.webhookEventsService       .findMany()       .pipe(         tap((events) => {           this.events = events;            if (this.id) {             this.findOne()               .pipe(                 tap((result) => this.afterFind.next(result)),                 untilDestroyed(this)               )               .subscribe();           } else {             this.setFieldsAndModel();           }         }),         untilDestroyed(this)       )       .subscribe();   }    setFieldsAndModel(data: Partial<WebhookObjectInterface> = {}) {     this.formlyFields$.next([       {         key: 'enabled',         type: 'checkbox',         validation: {           show: true,         },         props: {           label: `webhook.form.enabled`,           placeholder: 'enabled',           required: true,         },       },       {         key: 'endpoint',         type: 'input',         validation: {           show: true,         },         props: {           label: `webhook.form.endpoint`,           placeholder: 'endpoint',           required: true,         },       },       {         key: 'eventName',         type: 'select',         validation: {           show: true,         },         props: {           label: `webhook.form.eventName`,           placeholder: 'eventName',           required: true,           options: this.events.map((e) => ({             value: e.eventName,             label: e.description,           })),         },       },       {         key: 'headers',         type: 'textarea',         validation: {           show: true,         },         props: {           label: `webhook.form.headers`,           placeholder: 'headers',           required: true,         },       },       {         key: 'requestTimeout',         type: 'input',         validation: {           show: true,         },         props: {           label: `webhook.form.requestTimeout`,           placeholder: 'requestTimeout',           required: false,         },       },     ]);     this.formlyModel$.next(this.toModel(data));   }    submitForm(): void {     if (this.form.valid) {       if (this.id) {         this.updateOne()           .pipe(             tap((result) => {               this.nzMessageService.success('Success');               this.afterUpdate.next(result);             }),             untilDestroyed(this)           )           .subscribe();       } else {         this.createOne()           .pipe(             tap((result) => {               this.nzMessageService.success('Success');               this.afterCreate.next(result);             }),              untilDestroyed(this)           )           .subscribe();       }     } else {       console.log(this.form.controls);       this.nzMessageService.warning('Validation errors');     }   }    createOne() {     return this.webhookService.createOne(this.toJson(this.form.value));   }    updateOne() {     if (!this.id) {       throw new Error('id not set');     }     return this.webhookService.updateOne(this.id, this.toJson(this.form.value));   }    findOne() {     if (!this.id) {       throw new Error('id not set');     }     return this.webhookService.findOne(this.id).pipe(       tap((result) => {         this.setFieldsAndModel(result);       })     );   }    private toModel(data: Partial<WebhookObjectInterface>): object | null {     return {       enabled: (data['enabled'] as unknown as string) === 'true' || data['enabled'] === true,       endpoint: data['endpoint'],       eventName: data['eventName'],       headers: data['headers'] ? JSON.stringify(data['headers']) : '',       requestTimeout: data['requestTimeout'] && !isNaN(+data['requestTimeout']) ? data['requestTimeout'] : '',     };   }    private toJson(data: Partial<WebhookObjectInterface>) {     return {       enabled: data['enabled'] === true,       endpoint: data['endpoint'] || '',       eventName: data['eventName'] || '',       headers: data['headers'] ? safeParseJson(data['headers']) : null,       requestTimeout: data['requestTimeout'] && !isNaN(+data['requestTimeout']) ? +data['requestTimeout'] : undefined,     };   } } 

Разметка формы имеет возможность отображать ее в виде inline на странице с встроенными кнопками, а также ее можно отображать в модальном окне у которого своя разметка для кнопок.

Создаем разметку формы libs/feature/webhook-angular/src/lib/forms/webhook-form/webhook-form.component.html

@if (formlyFields$ | async; as formlyFields) { <form nz-form [formGroup]="form" (ngSubmit)="submitForm()">   <formly-form [model]="formlyModel$ | async" [fields]="formlyFields" [form]="form"> </formly-form>   @if (!hideButtons) {   <nz-form-control>     <button nzBlock nz-button nzType="primary" type="submit" [disabled]="!form.valid">{{ id ? 'Save' : 'Create' }}</button>   </nz-form-control>   } </form> } 

5. Описываем компоненту с таблицей для просмотра, создания и редактирования сущностей Webhook

Таблица поддерживает серверную пагинацию, сортировку и поиск в текстовых полях.

После создания/редактирования/удаления происходит запрос текущий страницы таблицы.

Создание и редактирование записей происходит в модальном окне с формой.

При удалении записи отображается модальное окно с подтверждением действия.

Создаем класс таблицы libs/feature/webhook-angular/src/lib/grids/webhook-grid/webhook-grid.component.ts

import { CommonModule } from '@angular/common'; import { ChangeDetectionStrategy, Component, Input, OnInit, ViewContainerRef } from '@angular/core'; import { FormControl, FormsModule, ReactiveFormsModule } from '@angular/forms'; import { RouterModule } from '@angular/router'; import { WebhookObjectInterface } from '@nestjs-mod-fullstack/app-angular-rest-sdk'; import { UntilDestroy, untilDestroyed } from '@ngneat/until-destroy'; import isEqual from 'lodash/fp/isEqual'; import omit from 'lodash/fp/omit'; import { NzButtonModule } from 'ng-zorro-antd/button'; import { NzDividerModule } from 'ng-zorro-antd/divider'; import { NzGridModule } from 'ng-zorro-antd/grid'; import { NzIconModule } from 'ng-zorro-antd/icon'; import { NzInputModule } from 'ng-zorro-antd/input'; import { NzLayoutModule } from 'ng-zorro-antd/layout'; import { NzMenuModule } from 'ng-zorro-antd/menu'; import { NzModalModule, NzModalService } from 'ng-zorro-antd/modal'; import { NzTableModule, NzTableQueryParams } from 'ng-zorro-antd/table'; import { BehaviorSubject, debounceTime, distinctUntilChanged, tap } from 'rxjs';  import { WebhookScalarFieldEnumInterface } from '@nestjs-mod-fullstack/app-angular-rest-sdk'; import { getQueryMeta, getQueryMetaByParams, NzTableSortOrderDetectorPipe, RequestMeta } from '@nestjs-mod-fullstack/common-angular'; import { WebhookFormComponent } from '../../forms/webhook-form/webhook-form.component'; import { WebhookService } from '../../services/webhook.service';  @UntilDestroy() @Component({   standalone: true,   imports: [NzGridModule, NzMenuModule, NzLayoutModule, NzTableModule, NzDividerModule, CommonModule, RouterModule, NzModalModule, NzButtonModule, NzInputModule, NzIconModule, FormsModule, ReactiveFormsModule, NzTableSortOrderDetectorPipe],   selector: 'webhook-grid',   templateUrl: './webhook-grid.component.html',   changeDetection: ChangeDetectionStrategy.OnPush, }) export class WebhookGridComponent implements OnInit {   items$ = new BehaviorSubject<WebhookObjectInterface[]>([]);   meta$ = new BehaviorSubject<RequestMeta | undefined>(undefined);   searchField = new FormControl('');   selectedIds$ = new BehaviorSubject<string[]>([]);   columns = ['id', 'enabled', 'endpoint', 'eventName', 'headers', 'requestTimeout'];    private filters?: Record<string, string>;    constructor(private readonly webhookService: WebhookService, private readonly nzModalService: NzModalService, private readonly viewContainerRef: ViewContainerRef) {     this.searchField.valueChanges       .pipe(         debounceTime(700),         distinctUntilChanged(),         tap(() => this.loadMany({ force: true })),         untilDestroyed(this)       )       .subscribe();   }    ngOnInit(): void {     this.loadMany();   }    loadMany(args?: { filters?: Record<string, string>; meta?: RequestMeta; queryParams?: NzTableQueryParams; force?: boolean }) {     let meta = { meta: {}, ...(args || {}) }.meta as RequestMeta;     const { queryParams, filters } = { filters: {}, ...(args || {}) };      if (!args?.force && queryParams) {       meta = getQueryMetaByParams(queryParams);     }      meta = getQueryMeta(meta, this.meta$.value);      if (!filters['search'] && this.searchField.value) {       filters['search'] = this.searchField.value;     }      if (       !args?.force &&       isEqual(         omit(['totalResults'], { ...meta, ...filters }),         omit(['totalResults'], {           ...this.meta$.value,           ...this.filters,         })       )     ) {       return;     }      this.webhookService       .findMany({ filters, meta })       .pipe(         tap((result) => {           this.items$.next(             result.webhooks.map((item) => ({               ...item,               // eslint-disable-next-line @typescript-eslint/no-explicit-any               headers: JSON.stringify(item.headers) as any,             }))           );           this.meta$.next({ ...result.meta, ...meta });           this.filters = filters;           this.selectedIds$.next([]);         }),         untilDestroyed(this)       )       .subscribe();   }    showCreateOrUpdateModal(id?: string): void {     const modal = this.nzModalService.create<WebhookFormComponent, WebhookFormComponent>({       nzTitle: id ? 'Update webhook' : 'Create webhook',       nzContent: WebhookFormComponent,       nzViewContainerRef: this.viewContainerRef,       nzData: {         hideButtons: true,         id,       } as WebhookFormComponent,       nzFooter: [         {           label: 'Cancel',           onClick: () => {             modal.close();           },         },         {           label: id ? 'Save' : 'Create',           onClick: () => {             modal.componentInstance?.afterUpdate               .pipe(                 tap(() => {                   modal.close();                   this.loadMany({ force: true });                 }),                 untilDestroyed(modal.componentInstance)               )               .subscribe();              modal.componentInstance?.afterCreate               .pipe(                 tap(() => {                   modal.close();                   this.loadMany({ force: true });                 }),                 untilDestroyed(modal.componentInstance)               )               .subscribe();              modal.componentInstance?.submitForm();           },           type: 'primary',         },       ],     });   }    showDeleteModal(id: string) {     this.nzModalService.confirm({       nzTitle: `Delete webhook #${id}`,       nzOkText: 'Yes',       nzCancelText: 'No',       nzOnOk: () => {         this.webhookService           .deleteOne(id)           .pipe(             tap(() => {               this.loadMany({ force: true });             }),             untilDestroyed(this)           )           .subscribe();       },     });   } } 

Создаем разметку таблицы libs/feature/webhook-angular/src/lib/grids/webhook-grid/webhook-grid.component.html

<div class="table-operations" nz-row nzJustify="space-between">   <div nz-col nzSpan="4">     <button nz-button nzType="primary" (click)="showCreateOrUpdateModal()">Create new</button>   </div>   <div nz-col nzSpan="4">     <nz-input-group nzSearch [nzAddOnAfter]="suffixIconButton">       <input type="text" [formControl]="searchField" nz-input placeholder="input search text" />     </nz-input-group>     <ng-template #suffixIconButton>       <button (click)="loadMany({ force: true })" nz-button nzType="primary" nzSearch>         <span nz-icon nzType="search"></span>       </button>     </ng-template>   </div> </div> @if ((meta$ | async); as meta){ <nz-table   #basicTable   [nzBordered]="true"   [nzOuterBordered]="true"   nzShowPagination   nzShowSizeChanger   [nzFrontPagination]="false"   [nzPageSizeOptions]="[1, 5, 10, 20, 30, 40]"   [nzPageIndex]="meta.curPage"   [nzPageSize]="meta.perPage"   [nzTotal]="meta.totalResults || 0"   (nzQueryParams)="     loadMany({       queryParams: $event     })   "   [nzData]="(items$ | async) || []" >   <thead>     <tr>       @for (key of columns; track $index) {       <th [nzColumnKey]="key" [nzSortFn]="true" [nzSortOrder]="meta.sort[key] | nzTableSortOrderDetector">webhook.grid.{{ key }}</th>       }       <th>Action</th>     </tr>   </thead>   @if (selectedIds$ | async; as selectedIds) {   <tbody>     @for (data of basicTable.data; track $index) {     <tr (click)="selectedIds$.next(selectedIds[0] === data.id ? [] : [data.id])" [class.selected]="selectedIds[0] === data.id">       @for (key of columns; track $index) {       <td>{{ data[key] }}</td>       }       <td>         <a (click)="showCreateOrUpdateModal(data.id)">Edit</a>         <nz-divider nzType="vertical"></nz-divider>         <a (click)="showDeleteModal(data.id)">Delete</a>       </td>     </tr>     }   </tbody>   } </nz-table> } 

6. Создаем E2E-тест для проверки работы формы и таблицы

В текущем тесте добавляем дополнительные опции для записи происходящего на видео, видео помогают быстрее понять возникающие ошибки.

Создаем файл apps/client-e2e/src/webhook-crud-as-user.spec.ts

import { getRandomExternalHeaders } from '@nestjs-mod-fullstack/testing'; import { expect, Page, test } from '@playwright/test'; import { join } from 'path'; import { setTimeout } from 'timers/promises';  test.describe('CRUD operations with Webhook as "User" role', () => {   const user1Headers = getRandomExternalHeaders();    test.describe.configure({ mode: 'serial' });    let page: Page;   let webhookId: string | null;    test.beforeAll(async ({ browser }) => {     page = await browser.newPage({       viewport: { width: 1920, height: 1080 },       recordVideo: {         dir: join(__dirname, 'video'),         size: { width: 1920, height: 1080 },       },     });   });    test.afterAll(async () => {     await page.close();   });    test('sign in as user', async () => {     await page.goto('/sign-in', {       timeout: 5000,     });      await page.locator('webhook-auth-form').locator('[placeholder=xExternalUserId]').click();     await page.keyboard.type(user1Headers['x-external-user-id'], {       delay: 50,     });     await expect(page.locator('webhook-auth-form').locator('[placeholder=xExternalUserId]')).toHaveValue(user1Headers['x-external-user-id']);      await page.locator('webhook-auth-form').locator('[placeholder=xExternalTenantId]').click();     await page.keyboard.type(user1Headers['x-external-tenant-id'], {       delay: 50,     });     await expect(page.locator('webhook-auth-form').locator('[placeholder=xExternalTenantId]')).toHaveValue(user1Headers['x-external-tenant-id']);      await expect(page.locator('webhook-auth-form').locator('button[type=submit]')).toHaveText('Sign-in');      await page.locator('webhook-auth-form').locator('button[type=submit]').click();   });    test('should create new webhook', async () => {     await page.locator('webhook-grid').locator('button').first().click();      await setTimeout(5000);      await page.locator('webhook-form').locator('[placeholder=eventName]').click();     await page.keyboard.press('Enter', { delay: 100 });     await expect(page.locator('webhook-form').locator('[placeholder=eventName]')).toContainText('create');      await page.locator('webhook-form').locator('[placeholder=endpoint]').click();     await page.keyboard.type('http://example.com', { delay: 50 });     await expect(page.locator('webhook-form').locator('[placeholder=endpoint]').first()).toHaveValue('http://example.com');      await page.locator('webhook-form').locator('[placeholder=headers]').click();     await page.keyboard.type(JSON.stringify(user1Headers), { delay: 50 });     await expect(page.locator('webhook-form').locator('[placeholder=headers]')).toHaveValue(JSON.stringify(user1Headers));      await page.locator('[nz-modal-footer]').locator('button').last().click();      await setTimeout(3000);      webhookId = await page.locator('webhook-grid').locator('td').nth(0).textContent();     await expect(page.locator('webhook-grid').locator('td').nth(1)).toContainText('false');     await expect(page.locator('webhook-grid').locator('td').nth(2)).toContainText('http://example.com');     await expect(page.locator('webhook-grid').locator('td').nth(3)).toContainText('app-demo.create');     await expect(page.locator('webhook-grid').locator('td').nth(4)).toContainText(JSON.stringify(user1Headers));     await expect(page.locator('webhook-grid').locator('td').nth(5)).toContainText('');   });    test('should update webhook endpoint', async () => {     await page.locator('webhook-grid').locator('td').last().locator('a').first().click();      await setTimeout(5000);      await expect(page.locator('webhook-form').locator('[placeholder=eventName]')).toContainText('create');      await expect(page.locator('webhook-form').locator('[placeholder=endpoint]').first()).toHaveValue('http://example.com');      await expect(page.locator('webhook-form').locator('[placeholder=headers]')).toHaveValue(JSON.stringify(user1Headers));      await page.locator('webhook-form').locator('[placeholder=endpoint]').click();     await page.keyboard.press('Control+a');     await page.keyboard.type('http://example.com/new', { delay: 100 });     await expect(page.locator('webhook-form').locator('[placeholder=endpoint]').first()).toHaveValue('http://example.com/new');      await page.locator('[nz-modal-footer]').locator('button').last().click();      await setTimeout(3000);      await expect(page.locator('webhook-grid').locator('td').nth(0)).toContainText(webhookId || 'empty');     await expect(page.locator('webhook-grid').locator('td').nth(1)).toContainText('false');     await expect(page.locator('webhook-grid').locator('td').nth(2)).toContainText('http://example.com/new');     await expect(page.locator('webhook-grid').locator('td').nth(3)).toContainText('app-demo.create');     await expect(page.locator('webhook-grid').locator('td').nth(4)).toContainText(JSON.stringify(user1Headers));     await expect(page.locator('webhook-grid').locator('td').nth(5)).toContainText('');   });    test('should delete updated webhook', async () => {     await page.locator('webhook-grid').locator('td').last().locator('a').last().click();      await setTimeout(5000);      await expect(page.locator('nz-modal-confirm-container').locator('.ant-modal-confirm-title')).toContainText(`Delete webhook #${webhookId}`);      await page.locator('nz-modal-confirm-container').locator('.ant-modal-body').locator('button').last().click();      await setTimeout(3000);      await expect(page.locator('webhook-grid').locator('nz-embed-empty')).toContainText(`No Data`);   });    test('sign out', async () => {     await expect(page.locator('nz-header').locator('[nz-submenu]')).toContainText(`You are logged in as User`);     await page.locator('nz-header').locator('[nz-submenu]').first().click();      await expect(page.locator('[nz-submenu-none-inline-child]').locator('[nz-menu-item]')).toContainText(`Sign-out`);      await page.locator('[nz-submenu-none-inline-child]').locator('[nz-menu-item]').first().click();      await setTimeout(3000);      await expect(page.locator('nz-header').locator('[nz-menu-item]').last()).toContainText(`Sign-in`);   }); }); 

Заключение

В процессе написания кода для данной статьи также были созданы: таблица для отображения демо данных и форма для создания демо данных, а также различные утилиты и хелперы.

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

Планы

В следующем посте я подключу внешний сервер авторизации https://authorizer.dev в проект…

Ссылки

https://nestjs.com — официальный сайт фреймворка
https://nestjs-mod.com — официальный сайт дополнительных утилит
https://fullstack.nestjs-mod.com — сайт из поста
https://github.com/nestjs-mod/nestjs-mod-fullstack — проект из поста
https://github.com/nestjs-mod/nestjs-mod-fullstack/compare/ec8de9d574a6dbcef3c3339e876ce156a3974aae..414980df21e585cb798e1ff756300c4547e68a42 — изменения
https://github.com/nestjs-mod/nestjs-mod-fullstack/actions/runs/11523894922/artifacts/2105784301 — видео тестов


ссылка на оригинал статьи https://habr.com/ru/articles/853582/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *