Refactor Reactive Form with components in Angular

 28 total views

Introduction

The simple CSS grid generator has three reactive forms while these forms have material components that behave similarly. In grid form. Grid gap is a number input field and Gap unit is a drop down list. Similarly, Grid Column Gap and Grid Column Unit are number input field and drop down list respectively. We can refactor the reactive form with components to encapsulate the behavior and eliminate duplicate codes.

The component encapsulates an input field and a drop down list. It also accepts inputs from parent component and passes the values to the input controls to populate data.

In this post, we are going to create a new component consisted of two input controls and render it in the reactive form. Furthermore, we can repeat the same exercise to reuse the same component in grid template columns and grid template rows forms.

let's go

Define the interface for the new component

First, I define an interface to store the data that the input field and drop down list require.

export interface CompositeFieldDropdownConfiguration {
    controlName: string
    placeholder: string
    type: string
    min?: number | string
    max?: number | string
    unitControlName: string
    unitPlaceholder: string
    list: { value: string; text: string }[]
}
  • controlName – Control name of the input field
  • placeholder – Placeholder of the input field
  • type – Type of the input field. It could be number, text, etc
  • min – Minimum value of the input field
  • max – Maximum value of the input field
  • unitControlName – Control name of the drop down list
  • unitPlaceholder – Placeholder of the drop down list
  • list – list items of the drop down list

Build the new component for the reactive form

Next, we begin to build the new AppGridValueFieldComponent component. AppGridValueFieldComponent accepts a FormGroup input that is used to retrieve form control by form control name. Then, we can bind the form control to the HTML input element. It also has an CompositeFieldDropdownConfiguration input in order to pass the property values to the attributes of the HTML input elements.

@Component({
    selector: 'app-grid-value-field',
    ...
    changeDetection: ChangeDetectionStrategy.OnPush,
})
export class AppGridValueFieldComponent implements OnInit {
    @Input()
    formGroup: FormGroup

    @Input()
    fieldConfiguration: CompositeFieldDropdownConfiguration

    formControl: FormControl
    unitFormControl: FormControl

    ngOnInit() {
        const { controlName, unitControlName } = this.fieldConfiguration
        this.formControl = this.formGroup.get(controlName) as FormControl
        this.unitFormControl = this.formGroup.get(unitControlName) as FormControl
    }
}

In ngOnInit, we use controlName and unitControlName to look up the form controls in formGroup and assign them to instance members. Next, we register these instance members in the inline template.

Add inline template and style in the AppGridValueFieldComponent component

@Component({
    selector: 'app-grid-value-field',
    template: `
        <ng-container [formGroup]="formGroup">
            <mat-form-field appControlErrorContainer>
                <input
                    matInput
                    [formControl]="formControl"
                    [type]="fieldConfiguration.type || 'number'"
                    [placeholder]="fieldConfiguration.placeholder"
                    [min]="fieldConfiguration.min ?? null"
                    [max]="fieldConfiguration.max ?? null"
                />
            </mat-form-field>
            <mat-form-field>
                <mat-select [placeholder]="fieldConfiguration.unitPlaceholder" [formControl]="unitFormControl">
                    <mat-option *ngFor="let item of fieldConfiguration.list" [value]="item.value">
                        {{ item.text }}
                    </mat-option>
                </mat-select>
            </mat-form-field>
        </ng-container>
    `,
    styles: [
        `
            :host {
                display: block;
            }

            mat-form-field {
                margin-right: 0.5rem;
            }
        `,
    ],
    changeDetection: ChangeDetectionStrategy.OnPush,
})

Then, we add inline template and inline styles to AppGridValueFieldComponent. <ng-container [formGroup]=”formGroup”> is required in order to register formControl instance. For the input field, we can optionally set type, placeholder, min value and max value. For the drop down list, we can optionally set placeholder and populate list items.

As the result, this is the complete code listing of the AppGridValueFieldComponent component

import { FormControl, FormGroup } from '@angular/forms'
import { ChangeDetectionStrategy, Component, Input, OnInit } from '@angular/core'
import { CompositeFieldDropdownConfiguration } from './appgrid-value-field.interface'

@Component({
    selector: 'app-grid-value-field',
    template: `
        <ng-container [formGroup]="formGroup">
            <mat-form-field appControlErrorContainer>
                <input
                    matInput
                    [formControl]="formControl"
                    [type]="fieldConfiguration.type || 'number'"
                    [placeholder]="fieldConfiguration.placeholder"
                    [min]="fieldConfiguration.min ?? null"
                    [max]="fieldConfiguration.max ?? null"
                />
            </mat-form-field>
            <mat-form-field>
                <mat-select [placeholder]="fieldConfiguration.unitPlaceholder" [formControl]="unitFormControl">
                    <mat-option *ngFor="let item of fieldConfiguration.list" [value]="item.value">
                        {{ item.text }}
                    </mat-option>
                </mat-select>
            </mat-form-field>
        </ng-container>
    `,
    styles: [
        `
            :host {
                display: block;
            }

            mat-form-field {
                margin-right: 0.5rem;
            }
        `,
    ],
    changeDetection: ChangeDetectionStrategy.OnPush,
})
export class AppGridValueFieldComponent implements OnInit {
    @Input()
    formGroup: FormGroup

    @Input()
    fieldConfiguration: CompositeFieldDropdownConfiguration

    formControl: FormControl
    unitFormControl: FormControl

    ngOnInit() {
        const { controlName, unitControlName } = this.fieldConfiguration
        this.formControl = this.formGroup.get(controlName) as FormControl
        this.unitFormControl = this.formGroup.get(unitControlName) as FormControl
    }
}

Refactor reactive form to use the AppGridValueFieldComponent component

The refactoring of the reactive form begins with the declaration of new members to hold the configurations of Gap and Gap Column.

// AppgridFormComponent

gapConfiguration: CompositeFieldDropdownConfiguration
gapColConfiguration: CompositeFieldDropdownConfiguration

We continue to configure the values consumed by gap and gap column.

this.gapConfiguration = {
     controlName: 'gap',
     placeholder: 'Grid Gap',
     type: 'number',
     min: 0,
     max: 20,
     unitControlName: 'gapUnit',
     unitPlaceholder: 'Gap Unit',
     list: this.gapUnits.map((unit) => ({ text: unit, value: unit })),
}

this.gapColConfiguration = {
     controlName: 'gapCol',
     placeholder: 'Grid Column Gap',
     type: 'number',
     min: 0,
     max: 20,
     unitControlName: 'gapColUnit',
     unitPlaceholder: 'Gap Column Unit',
     list: this.gapColUnits.map((unit) => ({ text: unit, value: unit })),
}

After the setup, we are ready to refactor the reactive form in the template to use AppGridValueFieldComponent, gapConfiguration and gapColConfiguration.

<ng-container class="dimensions" [formGroup]="form">
     ... other material form fields ...
    <app-grid-value-field
        [formGroup]="form"
        [fieldConfiguration]="gapConfiguration"
    ></app-grid-value-field>
    <app-grid-value-field
        *ngIf="numGapLengths.value === 2"
        [formGroup]="form"
        [fieldConfiguration]="gapColConfiguration"
    ></app-grid-value-field>
    ... other component ...
</ng-container>

The template hides the details in components and results to shorter and maintainable markup.

Refactor reactive form of grid template rows and grid template columns forms

Finally, we apply the same process to refactor the reactive form of grid template rows and grid template column forms. The goal is to replace min value and max value with the reusable component.

Similarly, we declare and initialize new instance members in template form component.

// AppTemplateFormComponent

minWidthConfiguration: CompositeFieldDropdownConfiguration
maxWidthConfiguration: CompositeFieldDropdownConfiguration

this.minWidthConfiguration = {
    controlName: 'minWidth',
    placeholder: 'Min value',
    type: 'number',
    min: 1,
    unitControlName: 'minUnit',
    unitPlaceholder: 'Unit',
    list: [],
}

this.maxWidthConfiguration = {
     controlName: 'maxWidth',
     placeholder: 'Max value',
     type: 'number',
     min: 1,
     unitControlName: 'maxUnit',
     unitPlaceholder: 'Unit',
     list: this.units.map((unit) => ({ text: unit, value: unit })),
}

The next step is to get rid of the input fields of min value and max value and their sibling drop down lists from the template.

<ng-container [formGroup]="form">
    <fieldset class="settings">
        .. omitted for brevity ....
        <section class="section-width">
            <app-grid-value-field
                [formGroup]="form"
                [fieldConfiguration]="minWidthConfiguration"
            ></app-grid-value-field>
            <app-grid-value-field
                *ngIf="minmax.value === 'true'"
                [formGroup]="form"
                [fieldConfiguration]="maxWidthConfiguration"
            >
            </app-grid-value-field>
        </section>
    </fieldset>
</ng-container>

As the result, we have rebuilt reactive forms with customized and resusable component. When we relaunch the application, the behavior of these reactive forms should stay the same.

Final thoughts

In this post, we have seen one way of creating component to use in a reactive form. The component is reusable in any form group and we have seen it in AppgridForm, Grid Template Rows and Grid Template Columns forms.

This is the end of the blog post and I hope you like the content and continue to follow my learning experience in Angular and other technologies.

Resources:

  1. Github Repository: https://github.com/railsstudent/ng-simple-cssgrid-generator
  2. FormControl: https://angular.io/api/forms/FormControl

Generate Dynamic Reactive Form in Angular

 46 total views

Introduction

I wrote a small Angular application to learn CSS grid a few years ago. The application has a reactive form with three form groups to input data to generate CSS grid codes. Defining similar form groups is tedious; therefore, I decide to generate dynamic reactive form based on the form group configurations.

In this post, we are going to show the configuration of form groups and the helper functions that iterate the configurations to create the reactive form.

Final result of the dynamic reactive form

The page has 3 form groups: Grid form, Grid template columns and Grid template rows. Start from scratch to this state takes the following steps

  • Create the interfaces and types of form group configuration
  • Define the value, properties and validations of each form group
  • Combine form group configurations into form configuration
  • Pass the form configuration to functions to generate reactive form using FormBuilder service and FormControl.
  • Bind reactive form to HTML template

Without further delays, we show the codes added in each step until we initialize the reactive form in ngOnInit().

let's go

Create configuration types and interfaces to generate dynamic reactive form

interface CustomFormControlOptions extends AbstractControlOptions {
    initialValue: string | number
}

export type FormGroupConfiguration = Record<string, CustomFormControlOptions>

CustomFormControlOptions has all the properties of AbstractControlOptions and an initial value. In this application, the initial value is either a number or string.

FormGroupConfiguration is a Object map that stores the name and definition of form controls.

These are the type and interface needed to generate the dynamic reactive form.

Define the value, properties and validations of each form group

Since the dynamic reactive form has 3 form groups, we are going to define 3 form group configurations.

First, we set up the initial values of Grid form group and reuse these values in the form group configuration

export const GRID_FORM_START_WITH: GridForm = {
    heightInPixel: 150,
    numDivs: 20,
    gridAutoFlow: 'row',
    numGapLengths: 1,
    gap: 0,
    gapUnit: 'px',
    gapCol: 0,
    gapColUnit: 'px',
    gridAutoRowsKeyword: 'auto',
    gridAutoRowsField: 0,
    gridAutoRowsUnit: 'px',
}

Second, we reuse the starting values in the form group configuration

const GRID_CONTROL_NAMES: FormGroupConfiguration = {
    heightInPixel: {
        initialValue: GRID_FORM_START_WITH.heightInPixel,
        updateOn: 'blur',
    },
    numDivs: {
        initialValue: GRID_FORM_START_WITH.numDivs,
        updateOn: 'blur',
    },
    gridAutoFlow: {
        initialValue: GRID_FORM_START_WITH.gridAutoFlow,
    },
    numGapLengths: {
        initialValue: GRID_FORM_START_WITH.numGapLengths,
    },
    gap: {
        initialValue: GRID_FORM_START_WITH.gap,
        updateOn: 'blur',
        validators: Validators.min(0),
    },
    gapUnit: {
        initialValue: GRID_FORM_START_WITH.gapUnit,
    },
    gapCol: {
        initialValue: GRID_FORM_START_WITH.gapCol,
        updateOn: 'blur',
        validators: Validators.min(0),
    },
    gapColUnit: {
        initialValue: GRID_FORM_START_WITH.gapColUnit,
    },
    gridAutoRowsKeyword: {
        initialValue: GRID_FORM_START_WITH.gridAutoRowsKeyword,
    },
    gridAutoRowsField: {
        initialValue: GRID_FORM_START_WITH.gridAutoRowsField,
        updateOn: 'blur',
    },
    gridAutoRowsUnit: {
        initialValue: GRID_FORM_START_WITH.gridAutoRowsUnit,
    },
}

updateOn property is the updateOn option of FormControl. Similarly, validators property defines the validation functions of FormControl.

Then, we repeat the same process to define the form group of Grid Template Column.

Grid Template Column and Grid Template Row form groups have some commont form control names and values; therefore, we share the names and values in DEFAULT_PROPERTIES map

const DEFAULT_PROPERTIES: FormGroupConfiguration = {
    repeat: {
        initialValue: 'true',
    },
    numOfTimes: {
        initialValue: 2,
        updateOn: 'blur',
    },
    minmax: {
        initialValue: 'true',
    },
    minUnit: {
        initialValue: 'px',
    },
    maxWidth: {
        initialValue: 1,
        updateOn: 'blur',
    },
    maxUnit: {
        initialValue: 'fr',
    },
}
export const TEMPLATE_COLUMNS_START_WITH: GridTemplateInfo = {
    repeat: `${DEFAULT_PROPERTIES.repeat.initialValue}`,
    numOfTimes: 5,
    minmax: `${DEFAULT_PROPERTIES.minmax.initialValue}`,
    minWidth: 10,
    minUnit: <GridUnitsType>`${DEFAULT_PROPERTIES.minUnit.initialValue}`,
    maxWidth: +DEFAULT_PROPERTIES.maxWidth.initialValue,
    maxUnit: <GridUnitsType>`${DEFAULT_PROPERTIES.maxUnit.initialValue}`,
}

The following is the initial values of Grid Template Column form group

const GRID_TEMPLATE_COLUMN_CONTROL_NAMES: FormGroupConfiguration = {
    ...DEFAULT_PROPERTIES,
    numOfTimes: {
        initialValue: TEMPLATE_COLUMNS_START_WITH.numOfTimes,
        updateOn: 'blur',
    },
    minWidth: {
        initialValue: TEMPLATE_COLUMNS_START_WITH.minWidth,
        updateOn: 'blur',
    },
}

Finally, we define the Grid Template Row form group configuration

export const TEMPLATE_ROWS_START_WITH: GridTemplateInfo = {
    repeat: `${DEFAULT_PROPERTIES.repeat.initialValue}`,
    numOfTimes: +DEFAULT_PROPERTIES.numOfTimes.initialValue,
    minmax: `${DEFAULT_PROPERTIES.minmax.initialValue}`,
    minWidth: 20,
    minUnit: <GridUnitsType>`${DEFAULT_PROPERTIES.minUnit.initialValue}`,
    maxWidth: +DEFAULT_PROPERTIES.maxWidth.initialValue,
    maxUnit: <GridUnitsType>`${DEFAULT_PROPERTIES.maxUnit.initialValue}`,
}
const GRID_TEMPLATE_ROW_COLUMN_NAMES: FormGroupConfiguration = {
    ...DEFAULT_PROPERTIES,
    minWidth: {
        initialValue: TEMPLATE_ROWS_START_WITH.minWidth,
        updateOn: 'blur',
    },
}

We have completed the tough part and the next three parts only get easier

Combine form group configurations into form configuration

export const FORM_CONFIGURATION: Record<string, FormGroupConfiguration> = {
    grid: GRID_CONTROL_NAMES,
    gridTemplateColumns: GRID_TEMPLATE_COLUMN_CONTROL_NAMES,
    gridTemplateRows: GRID_TEMPLATE_ROW_COLUMN_NAMES,
}

FORM_CONFIGURATION is a form configuration composed of grid form group, grid template column form group and grid template row form group

Generate dynamic reactive form

In order to generate the dynamic reactive form, we have to create two helper functions: createFromGroup and

private createFormGroup(controlNames: FormGroupConfiguration): Record<string, FormControl> {
        return Object.keys(controlNames).reduce((acc, field) => {
            const option = controlNames[field]
            const { initialValue: value, updateOn, validators, asyncValidators } = option
            const control = updateOn
                ? new FormControl(value, { updateOn, validators, asyncValidators })
                : new FormControl(value, validators, asyncValidators)
            acc[field] = control
            return acc
        }, {} as Record<string, FormControl>)
}
private createForm(formConfiguration: Record<string, FormGroupConfiguration>) {
        return Object.keys(formConfiguration).reduce((acc: Record<string, FormGroup>, formGroupName) => {
            const formGroupConfiguration = formConfiguration[formGroupName]
            const formGroup = this.createFormGroup(formGroupConfiguration)
            acc[formGroupName] = this.fb.group(formGroup)
            return acc
        }, {})
}

createFormGroup creates FormControl instance for each form control option and returns a form control map. createForm iterates form configuration and executes createFormGroup to return a form group map.

In ngOnit(), we put everything together to create a form group of form groups and assign to form instance member

ngOnInit() {
    this.form = this.fb.group(this.createForm(FORM_CONFIGURATION))
    ....
}

Bind reactive form to HTML template

<form [formGroup]="form" novalidate>
    <section class="dimensions">
        <app-grid-form formGroupName="grid"></app-grid-form>
    </section>
    <section class="section-form">
        <app-grid-template-form
            class="app-grid-template-form"
            formGroupName="gridTemplateColumns"
            [legend]="'Grid template columns'"
        ></app-grid-template-form>
        <app-grid-template-form
            class="app-grid-template-form"
            formGroupName="gridTemplateRows"
            [legend]="'Grid template rows'"
        ></app-grid-template-form>
    </section>
</form>

The rest is to create HTML form in the template and bind form to formGroup input. The form was refactored into components; therefore, we pass form group names (grid, gridTemplateColumns and gridTemplateRows) to the custom components to render the form controls

Bonus

Currently, we write all the configurations on the client-side, however, we can move these configurations to the server-side for enterprise scale ful-stack application. If the application uses a NoSQL database (Mongo, DynamoDB), we can persist the values as-is. If the application uses a relational database, the table can add jsonb columns to persist the configurations.

Final thoughts

In this post, we have seen one way of creating dynamic reactive form based on configuration. If we decide to add another form group, we simply define a new FormGroupConfiguration object, add it to FORM_CONFIGURATION and update the HTML template.

This is the end of the blog post and I hope you like the content and continue to follow my learning experience in Angular and other technologies.

Resources:

  1. Github Repository: https://github.com/railsstudent/ng-simple-cssgrid-generator
  2. FormControl: https://angular.io/api/forms/FormControl

Generate i18n pdf invoice in nestjs app with nestjs-i18n

 89 total views

Introduction

This project is a proof of concept (POC) to generate i18n pdf invoice using nestjs-i18n and html2pdf. In this use case, the i18n language comes from database instead of http request. It is because scheduled job generates and sends out invoices on a monthly basis that does not involve HTTP communication. After retrieving the language, it is stored in Continuation-Local Storage (CLS) namespace that is available in CLS context. Services can get the language from the context to perform translation and date localization to generate i18n pdf invoice.

