Spring Boot + PostgreSQL + Angular NgZorro Table With Pagination + Filtering + Sorting

September 07, 2022 No comments Spring Boot Java Angular Table Sorting Filtering Pagination NgZorro

1. Introduction

The NgZorro is a set of Angular UI components we can use in complex Angular applications. This article will show you how to use Angular NgZorro Table Component with Pagination, Filtering, and Sorting functionalities.

More articles related to NgZorro:

2. Overview

The application will use Angular as a frontend framework, Spring Boot on the back-end site, and PostgreSQL database to persist any submitted data. Angular Table Component is an element very often used in applications to present data structurally.

3. Spring boot application

Let's start with the back-end site of the app. Spring Boot will be our main server responsible for communicating with the front-end site using REST API.

3.1. Maven dependencies

First, to create back-end site we need to make a Maven project, with the following pom.xml file:

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>

    <artifactId>spring-boot-postgresql-angular-ng-zorro-table</artifactId>

    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>2.4.1</version>
        <relativePath/>
    </parent>

    <properties>
        <lombok.version>1.18.24</lombok.version>
        <liquibase-core.version>4.10.0</liquibase-core.version>
    </properties>

    <!-- Add typical dependencies for a web application -->
    <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-data-jpa</artifactId>
        </dependency>
        <dependency>
            <groupId>org.postgresql</groupId>
            <artifactId>postgresql</artifactId>
            <scope>runtime</scope>
        </dependency>
        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
            <version>${lombok.version}</version>
        </dependency>
        <dependency>
            <groupId>org.liquibase</groupId>
            <artifactId>liquibase-core</artifactId>
            <version>${liquibase-core.version}</version>
        </dependency>
    </dependencies>

    <!-- Package as an executable jar -->
    <build>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
            </plugin>
        </plugins>
    </build>
</project>

We used the following dependencies:

  • spring-boot-starter-web- Spring Boot web container - used to create the REST API,
  • spring-boot-starter-data-jpa - Spring Data for JPA - used in persistence layer,
  • postgresql - PostgreSQL driver - used for connection with the PostgreSQL database,
  • liquibase - Liquibase to track, version, and deploy database changes.
3.2. Backend project structure

Let's take a look at the backend project file structure:

├── main
│   ├── java
│   │   └── com
│   │       └── frontbackend
│   │           └── springboot
│   │               ├── person
│   │               │   ├── config
│   │               │   │   └── CorsFilter.java
│   │               │   ├── controller
│   │               │   │   ├── model
│   │               │   │   │   ├── paging
│   │               │   │   │   │   ├── PageInfo.java
│   │               │   │   │   │   ├── Sorting.java
│   │               │   │   │   │   └── SortOrder.java
│   │               │   │   │   └── request
│   │               │   │   │       ├── CreatePersonRequest.java
│   │               │   │   │       ├── EditPersonRequest.java
│   │               │   │   │       └── PersonFilterRequest.java
│   │               │   │   └── PersonController.java
│   │               │   ├── dto
│   │               │   │   ├── PageDto.java
│   │               │   │   ├── PersonDto.java
│   │               │   │   └── PersonFilter.java
│   │               │   ├── exception
│   │               │   │   └── PersonNotFoundException.java
│   │               │   ├── jpa
│   │               │   │   ├── model
│   │               │   │   │   └── PersonEntity.java
│   │               │   │   ├── repository
│   │               │   │   │   └── PersonRepository.java
│   │               │   │   └── utils
│   │               │   │       ├── JpaSpecifications.java
│   │               │   │       └── PageInfoMapper.java
│   │               │   └── service
│   │               │       └── PersonService.java
│   │               └── SpringBootAngularNgZorroTable.java
│   └── resources
│       ├── application.properties
│       └── db-scripts
│           ├── db-changelog-master.xml
│           └── PERSON-1.0.0.xml

In that structure we could find the following classes:

  • SpringBootAngularNgZorroTable - the main Spring Boot class that starts the web container,
  • PersonController - REST controller class that will handle all HTTP requests,
  • PersonEntity - entity class that represents a table in the database,
  • PersonRepository - for communication with PostgreSQL database,
  • PersonService - service that calls repository methods,
  • JpaSpecifications - helper class used for filtering records,
  • CorsFilter - filter to allow REST API calls from different domains and port,
  • application.properties - Spring Boot configuration file.
3.3. Entity Model

The PersonEntity class is a java representation of tbl_person table in the PostgreSQL database:

package com.frontbackend.springboot.person.jpa.model;

import java.util.UUID;
import javax.persistence.Entity;
import javax.persistence.Id;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import org.hibernate.annotations.Type;

@Entity(name = "tbl_person")
@NoArgsConstructor
@AllArgsConstructor
@Setter
@Getter
public class PersonEntity {

    @Id
    @Type(type = "pg-uuid")
    private UUID id;

    private String firstName;
    private String lastName;
    private int age;
    private String address;
}

