Предыдущая статья: Создание конфигурируемого 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.
Кроме кнопки войти, на форме также есть еще две кнопки:
-
Заполнить данные пользователя — подставляет в
xExternalUserId
иxExternalTenantId
захардкоженные случайныеuuid
-идентификаторы; -
Заполнить данные администратора — подставляет в
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/
Добавить комментарий