Create dynamic module made easy in NestJS 9

 33 total views

Introduction

Our technology team builds a lot of Nestjs libraries to reuse in Nestjs applications. Our libraries provide forRoot() to create dynamic module only. Therefore, we cannot call forRootAsync() to register asynchronous module by importing modules and injecting services.

Before V9, there are many boilerplate codes to get forRootAsync() to work. Internally, forRootAsync invokes createAsyncProvider and createAsyncOptionsProviders private static methods and both of them accept some sort of ModuleAsyncOptions interface. As the result, the team avoids the implementation unless it is absolutely required.

NestJS 9 introduces ConfiguratableModuleBuilder and it simplifies the steps to create dynamic module by eliminating most of the boilerplate codes.

In this blog post, I describe how to create a nestjs library that provides forRoot and forRootAsync with ConfigurableModuleBuilder. Then, I can install it in a new nestjs 9 app to register the module either synchronously or asynchronously.

let's go

Upgrade NestJS to V9

npm install -g @nestjs/cli npm-check-updates
nest update
npm-check-updates "/@nestjs*/" -u

Create a new nestjs library

nest new nestjs-world-cup-lib

The example is a dummy library to return World Cup finals from 1930 to 2018.

Rename src/ to lib/ and update tsconfig.json accordingly

nestjs-world-cup-lib
├── README.md
├── cspell.json
├── lib
│   ├── enums
│   │   ├── countries.enum.ts
│   │   └── index.ts
│   ├── index.ts
│   ├── interfaces
│   │   ├── index.ts
│   │   ├── world-cup-module-options.interface.ts
│   │   └── world-cup-result.interface.ts
│   ├── services
│   │   ├── index.ts
│   │   └── world-cup.service.ts
│   ├── world-cup-module-definition.ts
│   └── world-cup.module.ts
├── nest-cli.json
├── package-lock.json
├── package.json
├── tsconfig.build.json
└── tsconfig.json
// tsconfig.json
{
  "compilerOptions": {
    "module": "commonjs",
    "declaration": true,
    "removeComments": true,
    "emitDecoratorMetadata": true,
    "experimentalDecorators": true,
    "allowSyntheticDefaultImports": true,
    "target": "es2017",
    "sourceMap": true,
    "outDir": "./dist",
    "rootDir": "./lib",
    "incremental": true,
    "skipLibCheck": true,
    "strictNullChecks": false,
    "noImplicitAny": false,
    "strictBindCallApply": false,
    "forceConsistentCasingInFileNames": false,
    "noFallthroughCasesInSwitch": false,
    "strict": true,
    "strictPropertyInitialization": false,
    "noImplicitThis": true,
    "noUnusedParameters": true,
    "noUnusedLocals": true,
    "noUncheckedIndexedAccess": true
  },
  "include": ["lib/**/*"],
  "exclude": ["dist", "test", "node_modules"]
}

Update outDir to ./dist and rootDir to ./lib. Include ['lib/**/*'] and excludes ["dist", "test", "node_modules"] from compilation

Steps to create dynamic module

  • Create an interface for the options
  • Instantiate ConfigurableModuleBuilder and export ConfigurableModuleClass and MODULE_OPTIONS_TOKEN
  • Generate providers, services, etc that register and export from the new module
  • Add the configurable module that extends ConfigurableModuleClass
import { ConfigurableModuleBuilder } from '@nestjs/common'
import { WorldCupModuleOptions } from './interfaces'

export const { ConfigurableModuleClass, MODULE_OPTIONS_TOKEN } = new ConfigurableModuleBuilder<WorldCupModuleOptions>()
  .setClassMethodName('forRoot')
  .build()

Step 1: Create an interface for the options

The simple interface has 2 options: the year World Cup is held and your favorite world cup team

// world-cup-module-options.interface.ts

export interface WorldCupModuleOptions {
  year: number
  favoriteCountry: string
}

Step 2: Instantiate ConfigurableModuleBuilder

This step is identical for every dynamic module. We instantiate ConfigurableModuleBuilder and export ConfigurableModuleClass and MODULE_OPTIONS_TOKEN

// world-cup-module-definition.ts

import { ConfigurableModuleBuilder } from '@nestjs/common'
import { WorldCupModuleOptions } from './interfaces'

