16
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

ElixirAdvent Calendar 2022

Day 20

今更聞けないLiveView

Last updated at Posted at 2022-12-20

はじめに

この記事は、Elixir Advent Calendar 2022 の 20日目の投稿です。

前日は @a_utsuki さんの Elixirでつくりたいもの です。

業務ほぼRailsの為、すこしやっては忘れ去るLiveView。以前Apiからの操作で苦労したものを、DBのデーターでやってみようという趣旨です。

前提

erlang 25.2
elixir 1.14.2-otp-25
phoenix 1.6.15
postgres 14.1

アプリの作成

早速作っていきましょう。

zsh
kimny@kimuratomoakinoMacBook my_work % mix archive.install hex phx_new
Could not find Hex, which is needed to build dependency :phx_new
Shall I install Hex? (if running non-interactively, use "mix local.hex --force") [Yn] Y
* creating /Users/kimny/.asdf/installs/elixir/1.14.2-otp-25/.mix/archives/hex-2.0.0
Resolving Hex dependencies...
Resolution completed in 0.06s
New:
  phx_new 1.6.15
* Getting phx_new (Hex package)
All dependencies are up to date
Compiling 11 files (.ex)
Generated phx_new app
()
* creating live_test/assets/js/app.js
* creating live_test/priv/static/robots.txt
* creating live_test/priv/static/images/phoenix.png
* creating live_test/priv/static/favicon.ico

Fetch and install dependencies? [Yn] Y
* running mix deps.get

We are almost there! The following steps are missing:

    $ cd live_test

Then configure your database in config/dev.exs and run:

    $ mix ecto.create

Start your Phoenix app with:

    $ mix phx.server

You can also run your app inside IEx (Interactive Elixir) as:

    $ iex -S mix phx.server

ガイドが出てますので、そのとおりに進めていきましょう。

zsh
% mix ecto.create

13:34:15.435 [info] Compiling file system watcher for Mac...

13:34:18.906 [info] Done.
==> file_system
Compiling 7 files (.ex)
Generated file_system app
==> connection
Compiling 1 file (.ex)
Generated connection app
==> decimal
Compiling 4 files (.ex)
Generated decimal app
==> mime
Compiling 1 file (.ex)
Generated mime app
==> live_test
Could not find "rebar3", which is needed to build dependency :telemetry
I can install a local copy which is just used by Mix
略
  (mix 1.14.2) lib/mix/cli.ex:84: Mix.CLI.run_task/2
  (elixir 1.14.2) src/elixir_compiler.erl:66: :elixir_compiler.dispatch/4
  (elixir 1.14.2) src/elixir_compiler.erl:51: :elixir_compiler.compile/3

==> live_test
Compiling 14 files (.ex)
Generated live_test app
The database for LiveTest.Repo has been created
zsh
% mix phx.server
[info] Running LiveTestWeb.Endpoint with cowboy 2.9.0 at 127.0.0.1:4000 (http)
[info] Access LiveTestWeb.Endpoint at http://localhost:4000
[watch] build finished, watching for changes...

できました。

一旦 ctrl + c で落とします。

モデル

続いてモデルを作成します。

Place モデルには、 :name, :latitude, :longitude のattributeを作っておきます。

Phoenix 版の rails g scaffold 的なやつで作ります。

zsh
% mix phx.gen.html Prefectures Place places name latitude:float longitude:float 
* creating lib/live_test_web/controllers/place_controller.ex
* creating lib/live_test_web/templates/place/edit.html.heex
* creating lib/live_test_web/templates/place/form.html.heex
* creating lib/live_test_web/templates/place/index.html.heex
* creating lib/live_test_web/templates/place/new.html.heex
* creating lib/live_test_web/templates/place/show.html.heex
* creating lib/live_test_web/views/place_view.ex
* creating test/live_test_web/controllers/place_controller_test.exs
* creating lib/live_test/prefectures/place.ex
* creating priv/repo/migrations/20221220093711_create_places.exs
* creating lib/live_test/prefectures.ex
* injecting lib/live_test/prefectures.ex
* creating test/live_test/prefectures_test.exs
* injecting test/live_test/prefectures_test.exs
* creating test/support/fixtures/prefectures_fixtures.ex
* injecting test/support/fixtures/prefectures_fixtures.ex

