Brightコードハック①Elixirプロダクション攻略：【Web CRUD】LiveView Scaffold＋Tailwindで爆速開発

この記事は、[Elixir Advent Calendar 2023 シリーズ12](https://qiita.com/advent-calendar/2023/elixir) の7日目です

---

[piacere](https://twitter.com/piacere_ex) です、ご覧いただいてありがとございます :bow:

過去／現在／未来のスキルから、あなたのBright（輝き）とRight（正しさ）を引き出すプロダクト「Bright」を今年後半にαリリースしました

https://bright-fun.org

まもなくβリリースするBrightの実装から、Elixirプロダクション開発のヒントをお届けします

なお、Brightが生まれた背景は、昨年Elixir Advent Calendarで下記にまとめています（400いいねを超える人気コラム）

https://qiita.com/piacerex/items/bac30ec027c9eef0e717

あと、このコラムが、面白かったり、役に立ったら、![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/155423/907a8695-4d83-1b5c-8052-d0ce52de5f66.png) をお願いします :bow:

# LiveView Scaffold＋Tailwindで爆速開発

今回は、Web CRUD開発の基本となる「LiveView Scaffold」と、最新のPhoenix 1.7で標準搭載化された「Tailwind」の組み合わせで、いかに簡単にプロダクト開発できるかを解説します

実際にPJを作成し、コーディングしていくので、ぜひお手元でもお試しください
[![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/155423/9a5264d2-0705-7e9c-2249-76a4d7c7be88.png)](https://bright-fun.org)

# 事前準備：Phoenix PJの作成

前提として、ElixirとPostgreSQLのインストールが済んでいることとします

未インストールの方は、エリクサーチの下記コラムを参考に構築してください

https://elixir-lang.info/topics/entry_column
[![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/155423/2ab1aca3-259d-c21b-74cd-6f6091278a36.png)](https://elixir-lang.info/topics/entry_column)

https://elixir-lang.info/topics/web_spa
※下図「③ローカル環境上でLiveView環境を構築（その後は上記②がローカルで可能となる）」のPostgreSQL部分のみ実施してください
[![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/155423/ee023ad8-b068-6665-fda8-26b7d493c92d.png)](https://elixir-lang.info/topics/web_spa)

次に、Phoenixをインストールし、Phoenix PJを作成した後、起動します

なお、BrightのPhoenixは1.7.3ですが、2023年12月5日時点の最新版は1.7.10でした

```bash
mix archive.install hex phx_new
mix phx.new bright
cd bright
mix ecto.create
iex -S mix phx.server
```

ブラウザで `http://localhost:4000` にアクセスすると、PJ初期画面が表示できます
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/155423/9faebb39-1ed6-b38e-5214-d91af3bc8ab8.png)

# LiveView Scaffoldの基本

LiveView Scaffoldを使うと、DB CRUD画面の自動生成ができ、テーブル作成のためのマイグレーションファイルも生成できます

ここでは、Brightの「スキルパネル」機能の下記赤枠部分と似たようなものを作ってみます
https://app.bright-fun.org/panels/01HA663FMC0SM4AX1D56750QAN?class=1
[![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/155423/c7809dd5-23b2-b3e1-b35e-5c34d38bdcba.png)](https://app.bright-fun.org/panels/01HA663FMC0SM4AX1D56750QAN?class=1)

上記画面に存在する項目相当として、下記4つのテーブルとDB CRUD画面を自動生成してみます

- skill_units
- skill_categories
- skills
- skill_scores

なお、実際のBrightにおける各テーブルIDは、UUIDを利用していますが、ここでは簡便化のためにintegerで定義します

またuser_idは、`phx.gen.auth` での生成をBrightでは利用していますが、ここではタダのintegerで定義し、GETパラメータでスイッチする想定とします

```bash
mix phx.gen.live SkillUnits SkillUnit skill_units name:string loced_date:datetime trace_id:integer

mix phx.gen.live SkillUnits SkillCategory skill_categories skill_unit_id:references:skill_units name:string position:integer trace_id:integer 

mix phx.gen.live SkillUnits Skill skills skill_category_id:references:skill_categories name:string position:integer trace_id:integer

mix phx.gen.live SkillScores SkillScore skill_scores user_id:integer skill_id:integer score:string exam_progress:string reference_read:boolean evidence_filled:boolean
```

routerに下記を追加します（なお、上記コマンド実施時にターミナルにも出力されます）

```elixir:/lib/bright_web/router.ex
defmodule BrightWeb.Router do
  use BrightWeb, :router
…
  scope "/", BrightWeb do
    pipe_through :browser

    ## ここから追加
    live "/skill_units", SkillUnitLive.Index, :index
    live "/skill_units/new", SkillUnitLive.Index, :new
    live "/skill_units/:id/edit", SkillUnitLive.Index, :edit
    live "/skill_units/:id", SkillUnitLive.Show, :show
    live "/skill_units/:id/show/edit", SkillUnitLive.Show, :edit

    live "/skill_categories", SkillCategoryLive.Index, :index
    live "/skill_categories/new", SkillCategoryLive.Index, :new
    live "/skill_categories/:id/edit", SkillCategoryLive.Index, :edit
    live "/skill_categories/:id", SkillCategoryLive.Show, :show
    live "/skill_categories/:id/show/edit", SkillCategoryLive.Show, :edit

    live "/skills", SkillLive.Index, :index
    live "/skills/new", SkillLive.Index, :new
    live "/skills/:id/edit", SkillLive.Index, :edit
    live "/skills/:id", SkillLive.Show, :show
    live "/skills/:id/show/edit", SkillLive.Show, :edit

    live "/skill_scores", SkillScoreLive.Index, :index
    live "/skill_scores/new", SkillScoreLive.Index, :new
    live "/skill_scores/:id/edit", SkillScoreLive.Index, :edit
    live "/skill_scores/:id", SkillScoreLive.Show, :show
    live "/skill_scores/:id/show/edit", SkillScoreLive.Show, :edit
    ## ここまで追加

    get "/", PageController, :home
  end
…
```

下記コマンドでマイグレートします

```bash
mix ecto.migrate
```

ブラウザで `http://localhost:4000/skills` にアクセスすると、下記画面でDB CRUD操作できるようになります（`/skill_units` や `/skill_categories`、`/skill_scores` も同様）
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/155423/4f0c31e4-662b-c6bc-df10-6fa6b5525152.png)

「New Skill」ボタンからデータ追加できます（作成後、「Edit」リンクや「Delete」リンクでデータ操作もできます）
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/155423/d92ace11-db40-5c97-3bcb-781eb3580497.png)

`skills` にサンプルデータを下記のように追加します
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/155423/65b0dcfb-5255-7444-fd2c-09ed508af793.png)

`skills` に紐づく `skill_scores` のサンプルデータも下記のように追加します
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/155423/8713a533-fa24-cc81-b77d-126c177b4ef8.png)

# テーブル同士をリレーションさせる

`skills` と `skill_scores` の間に、1:n のリレーションを設定します

```elixir:/lib/bright/skill_units/skill.ex
defmodule Bright.SkillUnits.Skill do
  use Ecto.Schema
  import Ecto.Changeset

  schema "skills" do
    field :name, :string
    field :position, :integer
    field :trace_id, :integer
    field :skill_category_id, :id

    # 下記を追加
    has_many :skill_scores, Bright.SkillScores.SkillScore

    timestamps(type: :utc_datetime)
  end
…
```

`skills` の一覧に `skill_scores` をLEFT JOINで `user_id` 一致時、かつ `skill_id` 一致時もしくは `nil` 時に結合して取得します

```elixir:/lib/bright/skill_units.ex
defmodule Bright.SkillUnits do
…
  def list_skills do
    Repo.all(Skill)
  end

  # 下記を追加
  alias Bright.SkillScores.SkillScore
  def list_skills_with_scores(nil), do: []
  def list_skills_with_scores(user_id) do
    Repo.all(from skill in Skill,
      left_join: score in SkillScore,
      on: ^user_id == score.user_id and (skill.id == score.skill_id or is_nil(score.skill_id)),
      order_by: skill.id,
      preload: [skill_scores: score])
  end
…
```

LiveView mount時に上記関数を使い、GETパラメータ `id` でユーザー指定できるよう修正します

```elixir:/lib/bright_web/live/skill_live/index.ex
defmodule BrightWeb.SkillLive.Index do
…
  @impl true
  def mount(params, _session, socket) do
#    {:ok, stream(socket, :skills, SkillUnits.list_skills())}
    # 下記に変更
    {:ok, stream(socket, :skills, SkillUnits.list_skills_with_scores(params["id"]))}
  end
…
```

`skill.skill_scores` をinspectします（ついでに不要な `skill.position` 行／`skill.trace_id` 行を削除）

```diff_erb:/lib/bright_web/live/skill_live/index.html.heex
…
  <:col :let={{_id, skill}} label="Name"><%= skill.name %></:col>
  <!-- skill.position行を削除 -->
  <!-- 下記を追加 -->
  <:col :let={{_id, skill}} label="Score"><%= skill.skill_scores |> inspect %></:col>
  <!-- skill.trace_id行を削除 -->
  <:action :let={{_id, skill}}>
…
```

`http://localhost:4000/skills?id=1` を表示すると、以下のようになります
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/155423/bc1a4fa9-ab5b-761d-6604-733cafd8359c.png)

`http://localhost:4000/skills?id=2` だと下記です
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/155423/9f365c31-24ef-356b-aeaa-7b0de0437575.png)

ユーザー毎の保有スキルである `skill_scores` を `skills` に紐付いて表示（保有スキル無時は空リスト）できるようになりました

# PhoenixにおけるTailwindの基本

上記データテーブルをPhoenix 1.7から標準装備のTailwindでビジュアルを良くしていきましょう

データテーブルのHTMLは、これまたPhoenix 1.7で機能追加された「core_components」の `table` 関数にあります

```diff_elixir:/lib/bright_web/components/core_components.ex
defmodule BrightWeb.CoreComponents do
…
  def table(assigns) do
    …
    ~H"""
    <div class="overflow-y-auto px-4 sm:overflow-visible sm:px-0">
      <table class="w-[40rem] mt-11 sm:w-full">
        <thead class="text-sm text-left leading-6 text-zinc-500">
          <tr>
-           <th :for={col <- @col} class="p-0 pb-4 pr-6 font-normal"><%= col[:label] %></th>
+           <th :for={col <- @col} class="py-3 text-center border border-gray-400 text-sm font-bold bg-black text-white"><%= col[:label] %></th>
            <th :if={@action != []} class="relative p-0 pb-4">
              <span class="sr-only"><%= gettext("Actions") %></span>
            </th>
          </tr>
        </thead>
        <tbody
          id={@id}
          phx-update={match?(%Phoenix.LiveView.LiveStream{}, @rows) && "stream"}
          class="relative divide-y divide-zinc-100 border-t border-zinc-200 text-sm leading-6 text-zinc-700"
        >
          <tr :for={row <- @rows} id={@row_id && @row_id.(row)} class="group hover:bg-zinc-50">
            <td
              :for={{col, i} <- Enum.with_index(@col)}
              phx-click={@row_click && @row_click.(row)}
-             class={["relative p-0", @row_click && "hover:cursor-pointer"]}
+             class={["relative p-0 border border-gray-400", @row_click && "hover:cursor-pointer"]}
            >
-             <div class="block py-4 pr-6">
+             <div class="block">
                <span class="absolute -inset-y-px right-0 -left-4 group-hover:bg-zinc-50 sm:rounded-l-xl" />
-               <span class={["relative", i == 0 && "font-semibold text-zinc-900"]}>
+               <span class={["relative", i == 0 && "p-4 text-base"]}>
…
```

外枠はBrightの「スキルパネル」そっくりになりました
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/155423/d9c2731c-edcb-4c81-6df0-ff4e75afb03d.png)

# 保有スキルのアイコン化

保有スキルを「●」「▲」「－」で表示分けします

```diff_erb:/lib/bright_web/live/skill_live/index.html.heex
…
  <!-- 下記を追加 -->
- <:col :let={{_id, skill}} label="Score"><%= skill.skill_scores |> inspect %></:col>
+ <:col :let={{_id, skill}} label="Score">
+<%
+score = case skill.skill_scores |> List.first do
+ nil -> %{class: "", text: "－"}
+ n   -> if n.score == "high" do
+     %{class: "text-teal-600", text: "●"}
+   else
+     %{class: "text-teal-400", text: "▲"}
+   end
+end
+%>
+<p class="text-center text-lg"><span class={score.class}><%= score.text %></span></p>
+  </:col>
  <!-- skill.trace_id行を削除 -->
…
```

内側もBrightの「スキルパネル」そっくりになりました
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/155423/1b66269b-2a78-ed1d-352d-dac1c1b92d72.png)

# ここまでで習得したスキルは？

さて、Bright「スキルパネル」そっくりな画面を作るのに、下記のスキルを習得したことになります

- 「Elixir入門」
  - クラス１：個人で簡単なWeb+DBアプリが作れる
    - if～else／case／cond
    - リスト／マップ
    - Web inspectデバッグ
    - mix archive.install hex phx_new
    - mix phx.new
    - mix phx.server／iex -S mix phx.server
    - Router（router.ex）追加／変更
    - mix phx.gen.live（scaffold）／ブラウザ上CRUD操作
    - scaffold生成.html.heexの「<%= ～ %>」追加／改修
    - scaffold生成.exのmount改修
    - Scaffold後のmix ecto.create
    - Scaffold後のmix ecto.migrate
    - HTML
  - クラス２：Web+DBアプリ開発チームに参画できる
    - 関数パターンマッチ
    - scaffold生成のContext／Schema改修
    - Ecto.Query記述（from／DSL両方）
  - クラス３：サポート有ならWeb+DBチーム開発できる
    - params
    - has_one／has_many／many_to_many／belongs_to
    - preload
- 「Webアプリ開発 Elixir」
  - クラス１：零細Web開発をサポート無しでこなせる
    - Tailwind
  - クラス２：小規模Web開発／マイクロサービスできる
    - ピン演算子

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/155423/526eafcc-1da1-8fd7-b0cc-3e0a7fa49a7d.png)

ぜひ下記からBrightを無料ユーザー登録して、スキルパネル「Elixir入門」「Webアプリ開発 Elixir」に、ここまで習得したスキルに「●」をスキル入力してみてください

https://bright-fun.org/

イマイチ内容とスキルが結びついていない方も、Brightを使いながら、該当スキルを調べたり、復習をしてみてください

[次回は、プロダクションの要である認証周りとして、phx_gen_auth＋Überauthで独自ID＋SNSログイン](https://qiita.com/piacerex/items/b34598cfc314e3400b3e)を攻略します

# p.s.このコラムが、面白かったり、役に立ったら…

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/155423/907a8695-4d83-1b5c-8052-d0ce52de5f66.png) にて、どうぞ応援よろしくお願いします :bow: