LiveView で無限スクロールデータテーブルを作る

@koyo-miyamura です！
最近は本業の傍ら、Elixirコミュニティの方で副業しており、実務でのElixir実装にハマっております。
「お仕事でも使える Elixir」をテーマに実務での実装例の紹介に励んでおりますb

# 背景
副業で「無限スクロールデータテーブル」を実装する機会がありました。

無限スクロールデータテーブルというのはこういう感じで、テーブルをスクロールしたら次のページへのアクセスが行われるようなデータテーブルです。
（ツイッターなどのタイムラインをイメージするとわかりやすいです。）

![edit_20221113214417.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/201242/75723014-5ba2-2c12-6576-0a70e300dfb1.gif)


実際に英語含めて調べてみても、あまり実装例なくて Javascript の動作を細かく追ったりして苦労したので、アルケミストの皆様のためにご紹介させていただければ！
（自分がやるときに欲しかった笑）

# バージョン
```
Phoenix 1.6.15
LiveView 0.17.12
```

# 無限スクロールデータテーブルとは
実は「無限スクロール」の実装自体は以前 @torifukukaiou さんが過去に紹介しています。

https://qiita.com/torifukukaiou/items/c3e37813c6c32fb6d5d2

実は LiveView 公式でも紹介されていたり。

https://hexdocs.pm/phoenix_live_view/0.17.12/js-interop.html#client-server-communication

しかしこれはスクロール領域が「画面全体」である場合の実装であり今回の要件を満たしませんでした。
スクロール領域を「データテーブル領域」にするにはここから一工夫必要となります。

# 実装
ということで実装してみましょう！

## プロジェクト準備
以前ご紹介した方法で docker でプロジェクト準備します。
（もちろん普通にローカルで mix phx.new しても ok です！）

https://qiita.com/koyo-miyamura/items/a609de2e9fadaf198243

https://github.com/koyo-miyamura/elixir_phoenix_docker

```bash
docker compose run --rm --no-deps web mix phx.new scrollable_table_sample
```

いつも通り User テーブルを作ります。
```bash
docker compose exec web mix phx.gen.live Users User users name:string
docker compose exec web mix ecto.migrate
```

`router.ex` への追加も忘れずに

```elixir:router.ex
  scope "/", ScrollableTableSampleWeb do
    pipe_through :browser

    get "/", PageController, :index

    live "/users", UserLive.Index, :index
    live "/users/new", UserLive.Index, :new
    live "/users/:id/edit", UserLive.Index, :edit

    live "/users/:id", UserLive.Show, :show
    live "/users/:id/show/edit", UserLive.Show, :edit
  end
```

100名のユーザーを作ります。
Phoenix には seed を管理する仕組みがあるのでこれを使ってみましょう！

```elixir:priv/repo/seeds.exs
for i <- 1..100, do: %{name: "name_#{i}"} |> ScrollableTableSample.Users.create_user()
```

これで流してみます。

```bash
docker compose exec web mix run priv/repo/seeds.exs
```

100名のユーザーができました！

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/201242/10972ca0-22d1-81d8-dd95-1a9142c02610.png)

## スクローラブルにする
デフォルトだとテーブルがスクロールしないので以下のようにしてみます。

```eex:lib/scrollable_table_sample_web/live/user_live/index.html.heex
<div style="overflow-y: scroll; max-height: 70vh;">
  <table>
  ...
  </table>
</div>
```

スクロールバーがつきました :+1:

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/201242/4ab3b7f8-739e-ae40-bf54-eea5ab51ead5.png)

## Scrivenner を導入する
事前に Scrivenner というDBのページネーション対応をサポートするライブラリを導入します。
導入は以前紹介しているのでこちらを参考に。

https://qiita.com/koyo-miyamura/items/663230bf4bc61e80a711#%E3%83%9A%E3%83%BC%E3%82%B8%E3%83%8D%E3%83%BC%E3%82%B7%E3%83%A7%E3%83%B3%E3%82%92%E5%AE%9F%E7%8F%BE%E3%81%99%E3%82%8B%E3%83%A9%E3%82%A4%E3%83%96%E3%83%A9%E3%83%AA-scrivenner

## 無限スクロールデータテーブルにする
さてここからが本題です！
ユーザー一覧のテーブルを無限スクロール仕様にしてみましょう。

### テンプレートファイルの編集

まずテンプレートファイルを編集して `InfiniteScroll` という名前の hooks をテーブルに組み込みます。
（[LiveView には hook という仕組みがあり](https://hexdocs.pm/phoenix_live_view/js-interop.html#client-hooks-via-phx-hook)、これを用いることで Javascript <-> LiveView 間で連携できます。今回のようにブラウザのイベントを取りたい場合に特に便利ですね）

```eex:lib/scrollable_table_sample_web/live/user_live/index.html.heex
<div style="overflow-y: scroll; max-height: 70vh;" class="infinite-scrollable-table">
  <table>
    ...
    <tbody
      id="users"
      phx-update="append"
      phx-hook="InfiniteScroll"
      data-page={@page}
      data-total_pages={@total_pages}
      data-el=".infinite-scrollable-table"
    >
```

### hook の実装

hook は以下のように実装します。
hook は別ファイルに分けて実装すると見通しが良いですね :+1:

```js:assets/js/app.js
import { Hooks } from "./hooks"

let liveSocket = new LiveSocket("/live", Socket, {hooks: Hooks, params: {_csrf_token: csrfToken}})
```

hook の実装は以下のようになります。

```js:assets/js/hooks.js
let Hooks = {}

// Returns scroll rate [%] of scrollable area for target DOM (el).
const scrollAt = (el) => {
  const scrollTop = el.scrollTop
  const scrollHeight = el.scrollHeight
  const clientHeight = el.clientHeight

  const result = scrollTop / (scrollHeight - clientHeight) * 100

  return result
}

Hooks.InfiniteScroll = {
  page() { return parseInt(this.el.dataset.page, 10) },
  total_pages() { return this.el.dataset.total_pages },
  mounted() {
    this.loading = false

    const $scrollDiv = document.querySelector(this.el.dataset.el)
    $scrollDiv.addEventListener("scroll", _e => {
      if(!this.loading && this.page() != this.total_pages() && scrollAt($scrollDiv) >= 99){
        // change "loading" into true in order to prevent multiple page loading at once
        this.loading = true
        this.pushEventTo("#" + this.el.id, "load_more", {})
      }
    })
  },
  reconnected(){ this.loading = false },
  updated(){ this.loading = false }
}

export { Hooks }
```

細かい説明するとそれだけで1記事になるので詳細は割愛しますが、ざっくりいうと `scrollAt` 関数がデータテーブルのスクロール可能領域のうち、現在どこまでスクロールしているかを百分率で計算していて、それが規定値（今回は99%）を超えたら次のページを読み込むイベントが LiveView ハンドラー側に送られるという仕組みです。

`scrollAt` 詳細の仕組みを知りたい方は以下の MDN を読んでみるといいと思いますb

https://developer.mozilla.org/ja/docs/Web/API/Element/scrollTop

https://developer.mozilla.org/ja/docs/Web/API/Element/scrollHeight

https://developer.mozilla.org/ja/docs/Web/API/Element/clientHeight

なお、 loading フラグで制御しないと複数ページ一気に読み込まれるのでご注意を！

### LiveView ハンドラの実装
後はハンドラ部分を実装すれば完成です！
先ほど入れた Scrivenner を使ってページネーションしたデータを返すようにします。
また mount 時には 1ページ目だけ返すようにします。

後は Javascript 側の hook から送られてくる `load_more` イベントをハンドリングして、次の1ページの User データを返せば OK です！
全体ではなく差分を返すことに注意が必要ですb

```elixir:lib/scrollable_table_sample/users.ex
  def list_users(page: page, per_page: per_page) do
    from(u in User,
      order_by: [desc: u.updated_at, desc: u.id]
    )
    |> Repo.paginate(page: page, page_size: per_page)
  end
```

```elixir:lib/scrollable_table_sample_web/live/user_live/index.ex
...

  @default_page 1
  @per_page 20

  @impl true
  def mount(_params, _session, socket) do
    socket
    |> assing_paginate_users(page: @default_page, per_page: @per_page)
    |> then(&{:ok, &1})
  end

...

  def handle_event("delete", %{"id" => id}, socket) do
    user = Users.get_user!(id)
    {:ok, _} = Users.delete_user(user)

    socket
    |> assing_paginate_users(page: @default_page, per_page: @per_page)
    |> then(&{:noreply, &1})
  end

  @impl true
  def handle_event("load_more", _, %{assigns: assigns} = socket) do
    next_page = assigns.page + 1

    {:noreply,
     socket
     |> assing_paginate_users(page: next_page, per_page: @per_page)}
  end

  defp assing_paginate_users(socket, page: page, per_page: per_page) do
    paginate_users(page: page, per_page: per_page)
    |> then(&assign(socket, &1))
  end

  defp paginate_users(page: current_page, per_page: per_page) do
    %{
      entries: entries,
      page_number: page,
      page_size: _page_size,
      total_entries: _total_entries,
      total_pages: total_pages
    } =
      Users.list_users(
        page: current_page,
        per_page: per_page
      )

    [
      users: entries,
      page: page,
      total_pages: total_pages
    ]
  end
```

## 完成！

![edit_20221113214417.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/201242/05ffd8c1-4b97-906e-007f-2fdb742dcdcd.gif)


# まとめ
無限スクロールデータテーブルを LiveView で実装しました。

なお、実際に実務ではさらにこちらを LiveView コンポーネント化して再利用性を高めるということをしました。
実務ではコンポーネント化することも多いので、こちらについては Elixir アドベントカレンダーの12日目にてご紹介します！
お楽しみに！

https://qiita.com/advent-calendar/2022/elixir

なおリポジトリはこちらにあるので、ぜひ遊んでみてください！ :pray:

https://github.com/koyo-miyamura/scrollable_table_sample