Twitterクローンを作ります #36 検索

本家の検索画面だと様々なタブが存在しますが、"ツイート"と"アカウント"の2つだけ実装しましょう。

# 画面

画面はこんな感じでいいでしょう。

```vue:client/src/views/SearchView.vue
<template>
  <div class="uk-margin-right">
    <h3 class="uk-margin-top">検索</h3>
    <div class="uk-inline uk-margin-bottom uk-width-1-1">
      <div class="uk-form-custom uk-width-expand">
        <span class="uk-form-icon" uk-icon="search"></span>
        <input class="uk-input" type="text" placeholder="キーワード検索" />
      </div>
    </div>
    <div>
      <ul uk-tab class="uk-child-width-expand">
        <li>
          <a>ツイート</a>
        </li>
        <li>
          <a>アカウント</a>
        </li>
      </ul>
    </div>
  </div>
</template>
```

![スクリーンショット 2022-03-29 11.15.12.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2536525/a7a2f12a-414b-3cfb-c0c8-5869757be227.png)

URLは `/search?q=<検索文字列>&type=<タブ>` な感じにします。

# API

APIは `GET /posts` にパラメータを渡してもいいのですが、全件からの検索の処理が結構重そうな気がするのであとからElasticSearch等使いたくなったときに対応しやすいように `GET /search/posts` などにして別APIにしてしまいましょう。

```python:server/apis/posts.py
...

@app.route("/api/search/posts", methods=["GET"])
@login_required()
def search_posts():
    query = request.args.get("q")
    if not query:
        return jsonify([])
    results = []
    for post in Post._collection.find(
        {"content": {"$regex": query, "$options": "i"}}, sort=[("created_at", pymongo.DESCENDING)]
    ):
        results.append(_populate(post))

    if results:
        _set_liking_and_retweeting(results)

    return jsonify(results)

...
```

```python:server/api/users.py
...

@app.route("/api/search/users", methods=["GET"])
@login_required()
def search_users():
    query = request.args.get("q")
    if not query:
        return jsonify([])
    result = []    
    for user in User._collection.find({
        "$or": [
            { "username": { "$regex": query, "$options": "i" } },
            { "display_name": { "$regex": query, "$options": "i" } },
        ]
    }, sort=[("liked_at", pymongo.DESCENDING)]):
        del user["hashed_password"]
        result.append(user)

    return jsonify(result)
...
```

# 画面 (動き)

検索文字列が入力され、Enterが押されたら検索APIを呼び出し画面に表示するようにしていきましょう。

日本語の場合、変換の確定でもEnterを押すので下記を参考にしました。

https://r17n.page/2020/04/04/vue-submit-on-enter-japanese/

```vue:client/src/views/SearchView.vue
<template>
  <div class="uk-margin-right">
    <h3 class="uk-margin-top">検索</h3>
    <div class="uk-inline uk-margin-bottom uk-width-1-1">
      <div class="uk-form-custom uk-width-expand">
        <span class="uk-form-icon" uk-icon="search"></span>
        <input
          class="uk-input"
          type="text"
          placeholder="キーワード検索"
          v-model="query"
          @keypress.prevent.enter.exact="enableSubmit"
          @keyup.prevent.enter.exact="submit"
        />
      </div>
    </div>
    <div>
      <ul uk-tab class="uk-child-width-expand">
        <li :class="{ 'uk-active': tab !== 'users' }">
          <router-link
            :to="{
              path: '/search',
              query: {
                q: query,
                type: 'posts',
              },
            }"
            >ツイート</router-link
          >
        </li>
        <li :class="{ 'uk-active': tab === 'users' }">
          <router-link
            :to="{
              path: '/search',
              query: {
                q: query,
                type: 'users',
              },
            }"
            >アカウント</router-link
          >
        </li>
      </ul>
      <div v-if="tab === 'users'">
        <user-panel
          v-for="user in users"
          :key="user._id"
          :user="user"
        ></user-panel>
      </div>
      <div v-else>
        <post-panel
          v-for="post in posts"
          :key="post._id"
          :post="post"
        ></post-panel>
      </div>
    </div>
  </div>
</template>

<script>
import { computed, ref, watch } from "vue";
import { useRoute } from "vue-router";
import { useStore } from "vuex";
import PostPanel from "@/components/PostPanel.vue";
import UserPanel from "@/components/UserPanel.vue";

export default {
  components: {
    PostPanel,
    UserPanel,
  },
  setup() {
    const store = useStore();
    const route = useRoute();
    const query = ref("");
    const tab = ref("");
    const canSubmit = ref(false);

    const enableSubmit = () => {
      canSubmit.value = true;
    };

    const _search = () => {
      const action = tab.value === "users" ? "searchUsers" : "searchPosts";
      store.dispatch(action, { query: query.value });
    };

    const submit = () => {
      if (!canSubmit.value) {
        return;
      }
      _search();
      canSubmit.value = false;
    };

    const posts = computed(() => store.state.search.posts);
    const users = computed(() => store.state.search.users);

    watch(
      () => route.query,
      (newQuery) => {
        query.value = newQuery.q;
        tab.value = newQuery.type ? newQuery.type : "posts";
        _search();
      },
      {
        immediate: true,
      }
    );

    return {
      query,
      tab,
      posts,
      users,
      enableSubmit,
      submit,
    };
  },
};
</script>

```

ここまでくるとPostPanelやUserPanelをいじらなくても、データを取得して `<post-panel :post="post" ...>` のようにしてやるだけでちゃんと動いてくれるのでいいですね。

ストアは下記のようにしました。

```javascript:client/src/store/index.js
import axios from "axios";
import UIkit from "uikit";
import { createStore } from "vuex";

import router from "@/router";

function defaultErrorHandler(error) {
  if (error.response.status === 401) {
    router.push("/login");
  } else {
    UIkit.notification(error.response.data.error.message, {
      status: "danger",
    });
  }
}

export default createStore({
  state: {
    userLoggedIn: undefined,
    posts: [],
    profile: {
      user: {
        display_name: "",
        username: "",
      },
      users: [],
      tweets: [],
      tweetsAndReplies: [],
      likes: [],
    },
    search: {
      posts: [],
      users: [],
    },
    notifications: [],
    unreadNotificationCount: 0,
    replyTo: undefined,
    post: undefined,
  },
  getters: {},
  mutations: {
...
    setSearchPosts(state, args) {
      state.search.posts = args.posts;
    },
    setSearchUsers(state, args) {
      state.search.users = args.users;
    },
  },
  actions: {
...
    async searchPosts({ commit }, args) {
      return axios
        .get("/api/search/posts", {
          params: {
            q: args.query,
          },
        })
        .then((resp) => {
          commit("setSearchPosts", { posts: resp.data });
        })
        .catch(defaultErrorHandler);
    },
    async searchUsers({ commit }, args) {
      return axios
        .get("/api/search/users", {
          params: {
            q: args.query,
          },
        })
        .then((resp) => {
          commit("setSearchUsers", { users: resp.data });
        })
        .catch(defaultErrorHandler);
    },
  },
  modules: {},
});

```

![スクリーンショット 2022-03-29 13.22.17.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2536525/253e86f0-34b5-718a-8db6-900398fc3eb1.png)

![スクリーンショット 2022-03-29 13.22.08.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2536525/17a11ea9-70e1-592c-5ec9-08f4cdf1e5df.png)