ElixirDesktopでスマホアプリを作る - LiveViewを使用してCRUDの機能を実装する

こんにちは！
プログラミング未経験文系出身、Elixirの国に迷い込んだ？！見習いアルケミストのaliceと申します。
今回はElixirDesktop + Phoenix1.7のスマホアプリにCRUDの機能を実装したいと思います。

---------------------------------------------

なお、本記事は2023/7/13開催[ElixirMobile#3：DBも内蔵したElixirDesktopスマホアプリ開発ハンズオン](https://fukuokaex.connpass.com/event/283648/)のイベントレポート(後半)を兼ねています。

------ハンズオンの様子(後半)はこちらから------

↓27:03~以前が前回の記事、以降が本記事のハンズオン動画です

https://youtu.be/EFWPu1ibGfc?t=1623

↓「微調整」の部分以降の動画です。

https://youtu.be/qqKSozIbgm4

---------------------------------------------


■「ElixirDesktopでスマホアプリを作る」シリーズの目次
①[Windows11+WSL2に環境構築](https://qiita.com/Alicesky2127/items/5f9a1c6496b1c083720f)
|> ②[Phoenix1.7のアプリを起動](https://qiita.com/Alicesky2127/items/7650f5650069b56bc173)
|> ③[LiveViewを使用してCRUDの機能を実装する](https://qiita.com/Alicesky2127/items/ada99a6b958be18ed270)
|> ④[Android実機に転送する(Google Drive経由)](https://qiita.com/Alicesky2127/items/623628ef64a3295fd8c5)

# 目的
こちらの記事をWindows11 + WSL2の環境で動かします。

https://qiita.com/the_haigo/items/17a89b5038a7f337b102


## 実行環境
Windows 11 ＋ WSL2 ＋ Ubuntu 22.04
Elixir v1.14.3
Erlang v25.0.4
Phoenix v1.7.3


# 前提条件
本記事は、[前回の記事](https://qiita.com/Alicesky2127/items/7650f5650069b56bc173)までの実施が完了している前提で進めます。


# CRUD作成
LiveViewのスキャフォールドを使用して、CRUDの機能を実装します。
```bash:todo_app
mix phx.gen.live Tasks Task tasks name:string status:boolean
```

上記コマンド実行後、コンソールの指示に従って以下を追加します。
```diff_elixir:todo_app/lib/todo_app_web/router.ex
   scope "/", TodoAppWeb do
     pipe_through :browser

     get "/", PageController, :home

+    live "/tasks", TaskLive.Index, :index
+    live "/tasks/new", TaskLive.Index, :new
+    live "/tasks/:id/edit", TaskLive.Index, :edit

+    live "/tasks/:id", TaskLive.Show, :show
+    live "/tasks/:id/show/edit", TaskLive.Show, :edit
   end
```


# Migration周り
ElixirDesktopにはマイグレーションしてくれる機能がありません。
よって起動時にマイグレーションを実行する機能の実装が必要です。

## migrationsディレクトリのコピーとリネーム
`todo_app/priv/repo/migrations`フォルダ全体を`todo_app/lib/todo_app/`にコピーします。
`xxxx_create_tasks.exs`を`xxxx_create_tasks.ex`にリネームします。[^0]

※青枠の部分が実施前、赤枠の部分が実施後のmigrations周りのディレクトリです。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/677280/5acb45de-929c-54d3-c39c-1f3e9aa96bea.png)


名前衝突を防ぐためにモジュール名をリネームします。
```diff_elixir:todo_app/lib/todo_app/migrations/xxx_create_tasks.ex
- defmodule TodoApp.Repo.Migrations.CreateTasks do
+ defmodule TodoApp.Migrations.CreateTasks do
  ...
end
```


## 起動時にマイグレーションする設定
[前回の記事](https://qiita.com/Alicesky2127/items/7650f5650069b56bc173#repoex%E3%81%AE%E5%A4%89%E6%9B%B4)で`repo.ex`内に作成したinitialize関数に、マイグレーションを実行する内容を書いていきます。

Ecto.Migrator.upはマイグレーションを実行する関数で
第1引数はRepoモジュール
第2引数はマイグレーション番号、今回はファイル名のタイムスタンプ
第3引数は先ほどコピーしたマイグレーションファイルのモジュール名
をそれぞれ指定します。

```diff_elixir:todo_app/lib/todo_app/repo.ex
defmodule TodoApp.Repo do
  ...
   def initialize() do
+    Ecto.Migrator.up(TodoApp.Repo, 20_230_712_123_236, TodoApp.Migrations.CreateTasks)
   end
end
```

# 起動時に開くページを指定

前回の記事完了時点では、起動時にPhoenix1.7のトップページ[^1]が開かれています。
起動時の設定を変更し、エンドポイント`/tasks`が起動時に開かれるようにします。

```diff_elixir:todo_app/lib/todo_app.ex
defmodule TodoApp do
  ...

  @app Mix.Project.config()[:app]
  def start(:normal, []) do
    ...
    {:ok, _} =
      Supervisor.start_child(sup, {
        Desktop.Window,
        [
          app: @app,
          id: TodoAppWindow,
          title: "TodoApp",
          size: {400, 800},
-         url: "http://localhost:#{port}"
+         url: "http://localhost:#{port}/tasks"
        ]
      })
  end
end
```

以上がPhonenixプロジェクトの設定です。

# androidでの起動

## 再ビルド
ブランクスクリプトではない方を走らせて、リビルドします。

```bash:shell
cd ../android-example-app/app
./run_mix_build
```

## Android StudioでRunする
[前回の記事の手順](https://qiita.com/Alicesky2127/items/7650f5650069b56bc173#android-studio%E3%81%A7run%E3%81%99%E3%82%8B)に従って、Android StudioでRunします。

無事CRUD画面が表示されました(^▽^)/


![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/677280/7eab009a-f79f-811d-ea9e-609b63b1cacc.png)


### (補足)"table tasks already exits"という内容のエラーが出て変更内容がandroidに反映されない場合
"table tasks already exits"というエラーが返ってくる場合、DBのリネームが必要です。

```diff_elixir:todo_app/lib/todo_app.ex
     # DBの場所を指定
     Application.put_env(:todo_app, TodoApp.Repo,
-      database: Path.join(config_dir(), "/database.sq3")
+      database: Path.join(config_dir(), "/database2.sq3")
     )
```




# 微調整
元記事様と同様に、微調整を行います。

## core_component カスタム
core_components.ex内にあるtableで表のサイズが指定されていますので、これを微調整します。

```diff_elixir:todo_app/lib/todo_app_web/components/core_components.ex
  def table(assigns) do
    assigns =
      with %{rows: %Phoenix.LiveView.LiveStream{}} <- assigns do
        assign(assigns, row_id: assigns.row_id || fn {id, _item} -> id end)
      end

    ~H"""
    <div class="overflow-y-auto px-4 sm:overflow-visible sm:px-0">
-      <table class="mt-11 w-[40rem] sm:w-full">
+      <table class="mt-11 w-full"> 
    ...
    """
  end
```

## confirm modalを追加
元記事様と同様に、削除リンクをモーダルを開くナビゲーションにしてindex.html.heexの末尾にconfirm modalを表示するようにします。


### 削除イベントのトリガー(発火)部分
editを参考にイベントの発火部分と、deleteモーダルのレンダリング部分を追加します。

```diff_elixir:todo_app/lib/todo_app_web/live/task_live/index.html.heex
...
<.table
  id="tasks"
  rows={@streams.tasks}
  row_click={fn {_id, task} -> JS.navigate(~p"/tasks/#{task}") end}
>
  <:col :let={{_id, task}} label="Name"><%= task.name %></:col>
  <:col :let={{_id, task}} label="Status"><%= task.status %></:col>
  <:action :let={{_id, task}}>
    <div class="sr-only">
      <.link navigate={~p"/tasks/#{task}"}>Show</.link>
    </div>
    <.link patch={~p"/tasks/#{task}/edit"}>Edit</.link>
  </:action>
-  <:action :let={{id, task}}>
+  <:action :let={{_id, task}}> #使用しない変数idを_で潰しておく。
    <.link
-      phx-click={JS.push("delete", value: %{id: task.id}) |> hide("##{id}")}
-      data-confirm="Are you sure?"
+      patch={~p"/tasks/#{task}/delete"} #"delete"のリンクを押下した際に部分的にページを更新する。
    >
      Delete
    </.link>
  </:action>
</.table>

...
+ <.modal
+   :if={@live_action in [:delete]} #変数live_actionがdeleteのときに表示されるモーダル。
+   id="task-delete-modal" #idは一意の値。
+   show
+   on_cancel={JS.navigate(~p"/tasks")} #モーダルのキャンセルボタンが押下されたときは~p"/tasks"に遷移する。
+ >
+   <p class="m-8">Are you sure?</p>
+   <.button phx-click={JS.push("delete", value: %{id: @task.id})}>Delete</.button> #ボタンのレンダリング、およびそのボタンが押下された時に、valueとして渡されたtask.idの内容を削除するイベントをトリガーする。
+ </.modal>
```

- `patch={~p"/tasks/#{task}/delete"}`の部分が増えたので、新しいルーティングが必要になります。（「削除イベントを実行させるルーティング」参照）

- `:if={@live_action in [:delete]}`の部分が増えたため、index.ex内handle_params関数のパターンマッチにヒットするようなapply_action関数が必要になります（「削除イベントのハンドラ部分」参照）
- `phx-click={JS.push("delete", value: %{id: @task.id}`の部分が増えたので、トリガーしたイベントの処理内容を書く関数が必要になります。（「削除イベントのハンドラ部分」参照）


### 削除イベントのハンドラ部分
こちらもeditを参考にapply_action部分と、トリガーしたイベントの処理内容を追加します。

```diff_elixir:todo_app/lib/todo_app_web/live/task_live/index.ex
 defmodule TodoAppWeb.TaskLive.Index do
   use TodoAppWeb, :live_view

   alias TodoApp.Tasks
   alias TodoApp.Tasks.Task

  ...

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

  ...
   defp apply_action(socket, :index, _params) do
     socket
     |> assign(:page_title, "Listing Tasks")
     |> assign(:task, nil)
   end

+  defp apply_action(socket, :delete, %{"id" => id}) do
+    socket
+    |> assign(:page_title, "Delete Task")
+    |> assign(:task, Tasks.get_task!(id))
+  end

   @impl true
   def handle_info({TodoAppWeb.TaskLive.FormComponent, {:saved, task}}, socket) do
     {:noreply, stream_insert(socket, :tasks, task)}
   end

   @impl true
   def handle_event("delete", %{"id" => id}, socket) do 
     task = Tasks.get_task!(id)
     {:ok, _} = Tasks.delete_task(task)

+    {
+      :noreply,
+      socket
+      |> put_flash(:info, "Task deleted successfully") # flashを追加
+      |> push_navigate(to: ~p"/tasks") # モーダルを閉じる
+      |> stream_delete(:tasks, task) # 要素が削除されたことをstream上に反映させる
+    }
   end
 end
```


### 削除イベントを実行させるルーティング
routeにdelete actionを追加します

```diff_elixir:todo_app/router.ex
   scope "/", TodoAppWeb do
     pipe_through :browser

     get "/", PageController, :home

     live "/tasks", TaskLive.Index, :index
     live "/tasks/new", TaskLive.Index, :new
     live "/tasks/:id/edit", TaskLive.Index, :edit
+    live "/tasks/:id/delete", TaskLive.Index, :delete # 追加

     live "/tasks/:id", TaskLive.Show, :show
     live "/tasks/:id/show/edit", TaskLive.Show, :edit
   end
```

# androidでの起動(微調整後)
## 再ビルド
ブランクスクリプトではない方を走らせて、リビルドします

```bash:shell
cd ../android-example-app/app
./run_mix_build
```

## Android StudioでRunする
[前回の記事の手順](https://qiita.com/Alicesky2127/items/7650f5650069b56bc173#android-studio%E3%81%A7run%E3%81%99%E3%82%8B)に従って、Android StudioでRunします。

DELETEの機能がついて、CRUDの機能が一通り実装されました(^▽^)/
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/677280/0617bbb9-0117-2bc7-10b1-f30b22b0ada1.png)

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/677280/86c26505-53f8-6c0b-cd94-48f2e3837035.png)

 

# ～Elixirの国のご案内～
↓Elixirって何ぞや？と思ったらこちらもどぞ。Elixirは先端のアレコレをだいたい全部できちゃいます:laughing::sparkles::sparkles:

https://qiita.com/piacerex/items/09876caa1e17169ec5e1

↓ゼロからElixirを始めるなら「エリクサーチ」がおすすめ！私もエンジニア未経験から学習中です。

https://elixir-lang.info/topics/entry_column


↓[We Are The Alchemists, my friends!:bouquet:](https://qiita.com/torifukukaiou/items/6bcd5bbb80df9e594e9e "Awesome-san's Awesome article")[^10]
Elixirコミュニティは本当に優しくて温かい人たちばかり！
私が挫折せずにいられるのもこの恵まれた環境のおかげです。
まずは気軽にコミュニティを訪れてみてください。[^11]


https://speakerdeck.com/elijo/elixirkomiyunitei-falsebu-kifang-guo-nei-onrainbian

[^0]: この`xxxx_create_tasks.exs`および`xxxx_create_tasks.ex`をマイグレーションファイルと呼びます。

[^1]:トップページ = PhoenixのロゴとPeace of mind from prototype to production.と書かれたページ 

[^10]: @torifukukaiouさんのAwesomeな名言をお借りしました。Elixirコミュニティを一言で表すと、これに尽きます。


[^11]: @kn339264さんの素敵なスライドをお借りしました。Elixirコミュニティはいろんな形で活動中！