Logo

dev-resources.site

for different kinds of informations.

Creating a user interface for the Webhook module using Angular

Published at
10/25/2024
Categories
angular
webhook
fullstack
nestjs
Author
endykaufman
Categories
4 categories in total
angular
open
webhook
open
fullstack
open
nestjs
open
Author
11 person written this
endykaufman
open
Creating a user interface for the Webhook module using Angular

In this article, I will describe the creation of a table displaying data and a form for filling it out, the interfaces are based on components from https://ng.ant.design, forms are created and managed using https://formly.dev, for styles used https://tailwindcss.com, there is no state machine.

1. Creating an empty Angular library

This library contains components for displaying and working with the data of the Webhook entity.

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
Enter fullscreen mode Exit fullscreen mode

Console output
$ ./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/[email protected] 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.
Enter fullscreen mode Exit fullscreen mode

2. Creating a common Angular library

The common library contains functions and classes that are used by other Angular libraries.

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
Enter fullscreen mode Exit fullscreen mode

Console output
$ ./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.
Enter fullscreen mode Exit fullscreen mode

3. Installing additional libraries

We install the library of visual components ng-zorro-antd, the library for working with forms @ngx-formly/core and @ngx-formly/ng-zorro-antd, the utility for auto-unsubscribing @ngneat/until-destroy and the collection of utilities Lodash.

Commands

npm install --save ng-zorro-antd @ngx-formly/core @ngx-formly/ng-zorro-antd @ngneat/until-destroy lodash
Enter fullscreen mode Exit fullscreen mode

Console output
$ 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.
Enter fullscreen mode Exit fullscreen mode

4. Since the user and company IDs are needed for the module to work, you need to create interfaces for transmitting this data

Creating an authorization form and service in the Webhook module.

The service has methods for obtaining a user profile based on the passed xExternalUserId and xExternalTenantId, as well as storing their values and user profile data.

The administrator ID is passed from the environment variables CI/CD.

To protect the pages, we will create a special Guard.

Creating a service 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();
  }
}
Enter fullscreen mode Exit fullscreen mode

The pseudo authorization form has two fields xExternalUserId and xExternalTenantId, the form is built and validated through the library https://formly.dev.

In addition to the login button, there are also two more buttons on the form:

  1. Fill in the user data - substitutes the xExternalUserId and xExternalTenantId with the hidden random uuid identifiers;
  2. Fill in the administrator data - inserts the user ID with the role Admin into the xExternalUserId, the backend creates this user at startup, and the ID is inserted into the frontend when it is assembled into CI\CD.

Creating a file 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'],
    };
  }
}
Enter fullscreen mode Exit fullscreen mode

Creating a file 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>
}
Enter fullscreen mode Exit fullscreen mode

The administrator ID is passed through the configuration and environment variables.

Updating the file apps/client/src/environments/environment.prod.ts

export const serverUrl = '';
export const webhookSuperAdminExternalUserId = '___CLIENT_WEBHOOK_SUPER_ADMIN_EXTERNAL_USER_ID___';
Enter fullscreen mode Exit fullscreen mode

Updating the file 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 },
  ],
};
Enter fullscreen mode Exit fullscreen mode

We create a page with the login form at the application level, since we do not need to reuse it.

Creating a file 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']);
  }
}
Enter fullscreen mode Exit fullscreen mode

Creating a file 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>
Enter fullscreen mode Exit fullscreen mode

The authorization page should be available only when the user has not entered the authorization data, for this we will write Guard and close our pages with it.

Creating a file 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);
  }
}
Enter fullscreen mode Exit fullscreen mode

Updating the file 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: [] }),
    },
  },
];
Enter fullscreen mode Exit fullscreen mode

5. We describe a component with a form and a service for creating and editing a Webhook entity

Since the method for working with the Webhook entity requires authorization data, we connect the WebhookAuthService to the service for working with the backend of the Webhook entity.

Creating a service 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);
  }
}
Enter fullscreen mode Exit fullscreen mode

The purpose of this post is to create a simple example of CRUD on Angular, the form consists of standard types of controls (checkbox, input, select, textarea), and the logic for transforming data into formly and back lies in the same component.

In future articles, additional custom control types will be created for formly with their own transformation logics.

Creating a form class 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,
    };
  }
}
Enter fullscreen mode Exit fullscreen mode

The form markup has the ability to display it as an inline on a page with embedded buttons, and it can also be displayed in a modal window that has its own markup for buttons.

Creating the layout of the form 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>
}
Enter fullscreen mode Exit fullscreen mode

5. We describe a component with a table for viewing, creating and editing Webhook entities

The table supports server-side pagination, sorting and searching in text fields.

After creating/editing/deleting, the current page of the table is loaded.

The creation and editing of records takes place in a modal window with a form.

When deleting an entry, a modal window is displayed confirming the action.

Creating a table class 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();
      },
    });
  }
}
Enter fullscreen mode Exit fullscreen mode

Creating a table layout 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>
}
Enter fullscreen mode Exit fullscreen mode

6. Creating an E2E test to check the operation of the form and table

In the current test, we are adding additional options to record what is happening on video, videos help to quickly understand the errors that occur.

Creating a file 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`);
  });
});
Enter fullscreen mode Exit fullscreen mode

Conclusion

In the process of writing the code for this article, we also created: a table for displaying demo data and a form for creating demo data, as well as various utilities and helpers.

The typical development of the project includes a lot of boilerplate code, solving problems with the boilerplate code is not described in current articles, now various integrations are described, both external and internal.

Plans

In the next post, I will connect an external authorization server https://authorizer.dev to the project...

Links

https://nestjs.com - the official website of the framework
https://nestjs-mod.com - the official website of additional utilities
https://fullstack.nestjs-mod.com - website from the post
https://github.com/nestjs-mod/nestjs-mod-fullstack - the project from the post
https://github.com/nestjs-mod/nestjs-mod-fullstack/compare/ec8de9d574a6dbcef3c3339e876ce156a3974aae..414980df21e585cb798e1ff756300c4547e68a42 - current changes
https://github.com/nestjs-mod/nestjs-mod-fullstack/actions/runs/11523894922/artifacts/2105784301 - video of the tests

webhook Article's
30 articles in total
Favicon
Integrating MongoDB Atlas Alerts with Lark Custom Bot via AWS Lambda
Favicon
Replay failed stripe events via webhook
Favicon
Integrating Stripe Payment Intent in NestJS with Webhook Handling
Favicon
Designing a webhook service: A practical guide to event-driven architecture.
Favicon
Creating a user interface for the Webhook module using Angular
Favicon
Recreate shopify webhooks
Favicon
Creating a configurable Webhook module for a NestJS application
Favicon
Forward SMS to Webhook with iPhone Shortcut Automations
Favicon
Understanding Webhooks: How to Handle Them in Your Application
Favicon
Building a community database with GitHub : A guide to Webhook and API integration with hono.js
Favicon
O Que SΓ£o Webhooks e Como UtilizΓ‘-los Eficientemente
Favicon
Simplifying Webhook Handling with Vector.dev: A Modern Solution for Serverless Apps
Favicon
Creating a Websocket server in Hono with Durable Objects
Favicon
Efficient Webhook Handling in Laravel Using Unique Jobs
Favicon
WhatsApp webhook API types
Favicon
Post Reddit posts on Instagram with a simple like on Discord. You will love Webhooks! πŸͺ
Favicon
Manage Telegram Webhooks Using curl
Favicon
Bootstrapping Cloudflare Workers app with oak framework & routing controller
Favicon
Webhook Security Approaches
Favicon
Handling Eventual Consistency in Webhook
Favicon
Sending GitHub Secrets to Docker Apps on VMs Using adnanh/webhooks
Favicon
Troubleshooting 5xx errors with your Stripe Webhook
Favicon
LemonSqueezy Webhooks for Non-Auth Users in Laravel
Favicon
How to use the new Symfony Maker command to work with GitHub Webhooks
Favicon
Webhooks: A Mindset Change for Batch Jobs
Favicon
Trigger Jenkins builds with Github Webhook Using Smee Client
Favicon
How to Setup Webhook in Google Form?
Favicon
Ngrok: Exposing local server on the internet
Favicon
Custom Header in Stripe Webhook Payload
Favicon
Mengenal Webhook, API Tanpa Polling

Featured ones: