ふるてつのぶろぐ

福岡在住のエンジニアです。

写真提供:福岡市

Angular7 で Web アプリを作ろう - Material PagenatorとTableでサーバサイドページング

今日すること

こんにちは、ふるてつです。
今回はAngular MaterialPagenatorTableを組合わせてサーバサイドページングをおこなってみました。

Pagenator関連のリファレンス

Materialのリファレンスでサーバーサイドページングについては「Table」の「EXAMPLES」タブ欄に書いてあります。
下記のページです。
https://material.angular.io/components/table/examples
ページ中段くらいに「Table retrieving data through HTTP」と書いてある箇所があります。
リファレンスをくまなく探して見つけました。もうちょっと目立つところに書いてほしいですねぇ…
f:id:tetsufuru:20190622211928p:plain:w500
基本的にはここと同じように書きます。

完成イメージ

私が今回作成した画面は下記のようになります。
上段には検索条件の入力欄があり、検索ボタンをクリックすると、サーバ側で必要な件数のデータのみを取得してくるようにしています。
f:id:tetsufuru:20190622214957p:plain:w500
下記が検索ボタンをクリックした後です。
今回作成する一覧(Table)が表示され、Pagenatorをクリックできるようになりました。
f:id:tetsufuru:20190622215032p:plain:w500

作り方

基本的にリファレンスに従って、コンポーネントのhtml、tsを記述しました。

●html