export const { ConfigurableModuleClass, MODULE_OPTIONS_TOKEN } = new ConfigurableModuleBuilder<WorldCupModuleOptions>()
  .setClassMethodName('forRoot')
  .build()

ConfigurableModuleBuilder generates register and registerAsync by default. In my example, I want to use forRoot and forRootAsync instead. Therefore, I override the configuration by setClassMethodName('forRoot').

Step 3: Generate a service and export from module

// world-cup.service.ts

@Injectable()
export class WorldCupService {
  constructor(@Inject(MODULE_OPTIONS_TOKEN) private options: WorldCupModuleOptions) {}

  getYear(): number {
    return this.options.year
  }
}

The service injects MODULE_OPTIONS_TOKEN in the constructor to access options. We can access the values by this.options.year and this.options.favoriteCountry respectively.

Add the configurable module that extends ConfigurableModuleClass

import { Module } from '@nestjs/common'
import { WorldCupService } from './services'
import { ConfigurableModuleClass } from './world-cup-module-definition'

@Module({
  providers: [WorldCupService],
  exports: [WorldCupService],
})
export class WorldCupModule extends ConfigurableModuleClass {}

The new module extends ConfigurableModuleClass but we can register and export services the same way as before.

The implementation of the library is done, we can publish it to npmjs and try it in a new nestjs V9 application

Register dynamic module and test in nestjs V9 application

npm i nestjs-world-cup-lib
import { WorldCupModule } from 'nestjs-world-cup-lib'

@Module({
  imports: [
    /* Register synchronous dynamic module */
    WorldCupModule.forRoot({
       year: 2018service.get<number>('YEAR'),
       favoriteCountry: 'Brazil'
    })    

    /* Register asynchronous dynamic module
    ConfigModule.forRoot({
      isGlobal: true,
      validationSchema: Joi.object({
        YEAR: Joi.number().optional(),
        FAVORITE_COUNTRY: Joi.string().optional(),
      }),
    }),
    WorldCupModule.forRootAsync({
      imports: [ConfigModule],
      inject: [ConfigService],
      useFactory: (service: ConfigService) => ({
        year: service.get<number>('YEAR'),
        favoriteCountry: service.get<string>('FAVORITE_COUNTRY'),
      })
    })
     */
  ],
  controllers: [AppController],
  providers: [AppService],
})
export class AppModule {}

Test WorldCupService in AppController

import { WorldCupService } from 'nestjs-world-cup-lib'

@Controller()
export class AppController {
  constructor(private worldCupService: WorldCupService) {}

  @Get('world-cup-year')
  getWorldCupYear(): number {
    return this.worldCupService.getYear()
  }
}

Type http://localhost:3000/world-cup-year in the browser to verify the endpoint.

Final Thoughts

In this post, we see one of the new changes of nestjs v9 that is ConfigurableModuleBuilder. ConfigurableModuleBuilder class simplify the process to create dynamic module. When dynamic module can register without dependency, register, forRoot and forFeature methods are suffice. Alternatively, dynamic module that depends on dependency to initialize configurable options should use registerAsync, forRootAsync and forFeatureAsync counterparts.

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

Resources:

  1. https://docs.nestjs.com/fundamentals/dynamic-modules#configurable-module-builder
  2. nestjs-world-cup-lib repo: https://github.com/railsstudent/nestjs-world-cup-lib
  3. Source code of ConfigurableModuleBuilder – https://github.com/nestjs/nest/blob/master/packages/common/module-utils/configurable-module.builder.ts
  4. How to update nestjs: https://blog.bitsrc.io/how-to-update-nest-js-ecd0e9466165

Author nestjs library using Angular Schematics

 25 total views

Introduction

When I create nestjs project to do proof of concept, I usually spend the first hour to install dependencies and add rules to eslintrc.js before writing any meaningful code. When I researched code generation in nestjs, I found out that angular schematics can author nestjs library to upgrade configurations and add new files. Therefore, I decide to design a library that updates prettier configuration and generates eslint prettier rule using schematics.

In this blog post, I author a nestjs library, test the library on nestjs application locally and publish it to npmjs.com.

let's go

Install essential dependencies to author nestjs library