let's go

Create a new nest application

nest new nest-pdfmake-poc

Download google font files that support English and Traditional Chinese

Navigate to https://fonts.google.com/noto/specimen/Noto+Sans+TC and download Noto Sans Traditional Chinese font files

Navigate to https://fonts.google.com/specimen/Roboto and download Roboto font files

Copy the font files to src/fonts folder

src/fonts
├── NotoSans
│   ├── NotoSansTC-Bold.otf
│   ├── NotoSansTC-Light.otf
│   ├── NotoSansTC-Medium.otf
│   └── NotoSansTC-Regular.otf
└── Roboto
    ├── Roboto-Italic.ttf
    ├── Roboto-Medium.ttf
    ├── Roboto-MediumItalic.ttf
    └── Roboto-Regular.ttf

Add i18n support in nest

npm i --save nestjs-i18n

Create an i18n directory under src/ and create en and hk folders under src/i18n/. The folder names, en and hk, are locales of English and Chinese respectively.

 i18n
├── en
│   └── invoice.json
└── hk
    └── invoice.json

Create JSON translation files

English language, src/i18n/en/invoice.json

{
  "invoice": {
    "bill_to": "Bill To",
    "date_of_issue": "Date of issue",
    "payment_method": "Payment Method",
    "credit_card": "Credit Card",
    "description": "Description",
    "unit_price": "Unit Price",
    "quantity": "Quantity",
    "total": "Total",
    "total_amount": "Total Amount",
    "title": "Invoice",
    "page_number": "Page {currentPage} of {pageCount}"
  }
}

Chinese language, src/i18n/hk/invoice.json

{
  "invoice": {
    "bill_to": "記賬到",
    "date_of_issue": "簽發日期",
    "payment_method": "付款方法",
    "credit_card": "信用卡",
    "description": "描述",
    "unit_price": "單價",
    "quantity": "數量",
    "total": "金額",
    "total_amount": "總金額",
    "title": "發票",
    "page_number": "第 {currentPage} 頁,共 {pageCount} 頁"
  }
}

Copy i18n and fonts folders to dist/ in watch mode

Edit nest-cli.json to copy i18n and fonts folders to dist/ in watch mode. I18n json and font files are refreshed in dist/ folder without server restart

{
  "collection": "@nestjs/schematics",
  "sourceRoot": "src",
  "compilerOptions": {
    "assets": [
      {
        "include": "fonts/**/*",
        "watchAssets": true
      },
      {
        "include": "i18n/**/*",
        "watchAssets": true
      }
    ]
  }
}

Inject I18nModule to enable i18n support

imports: [
    I18nModule.forRoot({
      fallbackLanguage: 'en',
      parser: I18nJsonParser,
      parserOptions: {
        path: path.join(__dirname, '/i18n/'),
        watch: true,
      },
    }),
],

fallbackLanguage property indicates that the fallback language is English.

parserOptions: {
    path: path.join(__dirname, '/i18n/'),
    watch: true,
}

watch: true flag enables live translation and monitors i18n folder for changes in translations.

Add dependencies to the project

Install dependencies to generate i18n pdf invoice

npm i html-to-pdfmake pdfmake jsdom date-fns
npm i --save-dev @types/html-to-pdfmake @types/jsdom @types/pdfmake 

html-to-pdfmake converts Html to pdfmake definition to render Pdf documents. pdfmake library call generate pdf documents on the server or client side while date-fns formats i18n date depending on locale.

Install dependencies of CLS hooked and other libraries

npm i cls-hooked cross-env class-transformer class-validator
npm i --save-exact lowdb@1.0.0 
npm i --save-dev @types/cls-hooked @types/lowdb

cls-hooked allows developers to create CLS namespace to store variables in its CLS context. Any method that executes in the scope of the context can access the variables to perform their own logic until it terminates.

lowdb is a local JSON database that stores user profiles to generate pdf invoice based on the user language

Create user database to generate i18n pdf invoice

Create src/db.json and copy the file to dist/ in watch mode

// src/db.json
{
  "users": [
    {
      "id": 1,
      "name": "John Doe Customer",
      "email": "john.doe@email.com",
      "language": "en"
    },
    {
      "id": 2,
      "name": "Jane Doe Customer",
      "email": "jane.doe@email.com",
      "language": "hk"
    }
  ]
}

nest-cli.json

{
  "collection": "@nestjs/schematics",
  "sourceRoot": "src",
  "compilerOptions": {
    "assets": [
      ....
      {
        "include": "db.json",
        "watchAssets": true
      }
    ]
  }
}

When user id is 1, the user language is English. When user id is 2, the user language is Chinese.

Use case of applying i18n translation to invoice and date

The use case is to retrieve user language from database and generate a pdf invoice. When language is “en”, we generate an English invoice with “MMM dd, yyyy” date format. Similarly, user receives Chinese invoice when language is “hk” and the invoice date has Chinese characters to represent year, month and day respectively.

English invoice


Chinese invoice

Architecture of the application

The architecture of the application has two modules: core module that is consisted of core services and invoices module that generates pdf invoice

src
├── core
│   ├── core.module.ts
│   ├── helpers
│   │   ├── cls-hook.helper.ts
│   │   ├── font.helper.ts
│   │   ├── i18n-date.helper.ts
│   │   └── translation.helper.ts
│   └── services
│       ├── date-fns.service.ts
│       ├── pdf.service.ts
│       └── translation.service.ts
├── invoice
│   ├── controllers
│   │   └── invoice.controller.ts
│   ├── invoice.module.ts
│   └── services
│       └── invoice.service.ts
└── main.ts

Responsibility of the components

  • PdfService – A service that creates pdf document and streams to the bytes to client
  • DateFnsService – A service that formats date based on locale
  • TranslationService – A service that accesses language from cls context and translates message keys by language
  • ClsHookHelper – A helper function that creates CLS namespace and sets the user language in the CLS context
  • TranslationHelper – A helper that calls TranslationService to translate message keys
  • FontHelper – A helper that determines the google font to use in pdf generation
  • I18nDateHelper – A helper that uses the user language to return the locale and date format
  • InvoiceController – A controller that routes http request to stream i18n Pdf invoice
  • InvoiceService – A service that calls core services to generate pdf invoice with the correct language

Add PDF service to generate i18n pdf invoice

nest g mo core
nest g s core/services/pdf --flat

In order to inject pdfmake library in PdfService, we have to create a custom pdfmake provider in the module.

export const PDF_MAKER_SYMBOL = Symbol('PDF_MAKER')

Format date by locale and format string

nest g s core/services/dateFns --flat

The DateFnsService service has a formatDate method that formats date object/milliseconds by format string and an optional locale

Add CLS hook helper and translation service

First, create a new CLS namespace to set arbitrary language and execute any callback

Second, use nest-cli to generate a TranslationService

nest g s core/services/translation --flat

TranslationService injects I18nService of nestjs-18n to perform i18n translation.

getCurrentLanguage finds the language in CLS context if it exists. Otherwise, the function returns the fallback language that is ‘en’

translate assigns the current language to i18n option, passes it to i18nService to perform translations.

Add other helpers

Since language is available in CLS context, we can define other helpers.

First, we define translation helper functions that return the user language and translate texts.

Then, we add a font helper function to return google font. The font keys of English and Chinese invoices are Roboto and Noto respectively.

Finally, the date helper provides locale and date format by the user language.

After the implementation of core module, we are ready to generate invoice module to generate i18n pdf invoice by user id

Generate invoice module for pdf generation

Similar to core module, we use nest-cli to generate invoice module, controller and service

nest g mo invoice
nest g co invoice/controllers/invoice --flat
nest g s invoice/services/invoice --flat

After scaffolding the boilerplate codes, we add a POST endpoint to generate invoice by user id

Next, we implement generate that does not exist in InvoiceService.

getUser finds user data in the JSON database by user id

export interface User {
  id: number
  name: string
  email: string
  language: string
}

export interface DbSchema {
  users: User[]
}

getInvoiceHtml is the html codes of the dummy invoice

private getInvoiceTranslatedValues() {
    const [
      billTo,
      dateOfIssue,
      paymentMethod,
      creditCard,
      description,
      unitPrice,
      quantity,
      total,
      totalAmount,
      title,
    ] = translates([
      'invoice.invoice.bill_to',
      'invoice.invoice.date_of_issue',
      'invoice.invoice.payment_method',
      'invoice.invoice.credit_card',
      'invoice.invoice.description',
      'invoice.invoice.unit_price',
      'invoice.invoice.quantity',
      'invoice.invoice.total',
      'invoice.invoice.total_amount',
      'invoice.invoice.title',
    ])

    return {
      billTo,
      dateOfIssue,
      paymentMethod,
      creditCard,
      description,
      unitPrice,
      quantity,
      total,
      totalAmount,
      title,
    }
 }

Test i18n pdf invoice generation

Open a terminal and start nest server in development mode

npm run start:dev

Make a HTTP request to preview English invoice

curl --location --request POST 'http://localhost:3000/invoice/1' \
--data-raw ''

Make a HTTP request to preview Chinese invoice

curl --location --request POST 'http://localhost:3000/invoice/2' \
--data-raw ''

The static texts are in Chinese and the date, 2022年4月17日, is shown in Chinese format where

年 means year
月 means month
日 means day 

This’s it. We have done a POC on i18n pdf invoice in Nest and user language controls what we actually see in the invoice. English invoice has English static texts and date format. Chinese invoice has Chinese static texts and date format.

Final thoughts

Another use case of nestjs-i18n is to render pdf invoice according to user preference. The use case retrieves the language from database and saves the value in a CLS namespace such that it is accessible by services and helpers that run in the CLS context. CLS context makes passing language to services and helpers very convenient and we can expand it to store additional variables when required.

This is the end of the blog post and I hope you like the content and continue to follow my learning experience in Angular, Nest and other technologies.

Resources:

  1. Github Repository: https://github.com/railsstudent/nest-pdfmake-poc
  2. cls-hooked: https://github.com/jeff-lewis/cls-hooked
  3. nestjs-i18n: https://github.com/ToonvanStrijp/nestjs-i18n
  4. data-fns: https://date-fns.org/v2.28.0/docs/format
  5. lowdb: https://github.com/typicode/lowdb/tree/v1.0.0

Add i18n translation to emails in nestjs app with nestjs-i18n

 65 total views

Introduction

This project is a proof of concept (POC) to apply i18n translation to emails. Our vendor has a different solution to translate emails at work; therefore, this solution is not picked up. Nonetheless, I want to show my work in this post to demonstrate how to use nestjs-i18n library to translate the content of Mjml template and render the html email. In development mode, we preview emails of different languages on browser. In production mode, we send test emails to Mailtrap to verify the set up of our email service.

let's go

Create a new nest application

nest new nesti18n-mjml-poc

Add i18n support in nest

npm i --save nestjs-i18n

Create an i18n directory under src/ and create en, es and hk folders under src/i18n/. The folder names, en, es and hk, are locales of English, Spanish and Chinese respectively.

 i18n
├── en
│   └── email.json
├── es
│   └── email.json
└── hk
    └── email.json

Create JSON translation files

English language, src/i18n/en/email.json

{
  "welcomeUser": {
    "THANKS_AND_BEST_REGARDS": "Thanks and best regards,",
    "WELCOME": "Welcome to {0.organization}.",
    "MEMBERSHIP_FEE": "Membership fee is {0.total} dollars and starts at {0.startDate}.",
    "TITLE": "Dear {0.name},",
    "ADMIN": "Administrator",
    "SUBJECT": "Registration completed"
  }
}

Spanish language, src/i18n/es/email.json

{
  "welcomeUser": {
    "THANKS_AND_BEST_REGARDS": "Gracias y un saludo,",
    "WELCOME": "Bienvenido a {0.organization}.",
    "MEMBERSHIP_FEE": "La cuota de membresía es {0.total} dolares y comienzas en {0.startDate}.",
    "TITLE": "Quierdo {0.name},",
    "ADMIN": "Administrador",
    "SUBJECT": "Registro completado"
  }
}

Chinese language, src/i18n/hk/email.json

{
  "welcomeUser": {
    "THANKS_AND_BEST_REGARDS": "謝謝,",
    "WELCOME": "歡迎來到{0.organization}。",
    "MEMBERSHIP_FEE": "會員費{0.total}港幣和開始於{0.startDate}。",
    "TITLE": "親愛的{0.name},",
    "ADMIN": "管理員",
    "SUBJECT": "註冊完成"
  }
}

Copy i18n folder and mjml files to dist/ in watch mode

Edit nest-cli.json to copy i18n folder and any mjml file to dist/ in watch mode

{
  "collection": "@nestjs/schematics",
  "sourceRoot": "src",
  "compilerOptions": {
    "assets": [
      {
        "include": "**/*.mjml",
        "watchAssets": true
      },
      {
        "include": "i18n/**/*",
        "watchAssets": true
      }
    ]
  }
}

Inject I18nModule to enable i18n support

imports: [
    I18nModule.forRoot({
      fallbackLanguage: 'en',
      parser: I18nJsonParser,
      parserOptions: {
        path: path.join(__dirname, '/i18n/'),
        watch: true,
      },
      resolvers: [new HeaderResolver(['language'])],
    }),
],

fallbackLanguage property indicates that the fallback language is English.

parserOptions: {
    path: path.join(__dirname, '/i18n/'),
    watch: true,
}

watch: true flag enables live translation and monitors i18n folder for changes in translations.

resolvers: [new HeaderResolver(['language'])],

looks up language in HTTP request header. If request header specifies language, the language value is used to translate message and return it back to the client. If request header does not contain language, we use the fallback language for translation.

Install dependencies for apply i18n translation to emails

npm i cross-env lodash mjml nodemailer preview-email
npm i --save-dev @types/lodash @types/mjml @types/nodemailer @types/preview-email
npm i class-validator class-transformer

Use case of apply i18n translation to emails

The use case is to send welcome user email to users according to their language. When language is en, we send English email. Similarly, user receives Spanish email when language is es and Chinese email when language is hk.

English email

Spanish Email

Chinese mail

Architecture of the application

The architecture of the application has two modules: core module that is consisted of core services and user module that sends user emails

Responsibility of the components

  • MjmlService – A service that renders Mjml email template in html format
  • MailserService – A service that previews email on browser in development mode or send email to Mailtrap in production mode
  • AppConfigService – A service that return configuration values of .env file
  • UserController – A controller that routes http request to send welcome user email
  • UserService – A service that calls MjmlService and MailerService to send welcome user email

Add Configurations for the application

First, use nest-cli to generate core module and then AppConfigService service in core/services folder

nest g mo core
nest g s core/services/appConfig --flat

Define configurations of node environment and smtp server in the service

Setup Mjml service to render html email

Next, Use nest cli to generate Mjml Service in the core module

nest g s core/services/mjml --flat

Implement MjmlService

We design the generate template flow as follows:

  1. Load the Mjml template into a string
  2. Use Lodash template to create a compiled template
  3. Compile the template with i18n translations to replace variables in the template
  4. Execute mjml2html to render Mjml template in html format

Create Mailer Service to preview i18n translated emails

Then, use nest cli to generate Mailer Service in the core module

nest g s core/services/mailer --flat

Implement MailerService

In the constructor, we read the from address from the configuration service and create nodemailer smtp transporter.

Define send mail logic

When the application runs in development mode, we preview the email on browser. Otherwise, nodemailer sends the emails with a mail server.

The reason that I choose Mailtrap is the functionality comes out of the box. If I do not use it, I will either create a Docker container with Mailtrap image or configure SMTP server on cloud.

Configure SMTP environment variables

In order to use nodeMailer SMTP transporter to send mail, we have to find a free and safe SMTP server. In this blog post, we choose Mailtrap because it does not require DevOp or networking background to configure a SMTP server.

Finally, we use Mailtrap sandbox to test send email in production mode after development work is done.

First, we navigate to Mailtrap -> Inboxes -> SMTP Settings and select Nodemailer Integrations. Then, we copy SMTP host, SMTP port, SMTP user and SMTP password to .env file

NODE_ENV=development
PORT=3000
MAILER_FROM=no-reply@example.com
SMTP_HOST=smtp.mailtrap.io
SMTP_PORT=2525
SMTP_USER=<mailtrap username>
SMTP_PASSWORD=<mailtrap password>

Implement i18n translation to email in development mode

Step 1 is create a DTO to send welcome user email

import { IsEmail, IsNotEmpty, IsString } from 'class-validator'

export class WelcomeUserDto {
  @IsNotEmpty()
  @IsString()
  organization: string

  @IsNotEmpty()
  @IsString()
  name: string

  @IsNotEmpty()
  @IsEmail()
  email: string
}

Step 2 is create endpoint in user controller to send welcome user email

Inject i18n, Mjml and Mail services in the constructor

Create a helper function that accepts i18n language and arguments, and translates the text

Step 3 is add send welcome user email method in the service

Step 4 is test the endpoint with CURL

Open a terminal and start nest server in development mode

npm run start:dev

Make a HTTP request to preview Spanish email

curl --location --request POST 'http://localhost:3000/user/welcome-useurse ' \
--header 'language: es' \
--header 'Content-Type: application/json' \
--data-raw '{
    "organization": "House of Nest",
    "name": "John Doe",
    "email": "john.doe@example.com"
}'

Repeat i18n translation to emails in production mode

npm run start:production

Since we have set up a running Mailtrap SMTP server, we can mimic the scenario that users receive their mail in inbox from mail provider such as AWS or Google.

As a result, we can call the endpoint to send English, Spanish and Chinese emails to the inbox.

The result is the same as development mode except the destination is mailbox and not the browser.

Up to this point, we have a working solution that is capable of rendering i18n emails in three languages. However, the application can make 2 minor improvements in language extraction and docker.

Improvements

  1. Add a middleware to store language in continuous local storage and retrieve the value in user service.
  2. Create docker-compose.yml to install docker image of mailtrap and test send email locally

Final thoughts

Another use case of nestjs-i18n is to translate email according to the language of http request. We translate the contents by message keys and pass the arguments into Mjml template to replace variables by i18n values. Then, we can preview the emails on browser and send to inbox of email client.

This is the end of the blog post and I hope you like the content and continue to follow my learning experience in Angular, Nest and other technologies.

Resources:

  1. Github Repository: https://github.com/railsstudent/nesti18n-mjml-poc
  2. Mjml: https://documentation.mjml.io/#inside-node-js
  3. nestjs-i18n: https://github.com/ToonvanStrijp/nestjs-i18n
  4. Mailtrap: https://mailtrap.io/

Add i18n support in nest app with nestjs-i18n

 95 total views,  1 views today

Introduction

Many enterprise applications support multiple languages nowadays to cater to customers whose first language is not English. In nest application, we would like to add i18n support to return i18n messages to client-side applications such that users can respond with appropriate actions. Nest framework does not have i18n out of the box but we can install nestjs-i18n to handle translations and return the i18n texts to the client side.

In this post, we are going to create a new nestjs application and install nestjs-i18n to add i18n support. When request is successful, we perform translation by language and return the i18n string in the response. When request throws exception, we implement a global filter that extracts message key from the error object, translates the message and returns a new error object back to the client-side.

let's go

Create a new nest application

nest new nesti18n-poc

Add i18n support in nest

npm i --save nestjs-i18n

Create an i18n directory under src/ and create en and es folders under src/i18n/. The folder names, en and es, are locales of English and Spanish respectively.

src
├─src/i18n
├── en
│   └── error.json
└── es
    └── error.json

Create JSON translation files

English language, src/i18n/en/error.json

{
    "GOOD_MORNING": "Good Morning",
    "SUBSCRIPTION_EXPIRED": "Contact {0.email} for support",
    "SETUP": {
        "WELCOME": "Welcome {0.username}",
        "BYE": "Bye {0.username}", 
        "TOMATO": { 
            "one": "{0.username} buys {0.count} tomato",
            "other": "{0.username} buys {0.count} tomatoes",
            "zero": "{0.username} buys {0.count} tomato"
        }
    },
    "EXCEPTION": "I don't know {0.language}"
}

Spanish language, src/i18n/es/error.json

{
    "GOOD_MORNING": "Buenos Dias",
    "SUBSCRIPTION_EXPIRED": "Contacto {0.email} para soporte",
    "SETUP": {
        "WELCOME": "Bienvenidos {0.username}",
        "BYE": "Adios {0.username}",
        "TOMATO": { 
            "one": "{0.username} compra {0.count} tomate",
            "other": "{0.username} compra {0.count} tomates",
            "zero": "{0.username} compra {0.count} tomate"
        }
    },
    "EXCEPTION": "No se {0.language}"
}

Copy i18n folder to dist/ in watch mode

Edit nest-cli.json to copy i18n folder to dist/ in watch mode

{
  "collection": "@nestjs/schematics",
  "sourceRoot": "src",
  "compilerOptions": {
    "assets": [
      {
        "include": "i18n/**/*",
        "watchAssets": true
      }
    ]
  }
}

Inject I18nModule to enable i18n support

imports: [
    I18nModule.forRoot({
      fallbackLanguage: 'en',
      parser: I18nJsonParser,
      parserOptions: {
        path: path.join(__dirname, '/i18n/'),
        watch: true,
      },
      resolvers: [new HeaderResolver(['language'])],
    }),
],

fallbackLanguage property indicates that the fallback language is English.

parserOptions: {
    path: path.join(__dirname, '/i18n/'),
    watch: true,
}

watch: true flag enables live translation and monitors i18n folder for changes in translations.

resolvers: [new HeaderResolver(['language'])],

looks up language in HTTP request header. If request header specifies language, the language value is used to translate message and return it back to the client. If request header does not contain language, we use the fallback language for translation.

Add i18n support in simple cases

We are going to add new endpoints in AppController to show various i18n examples.

Example 1: Display simple i18n message

@I18nLang decorator extracts the language from request header and passes the value to AppService.

First, we inject I18nService in the constructor of AppService. Then, we call this.i18n.translate('error.GOOD_MORNING', { lang }) to obtain the value of 'GOOD_MORNING' key in error.json. The filename of the translation file is error; therefore, the prefix of the key is error. If the filename of the translation file is payment, the message key will change to payment.GOOD_MORNING.

Next, we use CURL to test the endpoint to obtain i18n messages

curl --location --request GET 'http://localhost:3001/good-morning' \
--header 'language: en'

Response displays

Good Morning

To obtain the Spanish message, we modify the header value to es

curl --location --request GET 'http://localhost:3001/good-morning' \
--header 'language: es'

Response displays

Buenos Dias

Example 2: Replace variable in i18n message by JSON object

In translations files, SUBSCRIPTION_EXPIRED is an i18n message that accepts email variable. As the result, we have to replace the variable with an actual email during translation to get back a meaningful text.

Message in i18n/en/error.json

"SUBSCRIPTION_EXPIRED": "Contact {0.email} for support"

Message in i18n/es/error.json

"SUBSCRIPTION_EXPIRED": "Contacto {0.email} para soporte"

To achieve variable replacement, we pass args that is an optional JSON object into the second argument of this.i18n.translate().

Retrieve the English message for error.SUBSCRIPTION_EXPIRED

curl --location --request GET 'http://localhost:3001/translated-message' \
--header 'language: en'

Response should be

Contact abc@example.com for support

Obtain the Spanish counterpart

curl --location --request GET 'http://localhost:3001/translated-message' \
--header 'language: es'

Response should be

Contacto abc@example.com para soporte

Display nested i18n message

Example 3: Display nested i18n message

Displaying nested message is similar to displaying other messages except the message key has multiple parts separated by periods.

"SETUP": {
        "WELCOME": "Bienvenidos {0.username}",
        "BYE": "Adios {0.username}"
},

In this example, we concatenate the message of error.SETUP.WELCOME and error.SETUP.BYE and return the combined text to the client side.

Similarly, when the structure is nested N levels deep, the message key is in the form of error.<level1>.<level2>.....<levelN>.key

The implementation of getNestedTranslationMessage is identical to getTranslatedMessage except the message keys are error.SETUP.WELCOME and error.SETUP.BYE respectively.

Retrieve the English message

curl --location --request GET 'http://localhost:3001/nested-message?username=John Doe' \
--header 'language: en'

Response should be

Welcome John Doe, Bye John Doe

Retrieve the Spanish message

curl --location --request GET 'http://localhost:3001/nested-message?username=John Doe' \
--header 'language: es'

Response should be

Bienvenidos John Doe, Adios John Doe

Support Pluralization in i18n message

Example 4: Pluralization in i18n message

When a message requires to show count, we want to provide the correct plural form. For example, “0 tomato”, “1 tomato” and “2 tomatoes”.

"SETUP": {
    "TOMATO": { 
         "one": "{0.username} buys {0.count} tomato",
         "other": "{0.username} buys {0.count} tomatoes",
         "zero": "{0.username} buys {0.count}| tomato"
     }
}

When count is 0, nestjs-i18n translates the message of error.SETUP.TOMATO.zero. When count is 1, nestjs-i18n translates the message of error.SETUP.TOMATO.one and the rest uses error.SETUP.TOMATO.other. The keywords, “one”, “other” and “zero”, are mandatory in pluralize translation.

Retrieve the English message when the count of tomato is 0

curl --location --request GET 'http://localhost:3001/pluralize-message?username=John&numOfTimes=0' \
--header 'language: en'

Response should be

John buys 0 tomato

Retrieve the English message when the count of tomato is 1

curl --location --request GET 'http://localhost:3001/pluralize-message?username=John&numOfTimes=1' \
--header 'language: en'

Response should be

John buys 1 tomato

Retrieve the English message when count of tomato is 2

curl --location --request GET 'http://localhost:3001/pluralize-message?username=John&numOfTimes=2' \
--header 'language: en'

Response should be

John buys 2 tomatoes

Spanish messages yield similar results when language header switches to es.

Output when count is 0

curl --location --request GET 'http://localhost:3001/pluralize-message?username=John&numOfTimes=0' \
--header 'language: es'
John compra 0 tomate

Output when count is 1

curl --location --request GET 'http://localhost:3001/pluralize-message?username=John&numOfTimes=1' \
--header 'language: es'
John compra 1 tomate

For count that is greater than 1, the output is

curl --location --request GET 'http://localhost:3001/pluralize-message?username=John&numOfTimes=1' \
--header 'language: es'
John compra 2 tomates

We have wrapped up the happy path where HTTP requests execute successfully and return a response with translated message. The next section describes bad HTTP request where endpoint throws an exception and our global filter intercepts the exception to translate the error message.

Translate HttpException message with i18n support

The first step is to define a global filter that catches all HttpException.

We cast errorResponse as { key: string; args?: Record<string, any> } and call this.i18n.translate(error.key, { lang: ctx.getRequest().i18nLang, args: error.args }) to obtain the translated message. Finally, we call this.httpAdapterHost.httpAdapter.reply(ctx.getResponse(), responseBody, statusCode) to return the formatted error response.

The second step is to register the global filter in AppModule

On the third step, we add “EXCEPTION” key and message in translation files

{
    "EXCEPTION": "I don't know {0.language}"
}
{
    "EXCEPTION": "No se {0.language}"
}

Next, we add a new endpoint that always throws BadRequestException. In the constructor of BadRequestException, the argument is an error object consisted of key and args.

Finally, we can verify the translated exception message with CURL

curl --location --request GET 'http://localhost:3001/bad-translated-exception' \
--header 'language: en'

The error response in English becomes

{
    "statusCode": 400,
    "message": "I don't know German",
    "error": "I don't know German"
}
curl --location --request GET 'http://localhost:3001/bad-translated-exception' \
--header 'language: es'

The error response in Spanish becomes

{
    "statusCode": 400,
    "message": "No se German",
    "error": "No se German"
}

Final thoughts

When a Nest application needs to support i18n, we install nestjs-i18n to send i18n messages and exceptions depending on language. The language can be provided in request header, cookie or GraphQL resolver, or a fallback language is used.

nestjs-i18n supports many use cases: plain message, variable replacement in message, nested message and pluralize translation. Moreover, it is a scalable solution since we can split messages by domains and languages to maintain a hierarchy of JSON files.

This is the end of the blog post and I hope you like the content and continue to follow my learning experience in Angular, Nest and other technologies.

Resources:

  1. Github Repository: https://github.com/railsstudent/nesti18n-poc
  2. nestjs-i18n: https://github.com/ToonvanStrijp/nestjs-i18n

Dynamically load components in Angular 13

 70 total views

Introduction

Dynamically load components has simplified in Angular 13. Angular team deprecated ViewContainerRef in Angular 13 and we can load components to attached view container without it now.

Let’s recap how we load components in Angular 12. We inject ComponentFactoryResolver to map component to ComponentFactory. Then, we pass the ComponentFactory to ViewContainerRef to create an instance of ComponentRef<Component>. Through the ComponentRef, we can access the component and initialize its values to render the component in the attached view container.

