React

AngularJS のチュートリアルを React にリプレイスしてみた②

AngularJS のチュートリアルを React にリプレイスします。今回の記事では AngularJS のコンポーネントをすべてリプレイスして、angular-resorce の代わりに API クライアントを実装します。

詳細ページを React コンポーネントにする

電話の詳細を表示するページを React へ移行しましょう。まずは PhoneDetail の型定義を作成します。app/phone-detail/types.ts ファイルを作成しましょう。

export type Android = {
  os: string;
  ui: string;
};
 
export type Battery = {
  standbyTime: string;
  talkTime: string;
  type: string;
};
 
export type Camera = {
  features: string[];
  primary: string;
};
 
export type Connectivity = {
  bluetooth: string;
  cell: string;
  gps: boolean;
  infrared: boolean;
  wifi: string;
};
 
export type Display = {
  screenResolution: string;
  screenSize: string;
  touchScreen: boolean;
};
 
export type Hardware = {
  accelerometer: boolean;
  audioJack: string;
  cpu: string;
  fmRadio: boolean;
  physicalKeyboard: boolean;
  usb: string;
};
 
export type SizeAndWeight = {
  dimensions: string[];
  weight: string;
};
 
export type Storage = {
  flash: string;
  ram: string;
};
 
export type PhoneDetail = {
  additionalFeatures: string;
  android: Android;
  availability: string[];
  battery: Battery;
  camera: Camera;
  connectivity: Connectivity;
  description: string;
  display: Display;
  hardware: Hardware;
  id: string;
  images: string[];
  name: string;
  sizeAndWeight: SizeAndWeight;
  storage: Storage;
};

さて、対象の詳細ページの以下のとおりになっています。サムネイルを表示する部分と、スペックを表示する部分でコンポーネントを分割して実装しましょう。

スクリーンショット 2022-08-04 8.29.34

Specification コンポーネント

まずはスペックを表示する部分のコンポーネントです。元のテンプレートは以下のようになっています。

<ul class="specs">
  <li>
    <span>Availability and Networks</span>
    <dl>
      <dt>Availability</dt>
      <dd ng-repeat="availability in $ctrl.phone.availability">{{availability}}</dd>
    </dl>
  </li>
  <li>
    <span>Battery</span>
    <dl>
      <dt>Type</dt>
      <dd>{{$ctrl.phone.battery.type}}</dd>
      <dt>Talk Time</dt>
      <dd>{{$ctrl.phone.battery.talkTime}}</dd>
      <dt>Standby time (max)</dt>
      <dd>{{$ctrl.phone.battery.standbyTime}}</dd>
    </dl>
  </li>
  <li>
    <span>Storage and Memory</span>
    <dl>
      <dt>RAM</dt>
      <dd>{{$ctrl.phone.storage.ram}}</dd>
      <dt>Internal Storage</dt>
      <dd>{{$ctrl.phone.storage.flash}}</dd>
    </dl>
  </li>
  <li>
    <span>Connectivity</span>
    <dl>
      <dt>Network Support</dt>
      <dd>{{$ctrl.phone.connectivity.cell}}</dd>
      <dt>WiFi</dt>
      <dd>{{$ctrl.phone.connectivity.wifi}}</dd>
      <dt>Bluetooth</dt>
      <dd>{{$ctrl.phone.connectivity.bluetooth}}</dd>
      <dt>Infrared</dt>
      <dd>{{$ctrl.phone.connectivity.infrared | checkmark}}</dd>
      <dt>GPS</dt>
      <dd>{{$ctrl.phone.connectivity.gps | checkmark}}</dd>
    </dl>
  </li>
  <li>
    <span>Android</span>
    <dl>
      <dt>OS Version</dt>
      <dd>{{$ctrl.phone.android.os}}</dd>
      <dt>UI</dt>
      <dd>{{$ctrl.phone.android.ui}}</dd>
    </dl>
  </li>
  <li>
    <span>Size and Weight</span>
    <dl>
      <dt>Dimensions</dt>
      <dd ng-repeat="dim in $ctrl.phone.sizeAndWeight.dimensions">{{dim}}</dd>
      <dt>Weight</dt>
      <dd>{{$ctrl.phone.sizeAndWeight.weight}}</dd>
    </dl>
  </li>
  <li>
    <span>Display</span>
    <dl>
      <dt>Screen size</dt>
      <dd>{{$ctrl.phone.display.screenSize}}</dd>
      <dt>Screen resolution</dt>
      <dd>{{$ctrl.phone.display.screenResolution}}</dd>
      <dt>Touch screen</dt>
      <dd>{{$ctrl.phone.display.touchScreen | checkmark}}</dd>
    </dl>
  </li>
  <li>
    <span>Hardware</span>
    <dl>
      <dt>CPU</dt>
      <dd>{{$ctrl.phone.hardware.cpu}}</dd>
      <dt>USB</dt>
      <dd>{{$ctrl.phone.hardware.usb}}</dd>
      <dt>Audio / headphone jack</dt>
      <dd>{{$ctrl.phone.hardware.audioJack}}</dd>
      <dt>FM Radio</dt>
      <dd>{{$ctrl.phone.hardware.fmRadio | checkmark}}</dd>
      <dt>Accelerometer</dt>
      <dd>{{$ctrl.phone.hardware.accelerometer | checkmark}}</dd>
    </dl>
  </li>
  <li>
    <span>Camera</span>
    <dl>
      <dt>Primary</dt>
      <dd>{{$ctrl.phone.camera.primary}}</dd>
      <dt>Features</dt>
      <dd>{{$ctrl.phone.camera.features.join(', ')}}</dd>
    </dl>
  </li>
  <li>
    <span>Additional Features</span>
    <dd>{{$ctrl.phone.additionalFeatures}}</dd>
  </li>
</ul>

ng-reapeat でループ処理をしている部分は Array#map に置き換えれば問題ないでしょう。少しやっかいなのは {{$ctrl.phone.connectivity.infrared | checkmark}} のように checkmark フィルターを使用しているところです。AngularJS のフィルタ自体は通常の関数呼び出しで置き換えできますが、checkmark フィルターはユーザーによって定義されたカスタムフィルタです(app/core/checkmark/checkmark.filter.js に実装があります)。

checkmark フィルタ自体は単純な実装なのでそのまま関数に置き換えることも可能ですが、練習のためカスタムフィルタを React コンポーネントに注入する形で実装してみましょう。

app/phone-detail/Specitfication.tsx ファイルを作成します。

import angular from 'angular';
import React from 'react';
import { react2angular } from 'react2angular';
import { PhoneDetail } from './types';
 
type Props = {
  phone: PhoneDetail;
  $filter: ng.IFilterService;
};
 