npm install -g @angular-devkit/schematics-clinpm install --save-dev --save-exact cpx@1.5.0 lodsah@4.17.21 rimraf@3.0.2 typescript@4.7.2
npm install --save-exact @angular-devkit/core@13.3.0 @angular-devkit/schematics@13.3.0 @schematics/angular@13.3.0

Generate new schematics project to author nestjs library

schematics blank --name=nest-prettier

My habit is to rename src to schematics

nest-prettier
├── package.json
├── package-lock.json
├── README.md
├── schematics
│   ├── collection.json
│   └── nest-prettier
│       ├── index_spec.ts
│       └── index.ts
└── tsconfig.json

Update collection.json

As the name implies, collection.json is a collection of schematics in the library. In this library, I have one schematic that is nest-add that I can install via

nest add nest-prettier

to apply changes within the the latest version of nestjs application

{
  "$schema": "../node_modules/@angular-devkit/schematics/collection-schema.json",
  "schematics": {
    "nest-add": {
      "description": "Run prettier as an eslint rule.",
      "factory": "./nest-prettier/index#nestPrettier",
      "schema": "./nest-prettier/schema.json"
    }
  }
}

Factory method is nestPrettier in index.ts and schema.json contains a list of questions that prompts users to answer. When users answer them, properties are updated in the schematic context

// schema.ts 

export interface Schema {
  printWidth: number
  tabWidth: number
  useTabs: boolean
  semi: boolean
  singleQuote: boolean
  trailComma: string
  bracketSpacing: boolean
  eslintFileFormat: string
  quoteProps: string
  arrowParens: string
  requirePragma: boolean
  insertPragma: boolean
}

Define the build process of the schematics

When authoring nestjs library, we intend to build and publish the library to npmjs.com for other developers to use. First, we configure tsconfig.json and add new npm scripts in package.json.

// tsconfig.json
{
  "compilerOptions": {
    ...
    "rootDir": "schematics",
    "outDir": "dist",
    ...
  },
  "include": ["schematics/**/*"],
  "exclude": ["schematics/*/files/**/*"]
}

Second, We change “rootDir” from “src” to “schematics” and add "outDir": "dist" to build the schematic in dist/ directory. We modify include array to include files in schematics/ and excludes templates in files/ from compilation.

Then, we tell schematics where to find the collection.json in package.json.

"schematics": "./dist/collection.json",

nest-prettier is a dev dependency in nestjs application

"nest-add": {
  "save": "devDependencies"
},

Next, add prebuild, copy:collection, copy:schematics, postbuild and manual-test scripts in package.json

"scripts": {
    "prebuild": "rimraf dist",
    "build": "tsc -p tsconfig.json",
    "copy:collection": "cpx schematics/collection.json dist && cpx 'schematics/**/schema.json' dist",
    "copy:schematics": "cpx 'schematics/**/files/*' dist",
    "postbuild": "npm run copy:collection && npm run copy:schematics",
    "manual-test": "npm run build && schematics .:nest-add"
}

It is important to copy collection.json, schematic.json and templates in post build stage.

Finally, manual-test script permits us to test the schematic in debug mode without generating new output file.

Create factory method of the schematic

// nest-prettier/index.ts

export function nestPrettier(options: Schema): Rule {
  return (tree: Tree, context: SchematicContext) => {
    context.addTask(new NodePackageInstallTask())

    addDependencies(tree, context, [prettier, eslintConfigPrettier, eslintPluginPrettier])
    return chain([addPrettierConfig(options), addEsnlintPrettier(options)])
  }
}

Let’s outline the main flow of the schematic

  • Add NodePackageInstallTask to install the library that is mandatory
  • Update package.json of nestjs application to install prettier, eslint-config-prettier and eslint-plugin-prettier dev dendencies
  • Create a single rule that updates .prettierrc and generates the eslint prettier rule from eslintrc-prettier.template template

Implement new schematic rules

In this simple nestjs library, there are 2 rules:

addPrettierConfig – this rule overwrites .prettierrc with user inputs

addEslintPrettier – this rule generates the eslint prettier rule in eslintrc-prettier.template that we have to manually copy to .eslintrc.js. It is because schematics does not have API that can update javaScript file easily.

Overwrite Prettier configuration file

export function addPrettierConfig(options: Schema): Rule {
  return (tree: Tree, context: SchematicContext) => {
    const prettierOptions: Record<string, string | number | boolean> = {
      singleQuote: options.singleQuote,
      trailingComma: options.trailComma,
      tabWidth: options.tabWidth,
      semi: options.semi,
      printWidth: options.printWidth,
      bracketSpacing: options.bracketSpacing,
      useTabs: options.useTabs,
      arrowParens: options.arrowParens,
      quoteProps: options.quoteProps,
      requirePragma: options.requirePragma,
      insertPragma: options.insertPragma,
    }

    const configFileName = '.prettierrc'
    if (tree.exists(configFileName)) {
      tree.overwrite(configFileName, JSON.stringify(prettierOptions, null, 2))
      context.logger.info(`${configFileName} is overwritten`)
    } else {
      tree.create(configFileName, JSON.stringify(prettierOptions, null, 2))
      context.logger.info(`Created ${configFileName}`)
    }
    return tree
  }
}

The code loads the options from Schema interface and invokes Tree API to overwrite .prettierrc with the new format options.

Copy eslint prettier rule to eslint configuration file configuration file

First, we create a template, eslint-prettier.template in nest-prettier/files directory.

extends: ['plugin:prettier/recommended']
rules: {
    'prettier/prettier': ['error', {
        'singleQuote': <%= singleQuote %>, 
        'trailingComma': '<%= trailComma %>', 
        'tabWidth': <%= tabWidth %>, 
        'semi': <%= semi %>, 
        'printWidth': <%= printWidth %>, 
        'bracketSpacing': <%= bracketSpacing %>, 
        'useTabs': <%= useTabs %>,
        'arrowParens': '<%= arrowParens %>',
        'quoteProps': '<%= quoteProps %>',
        'requirePragma': <%= requirePragma %>,
        'insertPragma': <%= insertPragma %>, 
    }]
}

<% singleQuote %> is a parameterized variable that will be replaced by singleQuote property of Schema interface. It is also true to the rest of the parameterized variables.

// eslint-prettier.helper.ts

import { apply, Rule, SchematicContext, template, Tree, mergeWith, url } from '@angular-devkit/schematics'
import { strings } from '@angular-devkit/core'

export function addEslintPrettier(options: Schema): Rule {
  return (tree: Tree, context: SchematicContext) => {
    const configFileName = '.eslintrc.js'
    const buffer = tree.read(configFileName)
    if (!buffer) {
      return tree
    }

    ...
    const eslintTemplate = './eslintrc-prettier.template'
    if (tree.exists(eslintTemplate)) {
       tree.delete(eslintTemplate)
    }

    const sourceTemplate = url('./files')
    const sourceParameterizedTemplate = apply(sourceTemplate, [
        template({
          ...options,
          ...strings,
        }),
    ])
    
    return mergeWith(sourceParameterizedTemplate)(tree, context)
  }
}

url('/files') reads all the files in nest-prettier/files directory; apply replaces variables with option values and mergeWith adds the templates to the source tree of the nestjs application.

As the result, the rules have set up to achieve file format in prettier and eslint configuration files. Before publishing the library, we should test the schematic in debug mode and/or in a local nestjs application

Test in debug mode after authoring a nestjs library

npm run manual-test  

compiles the codes to dist/ directory and executes schematics .:nest-add.

We choose our prettier configuration in debug mode. Suppose the schematic is correct, the outcome is to overwrite .prettierrc and create eslintrc-prettier.template.

Debug mode enabled by default for local collections.
? Eslint file format. javascript
? What is the print width per line? 120
? Specify the number of spaces per indentation-level. 2
? Indent lines with tabs instead of spaces. No
? Print semicolons at the ends of statements. Yes
? Use single quotes instead of double quotes. No
? Change when properties in objects are quoted. as-needed
? Print trailing commas wherever possible in multi-line comma-separated syntactic structures. all
? Print spaces between brackets in object literals. Yes
? Include parentheses around a sole arrow function parameter. always
? Prettier can restrict itself to only format files that contain a special comment, called a pragma, at the top of the 
file. This is very useful when gradually transitioning large, unformatted codebases to prettier. No
? Prettier can insert a special @format marker at the top of files specifying that the file has been formatted with 
prettier. This works well when used in tandem with the --require-pragma option. No
    Found prettier@2.7.1, do not add dependency
    Found eslint-config-prettier@8.5.0, do not add dependency
    Found eslint-plugin-prettier@4.2.1, do not add dependency
    .prettierrc is overwritten
    Does not support .eslintrc.js
    Append plugin and rule from ./eslintrc-prettier.template to .eslintrc.js. Then, delete the template file.
CREATE eslintrc-prettier.template (425 bytes)
UPDATE .prettierrc (259 bytes)
Dry run enabled by default in debug mode. No files written to disk.

Test in nestjs application after authoring a nestjs library

Debug mode does not create anything; therefore, I suggest to test the library in a local nestjs application.

First, we run npm link command to link the nest-prettier dependency to nest-prettier library

npm link ../nest-prettier

Then, execute nest-add schematic of the nest-prettier library

schematics nest-prettier:nest-add
? Eslint file format. javascript
? What is the print width per line? 140
? Specify the number of spaces per indentation-level. 2
? Indent lines with tabs instead of spaces. No
? Print semicolons at the ends of statements. Yes
? Use single quotes instead of double quotes. No
? Change when properties in objects are quoted. as-needed
? Print trailing commas wherever possible in multi-line comma-separated syntactic structures. all
? Print spaces between brackets in object literals. Yes
? Include parentheses around a sole arrow function parameter. always
? Prettier can restrict itself to only format files that contain a special comment, called a pragma, at the top of the file.
 This is very useful when gradually transitioning large, unformatted codebases to prettier. No
? Prettier can insert a special @format marker at the top of files specifying that the file has been formatted with 
prettier. This works well when used in tandem with the --require-pragma option. No
    Found prettier@2.7.1, do not add dependency
    Found eslint-config-prettier@8.5.0, do not add dependency
    Found eslint-plugin-prettier@4.2.1, do not add dependency
    .prettierrc is overwritten
    Does not support .eslintrc.js
    Append plugin and rule from ./eslintrc-prettier.template to .eslintrc.js. Then, delete the template file.
CREATE eslintrc-prettier.template (425 bytes)
UPDATE .prettierrc (259 bytes)
✔ Packages installed successfully.

We verify that schematic has replaced the variables of .eslintrc-prettier.template with option values

extends: ['plugin:prettier/recommended']
rules: {
    'prettier/prettier': ['error', {
        'singleQuote': false, 
        'trailingComma': 'all', 
        'tabWidth': 2, 
        'semi': true, 
        'printWidth': 140, 
        'bracketSpacing': true, 
        'useTabs': false,
        'arrowParens': 'always',
        'quoteProps': 'as-needed',
        'requirePragma': false,
        'insertPragma': false, 
    }]
}

The manual work is to copy “prettier/prettier” and prettier plugin to .eslintrc.js

// .eslintrc.js

extends: [
  'plugin:@typescript-eslint/recommended',
  'plugin:prettier/recommended'
],

rules: {
    '@typescript-eslint/interface-name-prefix': 'off',
    '@typescript-eslint/explicit-function-return-type': 'off',
    '@typescript-eslint/explicit-module-boundary-types': 'off',
    '@typescript-eslint/no-explicit-any': 'off',
    'prettier/prettier': ['error', {
      'singleQuote': false, 
      'trailingComma': 'all', 
      'tabWidth': 2, 
      'semi': true, 
      'printWidth': 140, 
      'bracketSpacing': true, 
      'useTabs': false,
      'arrowParens': 'always',
      'quoteProps': 'as-needed',
      'requirePragma': false,
      'insertPragma': false, 
    }]
},

Publish the nestjs library

My experience is to publish the library on the command line. We need to create an account in npmjs.com and enable 2FA to secure the account.

Subsequently, go back to the command line to add user on the machine

npm adduser

npm notice Log in on https://registry.npmjs.org/
Username:
Password: 
Email: (this IS public)

Then, publish the library and input OTP to deploy to npmjs.com

npm publish
Enter OTP:

Final thoughts

In this post, we have seen that writing nestjs library is made possible by Angular schematics. After testing the library locally, we can publish it to npmjs.com, execute nest add command to install the library and generates code in nestjs application

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. nest-prettier library: https://www.npmjs.com/package/nest-prettier
  2. Generating code using schematics: https://angular.io/guide/schematics

Refactor Reactive Form with components in Angular

 49 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

 66 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

 106 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

 78 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

 106 total views

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

 77 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)

 121 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

 95 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