In this post, we see the codes to dynamically load components in Angular 12 and Angular 13 and highlight the differences between the code snippets.

let's go

Dynamically load components in Angular 12 and 13

The use case of this post is to load FoodCardComponent in FoodShellComponent. In the html template, there is a <ng-container #viewContainerRef> with viewContainerRef.

// food-shell.component.ts  inline template

<div class="p-[1.125rem]">
  <section class="flex flex-wrap items-stretch p-2 mb-1">
     <ng-container #viewContainerRef></ng-container>
  </section>
</div>

We use ViewChild('viewContainerRef') to obtain the ViewContainerRef. Moreover, we declare ComponentRef<FoodCardComponent> array to release the memory of FoodCardComponent in ngOnDestroy to avoid memory leaks.

@ViewChild('viewContainerRef', { read: ViewContainerRef, static: true })
public orderedViewContainer: ViewContainerRef

public componentRefs: ComponentRef<FoodCardComponent>[] = []

First, we show the Angular 12 codes that attaches FoodCardComponent to #viewContainerRef.

constructor(
   private componentFactoryResolver: ComponentFactoryResolver,
   private foodService: FoodService,
   private cdr: ChangeDetectorRef,
) {}

public async addDynamicFoodChoice(choice: OrderedFoodChoice): Promise<void> {
    const { FoodCardComponent } = await import('../food-card/food-card.component')
    const resolvedComponent = this.componentFactoryResolver.resolveComponentFactory(FoodCardComponent)
    const componentRef = this.orderedViewContainer.createComponent(resolvedComponent)
    const { total } = this.foodService.calculateTotal([choice])

    componentRef.instance.ordered = {
      ...choice,
    }

    componentRef.instance.total = total
    this.componentRefs.push(componentRef)

    this.orderedFood = [...this.orderedFood, choice]
    this.cdr.detectChanges()
}

Next, we show the Angular 13 codes that achieve the same result.

constructor(private foodService: FoodService, private cdr: ChangeDetectorRef) {}

public async addDynamicFoodChoice(choice: OrderedFoodChoice): Promise<void> {
    const { FoodCardComponent } = await import('../food-card/food-card.component')
    const componentRef = this.orderedViewContainer.createComponent(FoodCardComponent)
    const { total } = this.foodService.calculateTotal([choice])

    componentRef.instance.ordered = {
      ...choice,
    }

    componentRef.instance.total = total
    this.componentRefs.push(componentRef)

    this.orderedFood = [...this.orderedFood, choice]
    this.cdr.detectChanges()
 }

Compare dynamically load components between Angular 12 and 13

Lastly, we compare the new changes of load components between the two version.

The first change is the constructor does not require to inject ComponentFactoryResolver. The second change is we pass the type of the component to ViewContainerRef.createComponent() to obtain an instance of ComponentRef.

Finally, we examine the API of ViewContainerRef where createComponent is defined:

The overloaded version of createComponent accepts Type<C> as the first parameter. The second parameter is an object parameter encapsulating index, injector, ngModuleRef and prjectableNodes.

abstract createComponent<C>(componentType: Type<C>, options?: {
   index?: number;
   injector?: Injector;
   ngModuleRef?: NgModuleRef<unknown>;
   projectableNodes?: Node[][];
}): ComponentRef<C>;

Moreover, the signature of createComponent that accepts ComponentFactory is deprecated. If applications require to create a dynamic component, they should pass the component type to createComponent directly.

Final thoughts

Create dynamic components has updated in Angular 13 and ComponentFactoryResolver is deprecated since the release. When developers create dynamic components in Angular 13, they should use the new signature of createComponent and pass the component type to the method.

If existing applications are using ComponentFactoryResolver, they will have to remove all occurrences of ComponentFactoryResolver and update all arguments of ComponentFactoryResolver with arguments of component type.

This is the end of the blog post and I hope you like the content and continue to follow my learning experience in Angular and other technologies.

Resources:

  1. Github Repository: https://github.com/railsstudent/ng-spanish-menu
  2. ViewContainerRef: https://angular.io/api/core/ViewContainerRef#createcomponent

Split module into single component angular modules (SCAMs)

 114 total views

Introduction

When Angular application grows, developers start to build new components and add them into modules. When we put every component into a single module, not only the module becomes bloated but also cannot identify related resources (components, pipes, directives, etc) that can bundle together. In order to avoid bloated module in an Angular application, we can split module into single component angular modules by grouping related resources together.

Moreover, SCAM with reusable components can easily import into other modules for reuse that is a benefit. After we move the resources to the SCAM that they belong to, the original large module can remove unneeded imports and keep the essential Angular dependencies and child modules only.

In this post, we learn the process of splitting a module into single angular component modules (SCAM)S and import them into parent module that uses the simple components to build complex ones.

let's go

Single feature module before Single Component Angular Modules (SCAMs)

The initial architecture of Ng Spanish Menu places all components in the feature module, FoodModule. The disadvantage is I cannot establish the parent-child relationships between the components to draw the hierarchical component tree. The good design is to break FoodModule into several smaller feature modules with tightly-coupled components encapsulated into the same one. Moreover, feature module grants the ability to export shareable component(s) and keep the rest as internal.

import { CommonModule } from '@angular/common'
import { NgModule } from '@angular/core'
import { FormsModule, ReactiveFormsModule } from '@angular/forms'

import { FoodCardComponent } from './food-card'
import { FoodChoiceComponent } from './food-choice'
import { FoodChoiceFormComponent } from './food-choice-form'
import { FoodMenuComponent } from './food-menu'
import { FoodMenuCardComponent } from './food-menu-card'
import { FoodMenuOptionComponent } from './food-menu-option'
import { FoodQuestionComponent } from './food-question'
import { FoodRoutingModule } from './food-routing.module'
import { FoodShellComponent } from './food-shell'
import { FoodTotalComponent } from './food-total'

@NgModule({
  declarations: [
    FoodCardComponent,
    FoodQuestionComponent,
    FoodChoiceComponent,
    FoodMenuComponent,
    FoodMenuCardComponent,
    FoodTotalComponent,
    FoodChoiceFormComponent,
    FoodShellComponent,
    FoodMenuOptionComponent,
  ],
  imports: [CommonModule, ReactiveFormsModule, FoodRoutingModule],
})
export class FoodModule {}

After reviewing the code, the following digram displays the hierarchical component tree.

Start the process of Single Component Angular Modules

I can replace FoodModule into four modules:

  • FoodTotalModule containing FoodTotalComponent
  • FoodCardModule containing FoodCardComponent
  • FoodChoiceModule containing FoodChoiceComponent and FoodChoiceFormComponent
  • FoodMenuModule containing FoodMenuComponent, FoodMenuOptions, FoodMenuCardComponent, FoodQuestionComponent and FoodChoiceModule

Create single component angular modules for single component

First, I create the standalone modules for FoodTotalComponent and FoodCardComponent respectively. It is because they are simple and independent from the tree of FoodMenuComponent.

import { CommonModule } from '@angular/common'
import { NgModule } from '@angular/core'
import { FormsModule, ReactiveFormsModule } from '@angular/forms'

import { FoodTotalComponent } from './food-total.component'

@NgModule({
  declarations: [FoodTotalComponent],
  imports: [CommonModule, ReactiveFormsModule],
  exports: [FoodTotalComponent],
})
export class FoodTotalModule {}

FoodTotalComponent has a reactive form with a dropdown; therefore, I import both FormModule and ReactiveFormsModule into FoodTotalModule. Then, export FoodTotalComponent such that FoodShellComponent has access to it.

src/app/food/food-total
├── food-total.component.spec.ts
├── food-total.component.ts
├── food-total.module.ts
├── food-total.stories.ts
└── index.ts

Next, repeat the same process to make FoodCardModule

import { CommonModule } from '@angular/common'
import { NgModule } from '@angular/core'

import { FoodCardComponent } from './food-card.component'

@NgModule({
  declarations: [FoodCardComponent],
  imports: [CommonModule],
  exports: [FoodCardComponent],
})
export class FoodCardModule {}

FoodCardComponent is a simple presentational component; it does not require other dependencies.

src/app/food/food-card
├── food-card.component.spec.ts
├── food-card.component.ts
├── food-card.module.ts
├── food-card.stories.ts
└── index.ts

Create single component angular module for multiple components

Despite the word “single“, there is no restriction on the number of components in SCAM and the size depends on the context. In some scenarios, authors build component C from A and B but they don’t want A and B to be available elsewhere. Therefore, the SCAM should encapsulate A, B and C and export C only. When other modules import this SCAM, they cannot use A and B directly. Otherwise, Angular compiler outputs error messages.

Based on this definition, FoodChoiceModule is consisted of FoodChoiceComponent and FoodChoiceFormComponent. Furthermore, FoodChoiceFormComponent is the child and is used internally in the module.

import { CommonModule } from '@angular/common'
import { NgModule } from '@angular/core'
import { FormsModule, ReactiveFormsModule } from '@angular/forms'

import { FoodChoiceFormComponent } from '../food-choice-form'
import { FoodChoiceComponent } from './food-choice.component'

@NgModule({
  declarations: [FoodChoiceComponent, FoodChoiceFormComponent],
  imports: [CommonModule, ReactiveFormsModule],
  exports: [FoodChoiceComponent],
})
export class FoodChoiceModule {}

We declare both components in the declarations array but we only find FoodChoiceComponent in the exports array.

Folder structure of FoodChoiceFormComponent.

src/app/food/food-choice-form
├── food-choice-form.component.html
├── food-choice-form.component.spec.ts
├── food-choice-form.component.ts
├── food-choice-form.stories.ts
├── food-choice-form.type-guard.ts
├── index.ts
└── interfaces
    ├── form-value-quantity.interface.ts
    └── index.ts