The structure of the PersonEntity class is simple. It contains three fields:

  • id - the PRIMARY KEY (note that to handle UUID fields in JPA we need to add @Type(type="pg-uuid") annotation,
  • firstName - person first name ,
  • lastName - person last name,
  • age - person age,
  • address - person address.
3.4. Persistance layer

The PersonRepository is an interface that extends Spring Data JpaRepository and JpaSpecificationExecutor:

package com.frontbackend.springboot.person.jpa.repository;

import com.frontbackend.springboot.person.jpa.model.PersonEntity;
import java.util.UUID;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;

public interface PersonRepository extends JpaRepository<PersonEntity, UUID>, JpaSpecificationExecutor<PersonEntity> {
}

This interface is used for CRUD operations on Person object like save, update, delete, find etc.

3.5. JpaSpecifications class

This helper class will be used to filter records using JpaSpecification interface.

package com.frontbackend.springboot.person.jpa.utils;

import java.util.List;
import lombok.experimental.UtilityClass;
import org.springframework.data.jpa.domain.Specification;
import org.springframework.util.StringUtils;

@UtilityClass
public class JpaSpecifications {

    public <T> Specification<T> upperLike(String value, String columnName) {
        return !StringUtils.hasText(value) ?
                null :
                ((root, query, cb) -> cb.like(cb.upper(root.get(columnName)), "%" + value.toUpperCase() + "%"));
    }

    public <T> Specification<T> checkList(List<?> values, String columnName) {
        return (values == null || values.isEmpty()) ?
                null :
                ((root, query, cb) -> root.get(columnName)
                        .in(values));
    }
}
3.6. PageInfo Mapper

The PageInfoMapper class is a utility class used for mapping pagination requests that come from API into Spring PageRequest object known by Spring Data.

package com.frontbackend.springboot.person.jpa.utils;

import com.frontbackend.springboot.person.controller.model.paging.PageInfo;
import com.frontbackend.springboot.person.controller.model.paging.Sorting;
import java.util.Collections;
import java.util.List;
import java.util.stream.Collectors;
import lombok.experimental.UtilityClass;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Sort;
import org.springframework.util.CollectionUtils;

@UtilityClass
public class PageInfoMapper {

    public PageRequest toPageRequest(PageInfo pageInfo) {
        return pageInfo.getSort() != null ? PageRequest.of(pageInfo.getPageNumber() - 1,
                                                           pageInfo.getPageSize(),
                                                           Sort.by(mapToOrders(pageInfo.getSort()))) :
                PageRequest.of(pageInfo.getPageNumber() - 1, pageInfo.getPageSize());
    }

    private List<Sort.Order> mapToOrders(List<Sorting> sortingList) {
        return CollectionUtils.isEmpty(sortingList) ? Collections.emptyList() :
                sortingList.stream()
                        .map(PageInfoMapper::toOrder)
                        .collect(Collectors.toList());
    }

    private Sort.Order toOrder(Sorting sorting) {
        return new Sort.Order(Sort.Direction.fromString(sorting.getOrder()
                                                                .name()), sorting.getColumn());
    }
}
3.7. REST API

Spring Boot application will provide the following REST API:

URL Method Action
/api/persons/search POST Get page of filtered records
/api/persons/:id GET Find person by id
/api/persons/:id PUT Updating person data
/api/persons POST Create new person
/api/persons/:id DELETE Remove selected person

Above endpoints will be defined in PersonController class annotated as Spring @RestController:

package com.frontbackend.springboot.person.controller;

import com.frontbackend.springboot.person.controller.model.request.CreatePersonRequest;
import com.frontbackend.springboot.person.controller.model.request.PersonFilterRequest;
import com.frontbackend.springboot.person.dto.PageDto;
import com.frontbackend.springboot.person.dto.PersonDto;
import com.frontbackend.springboot.person.service.PersonService;
import java.util.UUID;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@RequiredArgsConstructor
@RestController
@RequestMapping("/api/persons")
public class PersonController {

    private final PersonService personService;

    @PostMapping
    public UUID create(@RequestBody CreatePersonRequest createPersonRequest) {
        return personService.save(PersonDto.builder()
                                          .firstName(createPersonRequest.getFirstName())
                                          .lastName(createPersonRequest.getLastName())
                                          .age(createPersonRequest.getAge())
                                          .address(createPersonRequest.getAddress())
                                          .build());
    }

    @GetMapping("/{id}")
    public PersonDto find(@PathVariable("id") UUID id) {
        return personService.find(id);
    }

    @PutMapping("/{id}")
    public UUID save(@PathVariable UUID id, @RequestBody CreatePersonRequest createPersonRequest) {
        return personService.save(PersonDto.builder()
                                          .id(id)
                                          .firstName(createPersonRequest.getFirstName())
                                          .lastName(createPersonRequest.getLastName())
                                          .age(createPersonRequest.getAge())
                                          .address(createPersonRequest.getAddress())
                                          .build());
    }

    @DeleteMapping("/{id}")
    public void delete(@PathVariable UUID id) {
        personService.deletePerson(id);
    }

    @PostMapping("/search")
    public PageDto<PersonDto> list(@RequestBody PersonFilterRequest request) {
        return personService.filter(request.getFilter(), request.getPageInfo());
    }
}

Controller endpoint methods used several POJO objects:

The request used for creating a new Person:

package com.frontbackend.springboot.person.controller.model.request;

import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;

@NoArgsConstructor
@Setter
@Getter
public class CreatePersonRequest {
    private String firstName;
    private String lastName;
    private int age;
    private String address;
}

Request object used for Person data modifications:

package com.frontbackend.springboot.person.controller.model.request;

import java.util.UUID;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;

@NoArgsConstructor
@Setter
@Getter
public class EditPersonRequest {
    private UUID id;
    private String firstName;
    private String lastName;
    private int age;
    private String address;
}

The PersonFilterRequest object contains:

  • PageInfo - used for pagination and sorting results,
  • PersonFilter - contains fields used for filtering records.
package com.frontbackend.springboot.person.controller.model.request;

import com.frontbackend.springboot.person.controller.model.paging.PageInfo;
import com.frontbackend.springboot.person.dto.PersonFilter;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;

@NoArgsConstructor
@Setter
@Getter
public class PersonFilterRequest {
    private PageInfo pageInfo;
    private PersonFilter filter;
}

PageInfo holds pagination and sorting information:

package com.frontbackend.springboot.person.controller.model.paging;

import java.util.List;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;

@Getter
@Setter
@NoArgsConstructor
public class PageInfo {

    private int pageNumber;
    private int pageSize;
    private List<Sorting> sort;

}

Sorting class contains column and order fields:

package com.frontbackend.springboot.person.controller.model.paging;

import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;

@Setter
@Getter
@NoArgsConstructor
public class Sorting {

    private String column;
    private SortOrder order;

}

SortOrder is a simple enum object:

package com.frontbackend.springboot.person.controller.model.paging;

public enum SortOrder {
    DESC,
    ASC
}

PersonFilter contains fields used for filtering rows:

package com.frontbackend.springboot.person.dto;

import java.util.List;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;

@NoArgsConstructor
@Getter
@Setter
public class PersonFilter {
    private String firstName;
    private String lastName;
    private List<Integer> age;
    private String address;
}

PageDto wraps return records and contain additional totalPages and totalElements fields used by the table component on the front-end site:

package com.frontbackend.springboot.person.dto;

import java.util.List;
import lombok.Builder;
import lombok.Getter;

@Builder
@Getter
public class PageDto<T> {
    private final int totalPages;
    private final long totalElements;
    private final List<T> rows;
}

The PersonDto is Data Transfer Object returned by REST API as JSON:

package com.frontbackend.springboot.person.dto;

import java.util.UUID;
import lombok.Builder;
import lombok.Getter;

@Builder
@Getter
public class PersonDto {
    private UUID id;
    private String firstName;
    private String lastName;
    private int age;
    private String address;
}
3.8. Service

PersonService was introduced to eliminate complex methods in RestController class. We can keep important logic here:

package com.frontbackend.springboot.person.service;

import static org.springframework.data.jpa.domain.Specification.where;

import com.frontbackend.springboot.person.controller.model.paging.PageInfo;
import com.frontbackend.springboot.person.dto.PageDto;
import com.frontbackend.springboot.person.dto.PersonDto;
import com.frontbackend.springboot.person.dto.PersonFilter;
import com.frontbackend.springboot.person.exception.PersonNotFoundException;
import com.frontbackend.springboot.person.jpa.model.PersonEntity;
import com.frontbackend.springboot.person.jpa.repository.PersonRepository;
import com.frontbackend.springboot.person.jpa.utils.JpaSpecifications;
import com.frontbackend.springboot.person.jpa.utils.PageInfoMapper;
import java.util.UUID;
import java.util.stream.Collectors;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.jpa.domain.Specification;
import org.springframework.stereotype.Service;

@RequiredArgsConstructor
@Service
public class PersonService {

    private final PersonRepository personRepository;

    public UUID save(PersonDto personDto) {
        return personDto.getId() != null ? saveModified(personDto) : saveNew(personDto);
    }

    public PersonDto find(UUID id) {
        return personRepository.findById(id)
                .map(this::toPersonDto)
                .orElseThrow(PersonNotFoundException::new);
    }

    private UUID saveModified(PersonDto personDto) {
        PersonEntity entity = personRepository.findById(personDto.getId())
                .map(ent -> this.fillPersonEntity(personDto, ent))
                .orElseThrow(PersonNotFoundException::new);

        personRepository.save(entity);
        return personDto.getId();
    }

    private UUID saveNew(PersonDto personDto) {
        PersonEntity entity = fillPersonEntity(personDto, new PersonEntity());
        entity.setId(UUID.randomUUID());
        PersonEntity saved = personRepository.save(entity);
        return saved.getId();
    }

    private PersonEntity fillPersonEntity(PersonDto personDto, PersonEntity entity) {
        entity.setFirstName(personDto.getFirstName());
        entity.setLastName(personDto.getLastName());
        entity.setAddress(personDto.getAddress());
        entity.setAge(personDto.getAge());
        return entity;
    }

    public void deletePerson(UUID id) {
        PersonEntity byId = personRepository.findById(id)
                .orElseThrow(PersonNotFoundException::new);
        personRepository.delete(byId);
    }

    public PageDto<PersonDto> filter(PersonFilter filter, PageInfo pageInfo) {
        PageRequest pageRequest = PageInfoMapper.toPageRequest(pageInfo);
        Page<PersonEntity> personEntityPage = personRepository.findAll(applyWhere(filter), pageRequest);

        return PageDto.<PersonDto>builder()
                .rows(personEntityPage.getContent()
                              .stream()
                              .map(this::toPersonDto)
                              .collect(Collectors.toList()))
                .totalElements(personEntityPage.getTotalElements())
                .totalPages(personEntityPage.getTotalPages())
                .build();
    }

    private Specification<PersonEntity> applyWhere(PersonFilter filter) {
        Specification<PersonEntity> firstName = JpaSpecifications.upperLike(filter.getFirstName(), "firstName");
        Specification<PersonEntity> lastName = JpaSpecifications.upperLike(filter.getLastName(), "lastName");
        Specification<PersonEntity> address = JpaSpecifications.upperLike(filter.getAddress(), "address");
        Specification<PersonEntity> age = JpaSpecifications.checkList(filter.getAge(), "age");

        return where(firstName).and(lastName)
                .and(address)
                .and(age);
    }

    private PersonDto toPersonDto(PersonEntity entity) {
        return PersonDto.builder()
                .id(entity.getId())
                .firstName(entity.getFirstName())
                .lastName(entity.getLastName())
                .age(entity.getAge())
                .address(entity.getAddress())
                .build();
    }
}
3.9. Spring Boot configuration file

In the Spring Boot configuration file we have properties responsible for the data source, JPA and Liquibase:

spring.datasource.url=jdbc:postgresql://localhost:5432/db
spring.datasource.username=username
spring.datasource.password=password

spring.liquibase.change-log=classpath:/db-scripts/db-changelog-master.xml
3.10. Liquibase

Liquibase is used for managing database changes. To start using it in our Spring Boot project all we need to do is to add a special dependency in our pom.xml file and create an entry in application.properties file that points to change-log:

spring.liquibase.change-log=classpath:/db-scripts/db-changelog-master.xml

The db-scripts/db-changelog-master.xml has the following content:

<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
                   xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
                   xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-3.4.xsd">

    <include file="db-scripts/PERSON-1.0.0.xml"/>

</databaseChangeLog>

As you can see it points to single file db-scripts/PERSON-1.0.0.xml that contains command to create a tbl_person table structure and fills it with data:

<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
                   xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
                   xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-3.4.xsd">

    <changeSet author="frontbackend" id="1.0.0-1">
        <createTable tableName="tbl_person">
            <column name="id" type="uuid">
                <constraints nullable="false"
                             primaryKey="true"
                             unique="true"/>
            </column>
            <column name="first_name" type="VARCHAR2(30)"/>
            <column name="last_name" type="VARCHAR2(30)"/>
            <column name="age" type="VARCHAR2(500)"/>
            <column name="address" type="VARCHAR2(200)"/>
        </createTable>
    </changeSet>

    <changeSet author="frontbackend" id="1.0.0-2">
        <sql>
            insert into tbl_person (id, first_name, last_name, age, address) values ('597f4585-aa32-4606-bcc5-ae070616de3b', 'Worden', 'Handes', 29, '4566 Di Loreto Park');
            insert into tbl_person (id, first_name, last_name, age, address) values ('fb3013fb-5343-4832-ac95-1ee6f0fa264d', 'Eddie', 'Kivits', 48, '97525 Oakridge Trail');
            ...
            insert into tbl_person (id, first_name, last_name, age, address) values ('25e55652-5a29-4a7a-94ba-38d960d45d9f', 'Lonnie', 'Gianni', 45, '578 Knutson Center');
        </sql>
    </changeSet>

</databaseChangeLog>

During the first Spring Boot run you should see in your console logs, similar to these below:

2022-05-21 22:17:52.988  INFO 101988 --- [           main] liquibase.database                       : Set default schema name to public
2022-05-21 22:17:53.048  INFO 101988 --- [           main] liquibase.lockservice                    : Successfully acquired change log lock
2022-05-21 22:17:53.179  INFO 101988 --- [           main] liquibase.changelog                      : Creating database history table with name: public.databasechangelog
2022-05-21 22:17:53.184  INFO 101988 --- [           main] liquibase.changelog                      : Reading from public.databasechangelog
Running Changeset: db-scripts/PERSON-1.0.0.xml::1.0.0-1::frontbackend
2022-05-21 22:17:53.222  INFO 101988 --- [           main] liquibase.changelog                      : Table tbl_person created
2022-05-21 22:17:53.223  INFO 101988 --- [           main] liquibase.changelog                      : ChangeSet db-scripts/PERSON-1.0.0.xml::1.0.0-1::frontbackend ran successfully in 6ms
Running Changeset: db-scripts/PERSON-1.0.0.xml::1.0.0-2::frontbackend
2022-05-21 22:17:53.267  INFO 101988 --- [           main] liquibase.changelog                      : Custom SQL executed
2022-05-21 22:17:53.268  INFO 101988 --- [           main] liquibase.changelog                      : ChangeSet db-scripts/PERSON-1.0.0.xml::1.0.0-2::frontbackend ran successfully in 42ms
2022-05-21 22:17:53.270  INFO 101988 --- [           main] liquibase.lockservice                    : Successfully released change log lock

This will be the approval that everything was well configured.

4. Angular application

To create an Angular application we used the Angular CLI, which is a command-line tool that can be used to initialize, develop, update, and maintain Angular applications directly from a system terminal.

In case you don't have Angular CLI on your machine, use the following command to install it globally:

npm install -g @angular/cli

Next, let's create an Angular project called table:

ng new table

Angular CLI will print the following output:

 Would you like to add Angular routing? Yes
? Which stylesheet format would you like to use? SCSS   [ https://sass-lang.com/documentation/syntax#scss           
     ]
CREATE table/README.md (1051 bytes)
CREATE table/.editorconfig (274 bytes)
CREATE table/.gitignore (548 bytes)
CREATE table/angular.json (3201 bytes)
CREATE table/package.json (1068 bytes)
CREATE table/tsconfig.json (863 bytes)
CREATE table/.browserslistrc (600 bytes)
CREATE table/karma.conf.js (1422 bytes)
CREATE table/tsconfig.app.json (287 bytes)
CREATE table/tsconfig.spec.json (333 bytes)
CREATE table/.vscode/extensions.json (130 bytes)
CREATE table/.vscode/launch.json (474 bytes)
CREATE table/.vscode/tasks.json (938 bytes)
CREATE table/src/favicon.ico (948 bytes)
CREATE table/src/index.html (291 bytes)
CREATE table/src/main.ts (372 bytes)
CREATE table/src/polyfills.ts (2338 bytes)
CREATE table/src/styles.scss (80 bytes)
CREATE table/src/test.ts (745 bytes)
CREATE table/src/assets/.gitkeep (0 bytes)
CREATE table/src/environments/environment.prod.ts (51 bytes)
CREATE table/src/environments/environment.ts (658 bytes)
CREATE table/src/app/app-routing.module.ts (245 bytes)
CREATE table/src/app/app.module.ts (393 bytes)
CREATE table/src/app/app.component.scss (0 bytes)
CREATE table/src/app/app.component.html (23364 bytes)
CREATE table/src/app/app.component.spec.ts (1070 bytes)
CREATE table/src/app/app.component.ts (210 bytes)
✔ Packages installed successfully.

In the next step we added ng-zorro-antd dependency to our Angular project:

ng add ng-zorro-antd

The output of the above command will be as follows:

ℹ Using package manager: npm
✔ Found compatible package version: ng-zorro-antd@13.2.2.
✔ Package information loaded.

The package ng-zorro-antd@13.2.2 will be installed and executed.
Would you like to proceed? Yes
✔ Package successfully installed.
? Enable icon dynamic loading [ Detail: https://ng.ant.design/components/icon/en ] No
? Set up custom theme file [ Detail: https://ng.ant.design/docs/customize-theme/en ] No
? Choose your locale code: en_US
? Choose template to create project: sidemenu
CREATE src/app/icons-provider.module.ts (482 bytes)
CREATE src/app/pages/welcome/welcome-routing.module.ts (352 bytes)
CREATE src/app/pages/welcome/welcome.component.scss (0 bytes)
CREATE src/app/pages/welcome/welcome.component.html (0 bytes)
CREATE src/app/pages/welcome/welcome.component.ts (274 bytes)
CREATE src/app/pages/welcome/welcome.module.ts (314 bytes)
UPDATE package.json (1099 bytes)
UPDATE src/app/app.module.ts (1127 bytes)
UPDATE angular.json (3337 bytes)
UPDATE src/app/app-routing.module.ts (416 bytes)
UPDATE src/app/app.component.scss (1245 bytes)
UPDATE src/app/app.component.html (1592 bytes)
UPDATE src/app/app.component.ts (214 bytes)
✔ Packages installed successfully.

Now, we can run the Angular application to see if everything works as expected:

Type this command in your terminal to start the Angular app:

ng serve

Go to the browser and enter: http://localhost:4200 into the address bar. You should see the following screen:

Ng zorro welcome screen

Of course, this is not what our final application will look like :)

4.1. Table module and component

First, we need to create a new module and base table component:

ng g module pages/table
ng g component pages/table

The remaining objects will be created manually.

The final Angular project has the following structure:

.
├── app
│   ├── app.component.html
│   ├── app.component.scss
│   ├── app.component.spec.ts
│   ├── app.component.ts
│   ├── app.module.ts
│   ├── app-routing.module.ts
│   ├── components
│   │   └── person-form-modal
│   │       ├── person-form-modal.component.html
│   │       ├── person-form-modal.component.scss
│   │       └── person-form-modal.component.ts
│   ├── icons-provider.module.ts
│   ├── model
│   │   ├── page.info.ts
│   │   ├── page.response.ts
│   │   ├── person.filter.ts
│   │   ├── person.page.request.ts
│   │   ├── person.ts
│   │   ├── sorting.ts
│   │   └── sort.order.ts
│   ├── pages
│   │   ├── table
│   │   │   ├── table.component.html
│   │   │   ├── table.component.scss
│   │   │   ├── table.component.spec.ts
│   │   │   ├── table.component.ts
│   │   │   ├── table.module.ts
│   │   │   └── table-routing.module.ts
│   │   └── welcome
│   │       ├── welcome.component.html
│   │       ├── welcome.component.scss
│   │       ├── welcome.component.ts
│   │       ├── welcome.module.ts
│   │       └── welcome-routing.module.ts
│   └── service
│       └── person.http.service.ts
├── assets
├── environments
│   ├── environment.prod.ts
│   └── environment.ts
├── favicon.ico
├── index.html
├── main.ts
├── polyfills.ts
├── styles.scss
└── test.ts
4.2. Table module and components

Table module will be used to aggregate all components related to table.

import { Component, OnInit } from '@angular/core';
import { Person } from '../../model/person';
import { NzTableQueryParams } from 'ng-zorro-antd/table';
import { PersonPageRequest } from '../../model/person.page.request';
import { PersonFilter } from '../../model/person.filter';
import { PageInfo } from '../../model/page.info';
import { SortOrder } from '../../model/sort.order';
import { PersonHttpService } from '../../service/person.http.service';
import { NzModalService } from 'ng-zorro-antd/modal';
import { PersonFormModalComponent } from '../../components/person-form-modal/person-form-modal.component';

@Component({
  selector: 'app-table',
  templateUrl: './table.component.html',
  styleUrls: ['./table.component.scss']
})
export class TableComponent implements OnInit {

  persons: Person[] = [];

  loading = true;
  pageSize = 10;
  pageIndex = 1;
  total = 1;

  visibleFn = false;
  visibleLn = false;

  pageRequest: PersonPageRequest = {
    pageInfo: {
      pageSize: this.pageSize,
      pageNumber: this.pageIndex,
      sort: null
    } as PageInfo,
    filter: {
      firstName: '',
      lastName: ''
    } as PersonFilter
  };

  constructor(private personService: PersonHttpService,
              private modal: NzModalService) {
  }

  ngOnInit(): void {
  }

  onQueryParamsChange(params: NzTableQueryParams): void {
    const {pageSize, pageIndex, sort, filter} = params;
    const currentSort = sort.find(item => item.value !== null);
    const sortField = (currentSort && currentSort.key) || null;
    const sortOrder = (currentSort && currentSort.value) || null;

    this.pageRequest.pageInfo = {
      pageSize: pageSize,
      pageNumber: pageIndex,
      sort: (sortField && sortOrder) ? [
        {
          column: sortField,
          order: sortOrder === 'ascend' ? SortOrder.ASC : SortOrder.DESC
        }
      ] : null
    };

    this.search();
  }

  resetFirstName(): void {
    this.pageRequest.filter.firstName = '';
    this.search();
  }

  resetLastName(): void {
    this.pageRequest.filter.lastName = '';
    this.search();
  }

  search(): void {
    this.visibleFn = false;
    this.visibleLn = false;

    this.loading = true;

    this.personService.personPage(this.pageRequest).subscribe(result => {
      this.persons = result.rows;
      this.total = result.totalElements;
      this.loading = false;
    }, () => this.loading = false);
  }

  addPerson(): void {
    this.modal.create({
      nzContent: PersonFormModalComponent,
      nzWidth: 900,
      nzCentered: true,
      nzComponentParams: {
        person: {} as Person
      } as Partial<PersonFormModalComponent>
    }).afterClose.subscribe(data => {
      if (!!data) {
        this.search();
      }
    });
  }

  removePerson(id: string): void {
    this.personService.delete(id).subscribe(result => {
      this.search();
    })
  }

  editPerson(id: string): void {
    this.personService.find(id).subscribe(result => {
      this.modal.create({
        nzContent: PersonFormModalComponent,
        nzWidth: 900,
        nzCentered: true,
        nzComponentParams: {
          person: result
        } as Partial<PersonFormModalComponent>
      }).afterClose.subscribe(data => {
        if (!!data) {
          this.search();
        }
      });
    });
  }
}

Table component HTML file:

<button nz-button (click)="addPerson()" nzType="primary">Add</button>
<br/>
<br/>

<nz-table
  nzShowSizeChanger
  [nzData]="persons"
  [nzFrontPagination]="false"
  [nzLoading]="loading"
  [nzTotal]="total"
  [nzPageSize]="pageSize"
  [nzPageIndex]="pageIndex"
  (nzQueryParams)="onQueryParamsChange($event)"
>
  <thead>
  <tr>
    <th nzColumnKey="firstName" [nzSortFn]="true" nzCustomFilter>
      First name
      <nz-filter-trigger [(nzVisible)]="visibleFn" [nzActive]="pageRequest.filter.firstName.length > 0" [nzDropdownMenu]="menuFn">
        <i nz-icon nzType="search"></i>
      </nz-filter-trigger>

      <nz-dropdown-menu #menuFn="nzDropdownMenu">
        <div class="ant-table-filter-dropdown">
          <div class="search-box">
            <input type="text" nz-input placeholder="Search person" [(ngModel)]="pageRequest.filter.firstName"/>
            <button nz-button nzSize="small" nzType="primary" (click)="search()" class="search-button">Search</button>
            <button nz-button nzSize="small" (click)="resetFirstName()">Reset</button>
          </div>
        </div>
      </nz-dropdown-menu>
    </th>
    <th nzColumnKey="lastName" [nzSortFn]="true" nzCustomFilter>
      Last name
      <nz-filter-trigger [(nzVisible)]="visibleLn" [nzActive]="pageRequest.filter.lastName.length > 0" [nzDropdownMenu]="menuLn">
        <i nz-icon nzType="search"></i>
      </nz-filter-trigger>

      <nz-dropdown-menu #menuLn="nzDropdownMenu">
        <div class="ant-table-filter-dropdown">
          <div class="search-box">
            <input type="text" nz-input placeholder="Search person" [(ngModel)]="pageRequest.filter.lastName"/>
            <button nz-button nzSize="small" nzType="primary" (click)="search()" class="search-button">Search</button>
            <button nz-button nzSize="small" (click)="resetLastName()">Reset</button>
          </div>
        </div>
      </nz-dropdown-menu>
    </th>
    <th nzColumnKey="age" [nzSortFn]="true">Age</th>
    <th nzColumnKey="address" [nzSortFn]="true">Address</th>
    <th>Action</th>
  </tr>
  </thead>
  <tbody>
  <tr *ngFor="let data of persons">
    <td>{{ data.firstName }}</td>
    <td>{{ data.lastName }}</td>
    <td>{{ data.age }}</td>
    <td>{{ data.address }}</td>
    <td>
      <nz-space>
        <button *nzSpaceItem nz-button nzType="primary" (click)="editPerson(data.id)"><i nz-icon nzType="edit"></i></button>
        <button *nzSpaceItem nz-button nzDanger nzType="primary" nz-popconfirm nzPopconfirmTitle="Sure to delete?" (nzOnConfirm)="removePerson(data.id)">
          <i nz-icon nzType="delete"></i>
        </button>
      </nz-space>
    </td>
  </tr>
  </tbody>
</nz-table>

Table component module, where we import several NgZorro modules:

  • NzTableModule - module for NgZorro Table component,
  • NzFormModule - the NgZorro form module,
  • NzButtonModule - the NgZorro buttons module,
  • NzInputModule - NgZorro input module,
  • NzIconModule - module for icons,
  • NzDropDownModule - module for drop-down components,
  • NzPopconfirmModule - component for inline confirmation popups,
  • NzModalModule - modal components,
  • NzSpaceModule - spaces between components.
import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { TableComponent } from './table.component';
import { TableRoutingModule } from './table-routing.module';
import { NzTableModule } from 'ng-zorro-antd/table';
import { NzIconModule } from 'ng-zorro-antd/icon';
import { NzDropDownModule } from 'ng-zorro-antd/dropdown';
import { NzInputModule } from 'ng-zorro-antd/input';
import { NzButtonModule } from 'ng-zorro-antd/button';
import { FormsModule, ReactiveFormsModule } from '@angular/forms';
import { NzPopconfirmModule } from 'ng-zorro-antd/popconfirm';
import { NzSpaceModule } from 'ng-zorro-antd/space';
import { NzModalModule } from 'ng-zorro-antd/modal';
import { PersonFormModalComponent } from '../../components/person-form-modal/person-form-modal.component';
import { NzFormModule } from 'ng-zorro-antd/form';


@NgModule({
  declarations: [
    TableComponent,
    PersonFormModalComponent
  ],
  imports: [
    CommonModule,
    TableRoutingModule,
    FormsModule,
    ReactiveFormsModule,
    NzTableModule,
    NzIconModule,
    NzDropDownModule,
    NzInputModule,
    NzButtonModule,
    NzPopconfirmModule,
    NzSpaceModule,
    NzModalModule,
    NzFormModule
  ]
})
export class TableModule {
}

The table routing module is simple and contains a mapping for the root table path:

import { RouterModule, Routes } from '@angular/router';
import { NgModule } from '@angular/core';
import { TableComponent } from './table.component';


const routes: Routes = [
  {path: '', component: TableComponent},
];

@NgModule({
  imports: [RouterModule.forChild(routes)],
  exports: [RouterModule]
})
export class TableRoutingModule {
}
4.3. Person modal form component

To create a new person and modify the existing one we use the form presented in the NgZorro modal:

import { Component, Input, OnInit } from '@angular/core';
import { NzModalRef } from 'ng-zorro-antd/modal';
import { Person } from '../../model/person';
import { FormBuilder, FormGroup, Validators } from '@angular/forms';
import { PersonHttpService } from '../../service/person.http.service';


@Component({
  selector: 'app-person-form-modal',
  templateUrl: './person-form-modal.component.html',
  styleUrls: ['./person-form-modal.component.scss']
})
export class PersonFormModalComponent implements OnInit {

  @Input()
  person!: Person;

  personForm!: FormGroup;

  constructor(private modalRef: NzModalRef,
              private personService: PersonHttpService,
              private fb: FormBuilder) {
  }

  ngOnInit(): void {
    this.personForm = this.fb.group({
      id: [null],
      firstName: [null, [Validators.required]],
      lastName: [null, [Validators.required]],
      age: [null, [Validators.required]],
      address: [null, [Validators.required]]
    });

    if (this.person && this.person.id) {
      this.personForm.setValue(this.person);
    }
  }

  closeModal(): void {
    this.modalRef.destroy();
  }

  submitForm(): void {
    for(const i in this.personForm.controls) {
      if (this.personForm.controls.hasOwnProperty(i)) {
        this.personForm.controls[i].markAsDirty();
        this.personForm.controls[i].updateValueAndValidity();
      }
    }

    if (this.personForm.valid) {
      if (this.person.id) {
        this.personService.save(this.personForm.value).subscribe(result => {
          this.modalRef.destroy(result);
        });
      } else {
        this.personService.create(this.personForm.value).subscribe(result => {
          this.modalRef.destroy(result);
        });
      }
    }
  }
}

The HTML file with form component:

<div *nzModalTitle>
  Person
</div>
<div>
  <form id="personForm" nz-form [formGroup]="personForm" (ngSubmit)="submitForm()">
    <nz-form-item>
      <nz-form-label [nzSm]="6" nzFor="firstNameField" nzRequired>First name</nz-form-label>
      <nz-form-control [nzSm]="16">
        <input id="firstNameField" nz-input formControlName="firstName" autocomplete="off"/>
      </nz-form-control>
    </nz-form-item>
    <nz-form-item>
      <nz-form-label [nzSm]="6" nzFor="lastNameField" nzRequired>Last name</nz-form-label>
      <nz-form-control [nzSm]="16">
        <input id="lastNameField" nz-input formControlName="lastName" autocomplete="off"/>
      </nz-form-control>
    </nz-form-item>
    <nz-form-item>
      <nz-form-label [nzSm]="6" nzFor="ageField" nzRequired>Age</nz-form-label>
      <nz-form-control [nzSm]="16">
        <input id="ageField" nz-input formControlName="age" autocomplete="off"/>
      </nz-form-control>
    </nz-form-item>
    <nz-form-item>
      <nz-form-label [nzSm]="6" nzFor="addressField" nzRequired>Address</nz-form-label>
      <nz-form-control [nzSm]="16">
        <input id="addressField" nz-input formControlName="address" autocomplete="off"/>
      </nz-form-control>
    </nz-form-item>
  </form>
</div>
<div *nzModalFooter>
  <button nz-button nzType="default" (click)="closeModal()">Close</button>
  <button type="submit" form="personForm" nz-button nzType="primary" (click)="submitForm()">Save</button>
</div>
4.4. HTTP client

The PersonHttpService using the HTTPClient library for communication with backend REST API:

import { Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { PersonPageRequest } from '../model/person.page.request';
import { Observable } from 'rxjs';
import { environment } from '../../environments/environment';
import { Person } from '../model/person';
import { PageResponse } from '../model/page.response';

const BASE_URL = environment.baseUrl;

@Injectable({
  providedIn: 'root'
})
export class PersonHttpService {

  constructor(private http: HttpClient) {
  }

  personPage(request: PersonPageRequest): Observable<PageResponse<Person>> {
    return this.http.post<PageResponse<Person>>(`${BASE_URL}/api/persons/search`, request);
  }

  find(id: string): Observable<Person> {
    return this.http.get<Person>(`${BASE_URL}/api/persons/${id}`);
  }

  delete(id: string): Observable<any> {
    return this.http.delete<Person>(`${BASE_URL}/api/persons/${id}`);
  }

  save(request: Person): Observable<any> {
    return this.http.put<Person>(`${BASE_URL}/api/persons/${request.id}`, request);
  }

  create(request: Person): Observable<any> {
    return this.http.post<Person>(`${BASE_URL}/api/persons`, request);
  }
}

Helper typescript objects that have representation in Java project:

Page info interface:

import { Sorting } from './sorting';

export interface PageInfo {
  pageNumber: number;
  pageSize: number;
  sort: Sorting[] | null;
}

Sort order enum:

export enum SortOrder {
  ASC = 'ASC',
  DESC = 'DESC'
}

Sorting interface:

import { SortOrder } from './sort.order';

export interface Sorting {
  order: SortOrder;
  column: string;
}

Person interface:

export interface Person {
  id: string;
  firstName: string;
  lastName: string;
  age: number;
  address: string;
}

Person page request with filter and page info object:

import { PageInfo } from './page.info';
import { PersonFilter } from './person.filter';

export interface PersonPageRequest {
  pageInfo: PageInfo;
  filter: PersonFilter;
}

Person filter interface:

export interface PersonFilter {
  firstName: string;
  lastName: string;
}

Person response object with rows and total elements/pages info:

export interface PageResponse<T> {
  rows: T[];
  totalElements: number;
  totalPages: number;
}
4.5. Base application component

The AppComponent will be our main component.

import { Component } from '@angular/core';

@Component({
  selector: 'app-root',
  templateUrl: './app.component.html',
  styleUrls: ['./app.component.scss']
})
export class AppComponent {
  isCollapsed = false;
}

The main component contains sidebar menu with a Welcome and Table items:

<nz-layout class="app-layout">
  <nz-sider class="menu-sidebar"
            nzCollapsible
            nzWidth="256px"
            nzBreakpoint="md"
            [(nzCollapsed)]="isCollapsed"
            [nzTrigger]="null">
    <div class="sidebar-logo">
      <a href="https://ng.ant.design/" target="_blank">
        <img src="https://ng.ant.design/assets/img/logo.svg" alt="logo">
        <h1>Ant Design Of Angular</h1>
      </a>
    </div>
    <ul nz-menu nzTheme="dark" nzMode="inline" [nzInlineCollapsed]="isCollapsed">
      <li nz-submenu nzOpen nzTitle="Dashboard" nzIcon="dashboard">
        <ul>
          <li nz-menu-item nzMatchRouter>
            <a routerLink="/welcome">Welcome</a>
          </li>
          <li nz-menu-item nzMatchRouter>
            <a routerLink="/table">Table</a>
          </li>
        </ul>
      </li>
    </ul>
  </nz-sider>
  <nz-layout>
    <nz-header>
      <div class="app-header">
        <span class="header-trigger" (click)="isCollapsed = !isCollapsed">
            <i class="trigger"
               nz-icon
               [nzType]="isCollapsed ? 'menu-unfold' : 'menu-fold'"
            ></i>
        </span>
      </div>
    </nz-header>
    <nz-content>
      <div class="inner-content">
        <router-outlet></router-outlet>
      </div>
    </nz-content>
  </nz-layout>
</nz-layout>
4.6. Application Module and Routing

The main application module imports all necessary modules from core Angular and NgZorro library:

import { NgModule } from '@angular/core';
import { BrowserModule } from '@angular/platform-browser';

import { AppRoutingModule } from './app-routing.module';
import { AppComponent } from './app.component';
import { en_US, NZ_I18N } from 'ng-zorro-antd/i18n';
import { registerLocaleData } from '@angular/common';
import en from '@angular/common/locales/en';
import { FormsModule } from '@angular/forms';
import { HttpClientModule } from '@angular/common/http';
import { BrowserAnimationsModule } from '@angular/platform-browser/animations';
import { IconsProviderModule } from './icons-provider.module';
import { NzLayoutModule } from 'ng-zorro-antd/layout';
import { NzMenuModule } from 'ng-zorro-antd/menu';
import { NzFormModule } from 'ng-zorro-antd/form';

registerLocaleData(en);

@NgModule({
  declarations: [
    AppComponent
  ],
  imports: [
    BrowserModule,
    AppRoutingModule,
    FormsModule,
    HttpClientModule,
    BrowserAnimationsModule,
    IconsProviderModule,
    NzLayoutModule,
    NzMenuModule
  ],
  providers: [{provide: NZ_I18N, useValue: en_US}],
  bootstrap: [AppComponent]
})
export class AppModule {
}

In the routing module we defined two pages:

  • welcome - redirects to the blank page,
  • table- redirect to page with table component.
import { NgModule } from '@angular/core';
import { Routes, RouterModule } from '@angular/router';

const routes: Routes = [
  { path: '', pathMatch: 'full', redirectTo: '/welcome' },
  { path: 'welcome', loadChildren: () => import('./pages/welcome/welcome.module').then(m => m.WelcomeModule) },
  { path: 'table', loadChildren: () => import('./pages/table/table.module').then(m => m.TableModule) }
];

@NgModule({
  imports: [RouterModule.forRoot(routes)],
  exports: [RouterModule]
})
export class AppRoutingModule { }
4.6. Environment file

In environment file we added baseUrl that points to Spring Boot application server:

export const environment = {
  production: false,
  baseUrl: 'http://localhost:8080'
};

5. Demo

Blank page with side-bar menu:

Ng zorro welcome screen sidebar

/table page with NgZorro table component:

Ng zorro table component view

Creating the new person in modal:

Ng zorro table component add person

Removing entry requires inline confirmation:

Ng zorro table component remove person confirmation

We can filter by columns:

Ng zorro table component filtering records

Menu could be collapsed:

Ng zorro table component with collpsed menu

6. Conclusion

In this article, we presented how to create a Spring Boot application connected with the PostgreSQL database and Angular on the frontend site. The NgZorro Table component was introduced with several features like sorting, filtering, removing (with inline confirmation), editing, and creating.

As usual this example could be found on our GitHub repository: GitHub

{{ message }}

{{ 'Comments are closed.' | trans }}