【備忘録】日本一わかりやすいReact-Redux講座 実践編 #14 「Firestoreの複合クエリで商品を条件検索しよう」【最終回】

## はじめに
### 概要
この記事は、トラハック氏([@torahack_](https://twitter.com/torahack_))が運営するYoutubeチャンネル『トラハックのエンジニア学習ゼミ【とらゼミ】』の『[日本一わかりやすいReact-Redux講座 実践編](https://www.youtube.com/watch?v=8fNgXnVTULU)』の学習備忘録です。

前回の講座で、注文履歴画面を作成しました。

今回は、クエリを利用した商品の条件検索を実装します。

今回で無料公開講座は最後になります！

※ 前回記事: [【備忘録】日本一わかりやすいReact-Redux講座 実践編 #13 「注文履歴を確認しよう〜コンポーネントの再利用〜」](https://qiita.com/ddpmntcpbr/items/cc042d8b5e856ed73327)


### 動画URL
[Firestoreの複合クエリで商品を条件検索しよう【日本一わかりやすいReact-Redux講座 実践編#14】](https://www.youtube.com/watch?v=-la-WWHtBmk)

### 要点
- Firebase Indexesで、複合クエリの条件検索を実装する。

### 完成系イメージ

**``http://localhost:3000``**

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/540216/8e91de2c-26d0-1423-665b-80a29ed24419.png)

Drawerメニューを開くと、商品カテゴリーがメニューアイテムとして追加されています。
例えば「メンズ」をクリックすると、

**``http://localhost:3000/?gender=male``**

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/540216/d7679c82-796a-88b1-a5bf-2770d1a7bf77.png)

メンズカテゴリーの商品のみがリスト表示されます。

## メイン

### categoriesコレクションの追加

``ProductEdit.jsx``内にコードで直接記述していたcategoriesをCloud Firestore上に定義し、都度データを受信して使うことにします。

Firebaseコンソールから、``categories``コレクションを追加します。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/540216/09e1bc74-37b4-7f93-b4df-9624a2b83a4d.png)

id, nameに加え、``order``というフィールドも追加し、ドキュメントごとに連続した数字を与えます。

``ProductEdit.jsx``に変更を加えます。

```jsx:src/templates/ProductEdit.jsx
.
.
.
const ProductEdit = () => {
  .
  .
  .
  const [name, setName] = useState(""),
        [description, setDescription] = useState(""),
        [category, setCategory] = useState(""),
        [categories, setCategories] = useState([]), //追記
        [gender, setGender] = useState(""),
        [images, setImages] = useState([]),
        [price, setPrice] = useState(""),
        [sizes, setSizes] = useState([]);
  .
  .
  .
  // 削除
  // const categories = [
  //   {id:"tops", name:"トップス"},
  //   {id:"shirt", name:"シャツ"},
  //   {id:"pants", name:"パンツ"}
  // ]
  // 削除ここまで
  .
  .
  .
  //追記
  useEffect(() => {
    db.collection("categories").orderBy("order","asc").get()
    .then(snapshots => {
      const list = [];
      snapshots.forEach(snapshot => {
        const data = snapshot.data();
        list.push({
          id: data.id,
          name: data.name
        })
      })
      setCategories(list)
    },[])
    //追記ここまで

  return (
    .
    .
    .
  )
}

export default ProductEdit
```

DBにcategoriesの情報を移動させ、``useEffect()``内で取得しています。

このとき、``orderBy("order","asc)``により、先ほど定義した order の順番にしたがって昇順でドキュメントを並べています。

```:firestore.rules
rules_version = '2';
service cloud.firestore {
  match /databases/{database}/documents {
    match /users/{userId} {
      allow read: if request.auth.uid != null;
      allow create;
      allow update: if request.auth.uid == userId;
      allow delete: if request.auth.uid == userId;

      match /cart/{cartId} {
        allow read,write: if request.auth.uid == userId;
      }

      match /orders/{orderId} {
        allow read,write: if request.auth.uid == userId;
      }
    }

    match /products/{productId} {
      allow read: if request.auth.uid != null;
      allow write: if request.auth.uid != null;
    }

    //追記
    match /categories/{categoryId} {
      allow read: if request.auth.uid != null;
      allow write: if request.auth.uid != null;
    }
    //追記ここまで
  }
}
```

読み書きルールを変更し、デプロイします。

```terminal:ターミナル
$ firebase deploy --only firestore:rules
```

以上で、カテゴリーの実装は完了です。

「商品登録ページ」から、これまで通りカテゴリーを選択、登録できていれば、成功です。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/540216/49e45f68-105a-7aff-5867-d714801342e3.png)


### ファイル実装

``ClosableDroawer.jsx``に、商品カテゴリーのメニューアイテムを追加し、各アイテムをクリックしたときに、

```:実装ファイル
1.src/components/Header/ClosableDrawer.jsx
2.src/templates/ProductList.jsx
3.src/reducks/products/operations.js
```


```jsx:src/components/Header/ClosableDrawer.jsx
import React, {useCallback, useState,useEffect} from "react"; // useEffectを追加
.
.
.
const ClosableDrawer = (props) => {
  .
  .
  .
  const selectMenu = (event, path) => {
    dispatch(push(path));
    props.onClose(event)
  }
  .
  .
  .
  const [filters, setFilters] = useState([
    {func: selectMenu, label:"すべて", id:"all", value:"/"},
    {func: selectMenu, label:"メンズ", id:"male", value:"/?gender=male"},
    {func: selectMenu, label:"レディース", id:"female", value:"/?gender=female"}
  ]);
  .
  .
  .
  useEffect(()=>{
    db.collection("categories").orderBy("order","asc").get()
    .then(snapshots => {
      const list = [];
      snapshots.forEach(snapshot => {
        const category = snapshot.data();
        list.push({func: selectMenu, label: category.name, id: category.id, value: `/?category=${category.id}`})
      })
      setFilters(prevState => [...prevState, ...list])
    })
  },[])

  return (
    <nav className={classes.drawer}>
      <Drawer
        container={container}
        variant="temporary"
        anchor="right"
        open={props.open}
        onClose={(e) => props.onClose(e)}
        classes={{paper: classes.drawerPaper}}
        ModalProps={{keepMounted: true}}
      >
        <div
          onClose={(e) => props.onClose(e)}
          onKeyDown={(e) => props.onClose(e)}
        />
        <div>
          <div className={classes.searchField}>
            <TextInput
              fullWidth={false} label={"キーワードを入力"} multiline={false}
              onChange={inputKeyword} required={false} rows={1} value={keyword} type={"text"}
            />
            <IconButton>
              <SearchIcon/>
            </IconButton>
          </div>
          <Divider />
          <List>
            {menus.map(menu => (
              <ListItem button key={menu.id} onClick={(e)=>menu.func(e, menu.value)}>
                <ListItemIcon>
                  {menu.icon}
                </ListItemIcon>
                <ListItemText primary={menu.label}/>
              </ListItem>
            ))}
            <ListItem button key="logout" onClick={(e) => handleSignOut(e)}>
              <ListItemIcon>
                <ExitToAppIcon/>
              </ListItemIcon>
              <ListItemText primary={"Log out"} />
            </ListItem>
          </List>
          <Divider/>
          
          {*追記*}
          <List>
            {filters.map(filter => (
              <ListItem
                button
                key={filter.id}
                onClick={(e) => filter.func(e, filter.value)}
              >
                <ListItemText primary={filter.label} />
              </ListItem>
            ))}
          </List>
          {*追記ここまで*}

        </div>
      </Drawer>
    </nav>
  )
}

export default ClosableDrawer
```

カテゴリーをクリックすることで、URLに対してクエリーパラメータが付与されます。

```jsx:2.src/templates/ProductList.jsx
import React,{useEffect} from "react";
import {ProductCard} from "../components/Products";
import {useDispatch, useSelector} from "react-redux";
import {fetchProducts} from "../reducks/products/operations"
import {getProducts} from "../reducks/products/selectors"

const ProductList = () => {
  const dispatch = useDispatch();
  const selector = useSelector((state) => state);
  const products = getProducts(selector);
  
  const query = selector.router.location.search;
  const gender = /^\?gender=/.test(query) ? query.split('?gender=')[1] : "";
  const category = /^\?category=/.test(query) ? query.split('?category=')[1] : "";

  useEffect (() => {
    dispatch(fetchProducts(gender,category))
  },[query]);

  return (
    .
    .
    .
  )
}

export default ProductList
```

``selector.router.location.search``により、現在のURLを取得します。

正規表現を用いて、``?gender=``, ``?category=``の値を取得し、商品情報をDBから取得する operations である``fetchProducts()``へ渡しています。

```jsx:3.src/reducks/products/operations.js
.
.
.
export const fetchProducts = (gender,category) => {
  return async (dispatch) => {
    let query = productsRef.orderBy("updated_at","desc");
    query = (gender !== "") ? query.where("gender","==",gender) : query;
    query = (category !== "") ? query.where("category","==",category) : query;

    query.get()
      .then(snapshots => {
      const productList = []
      snapshots.forEach(snapshot => {
        const product = snapshot.data();
        productList.push(product)
      })

      dispatch(fetchProductsAction(productList));
      })
  }
}
.
.
.
```

``where``を用いて、クエリパラメーターと一致するドキュメントのみを取得しています。

### Firebase indexes(複合インデックス)の追加

さて、ここまででReact側の実装は終了していますが、試しに Drawerメニューから「メンズ」をクリックすると、以下のようなエラ-メッセージが、Chrome developer tool から確認されます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/540216/5bb50fc7-a937-cd47-d8b1-5c3917263f79.png)

「**indexがありません。このURLから作れるよ**」と書いてあります。

``Firebase indexes(複合インデックス)``とは、**2つ以上の条件、ソートのクエリのパフォーマンスを向上される**ための設定です。

今回の例で言うと、

1. orderBy("updated_at","desc");
2. query.where("gender","==",gender)

のところで、複数条件のソートクエリが発行されています(``categories``の方も同様です)。

さて、URLをクリックすると、**Google Cloud Platform**が開きます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/540216/ef43d087-4f96-caf0-9b09-c4f86a565b7c.png)

モーダルが開くので、そのまま「作成」をクリックすることで、複合インデックスを作成してくれます。作成が完了するまで、5~10分程度時間がかかります。

複合インデックスができた状態で、再度、「メンズ」で検索をすると、

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/540216/d7679c82-796a-88b1-a5bf-2770d1a7bf77.png)

無事、条件によるソートが実装できています！

## さいごに
今回の要点をおさらいすると、
- Firebase Indexesで、複合クエリの条件検索を実装する。

以上で、本動画講座は全て完了です！お疲れ様でした！

これ以降は、運営者の有料コミュニティ『とらゼミ』([https://www.youtube.com/watch?v=tIzE7hUDbBM&t=1s](https://www.youtube.com/watch?v=tIzE7hUDbBM&t=1s))で公開とのことですので、興味のある方は覗いてみてください(私は関係者でもなんでも無いので、ステマではないですよ笑)。

このような学習内容を日々呟いていますので、よろしければTwitter([@ddpmntcpbr](https://twitter.com/ddpmntcpbr))のフォローもよろしくお願いします。