const Specifiction: React.FC<Props> = ({ phone, $filter }) => {
  const checkmark = $filter('checkmark') as (input: boolean) => string;
 
  return (
    <ul className="specs">
      <li>
        <span>Availability and Networks</span>
        <dl>
          <dt>Availability</dt>
          {phone.availability.map((availability) => (
            <dd key={availability}>{availability}</dd>
          ))}
        </dl>
      </li>
      <li>
        <span>Battery</span>
        <dl>
          <dt>Type</dt>
          <dd>{phone.battery.type}</dd>
          <dt>Talk Time</dt>
          <dd>{phone.battery.talkTime}</dd>
          <dt>Standby time (max)</dt>
          <dd>{phone.battery.standbyTime}</dd>
        </dl>
      </li>
      <li>
        <span>Storage and Memory</span>
        <dl>
          <dt>RAM</dt>
          <dd>{phone.storage.ram}</dd>
          <dt>Internal Storage</dt>
          <dd>{phone.storage.flash}</dd>
        </dl>
      </li>
      <li>
        <span>Connectivity</span>
        <dl>
          <dt>Network Support</dt>
          <dd>{phone.connectivity.cell}</dd>
          <dt>WiFi</dt>
          <dd>{phone.connectivity.wifi}</dd>
          <dt>Bluetooth</dt>
          <dd>{phone.connectivity.bluetooth}</dd>
          <dt>Infrared</dt>
          <dd>{phone.connectivity.infrared}</dd>
          <dt>GPS</dt>
          <dd>{checkmark(phone.connectivity.gps)}</dd>
        </dl>
      </li>
      <li>
        <span>Android</span>
        <dl>
          <dt>OS Version</dt>
          <dd>{phone.android.os}</dd>
          <dt>UI</dt>
          <dd>{phone.android.ui}</dd>
        </dl>
      </li>
      <li>
        <span>Size and Weight</span>
        <dl>
          <dt>Dimensions</dt>
          {phone.sizeAndWeight.dimensions.map((dim) => (
            <dd key={dim}>{dim}</dd>
          ))}
          <dt>Weight</dt>
          <dd>{phone.sizeAndWeight.weight}</dd>
        </dl>
      </li>
      <li>
        <span>Display</span>
        <dl>
          <dt>Screen size</dt>
          <dd>{phone.display.screenSize}</dd>
          <dt>Screen resolution</dt>
          <dd>{phone.display.screenResolution}</dd>
          <dt>Touch screen</dt>
          <dd>{checkmark(phone.display.touchScreen)}</dd>
        </dl>
      </li>
      <li>
        <span>Hardware</span>
        <dl>
          <dt>CPU</dt>
          <dd>{phone.hardware.cpu}</dd>
          <dt>USB</dt>
          <dd>{phone.hardware.usb}</dd>
          <dt>Audio / headphone jack</dt>
          <dd>{phone.hardware.audioJack}</dd>
          <dt>FM Radio</dt>
          <dd>{checkmark(phone.hardware.fmRadio)}</dd>
          <dt>Accelerometer</dt>
          <dd>{checkmark(phone.hardware.accelerometer)}</dd>
        </dl>
      </li>
      <li>
        <span>Camera</span>
        <dl>
          <dt>Primary</dt>
          <dd>{phone.camera.primary}</dd>
          <dt>Features</dt>
          <dd>{phone.camera.features.join(', ')}</dd>
        </dl>
      </li>
      <li>
        <span>Additional Features</span>
        <dd>{phone.additionalFeatures}</dd>
      </li>
    </ul>
  );
};

Props には API から種取得した電話詳細情報と $filter を受け取ります。$filter は AngularJS のサービスで、フィルターをテンプレート内以外のコントローラーやサービスなどの場所で使用するために Dependency Ingection されるサービスです。フィルターを React コンポーネント内で使用するために Props として渡しています。

type Props = {
  phone: PhoneDetail;
  $filter: ng.IFilterService;
};

$filter(フィルター名) のように呼び出すと引数で指定したフィルター名の関数が返されます。ここでは charkmark フィルターを呼び出し結果を変数に代入して後ほど jsx 内で使用しています。

const checkmark = $filter('checkmark') as (input: boolean) => string;

テンプレート内の変更部分としては、ng-repeatAArray#map に置き換えるのと。

  <dl>
    <dt>Availability</dt>
-    <dd ng-repeat="availability in $ctrl.phone.availability">{{availability}}</dd>
+    {phone.availability.map((availability) => (
+      <dd key={availability}>{availability}</dd>
+    ))}
  </dl>

checmark フィルターを呼び出していた箇所を通常の関数呼び出しに変更しています。

  <dt>GPS</dt>
- <dd>{{$ctrl.phone.connectivity.gps | checkmark}}</dd>  
+ <dd>{checkmark(phone.connectivity.gps)}</dd>

最後に、react2angular で AngularJS のコンポーネントに変換します。Props として第 2 引数に phone を渡すのと、注入するために第 3 引数に $filter 渡す必要があります。

angular
  .module('phoneDetail')
  .component('specification', react2angular(Specifiction, ['phone'], ['$filter']));

export default Specifiction;

angular .module('phoneDetail') .component('specification', react2angular(Specifiction,['phone'],['$filter']));


`app/main.ts` で追加したモジュールを import しましょう。

```diff
+ import './phone-detail/Specifiction';

app/phone-detail/phone-detail.template.html では元の HTML を Specification コンポーネントを使用するように置き換えます。

<specification ng-if="$ctrl.phone.$resolved" phone="$ctrl.phone"></specification> 

$ctrl.phone は AngularJS の resource による API コールが完了するまで期待して Phone 型が代入されていないので、$ctrl.phone.$resolvedtrue になり API コールが完了するまで ng-if で表示を制御しています。

ここまで完了したら、E2E テストを実行して何かを壊してしまっていないか確認してみましょう。

npm run e2e

ここまでのコミットは以下のとおりです。

checkmark サービスを置き換える

React コンポーネントに移行したものの、依然として $filter という AngularJS への依存が残っています。この箇所を AngularJS に依存しない形に置き換えていきましょう。

app/core/checkmark/checkmark.ts ファイルを作成します。

export const checkmark = (input: boolean) => {
  return input ? '\u2713' : '\u2718';
};

ファイルの中身としては、単純に app/core/checkmark/checkmark.filter.js の関数を抜粋したものとなっています。AngularJS のフィルターサービスとして関数を登録する代わりにモジュールとして関数を公開するようにしました。

テストコードも飽きましょう。app/core/checkmark/checmark.spec.ts ファイル作成します。

import { checkmark } from './checkmark';
 
describe('checkmark', () => {
  it('should convert boolean values to unicode checkmark or cross', () => {
    expect(checkmark(true)).toBe('\u2713');
    expect(checkmark(false)).toBe('\u2718');
  });
});

checkmark は純粋な関数ですので、難しいことを考える必要なくテストが書けますね。

それでは、app/phone-details/Specification.tsxfilter を注入する代わりに、作成した checkmark 関数を import して利用するように修正しましょう。

  import angular from 'angular';
  import React from 'react';
  import { react2angular } from 'react2angular';
  import { PhoneDetail } from './types';
+ import { checkmark } from '../core/checkmark/checkmark';
 
  type Props = {
    phone: PhoneDetail;
-   $filter: ng.IFilterService;
  };
 
- const Specifiction: React.FC<Props> = ({ phone, $filter }) => {
+ const Specifiction: React.FC<Props> = ({ phone, $filter }) => {
 
-   const checkmark = $filter('checkmark') as (input: boolean) => string;
 
    return (
      <ul className="specs">
        {/* ... */}
      </ul>
    )
  };
 
  export default Specifiction;
 
  angular
    .module('phoneDetail')
-   .component('specification', react2angular(Specifiction, ['phone'], ['$filter']));
+   .component('specification', react2angular(Specifiction, ['phone']));

修正前後で見た目に変化はないか確認しておきましょう。さらに、checkmark ファイルターが他の場所でも使われていないかどうか確認し、もう使われないことがわかったらファイルを削除してしまいましょう。

rm app/core/checkmark/checkmark.filter.js
rm app/core/checkmark/checkmark.filter.spec.js

app/main.ts の import も削除します。

- import './core/checkmark/checkmark.filter';

最後に e2e テストを実行して確認しましょう。

npm run e2e

ここまでのコミットログは以下のとおりです。

PhoneImages コンポーネント

続いて、サムネイル一覧を表示している箇所を React コンポーネントに置き換えます。テンプレートでは以下の部分です。

<div class="phone-images">
  <img ng-src="{{img}}" class="phone"
      ng-class="{selected: img === $ctrl.mainImageUrl}"
      ng-repeat="img in $ctrl.phone.images" />
</div>
 
<h1>{{$ctrl.phone.name}}</h1>
 
<p>{{$ctrl.phone.description}}</p>
 
<ul class="phone-thumbs">
  <li ng-repeat="img in $ctrl.phone.images">
    <img ng-src="{{img}}" ng-click="$ctrl.setImage(img)" />
  </li>
</ul>

<div class="phone-images"> はメインとなる画像を表示している箇所です。ここでは ng-repeat のループ処理で電話の画像をすべて表示し、現在選択されている画像($ctrl.mainImageUrl)と一致する場合には .selected クラスを付与して表示されるようにしています。

すべての画像を表示しているにはアニメーション処理を行うためです。.selected クラスが要素に追加されると、AngularJS にアニメーションを起動するよう合図します。.selected クラスが要素から削除されるとクラスが要素に適用され、別のアニメーションが開始されます。

アニメーションを実現しているコードは app/app.animations.js にあります。ここでは ngAnimate モジュールによる animation() メソッドでアニメーションを登録しています。React コンポーネントに置き換えるとこの animation() メソッドは使えなくなるので他の方法でアニメーションを実現する必要があります。

'use strict';
 
angular.module('phonecatApp').animation('.phone', function phoneAnimationFactory() {
  return {
    addClass: animateIn,
    removeClass: animateOut
  };
 
  function animateIn(element, className, done) {
    if (className !== 'selected') return;
 
    element
      .css({
        display: 'block',
        position: 'absolute',
        top: 500,
        left: 0
      })
      .animate(
        {
          top: 0
        },
        done
      );
 
    return function animateInEnd(wasCanceled) {
      if (wasCanceled) element.stop();
    };
  }
 
  function animateOut(element, className, done) {
    if (className !== 'selected') return;
 
    element
      .css({
        position: 'absolute',
        top: 0,
        left: 0
      })
      .animate(
        {
          top: -500
        },
        done
      );
 
    return function animateOutEnd(wasCanceled) {
      if (wasCanceled) element.stop();
    };
  }
});

詳しい説明は省きますが、animateIn 関数は .selected クラスが付与された際のアニメーションを、animateOut 関数は .selected クラスが削除されたときのアニメーションを jQuery の animate() メソッドで定義しています。

続いて <ul class="phone-thumbs"> はサムネイル画像の一覧を ng-repeat で表示しています。サムネイル画像がクリックされたとき AngularJS のイベントハンドラである ng-click が発火し、$ctrl.setImage 関数で現在選択されている画像を更新します。

概要を把握したところで React コンポーネントを作成しましょう。まずはアニメーションを再現するために使用するライブラリをインストールします。Framer Motion はたくさんの API が提供されており、基本的なアニメーションを簡単に実装できます。

npm i framer-motion@^6.5.1

続いて app/phone-detail/PhoneImages.tsx ファイルを作成します。

import angular from 'angular';
import React from 'react';
import { react2angular } from 'react2angular';
import { PhoneDetail } from './types';
import { motion, AnimatePresence } from 'framer-motion';
 
type Props = {
  phone: PhoneDetail;
  mainImageUrl: string;
  setImage: (imageUrl: string) => void;
};
 
const PhoneImages: React.FC<Props> = ({ phone, mainImageUrl, setImage }) => {
  return (
    <>
      <div className="phone-images">
        <AnimatePresence initial={false}>
          <motion.img
            src={mainImageUrl}
            key={mainImageUrl}
            className="phone selected"
            data-testid="main-image"
            transition={{ duration: 0.5 }}
            style={{ display: 'block', position: 'absolute', top: 500, left: 0 }}
            animate={{ top: 0 }}
            exit={{
              top: -500
            }}
          />
        </AnimatePresence>
      </div>
 
      <h1>{phone.name}</h1>
 
      <p>{phone.description}</p>
 
      <ul className="phone-thumbs">
        {phone.images.map((image) => (
          <li key={image}>
            <img key={image} src={image} onClick={() => setImage(image)} />
          </li>
        ))}
      </ul>
    </>
  );
};
 
export default PhoneImages;
 
angular
  .module('phoneDetail')
  .component('phoneImages', react2angular(PhoneImages, ['phone', 'mainImageUrl', 'setImage']));
 

メイン画像を表示している箇所は大きく変更しています。ng-repeat すべての画像を表示する代わりに AnimatePresence コンポーネントでアニメーション対象の要素をラップしています。AnimatePresence はコンポーネントが React ツリーから取り除かれる時にアニメーションを有効にします。React ではコンポーネントのキーを変更すると全く新しいコンポーネントとして扱われるため、AnimatePresence の子要素のキーを変更することで、元のスライドショーのようなアニメーションを簡単に作ることができます。initial Props に false を渡すことで初回のみアニメーションを無効にできます。

アニメーション対象の要素は <motion> コンポーネントを使用します。<motion> コンポーネントにアニメーション用の Props を渡すことでアニメーションを実装できます。

  • animate:アニメーション
  • transition:トランジションのプロパティ
  • exit:コンポーネントが取り除かれる時のアニメーション
<div className="phone-images">
  <AnimatePresence initial={false}>
    <motion.img
      src={mainImageUrl}
      key={mainImageUrl}
      className="phone selected"
      data-testid="main-image"
      style={{ display: 'block', position: 'absolute', top: 500, left: 0 }}
      animate={{ top: 0 }}
      exit={{
        top: -500
      }}
      transition={{ duration: 0.5 }}
    />
  </AnimatePresence>
</div>

サムネイル一覧では PropssetImage 関数を呼び出すようにしています。setImage` Props は AngularJS のコントローラーの関数が渡される想定です。

<ul className="phone-thumbs">
  {phone.images.map((image) => (
    <li key={image}>
      <img key={image} src={image} onClick={() => setImage(image)} />
    </li>
  ))}
</ul>

最後に、いつものとおり react2angular で AngularJS のコンポーネントに変換します。

angular
  .module('phoneDetail')
  .component('phoneImages', react2angular(PhoneImages, ['phone', 'mainImageUrl', 'setImage']));

app/main.ts に import を追加してこのコンポーネントを使用できるようにしましょう。

+ import './phone-detail/PhoneImags';

テンプレートも phoneImages コンポーネントを使用するように置き換えます。

- <div class="phone-images">
-   <img ng-src="{{img}}" class="phone"
-       ng-class="{selected: img === $ctrl.mainImageUrl}"
-       ng-repeat="img in $ctrl.phone.images" />
- </div>
- 
- <h1>{{$ctrl.phone.name}}</h1>
- 
- <p>{{$ctrl.phone.description}}</p>
- 
- <ul class="phone-thumbs">
-   <li ng-repeat="img in $ctrl.phone.images">
-     <img ng-src="{{img}}" ng-click="$ctrl.setImage(img)" />
-   </li>
- </ul>
 
+ <phone-images ng-if="$ctrl.phone.$resolved" phone="$ctrl.phone" main-image-url="$ctrl.mainImageUrl"
+   set-image="$ctrl.setImage">
+ </phone-images>

ここまでの作業が完了したら一度動作を確認してみましょう。実際に試してみると正しく描画はされているのですが、サムネイルをクリックしてみても画像が切り替わりません。setImage 関数に console.log を仕込んでみると正しく呼ばれているはずなのですが、なぜ画像が切り替わらないのでしょうか?

setImage not working

これは、AngularJS の管轄外で mainImageUrl を更新したためです。AngularJS は内部でコントローラーの保持する変数を監視して、その変更に応じてビューを更新する手続きをとっていましたが、setImage 関数は React コンポーネント内で呼ばれているため AngularJS はその変更を監視できていませんでした。

これを修正するためには、setImage 関数を呼び出したときにビューを強制的に更新する必要があります。強制的にビューを変更するにはよく $timeout サービスが使われます。$timeout サービスは setTimeout のラッパーなのですが、コールバック関数内で行われた変更は AngularJS に伝えられます。