コンポーネントのhtmlには下記を記述しました。

  1. 検索条件欄(<mat-form-field>
  2. ページネーション(<mat-paginator>
  3. 検索・クリアボタン(<button mat-raised-button>
  4. 検索結果覧
    4-1. ローディング(<mat-spinner>
    4-2. 検索結果のテーブル(<table mat-table

サンプルソース(evaluation-result.component.html)

<!-- 検索条件欄 -->
<mat-card>
  <mat-card-content>
    <app-error-messages></app-error-messages>
    <div id="searchConditionsArea">
      <!-- -------------------- 1 -------------------- -->
      <div id="searchCondition1">
        <app-mat-datepicker-year [locale]='locale' [placeholder]='displayNameYearFrom'
          (event)=" onReceiveEventFromChild($event)">
        </app-mat-datepicker-year>
      </div>
      <div id="searchCondition2">
        <mat-form-field class="form-field">
          <input id="employeeCode" matInput type="text" formControlName="employeeCode"
            placeholder="{{ 'evaluationResultScreen.employeeCode' | translate }}">
        </mat-form-field>
      </div>
      ~ 中略 ~
    </div>
  </mat-card-content>
</mat-card>

<!-- ボタン欄 -->
<div id="searchButtonArea">
  <!-- ページネーション -->
  <div id="paginatorArea">
    <mat-paginator [length]="resultsLength" [pageSize]="50" [pageSizeOptions]="[10, 50, 100]"></mat-paginator>
  </div>
  ~ 中略 ~
  <!-- 検索ボタン -->
  <div id="searchBtnArea">
    <button mat-raised-button color="primary" id="searchBtn" class="btn" type="submit" (click)="onSearch()"
      [disabled]="!mainForm.valid">{{ "evaluationResultScreen.searchBtn" | translate }}
    </button>
  </div>
</div>

<!-- 検索結果覧-->
<div id="evaluationResult">
  <!-- ローディング画面のスピナー -->
  <div class="loading-shade" *ngIf="isLoadingResults || isRateLimitReached">
    <mat-spinner class="loading-spinner" *ngIf="isLoadingResults"></mat-spinner>
  </div>

  <!-- 検索結果のテーブル-->
  <div class="example-container">
    <table mat-table *ngIf="resultsLength>0" [dataSource]="searchEvaluationResultDtos">
      <ng-container matColumnDef="employeeCode">
        <th mat-header-cell *matHeaderCellDef style="width: 10%;">
          {{ "evaluationResultScreen.employeeCode" | translate }}
        </th>
        <td mat-cell *matCellDef="let element"> {{element.employeeCode}} </td>
      </ng-container>
      <ng-container matColumnDef="employeeName">
        <th mat-header-cell *matHeaderCellDef>
          {{ "evaluationResultScreen.searchResult.employeeName" | translate }}
        </th>
        <td mat-cell *matCellDef="let element"> {{element.employeeName}} </td>
      </ng-container>
      ~ 中略 ~
      <ng-container matColumnDef="evaluatePoint12">
        <th mat-header-cell *matHeaderCellDef>
          {{ "evaluationResultScreen.searchResult.evaluatePoint12" | translate }}</th>
        <td mat-cell *matCellDef="let element"> {{element.evaluatePoint12}} </td>
      </ng-container>

      <tr mat-header-row *matHeaderRowDef="displayEvaluationResultColumns; sticky: true"></tr>
      <tr mat-row *matRowDef="let row; columns: displayEvaluationResultColumns;"></tr>
    </table>
  </div>
</div>

●ts

コンポーネントのtsには下記を記述しました。

  1. 各検索条件を格納する変数
  2. サーバから返された検索結果リストを格納する配列
  3. サーバから返された検索結果の件数を格納する変数(resultsLength: number
  4. 検索中であるかどうかを格納する変数(isLoadingResults: boolean
  5. ページネーションの中身を取得するための@ViewChild
  6. 検索ボタンをクリックした時のアクション(#onSearch()
    6-1. 検索中のフラグを立てる(this.isLoadingResults = true
    6-2. 各検索条件とページネーション(pageSizepageIndex)を引数に検索サービスを呼び出す。
    6-3. サーバから検索結果が返ってくる。
    6-4. 検索中のフラグをおろす(this.isLoadingResults = false
    6-5. サーバから返された検索結果の件数を画面の変数にセットする。
    6-6. サーバから返された検索結果リストを画面の変数にセットする。

サンプルソース(evaluation-result.component.ts)

import { Component, OnInit, ViewChild, Inject, LOCALE_ID } from '@angular/core';
import { FormBuilder, FormControl } from '@angular/forms';
import { EvaluationService } from 'src/app/service/evaluation/evaluation.service';
import { SearchEvaluationResultDto } from 'src/app/entity/evaluation/search-evaluation-result-dto';
import { MatPaginator } from '@angular/material';
import { merge, of } from 'rxjs';
import { startWith, switchMap, map, catchError } from 'rxjs/operators';
import { formatDate } from '@angular/common';
import { MatDatepickerYearComponent } from '../../common/date/mat-datepicker-year/mat-datepicker-year.component';
import { HttpParams } from '@angular/common/http';
import { HttpParamsOptions } from '@angular/common/http/src/params';

@Component({
  selector: 'app-evaluation-result',
  templateUrl: './evaluation-result.component.html',
  styleUrls: ['./evaluation-result.component.css'],
  providers: [EvaluationService]
})
export class EvaluationResultComponent implements OnInit {

  // 検索条件を格納する変数
  public yearFrom = new FormControl('', []);
  public employeeCode = new FormControl('', []);
  public employeeRank = new FormControl('', []);
  ~中略~
  public retiree = new FormControl('', []);

  // 検索結果を格納する変数
  public searchEvaluationResultDtos: SearchEvaluationResultDto[];
 
  // 検索結果のヘッダーを定義する変数
  public displayEvaluationResultColumns: string[] = [
    'employeeCode',
    'employeeName',
    ~中略~
    'evaluatePoint12'
  ];

  // 検索結果の件数を格納する変数
  public resultsLength = 0;
  
  // 検索中かどうかを格納する変数
  public isLoadingResults = false;

  // ページネーションの中身を取得するためのViewChild
  @ViewChild(MatPaginator) public paginator: MatPaginator;

  constructor(
    private evaluationService: EvaluationService
  ) { }

  ngOnInit() {
  }

  private onSearch() {

    merge(this.paginator.page)
      .pipe(
        startWith({}),
        switchMap(() => {
          this.isLoadingResults = true;
          return this.evaluationService.getEvaluationResult(this.createHttpParams());
        }),

        map(data => {
          // Flip flag to show that loading has finished.
          this.isLoadingResults = false;
          this.resultsLength = data.resultsLength;
          this.paginator.pageIndex = data.pageIndex;
          return data.searchEvaluationResultDtos;
        }),

        catchError(() => {
          this.isLoadingResults = false;
          return of(null as any);
        })

      ).subscribe(data => this.searchEvaluationResultDtos = data);
  }

  private createHttpParams(): HttpParams {
    const conditions = {

      yearFrom: formatDate(this.yearFrom.value, 'yyyy', this.locale),
      employeeCode: this.employeeCode.value,
      employeeRank: this.employeeRank.value,
      ~中略~
      retiree: this.retiree.value,

      pageSize: this.paginator.pageSize.toString(),
      pageIndex: this.paginator.pageIndex.toString()

    };
    const paramsOptions = { fromObject: conditions };
    const params = new HttpParams(paramsOptions);

    return params;
  }
}

●その他

わたしはサーバからデータを取得するのに別途サービスを追加しました。
ここはリファレンス通りではありません。

サービスのソース(evaluation.service.ts)

import { Observable, of } from 'rxjs';
import { catchError } from 'rxjs/operators';
import { AppConst } from 'src/app/app-const';
import { SearchEvaluationResultListDto } from 'src/app/entity/evaluation/search-evaluation-result-list-dto';
import { ErrorMessageService } from 'src/app/service/message/error-message.service';
import { environment } from 'src/environments/environment';

import { HttpClient, HttpParams, HttpHeaders } from '@angular/common/http';
import { Injectable } from '@angular/core';
import { TranslateService } from '@ngx-translate/core';

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

  private server = environment.production ? AppConst.URL_PROD_SERVER : AppConst.URL_DEV_SERVER;
  private webApiUrl = 'evaluationResult';

  constructor(
    private http: HttpClient,
    private errorMessageService: ErrorMessageService,
    private readonly translateService: TranslateService
  ) { }

  public getEvaluationResult(httpParams: HttpParams): Observable {

    return this.http.get(this.server + this.webApiUrl, { params: httpParams })
      .pipe(
        catchError(err => {
          this.errorMessageService.add(this.translateService.instant('errMessage.http'));
          return of(null as any);
        })
      );
  }
}

その他DTO(search-evaluation-result-list-dto.ts)

import { SearchEvaluationResultDto } from './search-evaluation-result-dto';

export class SearchEvaluationResultListDto {
  pageIndex: number;
  resultsLength: number;
  searchEvaluationResultDtos: SearchEvaluationResultDto[];
}

●サーバサイド

サーバサイドはSpring Bootを使用しています。
なにかの参考になればと思い、コントローラとDTOを書いておきます。
ちなみにサービスは常に固定で40~50件のデータを返すようにしているだけです(完全にモックですので省略しました)

コントローラ(EvaluationRestController.java

import org.springframework.web.bind.annotation.CrossOrigin;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

import com.weekenditlaboratory.entity.dto.evaluation.SearchEvaluationConditionDto;
import com.weekenditlaboratory.entity.dto.evaluation.SearchEvaluationResultListDto;
import com.weekenditlaboratory.entity.dto.pagenator.PagenatorDto;
import com.weekenditlaboratory.mockService.MockEvaluationService;

import lombok.RequiredArgsConstructor;

@RequiredArgsConstructor
@RestController
@RequestMapping("api/evaluationResult")
@CrossOrigin
public class EvaluationRestController {

    private final MockEvaluationService mockEvaluationService;

    @GetMapping
    public SearchEvaluationResultListDto getEvaluationResult(PagenatorDto pagenatorDto,SearchEvaluationConditionDto searchEvaluationConditionDto) {
        return mockEvaluationService.getEvaluationResult(pagenatorDto,searchEvaluationConditionDto);
    }

}

その他DTO PagenatorDto.java

import lombok.AllArgsConstructor;
import lombok.Data;

@Data
@AllArgsConstructor
public class PagenatorDto {

    private Integer pageSize;
    private Integer pageIndex;

}

SearchEvaluationConditionDto.java

import lombok.AllArgsConstructor;
import lombok.Data;

@Data
@AllArgsConstructor
public class SearchEvaluationConditionDto{

    private String yearFrom;
    private String employeeCode;
    private String employeeRank;
        ~中略~
    private String retiree;

}

SearchEvaluationResultListDto.java

import java.util.List;

import lombok.AllArgsConstructor;
import lombok.Data;

@Data
@AllArgsConstructor
public class SearchEvaluationResultListDto {

    private Integer pageIndex;

    private Integer resultsLength;

    private List searchEvaluationResultDtos;

}

今日の感想

今日はMaterial PagenatorTableでサーバサイドページングを行いました。
GW明けから着手したのですが、大分苦労しました。
最初リファレンスにサーバサイドページングのことは書かれていないと勘違いして遠回りをしました。
実際に作り始めると時間はかからなかったので、オリジナルのページネーションを作るより楽だと感じました。

あとデザインに関してですが、検索結果の一覧はヘッダーを固定してスクロールするようにしたかったのですが、うまくできませんでした。
リファレンスのcssも取り込んだのですが…
会社の若い者に聞いてみます。

では、今日もお疲れ様でした。