Add the resource to your browser scope in lib/live_test_web/router.ex:

    resources "/places", PlaceController


Remember to update your repository by running migrations:

    $ mix ecto.migrate

マイグレーションを通します。

zsh
% mix ecto.migrate
warning: the :gettext compiler is no longer required in your mix.exs.

Please find the following line in your mix.exs and remove the :gettext entry:

    compilers: [..., :gettext, ...] ++ Mix.compilers(),

  (gettext 0.20.0) lib/mix/tasks/compile.gettext.ex:5: Mix.Tasks.Compile.Gettext.run/1
  (mix 1.14.2) lib/mix/task.ex:421: anonymous fn/3 in Mix.Task.run_task/4
  (mix 1.14.2) lib/mix/tasks/compile.all.ex:92: Mix.Tasks.Compile.All.run_compiler/2
  (mix 1.14.2) lib/mix/tasks/compile.all.ex:72: Mix.Tasks.Compile.All.compile/4
  (mix 1.14.2) lib/mix/tasks/compile.all.ex:59: Mix.Tasks.Compile.All.with_logger_app/2
  (mix 1.14.2) lib/mix/tasks/compile.all.ex:33: Mix.Tasks.Compile.All.run/1


02:21:14.515 [info] == Running 20221220093711 LiveTest.Repo.Migrations.CreatePlaces.change/0 forward

02:21:14.517 [info] create table places

02:21:14.522 [info] == Migrated 20221220093711 in 0.0s

warningが出る?

何やら warning 出てますね。
mix.exs にある、

mix.exs
compilers: [..., :gettext, ...] ++ Mix.compilers(),

この記述を消せというようです。
ファイルはプロジェクト直下にあります。

mix.exs

defmodule LiveTest.MixProject do
  use Mix.Project

  def project do
    [
      app: :live_test,
      version: "0.1.0",
      elixir: "~> 1.12",
      elixirc_paths: elixirc_paths(Mix.env()),
      compilers: [..., :gettext, ...] ++ Mix.compilers(),#←これを消しておきましょう。
      start_permanent: Mix.env() == :prod,
      aliases: aliases(),
      deps: deps()
    ]
  end
(略)

下記のようになればOKです。

mix.exs

defmodule LiveTest.MixProject do
  use Mix.Project

  def project do
    [
      app: :live_test,
      version: "0.1.0",
      elixir: "~> 1.12",
      elixirc_paths: elixirc_paths(Mix.env()),
      start_permanent: Mix.env() == :prod,
      aliases: aliases(),
      deps: deps()
    ]
  end
(略)

:gettext 自体は必要なので、ライブラリから消さないようにとの事。
バージョンが上がって設定の書き方が変わっているようです。

ダミーデーター

では、次に seed を書いてみましょう。

alias LiveTest.Prefectures.Place
alias LiveTest.Repo

data = [
    ["東京駅", 33.8569343, 30.7466767],
    ["梅田駅", 34.7031952, 135.4276025],
    ["天神駅", 33.5913809, 130.3288387]
]

data
    |> Enum.map(fn [name, latitude, longitude] -> %Place{name: name, latitude: latitude, longitude: longitude} end)
    |> Enum.map(fn (place) -> Repo.insert!(place) end)

とりあえず3件程。必要な alias も書いておきます。
下記のようになればOKです。

ファイルを開くとコメントがありますが、コメントの下にでも追記しましょう。

priv/repo/seeds.exs
# Script for populating the database. You can run it as:
#
#     mix run priv/repo/seeds.exs
#
# Inside the script, you can read and write to any of your
# repositories directly:
#
#     LiveTest.Repo.insert!(%LiveTest.SomeSchema{})
#
# We recommend using the bang functions (`insert!`, `update!`
# and so on) as they will fail if something goes wrong.

alias LiveTest.Prefectures.Place
alias LiveTest.Repo

data = [
    ["東京駅", 33.8569343, 30.7466767],
    ["梅田駅", 34.7031952, 135.4276025],
    ["天神駅", 33.5913809, 130.3288387]
]

data
    |> Enum.map(fn [name, latitude, longitude] -> %Place{name: name, latitude: latitude, longitude: longitude} end)
    |> Enum.map(fn (place) -> Repo.insert!(place) end)

コメント部分には、seedの流し方

mix run priv/repo/seeds.exs

が書かれていますので、実行します。

zsh
% mix run priv/repo/seeds.exs
[debug] QUERY OK db=2.2ms decode=1.2ms queue=0.3ms idle=25.4ms
INSERT INTO "places" ("latitude","longitude","name","inserted_at","updated_at") VALUES ($1,$2,$3,$4,$5) RETURNING "id" [33.8569343, 30.7466767, "東京駅", ~N[2022-12-20 18:30:55], ~N[2022-12-20 18:30:55]]
↳ Enum."-map/2-lists^map/1-0-"/2, at: lib/enum.ex:1658
[debug] QUERY OK db=0.3ms queue=0.4ms idle=36.5ms
INSERT INTO "places" ("latitude","longitude","name","inserted_at","updated_at") VALUES ($1,$2,$3,$4,$5) RETURNING "id" [34.7031952, 135.4276025, "梅田駅", ~N[2022-12-20 18:30:55], ~N[2022-12-20 18:30:55]]
↳ Enum."-map/2-lists^map/1-0-"/2, at: lib/enum.ex:1658
[debug] QUERY OK db=0.2ms queue=0.2ms idle=37.3ms
INSERT INTO "places" ("latitude","longitude","name","inserted_at","updated_at") VALUES ($1,$2,$3,$4,$5) RETURNING "id" [33.5913809, 130.3288387, "天神駅", ~N[2022-12-20 18:30:55], ~N[2022-12-20 18:30:55]]
↳ Enum."-map/2-lists^map/1-0-"/2, at: lib/enum.ex:1658

登録できました。

iexで確認したいので、一旦 iex 用の alias も書いておきます。
プロジェクト直下に.iex.exs を新規作成します。

.iex.exs
alias LiveTest.Repo
alias LiveTest.Prefectures
alias LiveTest.Prefectures.Place

こんな感じでOKです。

では、 iex をPhoenixから起動しましょう。 -S mix を付けます。

zsh
% iex -S mix          
Erlang/OTP 25 [erts-13.1.3] [source] [64-bit] [smp:8:8] [ds:8:8:10] [async-threads:1] [jit]

Interactive Elixir (1.14.2) - press Ctrl+C to exit (type h() ENTER for help)
iex(1)> 

起動しました。下記を試しておきます。

iex
iex(1)> Place |> Repo.all
[debug] QUERY OK source="places" db=7.9ms decode=1.3ms queue=0.7ms idle=1601.5ms
SELECT p0."id", p0."latitude", p0."longitude", p0."name", p0."inserted_at", p0."updated_at" FROM "places" AS p0 []
 anonymous fn/4 in :elixir.eval_external_handler/1, at: src/elixir.erl:309
[
  %LiveTest.Prefectures.Place{
    __meta__: #Ecto.Schema.Metadata<:loaded, "places">,
    id: 1,
    latitude: 33.8569343,
    longitude: 30.7466767,
    name: "東京駅",
    inserted_at: ~N[2022-12-20 18:50:03],
    updated_at: ~N[2022-12-20 18:50:03]
  },
  %LiveTest.Prefectures.Place{
    __meta__: #Ecto.Schema.Metadata<:loaded, "places">,
    id: 2,
    latitude: 34.7031952,
    longitude: 135.4276025,
    name: "梅田駅",
    inserted_at: ~N[2022-12-20 18:50:03],
    updated_at: ~N[2022-12-20 18:50:03]
  },
  %LiveTest.Prefectures.Place{
    __meta__: #Ecto.Schema.Metadata<:loaded, "places">,
    id: 3,
    latitude: 33.5913809,
    longitude: 130.3288387,
    name: "天神駅",
    inserted_at: ~N[2022-12-20 18:50:03],
    updated_at: ~N[2022-12-20 18:50:03]
  }
]
iex
iex(2)> Place |> Repo.get(2)
[debug] QUERY OK source="places" db=0.4ms queue=0.5ms idle=1496.1ms
SELECT p0."id", p0."latitude", p0."longitude", p0."name", p0."inserted_at", p0."updated_at" FROM "places" AS p0 WHERE (p0."id" = $1) [2]
 anonymous fn/4 in :elixir.eval_external_handler/1, at: src/elixir.erl:309
%LiveTest.Prefectures.Place{
  __meta__: #Ecto.Schema.Metadata<:loaded, "places">,
  id: 2,
  latitude: 34.7031952,
  longitude: 135.4276025,
  name: "梅田駅",
  inserted_at: ~N[2022-12-20 18:50:03],
  updated_at: ~N[2022-12-20 18:50:03]
}

ルーティング

作られたページの為のルーティングを書き換えます。
今回は、 gen.html で作っていますので、 resources のルーティングを書く事と、トップページの設定を行います。

lib/live_test_web/router.ex
defmodule LiveTestWeb.Router do
  use LiveTestWeb, :router

  pipeline :browser do
    plug :accepts, ["html"]
    plug :fetch_session
    plug :fetch_live_flash
    plug :put_root_layout, {LiveTestWeb.LayoutView, :root}
    plug :protect_from_forgery
    plug :put_secure_browser_headers
  end

  pipeline :api do
    plug :accepts, ["json"]
  end

  scope "/", LiveTestWeb do
    pipe_through :browser

    get "/", PageController, :index
  end
()

このようになっていますので、

scope "/", LiveTestWeb do
end

このブロック内を以下のように書き換えます。

  scope "/", LiveTestWeb do
    pipe_through :browser

    get "/", PlaceController, :index
    resources "/places", PlaceController
  end

全体では、以下のようになります。

lib/live_test_web/router.ex
defmodule LiveTestWeb.Router do
  use LiveTestWeb, :router

  pipeline :browser do
    plug :accepts, ["html"]
    plug :fetch_session
    plug :fetch_live_flash
    plug :put_root_layout, {LiveTestWeb.LayoutView, :root}
    plug :protect_from_forgery
    plug :put_secure_browser_headers
  end

  pipeline :api do
    plug :accepts, ["json"]
  end

  scope "/", LiveTestWeb do
    pipe_through :browser

    get "/", PlaceController, :index
    resources "/places", PlaceController
  end
()

では、サーバーを起動しましょう。 iex を使う可能性がある場合は、

iex -S mix phx.server

で、ブラウザと iex 間でシームレスに実験が出来ます。

では、 localhost:4000 を見てみましょう。

いい感じです。
一通り遷移できたら一度落としましょう。

LiveViewへの移行

では、LiveViewの導入に入ります。
今回は、DBからのデーターをLiveViewで表示し、簡単なイベントを仕込んでみたいと思います。

モデル作成時のコマンドを改造します。

mix phx.gen.live Prefectures Place places name latitude:float longitude:float
zsh
% mix phx.gen.live Prefectures Place places name latitude:float longitude:float
You are generating into an existing context.

The LiveTest.Prefectures context currently has 6 functions and 1 file in its directory.

  * It's OK to have multiple resources in the same context as long as they are closely related. But if a context grows too large, consider breaking it apart

  * If they are not closely related, another context probably works better

The fact two entities are related in the database does not mean they belong to the same context.

If you are not sure, prefer creating a new context over adding to the existing one.

Would you like to proceed? [Yn] Y   
The following files conflict with new files to be generated:

  * lib/live_test_web/live/place_live/show.ex
  * lib/live_test_web/live/place_live/index.ex
  * lib/live_test_web/live/place_live/form_component.ex
  * lib/live_test_web/live/place_live/form_component.html.heex
  * lib/live_test_web/live/place_live/index.html.heex
  * lib/live_test_web/live/place_live/show.html.heex
  * test/live_test_web/live/place_live_test.exs
  * lib/live_test/prefectures/place.ex

See the --web option to namespace similarly named resources

Proceed with interactive overwrite? [Yn] Y
* creating lib/live_test_web/live/place_live/show.ex
* creating lib/live_test_web/live/place_live/index.ex
* creating lib/live_test_web/live/place_live/form_component.ex
* creating lib/live_test_web/live/place_live/form_component.html.heex
* creating lib/live_test_web/live/place_live/index.html.heex
* creating lib/live_test_web/live/place_live/show.html.heex
* creating test/live_test_web/live/place_live_test.exs
* creating lib/live_test/prefectures/place.ex
* creating priv/repo/migrations/20221220193931_create_places.exs

Add the live routes to your browser scope in lib/live_test_web/router.ex:

    live "/places", PlaceLive.Index, :index
    live "/places/new", PlaceLive.Index, :new
    live "/places/:id/edit", PlaceLive.Index, :edit

    live "/places/:id", PlaceLive.Show, :show
    live "/places/:id/show/edit", PlaceLive.Show, :edit


Remember to update your repository by running migrations:

    $ mix ecto.migrate

と上書きを聞かれますが、全て Y でOKです。

そして、

同じマイグレーションファイルが出来るので、削除しておきましょう。

ルーティングの変更点

先程作成した

  scope "/", LiveTestWeb do
    pipe_through :browser

    get "/", PlaceController, :index
    resources "/places", PlaceController
  end

こちらは、以下のように書き換えます。

  scope "/", LiveTestWeb do
    pipe_through :browser

    live "/", PlaceLive.Index, :index
    live "/places", PlaceLive.Index, :index
    live "/places/new", PlaceLive.Index, :new
    live "/places/:id/edit", PlaceLive.Index, :edit

    live "/places/:id", PlaceLive.Show, :show
    live "/places/:id/show/edit", PlaceLive.Show, :edit
  end

これでLiveViewへの変換が終了です。

このタイミングで、以前のコントローラーや、テンプレートなども消してしまって良いです。

コントローラー側

LiveViewは感覚的にVue.jsのような感じでイベントやステートの管理が出来ます。
そしてすでに必要なコードが書かれていますので、変更点を書き加えて行く状態になります。

例えば、

Image from Gyazo

こんな感じででボタンを動かしたい場合、 mount/3socket にステートを入れてしまいます。

  def mount(_params, _session, socket) do
    socket = socket
      |> assign(:data,
        %{
          columns:
             %{
                name: %{ status: "asc", icon: "▲" },
                latitude: %{ status: "unsorted", icon: "■" },
                longitude: %{ status: "unsorted", icon: "■" }
             },
          default_column: :name
        }
      )
    {:ok, assign(socket, :places, list_places())}
  end

そして、

  def handle_event("sort", value, socket) do
    { :noreply, update(socket, :data, &(sort_data(&1, value))) }
  end

このようなイベントハンドラーを書いておき、

  def sort_data(data, value) do
    sort =  value["sort"]
      |> String.to_atom
    status =  value["status"]
      |> String.to_atom
    result = fn
      { :desc, data } ->
        _data = data
          #ここで並び替え
        dom_update(_data, sort, "asc", "▲")
      { _, data } ->
        _data = data
          #ここで並び替え
        dom_update(_data, sort, "desc", "▼")
    end
    result.({ status, data })
  end

  def dom_update(data, sort, status, icon) do
    data
      |> put_in([:columns, data[:default_column]], %{ status: "unsorted", icon: "■" })
      |> put_in([:columns, sort, :icon], icon )
      |> put_in([:columns, sort, :status], status )
      |> Map.put(:default_column, sort)
  end

ステートに加えた変更を更新するといった流れです。

全体では、

lib/live_test_web/live/place_live/index.ex
defmodule LiveTestWeb.PlaceLive.Index do
  use LiveTestWeb, :live_view

  alias LiveTest.Prefectures
  alias LiveTest.Prefectures.Place

  @impl true
  def mount(_params, _session, socket) do
    socket = socket
      |> assign(:data,
        %{
          columns:
             %{
                name: %{ status: "asc", icon: "▲" },
                latitude: %{ status: "unsorted", icon: "■" },
                longitude: %{ status: "unsorted", icon: "■" }
             },
          default_column: :name
        }
      )
    {:ok, assign(socket, :places, list_places())}
  end

  @impl true
  def handle_params(params, _url, socket) do
    {:noreply, apply_action(socket, socket.assigns.live_action, params)}
  end

  defp apply_action(socket, :edit, %{"id" => id}) do
    socket
    |> assign(:page_title, "Edit Place")
    |> assign(:place, Prefectures.get_place!(id))
  end

  defp apply_action(socket, :new, _params) do
    socket
    |> assign(:page_title, "New Place")
    |> assign(:place, %Place{})
  end

  defp apply_action(socket, :index, _params) do
    socket
    |> assign(:page_title, "Listing Places")
    |> assign(:place, nil)
  end

  @impl true
  def handle_event("delete", %{"id" => id}, socket) do
    place = Prefectures.get_place!(id)
    {:ok, _} = Prefectures.delete_place(place)

    {:noreply, assign(socket, :places, list_places())}
  end

  defp list_places do
    Prefectures.list_places()
  end

  def handle_event("sort", value, socket) do
    { :noreply, update(socket, :data, &(sort_data(&1, value))) }
  end

  def sort_data(data, value) do
    sort =  value["sort"]
      |> String.to_atom
    status =  value["status"]
      |> String.to_atom
    result = fn
      { :desc, data } ->
        _data = data
          #ここで並び替え
        dom_update(_data, sort, "asc", "▲")
      { _, data } ->
        _data = data
          #ここで並び替え
        dom_update(_data, sort, "desc", "▼")
    end
    result.({ status, data })
  end

  def dom_update(data, sort, status, icon) do
    data
      |> put_in([:columns, data[:default_column]], %{ status: "unsorted", icon: "■" })
      |> put_in([:columns, sort, :icon], icon )
      |> put_in([:columns, sort, :status], status )
      |> Map.put(:default_column, sort)
  end

  defp place_columns do
    %{
        name:  %{ status: "asc", icon: "▲" },
        latitude: %{ status: "unsorted", icon: "■" },
        longitude: %{ status: "unsorted", icon: "■" }
      }
  end
end

このような感じになります。

テンプレート側

基本的にテンプレートも自動作成されているのですが、

  <thead>
    <tr>
      <%= for column <- [:name, :latitude, :longitude] do %>
      <th
        phx-click="sort"
        phx-value-sort={ column }
        phx-value-status={ @data[:columns][column][:status] }
      >
        <%= Atom.to_string(column) %><%= @data[:columns][column][:icon] %>
      </th>
      <% end %>
      <th></th>
    </tr>
  </thead>

このように、イベントのボタンをVueライクな感じで書き加えておきます。
全体では、

lib/live_test_web/live/place_live/index.html.heex
<h1>Listing Places</h1>

<%= if @live_action in [:new, :edit] do %>
  <.modal return_to={Routes.place_index_path(@socket, :index)}>
    <.live_component
      module={LiveTestWeb.PlaceLive.FormComponent}
      id={@place.id || :new}
      title={@page_title}
      action={@live_action}
      place={@place}
      return_to={Routes.place_index_path(@socket, :index)}
    />
  </.modal>
<% end %>

<table>
  <thead>
    <tr>
      <%= for column <- [:name, :latitude, :longitude] do %>
      <th
        phx-click="sort"
        phx-value-sort={ column }
        phx-value-status={ @data[:columns][column][:status] }
      >
        <%= Atom.to_string(column) %><%= @data[:columns][column][:icon] %>
      </th>
      <% end %>
      <th></th>
    </tr>
  </thead>
  <tbody id="places">
    <%= for place <- @places do %>
      <tr id={"place-#{place.id}"}>
        <td><%= place.name %></td>
        <td><%= place.latitude %></td>
        <td><%= place.longitude %></td>

        <td>
          <span><%= live_redirect "Show", to: Routes.place_show_path(@socket, :show, place) %></span>
          <span><%= live_patch "Edit", to: Routes.place_index_path(@socket, :edit, place) %></span>
          <span><%= link "Delete", to: "#", phx_click: "delete", phx_value_id: place.id, data: [confirm: "Are you sure?"] %></span>
        </td>
      </tr>
    <% end %>
  </tbody>
</table>

<span><%= live_patch "New Place", to: Routes.place_index_path(@socket, :new) %></span>

このような感じです。

データーの並び替え

あとはデーターを並び替えるだけです。

おわりに

ここまで見ていただきありがとうございます。

21日めは、

@mnishiguchi さんで Elixirコードからmix.exsの中身を取得する方法 です。

16
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
16
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?