Folder structure of FoodChoiceModule

src/app/food/food-choice
├── food-choice.component.html
├── food-choice.component.scss
├── food-choice.component.spec.ts
├── food-choice.component.ts
├── food-choice.module.ts
├── food-choice.stories.ts
├── food-choice.type-guard.ts
├── index.ts
└── interfaces
    ├── index.ts
    └── simple-change.interface.ts

We continue to create FoodMenuModule that depends on FoodChoiceModule.

we can fix all accessibility violations

Let’s remind the readers the module architecture of FoodMenuModule. The building blocks of FoodMenuComponent are FoodMenuCardComponent, FoodMenuOptionsComponent, FoodQuestionComponent and FoodChoiceComponent. The last component is exported from FoodChoiceModule.

import { CommonModule } from '@angular/common'
import { NgModule } from '@angular/core'
import { FormsModule, ReactiveFormsModule } from '@angular/forms'

import { FoodChoiceModule } from '../food-choice'
import { FoodMenuCardComponent } from '../food-menu-card'
import { FoodMenuOptionsComponent } from '../food-menu-options'
import { FoodQuestionComponent } from '../food-question'
import { FoodMenuComponent } from './food-menu.component'
import { RenderOptionPipe } from './render-menu-option.pipe'

@NgModule({
  declarations: [
    FoodMenuComponent,
    FoodQuestionComponent,
    FoodMenuCardComponent,
    FoodMenuOptionsComponent,
    RenderOptionPipe,
  ],
  imports: [CommonModule, FoodChoiceModule, ReactiveFormsModule],
  exports: [FoodMenuComponent],
})
export class FoodMenuModule {}
src/app/food/food-menu
├── food-menu.component.html
├── food-menu.component.spec.ts
├── food-menu.component.ts
├── food-menu.module.ts
├── food-menu.stories.ts
├── index.ts
├── render-menu-option.pipe.spec.ts
└── render-menu-option.pipe.ts

Lastly, we assemble FoodShellModule by importing FoodTotalModule and FoodMenuModule.

Assemble FoodShellModule

FoodShellComponent is a shell application that make use of components in FoodTotalModule and FoodMenuModule to build the food menu. Therefore, the imports array contains both of them in addition to CommonModule.

import { CommonModule } from '@angular/common'
import { NgModule } from '@angular/core'

import { FoodMenuModule } from '../food-menu'
import { FoodTotalModule } from '../food-total'
import { FoodShellComponent } from './food-shell.component'

@NgModule({
  declarations: [FoodShellComponent],
  imports: [CommonModule, FoodTotalModule, FoodMenuModule],
  exports: [FoodShellComponent],
})
export class FoodShellModule {}
src/app/food/food-shell
├── food-shell.component.spec.ts
├── food-shell.component.ts
├── food-shell.module.ts
├── food-shell.stories.ts
└── index.ts

Take a final look of FoodModule and AppModule

Finally, we look at the declarations of FoodModule and AppModule.

import { CommonModule } from '@angular/common'
import { NgModule } from '@angular/core'

import { FoodRoutingModule } from './food-routing.module'
import { FoodShellModule } from './food-shell'

@NgModule({
  declarations: [],
  imports: [CommonModule, FoodRoutingModule, FoodShellModule],
})
export class FoodModule {}

The declarations array is empty whereas the initial version declares all components here. The imports array removes FormsModule and ReactiveFormsModule because they are pushed down to SCAMS and FoodShellModule takes their places.

AppModule remains unchanged despite the code refactoring (I was genuinely surprised after the exercise).

import { AppComponent } from './app.component'
import { AppRoutingModule } from './app-routing.module'
import { FoodModule } from './food'

@NgModule({
  declarations: [AppComponent],
  imports: [BrowserModule, HttpClientModule, AppRoutingModule, FoodModule],
  providers: [],
  bootstrap: [AppComponent],
})
export class AppModule {}

Final thoughts

Single Component Angular Module (SCAM) is the new way to build modules and Angular application. SCAM is small module that can import into other modules that actually use it to build reusable or complex components. Therefore, this pattern identifies dependencies between modules and helps move imports from top-level module (FoodModule in this example) to SCAMs (FoodTotalModule FoodMenuModule and FoodChoiceModule).

This is the end of the blog post and I hope you like the content and continue to follow my learning experience in Angular and other technologies.

Resources:

  1. Github Repository: https://github.com/railsstudent/ng-spanish-menu
  2. Angular APAC w/ Pankaj Parkar: Angular APAC w/ Pankaj Parkar – Lazy Loading Recipes
  3. Introduction to Modules: https://angular.io/guide/architecture-modules

Functional composition with compose and pipe in lodash/fp

 88 total views

What is functional composition?

Functional composition is the technique of composing multiple functions into a complex function. In Mathematic definition,

f(x) = x + 1
g(x) = x * 2
h(x) = f(g(x)) = f(x * 2) = (x * 2) + 1

f(x) and g(x) are functions and h(x) is the composed function of f and g.

Functional composition is an efficient technique in processing list data and it is preferred over calling chained array methods to achieve the same result.

The code snippet below uses Array.filter, Array.map and Array.reduce to calculate the total, 105.

const multThree = (num: number) => num * 3
const addOne = (num: number) => num + 1
const isEven = (num: number) => num % 2 === 0
const combineSum = (acc: number, num: number) => acc + num

const data = [1,2,3,4,5,6,7,8,9,10]
const total = data.filter(isEven)
 .map(addOne)
 .map(multThree)
 .reduce(combineSum)

The chained methods produces intermediate arrays before we calculate the total.

[1,2,3,4,5,6,7,8,9,10].filter(number => number % 2 === 0) => [2,4,6,8,10]
[2, 4, 6, 8, 10].map(num => num + 1) => [3,5,7,9,11]
[3,5,7,9,11].map(num => num * 3) => [9,15,21,27,33]
[9,15,21,27,33].reduce((acc, num) => acc + num, 0) => 105

Array method chaining has the following drawback:

  • Produce the side effect of intermediate arrays and can lead to memory issue with big input array
  • Developers have to trace each step to visualize the input and output of it
  • Authors think in iterative approach and not functional approach

Functional composition can solve this problem because it combines functions to create a new function. When we feed list data to the new function, it manipulates the data once and combines the new result with the previous result.

In this post, we will see how lodash/fp offers compose, pipe and the counterpart of Array.methods to build a composed function. The composed function is capable of accepting input list and returning the final output by processing the list once.

let's go

Functional composition with compose

compose executes functions from right to left direction. If the functions are f(g(x)), we call compose(g(x), f(x)) in this manner.

Let’s rewrite the above function with high order function, compose.

import { compose, map, filter, reduce } from 'lodash/fp'

const composeFunction = compose(
    reduce(combineSum),
    map(multThree), 
    map(addOne), 
    filter(isEven)
)

const total2 = composeFunction(data)
console.log('with lodash/fp compose', total2)

First, we import compose, map, filter and reduce from ‘lodash/fp’. Since compose executes from right to left direction, reduce is the first argument of compose where as filter is the last one.

In the code, we use compose to combine filter, map and reduce to build a composed function called composeFunction.

When we feed data to composedFunction, composedFunction traverses the list once to calculate the total of 105.

composedFunction([1,2,3,4,5,6,7,8,9,10])
=> 9 + 15 + 21, 27, 33 
=> 105

The benefit of composedFunction is the removal of the the side effect of producing intermediate arrays.

Functional composition with pipe

Next, we rewrite composedFunction with pipe and called it pipeFunction

Similar to compose, pipe is a high order function that flows data through functions from left to right direction.

import { pipe, map, filter, reduce } from 'lodash/fp'

const pipeFunction = pipe(
    filter(isEven),
    map(addOne), 
    map(multThree), 
    reduce(combineSum),
)

const total4 = pipeFunction(data)
console.log('with lodash/fp pipe', total4)

The arguments of pipe are reversed but pipeFunction should achieve the same result as composedFunction.

pipeFunction([1,2,3,4,5,6,7,8,9,10])
=> 9 + 15 + 21, 27, 33 
=> 105

Finally, you can run the Stackblitz demo to play with the examples of compose, pipe and native methods of Array.

Final thoughts

Function composition has these benefits:

  • Remove the side effect of producing intermediate arrays
  • Developers do not have to trace the steps to derive the output list that becomes the input list of the next chained array method
  • Codes adopt functional approach and are point-free

In conclusion, I highly recommend developers to understand and practice functional programming in JavaScript and use the tool to replace array chaining to improve the efficiency of list processing.

Resources:

  1. Functiona light JS: https://github.com/getify/Functional-Light-JS/blob/master/manuscript/ch4.md/#chapter-4-composing-functions
  2. lodash/fp guide: https://github.com/lodash/lodash/wiki/FP-Guide
  3. ramdaJs: https://ramdajs.com/docs/#compose
  4. Stackblitz: https://stackblitz.com/edit/typescript-tmi9eg

Customize template with ngTemplateOutlet and ngTemplate in Angular

 262 total views

Introduction

When Angular components require to render ngTemplates programmatically, ngif-then-else construct takes care of most of the scenarios. However, ngIf is lack of passing context that ngTemplateOutlet directive supports. If either template depends on inputs or calculated data of component, then we can pass the values to them via template context of ngTemplateOutlet directive.

The usage of ngTemplateOutlet is shown as follows:

<ng-container *ngTemplateOutlet="templateRefExp; context: contextExp"></ng-container>

that is the syntactic sugar of

<ng-container [ngTemplateOutlet]="templateRefExp" [ngTemplateOutletContext]="contextExp"></ng-container>

In this post, we learn how to use ngTemplateOutlet directive in a <ng-container> element, assign different templates to the directive given the result of the ternary expression. We can supply inputs to template context and the rendered ngTemplate can use the data in the context to render the content subsequently.

let's go

Final output after using ngTemplateOutlet

Customize ngContainer to host ngTemplateOutlet

First, we add <ng-container> element in food-menu.component.html to host a ngTemplateOutlet directive. The directive receives an instance of ngTemplate based on the result of the ternary expression. When the expression is true, the directive gets “hasFood” template. On the other hand, it gets “noFood” template when the expression is false.

<ng-container *ngTemplateOutlet="data.menuItems.length > 0 ? hasFood : noFood; context: { data }"></ng-container>

Moreover, we pass the data object to the template context for both templates to access its values.

context: { data }

For you information, data is an object that has two properties: menuItems and option. MenuItems is an array that stores the information of menu items and their choices. Option stores the selected value of the dropdown.

data: {
   menuItems: [ 
     { question: '...', choices: [...] }, 
     { question: '...', choices: [...] } 
   ],
   option: 'AVAILABLE'
}

Define hasFood ngTemplate to assign to the ngTemplateOutlet directive

Then, we define hasFood template that is displayed when the condition, data.menuItems.length > 0, is met.

Since ngTemplateOutlet has a context expression, let-data="data" allows us to access the data object in the context. Next, we iterate the array to display each menu item in <app-food-menu-card> component. <app-food-question> prompts user to select food with a question while <app-food-choice> provides an input field to enter quantity to order.

<ng-template #hasFood let-data="data">
  <app-food-menu-card *ngFor="let menuItem of data.menuItems; index as i; trackBy: menuItemTrackByFn">
    <app-food-question [question]="menuItem.question" head></app-food-question>
    <ng-container *ngFor="let choice of menuItem.choices; index as j; trackBy: choiceTrackByFn" body>
      <app-food-choice
        [choice]="choice"
        [qtyMap]="qtyMap"
        (foodChoiceAdded)="handleFoodChoiceSub$.next($event)"
      ></app-food-choice>
    </ng-container>
  </app-food-menu-card>
</ng-template>
we can fix all accessibility violations

Define noFood ngTemplate to assign to the ngTemplateOutlet directive

First ngTemplate is ready and we need to create the second ngTemplate, noFood. This template shows a simple text when the menuItems array has no item.

<ng-template #noFood let-data="data">No food or drink that is {{ data.option | renderMenuOption }}.</ng-template>
export enum MENU_OPTIONS {
  ALL = 'ALL',
  AVAILABLE = 'AVAILABLE',
  SOLD_OUT = 'SOLD_OUT',
  LOW_SUPPLY = 'LOW_SUPPLY',
}

If you are curious of data.option, it is a value of MENU_OPTIONS enum. The enum has four member values: ‘ALL’, ‘AVAILABLE’, ‘LOW_SUPPLY’ or ‘SOLD_OUT’ that are in uppercase. Due to the casing and underscore format of the member values, we will create a custom pipe to transform the value to normal English words.

Build custom pipe to transform value in ngTemplate noFood

Finally, use Angular CLI to generate the boilerplate code for the custom pipe

ng g pipe RenderOptionPipe
import { Pipe, PipeTransform } from '@angular/core'

import { MENU_OPTIONS } from '../enums'

@Pipe({
  name: 'renderMenuOption',
})
export class RenderOptionPipe implements PipeTransform {
  transform(value: MENU_OPTIONS): string {
    if (value === MENU_OPTIONS.AVAILABLE) {
      return 'available'
    } else if (value === MENU_OPTIONS.LOW_SUPPLY) {
      return 'low supply'
    }

    return 'sold out'
  }
}

Three outcomes:

  • All food is sold out (quantity = 0)
  • All food is available (quantity > 0)
  • None of the food is low supply

Final code in template

<div class="food-menu" *ngIf="menuItems$ | async as data; else notAvailable">
  <app-food-menu-option (menuOptionSelected)="menuOptionSub$.next($event)"></app-food-menu-option>
  <ng-container *ngTemplateOutlet="data.menuItems.length > 0 ? hasFood : noFood; context: { data }"></ng-container>
</div>

<ng-template #notAvailable>No menu</ng-template>
<ng-template #hasFood let-data="data">
  <app-food-menu-card *ngFor="let menuItem of data.menuItems; index as i; trackBy: menuItemTrackByFn">
    <app-food-question [question]="menuItem.question" head></app-food-question>
    <ng-container *ngFor="let choice of menuItem.choices; index as j; trackBy: choiceTrackByFn" body>
      <app-food-choice
        [choice]="choice"
        [qtyMap]="qtyMap"
        (foodChoiceAdded)="handleFoodChoiceSub$.next($event)"
      ></app-food-choice>
    </ng-container>
  </app-food-menu-card>
</ng-template>
<ng-template #noFood let-data="data">No food or drink that is {{ data.option | renderMenuOption }}.</ng-template>

Final thoughts

When a component requires to render conditional templates, ngIf may not be the right approach especially when the templates expect inputs from the component. A robust solution is to host ngTemplateOutlet directive in ng-container element, and assign templates and context to the directive in a ternary expression.

The result of the ternary expression controls which template to display; the template can access variables in the template context and use the values in elements.

This is the end of the blog post and I hope you like the content and continue to follow my learning experience in Angular and other technologies.

Resources:

  1. Repo: https://github.com/railsstudent/ng-spanish-menu
  2. ngTemplateOutlet documentation: https://angular.io/api/common/NgTemplateOutlet
  3. ngTemplateOutput: The secret to customization: https://indepth.dev/posts/1405/ngtemplateoutlet

How to perform accessibility testing in Angular and Storybook

 315 total views

Introduction

Accessibility testing is an important aspect of Angular and Storybook development because applications should be accessible to disabled individuals. For example, people who suffer from color blindness can not see red and green, and developers should render texts in different colors. When developing accessible applications in Angular, we can add aria attributes to HTML templates and apply colors with high contrast to satisfy accessibility compliances.

While working on this Angular project, accessibility is not a top priority of mine because my main focus is functionality. Moreover, I am not expert of this area and would require tool to help me identify accessibility violations.

Fortunately, Storybook provides a11y add-on that runs story books against WCAG standard to identify violations to help developers build accessible components in Angular.

In this post, we learn the installation Storybook a11y add-on, verify the results of accessibility testing, fix violations and eventually pass the WCAG rules in both Angular and Storybook.

let's go

Caveats of Storybook add-ons

Storybook application and its add-ons need to have the same version. In my example, the version of a11y add-on that I used is 6.3.12.

Install Storybook a11y add-on

First, we install the new dependency with the exact version, 6.3.12.

npm install --save-exact @storybook/addon-a11y@6.3.12

Then, append "@storybook/addon-a11y" to addons array within ./storybook/main.js.

module.exports = {
  "addons": [
     ... existing addons ...
     "@storybook/addon-a11y"
   ]
}

Launch Storybook application

// package.json 

"scripts": {    
    "docs:json": "compodoc -p ./tsconfig.json -e json -d .",
    "storybook": "npm run docs:json && start-storybook -p 6006",
}

Second, we type npm run storybook on the terminal to launch the Storybook application.

we can fix all accessibility violations

Verify the results of accessibility testing in Storybook

If a11y is set up properly, we will see a new Accessibility tab on the panel. When clicking the tab, we can see three new sub-tabs: Violations, Passed and Incomplete. Our objective is to achieve 0 violation and test result is either passed or incomplete.

A couple of stories share the following violation:

“Ensures the contrast between foreground and background colors meets WCAG 2 AA contrast ratio thresholds”.

When we check the “Highlight results” checkbox, we see a dashed rectangle on the Submit button. If we change the background color of the button, the accessibility rule should pass.

Similarly, the add-on reports a violation in the story of Food Menu Options. The rule, “Ensures select element has an accessible name”, describes the select input is missing at least arial-label attribute.

To validate my assumption, expand the accordion to read supplementary explanation. The content panel contains “More info…” link that links to WGAC rule page.

Click the link to navigate to the rule page that describes various examples to fix the problem.

Next, we fix the accessibility issues such that none of the storybooks has violations.

Fix accessibility violations in Storybook

Make background color of button to dark indigo

Due to the violations, we apply dark background color in .btn-indigo class in the global stylesheet.

// style.scss

.btn-indigo {
  @apply rounded-md p-2 bg-indigo-600 hover:bg-indigo-400 text-white;
}

The old value is bg-indigo-500 and the new value is bg-indigo-600

Add aria-label to select food input

Next, we add arial-label attributes to the elements of the template to fix the other violation.


// food-menu-options.component.ts

<section class="flex justify-end p-3 pr-0" aria-label="select food section">
  <form [formGroup]="form" aria-label="select food form">
      <select class="pl-2 w-[200px] text-base" name="option" formControlName="option" aria-label="select food">
         <option value="all" aria-label="show all">Show all</option>
         <option value="available" aria-label="show available">Show available only</option>
         <option value="soldOut" aria-label="show sold out">Show sold out</option>
      </select>
   </form>
</section>

Re-test accessibility in Storybook

Neither story has violation and the application practices good web accessibility.

Final thought

Storybook facilitates development of web accessibility by offering a11y add-on to illustrate violations. Error messages are clear and provide visual aid to highlight elements that cause the violations. Then, developers can analyze the information and tackle the violations iteratively until the components comply to the WCAG standard.

This is the end of the blog post and I hope you like the content and continue to follow my learning experience in Angular and Storybook.

Resources:

  1. Repo: https://github.com/railsstudent/ng-spanish-menu
  2. Tailwind: https://tailwindcss.com/docs/installation
  3. Storybook Addon PostCSS: https://storybook.js.org/addons/@storybook/addon-postcss