2018年10月22日

Phoenix_7_JavaScript

Watching Video

  • 修改 views 改成可以查看 videos
  • 建立一個新的 controller for watching video
  • 修改 router for new routes
  • 增加 JavaScript 以使用 YouTube API

修改 /web/templates/layout/app.html.eex 的 header

        <div class="header">
        <ol class="breadcrumb text-right">
          <%= if @current_user do %>
            <li><%= @current_user.username %></li>
            <li><%= link "My Videos", to: video_path(@conn, :index) %></li>
            <li>
              <%= link "Log out", to: session_path(@conn, :delete, @current_user),
                                  method: "delete" %>
            </li>
          <% else %>
            <li><%= link "Register", to: user_path(@conn, :new) %></li>
            <li><%= link "Log in", to: session_path(@conn, :new) %></li>
          <% end %>
        </ol>
        <span class="logo"></span>
      </div>

登入後,點擊 header 上面的 "My Videos" 進入 http://localhost:4000/manage/videos


新增 /web/controllers/watch_controller.ex

defmodule Rumbl.WatchController do
  use Rumbl.Web, :controller
  alias Rumbl.Video

  def show(conn, %{"id" => id}) do
    video = Repo.get!(Video, id)
    render conn, "show.html", video: video
  end
end

/web/templates/watch/show.html.eex

<h2><%= @video.title %></h2>
<div class="row">
  <div class="col-sm-7">
    <%= content_tag :div, id: "video",
          data: [id: @video.id, player_id: player_id(@video)] do %>
    <% end %>
  </div>
  <div class="col-sm-5">
    <div class="panel panel-default">
      <div class="panel-heading">
        <h3 class="panel-title">Annotations</h3>
      </div>
      <div id="msg-container" class="panel-body annotations">

      </div>
      <div class="panel-footer">
        <textarea id="msg-input"
                  rows="3"
                  class="form-control"
                  placeholder="Comment..."></textarea>
        <button id="msg-submit" class="btn btn-primary form-control"
type="submit">
          Post
        </button>
      </div>
    </div>
  </div>
</div>

/web/views/watch_view.ex

defmodule Rumbl.WatchView do
  use Rumbl.Web, :view

  def player_id(video) do
    ~r{^.*(?:youtu\.be/|\w+/|v=)(?<id>[^#&?]*)}
    |> Regex.named_captures(video.url)
    |> get_in(["id"])
  end
end

修改 /web/router.ex

  scope "/", Rumbl do
    pipe_through :browser # Use the default browser stack

    get "/", PageController, :index
    resources "/users", UserController, only: [:index, :show, :new, :create]

    resources "/sessions", SessionController, only: [:new, :create, :delete]

    get "/watch/:id", WatchController, :show
  end

修改 /web/templates/video/index.html.eex

<h2>Listing videos</h2>

<table class="table">
  <thead>
    <tr>
      <th>User</th>
      <th>Url</th>
      <th>Title</th>
      <th>Description</th>

      <th></th>
    </tr>
  </thead>
  <tbody>
<%= for video <- @videos do %>
    <tr>
      <td><%= video.user_id %></td>
      <td><%= video.url %></td>
      <td><%= video.title %></td>
      <td><%= video.description %></td>

      <td class="text-right">
        <%= link "Watch", to: watch_path(@conn, :show, video),
                          class: "btn btn-default btn-xs" %>

        <%= link "Edit", to: video_path(@conn, :edit, video),
                         class: "btn btn-default btn-xs" %>

        <%= link "Delete", to: video_path(@conn, :delete, video),
                           method: :delete,
                           data: [confirm: "Are you sure?"],
                           class: "btn btn-danger btn-xs" %>
      </td>
    </tr>
<% end %>
  </tbody>
</table>

<%= link "New video", to: video_path(@conn, :new) %>

Adding JavaScript

Brunch 是用 Node.js 撰寫的 build tool,Phoenix 使用 Brunch 去 build, transform, minify JS code,也能處理 css 及 assets。

Brunch 資料夾結構

web/static
  - assets
  - css
  - js
  - vendor

放在 assets 目錄是不需要 Brunch 轉換的資源,只會被複製到 priv/static,這個目錄是由 Phoenix.Static 作為 endpoint。

vendor 目錄是放 3rd party tools 例如 jQuery,external dependencies 不需要 import。

Brunch 是使用 ECMAScript6 (ES6) version,有支援 import 功能。每個 file 是一個 function,除非 import 到 app.js,否則不會自動被 browser 執行。

/web/static/js/app.js 裡面有一行

import "phoenix_html"

這就是 /web/templates/layout/app.html.eex 最後面 include 的 js

<script src="<%= static_path(@conn, "/js/app.js") %>"></script>

可在 brunch-config.js 填寫 Brunch 的設定

brunch 有三個指令

  1. brunch build

    build 所有 static files,compiling & copy 結果到 /priv/static

  2. brunch build --production

    build & minifies

  3. brunch watch

    開發時使用,brunch 會自動 recompile files。通常不需要執行,因為 Phoenix 已經有啟動了。

    /config/dev.exs 裡面有一行 watchers 設定

    watchers: [node: ["node_modules/brunch/bin/brunch", "watch", "--stdin",
                    cd: Path.expand("../", __DIR__)]]

新增 /web/static/js/player.js

let Player = {
  player: null,

  init(domId, playerId, onReady){
    window.onYouTubeIframeAPIReady = () => {
      this.onIframeReady(domId, playerId, onReady)
    }
    let youtubeScriptTag = document.createElement("script")
    youtubeScriptTag.src = "//www.youtube.com/iframe_api"
    document.head.appendChild(youtubeScriptTag)
  },

  onIframeReady(domId, playerId, onReady){
    this.player = new YT.Player(domId, {
      height: "360",
      width: "420",
      videoId: playerId,
      events: {
        "onReady":  (event => onReady(event) ),
        "onStateChange": (event => this.onPlayerStateChange(event) )
      }
    })
  },

  onPlayerStateChange(event){ },
  getCurrentTime(){ return Math.floor(this.player.getCurrentTime() * 1000) },
  seekTo(millsec){ return this.player.seekTo(millsec / 1000) }
}
export default Player

修改 /web/static/js/app.js,這樣才會編譯 player.js

import "phoenix_html"

import Player from "./player"
let video = document.getElementById("video")

if(video) {
    Player.init(video.id, video.getAttribute("data-player-id"), () => {
        console.log("player ready!")
    })
}

新增 /web/static/css/video.css

#msg-container {
  min-height: 190px;
}

Creating Slugs

如希望 videos 有一個唯一的 URL-friendly identified,稱為 slug,就需要一個 table 欄位,記錄給 search engine 使用的 unique URL。ex: 1-elixir

add a slug column to table videos

mix ecto.gen.migration add_slug_to_video

修改 migration

defmodule Rumbl.Repo.Migrations.AddSlugToVideo do
  use Ecto.Migration

  def change do
    alter table(:videos) do
      add :slug, :string
    end
  end
end

升級 DB

$ mix ecto.migrate
[info] == Running Rumbl.Repo.Migrations.AddSlugToVideo.change/0 forward
[info] alter table videos
[info] == Migrated in 0.0s

修改 /web/models/video.ex,增加 slug 欄位,並在 changeset 加上 slugify_title()


defmodule Rumbl.Video do
  use Rumbl.Web, :model

  schema "videos" do
    field :url, :string
    field :title, :string
    field :description, :string
    field :slug, :string
    belongs_to :user, Rumbl.User
    belongs_to :category, Rumbl.Category

    timestamps
  end

  @required_fields ~w(url title description)
  @optional_fields ~w(category_id)

  def changeset(model, params \\ %{}) do
    model
    |> cast(params, @required_fields, @optional_fields)
    |> slugify_title()
    |> assoc_constraint(:category)
  end

  defp slugify_title(changeset) do
    if title = get_change(changeset, :title) do
      put_change(changeset, :slug, slugify(title))
    else
      changeset
    end
  end

  defp slugify(str) do
    str
    |> String.downcase()
    |> String.replace(~r/[^\w-]+/u, "-")
  end
end
  • 因 Ecto 區隔了 changeset 及 record 定義,可將 change policy 分開,也能在 create video 的 JSON API 加上 slug

  • changeset 會 filter and cast 新資料,確保一些敏感資料不會從系統外面進來

  • changeset 可以 validate 資料

  • changeset 讓程式碼更容易閱讀及實作


Extending Phoenix with Protocols

查看 /web/templates/video/index.html.eex 產生 link 的部分

<%= link "Watch", to: watch_path(@conn, :show, video),
class: "btn btn-default btn-xs" %>

為了改用 slug,就修改為

watch_path(@conn, :show, "#{video.id}-#{video.slug}")

Phoenix.Param 是 Elixir Protocol,可謂任意一個 data type 自訂此參數。

修改 /web/models/video.ex 增加 defimpl Phoenix.Param, for: Rumbl.Video

  defimpl Phoenix.Param, for: Rumbl.Video do
    def to_param(%{slug: slug, id: id}) do
      "#{id}-#{slug}"
    end
  end

IEx 測試

iex(1)> video = %Rumbl.Video{id: 1, slug: "hello"}
%Rumbl.Video{__meta__: #Ecto.Schema.Metadata<:built, "videos">,
 category: #Ecto.Association.NotLoaded<association :category is not loaded>,
 category_id: nil, description: nil, id: 1, inserted_at: nil, slug: "hello",
 title: nil, updated_at: nil, url: nil,
 user: #Ecto.Association.NotLoaded<association :user is not loaded>,
 user_id: nil}


iex(2)> Rumbl.Router.Helpers.watch_path(%URI{}, :show, video)
"/watch/1-hello"
iex(4)> url = URI.parse("http://example.com/prefix")
%URI{authority: "example.com", fragment: nil, host: "example.com",
 path: "/prefix", port: 80, query: nil, scheme: "http", userinfo: nil}
iex(5)> Rumbl.Router.Helpers.watch_path(url, :show, video)
"/prefix/watch/1-hello"
iex(6)> Rumbl.Router.Helpers.watch_url(url, :show, video)
"http://example.com/prefix/watch/1-hello"

可使用 Rumbl.Endpoint.struct_url

iex(8)> url = Rumbl.Endpoint.struct_url
%URI{authority: nil, fragment: nil, host: "localhost", path: nil, port: 4000,
 query: nil, scheme: "http", userinfo: nil}
iex(9)> Rumbl.Router.Helpers.watch_url(url, :show, video)
"http://localhost:4000/watch/1-hello"

Extending Schemas with Ecto Types

新增 /lib/rumbl/permalink.ex


defmodule Rumbl.Permalink do
  @behaviour Ecto.Type

  def type, do: :id

  def cast(binary) when is_binary(binary) do
    case Integer.parse(binary) do
      {int, _} when int > 0 -> {:ok, int}
      _ -> :error
    end
  end

  def cast(integer) when is_integer(integer) do
    {:ok, integer}
  end

  def cast(_) do
    :error
  end

  def dump(integer) when is_integer(integer) do
    {:ok, integer}
  end

  def load(integer) when is_integer(integer) do
    {:ok, integer}
  end
end

Rumbl.Permalink 是根據 Ecto.Type behavior 定義的 custom type,需要定義四個 functions

  1. type

    回傳 underlying Ecto type,目前是以 :id 來建構

  2. cast

    當 external data 傳入 Ecto 時會呼叫,在 values in queries 被 interpolated 或是在 changeset 的 cast 被呼叫

  3. dump

    當 data 發送給 database 時被呼叫

  4. load

    由 DB 載入資料時被呼叫

iex(1)> alias Rumbl.Permalink, as: P
Rumbl.Permalink
iex(2)> P.cast "1"
{:ok, 1}
iex(3)> P.cast 1
{:ok, 1}
iex(4)> P.cast "13-hello-world"
{:ok, 13}
iex(5)> P.cast "hello-world-13"
:error

web/models/video.ex 增加 @primary_key

  @primary_key {:id, Rumbl.Permalink, autogenerate: true}
  schema "videos" do

就可以使用 http://localhost:4000/watch/2-elixir 這樣的 URL

References

Programming Phoenix