app/phone-detail/phone-detail-component.js を修正して $timeout サービスを使うように修正しましょう。

  angular.module('phoneDetail').component('phoneDetail', {
    template,
     controller: [
+     '$timeout',
      '$routeParams',
      'Phone',
-     function PhoneDetailController($routeParams, Phone) {
+     function PhoneDetailController($timeout, $routeParams, Phone) {
        var self = this;
        self.phone = Phone.get({ phoneId: $routeParams.phoneId }, function (phone) {
+         self.setImage(phone.images[0]);          
-         self.mainImageUrl = phone.images[0];
        });
 
        self.setImage = function setImage(imageUrl) {
-         self.mainImageUrl = imageUrl;
+         $timeout(function () {
+           self.mainImageUrl = imageUrl;
+         });
        };
      }
    ]
  });

アノテーションに $timeout サービスを追加して、$timeout サービスを使用することを伝えます。コントローラーの引数で $timeout サービスを受け取りましょう。後は setImage 関数内で self.mainImageUrl に代入する箇所を $timeout でラップするだけど OK です。なお API コールが完了した後に setImage 関数を呼び出す箇所がありますが、ここでは即座に変更を反映してほしいので setImage 関数を使わないように修正しています。

実際に動かして確認してみましょう。正しく画像の選択とアニメーションが機能しているはずです。

setImage

e2e テストを実行する前にテストの内容を少し修正します。サムネイルをクリックしたときのテストにおいてアニメーションが完了するまでの間 .selected が付与される要素が、新たに追加される要素と削除される要素の 2 つになってしまったので、クリックしてからアニメーションの完了を待つようにします。

  test('should swap main image if a thumbnail is clicked', async ({ page }) => {
    const mainImage = page.locator('img.selected');
    const thumbnails = page.locator('role=listitem').locator('role=img');
 
    await thumbnails.nth(2).click();
+   await page.waitForTimeout(1000);
    expect(mainImage).toHaveAttribute('src', 'img/phones/nexus-s.2.jpg');
 
    await thumbnails.first().click();
+   await page.waitForTimeout(1000);
    expect(mainImage).toHaveAttribute('src', 'img/phones/nexus-s.0.jpg');
  });

修正が完了したら、e2e テストを実行して問題がないか確認しましょう。

npm run e2e

PhoneImags コンポーネントのテスト

コンポーネントのテストも作成しましょう。メイン画像に mainImageUrl Props で渡した値を使用しているか、サムネイル画像がクリックしたとき setImage Props が正しく呼ばれるかをテストします。

app/phone-detail/PhoneImages.spec.tsx ファイルを作成します。

import React from 'react';
import { render, screen, within } from '@testing-library/react';
import 'angular';
import './phone-detail.module';
import PhoneImages from './PhoneImags';
import nexusS from '../phones/nexus-s.json';
import userEvent from '@testing-library/user-event';
 
describe('PhoneImages', () => {
  it('should display the `mainImageUrl` props as the main phone image', () => {
    const setImage = jest.fn();
    render(
      <PhoneImages phone={nexusS} mainImageUrl="img/phones/nexus-s.0.jpg" setImage={setImage} />
    );
 
    expect(screen.getByTestId('main-image')).toHaveAttribute('src', 'img/phones/nexus-s.0.jpg');
  });
 
  it('should call `setImage` when an image is clicked', () => {
    const setImage = jest.fn();
    render(
      <PhoneImages phone={nexusS} mainImageUrl="img/phones/nexus-s.1.jpg" setImage={setImage} />
    );
 
    const thumbnails = screen.getAllByRole('listitem');
    userEvent.click(within(thumbnails[1]).getByRole('img'));
 
    expect(setImage).toHaveBeenCalledWith('img/phones/nexus-s.1.jpg');
  });
});

1 つ目のテストでは data-testid 属性を指定してメイン画像要素を取得しています。取得した要素に対して toHaveAttributesrc 属性に Props で渡した mainImageUrl が設定されているか確認しています。

expect(screen.getByTestId('main-image')).toHaveAttribute('src', 'img/phones/nexus-s.0.jpg');

1 つ目のテストは listitem ロールからサムネイル一覧を取得しています。within(thumbnails[1]).getByRole('img') で 2 つ目のリスト要素の子要素となる画像を取得して、 userEvent.click() でクリックしています。画像をクリックした後 setImage Props がクリックした画像の URL を引数に呼ばれるかを検証しています。

const thumbnails = screen.getAllByRole('listitem');
userEvent.click(within(thumbnails[1]).getByRole('img'));
 
expect(setImage).toHaveBeenCalledWith('img/phones/nexus-s.1.jpg');

テストコードの作成に完了したら、単体テストを実行しましょう。

npm run test

すべてのテストが問題なく PASS しているかと思います。

ここまでのコミットログは以下のとおりです。

PhoneDetail コンポーネント

AngularJS の phoneDetail コンポーネントのテンプレートの 2 箇所を React コンポーネントに置き換えてきました。最後に phoneDetail コンポーネント自体を React コンポーネントに置き換えましょう。

まずは $routeParams サービスをコンポーネントに注入する必要があるので、型定義をインストールしておきます。

npm install --save-dev @types/angular-route

app/phone-detail/PhoneDetail.tsx ファイルを作成します。

import angular from 'angular';
import React, { useEffect, useState } from 'react';
import { react2angular } from 'react2angular';
import PhoneImages from './PhoneImags';
import Specifiction from './Specifiction';
import { PhoneDetail } from './types';
 
type Props = {
  Phone: ng.resource.IResourceClass<PhoneDetail>;
  $routeParams: ng.route.IRouteParamsService;
};
 
const PhoneDetail: React.FC<Props> = ({ Phone, $routeParams }) => {
  const [phone, setPhone] = useState<PhoneDetail | null>(null);
  const [mainImageUrl, setMainImageUrl] = useState('');
 
  useEffect(() => {
    let igonre = false;
    Phone.get({ phoneId: $routeParams.phoneId }, (result: PhoneDetail) => {
      console.log({ result });
      if (!igonre) {
        setPhone(result);
        setMainImageUrl(result.images[0]);
      }
    });
    return () => {
      igonre = true;
    };
  }, [Phone, setPhone, $routeParams, setMainImageUrl]);
 
  if (!phone || !mainImageUrl) {
    return null;
  }
 
  return (
    <>
      <PhoneImages phone={phone} mainImageUrl={mainImageUrl} setImage={setMainImageUrl} />
 
      <Specifiction phone={phone} />
    </>
  );
};
 
export default PhoneDetail;
 
angular
  .module('phoneDetail')
  .component('phoneDetail', react2angular(PhoneDetail, [], ['Phone', '$routeParams']));

Props で Phone resource サービスと $routeParams サービスを注入します。

type Props = {
  Phone: ng.resource.IResourceClass<PhoneDetail>;
  $routeParams: ng.route.IRouteParamsService;
};

this.phonethis.mainImageUrl の代わりに useStatephonemainImageUrl を状態として保持します。useEffect 内で Phone resource サービスを利用して電話詳細情報を取得します。API コールが完了したら Phone.get の第 2 引数のコールバック関数が呼ばれるので、電話詳細情報と 1 つ目の画像をセットします。

const [phone, setPhone] = useState<PhoneDetail | null>(null);
const [mainImageUrl, setMainImageUrl] = useState('');
 
useEffect(() => {
  let igonre = false;
  Phone.get({ phoneId: $routeParams.phoneId }, (result: PhoneDetail) => {
    if (!igonre) {
      setPhone(result);
      setMainImageUrl(result.images[0]);
    }
  });
  return () => {
    igonre = true;
  };
}, [Phone, setPhone, $routeParams, setMainImageUrl]);

API コールが完了して電話詳細情報とメイン画像が設定されるまでは null を返却して何も描画されないようにしています。

if (!phone || !mainImageUrl) {
  return null;
}

最後に今まで作成したコンポーネントを描画しています。AngularJS のコンポーネントではケパブケース main-image-url でしたが、React コンポーネントではキャメルケース mainImageUrl を使用することに注意してください。

return (
  <>
    <PhoneImages phone={phone} mainImageUrl={mainImageUrl} setImage={setMainImageUrl} />
 
    <Specifiction phone={phone} />
  </>
);

いつものとおり、作成した React コンポーネントを react2angular で AngularJS のコンポーネントに変換します。第 3 引数で Phone サービスと $routeParams サービスを注入しています。

angular
  .module('phoneDetail')
  .component('phoneDetail', react2angular(PhoneDetail, [], ['Phone', '$routeParams']));

app/main.tsPhoneDetail コンポーネントを import します。AnguarJS の phoneDetail コンポーネントの import は削除しておきましょう。

+ import './phone-detail/PhoneDetail';
- import './phone-detail/phone-detail.component';
- import './phone-detail/Specifiction';
- import './phone-detail/PhoneImags';

不要なファイルも削除しましょう。

rm app/phone-detail/phone-detail.compoment.js
rm app/phone-detail/phone-detail.compoment.spec.js
rm app/phone-detail/phone-detail.template.html

phoneList コンポーネントのときと同じく、app/app.config.js のルーティングは同じなめのコンポーネントで作成しているので修正は不要です。開発サーバーで問題なく動作しているか確認しましょう。

phone-detail

e2e テストも実行します。

npm run e2e

PhoneDetail コンポーネントのテスト

いつもどおり、コンポーネントのテストも作成しておきましょう。以下の観点のテストを作成します。

  • $routeParamsphoneId の値を利用して電話詳細情報を取得できる
  • メイン画像に電話詳細情報の 1 番目の画像が設定される
  • サムネイル画像をクリックしたらメイン画像が置き換わる

app/phone-detail/PhoneDetail.spec.tsx ファイルを作成します。

import React from 'react';
import { act, render, screen, waitFor, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import angular from 'angular';
import 'angular-resource';
import 'angular-route';
import 'angular-mocks';
import './phone-detail.module';
import '../core/phone/phone.module';
import PhoneDetail from './PhoneDetail';
import nexusS from '../phones/nexus-s.json';
import { PhoneDetail as PhoneDetailType } from './types';
 
describe('PhoneList', () => {
  let Phone: ng.resource.IResourceClass<PhoneDetailType>;
  let $httpBackend: ng.IHttpBackendService;
  let $routeParams: ng.route.IRouteParamsService;
 
  beforeEach(() => {
    angular.mock.module('phoneDetail');
    angular.mock.inject(($resource, _$httpBackend_, _$routeParams_) => {
      Phone = $resource(
        'phones/:phoneId.json',
        {},
        {
          query: {
            method: 'GET',
            params: { phoneId: 'phones' },
            isArray: true
          }
        }
      );
 
      $httpBackend = _$httpBackend_;
      $httpBackend.expectGET('phones/nexus-s.json').respond(nexusS);
 
      $routeParams = _$routeParams_;
      $routeParams.phoneId = 'nexus-s';
    });
  });
 
  it('should fetch the `nexus-s`', async () => {
    render(<PhoneDetail Phone={Phone} $routeParams={$routeParams} />);
 
    act(() => {
      $httpBackend.flush();
    });
 
    expect(screen.getByRole('heading')).toHaveTextContent('Nexus S');
  });
 
  it('should display the first phone image as the main phone image', async () => {
    render(<PhoneDetail Phone={Phone} $routeParams={$routeParams} />);
 
    act(() => {
      $httpBackend.flush();
    });
 
    expect(screen.getByTestId('main-image')).toHaveAttribute('src', 'img/phones/nexus-s.0.jpg');
  });
 
  it('should swap main image if a thumbnail is clicked', () => {
    render(<PhoneDetail Phone={Phone} $routeParams={$routeParams} />);
 
    act(() => {
      $httpBackend.flush();
    });
 
    const thumbnails = screen.getAllByRole('listitem');
    userEvent.click(within(thumbnails[2]).getByRole('img'));
 
    waitFor(() =>
      expect(screen.getByTestId('main-image')).toHaveAttribute('src', 'img/phones/nexus-s.2.jpg')
    );
  });
});

PhoneList コンポーネン同様に Props として Phone resource サービスを渡す必要があるので、anguar-mocks を利用し beforeEach 内で作成しています。API をモックするために $httpBackend も用意しています。

ルーティングのパラメータのモックである $routeParams も用意して、phoneId パラメータを 'nexus-s' で固定しています。

describe('PhoneList', () => {
  let Phone: ng.resource.IResourceClass<PhoneDetailType>;
  let $httpBackend: ng.IHttpBackendService;
  let $routeParams: ng.route.IRouteParamsService;
 
  beforeEach(() => {
    angular.mock.module('phoneDetail');
    angular.mock.inject(($resource, _$httpBackend_, _$routeParams_) => {
      Phone = $resource(
        'phones/:phoneId.json',
        {},
        {
          query: {
            method: 'GET',
            params: { phoneId: 'phones' },
            isArray: true
          }
        }
      );
 
      $httpBackend = _$httpBackend_;
      $httpBackend.expectGET('phones/nexus-s.json').respond(nexusS);
 
      $routeParams = _$routeParams_;
      $routeParams.phoneId = 'nexus-s';
    });
  });

1 つ目のテストでは、ルートパラメータが 'nexus-s' であるため「Nexus S」の情報が取得されるかどうかを検証します。正しく「Nexus S」をフェッチできていれば、ヘディング要素に 'Nexus S' と表示されるはずです。

$httpBackend でモックしたレスポンスを返却するために act 内で $httpBackend.flush() メソッドを呼び出します。

 
it('should fetch the `nexus-s`', async () => {
  render(<PhoneDetail Phone={Phone} $routeParams={$routeParams} />);
 
  act(() => {
    $httpBackend.flush();
  });
 
  expect(screen.getByRole('heading')).toHaveTextContent('Nexus S');
});

2 つ目のテストはメイン画像に 1 つ目の画像が設定されているか確認します。1 つ目のテストと大きく変わりません。モックしたレスポンスを返却した後、data-testid 属性からメイン画像要素を取得して src 属性を検証します。

it('should display the first phone image as the main phone image', async () => {
  render(<PhoneDetail Phone={Phone} $routeParams={$routeParams} />);
 
  act(() => {
    $httpBackend.flush();
  });
 
  expect(screen.getByTestId('main-image')).toHaveAttribute('src', 'img/phones/nexus-s.0.jpg');
});

3 つ目のテストではサムネイルをクリックしたとき、メイン画像が置き換わるかどうかの検証です。3 つ目のサムネイル画像を useEvent.click() でクリックした後、waitFor でアニメーションの完了を待ってからメイン画像の src 属性を検証しています。

it('should swap main image if a thumbnail is clicked', () => {
  render(<PhoneDetail Phone={Phone} $routeParams={$routeParams} />);
 
  act(() => {
    $httpBackend.flush();
  });
 
  const thumbnails = screen.getAllByRole('listitem');
  userEvent.click(within(thumbnails[2]).getByRole('img'));
 
  waitFor(() =>
    expect(screen.getByTestId('main-image')).toHaveAttribute('src', 'img/phones/nexus-s.2.jpg')
  );
});

テストコードの作成に完了したら、テストを実行して確認しましょう。

npm run test

ここまでのコミットログは以下のとおりです。

Phone サービスを置き換える

実は今までの作業で AngularJS のコンポーネントはすべて React コンポーネントへのリプレイスが完了していました!残りの AngularJS のコードはもう少しです!

ここからは API クライアントである angular-resource を置き換えていきます。まずはじめに fetch をラップして使いやすくする fetcher 関数を作成します。fetcher 関数はそれぞれの API クライアントで使用されます。app/core/fetcher/index.ts ファイルを作成します。

export const fetcher = async <T>(input: RequestInfo, init?: RequestInit): Promise<T> => {
  const response = await fetch(input, init);
 
  if (!response.ok) {
    throw new Error(`${response.status} ${response.statusText}`);
  }
 
  return response.json();
};

API クライアントのライブラリとして SWR を使用します。SWR はデータフェッチのための React Hooks ライブラリです。“SWR” という名前は、 HTTP RFC 5861 で提唱された HTTP キャッシュ無効化戦略である stale-while-revalidate に由来しています。

SWR は useSWR フックに key 文字列と fetcher 関数を受け取ります。key はデータの一意な識別子で、fetcher はデータを返す非同期関数です。このフックはリクエストの状態に応じで以下のように dataerror の 2 つの値を返します。

import useSWR from 'swr'
 
function Profile() {
  const { data, error } = useSWR('/api/user', fetcher)
 
  if (error) return <div>failed to load</div>
  if (!data) return <div>loading...</div>
  return <div>hello {data.name}!</div>
}

このように React におけるデータ取得ロジックを単純化して書くことができるのが特徴です。今回は各ディレクトリ配下に useSWR をラップした API クライアントを定義して使用する方針で利用します。

PhoneList コンポーネントの修正

app/phone-list/userPhones.ts ファイルを作成します。

import useSWR from 'swr';
import { Phone } from './types';
import { fetcher } from '../core/fetcher';
 
const getPhones = async (): Promise<Phone[]> => {
  return await fetcher<Phone[]>('phones/phones.json');
};
 
type UsePhonesReturn = {
  phones: Phone[];
  isLoading: boolean;
  error: Error | undefined;
}
 
const usePhones = (): UsePhonesReturn => {
  const { data, error } = useSWR<Phone[]>('phones/phones.json', getPhones);
  return {
    phones: data ?? [],
    isLoading: !error && !data,
    error
  };
};
 
export default usePhones;

getPhones 関数は useSWR に渡す fetchr 関数です。ここは fetch を利用すると大きくやることは変わりません。usePhones 関数内で useSWR をラップして再利用可能なフックを作成しています。

作成した usePhones 関数をコンポーネント内で使用するように修正しましょう。

  import angular from 'angular';
- import React, { useState } from 'react';
+ import React, { useStaet, useEffect } from 'react'
  import { react2angular } from 'react2angular';
+ import usePhones from './usePhones';
  import PhoneItems from './PhoneItems';
 
- type Props = {
-   Phone: ng.resource.IResourceClass<Phone>;
- };
 
- const PhoneList: React.FC<Props> => ({ Phone }) => {
+ const PhoneList: React.FC = () => {
-   const [phones, setPhones] = useState<Phone[]>([]);
+   const { phones } = usePhones();
    const [query, setQuery] = useState('');
    const [orderProp, setOrderProp] = useState<'name' | 'age'>('age');
 
-  useEffect(() => {
-   let igonre = false;
-   Phone.query().$promise.then((result) => {
-     if (!igonre) {
-       setPhones(result);
-     }
-   });
-   return () => {
-     igonre = true;
-   };
- }, [Phone, setPhones]);
 
    return (
      {/* ... */}
    );
  };
 
  export default PhoneList;
 
- angular.module('phoneList').component('phoneList', react2angular(PhoneList, [], ['Phone']));
+ angular.module('phoneList').component('phoneList', react2angular(PhoneList, []));

Phone resource サービスをもはや注入する必要がなくなったので、Props を削除しています。phones の状態管理は const { phones } = usePhones() の 1 行だけになり、煩雑な useEffect のコードが不要になったことが見てわかるかと思います。react2angular で AngularJS コンポーネントに変換する際に、Phone resource サービスを注入しないよう第 3 引数を削除しました。

テストコードである app/phone-list/PhoneList.spec.tsx ファイル内で Phone を Props で渡しているのでこの箇所が肩エラーとなってしまい、これが原因で開発サーバーを起動時にエラーとなってしまいます。後ほどテストコードを修正するので、ここはいったん Phone を Props として渡している箇所を削除しておきましょう。

- render(<PhoneList Phone={Phone} />);
+ render(<PhoneList />);

開発サーバーで確認してみると、変わらず動作していることが確認できるかと思います。E2E テストでも確認してみましょう。

npm run e2e

E2E テストも問題なく PASS していることが確認できるかと思います。

PhoneList コンポーネントのテストの修正

E2E テストは問題なかったのですが、残念なことに PhoneList コンポーネントのテストは失敗していまいます。

npm run test
 
Test Suites: 1 failed, 5 passed, 6 total
Tests:       3 failed, 10 passed, 13 total
Snapshots:   0 total
Time:        23.607 s

このテストコードは Phone resource サービスを注入するために AngularJS に依存していたところがあったので、ある程度仕方のないことでしょう。ここは、特定のライブラリ・HTTP クライアントに依存しないテストコードに修正して、より安定したテストコードにしましょう。

特定のライブラリ・HTTP クライアントに依存しない方法として、Mock Service Worker(msw)が最適です。msw はサービスワーカーレベルでリクエストをインターセプトしてリクエストを返却するという特徴があります。例えば fetchaxios など特定の HTTP クライアントに対してモックしないので、抽象度の高いテストコードを作成できます。まずはパッケージをインストールしましょう。

npm install --save-dev msw

app/mocks ディレクトリを作成して、その中にモックサーバーを実装します。app/mocks/resolvers/phones.ts では実際にモックサーバーがどのような値を返すのかを実装します。

import { ResponseResolver, RestContext, RestRequest } from 'msw';
import phones from '../../phones/phones.json';
import { Phone } from '../../phone-list/types';
 
export const phoneList: ResponseResolver<RestRequest, RestContext> = (req, res, ctx) => {
  return res(ctx.status(200), ctx.json<Phone[]>(phones));
};

モックサーバーの実装は Express に近い書き方ができます。引数で req, res, ctx を受け取り、resreturn して値を返却します。JSON をレスポンスとして返却する場合には ctx.json() メソッドを使用します。

app/mocks/handlers.ts ではリクエストメソッド・パスをモックサーバーの実装に紐付けます。

import { rest } from 'msw'
import { phoneList } from './resolvers/phones'
 
export const handlers = [
  rest.get('phones/phones.json', phoneList),
]

app/mocks/server.tssetupServer を呼び出してテスト用のモックサーバーを作成します。

import { setupServer } from "msw/node";
import { handlers } from "./handlers";
 
export const server = setupServer(...handlers)

Jest のセットアップを行う jest-setup.js でテスト時にモックサーバーが起動するようにしましょう。

import '@testing-library/jest-dom';
import { server } from './app/mocks/server.ts';
 
beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
 
global.jasmine = true;

これで msw の準備は整いました。もう 1 つ、SWR をテストで使うために修正が必要です。SWR はリクエストをキャッシュするので、各テスト間で独立したテストでなくなってしまいます。そのため、SWRConfig コンポーネントでキャッシュをさせない処理を入れます。この修正のために、render 関数ですべてのコンポーネントを SWRConfig でラップする必要があるのでカスタムレンダーを使用するようにします。

app/test-utils.tsx ファイルを作成します。

import React, { ReactNode, ReactElement } from 'react';
import { render, RenderOptions } from '@testing-library/react';
import { SWRConfig } from 'swr';
 
const Wrapper = ({ children }: { children: ReactNode }) => {
  return <SWRConfig value={{ dedupingInterval: 0 }}>{children}</SWRConfig>;
};
 
const customRender = (ui: ReactElement, options?: Omit<RenderOptions, 'wrapper'>) =>
  render(ui, { wrapper: Wrapper, ...options });
 
export * from '@testing-library/react';
 
export { customRender as render };

テストコードでは @testing-libary/react の代わりに test-utils を import するようにします。

- import { render, screen, waitFor } from '@testing-library/react';
+ import { render, screen, waitFor } from '../test-utils';
import { cache } from "swr";
 
afterEach(() => {
  cache.clear();
});

またテストが実行される環境は Node.js なのですが fetch が使用できるのは Node.js 18 以降に限ります。そのため、fetch の polyfill が必要となります。

npm install --save-dev whatwg-fetch

whatwg-fetchjest.setup.js 内で import することで fetch が使えるようになります。

import 'whatwg-fetch';

テストコードを修正しましょう。

はじめに、beforeEach で行っていたモック処理はすべて削除してしまいましょう。

- import 'angular-resource';
- import 'angular-mocks';
- import '../core/phone/phone.module';
- import phones from '../phones/phones.json
- import { Phone } from './types';
 
  describe('PhoneList', () => {
-   let Phone: ng.resource.IResourceClass<Phone>;
-   let $httpBackend: ng.IHttpBackendService;
-
-   beforeEach(() => {
-     angular.mock.module('phoneList');
-     angular.mock.inject(($resource, _$httpBackend_) => {
-       Phone = $resource(
-         'phones/:phoneId.json',
-         {},
-         {
-           query: {
-             method: 'GET',
-             params: { phoneId: 'phones' },
-             isArray: true
-           }
-         }
-       );
-
-       $httpBackend = _$httpBackend_;
-       $httpBackend.expectGET('phones/phones.json').respond(phones);
-     });
-   });

各テストに存在するモックリクエストを返却するための $httpBackend.flush() メソッドも削除します。

  it('should render phone items', async () => {
    render(<PhoneList Phone={Phone} />);
 
-   act(() => {
-     $httpBackend.flush();
-   });
    const phoneList = await screen.findAllByRole('listitem');
    expect(phoneList).toHaveLength(20);
    expect(phoneList[0]).toHaveTextContent('Motorola XOOM™ with Wi-Fi');
  });

修正箇所の大部分はモックしている箇所の削除で、テストコード自体の修正はほとんど必要ありません。再度テストを実行してみましょう。

npm run test
 
Test Suites: 6 passed, 6 total
Tests:       13 passed, 13 total
Snapshots:   0 total
Time:        18.863 s, estimated 19 s

問題なくテストが PASS しているかと思います。

ここまでのコミットログは以下のとおりです。

PhoneDetail コンポーネントの修正

同じような流れで、PhoneDetail コンポーネントも usePhone 関数利用するように修正しましょう。

app/phone-detail/usePhone.ts ファイルを作成します。

import useSWR from 'swr';
import { fetcher } from '../core/fetcher';
import { PhoneDetail } from './types';
 
const getPhoneById = async (url: string) => {
  return await fetcher<PhoneDetail>(url);
};
 
type UsePhonesReturn = {
  phone: PhoneDetail | undefined;
  isLoading: boolean;
  error: Error | undefined;
};
 
type UsePhoneParams = {
  id: string;
};
 
const usePhone = ({ id }: UsePhoneParams): UsePhonesReturn => {
  const { data, error } = useSWR<PhoneDetail>(`phones/${id}.json`, getPhoneById);
 
  return {
    phone: data,
    isLoading: !error && !data,
    error
  };
};
 
export default usePhone;

usePhone 関数は引数に phoneId を受け取ります。useSWR の引数に渡す fetcher 関数は引数に useSWR の第 1 引数である key を受け取ります。そのため key を API のパスに設定することで、fetcher 関数では引数で受けとった値をそのまま url として利用できます。

const getPhoneById = async (url: string) => {
  return await fetcher<PhoneDetail>(url);
};

PhoneDetail コンポーネント内で usePhone 関数を使用します。まずは Props から Phone resorce サービスを削除しましょう。

  type Props = {
-   Phone: ng.resource.IResourceClass<PhoneDetail>;
    $routeParams: ng.route.IRouteParamsService;
  };
 
- const PhoneDetail: React.FC<Props> = ({ Phone, $routeParams }) => {
+ const PhoneDetail: React.FC<Props> = ({ $routeParams }) => {

useStatephone の状態で管理していた箇所を usePhone に置き換えます。

- const [phone, setPhone] = useState<PhoneDetail | null>(null);
+ const { phone } = usePhone({
+   phoneId: $routeParams.phoneId
+ });
 
- useEffect(() => {
-   let igonre = false;
-   Phone.get({ phoneId: $routeParams.phoneId }, (result: PhoneDetail) => {
-     if (!igonre) {
-       setPhone(result);
-       setMainImageUrl(result.images[0]);
-     }
-   });
-   return () => {
-     igonre = true;
-   };
- }, [Phone, setPhone, $routeParams, setMainImageUrl]);

API のコールが完了したらメイン画像をセットする必要があるので、useEffectphone が取得されたタイミングに setMainImageUrl を呼び出すようにします。

  useEffect(() => {
    if (phone) {
      setMainImageUrl(phone.images[0]);
    }
  }, [phone]);

react2angular の第 3 引数から Phone resource サービスを取り除きましょう。

  angular
    .module('phoneDetail')
-   .component('phoneDetail', react2angular(PhoneDetail, [], 'Phone', '$routeParams']));
+  .component('phoneDetail', react2angular(PhoneDetail, [], ['$routeParams']));

コンポーネントの修正はこれで完了です。PhoneList コンポーネントを修正したときと同じく、テストコードで型エラーが発生しているので Phone を Props で渡している箇所を取り除きます。

- render(<PhoneDetail Phone={Phone} $routeParams={$routeParams} />);
+ render(<PhoneDetail $routeParams={$routeParams} />);

開発サーバーで詳細画面へ遷移して正しく動作しているかどうか確認してみましょう。E2E テストも実行します。

npm run e2e

E2E テストも PASS することが確認できるかと思います。

PhoneDetail コンポーネントのテストの修正

PhoneDetail コンポーネントのテストも同様に、$httpBackend モックを使わないように修正しましょう。まずはモックサーバーに詳細取得用のハンドラを用意します。app/mocks/resolvers/phone.ts ファイルを作成します。

import { ResponseResolver, RestContext, RestRequest } from 'msw';
import { PhoneDetail } from '../../phone-detail/types';
 
export const phoneDetail: ResponseResolver<RestRequest, RestContext> = async (req, res, ctx) => {
  const phone = await import(`../../phones/${req.params.phoneId}.json`);
  if (!phone) {
    return res(ctx.status(404));
  }
 
  return res(ctx.status(200), ctx.json<PhoneDetail>(phone));
};

パスパラメータは req.params.phoneId で取得できます。パスパラメータの phoneId 元に JSON ファイルを取得し、もし存在しない phoneId であった場合には 404 を返すようにしています。

app/mocks/handlers/ts で API を登録しましょう。

  import { rest } from 'msw';
+ import { phoneDetail } from './resolvers/phone';
  import { phoneList } from './resolvers/phones';
 
  export const handlers = [
    rest.get('phones/phones.json', phoneList),
+   rest.get('phones/:phoneId.json', phoneDetail)
  ];

テストコードである app/phone-detail/PhoneDetail.spec.tsx ファイルを修正します。まずは @testing-library/react の代わりにさきほど作成した test-utils を import します。

- import { act, render, screen, waitFor, within } from '@testing-library/react';
+ import { render, screen, waitFor, within } from '../test-utils';

$httpBackend 関連のモックも削除します。まだ $routeParams を利用しているので、モックを完全に取り除くことはできません。

  describe('PhoneList', () => {
-   let Phone: ng.resource.IResourceClass<PhoneDetailType>;
-   let $httpBackend: ng.IHttpBackendService;
    let $routeParams: ng.route.IRouteParamsService;
 
    beforeEach(() => {
      angular.mock.module('phoneDetail');
 -    angular.mock.inject(($resource, _$httpBackend_, _$routeParams_) => {
 +    angular.mock.inject((_$routeParams_) => {
 -      Phone = $resource(
 -        'phones/:phoneId.json',
 -        {},
 -        {
 -          query: {
 -            method: 'GET',
 -            params: { phoneId: 'phones' },
 -            isArray: true
 -          }
 -        }
 -      );
 
 -      $httpBackend = _$httpBackend_;
 -      $httpBackend.expectGET('phones/nexus-s.json').respond(nexusS);
 
        $routeParams = _$routeParams_;
        $routeParams.phoneId = 'nexus-s';
      });
    });

各テストでは $httpBackend.flush() を削除するとともに、API コールの完了を待つために getBy... クエリの代わりに await findBy... を使用するように修正します。Testing Library の getBy... クエリは要素が見つからなかったバイア即座に Error を返しますが、findBy... クエリは Promise を返し、要素が見つかったタイミングで解決します。

  it('should fetch the `nexus-s`', async () => {
    render(<PhoneDetail Phone={Phone} $routeParams={$routeParams} />);
 
-   act(() => {
-     $httpBackend.flush();
-    });
 
-   expect(screen.getByRole('heading')).toHaveTextContent('Nexus S');
+   expect(await screen.findByRole('heading')).toHaveTextContent('Nexus S');
  });
 
    it('should display the first phone image as the main phone image', async () => {
    render(<PhoneDetail Phone={Phone} $routeParams={$routeParams} />);
 
-   act(() => {
-     $httpBackend.flush();
-    });
 
-    expect(screen.getByTestId('main-image')).toHaveAttribute('src', 'img/phones/nexus-s.0.jpg');
+    expect(await screen.findByTestId('main-image')).toHaveAttribute('src', 'img/phones/nexus-s.0.jpg');
  });
 
  it('should swap main image if a thumbnail is clicked', () => {
    render(<PhoneDetail Phone={Phone} $routeParams={$routeParams} />);
 
-   act(() => {
-      $httpBackend.flush();
-    });
 
-   const thumbnails = screen.getAllByRole('listitem');
+   const thumbnails = await screen.findAllByRole('listitem');
    userEvent.click(within(thumbnails[2]).getByRole('img'));
 
    waitFor(() =>
      expect(screen.getByTestId('main-image')).toHaveAttribute('src', 'img/phones/nexus-s.2.jpg')
    );
  });

テストを実行して PASS することを確認しましょう。

npm run test

これで、Phone resource サービスへの依存は完全になくなったので、関連するファイルを削除しましょう。

rm -rf app/core/phone
rm -rf app/core/core.module.js

angular.module'core' または 'core.phone' サービスを登録していた以下のファイルもすべてのファイルも修正します。

  • app/phone-detail/phone-detail.module.js
  • app/phone-list/phone-list.module.js
  • app/app.module.js
- angular.module('phoneDetail', ['ngRoute', 'core.phone']);
+ angular.module('phoneDetail', ['ngRoute']);

また main.tsapp/phone-detail/PhoneDetail.spec.tsx 内の import も削除しましょう。

  // main.ts
- import 'angular-resource';
- import './core/core.module';
- import './core/phone/phone.module';
- import './core/phone/phone.service';
  // app/phone-detail/PhoneDetail.spec.tsx
- import '../core/phone/phone.module';

angular-resource パッケージもアンイストールします。

npm uninstall angular-resource @types/angular-resource

テストを実行してパッケージをアンイストールした後も正しく動作をするか確認します。

npm run test
npm run e2e

ここまでのコミットログは以下のとおりです。


Contributors

> GitHub で修正を提案する
この記事をシェアする
はてなブックマークに追加

関連記事