スポンサーリンク

2015年8月1日

[Elixir+Phoenix]Microposts List (with pagination)

Goal

マイクロポストの一覧を表示する。

Dev-Environment

OS: Windows8.1
Erlang: Eshell V6.4, OTP-Version 17.5
Elixir: v1.0.4
Phoenix Framework: v0.13.1
PostgreSQL: postgres (PostgreSQL) 9.4.4

Wait a minute

ユーザのプロファイルページに手を入れて、
マイクロポストの一覧を表示できるようにしましょう。
最初に謝罪しておきます。
ソースコードを修正に修正を繰り返していたら、
行動の順番が分からなくなってしまいました。
説明は記述していますが、必ずしも順番にはなっていません。
そして、修正量が多いです。
今までの記事をやった方々には、お手数をお掛けして申し訳ないです。
それでも仕方ないなやってやるよと言う親切な方は、
どうぞお付き合い下さい。

Index

Microposts List
|> Microposts List
|> Pagination again
|> Share template of pagination
|> Extra

Microposts List

ユーザに紐づいている、マイクロポストの一覧を表示します。
ファイル: web/controllers/user_controller.ex
showアクションを修正。
def show(conn, %{"id" => id}) do
  user = Repo.get(SampleApp.User, id)
  page = Repo.all(from(m in SampleApp.Micropost, where: m.user_id == ^user.id, order_by: [desc: m.inserted_at]))
  render(conn, "show.html", user: user, posts: page)
end
ファイル: web/templates/user/show.html.eex
マイクロポストを表示させる。
<h2>User profile</h2>

<div style="float: left; margin-top: 20px; margin-right: 20px;">
  <img src="<%= get_gravatar_url(@user) %>" class="gravatar">
  <h2><%= @user.name %></h2>
</div>

<%= if @posts do %>
  <div style="float: right; margin-right: 350px;">
    <h2>Microposts<h2>
    <h2>
      <%= for post <- @posts do %>
        <li style="padding: 10px 0; border-top: 1px solid #e8e8e8;"><%= post.content %></li>
      <% end %>
    </h2>
  </div>
<% end %>

<div style="clear: left; margin-top: 60px">
  <%= link "Edit", to: user_path(@conn, :edit, @user), class: "btn btn-default btn-xs" %>
  <%= link "Delete", to: user_path(@conn, :delete, @user), method: :delete, class: "btn btn-danger btn-xs" %>
</div>
う~ん、相変わらずの直書き・・・
私のデザイン力(CSS力)はこの程度だ。

Pagination again

ページネーション再び・・・
マイクロポストも数が増えてきたら、
ページネーションをしてあげないと見辛くなってしまいますね。
うん、またなんだ。一緒に頑張りましょう・・・
ファイル: lib/helpers/pagination_helper.ex
ページネーションを行うのに補助するモジュールを作成。
上から順に以下のような機能。
  • select_pageがnilかemptyか判定する関数
  • select_pageが有効なページか判定する関数
  • 判定をまとめている関数
  • 各モデルでのページネーションを補助する関数
defmodule SampleApp.Helpers.PaginationHelper do

  @page_size "1"

  defp is_nil_or_empty?(select_page) do
    is_nil(select_page) || select_page == ""
  end

  defp is_valid_value?(select_page) do
    Regex.match?(~r/^[0-9]+$/, select_page)
  end

  defp is_able_to_paginate?(select_page) do
    !is_nil_or_empty?(select_page) && is_valid_value?(select_page)
  end

  def paginate(query, select_page) do
    if is_able_to_paginate?(select_page) do
      query |> SampleApp.Repo.paginate(page: select_page, page_size: @page_size)
    else
      nil
    end
  end
end
ファイル: web/models/user.ex
定数と関数の削除。また、paginate/1関数の修正。
(ソースコード全文を記載しています)
defmodule SampleApp.User do
  use SampleApp.Web, :model
  use Ecto.Model.Callbacks

  import Ecto.Query

  before_insert :set_password_digest
  before_update :set_password_digest

  schema "users" do
    field :name, :string
    field :email, :string
    field :password_digest, :string
    field :password, :string, virtual: true

    has_many :microposts, SampleApp.Micropost

    timestamps
  end

  @required_fields ~w(name email password)
  @optional_fields ~w()

  @doc """
  Creates a changeset based on the `model` and `params`.

  If `params` are nil, an invalid changeset is returned
  with no validation performed.
  """
  def changeset(model, params \\ :empty) do
    model
    |> cast(params, @required_fields, @optional_fields)
    |> validate_presence(:name)
    |> validate_presence(:email)
    |> validate_presence(:password)
    |> validate_format(:email, ~r/\A[\w+\-.]+@[a-z\d\-.]+\.[a-z]+\z/i)
    |> validate_unique(:name, on: SampleApp.Repo)
    |> validate_unique(:email, on: SampleApp.Repo)
    |> validate_length(:name, min: 1)
    |> validate_length(:name, max: 50)
    |> validate_length(:password, min: 8)
    |> validate_length(:password, max: 100)
  end

  def paginate(select_page) do
    SampleApp.Helpers.PaginationHelper.paginate(
      from(u in SampleApp.User, order_by: [asc: :name]),
      select_page)
  end

  # before_insert - password to password_digest
  def set_password_digest(changeset) do
    password = Ecto.Changeset.get_field(changeset, :password)
    change(changeset, %{password_digest: encrypt(password)})
  end

  # find user from email
  def find_user_from_email(email) do
    SampleApp.Repo.get_by(SampleApp.User, email: email)
  end

  # password decrypt
  def decrypt(password) do
    Safetybox.decrypt(password)
  end

  # my presence check validation
  defp validate_presence(changeset, field_name) do
    field_data = Ecto.Changeset.get_field(changeset, field_name)
    cond do
      field_data == nil ->
        add_error changeset, field_name, "#{field_name} is nil"
      field_data == "" ->
        add_error changeset, field_name, "No #{field_name}"
      true ->
        changeset
    end
  end

  # password encrypt
  defp encrypt(password) do
    Safetybox.encrypt(password, :default)
  end
end
ファイル: web/models/micropost.ex
paginate/2関数を追加。
def paginate(user_id, select_page) do
  SampleApp.Helpers.PaginationHelper.paginate(
    from(m in SampleApp.Micropost, where: m.user_id == ^user_id, order_by: [desc: m.inserted_at]),
    select_page)
end
ファイル: web/controllers/user_controller.ex
index、showアクションで特定のパラメータが存在しない場合エラーを出す。
plug :scrub_params, "select_page" when action in [:index, :show]
indexアクションを以下のように修正。
def index(conn, %{"select_page" => select_page}) do
  page = SampleApp.User.paginate(select_page)

  if page do
    render(conn, "index.html",
           users: page.entries,
           current_page: page.page_number,
           total_pages: page.total_pages,
           page_list: Range.new(1, page.total_pages))
  else
    conn
    |> put_flash(:error, "Invalid page number!!")
    |> render("index.html", users: [])
  end
end
showアクションを以下のように修正。
def show(conn, %{"id" => id, "select_page" => select_page}) do
  user = Repo.get(SampleApp.User, id)
  page = SampleApp.Micropost.paginate(user.id, select_page)

  if page do
    render(conn, "show.html",
           user: user,
           posts: page.entries,
           current_page: page.page_number,
           total_pages: page.total_pages,
           page_list: Range.new(1, page.total_pages))
  else
    conn
    |> put_flash(:error, "Invalid page number!!")
    |> render("show.html", user: user, posts: [])
  end
end
ファイル: web/views/layout_view.ex
Profileページでも使えるように関数を修正。
defmodule SampleApp.LayoutView do
  use SampleApp.Web, :view

  def current_user(conn) do
    conn.assigns[:current_user]
  end

  def add_first_page_param(action) do
    "#{action}?select_page=1"
  end
end
ファイル: web/templates/layout/header.html.eex
ProfileとAll Usersへのリンクを修正。
<%= if current_user(@conn) do %>
  <li><%= link "Profile", to: add_first_page_param(user_path(@conn, :show, current_user(@conn).id)) %><li>
  <li><%= link "All Users", to: add_first_page_param(user_path(@conn, :index)) %><li>
  <li><%= link "Sign-out", to: session_path(@conn, :delete) %></li>
<% else %>

Share template of pagination

テンプレートに記述されているページネーション部分を
別テンプレートに移し、共有テンプレートとします。
また、UserViewに実装している、
ページネーションのリンクを作成している関数を修正します。
ファイル: web/views/user_view.ex
以下の関数を修正。
def get_previous_page_url(action, current_page) do
  get_page_url(action, current_page - 1)
end
def get_next_page_url(action, current_page) do
  get_page_url(action, current_page + 1)
end
def get_page_url(action, page_number) do
  "#{action}?select_page=#{page_number}"
end
以下の関数を追加。
def is_empty_list?(list) when is_list(list) do
  list == []
end
ファイル: web/templates/user/index.html.eex
以下のように修正する。
<h2 class="text-center">All users</h2>

<%= if !is_empty_list?(@users) do %>
  <%= render "pagination.html",
             action: user_path(@conn, :index),
             current_page: @current_page,
             page_list: @page_list,
             total_pages: @total_pages %>

  <table class="table">
    <thead>
      <tr>
        <th>Profile Image</th>
        <th>Name</th>
      </tr>
    </thead>
    <tbody>
    <%= for user <- @users do %>
      <tr>
        <td>
          <div class="gravatar" style="float: left; margin-right: 10px;">
            <img src="<%= get_gravatar_url(user) %>" class="gravatar">
          </div>
        </td>
        <td><h4><%= user.name %><h4></td>
      </tr>
    <% end %>
    </tbody>
  </table>

  <%= render "pagination.html",
             action: user_path(@conn, :index),
             current_page: @current_page,
             page_list: @page_list,
             total_pages: @total_pages %>

<% end %>
ファイル: web/templates/user/show.html.eex
以下のように修正する。
<%= alias SampleApp.LayoutView %>
<h2>User profile</h2>

<div style="float: left; margin-top: 20px; margin-right: 20px;">
  <img src="<%= get_gravatar_url(@user) %>" class="gravatar">
  <h2><%= @user.name %></h2>
</div>

<div style="float: right; margin-right: 350px;">
  <h2>Microposts</h2>

  <%= if !is_empty_list?(@posts) do %>
    <h2>
      <%= for post <- @posts do %>
        <li style="padding: 10px 0; border-top: 1px solid #e8e8e8;"><%= post.content %></li>
      <% end %>
    </h2>

    <%= render "pagination.html",
               action: user_path(@conn, :show, LayoutView.current_user(@conn).id),
               current_page: @current_page,
               page_list: @page_list,
               total_pages: @total_pages %>
  <% end %>

</div>

<div style="clear: left; margin-top: 60px">
  <%= link "Edit", to: user_path(@conn, :edit, @user), class: "btn btn-default btn-xs" %>
  <%= link "Delete", to: user_path(@conn, :delete, @user), method: :delete, class: "btn btn-danger btn-xs" %>
</div>
ファイル: web/templates/user/pagination.html.eex
共有のテンプレートを作成。
<nav>
  <ul class="pagination">

  <!-- previous link -->
  <%= if @current_page > 1 do %>
    <li>
      <a href="<%= get_previous_page_url(@action, @current_page) %>" aria-label="Previous">
        <span aria-hidden="true">&laquo;</span>
      </a>
    </li>
  <% end %>

  <!-- page link -->
  <%= for page_number <- @page_list do %>
    <%= if page_number == @current_page do %>
      <li class="active">
        <a href="<%= get_page_url(@action, page_number) %>">
          <%= page_number %><span class="sr-only">(current)</span>
        </a>
      </li>
    <% else %>
      <li><a href="<%= get_page_url(@action, page_number) %>"><%= page_number %></a></li>
    <% end %>
  <% end %>

  <!-- next link -->
  <%= if @current_page < @total_pages do %>
    <li>
      <a href="<%= get_next_page_url(@action, @current_page) %>" aria-label="Next">
        <span aria-hidden="true">&raquo;</span>
      </a>
    </li>
  <% end %>

  </ul>
</nav>

Extra

利用した正規表現のテストを記載。
正規表現: /^[0-9]+$/
説明: 数字の繰り返しに対してマッチ
iexで簡単な確認を行う
iex(1)> Regex.match?(~r/^[0-9]+$/, "12345")
true
iex(2)> Regex.match?(~r/^[0-9]+$/, "hoge")
false
iex(3)> Regex.match?(~r/^[0-9]+$/, "1hoge")
false
iex(4)> Regex.match?(~r/^[0-9]+$/, "1hoge2")
false
iex(5)> Regex.match?(~r/^[0-9]+$/, "123hoge234")
false
iex(6)> Regex.match?(~r/^[0-9]+$/, "1")
true
iex(7)> Regex.match?(~r/^[0-9]+$/, "12")
true
iex(8)> Regex.match?(~r/^[0-9]+$/, "01")
true
iex(9)> Regex.match?(~r/^[0-9]+$/, "-1")
false
ついでの確認。
iex(1)> String.to_integer("01")
1

Speaking to oneself

更新が遅れてしまって申し訳ない。
また、修正に次ぐ修正を強いてしまって申し訳ない。
全ては見通しと設計の甘さが問題ですね。
とりあえず、最低限レベルだが記事にしてもいいソースコードになった。
根本的にページネーションを実装するときの設計を間違えた気がします。
ページネーションの実装を完全に修正する可能性があります。
もしかしたら、といったレベルの話なので頭の片隅にでも置いといて下さい。
hexdocs - v0.14.3 Ecto.Query.preload/3を使えば、
ユーザからの紐づけでページネーションのデータを取得することができる気がするんだよ。
ちょっと調べきれてないので、
動作検証を行ったら修正するかもしれません。
最初から一発OKのソースコードを出せればいいのですが、
今のやり方だと難しいですね・・・
うわぁーん(・_・。)グスン
修正ばっかだな・・・全ては自分のレベルが低いのがいけない( ;∀;) カナシイナー

Bibliography

人気の投稿