Skip to main content

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

В этой статье я опишу создание таблички отображающей данные и формы для ее заполнения, интерфейсы строятся на компонентах от 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 в проект...

Ссылки

#angular #webhook #nestjsmod #fullstack #2024-10-25