【Rails6 + Vue.js】ブックマークを管理する簡単なSPAを作ってみた話（第三章：検索・絞り込み機能）

前回の記事の続きになります。
前回の記事はこちら↓

https://qiita.com/showy/items/5bead2820923ce7ce80a

## 本記事の概要

### 完成版のイメージ
今回実装するフリーワード検索、カテゴリー別絞り込み機能の完成イメージです。

[![Image from Gyazo](https://i.gyazo.com/8f081530d83bfd6b7bd86069b12cb8bf.gif)](https://gyazo.com/8f081530d83bfd6b7bd86069b12cb8bf)

### 今回実装する機能について

【フリーワード検索機能】

- フォームに文字を入力すると対象の文字に一致するタイトル、カテゴリーを検索し、ヒットしたブックマークを非同期で表示します。

【カテゴリー別絞り込み機能】

- カテゴリーを選択するとそのカテゴリーで登録されているブックマークを表示します。
- 新しいカテゴリーが追加された場合は自動的に選択肢にも追加されます。
- フリーワード検索機能との併用も可能にします。

【登録したURLへのアクセス機能】
前回までの記事で実装していなかったため今回の記事で実装します。

- 新規投稿時に登録したURLへ新規タブでアクセスできるようにします。
- 新規タブを開いてリンクする際のセキュリティ上の脆弱性についての対策も施します。

### 記事構成
本アプリケーションについての記事は大きく4部構成で作成しております。

- [第一章：アプリケーション作成〜ダミーデータ表示](https://qiita.com/showy/items/e0ed4b11cca5eb6f9944)
- [第二章：ブックマークのCRUD実装](https://qiita.com/showy/items/5bead2820923ce7ce80a)
- [第三章：本記事（フリーワード検索、カテゴリー別絞り込み機能の実装）]
- [第四章：ローディング表示、ページネーション、ユーザー管理機能の実装](https://qiita.com/showy/items/38747e6cebfd7f89cff3)

### 本記事の参考URL
本記事の作成にあたって主に以下のドキュメント・記事を参考にさせて頂きました。

- [Array.prototype.indexOf()](https://developer.mozilla.org/ja/docs/Web/JavaScript/Reference/Global_Objects/Array/indexOf#finding_if_an_element_exists_in_the_array_or_not_and_updating_the_array)
- [target="_blank"には気をつけよう](https://qiita.com/Apprentice_engineer/items/5db19a6d9bd4e7978aec)
- [テンプレート構文 #引数 -Vue.js](https://jp.vuejs.org/v2/guide/syntax.html#%E5%BC%95%E6%95%B0)


## 実装

それでは実装に移ります。

### 検索機能

#### templateに検索フォームを用意

```app/javascript/app.vue
<template>
  <v-app id="app">
  <!-- 中略 -->
    <h3>フリーワードで探す</h3>
      <v-text-field v-model="searchWord" @keyup="searchBookmarks" label="Input Keyword" style='margin-top:4px'></v-text-field>
      <br>
      <h3>カテゴリーごとに絞る</h3>
      <v-select
        v-model='category'
        :items="categories"
        label="Category"
        @change="searchBookmarks">
      </v-select>
  <!-- 中略 -->
  </v-app>
</template>
```

#### Vue.jsで検索機能を実装

今回は indexOf()メソッド を使って実装します。
indexOf() メソッドは引数に与えられた内容と同じ内容を持つ最初の配列要素の添字を返し、存在しない場合は -1 を返します。

つまり、上の「フリーワードで探す」のフォームで入力した文字は`v-model="searchWord"`の`searchWord`に入り、その`searchWord`が全てのブックマークの配列の中でタイトルやカテゴリーにヒットした場合、最初のキーを返し、1つもヒットしない場合は戻り値が-1になるということになります。

参考↓

https://developer.mozilla.org/ja/docs/Web/JavaScript/Reference/Global_Objects/Array/indexOf#finding_if_an_element_exists_in_the_array_or_not_and_updating_the_array


```app/javascript/app.vue
<script>
import draggable from 'vuedraggable'
import axios from 'axios';

axios.defaults.headers.common = {
    'X-Requested-With': 'XMLHttpRequest',
    'X-CSRF-TOKEN' : document.querySelector('meta[name="csrf-token"]').getAttribute('content')
};

export default {
  data: function () {
    return {
      isLoading: true,
      bookmarkList: ['',''],
      allData: ['',''],
      categories: ['All'],
      categoriesForEdit: [],
      category: 'ALL',
      dialogPostFlag: false,
      postTitle: "",
      postUrl: "",
      postCategory: "",
      dialogPutFlag: false,
      putTitle: '',
      putUrl: '',
      putCategory: '',
      dialogDeleteFlag: false,

      searchWord: '',  // 追加
    }
  },
  ...
  methods: {
    setBookmark: function () {
      axios.get('/api/bookmarks')
      .then(response => {
        this.allData = response.data
        this.bookmarkList = this.allData
        }
      );
      this.listCategories();
      this.searchBookmarks();  //searchBookmarksを呼ぶ
    },
    listCategories: function() {
      this.categories = []
      this.categoriesForEdit = []
      this.categories.push('ALL')
      for (i=0; i<this.allData.length; i++) {
        if (this.categories.indexOf(this.allData[i].category) == -1) {
          this.categories.push(this.allData[i].category)
          this.categoriesForEdit.push(this.allData[i].category)
        }
      }
    },

    ...

    // 検索するメソッド
    searchBookmarks: function() {
      if (this.category == 'ALL') {  // カテゴリーの選択肢が'ALL'（初期値）だったら
        this.bookmarkList = []
        for (i=0; i<this.allData.length; i++) {
          // 入力した文字(searchWord)がブックマークのタイトルまたはカテゴリーと一致する場合
          if ((this.allData[i].title.indexOf(this.searchWord) !== -1) || (this.allData[i].category.indexOf(this.searchWord) !== -1)) {
            this.bookmarkList.push(this.allData[i])  // 結果を返して配列にpush
          }
        }
      } else if (this.category != '') {  // カテゴリーが選択されている場合
        this.bookmarkList = []
        for (i=0; i<this.allData.length; i++) {
          // 選択したカテゴリーとブックマークのカテゴリーが等しい場合
          if (this.allData[i].category == this.category) {
            // 入力した文字(searchWord)がブックマークのタイトルまたはカテゴリーと一致する場合
            if ((this.allData[i].title.indexOf(this.searchWord) !== -1) || (this.allData[i].category.indexOf(this.searchWord) !== -1)) {
              this.bookmarkList.push(this.allData[i])  // 結果を返して配列にpush
            }
          }
        }   
      }
    }
  }
}
</script>
```

【フリーワード検索機能について】

- `searchBookmarks`メソッドでは、`indexOf()` を使って渡されてきた`searchWord`が、全ブックマークの中のタイトルやカテゴリーと一致する場合（戻り値が -1 では無い場合）に検索結果となるブックマークをbookmarkListの配列の中に随時pushする形で実装しています。

- `indexOf()`は、前回の記事の`listCategories`メソッドを定義した時に既に使用していますが、今回改めて自分の中での学習したことをアウトプットとして文章化しました。

- `listCategories`メソッドではカテゴリーが既存の物と一致しない場合（戻り値が -1 である場合）にそのカテゴリーを配列の中に新たにpushするというものでした。

【カテゴリー別絞り込み機能について】

- カテゴリー別絞り込み機能では、テンプレートの`v-select`内の`:items="categories"`で全てのカテゴリーを表示し、`v-model='category'`で選択したカテゴリーを渡して`searchBookmarks`メソッドを呼びます。

- `searchBookmarks`メソッドの `else if` の部分で処理が実行され結果を配列の中にpushし表示されます。

以上で検索機能の実装は終了です。


### 登録したURLへのアクセス機能

前回までの実装では、表示されているブックマークのタイトルは、ただの文字列で表示していました。

なのでブックマーク管理アプリとして機能するように、タイトルをクリックすると新規タブを開き登録したURLでアクセスできるように実装していきます。

```app/javascript/app.vue
<template>
  <v-app id="app">
  <!-- 中略 -->
    <a v-bind:href="bookmark.url" target="_blank" rel="noopener noreferrer" style="font-size: 18px;">
      {{ bookmark.title }}
    </a>
  <!-- 中略 -->
  </v-app>
</template>
```

タイトルを aタグで囲み、href属性で登録したブックマークのURLを指定します。
しかし、そのまま`<a href="{{ bookmark.url }}">`としてしまうと機能しません。

公式を見ると、「Mustache は、HTML 属性の内部で使用することはできません。代わりに、v-bind ディレクティブを使用してください」 と書いてありました。

なので、`<a v-bind:href="bookmark.url">`という形で実装しました。

参考URL：公式↓

https://jp.vuejs.org/v2/guide/syntax.html#%E5%BC%95%E6%95%B0

また、新規タブを開いてアクセスできるように`target="_blank"`を指定します。しかしそのままだとフィッシング詐欺攻撃の可能性などの危険性があるため、`rel="noopener noreferrer"`を付与して防ぎます。

参考にさせていただいた記事：

https://qiita.com/Apprentice_engineer/items/5db19a6d9bd4e7978aec


以上で、タイトルをクリックすると新規タブを開いて登録したURLにアクセスできるようになりました。

次章、ローディング表示、ページネーション、ユーザー管理機能の実装の記事はこちらになります↓

https://qiita.com/showy/items/38747e6cebfd7f89cff3