見出し画像

【テスト駆動開発】Springboot & React - 第13回 : 新旧記事一覧の表示


1. はじめに


こんにちは、前回は記事一覧を表示するを実装しました。

今回は新旧記事一覧をメイン画面やプロフィール画面に表示する機能を実装します。今日の実装完成の画面です。

「Load More」を押すと、
旧記事が5個づつ表示されます。
記事を登録すると、
新しく表示する記事の数が表示されます。
それを押すと、
新しく表示する記事が表示されます。


プロファイルにも「Load More」を押すと、
もっと多い記事が表示されます。

2. 実装過程


2.1 旧記事一覧(バックエンド)


旧記事を読み込ませるコードを作成します。
/HoaxControllerTest.java

@Test
public void getOldHoaxes_whenThereAreNoHoaxes_receiveOk() {
    // 情報がない場合のテストです。APIがHTTPステータスコードOK(200)を返すことを確認します。
    ResponseEntity<Object> response = getOldHoaxes(5, new ParameterizedTypeReference<Object>() {});
    assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
}

@Test
public void getOldHoaxes_whenThereAreHoaxes_receivePageWithItemsProvidedId() {
    // 情報がある場合のテストです。特定のIDより前の情報を取得し、ページ内のアイテム数が正しいことを確認します。
    User user = userService.save(TestUtil.createValidUser("user1"));
    // 情報をいくつか生成します。
    hoaxService.save(user, TestUtil.createValidHoax());
    hoaxService.save(user, TestUtil.createValidHoax());
    hoaxService.save(user, TestUtil.createValidHoax());
    Hoax fourth = hoaxService.save(user, TestUtil.createValidHoax());
    hoaxService.save(user, TestUtil.createValidHoax());

    ResponseEntity<TestPage<Object>> response = getOldHoaxes(fourth.getId(), new ParameterizedTypeReference<TestPage<Object>>() {});
    assertThat(response.getBody().getTotalElements()).isEqualTo(3);
}

@Test
public void getOldHoaxes_whenThereAreHoaxes_receivePageWithHoaxVMBeforeProvidedId() {
    // 情報がある場合ののテストです。特定のIDより前の情報を取得し、返された情報が特定のView Model(HoaxVM)の要求されたプロパティを持っているかどうかを確認します。
    User user = userService.save(TestUtil.createValidUser("user1"));
    // 情報をいくつか生成します。
    hoaxService.save(user, TestUtil.createValidHoax());
    hoaxService.save(user, TestUtil.createValidHoax());
    hoaxService.save(user, TestUtil.createValidHoax());
    Hoax fourth = hoaxService.save(user, TestUtil.createValidHoax());
    hoaxService.save(user, TestUtil.createValidHoax());

    ResponseEntity<TestPage<HoaxVM>> response = getOldHoaxes(fourth.getId(), new ParameterizedTypeReference<TestPage<HoaxVM>>() {});
    assertThat(response.getBody().getContent().get(0).getDate()).isGreaterThan(0);
}

public <T> ResponseEntity<T> getOldHoaxes(long hoaxId, ParameterizedTypeReference<T> responseType){
    // APIエンドポイントのパスを構築し、APIを呼び出して結果を返すメソッドです。
    String path = API_1_0_HOAXES + "/" + hoaxId +"?direction=before&page=0&size=5&sort=id,desc";
    return testRestTemplate.exchange(path, HttpMethod.GET, null, responseType);
}

記事の有無・VMをテストします。


/HoaxController.java

	@GetMapping("/hoaxes/{id:[0-9]+}")
	Page<?> getHoaxesRelative(@PathVariable long id, Pageable pageable) {
		return hoaxService.getOldHoaxes(id, pageable).map(HoaxVM::new);
	}


/HoaxService.java

	public Page<Hoax> getOldHoaxes(long id, Pageable pageable) {
		return hoaxRepository.findByIdLessThan(id, pageable);
	}



/HoaxRepository.java

	Page<Hoax> findByIdLessThan(long id, Pageable pageable);

指定されたIDより小さいIDを持つHoaxエンティティのページを取得する。


ユーザープロファイルに表示させる旧記事を表示するコードを作成します。

/HoaxControllerTest.java

	@Test
	public void getOldHoaxesOfUser_whenUserExistThereAreNoHoaxes_receiveOk() {
		userService.save(TestUtil.createValidUser("user1"));
		ResponseEntity<Object> response = getOldHoaxesOfUser(5, "user1", new ParameterizedTypeReference<Object>() {});
		assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
	}
	@Test
	public void getOldHoaxesOfUser_whenUserExistAndThereAreHoaxes_receivePageWithItemsProvidedId() {
		User user = userService.save(TestUtil.createValidUser("user1"));
		hoaxService.save(user, TestUtil.createValidHoax());
		hoaxService.save(user, TestUtil.createValidHoax());
		hoaxService.save(user, TestUtil.createValidHoax());
		Hoax fourth = hoaxService.save(user, TestUtil.createValidHoax());
		hoaxService.save(user, TestUtil.createValidHoax());

		ResponseEntity<TestPage<Object>> response = getOldHoaxesOfUser(fourth.getId(), "user1", new ParameterizedTypeReference<TestPage<Object>>() {});
		assertThat(response.getBody().getTotalElements()).isEqualTo(3);
	}

	@Test
	public void getOldHoaxesOfUser_whenUserExistAndThereAreHoaxes_receivePageWithHoaxVMBeforeProvidedId() {
		User user = userService.save(TestUtil.createValidUser("user1"));
		hoaxService.save(user, TestUtil.createValidHoax());
		hoaxService.save(user, TestUtil.createValidHoax());
		hoaxService.save(user, TestUtil.createValidHoax());
		Hoax fourth = hoaxService.save(user, TestUtil.createValidHoax());
		hoaxService.save(user, TestUtil.createValidHoax());

		ResponseEntity<TestPage<HoaxVM>> response = getOldHoaxesOfUser(fourth.getId(), "user1", new ParameterizedTypeReference<TestPage<HoaxVM>>() {});
		assertThat(response.getBody().getContent().get(0).getDate()).isGreaterThan(0);
	}


	@Test
	public void getOldHoaxesOfUser_whenUserDoesNotExistThereAreNoHoaxes_receiveNotFound() {
		ResponseEntity<Object> response = getOldHoaxesOfUser(5, "user1", new ParameterizedTypeReference<Object>() {});
		assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND);
	}

	@Test
	public void getOldHoaxesOfUser_whenUserExistAndThereAreNoHoaxes_receivePageWithZeroItemsBeforeProvidedId() {
		User user = userService.save(TestUtil.createValidUser("user1"));
		hoaxService.save(user, TestUtil.createValidHoax());
		hoaxService.save(user, TestUtil.createValidHoax());
		hoaxService.save(user, TestUtil.createValidHoax());
		Hoax fourth = hoaxService.save(user, TestUtil.createValidHoax());
		hoaxService.save(user, TestUtil.createValidHoax());

		userService.save(TestUtil.createValidUser("user2"));

		ResponseEntity<TestPage<HoaxVM>> response = getOldHoaxesOfUser(fourth.getId(), "user2", new ParameterizedTypeReference<TestPage<HoaxVM>>() {});
		assertThat(response.getBody().getTotalElements()).isEqualTo(0);
	}
...
	public <T> ResponseEntity<T> getOldHoaxesOfUser(long hoaxId, String username, ParameterizedTypeReference<T> responseType){
		String path = "/api/1.0/users/" + username + "/hoaxes/" + hoaxId +"?direction=before&page=0&size=5&sort=id,desc";
		return testRestTemplate.exchange(path, HttpMethod.GET, null, responseType);
	}hoaxify-backend/src/test/java/com/hoaxify/hoaxify/HoaxControllerTest.java


/HoaxController.java

	@GetMapping("/users/{username}/hoaxes/{id:[0-9]+}")
	Page<?> getHoaxesRelativeForUser(@PathVariable String username, @PathVariable long id, Pageable pageable) {
		return hoaxService.getOldHoaxesOfUser(id, username, pageable).map(HoaxVM::new);

	}


/HoaxService.java

	public Page<Hoax> getOldHoaxesOfUser(long id, String username, Pageable pageable) {
		User inDB = userService.getByUsername(username);
		return hoaxRepository.findByIdLessThanAndUser(id, inDB, pageable);
	}


/HoaxRepository.java

	Page<Hoax> findByIdLessThanAndUser(long id, User user, Pageable pageable);

指定されたIDより小さいIDを持ち、かつ指定されたユーザーに関連付けられたHoaxエンティティのページを取得。


2.2 新記事一覧(バックエンド)

これからは、新記事をControllerからRepositoryまで作成します。

/HoaxControllerTest.java

@Test
public void getNewHoaxes_whenThereAreHoaxes_receiveListOfItemsAfterProvidedId() {
	User user = userService.save(TestUtil.createValidUser("user1"));
	hoaxService.save(user, TestUtil.createValidHoax());
	hoaxService.save(user, TestUtil.createValidHoax());
	hoaxService.save(user, TestUtil.createValidHoax());
	Hoax fourth = hoaxService.save(user, TestUtil.createValidHoax());
	hoaxService.save(user, TestUtil.createValidHoax());

	ResponseEntity<List<Object>> response = getNewHoaxes(fourth.getId(), new ParameterizedTypeReference<List<Object>>() {});
	assertThat(response.getBody().size()).isEqualTo(1);
}

@Test
public void getNewHoaxes_whenThereAreHoaxes_receiveListOfHoaxVMAfterProvidedId() {
	User user = userService.save(TestUtil.createValidUser("user1"));
	hoaxService.save(user, TestUtil.createValidHoax());
	hoaxService.save(user, TestUtil.createValidHoax());
	hoaxService.save(user, TestUtil.createValidHoax());
	Hoax fourth = hoaxService.save(user, TestUtil.createValidHoax());
	hoaxService.save(user, TestUtil.createValidHoax());

	ResponseEntity<List<HoaxVM>> response = getNewHoaxes(fourth.getId(), new ParameterizedTypeReference<List<HoaxVM>>() {});
	assertThat(response.getBody().get(0).getDate()).isGreaterThan(0);
}
public <T> ResponseEntity<T> getNewHoaxes(long hoaxId, ParameterizedTypeReference<T> responseType){
	String path = API_1_0_HOAXES + "/" + hoaxId +"?direction=after&sort=id,desc";
	return testRestTemplate.exchange(path, HttpMethod.GET, null, responseType);
}


/HoaxController.java

	@GetMapping("/hoaxes/{id:[0-9]+}")
	ResponseEntity<?> getHoaxesRelative(@PathVariable long id, Pageable pageable,
			@RequestParam(name="direction", defaultValue="after") String direction) {
		if(!direction.equalsIgnoreCase("after")) {			
			return ResponseEntity.ok(hoaxService.getOldHoaxes(id, pageable).map(HoaxVM::new));
		}
		List<HoaxVM> newHoaxes = hoaxService.getNewHoaxes(id, pageable).stream()
				.map(HoaxVM::new).collect(Collectors.toList());
		return ResponseEntity.ok(newHoaxes);
	}
  • @RequestParam(name="direction", defaultValue="after") String directionは、リクエストパラメータとして受け取る「direction」という名前のパラメータを処理します。デフォルト値は "after" です。

  • {id:[0-9]+}は、数字のみを許可するパス変数。

  • もしdirectionが "after" でない場合、hoaxService.getOldHoaxes(id, pageable)を呼び出して、指定されたIDの前の情報を取得し、それをHoaxVMクラスにマッピングして返します。

  • もしdirectionが "after" の場合、hoaxService.getNewHoaxes(id, pageable)を呼び出して、指定されたIDの後の情報を取得し、それをHoaxVMクラスにマッピングしてリストに変換し、それをレスポンスとして返します。

/HoaxService.java

	public List<Hoax> getNewHoaxes(long id, Pageable pageable) {
		return hoaxRepository.findByIdGreaterThan(id, pageable.getSort());
	}


/HoaxRepository.java

	List<Hoax> findByIdGreaterThan(long id, Sort sort);


ユーザーで記事を探します。
/HoaxControllerTest.java

	@Test
	public void getNewHoaxesOfUser_whenUserExistThereAreNoHoaxes_receiveOk() {
		userService.save(TestUtil.createValidUser("user1"));
		ResponseEntity<Object> response = getNewHoaxesOfUser(5, "user1", new ParameterizedTypeReference<Object>() {});
		assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
	}

	@Test
	public void getNewHoaxesOfUser_whenUserExistAndThereAreHoaxes_receiveListWithItemsAfterProvidedId() {
		User user = userService.save(TestUtil.createValidUser("user1"));
		hoaxService.save(user, TestUtil.createValidHoax());
		hoaxService.save(user, TestUtil.createValidHoax());
		hoaxService.save(user, TestUtil.createValidHoax());
		Hoax fourth = hoaxService.save(user, TestUtil.createValidHoax());
		hoaxService.save(user, TestUtil.createValidHoax());

		ResponseEntity<List<Object>> response = getNewHoaxesOfUser(fourth.getId(), "user1", new ParameterizedTypeReference<List<Object>>() {});
		assertThat(response.getBody().size()).isEqualTo(1);
	}

	@Test
	public void getNewHoaxesOfUser_whenUserExistAndThereAreHoaxes_receiveListWithHoaxVMAfterProvidedId() {
		User user = userService.save(TestUtil.createValidUser("user1"));
		hoaxService.save(user, TestUtil.createValidHoax());
		hoaxService.save(user, TestUtil.createValidHoax());
		hoaxService.save(user, TestUtil.createValidHoax());
		Hoax fourth = hoaxService.save(user, TestUtil.createValidHoax());
		hoaxService.save(user, TestUtil.createValidHoax());

		ResponseEntity<List<HoaxVM>> response = getNewHoaxesOfUser(fourth.getId(), "user1", new ParameterizedTypeReference<List<HoaxVM>>() {});
		assertThat(response.getBody().get(0).getDate()).isGreaterThan(0);
	}


	@Test
	public void getNewHoaxesOfUser_whenUserDoesNotExistThereAreNoHoaxes_receiveNotFound() {
		ResponseEntity<Object> response = getNewHoaxesOfUser(5, "user1", new ParameterizedTypeReference<Object>() {});
		assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND);
	}

	@Test
	public void getNewHoaxesOfUser_whenUserExistAndThereAreNoHoaxes_receiveListWithZeroItemsAfterProvidedId() {
		User user = userService.save(TestUtil.createValidUser("user1"));
		hoaxService.save(user, TestUtil.createValidHoax());
		hoaxService.save(user, TestUtil.createValidHoax());
		hoaxService.save(user, TestUtil.createValidHoax());
		Hoax fourth = hoaxService.save(user, TestUtil.createValidHoax());
		hoaxService.save(user, TestUtil.createValidHoax());

		userService.save(TestUtil.createValidUser("user2"));

		ResponseEntity<List<HoaxVM>> response = getNewHoaxesOfUser(fourth.getId(), "user2", new ParameterizedTypeReference<List<HoaxVM>>() {});
		assertThat(response.getBody().size()).isEqualTo(0);
	}

…

	public <T> ResponseEntity<T> getNewHoaxesOfUser(long hoaxId, String username, ParameterizedTypeReference<T> responseType){
		String path = "/api/1.0/users/" + username + "/hoaxes/" + hoaxId +"?direction=after&sort=id,desc";
		return testRestTemplate.exchange(path, HttpMethod.GET, null, responseType);
	}


/HoaxController.java

	@GetMapping("/users/{username}/hoaxes/{id:[0-9]+}")
	ResponseEntity<?> getHoaxesRelativeForUser(@PathVariable String username, @PathVariable long id, Pageable pageable,
			@RequestParam(name="direction", defaultValue="after") String direction) {
		if(!direction.equalsIgnoreCase("after")) {			
			return ResponseEntity.ok(hoaxService.getOldHoaxesOfUser(id, username, pageable).map(HoaxVM::new));
		}
		List<HoaxVM> newHoaxes = hoaxService.getNewHoaxesOfUser(id, username, pageable).stream()
				.map(HoaxVM::new).collect(Collectors.toList());
		return ResponseEntity.ok(newHoaxes);

	}
  1. 取得された情報は、Stream<Hoax>として提供されます。

  2. map(HoaxVM::new)は、各偽情報をHoaxVMクラスの新しいインスタンスに変換します。

  3. collect(Collectors.toList())は、Stream内のすべての要素をリストにまとめます。

この操作により、HoaxエンティティがHoaxVM(View Model)クラスに変換され、それらがリストとして返されます。通常、View Modelは、データをビューに適した形式で提供するために使用され、表示目的に特化した情報や計算済みのプロパティを含むことがあります。


/HoaxService.java


	public List<Hoax> getNewHoaxesOfUser(long id, String username, Pageable pageable) {
		User inDB = userService.getByUsername(username);
		return hoaxRepository.findByIdGreaterThanAndUser(id, inDB, pageable.getSort());
	}


/HoaxRepository.java

	List<Hoax> findByIdGreaterThanAndUser(long id, User user, Sort sort);


記事を数えるコードを作成します。
/HoaxController.java

	@GetMapping("/hoaxes/{id:[0-9]+}")
	ResponseEntity<?> getHoaxesRelative(@PathVariable long id, Pageable pageable,
			@RequestParam(name="direction", defaultValue="after") String direction,
			@RequestParam(name="count", defaultValue="false", required=false) boolean count
			) {
		if(!direction.equalsIgnoreCase("after")) {			
			return ResponseEntity.ok(hoaxService.getOldHoaxes(id, pageable).map(HoaxVM::new));
		}

		if(count == true) {
			long newHoaxCount = hoaxService.getNewHoaxesCount(id);
			return ResponseEntity.ok(Collections.singletonMap("count", newHoaxCount));
		}

		List<HoaxVM> newHoaxes = hoaxService.getNewHoaxes(id, pageable).stream()
				.map(HoaxVM::new).collect(Collectors.toList());
		return ResponseEntity.ok(newHoaxes);
	}
…
	@GetMapping("/users/{username}/hoaxes/{id:[0-9]+}")
	ResponseEntity<?> getHoaxesRelativeForUser(@PathVariable String username, @PathVariable long id, Pageable pageable,
			@RequestParam(name="direction", defaultValue="after") String direction,
			@RequestParam(name="count", defaultValue="false", required=false) boolean count
			) {
		if(!direction.equalsIgnoreCase("after")) {			
			return ResponseEntity.ok(hoaxService.getOldHoaxesOfUser(id, username, pageable).map(HoaxVM::new));
		}

		if(count == true) {
			long newHoaxCount = hoaxService.getNewHoaxesCountOfUser(id, username);
			return ResponseEntity.ok(Collections.singletonMap("count", newHoaxCount));
		}
		List<HoaxVM> newHoaxes = hoaxService.getNewHoaxesOfUser(id, username, pageable).stream()
				.map(HoaxVM::new).collect(Collectors.toList());
		return ResponseEntity.ok(newHoaxes);
		
	}


Collections.singletonMap("count", newHoaxCount)
は、Javaで単一のエントリー(1つのキーと1つの値)を持つ不変のマップを作成するためのメソッドです。
このメソッドは、キーと値のペアを含む不変のマップを作成し、そのマップを返します。ここでは、"count"という文字列をキー、newHoaxCount変数の値をその対応する値として持つ単一のエントリーのマップを生成しています。

singletonMapは単一項目マップを簡単に生成して、修正する必要がない場合に便利に使うことができます。


/HoaxService.java


	public long getNewHoaxesCount(long id) {
		return hoaxRepository.countByIdGreaterThan(id);
	}

	public long getNewHoaxesCountOfUser(long id, String username) {
		User inDB = userService.getByUsername(username);
		return hoaxRepository.countByIdGreaterThanAndUser(id, inDB);
	}


/HoaxRepository.java

	long countByIdGreaterThan(long id);

	long countByIdGreaterThanAndUser(long id, User user);


簡単にリファクタリングをしましょう。
/HoaxController.java

	@GetMapping({"/hoaxes/{id:[0-9]+}", "/users/{username}/hoaxes/{id:[0-9]+}"}) 
	ResponseEntity<?> getHoaxesRelative(@PathVariable long id,
			@PathVariable(required= false) String username,
			Pageable pageable,
			@RequestParam(name="direction", defaultValue="after") String direction,
			@RequestParam(name="count", defaultValue="false", required=false) boolean count
			) {
		if(!direction.equalsIgnoreCase("after")) {			
			return ResponseEntity.ok(hoaxService.getOldHoaxes(id, username, pageable).map(HoaxVM::new));
		}

		if(count == true) {
			long newHoaxCount = hoaxService.getNewHoaxesCount(id, username);
			return ResponseEntity.ok(Collections.singletonMap("count", newHoaxCount));
		}

		List<HoaxVM> newHoaxes = hoaxService.getNewHoaxes(id, username, pageable).stream()
				.map(HoaxVM::new).collect(Collectors.toList());
		return ResponseEntity.ok(newHoaxes);
	}


/HoaxService.java

	public Page<Hoax> getOldHoaxes(long id, String username, Pageable pageable) {
		Specification<Hoax> spec = Specification.where(idLessThan(id));
		if(username != null) {			
			User inDB = userService.getByUsername(username);
			spec = spec.and(userIs(inDB));
		}
		return hoaxRepository.findAll(spec, pageable);
	}


	public List<Hoax> getNewHoaxes(long id, String username, Pageable pageable) {
		Specification<Hoax> spec = Specification.where(idGreaterThan(id));
		if(username != null) {			
			User inDB = userService.getByUsername(username);
			spec = spec.and(userIs(inDB));
		}
		return hoaxRepository.findAll(spec, pageable.getSort());
	}

	public long getNewHoaxesCount(long id, String username) {
		Specification<Hoax> spec = Specification.where(idGreaterThan(id));
		if(username != null) {			
			User inDB = userService.getByUsername(username);
			spec = spec.and(userIs(inDB));
		}
		return hoaxRepository.count(spec);
	}

	private Specification<Hoax> userIs(User user){
		return (root, query, criteriaBuilder) -> {
			return criteriaBuilder.equal(root.get("user"), user);
		};
	}

	private Specification<Hoax> idLessThan(long id){
		return (root, query, criteriaBuilder) -> {
			return criteriaBuilder.lessThan(root.get("id"), id);
		};
	}

	private Specification<Hoax> idGreaterThan(long id){
		return (root, query, criteriaBuilder) -> {
			return criteriaBuilder.greaterThan(root.get("id"), id);
		};
	}

getOldHoaxes メソッド:

与えられたIDより小さいHoaxエンティティを取得します。

getNewHoaxesメソッド:

与えられたIDより大きいHoaxエンティティを取得します。

getNewHoaxesCountメソッド:

与えられたIDより大きいHoaxエンティティの数を取得します。

オプションでユーザー(username)が与えられた場合、そのユーザーのHoax数を取得します。

さらに、userIs、idLessThan、idGreaterThanメソッドは、それぞれユーザーが特定の条件を満たすかどうか、IDが与えられた値より小さいか大きいかを判断するためのSpecificationを生成するユーティリティメソッドです。


/HoaxRepository.java


public interface HoaxRepository extends JpaRepository<Hoax, Long>, JpaSpecificationExecutor<Hoax>{

SpecificationはSpring Data JPAで提供する機能の一つで、動的なクエリを生成するための方法の一つです。 これを使用してクエリの条件を組み合わせて、データベースから条件に合う結果を検索することができます。

SpecificationはJPA Criteria APIに基づいて動作します。これを使うとクエリ条件をプログラム的に作成し、ランタイム時にこれを組み合わせて柔軟な検索条件を生成することができます。

主に使われるメソッドにはwhere、and、or、notなどがあり、それぞれのメソッドはPredicateオブジェクトを返してクエリに適用される条件を組み合わせます。PredicateはSQL WHERE構文の条件節に該当します。

例えば、userIs、idLessThan、idGreaterThanなどのメソッドはそれぞれユーザー、IDが与えられた条件に合うPredicateを生成する役割をします。 これらのPredicateは後にand、orなどを使って組み合わせることができ、これらを組み合わせて動的な検索条件を作ることができます。

PredicateはJava 8以降でjava.util.functionパッケージに属する関数型インターフェースです。主に関数型プログラミングで使われ、条件を表す関数を意味します。

Predicateは入力として値を受け取ってtrueまたはfalseを返すtest()メソッドを含む関数型インターフェースです。 これを使用して条件を定義し、この条件を満たすかどうかを判断することができます。

主に使うメソッドはand(), or(), negate() などがあります。これらのメソッドを使ってPredicateを組み合わせたり、否定してより複雑な条件を作ることができます。


2.3 旧記事一覧(フロントエンド)

APIを作ります。

frontend/src/api/apiCalls.spec.js

 describe('loadOldHoaxes', () => {
    it('calls /api/1.0/hoaxes/5?direction=before&page=0&size=5&sort=id,desc when hoax id param provided', () => {
      const mockGetHoaxes = jest.fn();
      axios.get = mockGetHoaxes;
      apiCalls.loadOldHoaxes(5);
      expect(mockGetHoaxes).toBeCalledWith(
        '/api/1.0/hoaxes/5?direction=before&page=0&size=5&sort=id,desc'
      );
    });
    it('calls /api/1.0/users/user3/hoaxes/5?direction=before&page=0&size=5&sort=id,desc when hoax id and username param provided', () => {
      const mockGetHoaxes = jest.fn();
      axios.get = mockGetHoaxes;
      apiCalls.loadOldHoaxes(5, 'user3');
      expect(mockGetHoaxes).toBeCalledWith(
        '/api/1.0/users/user3/hoaxes/5?direction=before&page=0&size=5&sort=id,desc'
      );
    });
  });
  describe('loadNewHoaxes', () => {
    it('calls /api/1.0/hoaxes/5?direction=after&sort=id,desc when hoax id param provided', () => {
      const mockGetHoaxes = jest.fn();
      axios.get = mockGetHoaxes;
      apiCalls.loadNewHoaxes(5);
      expect(mockGetHoaxes).toBeCalledWith(
        '/api/1.0/hoaxes/5?direction=after&sort=id,desc'
      );
    });
    it('calls /api/1.0/users/user3/hoaxes/5?direction=after&sort=id,desc when hoax id and username param provided', () => {
      const mockGetHoaxes = jest.fn();
      axios.get = mockGetHoaxes;
      apiCalls.loadNewHoaxes(5, 'user3');
      expect(mockGetHoaxes).toBeCalledWith(
        '/api/1.0/users/user3/hoaxes/5?direction=after&sort=id,desc'
      );
    });
  });
  describe('loadNewHoaxCount', () => {
    it('calls /api/1.0/hoaxes/5?direction=after&count=true when hoax id param provided', () => {
      const mockGetHoaxes = jest.fn();
      axios.get = mockGetHoaxes;
      apiCalls.loadNewHoaxCount(5);
      expect(mockGetHoaxes).toBeCalledWith(
        '/api/1.0/hoaxes/5?direction=after&count=true'
      );
    });
    it('calls /api/1.0/users/user3/hoaxes/5?direction=after&count=true when hoax id and username param provided', () => {
      const mockGetHoaxes = jest.fn();
      axios.get = mockGetHoaxes;
      apiCalls.loadNewHoaxCount(5, 'user3');
      expect(mockGetHoaxes).toBeCalledWith(
        '/api/1.0/users/user3/hoaxes/5?direction=after&count=true'
      );
    });
  });


frontend/src/api/apiCalls.js

export const loadOldHoaxes = (hoaxId, username) => {
  const basePath = username
    ? `/api/1.0/users/${username}/hoaxes`
    : '/api/1.0/hoaxes';
  const path = `${basePath}/${hoaxId}?direction=before&page=0&size=5&sort=id,desc`;
  return axios.get(path);
};

export const loadNewHoaxes = (hoaxId, username) => {
  const basePath = username
    ? `/api/1.0/users/${username}/hoaxes`
    : '/api/1.0/hoaxes';
  const path = `${basePath}/${hoaxId}?direction=after&sort=id,desc`;
  return axios.get(path);
};

export const loadNewHoaxCount = (hoaxId, username) => {
  const basePath = username
    ? `/api/1.0/users/${username}/hoaxes`
    : '/api/1.0/hoaxes';
  const path = `${basePath}/${hoaxId}?direction=after&count=true`;
  return axios.get(path);
};

loadOldHoaxes: この関数は、以前のhoaxIdより低いIDを持つHoaxesを取得するために使用します。以前のhoaxesを取得するために必要なAPIパスを生成し、axiosを使ってそのパスにGETリクエストを送ります。

loadNewHoaxes: この関数は、以降のhoaxIdより高いIDを持つHoaxesを取得するために使用されます。新しいhoaxesを取得するために必要なAPIパスを生成し、axiosを使ってそのパスにGETリクエストを送ります。

loadNewHoaxCount: この関数は、hoaxIdより高いIDを持つHoaxesの数を取得するために使用されます。新しいhoaxesの数を取得するために必要なAPIパスを生成し、axiosを使ってそのパスにGETリクエストを送ります。


旧記事を取得します。

frontend/src/components/HoaxFeed.js

  onClickLoadMore = () => {
    const hoaxes = this.state.page.content;
    if (hoaxes.length === 0) {
      return;
    }
    const hoaxAtBottom = hoaxes[hoaxes.length - 1];
    apiCalls
      .loadOldHoaxes(hoaxAtBottom.id, this.props.user)
      .then((response) => {
        const page = { ...this.state.page };
        page.content = [...page.content, ...response.data.content];
        page.last = response.data.last;
        this.setState({ page });
      });
  };
  render() {
    if (this.state.isLoadingHoaxes) {
      return <Spinner />;
…
   return (
      <div>
        {this.state.page.content.map((hoax) => {
          return <HoaxView key={hoax.id} hoax={hoax} />;
        })}
        {this.state.page.last === false && (
          <div
            className="card card-header text-center"
            onClick={this.onClickLoadMore}
            style={{ cursor: 'pointer' }}
          >
            Load More
          </div>
        )}
      </div>
    );

Reactコンポーネントの状態とライフサイクルメソッドを使ってHoaxesを動的にロードして、ユーザーが"Load More"ボタンをクリックするたびに追加的なHoaxesを取得してページに表示します。

onClickLoadMore 関数
stateにある現在のページのHoaxesを取得します。
取得したHoaxesが空であれば、何もせずに終了します。
最後のHoaxを確認し、そのHoaxのIDを使ってapiCalls.loadOldHoaxes関数を呼び出します。
その後、応答で受け取ったデータのHoaxを現在のページに追加して、最後のページかどうかを更新した後、setStateを使って状態を更新します。

render関数
現在のページが最後のページでない場合、"Load More"ボタンをレンダリングしてクリックしたらonClickLoadMore関数が呼び出されるようにします。


2.4 新記事一覧(フロントエンド)

frontend/src/components/HoaxFeed.js

class HoaxFeed extends Component {
  state = {
    page: {
      content: []
    },
    isLoadingHoaxes: false,
    newHoaxCount: 0
  };
  componentDidMount() {
    this.setState({ isLoadingHoaxes: true });
    apiCalls.loadHoaxes(this.props.user).then((response) => {
      this.setState({ page: response.data, isLoadingHoaxes: false }, () => {
        this.counter = setInterval(this.checkCount, 3000);
      });
    });
  }
  componentWillUnmount() {
    clearInterval(this.counter);
  }
  checkCount = () => {
    const hoaxes = this.state.page.content;
    let topHoaxId = 0;
    if (hoaxes.length > 0) {
      topHoaxId = hoaxes[0].id;
    }
    apiCalls.loadNewHoaxCount(topHoaxId, this.props.user).then((response) => {
      this.setState({ newHoaxCount: response.data.count });
    });
  };
  onClickLoadMore = () => {
    const hoaxes = this.state.page.content;
    if (hoaxes.length === 0) {
      return;
    }
    const hoaxAtBottom = hoaxes[hoaxes.length - 1];
    apiCalls
      .loadOldHoaxes(hoaxAtBottom.id, this.props.user)
      .then((response) => {
        const page = { ...this.state.page };
        page.content = [...page.content, ...response.data.content];
        page.last = response.data.last;
        this.setState({ page });
      });
  };

  onClickLoadNew = () => {
    const hoaxes = this.state.page.content;
    let topHoaxId = 0;
    if (hoaxes.length > 0) {
      topHoaxId = hoaxes[0].id;
    }
    apiCalls.loadNewHoaxes(topHoaxId, this.props.user).then((response) => {
      const page = { ...this.state.page };
      page.content = [...response.data, ...page.content];
      this.setState({ page, newHoaxCount: 0 });
    });
  };

  render() {
    if (this.state.isLoadingHoaxes) {
      return <Spinner />;
    }
    if (this.state.page.content.length === 0 && this.state.newHoaxCount === 0) {
      return (
        <div className="card card-header text-center">There are no hoaxes</div>
      );
    }
    return (
      <div>
        {this.state.newHoaxCount > 0 && (
          <div
            className="card card-header text-center"
            onClick={this.onClickLoadNew}
            style={{ cursor: 'pointer' }}
          >
            {this.state.newHoaxCount === 1
              ? 'There is 1 new hoax'
              : `There are ${this.state.newHoaxCount} new hoaxes`}
          </div>
        )}
        {this.state.page.content.map((hoax) => {
          return <HoaxView key={hoax.id} hoax={hoax} />;
        })}
        {this.state.page.last === false && (
          <div
            className="card card-header text-center"
            onClick={this.onClickLoadMore}
            style={{ cursor: 'pointer' }}
          >
            Load More
          </div>
        )}
      </div>
    );
  }
}
export default HoaxFeed;

Hoaxesを動的にレンダリングして、新しいHoaxを確認してロードする機能を実装しています。


componentDidMount メソッド

コンポーネントがマウントされた後に呼び出され、初期Hoaxesをロードします。
apiCalls.loadHoaxesを使ってHoaxesを取得し、取得したデータを状態に保存します。
その後、3秒ごとに新しいHoaxesがあるか確認するsetIntervalを設定します。


componentWillUnmountメソッド

コンポーネントがアンマウントされる前に呼び出され、setIntervalを削除してメモリリークを防止します。

checkCountメソッド

一定時間ごとに最上段のHoaxのIDを基準に新しいHoaxesの数を確認します。
apiCalls.loadNewHoaxCount を使って新しいHoaxesの数を取得してステータスに保存します。

onClickLoadMoreメソッド

"Load More"ボタンをクリックした時に呼び出され、ページの最後のHoaxesを基準に以前のHoaxesを取得します。
取得したHoaxesを現在のページに追加し、状態を更新します。

onClickLoadNew メソッド

"New Hoax"ボタンをクリックした時に呼び出され、最上段のHoaxのIDを基準に新しいHoaxesを取得します。
取得したHoaxesを現在のページの一番上に追加し、新しいHoaxの数を0に初期化します。

renderメソッド

データ読み込み中ならSpinnerをレンダリングします。

もし、Hoaxesがなく、新しいHoaxesもない場合は「There are no hoaxes」メッセージを表示します。
HoaxesをマッピングしてHoaxViewコンポーネントでレンダリングして、「Load More」ボタンを表示します。

新しいHoaxesがある場合、"New Hoax"ボタンを表示してクリックするとonClickLoadNew関数を呼び出して新しいHoaxesをロードします。


3. 最後に


今まで新旧記事一覧を表示する機能を実装しました。やっぱりページネーションを実装する時、記事の数と記事のID、記事の作成者を要素にして取得することをバックエンドからフロントエンドまで細かく考慮する必要がありますね。 今日は特にSpecificationを使って動的クエリを作成する方法も知りました。柔軟性がないと思っていたのですが、それでも選択肢はありましたね。 次回は記事に画像を入れる機能を実装してみます。


エンジニアファーストの会社 株式会社CRE-CO


【参考】


  • [Udemy] Spring Boot and React with Test Driven Development

この記事が気に入ったらサポートをしてみませんか?