Powered by AppSignal & Oban Pro

Trilha Phoenix · LiveView

16_liveview.livemd

Trilha Phoenix · LiveView

Mix.install(
  [
    {:phoenix, "~> 1.8.0"},
    {:phoenix_live_view, "~> 1.1"},
    {:phoenix_ecto, "~> 4.6"},
    {:ecto_sql, "~> 3.13"},
    {:postgrex, ">= 0.0.0"},
    {:bandit, "~> 1.5"},
    {:jason, "~> 1.4"},
    {:kino, "~> 0.14"},
    {:swoosh, "~> 1.16"},
    {:pbkdf2_elixir, "~> 2.0"}
  ],
  consolidate_protocols: false,
  config: [
    phoenix: [json_library: Jason, plug_init_mode: :runtime],
    swoosh: [api_client: false],
    hello: [
      {HelloWeb.Endpoint,
       [
         adapter: Bandit.PhoenixAdapter,
         http: [ip: {127, 0, 0, 1}, port: 4010],
         server: true,
         debug_errors: true,
         secret_key_base: String.duplicate("trilha-phoenix-", 5),
         render_errors: [formats: [html: HelloWeb.ErrorHTML, json: HelloWeb.ErrorJSON], layout: false],
         pubsub_server: Hello.PubSub
       ]},
      {Hello.Mailer, [adapter: Swoosh.Adapters.Local]}
    ]
  ]
)

defmodule Trilha do
  @moduledoc """
  Apoio deste notebook: faz requisições ao endpoint, abre LiveViews por um
  WebSocket de verdade (como o navegador faz) e mostra as mensagens que passam
  por ele, encontra os processos das LiveViews no servidor, verifica a conexão
  com o PostgreSQL, confere os exercícios e guarda as perguntas e as soluções.

  Não faz parte do Phoenix nem do Ecto. Você não precisa ler este módulo para seguir o guia.
  """

  @endpoint HelloWeb.Endpoint
  @router HelloWeb.Router
  @repo Hello.Repo

  # --- banco de dados ------------------------------------------------------------

  @doc "Tenta uma conexão com o PostgreSQL usando a configuração do Hello.Repo."
  def verificar_postgres(extra \\ []) do
    config = Keyword.merge(apply(@repo, :config, []), extra)
    onde = "`#{config[:username]}@#{config[:hostname]}:#{config[:port] || 5432}`"

    # Conecta ao banco "postgres", que sempre existe: o banco da trilha talvez ainda não tenha sido criado.
    resultado =
      try do
        Postgrex.Protocol.connect(Postgrex.Utils.default_opts(Keyword.put(config, :database, "postgres")))
      rescue
        erro -> {:error, erro}
      end

    case resultado do
      {:ok, estado} ->
        Postgrex.Protocol.disconnect(%RuntimeError{message: "fim da verificação"}, estado)
        Kino.Markdown.new("**Conectou** ao PostgreSQL em #{onde}. Pode seguir.")

      {:error, erro} ->
        Kino.Markdown.new(
          "**Não conectou** ao PostgreSQL em #{onde}. A mensagem do Postgrex:\n\n" <>
            cerca(Exception.message(erro), "") <> "\n\n" <> dica_conexao(erro)
        )
    end
  end

  defp dica_conexao(%Postgrex.Error{postgres: %{pg_code: "28P01"}}) do
    "**Senha recusada.** Confira `username` e `password` na célula `config/dev.exs`. " <>
      "Com autenticação por senha, o PostgreSQL dá esta mesma resposta quando o usuário não existe."
  end

  defp dica_conexao(%Postgrex.Error{postgres: %{pg_code: "28000", message: mensagem}}) do
    cond do
      mensagem =~ ~r/ident|peer/i ->
        "**O servidor não aceita senha para esta conexão.** Ele está configurado para autenticação `ident` ou `peer`, " <>
          "o padrão de algumas distribuições Linux, como o Fedora. O `LEIA-ME.md` da trilha mostra como trocar para senha no `pg_hba.conf`."

      mensagem =~ "does not exist" ->
        "**O usuário não existe no PostgreSQL.** Crie-o no `psql` ou troque `username` na célula `config/dev.exs`."

      true ->
        "**O servidor recusou a conexão.** Leia a mensagem acima e confira o `pg_hba.conf` do servidor."
    end
  end

  defp dica_conexao(%DBConnection.ConnectionError{message: mensagem}) do
    cond do
      mensagem =~ "econnrefused" ->
        "**Nada responde nesse endereço.** O servidor PostgreSQL está parado, ou escuta em outra porta. O `LEIA-ME.md` mostra como iniciá-lo."

      mensagem =~ "nxdomain" ->
        "**O nome do host não existe.** Confira `hostname` na célula `config/dev.exs`."

      true ->
        "**A conexão falhou antes de chegar ao PostgreSQL.** Confira `hostname` e `port` na célula `config/dev.exs`."
    end
  end

  defp dica_conexao(_erro), do: "Confira a configuração na célula `config/dev.exs`."

  @doc "Apaga todas as tabelas do banco da trilha, inclusive schema_migrations, e esquece os visitantes."
  def recomecar do
    sql("DROP SCHEMA public CASCADE")
    sql("CREATE SCHEMA public")
    esquecer_visitantes()

    Kino.Markdown.new(
      "O banco `#{apply(@repo, :config, [])[:database]}` está vazio: nenhuma tabela, nenhuma migration registrada."
    )
  end

  @doc "Registra uma migration com a versão que, no projeto, viria do nome do arquivo."
  def migracao(versao, modulo) do
    :persistent_term.put({__MODULE__, :migracoes}, Map.put(mapa_migracoes(), versao, modulo))
    nome = modulo |> Module.split() |> List.last() |> Macro.underscore()

    Kino.Markdown.new("No projeto, esta migration seria o arquivo `priv/repo/migrations/#{versao}_#{nome}.exs`.")
  end

  @doc "As migrations registradas, como pares {versão, módulo}, em ordem de versão."
  def migracoes, do: mapa_migracoes() |> Enum.sort()

  defp mapa_migracoes, do: :persistent_term.get({__MODULE__, :migracoes}, %{})

  @doc "Lista as tabelas e sequências do banco, como o comando \\d do psql."
  def relacoes do
    resultado =
      sql("""
      SELECT n.nspname AS "Schema", c.relname AS "Name",
             CASE c.relkind WHEN 'r' THEN 'table' WHEN 'S' THEN 'sequence' WHEN 'v' THEN 'view' END AS "Type",
             pg_get_userbyid(c.relowner) AS "Owner"
      FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace
      WHERE n.nspname = 'public' AND c.relkind IN ('r', 'S', 'v')
      ORDER BY c.relname
      """)

    if resultado.rows == [] do
      Kino.Markdown.new("`\\d`: o banco não tem nenhuma tabela.")
    else
      Kino.Markdown.new("**`\\d`**\n\n" <> tabela_markdown(resultado))
    end
  end

  @doc "Mostra as colunas, os índices e as chaves estrangeiras de uma tabela, como o comando \\d TABELA do psql."
  def descrever(tabela) do
    colunas =
      sql(
        """
        SELECT a.attname AS "Column", format_type(a.atttypid, a.atttypmod) AS "Type",
               CASE WHEN a.attnotnull THEN 'not null' ELSE '' END AS "Nullable",
               coalesce(pg_get_expr(d.adbin, d.adrelid), '') AS "Default"
        FROM pg_attribute a
        LEFT JOIN pg_attrdef d ON d.adrelid = a.attrelid AND d.adnum = a.attnum
        WHERE a.attrelid = to_regclass($1) AND a.attnum > 0 AND NOT a.attisdropped
        ORDER BY a.attnum
        """,
        [tabela]
      )

    if colunas.rows == [] do
      Kino.Markdown.new("`\\d #{tabela}`: a tabela `#{tabela}` não existe.")
    else
      indices = sql("SELECT indexdef FROM pg_indexes WHERE schemaname = 'public' AND tablename = $1 ORDER BY indexname", [tabela])
      lista = Enum.map_join(indices.rows, "\n", fn [definicao] -> "* `#{definicao}`" end)

      chaves =
        sql(
          """
          SELECT conname, pg_get_constraintdef(oid) FROM pg_constraint
          WHERE conrelid = to_regclass($1) AND contype = 'f' ORDER BY conname
          """,
          [tabela]
        )

      fks =
        if chaves.rows == [],
          do: "",
          else: "\n\n**Chaves estrangeiras**\n\n" <> Enum.map_join(chaves.rows, "\n", fn [nome, definicao] -> "* `#{nome}` #{definicao}" end)

      Kino.Markdown.new(
        "**`\\d #{tabela}`**\n\n" <>
          tabela_markdown(colunas) <> "\n\n**Índices**\n\n" <> if(lista == "", do: "nenhum", else: lista) <> fks
      )
    end
  end

  @doc "Executa um SQL e mostra o resultado como tabela."
  def consultar(consulta, parametros \\ []) do
    resultado = sql(consulta, parametros)
    Kino.Markdown.new(cerca(consulta, "sql") <> "\n\n" <> tabela_markdown(resultado))
  end

  defp sql(consulta, parametros \\ []) do
    apply(@repo, :query!, [consulta, parametros, [log: false]])
  end

  defp tabela_markdown(%{columns: colunas, rows: linhas}) do
    cabecalho = "| " <> Enum.join(colunas, " | ") <> " |"
    separador = "| " <> Enum.map_join(colunas, " | ", fn _ -> "---" end) <> " |"

    corpo =
      Enum.map(linhas, fn linha ->
        "| " <> Enum.map_join(linha, " | ", &celula/1) <> " |"
      end)

    Enum.join([cabecalho, separador | corpo], "\n")
  end

  defp celula(nil), do: ""
  defp celula(""), do: ""
  defp celula(valor) when is_binary(valor), do: "`" <> String.replace(valor, "|", "\\|") <> "`"
  defp celula(%Decimal{} = valor), do: "`" <> Decimal.to_string(valor) <> "`"
  defp celula(valor), do: "`" <> (valor |> inspect() |> String.replace("|", "\\|")) <> "`"

  # --- requisições -----------------------------------------------------------

  @doc "Despacha uma requisição para o endpoint, sem passar pela rede."
  def requisicao(metodo \\ :get, caminho, parametros \\ nil, conn \\ Phoenix.ConnTest.build_conn()) do
    descartar_respostas_antigas()

    conn = Phoenix.ConnTest.dispatch(conn, @endpoint, metodo, caminho, parametros)

    # Para NoRouteError (e só para ela), o Phoenix envia a página de erro e
    # devolve o conn sem relançar a exceção. Reconstruímos a exceção aqui.
    if conn.state == :sent and conn.status == nil do
      {:erro, Phoenix.Router.NoRouteError.exception(conn: conn, router: @router)}
    else
      {:ok, conn}
    end
  rescue
    erro -> {:erro, desembrulhar(erro)}
  end

  # Cada resposta enviada pelo adaptador de testes deixa duas mensagens no
  # processo que fez a requisição. Se ficarem acumuladas, o Phoenix acha que a
  # próxima página de erro já foi enviada e não a renderiza.
  defp descartar_respostas_antigas do
    receive do
      {ref, {status, cabecalhos, _corpo}} when is_reference(ref) and is_integer(status) and is_list(cabecalhos) ->
        descartar_respostas_antigas()

      {:plug_conn, :sent} ->
        descartar_respostas_antigas()
    after
      0 -> :ok
    end
  end

  defp desembrulhar(%Plug.Conn.WrapperError{reason: motivo}), do: desembrulhar(motivo)
  defp desembrulhar(erro), do: erro

  @doc "Faz a requisição e mostra o status, o content-type e o corpo da resposta."
  def mostrar(metodo \\ :get, caminho, parametros \\ nil) do
    titulo = "### #{metodo |> to_string() |> String.upcase()} #{caminho}"

    corpo =
      case requisicao(metodo, caminho, parametros) do
        {:ok, conn} ->
          tipo = conn |> Plug.Conn.get_resp_header("content-type") |> Enum.join()

          "**Status** `#{conn.status}` · **content-type** `#{tipo}`\n\n" <>
            cerca(to_string(conn.resp_body), if(tipo =~ "json", do: "json", else: "html"))

        {:erro, erro} ->
          "**A requisição levantou uma exceção:**\n\n" <> cerca(Exception.format_banner(:error, erro), "")
      end

    Kino.Markdown.new(titulo <> "\n\n" <> corpo)
  end

  # --- navegador ---------------------------------------------------------------

  @doc """
  Faz uma requisição como um navegador: envia os cookies que o visitante `quem`
  recebeu antes (a sessão, onde ficam a flash e o login) e segue os redirects.
  `quem` é um nome qualquer, como "ana"; cada nome tem os seus próprios cookies.
  Devolve `{:ok, conn}` com a última resposta, ou `{:erro, exceção}`.
  """
  def visitar(quem, metodo, caminho, parametros \\ nil) do
    quem |> seguir(metodo, caminho, parametros, []) |> List.last() |> elem(1)
  end

  @doc "Como `visitar/4`, mas mostra cada passo, a flash e o texto da página final."
  def navegar(quem, metodo \\ :get, caminho, parametros \\ nil) do
    passos = seguir(quem, metodo, caminho, parametros, [])

    linhas =
      passos
      |> Enum.with_index(1)
      |> Enum.map(fn {{requisicao, resultado}, i} -> linha_passo(i, requisicao, resultado) end)

    cabecalho = [
      "### #{quem}: #{rotulo(metodo, caminho)}",
      "",
      "| # | Requisição | Status | Redireciona para |",
      "| --- | --- | --- | --- |"
    ]

    Kino.Markdown.new(Enum.join(cabecalho ++ linhas, "\n") <> "\n\n" <> resumo_final(List.last(passos)))
  end

  @doc "Esquece os cookies de todos os visitantes, como se cada um abrisse um navegador novo."
  def esquecer_visitantes do
    for {{__MODULE__, :cookies, _} = chave, _} <- :persistent_term.get(), do: :persistent_term.erase(chave)
    :ok
  end

  defp seguir(quem, metodo, caminho, parametros, feitos) do
    conn = Enum.reduce(cookies(quem), Phoenix.ConnTest.build_conn(), fn {nome, valor}, c -> Plug.Test.put_req_cookie(c, nome, valor) end)
    resultado = requisicao(metodo, caminho, parametros, conn)
    feitos = feitos ++ [{rotulo(metodo, caminho), resultado}]

    with {:ok, resp} <- resultado do
      guardar_cookies(quem, resp)
    end

    with {:ok, resp} <- resultado,
         true <- resp.status in 300..399 and length(feitos) < 6,
         [destino] <- Plug.Conn.get_resp_header(resp, "location"),
         true <- String.starts_with?(destino, "/") do
      seguir(quem, :get, destino, nil, feitos)
    else
      _ -> feitos
    end
  end

  defp cookies(quem), do: :persistent_term.get({__MODULE__, :cookies, quem}, %{})

  defp guardar_cookies(quem, resp) do
    novos =
      Enum.reduce(resp.resp_cookies, cookies(quem), fn {nome, opcoes}, acc ->
        if opcoes[:max_age] == 0, do: Map.delete(acc, nome), else: Map.put(acc, nome, opcoes[:value])
      end)

    :persistent_term.put({__MODULE__, :cookies, quem}, novos)
  end

  defp rotulo(metodo, caminho), do: "#{metodo |> to_string() |> String.upcase()} #{caminho}"

  defp linha_passo(i, requisicao, {:ok, conn}) do
    destino = conn |> Plug.Conn.get_resp_header("location") |> List.first("")
    "| #{i} | `#{requisicao}` | `#{conn.status}` | #{if destino == "", do: "", else: "`#{destino}`"} |"
  end

  defp linha_passo(i, requisicao, {:erro, erro}) do
    "| #{i} | `#{requisicao}` | exceção: `#{erro.__struct__ |> inspect()}` | |"
  end

  defp resumo_final({_requisicao, {:ok, conn}}) do
    flash =
      case conn.assigns[:flash] do
        mapa when is_map(mapa) and map_size(mapa) > 0 ->
          "**Flash:** " <> Enum.map_join(mapa, " · ", fn {tipo, msg} -> "#{tipo}: \"#{msg}\"" end) <> "\n\n"

        _ ->
          ""
      end

    flash <> "**Texto da página** (sem as tags):\n\n" <> cerca(texto(to_string(conn.resp_body)), "")
  end

  defp resumo_final({_requisicao, {:erro, erro}}) do
    "**A requisição levantou uma exceção:**\n\n" <> cerca(Exception.format_banner(:error, erro), "")
  end

  @doc "O texto de uma página HTML, sem as tags: o que se lê no navegador."
  def texto(html) do
    conteudo =
      case Regex.run(~r{<main[^>]*>(.*)</main>}s, html) do
        [_, dentro] -> dentro
        nil -> html
      end

    conteudo
    |> String.replace(~r{<(script|style|head)[^>]*>.*?</\1>}s, "")
    |> String.replace(~r{<input[^>]*type="hidden"[^>]*>}, "")
    |> String.replace(~r{<option[^>]*\sselected[^>]*>}, "[x] ")
    |> String.replace(~r{<option[^>]*>}, "[ ] ")
    |> then(&Regex.replace(~r{<input[^>]*\svalue="([^"]*)"[^>]*>}, &1, fn _, valor -> if valor == "", do: " ", else: " [" <> valor <> "] " end))
    |> String.replace(~r{</?(p|div|li|tr|h\d|header|footer|ul|table|thead|tbody|form|label|select|option|section|br)[^>]*>}, "\n")
    |> String.replace(~r{</t[dh]>}, "  ")
    |> String.replace(~r{<[^>]+>}, "")
    |> String.replace(["&amp;", "<", ">", """, "'", "&nbsp;"], fn
      "&amp;" -> "&"
      "<" -> "<"
      ">" -> ">"
      """ -> "\""
      "'" -> "'"
      "&nbsp;" -> " "
    end)
    |> String.split("\n")
    |> Enum.map(&(&1 |> String.replace(~r/[ \t]+/, " ") |> String.trim()))
    |> Enum.reject(&(&1 == ""))
    |> Enum.join("\n")
  end

  defp cerca(texto, linguagem) do
    marca = String.duplicate("`", 3)
    marca <> linguagem <> "\n" <> String.trim(texto) <> "\n" <> marca
  end

  # --- curl ------------------------------------------------------------------------

  @doc """
  Roda um comando curl de verdade contra o servidor do notebook, por uma conexão
  TCP, e mostra a resposta como o curl mostraria. Entende -i, -X, -H e -d.
  """
  def curl(comando) do
    {requisicao, resposta} = executar_curl(comando)
    corpo = resumir_corpo(resposta)
    cabeca = resposta.bruta |> String.split("\n\n", parts: 2) |> hd()
    saida = if requisicao.incluir_cabecalhos, do: cabeca <> "\n\n" <> corpo, else: corpo
    Kino.Markdown.new(cerca("$ " <> String.trim(comando), "console") <> "\n\n" <> cerca(saida, ""))
  end

  # A página de depuração do Plug.Debugger tem dezenas de KB; mostramos só o começo.
  defp resumir_corpo(%{corpo: corpo} = resposta) when byte_size(corpo) > 3000 do
    tipo = resposta.cabecalhos |> List.keyfind("content-type", 0, {nil, ""}) |> elem(1)

    resumo =
      cond do
        tipo =~ "text/html" ->
          titulo =
            case Regex.run(~r{<title>(.*?)</title>}s, corpo) do
              [_, t] -> String.trim(t)
              nil -> "?"
            end
          "<!-- página HTML de #{byte_size(corpo)} bytes, título: #{titulo} -->"

        true ->
          corpo |> String.split("\n") |> Enum.take(12) |> Enum.join("\n")
      end

    resumo <> "\n[... #{byte_size(corpo)} bytes no total; o Trilha.curl mostra só o começo]"
  end

  defp resumir_corpo(%{corpo: corpo}), do: corpo

  @doc "Como curl/1, mas devolve %{status:, cabecalhos:, corpo:, json:} para conferências."
  def curl!(comando), do: comando |> executar_curl() |> elem(1)

  defp executar_curl(comando) do
    requisicao = interpretar_curl(comando)
    {requisicao, enviar_http(requisicao)}
  end

  defp interpretar_curl(comando) do
    palavras =
      Regex.scan(~r/'([^']*)'|"((?:[^"\\]|\\.)*)"|(\S+)/, String.replace(comando, "\\\n", " "))
      |> Enum.map(fn
        [_, simples] -> simples
        [_, "", duplas] -> String.replace(duplas, ~s(\\"), ~s("))
        [_, "", "", solta] -> solta
      end)
      |> Enum.drop_while(&(&1 in ["$", "curl"]))

    inicial = %{metodo: nil, url: nil, cabecalhos: [], dados: nil, incluir_cabecalhos: false}

    requisicao = ler_opcoes_curl(palavras, inicial)
    metodo = requisicao.metodo || if(requisicao.dados, do: "POST", else: "GET")
    %{requisicao | metodo: metodo}
  end

  defp ler_opcoes_curl([], acc), do: acc
  defp ler_opcoes_curl(["-H", cabecalho | resto], acc), do: ler_opcoes_curl(resto, %{acc | cabecalhos: acc.cabecalhos ++ [cabecalho]})
  defp ler_opcoes_curl(["-d", dados | resto], acc), do: ler_opcoes_curl(resto, %{acc | dados: dados})
  defp ler_opcoes_curl(["-X", metodo | resto], acc), do: ler_opcoes_curl(resto, %{acc | metodo: metodo})

  defp ler_opcoes_curl(["-" <> letras | resto], acc) when letras in ["i", "iX", "Xi"] do
    acc = %{acc | incluir_cabecalhos: true}

    case {String.contains?(letras, "X"), resto} do
      {true, [metodo | resto]} -> ler_opcoes_curl(resto, %{acc | metodo: metodo})
      {false, _} -> ler_opcoes_curl(resto, acc)
    end
  end

  defp ler_opcoes_curl([url | resto], acc), do: ler_opcoes_curl(resto, %{acc | url: url})

  defp enviar_http(requisicao) do
    porta = apply(@endpoint, :config, [:http])[:port]
    %URI{path: caminho, query: consulta} = URI.parse(requisicao.url)
    alvo = (caminho || "/") <> if(consulta, do: "?" <> consulta, else: "")
    corpo = requisicao.dados || ""

    tipo =
      if requisicao.dados && !Enum.any?(requisicao.cabecalhos, &(&1 =~ ~r/^content-type:/i)),
        do: ["Content-Type: application/x-www-form-urlencoded"],
        else: []

    # Como o curl: um cabeçalho passado com -H substitui o padrão de mesmo nome.
    padrao =
      Enum.reject(["User-Agent: curl/8", "Accept: */*"], fn linha ->
        nome = linha |> String.split(":") |> hd() |> String.downcase()
        Enum.any?(requisicao.cabecalhos, &(&1 |> String.split(":") |> hd() |> String.downcase() == nome))
      end)

    linhas =
      ["#{requisicao.metodo} #{alvo} HTTP/1.1", "Host: localhost:#{porta}"] ++
        padrao ++ requisicao.cabecalhos ++ tipo ++
        if(corpo != "", do: ["Content-Length: #{byte_size(corpo)}"], else: []) ++ ["Connection: close", "", corpo]

    socket =
      case :gen_tcp.connect(~c"127.0.0.1", porta, [:binary, active: false]) do
        {:ok, socket} ->
          socket

        {:error, motivo} ->
          raise "nada respondeu na porta #{porta} (#{inspect(motivo)}). Avalie a célula que inicia o HelloWeb.Endpoint."
      end

    :ok = :gen_tcp.send(socket, Enum.join(linhas, "\r\n"))
    bruta = receber_tudo(socket, "")
    :gen_tcp.close(socket)

    [cabeca, corpo_resposta] = String.split(bruta, "\r\n\r\n", parts: 2)
    [linha_status | linhas_cabecalho] = String.split(cabeca, "\r\n")
    [_, status | _] = String.split(linha_status, " ")

    cabecalhos =
      Enum.map(linhas_cabecalho, fn linha ->
        [nome, valor] = String.split(linha, ": ", parts: 2)
        {String.downcase(nome), valor}
      end)

    json =
      case Jason.decode(corpo_resposta) do
        {:ok, valor} -> valor
        _ -> nil
      end

    %{
      status: String.to_integer(status),
      cabecalhos: cabecalhos,
      corpo: corpo_resposta,
      json: json,
      bruta: String.replace(cabeca, "\r\n", "\n") <> "\n\n" <> corpo_resposta
    }
  end

  defp receber_tudo(socket, acc) do
    case :gen_tcp.recv(socket, 0, 5000) do
      {:ok, dados} -> receber_tudo(socket, acc <> dados)
      {:error, :closed} -> acc
    end
  end

  # --- LiveView pelo fio ------------------------------------------------------------

  @doc """
  Abre uma LiveView como o navegador faz: um GET normal (a renderização estática,
  com os cookies do visitante `como:`), depois uma conexão WebSocket em
  /live/websocket e o "phx_join" no tópico da LiveView.

  Devolve `{lv, resposta_do_join}`. A resposta traz a primeira renderização
  completa: estáticos e dinâmicos.
  """
  def abrir_lv(caminho, opcoes \\ []) do
    porta = apply(@endpoint, :config, [:http])[:port]
    quem = Keyword.get(opcoes, :como, "lv-#{System.unique_integer([:positive])}")

    # 1. A renderização estática, por HTTP.
    conn =
      case visitar(quem, :get, caminho) do
        {:ok, conn} -> conn
        {:erro, erro} -> raise erro
      end

    html = to_string(conn.resp_body)
    csrf = atributo_html(html, ~r/<meta name="csrf-token" content="([^"]+)"/)

    id =
      atributo_html(html, ~r/<div[^>]*\sid="(phx-[^"]+)"[^>]*data-phx-main/) ||
        atributo_html(html, ~r/<div[^>]*data-phx-main[^>]*\sid="(phx-[^"]+)"/)

    sessao = atributo_html(html, ~r/data-phx-session="([^"]+)"/)
    estatico = atributo_html(html, ~r/data-phx-static="([^"]*)"/)

    if is_nil(id) or is_nil(sessao),
      do: raise("a página #{caminho} não tem uma LiveView (status #{conn.status})")

    # 2. O WebSocket, com o cookie da sessão: o LiveView confere o token CSRF contra ele.
    cookie = Enum.map_join(cookies(quem), "; ", fn {nome, valor} -> "#{nome}=#{valor}" end)
    {:ok, socket} = :gen_tcp.connect(~c"127.0.0.1", porta, [:binary, active: false])
    chave = Base.encode64(:crypto.strong_rand_bytes(16))

    pedido =
      "GET /live/websocket?_csrf_token=#{URI.encode_www_form(csrf)}&vsn=2.0.0 HTTP/1.1\r\n" <>
        "Host: localhost:#{porta}\r\nUpgrade: websocket\r\nConnection: Upgrade\r\n" <>
        "Sec-WebSocket-Key: #{chave}\r\nSec-WebSocket-Version: 13\r\nCookie: #{cookie}\r\n\r\n"

    :ok = :gen_tcp.send(socket, pedido)
    {:ok, resposta} = :gen_tcp.recv(socket, 0, 5000)
    [cabeca, resto] = String.split(resposta, "\r\n\r\n", parts: 2)

    unless cabeca =~ "101", do: raise("o servidor recusou o WebSocket:\n" <> cabeca)

    lv = %{socket: socket, buffer: resto, topico: "lv:" <> id, join_ref: "1", ref: 1, quem: quem, porta: porta}

    # 3. O join no tópico da LiveView.
    url = "http://localhost:#{porta}#{caminho}"
    payload = %{"url" => url, "params" => %{"_csrf_token" => csrf, "_mounts" => 0}, "session" => sessao, "static" => estatico}
    enviar_quadro(lv, [lv.join_ref, "1", lv.topico, "phx_join", payload])
    esperar_resposta(%{lv | ref: 2}, "1")
  end

  @doc """
  Um clique (phx-click). Como o navegador, manda os phx-value-* do elemento
  mais o `value` dele (num `<button>`, uma string vazia). Devolve `{lv, resposta}`.
  """
  def clicar(lv, evento, valores \\ %{}) do
    enviar_evento(lv, "click", evento, Map.merge(%{"value" => ""}, valores))
  end

  @doc "phx-blur: o campo perdeu o foco. Manda o valor que estava no campo."
  def desfocar(lv, evento, valor), do: enviar_evento(lv, "blur", evento, %{"value" => valor})

  @doc """
  phx-change: um campo do formulário mudou. `campos` é o formulário inteiro, como
  mapa (`%{"meta" => %{"valor" => "95"}}`); `alvo` é o nome do campo que mudou
  (`"meta[valor]"`). O navegador manda o formulário codificado como numa URL.
  """
  def mudar(lv, evento, campos, alvo) do
    enviar(lv, "event", %{
      "type" => "form",
      "event" => evento,
      "value" => Plug.Conn.Query.encode(campos),
      "meta" => %{"_target" => alvo}
    })
  end

  @doc "phx-submit: o formulário foi enviado."
  def submeter(lv, evento, campos) do
    enviar(lv, "event", %{"type" => "form", "event" => evento, "value" => Plug.Conn.Query.encode(campos), "meta" => %{}})
  end

  @doc "Um evento qualquer, montado à mão: tipo (\"click\", \"blur\"...), nome e valor."
  def enviar_evento(lv, tipo, evento, valor) do
    enviar(lv, "event", %{"type" => tipo, "event" => evento, "value" => valor})
  end

  @doc """
  Um live patch, como o de `<.link patch={...}>`: a URL muda, a LiveView continua
  no mesmo processo e o servidor chama `handle_params/3`.
  """
  def patch(lv, caminho) do
    enviar(lv, "live_patch", %{"url" => "http://localhost:#{lv.porta}#{caminho}"})
  end

  defp enviar(lv, evento, payload) do
    ref = Integer.to_string(lv.ref)
    enviar_quadro(lv, [lv.join_ref, ref, lv.topico, evento, payload])
    esperar_resposta(%{lv | ref: lv.ref + 1}, ref)
  end

  @doc """
  Espera uma mensagem que o servidor empurra sem ter sido pedida (um diff vindo
  de `handle_info/2`, por exemplo). Devolve `{lv, diff}`, `{lv, {evento, payload}}`
  para outras mensagens, ou `{lv, :nada}` se nada chegar no tempo dado.
  """
  def receber(lv, tempo \\ 1000) do
    case ler_mensagem(lv, tempo) do
      {lv, [_, nil, _topico, "diff", diff]} -> {lv, diff}
      {lv, [_, _ref, _topico, evento, payload]} -> {lv, {evento, payload}}
      {lv, nil} -> {lv, :nada}
    end
  end

  @doc "Fecha a conexão. O processo da LiveView no servidor termina."
  def desconectar(lv) do
    :gen_tcp.close(lv.socket)
    :ok
  end

  @doc "Mostra uma mensagem do fio como JSON, como aparece nas ferramentas do navegador."
  def json(valor), do: Kino.Markdown.new(cerca(Jason.encode!(valor, pretty: true), "json"))

  @doc "O pid do processo que roda a LiveView desta conexão, no servidor."
  def processo(lv) do
    Enum.find(processos_lv(), fn pid -> estado_canal(pid)[:topic] == lv.topico end)
  end

  @doc "Os assigns da LiveView desta conexão, lidos de dentro do processo dela."
  def estado(lv) do
    case processo(lv) do
      nil -> raise "a LiveView desta conexão não está rodando (o processo terminou?)"
      pid -> assigns_de(pid)
    end
  end

  @doc "Uma tabela com os processos de LiveView de um módulo e o estado de cada um."
  def processos(modulo) do
    linhas =
      for pid <- processos_lv(), match?({^modulo, :mount, 3}, inicial(pid)) do
        assigns = assigns_de(pid) |> Map.drop([:flash, :live_action]) |> inspect()
        "| `#{inspect(pid)}` | `#{String.replace(assigns, "|", "\\|")}` |"
      end

    if linhas == [] do
      Kino.Markdown.new("Nenhum processo de `#{inspect(modulo)}` rodando agora.")
    else
      Kino.Markdown.new(Enum.join(["| Processo | Assigns |", "| --- | --- |" | linhas], "\n"))
    end
  end

  defp processos_lv do
    Enum.filter(Process.list(), fn pid -> match?({_, :mount, 3}, inicial(pid)) end)
  end

  # O LiveView marca cada processo com {módulo, :mount, 3}, para ferramentas como o Observer.
  defp inicial(pid) do
    case Process.info(pid, :dictionary) do
      {:dictionary, dicionario} -> Keyword.get(dicionario, :"$initial_call")
      nil -> nil
    end
  end

  defp estado_canal(pid) do
    :sys.get_state(pid, 1000)
  catch
    :exit, _ -> %{}
  end

  defp assigns_de(pid) do
    case estado_canal(pid) do
      %{socket: %{assigns: assigns}} -> Map.delete(assigns, :__changed__)
      _ -> %{}
    end
  end

  @doc """
  Guarda uma anotação, de qualquer processo. As LiveViews deste notebook a usam
  para mostrar de onde cada chamada veio. `anotacoes/0` mostra e limpa a lista.
  """
  def anotar(termo) do
    :persistent_term.put({__MODULE__, :anotacoes}, lista_anotacoes() ++ [termo])
    :ok
  end

  def anotacoes do
    lista = lista_anotacoes()
    :persistent_term.put({__MODULE__, :anotacoes}, [])

    if lista == [] do
      Kino.Markdown.new("Nenhuma anotação.")
    else
      linhas = lista |> Enum.with_index(1) |> Enum.map(fn {termo, i} -> "| #{i} | `#{inspect(termo)}` |" end)
      Kino.Markdown.new(Enum.join(["| # | Anotação |", "| --- | --- |" | linhas], "\n"))
    end
  end

  defp lista_anotacoes, do: :persistent_term.get({__MODULE__, :anotacoes}, [])

  defp esperar_resposta(lv, ref) do
    case ler_mensagem(lv, 5000) do
      {lv, [_, ^ref, _, "phx_reply", %{"status" => "ok", "response" => resposta}]} ->
        {lv, resposta}

      {_lv, [_, ^ref, _, "phx_reply", outra]} ->
        raise "o servidor respondeu #{inspect(outra)}"

      {_lv, [_, _, _, "phx_error", _]} ->
        raise "a LiveView caiu ao tratar a mensagem; o erro está no log acima"

      {_lv, nil} ->
        raise "o servidor não respondeu (a LiveView caiu? o erro está no log acima)"

      {lv, _outra} ->
        esperar_resposta(lv, ref)
    end
  end

  defp enviar_quadro(lv, mensagem) do
    dados = Jason.encode!(mensagem)
    mascara = :crypto.strong_rand_bytes(4)
    tamanho = byte_size(dados)

    cabeca =
      cond do
        tamanho < 126 -> <<1::1, 0::3, 1::4, 1::1, tamanho::7>>
        tamanho < 65_536 -> <<1::1, 0::3, 1::4, 1::1, 126::7, tamanho::16>>
        true -> <<1::1, 0::3, 1::4, 1::1, 127::7, tamanho::64>>
      end

    :ok = :gen_tcp.send(lv.socket, [cabeca, mascara, mascarar(dados, mascara)])
  end

  # O cliente mascara cada byte com uma chave de 4 bytes (exigência do protocolo WebSocket).
  defp mascarar(dados, <<a, b, c, d>>) do
    chave = {a, b, c, d}

    for {byte, i} <- Enum.with_index(:binary.bin_to_list(dados)), into: <<>> do
      <<Bitwise.bxor(byte, elem(chave, rem(i, 4)))>>
    end
  end

  # Lê um quadro de texto do servidor (sem máscara) e decodifica o JSON.
  defp ler_mensagem(lv, tempo) do
    case quadro(lv.buffer) do
      {:ok, texto, resto} ->
        {%{lv | buffer: resto}, Jason.decode!(texto)}

      :incompleto ->
        case :gen_tcp.recv(lv.socket, 0, tempo) do
          {:ok, dados} -> ler_mensagem(%{lv | buffer: lv.buffer <> dados}, tempo)
          {:error, _} -> {lv, nil}
        end
    end
  end

  defp quadro(<<_fin::1, _::3, _op::4, 0::1, 126::7, n::16, dados::binary-size(n), resto::binary>>), do: {:ok, dados, resto}
  defp quadro(<<_fin::1, _::3, _op::4, 0::1, 127::7, n::64, dados::binary-size(n), resto::binary>>), do: {:ok, dados, resto}
  defp quadro(<<_fin::1, _::3, _op::4, 0::1, n::7, dados::binary-size(n), resto::binary>>) when n < 126, do: {:ok, dados, resto}
  defp quadro(_), do: :incompleto

  defp atributo_html(html, regex) do
    case Regex.run(regex, html) do
      [_, valor] -> String.replace(valor, "&amp;", "&")
      nil -> nil
    end
  end

  # --- perguntas -------------------------------------------------------------

  @doc "Uma pergunta de múltipla escolha, respondida antes de ver a explicação."
  def pergunta(id) do
    %{texto: texto, opcoes: opcoes, correta: correta, explicacao: explicacao} = Map.fetch!(perguntas(), id)

    quadro = Kino.Frame.new()

    letras = Enum.map(opcoes, fn {letra, _texto} -> {letra, to_string(letra)} end)
    lista = Enum.map_join(opcoes, "\n", fn {letra, opcao} -> "* **#{letra})** #{opcao}" end)

    formulario =
      Kino.Control.form(
        [resposta: Kino.Input.select("Sua resposta", [{nil, "Escolha"} | letras])],
        submit: "Conferir"
      )

    Kino.listen(formulario, fn %{data: %{resposta: resposta}} ->
      retorno =
        cond do
          resposta == nil -> "Escolha uma opção antes de conferir."
          resposta == correta -> "**Certo.** " <> explicacao
          true -> "**Ainda não.** Releia o trecho acima e tente outra opção."
        end

      Kino.Frame.render(quadro, Kino.Markdown.new(retorno))
    end)

    Kino.Layout.grid([Kino.Markdown.new("**Antes de continuar:** " <> texto <> "\n\n" <> lista), formulario, quadro])
  end

  defp perguntas do
    %{
      alternativas: %{
        texto:
          "numa aplicação renderizada no servidor, sem LiveView, o usuário clica num botão e só um número da página deveria mudar. Quais são os caminhos?",
        opcoes: [
          a: "Nenhum: uma página renderizada no servidor não muda depois de carregada.",
          b: "Enviar um formulário e renderizar a página inteira de novo, ou escrever JavaScript no navegador que chama uma API e muda o número.",
          c: "O navegador recalcula o HTML sozinho, sem falar com o servidor.",
          d: "Mandar o servidor reiniciar a conexão HTTP."
        ],
        correta: :b,
        explicacao:
          "São os dois caminhos que o guia cita: o ciclo completo de requisição e resposta, com a página inteira de novo, ou levar a lógica para o cliente, com um framework JavaScript e uma API. O LiveView é um terceiro caminho: o estado fica no servidor, e o servidor manda ao navegador só as partes do HTML que mudaram."
      },
      primeira_renderizacao: %{
        texto: "um buscador (ou o `curl`, que não roda JavaScript) pede a página de uma LiveView. O que ele recebe?",
        opcoes: [
          a: "Uma página vazia, que só ganha conteúdo depois que o JavaScript conecta.",
          b: "O HTML completo, renderizado no servidor, como numa página de controller.",
          c: "Um JSON com o estado da LiveView.",
          d: "Um erro, porque uma LiveView só funciona por WebSocket."
        ],
        correta: :b,
        explicacao:
          "Toda LiveView é renderizada primeiro por uma requisição HTTP comum. A página chega pronta, o que é bom para a primeira pintura na tela e para buscadores. Só depois o JavaScript abre a conexão persistente, e daí em diante só trafegam eventos e diffs."
      },
      requisicoes: %{
        texto:
          "um contador com um botão `+`. O usuário clica 10 vezes. Quantas requisições HTTP o navegador faz com um controller (formulário com POST e redirect) e com uma LiveView, contando o carregamento da página?",
        opcoes: [
          a: "11 com o controller, 11 com a LiveView.",
          b: "21 com o controller; 2 com a LiveView.",
          c: "11 com o controller; 1 com a LiveView.",
          d: "21 com o controller; 12 com a LiveView."
        ],
        correta: :b,
        explicacao:
          "Com o controller, cada clique é um POST e, por causa do redirect, mais um GET: 20, mais o GET inicial. Com a LiveView, há o GET da página e o pedido de upgrade para WebSocket, que também é uma requisição HTTP. Os 10 cliques viajam como mensagens dentro dessa conexão aberta."
      },
      diff: %{
        texto: "a temperatura está em 71 e o usuário clica de novo em `+`. O que o servidor manda pelo WebSocket?",
        opcoes: [
          a: "O HTML inteiro da LiveView, com 72 no lugar de 71.",
          b: "Só o valor que mudou, na posição dele: algo como `{\"0\": \"72\"}`.",
          c: "Os estáticos e o novo valor: `{\"s\": [...], \"0\": \"72\"}`.",
          d: "Nada: o navegador soma 1 sozinho."
        ],
        correta: :b,
        explicacao:
          "Os estáticos já estão no navegador desde o join e nunca mudam. O servidor renderiza de novo, compara e manda só as partes dinâmicas que mudaram, identificadas pela posição. É isso que o guia chama de diffs pelo fio."
      },
      estado_onde: %{
        texto: "a página do termostato está aberta e o usuário clicou três vezes. Onde está guardado o valor 73?",
        opcoes: [
          a: "Num cookie do navegador.",
          b: "No banco de dados.",
          c: "Nos assigns do socket, dentro de um processo Elixir que existe só para essa página, no servidor.",
          d: "No JavaScript da página; o servidor não guarda nada entre um clique e outro."
        ],
        correta: :c,
        explicacao:
          "Cada página aberta tem o seu processo no servidor, e o estado dela mora ali, como dados imutáveis do Elixir. Fechar a conexão termina o processo, e o estado vai junto. Por isso o próximo GET começa de novo em 70."
      },
      so_umidade: %{
        texto:
          "a `ClimaLive` mostra a temperatura (posição `0`) e a umidade (posição `1`). O usuário clica em `+ humidity`. O que vem no diff?",
        opcoes: [
          a: "As duas posições, `0` e `1`, porque a página renderizou de novo.",
          b: "Só a posição `1`, com o novo valor da umidade.",
          c: "O HTML dos dois parágrafos.",
          d: "Nada, porque a temperatura não mudou."
        ],
        correta: :b,
        explicacao:
          "O HEEx sabe quais assigns cada parte dinâmica usa. Quando só `@humidity` muda, só a expressão que o usa é calculada e enviada. O mapa `__changed__` do socket é o que guarda o que mudou desde a última renderização."
      },
      mount_duas: %{
        texto:
          "um `mount/3` chama `Phoenix.PubSub.subscribe/2` sem conferir `connected?(socket)`. O que acontece na renderização estática?",
        opcoes: [
          a: "Nada, porque o mount estático não roda.",
          b: "O processo da requisição HTTP assina o tópico à toa: ele só renderiza o HTML, não é a LiveView e não trata as mensagens.",
          c: "A página dá erro.",
          d: "A LiveView passa a receber cada mensagem em dobro."
        ],
        correta: :b,
        explicacao:
          "O mount roda duas vezes: uma no processo que atende o GET, para gerar o HTML, e outra no processo da LiveView, quando o WebSocket conecta. Só o segundo vive o bastante para receber mensagens. Por isso o padrão é `if connected?(socket), do: subscribe(...)`. Trabalho caro no mount também roda duas vezes."
      },
      patch_processo: %{
        texto:
          "com o termostato aberto, o usuário clica num `<.link patch={~p\"/thermostat?unit=C\"}>`. O que acontece no servidor?",
        opcoes: [
          a: "Uma requisição HTTP nova e um mount novo, num processo novo.",
          b: "O mesmo processo recebe a URL nova e chama `handle_params/3`, sem mount.",
          c: "O processo termina e outro começa, pelo mesmo WebSocket.",
          d: "Nada: só o navegador muda a URL."
        ],
        correta: :b,
        explicacao:
          "Um live patch manda pela conexão aberta uma mensagem `live_patch` com a URL nova. A LiveView continua no mesmo processo, com o mesmo estado, e o servidor chama `handle_params/3` com os parâmetros novos. O navegador atualiza a barra de endereço."
      },
      form_target: %{
        texto:
          "num `phx-change`, o navegador manda o formulário inteiro a cada mudança. Como a LiveView sabe qual campo o usuário acabou de mexer?",
        opcoes: [
          a: "Não sabe; precisa comparar com os valores anteriores.",
          b: "Pelo parâmetro `\"_target\"`, que traz o caminho do campo, como `[\"meta\", \"valor\"]`.",
          c: "Pelo nome do evento.",
          d: "Pelo diff que o navegador manda."
        ],
        correta: :b,
        explicacao:
          "Junto com os campos, o cliente manda `_target` com o nome do campo que disparou o evento, e o LiveView o transforma numa lista de chaves. É útil para validar só o campo que mudou ou para não mostrar erros de campos que o usuário ainda não tocou."
      }
    }
  end

  # --- soluções --------------------------------------------------------------

  @doc "Um botão que revela a solução de um exercício."
  def solucao(id) do
    quadro = Kino.Frame.new()
    botao = Kino.Control.button("Mostrar a solução")

    Kino.listen(botao, fn _evento ->
      Kino.Frame.render(quadro, Kino.Markdown.new(cerca(Map.fetch!(solucoes(), id), "elixir")))
    end)

    Kino.Layout.grid([botao, quadro])
  end

  defp solucoes do
    %{
      rota: ~S'''
      # Na célula lib/hello_web/router.ex, no scope "/":
      scope "/", HelloWeb do
        pipe_through :browser

        get "/", PageController, :home

        live "/clima", ClimaLive
        live "/montagem", MontagemLive
        live "/bindings", BindingsLive

        live "/thermostat", ThermostatLive
      end
      ''',
      menos: ~S'''
      defmodule HelloWeb.ThermostatLive do
        use HelloWeb, :live_view

        def render(assigns) do
          ~H"""
          Current temperature: {@temperature}°F
          <button phx-click="inc_temperature">+</button>
          <button phx-click="dec_temperature">-</button>
          """
        end

        def mount(_params, _session, socket) do
          temperature = 70 # Let's assume a fixed temperature for now
          {:ok, assign(socket, :temperature, temperature)}
        end

        def handle_event("inc_temperature", _params, socket) do
          {:noreply, update(socket, :temperature, &(&1 + 1))}
        end

        def handle_event("dec_temperature", _params, socket) do
          {:noreply, update(socket, :temperature, &max(&1 - 1, 50))}
        end
      end
      ''',
      unidade: ~S'''
      defmodule HelloWeb.ThermostatLive do
        use HelloWeb, :live_view

        def render(assigns) do
          ~H"""
          Current temperature: {display(@temperature, @unit)}°{@unit}
          <button phx-click="inc_temperature">+</button>
          <button phx-click="dec_temperature">-</button>
          <p>
            <.link patch={~p"/thermostat"}>°F</.link>
            <.link patch={~p"/thermostat?unit=C"}>°C</.link>
          </p>
          """
        end

        def mount(_params, _session, socket) do
          temperature = 70 # Let's assume a fixed temperature for now
          {:ok, assign(socket, temperature: temperature, unit: "F")}
        end

        def handle_params(params, _uri, socket) do
          unit = if params["unit"] == "C", do: "C", else: "F"
          {:noreply, assign(socket, :unit, unit)}
        end

        def handle_event("inc_temperature", _params, socket) do
          {:noreply, update(socket, :temperature, &(&1 + 1))}
        end

        def handle_event("dec_temperature", _params, socket) do
          {:noreply, update(socket, :temperature, &max(&1 - 1, 50))}
        end

        defp display(temperature, "F"), do: temperature
        defp display(temperature, "C"), do: round((temperature - 32) * 5 / 9)
      end
      ''',
      leituras: ~S'''
      defmodule HelloWeb.ThermostatLive do
        use HelloWeb, :live_view

        def render(assigns) do
          ~H"""
          Current temperature: {display(@temperature, @unit)}°{@unit}
          <button phx-click="inc_temperature">+</button>
          <button phx-click="dec_temperature">-</button>
          <p>
            <.link patch={~p"/thermostat"}>°F</.link>
            <.link patch={~p"/thermostat?unit=C"}>°C</.link>
          </p>
          """
        end

        def mount(_params, _session, socket) do
          temperature = 70 # Let's assume a fixed temperature for now

          if connected?(socket) do
            Phoenix.PubSub.subscribe(Hello.PubSub, "leituras")
          end

          {:ok, assign(socket, temperature: temperature, unit: "F")}
        end

        def handle_params(params, _uri, socket) do
          unit = if params["unit"] == "C", do: "C", else: "F"
          {:noreply, assign(socket, :unit, unit)}
        end

        def handle_event("inc_temperature", _params, socket) do
          {:noreply, update(socket, :temperature, &(&1 + 1))}
        end

        def handle_event("dec_temperature", _params, socket) do
          {:noreply, update(socket, :temperature, &max(&1 - 1, 50))}
        end

        def handle_info({:leitura, graus}, socket) do
          {:noreply, assign(socket, :temperature, graus)}
        end

        defp display(temperature, "F"), do: temperature
        defp display(temperature, "C"), do: round((temperature - 32) * 5 / 9)
      end
      ''',
      meta: ~S'''
      defmodule HelloWeb.ThermostatLive do
        use HelloWeb, :live_view

        def render(assigns) do
          ~H"""
          Current temperature: {display(@temperature, @unit)}°{@unit}
          <button phx-click="inc_temperature">+</button>
          <button phx-click="dec_temperature">-</button>
          <p>
            <.link patch={~p"/thermostat"}>°F</.link>
            <.link patch={~p"/thermostat?unit=C"}>°C</.link>
          </p>

          <p :if={@target}>Target: {@target}°F</p>

          <.form for={@form} phx-change="validar" phx-submit="definir">
            <.input field={@form[:valor]} type="number" label="Target (°F)" />
            <button>Definir</button>
          </.form>
          """
        end

        def mount(_params, _session, socket) do
          temperature = 70 # Let's assume a fixed temperature for now

          if connected?(socket) do
            Phoenix.PubSub.subscribe(Hello.PubSub, "leituras")
          end

          {:ok,
           assign(socket,
             temperature: temperature,
             unit: "F",
             target: nil,
             form: to_form(%{"valor" => ""}, as: :meta)
           )}
        end

        def handle_params(params, _uri, socket) do
          unit = if params["unit"] == "C", do: "C", else: "F"
          {:noreply, assign(socket, :unit, unit)}
        end

        def handle_event("inc_temperature", _params, socket) do
          {:noreply, update(socket, :temperature, &(&1 + 1))}
        end

        def handle_event("dec_temperature", _params, socket) do
          {:noreply, update(socket, :temperature, &max(&1 - 1, 50))}
        end

        def handle_event("validar", %{"meta" => meta}, socket) do
          {:noreply, assign(socket, :form, form_da_meta(meta))}
        end

        def handle_event("definir", %{"meta" => meta}, socket) do
          form = form_da_meta(meta)

          if form.errors == [] do
            {:noreply, assign(socket, target: String.to_integer(meta["valor"]), form: form)}
          else
            {:noreply, assign(socket, :form, form)}
          end
        end

        def handle_info({:leitura, graus}, socket) do
          {:noreply, assign(socket, :temperature, graus)}
        end

        defp display(temperature, "F"), do: temperature
        defp display(temperature, "C"), do: round((temperature - 32) * 5 / 9)

        defp form_da_meta(meta) do
          errors =
            case Integer.parse(meta["valor"] || "") do
              {valor, ""} when valor in 50..90 -> []
              _ -> [valor: {"must be between 50 and 90", []}]
            end

          to_form(meta, as: :meta, errors: errors)
        end
      end
      ''',
      brecho: ~S'''
      # No router, dentro do scope "/":
      live "/vitrine", VitrineLive

      # lib/hello_web/live/vitrine_live.ex
      defmodule HelloWeb.VitrineLive do
        use HelloWeb, :live_view

        alias Hello.Vitrine

        def render(assigns) do
          ~H"""
          <Layouts.app flash={@flash}>
            <.header>Vitrine do Brechó</.header>
            <p :if={@produtos == []}>Nada à venda agora.</p>
            <ul>
              <li :for={produto <- @produtos} id={"produto-#{produto.id}"}>
                {produto.nome} · {produto.preco_centavos} centavos
                <button phx-click="comprar" phx-value-id={produto.id}>Comprar</button>
              </li>
            </ul>
          </Layouts.app>
          """
        end

        def mount(_params, _session, socket) do
          if connected?(socket) do
            Phoenix.PubSub.subscribe(Hello.PubSub, "vitrine")
          end

          {:ok, assign(socket, :produtos, Vitrine.listar_disponiveis())}
        end

        def handle_event("comprar", %{"id" => id}, socket) do
          case Vitrine.vender(Vitrine.buscar_produto!(id)) do
            {:ok, produto} ->
              Phoenix.PubSub.broadcast(Hello.PubSub, "vitrine", {:vendido, produto.id})
              {:noreply, put_flash(socket, :info, "#{produto.nome} é sua!")}

            {:error, :ja_vendido} ->
              {:noreply, put_flash(socket, :error, "Esta peça já foi vendida.")}
          end
        end

        def handle_info({:vendido, id}, socket) do
          {:noreply, update(socket, :produtos, fn produtos -> Enum.reject(produtos, &(&1.id == id)) end)}
        end
      end
      '''
    }
  end

  # --- conferência dos exercícios ----------------------------------------------

  @doc "Confere um exercício consultando o banco e fazendo requisições de verdade."
  def conferir(id) do
    resultado =
      Enum.reduce_while(Map.fetch!(verificacoes(), id), [], fn {descricao, teste}, feitos ->
        case executar(teste) do
          :ok -> {:cont, ["- Passou: " <> descricao | feitos]}
          {:erro, dica} -> {:halt, ["- **Ainda não:** " <> descricao <> "\n\n  " <> dica | feitos]}
        end
      end)

    Kino.Markdown.new(resultado |> Enum.reverse() |> Enum.join("\n"))
  end

  defp executar(teste) do
    teste.()
  rescue
    erro -> {:erro, explicar(erro)}
  end

  defp verificacoes do
    %{
      rota: [
        {"`GET /thermostat` responde 200 com uma LiveView (`data-phx-main`) e a temperatura 70.",
         fn ->
           case requisicao(:get, "/thermostat") do
             {:ok, %{status: 200} = conn} ->
               corpo = to_string(conn.resp_body)

               cond do
                 not (corpo =~ "data-phx-main") ->
                   {:erro, "A página respondeu 200, mas não traz uma LiveView. A rota usa `live`, e não `get`?"}

                 not (corpo =~ "70") ->
                   {:erro, "A página não mostra a temperatura 70."}

                 true ->
                   :ok
               end

             {:ok, conn} ->
               {:erro, "A resposta veio com status `#{conn.status}`."}

             {:erro, erro} ->
               {:erro, explicar(erro)}
           end
         end},
        {"O WebSocket conecta e o join traz a temperatura 70.",
         fn ->
           com_lv("/thermostat", fn lv, entrada ->
             cond do
               not (Jason.encode!(entrada) =~ "70") -> {:erro, "O join não trouxe a temperatura 70: `#{Jason.encode!(entrada)}`."}
               estado(lv)[:temperature] != 70 -> {:erro, "O assign `:temperature` deveria começar em 70."}
               true -> :ok
             end
           end)
         end}
      ],
      menos: [
        {"A página tem um botão com `phx-click=\"dec_temperature\"`.",
         fn ->
           corpo_tem("/thermostat", ~s(phx-click="dec_temperature"), "Acrescente ao `render/1` um `<button phx-click=\"dec_temperature\">-</button>`.")
         end},
        {"Um clique em `dec_temperature` leva 70 a 69.",
         fn ->
           com_lv("/thermostat", fn lv, _ ->
             {lv, _} = clicar(lv, "dec_temperature")
             temperatura(lv, 69, "Depois de um clique, `@temperature` deveria ser 69. O `handle_event(\"dec_temperature\", ...)` diminui 1?")
           end)
         end},
        {"Trinta cliques param em 50.",
         fn ->
           com_lv("/thermostat", fn lv, _ ->
             lv = Enum.reduce(1..30, lv, fn _, lv -> lv |> clicar("dec_temperature") |> elem(0) end)
             temperatura(lv, 50, "A temperatura deveria parar em 50. Use `max/2` no `update/3`: `&max(&1 - 1, 50)`.")
           end)
         end},
        {"Depois disso, o `+` continua funcionando: 51.",
         fn ->
           com_lv("/thermostat", fn lv, _ ->
             lv = Enum.reduce(1..30, lv, fn _, lv -> lv |> clicar("dec_temperature") |> elem(0) end)
             {lv, _} = clicar(lv, "inc_temperature")
             temperatura(lv, 51, "Depois de chegar a 50, um `+` deveria levar a 51.")
           end)
         end}
      ],
      unidade: [
        {"`GET /thermostat?unit=C` mostra `21°C` (70°F em Celsius, arredondado).",
         fn ->
           corpo_tem(
             "/thermostat?unit=C",
             "21°C",
             "A página em Celsius deveria mostrar `21°C`: `round((70 - 32) * 5 / 9)` é 21. A unidade vem de `params[\"unit\"]` em `handle_params/3`."
           )
         end},
        {"Um live patch para `/thermostat?unit=C` troca `@unit` para `\"C\"` no mesmo processo.",
         fn ->
           com_lv("/thermostat", fn lv, _ ->
             antes = processo(lv)
             {lv, _} = patch(lv, "/thermostat?unit=C")

             cond do
               processo(lv) != antes -> {:erro, "O processo mudou: um live patch não deveria remontar a LiveView."}
               estado(lv)[:unit] != "C" -> {:erro, "Depois do patch, `@unit` vale `#{inspect(estado(lv)[:unit])}`. O `handle_params/3` deve atribuir `:unit`."}
               true -> :ok
             end
           end)
         end},
        {"Um live patch de volta para `/thermostat` volta para `\"F\"`.",
         fn ->
           com_lv("/thermostat?unit=C", fn lv, _ ->
             {lv, _} = patch(lv, "/thermostat")

             if estado(lv)[:unit] == "F",
               do: :ok,
               else: {:erro, "Sem `unit` na URL, `@unit` deveria ser `\"F\"`; veio `#{inspect(estado(lv)[:unit])}`."}
           end)
         end}
      ],
      leituras: [
        {"O mount estático não assina o tópico \"leituras\"; só o conectado assina.",
         fn ->
           requisicao(:get, "/thermostat")
           assinou = self() in Enum.map(Registry.lookup(Hello.PubSub, "leituras"), &elem(&1, 0))
           Phoenix.PubSub.unsubscribe(Hello.PubSub, "leituras")

           if assinou,
             do: {:erro, "A renderização estática assinou o tópico. Cerque o `subscribe` com `if connected?(socket)`."},
             else: :ok
         end},
        {"Duas conexões abertas recebem a leitura publicada em \"leituras\", sem nenhum clique.",
         fn ->
           {a, _} = abrir_lv("/thermostat")
           {b, _} = abrir_lv("/thermostat")

           try do
             Phoenix.PubSub.broadcast(Hello.PubSub, "leituras", {:leitura, 64})
             {a, diff_a} = receber(a)
             {b, diff_b} = receber(b)

             cond do
               diff_a == :nada or diff_b == :nada ->
                 {:erro, "Nenhum diff chegou. No `mount/3`, assine o tópico \"leituras\" quando `connected?(socket)` for verdadeiro."}

               estado(a)[:temperature] != 64 or estado(b)[:temperature] != 64 ->
                 {:erro, "O diff chegou, mas a temperatura não virou 64. O `handle_info/2` deve casar `{:leitura, graus}` e atribuir `:temperature`."}

               true ->
                 :ok
             end
           after
             desconectar(a)
             desconectar(b)
           end
         end}
      ],
      meta: [
        {"`validar` com 95 mostra o erro `must be between 50 and 90`.",
         fn ->
           com_lv("/thermostat", fn lv, _ ->
             {_lv, resposta} = mudar(lv, "validar", %{"meta" => %{"valor" => "95"}}, "meta[valor]")

             if Jason.encode!(resposta) =~ "must be between 50 and 90",
               do: :ok,
               else: {:erro, "O diff não trouxe a mensagem de erro. Veio: `#{Jason.encode!(resposta)}`."}
           end)
         end},
        {"`validar` com 72 não deixa erro no formulário.",
         fn ->
           com_lv("/thermostat", fn lv, _ ->
             {lv, _} = mudar(lv, "validar", %{"meta" => %{"valor" => "72"}}, "meta[valor]")

             case estado(lv)[:form] do
               %Phoenix.HTML.Form{errors: []} -> :ok
               %Phoenix.HTML.Form{errors: erros} -> {:erro, "Com 72, o formulário ficou com erros: `#{inspect(erros)}`."}
               outro -> {:erro, "O assign `:form` deveria ser um formulário (`to_form/2`); veio `#{inspect(outro)}`."}
             end
           end)
         end},
        {"`definir` com 72 guarda 72 em `@target`, e a página mostra o valor.",
         fn ->
           com_lv("/thermostat", fn lv, _ ->
             {lv, resposta} = submeter(lv, "definir", %{"meta" => %{"valor" => "72"}})

             cond do
               estado(lv)[:target] != 72 -> {:erro, "`@target` vale `#{inspect(estado(lv)[:target])}`; deveria ser o inteiro 72."}
               not (Jason.encode!(resposta) =~ "72") -> {:erro, "O diff não mostra 72. O template exibe `@target`?"}
               true -> :ok
             end
           end)
         end},
        {"`definir` com 40 não muda a meta.",
         fn ->
           com_lv("/thermostat", fn lv, _ ->
             {lv, _} = submeter(lv, "definir", %{"meta" => %{"valor" => "72"}})
             {lv, _} = submeter(lv, "definir", %{"meta" => %{"valor" => "40"}})

             if estado(lv)[:target] == 72,
               do: :ok,
               else: {:erro, "Depois de definir 40, `@target` vale `#{inspect(estado(lv)[:target])}`; um valor inválido não deveria mudar a meta."}
           end)
         end}
      ],
      brecho: [
        {"`GET /vitrine` lista as peças disponíveis e não as vendidas.",
         fn ->
           com_pecas(fn [a, _b, vendida] ->
             case requisicao(:get, "/vitrine") do
               {:ok, %{status: 200} = conn} ->
                 corpo = to_string(conn.resp_body)

                 cond do
                   not (corpo =~ "data-phx-main") -> {:erro, "`/vitrine` não é uma LiveView. A rota usa `live`?"}
                   not (corpo =~ a.nome) -> {:erro, "A vitrine não mostra `#{a.nome}`, que está à venda. Liste `Vitrine.listar_disponiveis()`."}
                   corpo =~ vendida.nome -> {:erro, "A vitrine mostra `#{vendida.nome}`, que já foi vendida."}
                   true -> :ok
                 end

               {:ok, conn} ->
                 {:erro, "`GET /vitrine` respondeu `#{conn.status}`."}

               {:erro, erro} ->
                 {:erro, explicar(erro)}
             end
           end)
         end},
        {"Cada peça tem um botão `phx-click=\"comprar\"` com `phx-value-id`.",
         fn ->
           com_pecas(fn [a | _] ->
             {:ok, conn} = requisicao(:get, "/vitrine")
             corpo = to_string(conn.resp_body)

             if corpo =~ ~s(phx-click="comprar") and corpo =~ ~s(phx-value-id="#{a.id}"),
               do: :ok,
               else: {:erro, "Esperava um botão com `phx-click=\"comprar\"` e `phx-value-id=\"#{a.id}\"` para `#{a.nome}`."}
           end)
         end},
        {"Quando a ana compra, a vitrine aberta da bia recebe um diff e a peça some dela.",
         fn ->
           com_pecas(fn [a | _] ->
             {ana, _} = abrir_lv("/vitrine", como: "ana")
             {bia, _} = abrir_lv("/vitrine", como: "bia")

             try do
               {ana, _} = clicar(ana, "comprar", %{"id" => to_string(a.id)})
               {bia, empurrado} = receber(bia)
               ids_bia = Enum.map(estado(bia)[:produtos] || [], & &1.id)
               ids_ana = Enum.map(estado(ana)[:produtos] || [], & &1.id)

               cond do
                 not vendida?(a.id) -> {:erro, "Depois do clique, `#{a.nome}` não ficou vendida. O `handle_event(\"comprar\", ...)` chama `Vitrine.vender/1`?"}
                 empurrado == :nada -> {:erro, "A vitrine da bia não recebeu nada. Depois de vender, publique no tópico \"vitrine\", e assine esse tópico no mount conectado."}
                 a.id in ids_bia -> {:erro, "A bia recebeu uma mensagem, mas `@produtos` dela ainda tem a peça. O `handle_info/2` deve tirá-la da lista."}
                 a.id in ids_ana -> {:erro, "A vitrine da própria ana ainda mostra a peça comprada."}
                 true -> :ok
               end
             after
               desconectar(ana)
               desconectar(bia)
             end
           end)
         end},
        {"Comprar uma peça já vendida mostra a flash de erro `Esta peça já foi vendida.`",
         fn ->
           com_pecas(fn [_a, b, _] ->
             {ana, _} = abrir_lv("/vitrine", como: "ana")
             {bia, _} = abrir_lv("/vitrine", como: "bia")

             try do
               {_ana, _} = clicar(ana, "comprar", %{"id" => to_string(b.id)})
               {bia, _} = clicar(bia, "comprar", %{"id" => to_string(b.id)})

               case estado(bia)[:flash] do
                 %{"error" => "Esta peça já foi vendida."} -> :ok
                 flash -> {:erro, "A flash da bia ficou `#{inspect(flash)}`; esperava o erro `Esta peça já foi vendida.`"}
               end
             after
               desconectar(ana)
               desconectar(bia)
             end
           end)
         end},
        {"A compra que dá certo mostra uma flash de informação com o nome da peça.",
         fn ->
           com_pecas(fn [a | _] ->
             com_lv("/vitrine", fn lv, _ ->
               {lv, _} = clicar(lv, "comprar", %{"id" => to_string(a.id)})

               case estado(lv)[:flash] do
                 %{"info" => mensagem} -> if mensagem =~ a.nome, do: :ok, else: {:erro, "A flash `#{mensagem}` não cita `#{a.nome}`."}
                 flash -> {:erro, "Depois da compra, a flash ficou `#{inspect(flash)}`; esperava uma `:info` com o nome da peça."}
               end
             end)
           end)
         end},
        {"Cinco compradores clicam ao mesmo tempo na mesma peça: só um consegue.",
         fn ->
           com_pecas(fn [a | _] ->
             resultados =
               1..5
               |> Enum.map(fn i ->
                 Task.async(fn ->
                   {lv, _} = abrir_lv("/vitrine", como: "comprador#{i}")

                   try do
                     {lv, _} = clicar(lv, "comprar", %{"id" => to_string(a.id)})
                     estado(lv)[:flash]
                   after
                     desconectar(lv)
                   end
                 end)
               end)
               |> Task.await_many(15_000)

             sucessos = Enum.count(resultados, &match?(%{"info" => _}, &1))

             if sucessos == 1,
               do: :ok,
               else: {:erro, "#{sucessos} compradores receberam a flash de sucesso; só um deveria. Use o resultado de `Vitrine.vender/1`."}
           end)
         end}
      ]
    }
  end

  # --- ajudantes da conferência --------------------------------------------------

  # Abre a LiveView, roda o teste e sempre fecha a conexão.
  defp com_lv(caminho, teste) do
    {lv, entrada} = abrir_lv(caminho)

    try do
      teste.(lv, entrada)
    after
      desconectar(lv)
    end
  end

  defp corpo_tem(caminho, trecho, dica) do
    case requisicao(:get, caminho) do
      {:ok, %{status: 200} = conn} ->
        if to_string(conn.resp_body) =~ trecho, do: :ok, else: {:erro, dica}

      {:ok, conn} ->
        {:erro, "`GET #{caminho}` respondeu `#{conn.status}`."}

      {:erro, erro} ->
        {:erro, explicar(erro)}
    end
  end

  defp temperatura(lv, esperado, dica) do
    case estado(lv)[:temperature] do
      ^esperado -> :ok
      outro -> {:erro, dica <> " Veio `#{inspect(outro)}`."}
    end
  end

  # Cria três peças de conferência (a terceira já vendida), roda o teste e as apaga.
  defp com_pecas(teste) do
    unless Code.ensure_loaded?(Hello.Vitrine),
      do: raise("o contexto `Hello.Vitrine` não existe: avalie as células de \"O Brechó até aqui\"")

    sufixo = System.unique_integer([:positive])

    pecas =
      for {nome, preco} <- [{"Conferência Casaco #{sufixo}", 9000}, {"Conferência Saia #{sufixo}", 3000}, {"Conferência Chapéu #{sufixo}", 2000}] do
        {:ok, peca} = apply(Hello.Vitrine, :cadastrar_produto, [%{nome: nome, preco_centavos: preco}])
        peca
      end

    {:ok, _} = apply(Hello.Vitrine, :vender, [List.last(pecas)])

    try do
      teste.(pecas)
    after
      sql("DELETE FROM produtos WHERE nome LIKE 'Conferência%'")
    end
  end

  defp vendida?(id), do: coluna("SELECT vendido FROM produtos WHERE id = $1", [id]) == [true]

  defp coluna(consulta, parametros), do: consulta |> sql(parametros) |> Map.fetch!(:rows) |> Enum.map(&hd/1)

  defp explicar(%Phoenix.Router.NoRouteError{} = erro) do
    "Etapa: **router**. `#{Exception.message(erro)}`. Acrescente a rota `live` na célula do router e avalie-a de novo."
  end

  defp explicar(%UndefinedFunctionError{module: modulo, function: funcao, arity: aridade}) do
    "`#{inspect(modulo)}.#{funcao}/#{aridade}` não existe. O módulo foi definido e a célula foi avaliada?"
  end

  defp explicar(%RuntimeError{message: "a LiveView caiu" <> _ = mensagem}) do
    "A" <> String.slice(mensagem, 1..-1//1) <> ". O mais comum: falta a cláusula de `handle_event/3` (ou o `handle_params/3`) para essa mensagem, " <>
      "e o processo cai com `FunctionClauseError` ou `UndefinedFunctionError`."
  end

  defp explicar(%RuntimeError{message: mensagem}) do
    "#{mensagem}."
  end

  defp explicar(%Postgrex.Error{postgres: %{code: :undefined_table, message: mensagem}}) do
    "O PostgreSQL respondeu `#{mensagem}`. As células de \"O Brechó até aqui\" já rodaram?"
  end

  defp explicar(%MatchError{term: termo}) do
    "Um casamento de padrões falhou com o valor `#{inspect(termo, limit: 8)}`."
  end

  defp explicar(erro), do: "Levantou uma exceção:\n\n  `" <> Exception.format_banner(:error, erro) <> "`"
end

Como usar este notebook

Este notebook acompanha o guia LiveView da documentação do Phoenix 1.8, na mesma ordem e com o link de cada seção. O guia é curto e aponta, no fim, para a documentação do Phoenix LiveView. Onde ele só cita um assunto (o ciclo de vida, os bindings, os formulários), o notebook vai um pouco mais fundo, com links para os guias do LiveView 1.2.

As convenções são as de sempre: uma célula equivale a um arquivo, as perguntas "Antes de continuar" vêm antes da explicação, e os erros fazem parte do conteúdo.

O que muda neste módulo: agora as páginas reagem no navegador. Abra http://localhost:4010/thermostat (depois de criar a rota) e clique; o número muda sem recarregar a página. Para ver o que trafega entre o navegador e o servidor sem sair do notebook, o módulo Trilha traz um navegador simulado que fala o protocolo do LiveView por um WebSocket de verdade:

  • Trilha.abrir_lv("/caminho") faz o GET da página, abre o WebSocket e entra na LiveView; devolve {lv, resposta};
  • Trilha.clicar(lv, "evento"), Trilha.mudar/4, Trilha.submeter/3, Trilha.desfocar/3 e Trilha.patch/2 mandam o que o navegador mandaria num clique, num formulário, num campo que perde o foco ou num link de patch;
  • Trilha.receber(lv) espera uma mensagem que o servidor empurra sem ter sido pedida;
  • Trilha.json/1 mostra as mensagens como JSON, do jeito que aparecem nas ferramentas do navegador (aba Network, conexão WS);
  • Trilha.processo(lv), Trilha.estado(lv) e Trilha.processos(Modulo) mostram o processo de cada LiveView no servidor e o estado guardado nele.

A instalação é a dos notebooks 13 a 15. Se você vem do 12, o Livebook faz uma instalação nova na primeira avaliação, uma vez só. O banco de dados só entra no desafio do fim, mas a aplicação o inicia como nos outros notebooks.

A aplicação fica em http://localhost:4010. Se outro notebook da trilha estiver com o runtime ligado, desconecte-o antes. Avalie a primeira célula, no topo, antes de tudo.

A aplicação Hello dentro do notebook

config/dev.exs: a configuração do banco no ambiente de desenvolvimento, a mesma do notebook 07. Se o seu PostgreSQL usa outra senha ou outra porta, troque aqui e avalie a célula de novo.

Application.put_env(:hello, Hello.Repo,
  username: System.get_env("PGUSER", "postgres"),
  password: System.get_env("PGPASSWORD", "postgres"),
  hostname: System.get_env("PGHOST", "localhost"),
  port: String.to_integer(System.get_env("PGPORT", "5432")),
  database: "trilha_phoenix_dev",
  show_sensitive_data_on_connection_error: true,
  pool_size: 10,
  stacktrace: true
)

lib/hello/repo.ex

defmodule Hello.Repo do
  use Ecto.Repo,
    otp_app: :hello,
    adapter: Ecto.Adapters.Postgres
end

lib/hello_web.ex: como no projeto gerado, o bloco html_helpers importa os CoreComponents e dá o apelido JS ao Phoenix.LiveView.JS. A novidade deste módulo é o bloco live_view: use Phoenix.LiveView mais os mesmos html_helpers. É ele que use HelloWeb, :live_view traz para cada LiveView.

defmodule HelloWeb do
  def static_paths, do: ~w(assets fonts images favicon.ico robots.txt)

  def router do
    quote do
      use Phoenix.Router, helpers: false

      import Plug.Conn
      import Phoenix.Controller
      import Phoenix.LiveView.Router
    end
  end

  def controller do
    quote do
      use Phoenix.Controller, formats: [:html, :json]

      import Plug.Conn

      unquote(verified_routes())
    end
  end

  def live_view do
    quote do
      use Phoenix.LiveView

      unquote(html_helpers())
    end
  end

  def html do
    quote do
      use Phoenix.Component

      import Phoenix.Controller,
        only: [get_csrf_token: 0, view_module: 1, view_template: 1]

      unquote(html_helpers())
    end
  end

  defp html_helpers do
    quote do
      import Phoenix.HTML
      import HelloWeb.CoreComponents

      alias Phoenix.LiveView.JS
      alias HelloWeb.Layouts

      unquote(verified_routes())
    end
  end

  def verified_routes do
    quote do
      use Phoenix.VerifiedRoutes,
        endpoint: HelloWeb.Endpoint,
        router: HelloWeb.Router,
        statics: HelloWeb.static_paths()
    end
  end

  defmacro __using__(which) when is_atom(which) do
    apply(__MODULE__, which, [])
  end
end

lib/hello_web/router.ex: as rotas live das LiveViews de demonstração já estão aqui; a do termostato é o primeiro exercício. Como as LiveViews são definidas mais abaixo, a primeira avaliação mostra avisos __live__/0 is undefined, um por rota, como os init/1 is undefined dos notebooks anteriores.

defmodule HelloWeb.Router do
  use HelloWeb, :router

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

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

  scope "/", HelloWeb do
    pipe_through :browser

    get "/", PageController, :home

    # As LiveViews de demonstração deste notebook:
    live "/clima", ClimaLive
    live "/montagem", MontagemLive
    live "/bindings", BindingsLive

    # Sua vez: a rota do termostato vem aqui.
  end
end

lib/hello_web/endpoint.ex: duas novidades. A linha socket "/live", Phoenix.LiveView.Socket cria o endereço /live/websocket, onde o navegador conecta depois de carregar uma LiveView; connect_info: [session: ...] deixa a LiveView ler a sessão do cookie, que o LiveView usa para conferir o token CSRF. E os Plug.Static servem os arquivos JavaScript que, num projeto, o esbuild empacota no app.js: o phoenix_html.js, o phoenix.min.js (cliente de sockets) e o phoenix_live_view.min.js (cliente do LiveView).

defmodule HelloWeb.Endpoint do
  use Phoenix.Endpoint, otp_app: :hello

  @session_options [
    store: :cookie,
    key: "_hello_key",
    signing_salt: "trilha00",
    same_site: "Lax"
  ]

  socket "/live", Phoenix.LiveView.Socket,
    websocket: [connect_info: [session: @session_options]],
    longpoll: [connect_info: [session: @session_options]]

  plug Plug.Static, at: "/assets", from: {:phoenix_html, "priv/static"}, only: ~w(phoenix_html.js)
  plug Plug.Static, at: "/assets", from: {:phoenix, "priv/static"}, only: ~w(phoenix.min.js)
  plug Plug.Static, at: "/assets", from: {:phoenix_live_view, "priv/static"}, only: ~w(phoenix_live_view.min.js)

  plug Plug.RequestId
  plug Plug.Telemetry, event_prefix: [:phoenix, :endpoint]

  plug Plug.Parsers,
    parsers: [:urlencoded, :multipart, :json],
    pass: ["*/*"],
    json_decoder: Phoenix.json_library()

  plug Plug.MethodOverride
  plug Plug.Head
  plug Plug.Session, @session_options
  plug HelloWeb.Router
end

lib/hello_web/components/core_components.ex: os componentes que um projeto novo traz e que os templates gerados usam: flash, button, input, header, table, list e icon. O código é o do arquivo gerado pelo Phoenix 1.8.16, com uma diferença: no projeto, as mensagens de erro passam pelo Gettext, que traduz textos; aqui, translate_error/1 só preenche os valores na mensagem.

defmodule HelloWeb.CoreComponents do
  use Phoenix.Component

  alias Phoenix.LiveView.JS

  @doc "Renders flash notices."
  attr :id, :string, doc: "the optional id of flash container"
  attr :flash, :map, default: %{}, doc: "the map of flash messages to display"
  attr :title, :string, default: nil
  attr :kind, :atom, values: [:info, :error], doc: "used for styling and flash lookup"
  attr :rest, :global, doc: "the arbitrary HTML attributes to add to the flash container"

  slot :inner_block, doc: "the optional inner block that renders the flash message"

  def flash(assigns) do
    assigns = assign_new(assigns, :id, fn -> "flash-#{assigns.kind}" end)

    ~H"""
    <div
      :if={msg = render_slot(@inner_block) || Phoenix.Flash.get(@flash, @kind)}
      id={@id}
      phx-click={JS.push("lv:clear-flash", value: %{key: @kind}) |> hide("##{@id}")}
      role="alert"
      class="toast toast-top toast-end z-50"
      {@rest}
    >
      <div class={[
        "alert w-80 sm:w-96 max-w-80 sm:max-w-96 text-wrap",
        @kind == :info && "alert-info",
        @kind == :error && "alert-error"
      ]}>
        <.icon :if={@kind == :info} name="hero-information-circle" class="size-5 shrink-0" />
        <.icon :if={@kind == :error} name="hero-exclamation-circle" class="size-5 shrink-0" />
        <div>
          <p :if={@title} class="font-semibold">{@title}</p>
          <p>{msg}</p>
        </div>
        <div class="flex-1" />
        <button type="button" class="group self-start cursor-pointer" aria-label="close">
          <.icon name="hero-x-mark" class="size-5 opacity-40 group-hover:opacity-70" />
        </button>
      </div>
    </div>
    """
  end

  @doc "Renders a button with navigation support."
  attr :rest, :global, include: ~w(href navigate patch method download name value disabled)
  attr :class, :any
  attr :variant, :string, values: ~w(primary)
  slot :inner_block, required: true

  def button(%{rest: rest} = assigns) do
    variants = %{"primary" => "btn-primary", nil => "btn-primary btn-soft"}

    assigns =
      assign_new(assigns, :class, fn ->
        ["btn", Map.fetch!(variants, assigns[:variant])]
      end)

    if rest[:href] || rest[:navigate] || rest[:patch] do
      ~H"""
      <.link class={@class} {@rest}>
        {render_slot(@inner_block)}
      </.link>
      """
    else
      ~H"""
      <button class={@class} {@rest}>
        {render_slot(@inner_block)}
      </button>
      """
    end
  end

  @doc "Renders an input with label and error messages."
  attr :id, :any, default: nil
  attr :name, :any
  attr :label, :string, default: nil
  attr :value, :any

  attr :type, :string,
    default: "text",
    values: ~w(checkbox color date datetime-local email file month number password
               search select tel text textarea time url week hidden)

  attr :field, Phoenix.HTML.FormField,
    doc: "a form field struct retrieved from the form, for example: @form[:email]"

  attr :errors, :list, default: []
  attr :checked, :boolean, doc: "the checked flag for checkbox inputs"
  attr :prompt, :string, default: nil, doc: "the prompt for select inputs"
  attr :options, :list, doc: "the options to pass to Phoenix.HTML.Form.options_for_select/2"
  attr :multiple, :boolean, default: false, doc: "the multiple flag for select inputs"
  attr :class, :any, default: nil, doc: "the input class to use over defaults"
  attr :error_class, :any, default: nil, doc: "the input error class to use over defaults"

  attr :rest, :global,
    include: ~w(accept autocomplete capture cols disabled form list max maxlength min minlength
                multiple pattern placeholder readonly required rows size step)

  def input(%{field: %Phoenix.HTML.FormField{} = field} = assigns) do
    errors = if Phoenix.Component.used_input?(field), do: field.errors, else: []

    assigns
    |> assign(field: nil, id: assigns.id || field.id)
    |> assign(:errors, Enum.map(errors, &translate_error(&1)))
    |> assign_new(:name, fn -> if assigns.multiple, do: field.name <> "[]", else: field.name end)
    |> assign_new(:value, fn -> field.value end)
    |> input()
  end

  def input(%{type: "hidden"} = assigns) do
    ~H"""
    <input type="hidden" id={@id} name={@name} value={@value} {@rest} />
    """
  end

  def input(%{type: "checkbox"} = assigns) do
    assigns =
      assign_new(assigns, :checked, fn ->
        Phoenix.HTML.Form.normalize_value("checkbox", assigns[:value])
      end)

    ~H"""
    <div class="fieldset mb-2">
      <label for={@id}>
        <input
          type="hidden"
          name={@name}
          value="false"
          disabled={@rest[:disabled]}
          form={@rest[:form]}
        />
        <span class="label">
          <input
            type="checkbox"
            id={@id}
            name={@name}
            value="true"
            checked={@checked}
            class={@class || "checkbox checkbox-sm"}
            {@rest}
          />{@label}
        </span>
      </label>
      <.error :for={msg <- @errors}>{msg}</.error>
    </div>
    """
  end

  def input(%{type: "select"} = assigns) do
    ~H"""
    <div class="fieldset mb-2">
      <label for={@id}>
        <span :if={@label} class="label mb-1">{@label}</span>
        <select
          id={@id}
          name={@name}
          class={[@class || "w-full select", @errors != [] && (@error_class || "select-error")]}
          multiple={@multiple}
          {@rest}
        >
          <option :if={@prompt} value="">{@prompt}</option>
          {Phoenix.HTML.Form.options_for_select(@options, @value)}
        </select>
      </label>
      <.error :for={msg <- @errors}>{msg}</.error>
    </div>
    """
  end

  def input(%{type: "textarea"} = assigns) do
    ~H"""
    <div class="fieldset mb-2">
      <label for={@id}>
        <span :if={@label} class="label mb-1">{@label}</span>
        <textarea
          id={@id}
          name={@name}
          class={[
            @class || "w-full textarea",
            @errors != [] && (@error_class || "textarea-error")
          ]}
          {@rest}
        >{Phoenix.HTML.Form.normalize_value("textarea", @value)}</textarea>
      </label>
      <.error :for={msg <- @errors}>{msg}</.error>
    </div>
    """
  end

  # All other inputs text, datetime-local, url, password, etc. are handled here...
  def input(assigns) do
    ~H"""
    <div class="fieldset mb-2">
      <label for={@id}>
        <span :if={@label} class="label mb-1">{@label}</span>
        <input
          type={@type}
          name={@name}
          id={@id}
          value={Phoenix.HTML.Form.normalize_value(@type, @value)}
          class={[
            @class || "w-full input",
            @errors != [] && (@error_class || "input-error")
          ]}
          {@rest}
        />
      </label>
      <.error :for={msg <- @errors}>{msg}</.error>
    </div>
    """
  end

  # Helper used by inputs to generate form errors
  defp error(assigns) do
    ~H"""
    <p class="mt-1.5 flex gap-2 items-center text-sm text-error">
      <.icon name="hero-exclamation-circle" class="size-5" />
      {render_slot(@inner_block)}
    </p>
    """
  end

  @doc "Renders a header with title."
  slot :inner_block, required: true
  slot :subtitle
  slot :actions

  def header(assigns) do
    ~H"""
    <header class={[@actions != [] && "flex items-center justify-between gap-6", "pb-4"]}>
      <div>
        <h1 class="text-lg font-semibold leading-8">
          {render_slot(@inner_block)}
        </h1>
        <p :if={@subtitle != []} class="text-sm text-base-content/70">
          {render_slot(@subtitle)}
        </p>
      </div>
      <div class="flex-none">{render_slot(@actions)}</div>
    </header>
    """
  end

  @doc "Renders a table with generic styling."
  attr :id, :string, required: true
  attr :rows, :list, required: true
  attr :row_id, :any, default: nil, doc: "the function for generating the row id"
  attr :row_click, :any, default: nil, doc: "the function for handling phx-click on each row"

  attr :row_item, :any,
    default: &Function.identity/1,
    doc: "the function for mapping each row before calling the :col and :action slots"

  slot :col, required: true do
    attr :label, :string
  end

  slot :action, doc: "the slot for showing user actions in the last table column"

  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"""
    <table class="table table-zebra">
      <thead>
        <tr>
          <th :for={col <- @col}>{col[:label]}</th>
          <th :if={@action != []}>
            <span class="sr-only">Actions</span>
          </th>
        </tr>
      </thead>
      <tbody id={@id} phx-update={is_struct(@rows, Phoenix.LiveView.LiveStream) && "stream"}>
        <tr :for={row <- @rows} id={@row_id && @row_id.(row)}>
          <td
            :for={col <- @col}
            phx-click={@row_click && @row_click.(row)}
            class={@row_click && "hover:cursor-pointer"}
          >
            {render_slot(col, @row_item.(row))}
          </td>
          <td :if={@action != []} class="w-0 font-semibold">
            <div class="flex gap-4">
              <%= for action <- @action do %>
                {render_slot(action, @row_item.(row))}
              <% end %>
            </div>
          </td>
        </tr>
      </tbody>
    </table>
    """
  end

  @doc "Renders a data list."
  slot :item, required: true do
    attr :title, :string, required: true
  end

  def list(assigns) do
    ~H"""
    <ul class="list">
      <li :for={item <- @item} class="list-row">
        <div class="list-col-grow">
          <div class="font-bold">{item.title}</div>
          <div>{render_slot(item)}</div>
        </div>
      </li>
    </ul>
    """
  end

  @doc "Renders a [Heroicon](https://heroicons.com)."
  attr :name, :string, required: true
  attr :class, :any, default: "size-4"

  def icon(%{name: "hero-" <> _} = assigns) do
    ~H"""
    <span class={[@name, @class]} />
    """
  end

  ## JS Commands

  def show(js \\ %JS{}, selector) do
    JS.show(js,
      to: selector,
      time: 300,
      transition:
        {"transition-all ease-out duration-300",
         "opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95",
         "opacity-100 translate-y-0 sm:scale-100"}
    )
  end

  def hide(js \\ %JS{}, selector) do
    JS.hide(js,
      to: selector,
      time: 200,
      transition:
        {"transition-all ease-in duration-200", "opacity-100 translate-y-0 sm:scale-100",
         "opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"}
    )
  end

  @doc """
  Fills in the error message values. In the generated project this function
  translates the message with Gettext.
  """
  def translate_error({msg, opts}) do
    Enum.reduce(opts, msg, fn {key, value}, acc ->
      String.replace(acc, "%{#{key}}", fn _ -> to_string(value) end)
    end)
  end

  @doc "Translates the errors for a field from a keyword list of errors."
  def translate_errors(errors, field) when is_list(errors) do
    for {^field, {msg, opts}} <- errors, do: translate_error({msg, opts})
  end
end

lib/hello_web/components/layouts.ex: o root layout carrega o Tailwind e o daisyUI de uma CDN, para as páginas terem a aparência de um projeto gerado, e os três arquivos JavaScript servidos pelo endpoint. O último <script> faz o que o app.js de um projeto faz: cria o LiveSocket e conecta. A seção "Exemplo básico" volta a ele.

defmodule HelloWeb.Layouts do
  use HelloWeb, :html

  def root(assigns) do
    ~H"""
    <!DOCTYPE html>
    <html lang="en">
      <head>
        <meta charset="utf-8" />
        <meta name="viewport" content="width=device-width, initial-scale=1" />
        <meta name="csrf-token" content={get_csrf_token()} />
        <title>Hello · Phoenix Framework</title>
        <link href="https://cdn.jsdelivr.net/npm/daisyui@5" rel="stylesheet" type="text/css" />
        <script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4">
        </script>
        <script defer src={~p"/assets/phoenix_html.js"}>
        </script>
        <script src={~p"/assets/phoenix.min.js"}>
        </script>
        <script src={~p"/assets/phoenix_live_view.min.js"}>
        </script>
        <script>
          window.addEventListener("DOMContentLoaded", () => {
            const csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute("content")
            const liveSocket = new LiveView.LiveSocket("/live", Phoenix.Socket, {params: {_csrf_token: csrfToken}})
            liveSocket.connect()
            window.liveSocket = liveSocket
          })
        </script>
      </head>
      <body>
        {@inner_content}
      </body>
    </html>
    """
  end

  attr :flash, :map, required: true, doc: "the map of flash messages"
  attr :current_scope, :map, default: nil, doc: "the current scope"
  slot :inner_block, required: true

  def app(assigns) do
    ~H"""
    <header class="navbar px-4 sm:px-6 lg:px-8">
      <div class="flex-1">
        <a href="/" class="flex-1 flex w-fit items-center gap-2">
          <span class="text-sm font-semibold">Hello · Phoenix v{Application.spec(:phoenix, :vsn)}</span>
        </a>
      </div>
    </header>

    <main class="px-4 py-20 sm:px-6 lg:px-8">
      <div class="mx-auto max-w-2xl space-y-4">
        {render_slot(@inner_block)}
      </div>
    </main>

    <.flash_group flash={@flash} />
    """
  end

  attr :flash, :map, required: true, doc: "the map of flash messages"
  attr :id, :string, default: "flash-group", doc: "the optional id of flash container"

  def flash_group(assigns) do
    ~H"""
    <div id={@id} aria-live="polite">
      <.flash kind={:info} flash={@flash} />
      <.flash kind={:error} flash={@flash} />
    </div>
    """
  end
end

lib/hello_web/controllers/: as páginas de erro e a página inicial.

defmodule HelloWeb.ErrorHTML do
  def render(template, _assigns) do
    Phoenix.Controller.status_message_from_template(template)
  end
end

defmodule HelloWeb.ErrorJSON do
  def render(template, _assigns) do
    %{errors: %{detail: Phoenix.Controller.status_message_from_template(template)}}
  end
end

defmodule HelloWeb.PageController do
  use HelloWeb, :controller

  def home(conn, _params) do
    render(conn, :home)
  end
end

defmodule HelloWeb.PageHTML do
  use HelloWeb, :html

  def home(assigns) do
    ~H"""
    <Layouts.app flash={@flash}>
      <h1>Welcome to Phoenix!</h1>
    </Layouts.app>
    """
  end
end

Antes de começar: o PostgreSQL

Este notebook usa o mesmo PostgreSQL do notebook 07, com o banco trilha_phoenix_dev, mas só no desafio do fim. A célula abaixo tenta uma conexão e, se falhar, traduz a mensagem em uma dica. Os comandos para instalar e configurar o PostgreSQL estão no LEIA-ME.md da trilha.

Trilha.verificar_postgres()

Crie o banco, se ainda não existir (é o mix ecto.create). {:error, :already_up} quer dizer que ele já existe, e serve do mesmo jeito.

Ecto.Adapters.Postgres.storage_up(Hello.Repo.config())

lib/hello/application.ex: a árvore de supervisão inicia o Repo, o Phoenix.PubSub e o endpoint. O PubSub é o sistema de mensagens do Phoenix; as LiveViews deste notebook o usam para receber mensagens. Antes de iniciar o endpoint, a célula acrescenta à configuração dele o signing_salt do LiveView, usado para assinar os tokens que a página leva até o WebSocket. Num projeto, essa linha fica em config/config.exs; aqui ela entra em tempo de execução para não mudar a lista do Mix.install (o Livebook reaproveita a instalação só se a lista for idêntica).

{:ok, _} = Kino.start_child(Hello.Repo)
{:ok, _} = Kino.start_child({Phoenix.PubSub, name: Hello.PubSub})
config = Application.get_env(:hello, HelloWeb.Endpoint)
Application.put_env(:hello, HelloWeb.Endpoint, Keyword.put(config, :live_view, signing_salt: "trilhaLV"))
{:ok, _} = Kino.start_child(HelloWeb.Endpoint)

Kino.Markdown.new("Repo conectado e aplicação no ar em <http://localhost:4010>.")

Comece do zero: a célula abaixo apaga todas as tabelas do banco trilha_phoenix_dev, para que os números de id sejam os mesmos do texto. Avalie-a sempre que reabrir o notebook. Ela só mexe no banco da trilha.

Trilha.recomecar()

Por que LiveView

Na documentação: LiveView

Até aqui, toda interação seguiu o ciclo de vida da requisição: o router encontra a rota, um controller trata a requisição e uma view devolve a resposta no formato certo. Mas e as páginas interativas?

Trilha.pergunta(:alternativas)

Numa aplicação renderizada no servidor, mudar o conteúdo da página exige enviar um formulário e renderizar a página nova, ou levar a lógica para o cliente, com um framework JavaScript (jQuery, React, Vue...) e uma API para ele conversar com o servidor.

O Phoenix LiveView propõe outro caminho: todo o estado fica no servidor, e a página continua sendo HTML renderizado no servidor, mas com uma experiência interativa e em tempo real. É uma alternativa aos frameworks JavaScript no cliente, com pouquíssimo JavaScript escrito por você.

O que é uma LiveView

Na documentação: What is a LiveView?

LiveViews são processos que recebem eventos, atualizam o seu estado e mandam para a página as atualizações como diffs.

O modelo de programação é declarativo. Em vez de dizer "quando o evento X acontecer, mude Y na página", os eventos são mensagens comuns que podem mudar o estado. Quando o estado muda, a LiveView renderiza de novo as partes relevantes do template e as manda ao navegador, que atualiza a página do jeito mais eficiente possível.

O estado de uma LiveView é feito de estruturas de dados do Elixir, funcionais e imutáveis. Os eventos vêm do navegador ou de mensagens internas da aplicação, em geral publicadas pelo Phoenix.PubSub.

Trilha.pergunta(:primeira_renderizacao)

Toda LiveView é renderizada primeiro de forma estática, por uma requisição HTTP comum. Isso deixa a primeira pintura na tela rápida e ajuda buscadores e indexadores. Depois, o navegador abre uma conexão persistente com o servidor, e por ela passam os eventos e as mudanças da página. Como cada interação não precisa autenticar, decodificar, carregar e codificar tudo de novo, há menos trabalho e menos dados por evento. O guia resume assim: LiveView é "diffs over the wire", diffs pelo fio.

Na seção "Exemplo básico", você vai ver cada uma dessas peças funcionando: a renderização estática, a conexão, os diffs e o processo.

LiveView × controller e view

Na documentação: LiveView vs Controller + View

Controllers e LiveViews servem a propósitos parecidos, mas funcionam de jeitos bem diferentes:

Controller + view LiveView
Cada requisição e resposta HTTP é uma transação separada. O primeiro carregamento usa o ciclo de vida normal; depois, uma conexão bidirecional, feita com Phoenix Channels.
Cada carregamento de página ou envio de formulário é um ciclo completo. Os eventos viajam pela conexão aberta, não em requisições separadas.
Sem estado: os dados ficam fora, no banco ou na sessão. Um processo mantém o estado durante toda a interação.
A view é um módulo à parte, que renderiza com os dados do controller. Controller e view se juntam num modelo só.
Atualizar parte da página exige recarregá-la ou escrever JavaScript. Mudanças de estado renderizam de novo só as partes que mudaram; pouco JavaScript.
Trilha.pergunta(:requisicoes)

Exemplo básico

Na documentação: Basic example

Uma aplicação Phoenix nova já vem com LiveView. O exemplo do guia é um termostato:

lib/hello_web/live/thermostat_live.ex

defmodule HelloWeb.ThermostatLive do
  use HelloWeb, :live_view

  def render(assigns) do
    ~H"""
    Current temperature: {@temperature}°F
    <button phx-click="inc_temperature">+</button>
    """
  end

  def mount(_params, _session, socket) do
    temperature = 70 # Let's assume a fixed temperature for now
    {:ok, assign(socket, :temperature, temperature)}
  end

  def handle_event("inc_temperature", _params, socket) do
    {:noreply, update(socket, :temperature, &(&1 + 1))}
  end
end

A LiveView mostra o ciclo de vida básico:

  1. mount/3 inicializa o estado quando a LiveView começa: atribui 70 a :temperature;
  2. render/1 define o que aparece na tela, com um template HEEx, como nos componentes do notebook 06;
  3. handle_event/3 responde aos eventos que vêm do navegador: o botão tem phx-click="inc_temperature", e cada clique soma 1.

use HelloWeb, :live_view é o bloco live_view de lib/hello_web.ex, lá em cima: use Phoenix.LiveView mais os mesmos html_helpers das views HTML.

Sua vez: a rota

Para ligar a LiveView, o guia acrescenta ao router uma rota live:

scope "/", MyAppWeb do
  pipe_through :browser
  ...

  live "/thermostat", ThermostatLive
end

Na célula lib/hello_web/router.ex, lá em cima, há um comentário marcando onde a rota entra. Acrescente-a e avalie a célula do router de novo.

Trilha.solucao(:rota)
Trilha.conferir(:rota)

Ao avaliar o router, pode aparecer o aviso HelloWeb.ThermostatLive.__live__/0 is undefined. Ele aparece quando o router compila antes da LiveView existir, como na primeira avaliação do notebook, de cima para baixo. É o mesmo caso do aviso init/1 is undefined dos notebooks anteriores: a rota só procura o módulo quando uma requisição chega.

Todas as células desta seção usam a rota. Sem ela, cada uma mostra NoRouteError, e as que dependem da variável lv não compilam.

O app.js

Depois de renderizada, a LiveView vira uma resposta HTML comum. Num projeto, o arquivo assets/js/app.js traz:

import {Socket} from "phoenix"
import {LiveSocket} from "phoenix_live_view"

let csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute("content")
let liveSocket = new LiveSocket("/live", Socket, {params: {_csrf_token: csrfToken}})
liveSocket.connect()

Com isso, o cliente JavaScript conecta por WebSocket, e mount/3 é chamado de novo, agora dentro de um processo da LiveView. Neste notebook, o mesmo código está no <script> do root layout, e os dois arquivos vêm direto dos pacotes phoenix e phoenix_live_view.

Abra http://localhost:4010/thermostat no navegador e clique em +. Se quiser ver o fio, abra as ferramentas do navegador, aba Network, filtre por WS e clique na conexão websocket: cada clique aparece como uma mensagem enviada e uma recebida. As células abaixo mostram as mesmas mensagens.

A renderização estática

Primeiro, o que um cliente sem JavaScript recebe:

Trilha.curl("curl -i http://localhost:4010/thermostat")

É uma página HTML completa, com Current temperature: 70°F e o botão, renderizada no servidor como uma página de controller. A diferença está no <div> que envolve o conteúdo:

  • id="phx-..." e data-phx-main identificam a LiveView principal da página;
  • data-phx-session é um token assinado que diz qual LiveView montar (repare em ThermostatLive se decodificar o meio do token de Base64);
  • data-phx-static leva dados que não mudam entre conexões.

O JavaScript lê esses atributos para pedir ao servidor, pelo WebSocket, a mesma LiveView.

A conexão e o join

Trilha.abrir_lv/1 faz o que o navegador faz depois de carregar a página: abre o WebSocket em /live/websocket e manda uma mensagem phx_join com os tokens acima. A resposta do join é a primeira renderização completa, no formato que o cliente JavaScript entende:

{lv, entrada} = Trilha.abrir_lv("/thermostat")
Trilha.json(entrada)

O template foi separado em duas partes:

  • estáticos, a lista de pedaços de texto que nunca mudam ("Current temperature: " e "°F\n<button ...>+</button>"). Eles vêm em "p", uma tabela de estáticos compartilhados, e "s": 0 aponta para a posição 0 dela;
  • dinâmicos, as expressões entre {}, numeradas pela posição no template: "0": "70" é o valor de {@temperature}.

O HTML é os estáticos intercalados com os dinâmicos: "Current temperature: ", "70", "°F...". No log, CONNECTED TO Phoenix.LiveView.Socket registra a conexão e MOUNT HelloWeb.ThermostatLive, o mount dentro do processo da LiveView.

Um clique, um diff

Trilha.pergunta(:diff)

Trilha.clicar/2 manda o que o navegador manda quando você clica no botão: um evento click chamado inc_temperature.

{lv, diff} = Trilha.clicar(lv, "inc_temperature")
Trilha.json(diff)

A resposta é {"diff": {"0": "71"}}: só a posição 0, com o valor novo. Os estáticos não voltam, porque o navegador já os tem. Clique de novo:

{lv, diff} = Trilha.clicar(lv, "inc_temperature")
Trilha.json(diff)

Uma LiveView é um processo

Trilha.pergunta(:estado_onde)

Cada LiveView conectada é um processo Elixir no servidor, e o estado dela, os assigns, mora dentro dele. Trilha.processos/1 procura os processos de um módulo e mostra os assigns de cada um:

Trilha.processos(HelloWeb.ThermostatLive)

A temperatura 72 está no servidor, não no navegador. Se você abriu o termostato no navegador, o processo dele aparece também, com a temperatura que você deixou lá. Cada aba é um processo, com o seu próprio estado.

Quando a conexão fecha, o processo termina, e o estado vai junto:

pid = Trilha.processo(lv)
vivo_antes = Process.alive?(pid)

Trilha.desconectar(lv)
Process.sleep(100)

%{pid: pid, vivo_antes: vivo_antes, vivo_depois: Process.alive?(pid)}

Recarregar a página no navegador faz o mesmo: o processo antigo termina, um novo começa, e a temperatura volta a 70.

Sua vez: o botão de diminuir

Acrescente ao termostato um botão -, com phx-click="dec_temperature", e o handle_event/3 que diminui 1 grau. A temperatura nunca deve ficar abaixo de 50. Edite a célula thermostat_live.ex, avalie-a e teste no navegador (recarregue a página) ou com Trilha.abrir_lv/1 e Trilha.clicar/2.

Trilha.solucao(:menos)
Trilha.conferir(:menos)

Redefinir o módulo de uma LiveView com a página aberta no navegador não muda o processo que já está rodando: ele continua com o código antigo até reconectar. Recarregue a página depois de avaliar a célula.

Conceitos-chave

Na documentação: Key concepts

Socket e estado

O socket da LiveView é a estrutura que guarda todo o estado dela. É imutável e contém os assigns, os dados disponíveis no template. Onde os controllers têm conn, as LiveViews têm socket.

Mudanças no socket, com assign/3 ou update/3, provocam uma nova renderização. Como o socket é imutável, essas funções devolvem um socket novo, e é ele que o callback devolve em {:ok, socket} ou {:noreply, socket}. O estado inteiro fica no servidor; para o cliente vão só os diffs.

Para ver o "só o que mudou" com mais de um assign, esta LiveView mostra temperatura e umidade. A rota /clima já está no router.

lib/hello_web/live/clima_live.ex

defmodule HelloWeb.ClimaLive do
  use HelloWeb, :live_view

  def render(assigns) do
    ~H"""
    <p>Temperature: {@temperature}°F</p>
    <p>Humidity: {@humidity}%</p>
    <button phx-click="inc_temperature">+ temperature</button>
    <button phx-click="inc_humidity">+ humidity</button>
    """
  end

  def mount(_params, _session, socket) do
    {:ok, assign(socket, temperature: 70, humidity: 40)}
  end

  def handle_event("inc_temperature", _params, socket) do
    {:noreply, update(socket, :temperature, &(&1 + 1))}
  end

  def handle_event("inc_humidity", _params, socket) do
    {:noreply, update(socket, :humidity, &(&1 + 1))}
  end
end
{clima, entrada} = Trilha.abrir_lv("/clima")
Trilha.json(entrada)

Duas posições dinâmicas: "0" é a temperatura e "1" é a umidade.

Trilha.pergunta(:so_umidade)
{clima, diff} = Trilha.clicar(clima, "inc_humidity")
Trilha.desconectar(clima)
Trilha.json(diff)

Só "1". O HEEx sabe, na hora de compilar, quais assigns cada expressão usa. Quando @humidity muda, só a expressão {@humidity} é calculada de novo; a da temperatura nem roda. O guia Assigns and HEEx templates do LiveView explica esse rastreamento de mudanças e as armadilhas dele (por exemplo, variáveis criadas dentro do template desligam a otimização).

O ciclo de vida

Uma LiveView tem alguns momentos importantes, cada um com o seu callback:

  • mount/3 inicializa a LiveView com os parâmetros, a sessão e o socket;
  • handle_params/3 responde a mudanças na URL e atualiza o estado de acordo;
  • handle_event/3 responde às interações que vêm do navegador;
  • handle_info/2 responde a mensagens comuns de processos.

O mount acontece duas vezes

Esta LiveView anota, a cada mount/3, se o socket está conectado e qual processo está rodando. Trilha.anotar/1 guarda a anotação numa lista que qualquer processo pode alimentar; não é parte do LiveView.

lib/hello_web/live/montagem_live.ex

defmodule HelloWeb.MontagemLive do
  use HelloWeb, :live_view

  def render(assigns) do
    ~H"""
    Mounted {if @conectada, do: "connected", else: "disconnected"} in {inspect(@pid)}
    """
  end

  def mount(_params, _session, socket) do
    Trilha.anotar(%{connected?: connected?(socket), pid: self()})
    {:ok, assign(socket, conectada: connected?(socket), pid: self())}
  end
end
{montagem, _} = Trilha.abrir_lv("/montagem")
Trilha.desconectar(montagem)

Trilha.anotacoes()

Dois mounts, em dois processos:

  1. connected?: false, no processo que atendeu o GET. Ele renderiza o HTML estático e acabou. Aqui, como Trilha faz a requisição por dentro, sem rede, esse processo é o da própria célula; no navegador, é o processo do servidor HTTP que atende a requisição.
  2. connected?: true, num processo novo, criado quando o WebSocket conectou. É ele que vive enquanto a página está aberta.
Trilha.pergunta(:mount_duas)

O guia do LiveView recomenda: tudo o que só faz sentido com a página conectada (assinar tópicos, iniciar timers) fica dentro de if connected?(socket). E trabalho caro no mount/3, como uma consulta pesada, roda duas vezes; vale pensar se precisa estar ali.

handle_params e a URL

handle_params/3 é chamado logo depois do mount/3 e de novo toda vez que a URL muda por um live patch: um <.link patch={...}> clicado, ou um push_patch/2 no servidor. Ele recebe os parâmetros da URL, como um controller recebe params.

Trilha.pergunta(:patch_processo)

O guia do LiveView sobre navegação separa dois tipos: o patch, que fica na mesma LiveView e só chama handle_params/3, e o navigate, que troca de LiveView sem recarregar a página, montando uma nova pela mesma conexão.

Sua vez: Celsius ou Fahrenheit

Faça o termostato mostrar a temperatura em Celsius quando a URL tiver ?unit=C:

  • no mount/3, atribua também unit: "F";
  • defina handle_params(params, _uri, socket), que atribui :unit com "C" se params["unit"] for "C", e "F" em qualquer outro caso;
  • no template, mostre Current temperature: 21°C em vez de 70°F quando a unidade for C. A conversão é round((temperature - 32) * 5 / 9), e @temperature continua guardando Fahrenheit;
  • acrescente dois links de patch, <.link patch={~p"/thermostat"}>°F</.link> e <.link patch={~p"/thermostat?unit=C"}>°C</.link>.
Trilha.solucao(:unidade)
Trilha.conferir(:unidade)

Com o exercício feito, veja o que um live patch manda pelo fio e o que volta. Sem handle_params/3, a LiveView não sabe tratar o patch e cai; a célula avisa, e o erro aparece no log.

{lv, _} = Trilha.abrir_lv("/thermostat")
antes = Trilha.processo(lv)

{lv, resposta} = Trilha.patch(lv, "/thermostat?unit=C")
mesmo_processo = Trilha.processo(lv) == antes
Trilha.desconectar(lv)

Kino.Layout.grid([Kino.Markdown.new("**Mesmo processo depois do patch:** `#{mesmo_processo}`"), Trilha.json(resposta)])

O navegador mandou uma mensagem live_patch com a URL nova; no log aparece HANDLE PARAMS in HelloWeb.ThermostatLive com Parameters: %{"unit" => "C"}. A resposta é um diff com as posições que mudaram: o número e a unidade. No navegador, clique nos links °C e °F: a barra de endereço muda, a página não recarrega, e a temperatura que você ajustou com + e - continua a mesma, porque o processo é o mesmo.

handle_info e o PubSub

handle_info/2 recebe qualquer mensagem enviada ao processo da LiveView. O caso mais comum é o Phoenix.PubSub: a LiveView assina um tópico e, quando alguém publica nele, a mensagem chega ao handle_info/2. Se o estado mudar, o servidor empurra um diff para o navegador, sem que o usuário tenha feito nada. É assim que páginas LiveView mostram dados em tempo real.

Sua vez: leituras do sensor

Faça o termostato receber as leituras de um sensor:

  • no mount/3, quando connected?(socket), assine o tópico "leituras" do Hello.PubSub com Phoenix.PubSub.subscribe/2;
  • defina handle_info({:leitura, graus}, socket), que atribui graus a :temperature.
Trilha.solucao(:leituras)
Trilha.conferir(:leituras)

Com o exercício feito, publique uma leitura com duas páginas abertas. Se o termostato estiver aberto no navegador, ele muda também.

{a, _} = Trilha.abrir_lv("/thermostat")
{b, _} = Trilha.abrir_lv("/thermostat")

Phoenix.PubSub.broadcast(Hello.PubSub, "leituras", {:leitura, 64})

{a, diff_a} = Trilha.receber(a)
{b, diff_b} = Trilha.receber(b)
Trilha.desconectar(a)
Trilha.desconectar(b)

Trilha.json(%{"conexão a" => diff_a, "conexão b" => diff_b})

As duas conexões receberam {"0": "64"}. Diferente das respostas a cliques, essa mensagem chega sozinha, como um evento diff empurrado pelo servidor; quem publicou foi esta célula, que nem sabe quantas páginas estão abertas.

Bindings do DOM

Na documentação: DOM Bindings

O LiveView oferece atributos HTML, os bindings, que mandam eventos para o servidor quando algo acontece no navegador:

<button phx-click="inc_temperature">+</button>
<form phx-submit="save">...</form>
<input phx-blur="validate">

Cada evento chega ao handle_event/3. A lista completa (cliques, teclado, foco, rolagem, intervalo de repetição com phx-debounce e phx-throttle) está no guia Bindings do LiveView.

O que vem em params depende do binding. Esta LiveView, com a rota /bindings já no router, anota cada evento que recebe:

lib/hello_web/live/bindings_live.ex

defmodule HelloWeb.BindingsLive do
  use HelloWeb, :live_view

  def render(assigns) do
    ~H"""
    <button phx-click="ajustar" phx-value-quanto="5">+5</button>
    <button phx-click="ajustar" phx-value-quanto="-5">-5</button>
    <input type="text" name="apelido" phx-blur="saiu" placeholder="Type, then click outside" />
    <p>Last event: {@ultimo}</p>
    """
  end

  def mount(_params, _session, socket) do
    {:ok, assign(socket, :ultimo, "none")}
  end

  def handle_event(evento, params, socket) do
    Trilha.anotar({evento, params})
    {:noreply, assign(socket, :ultimo, "#{evento} #{inspect(params)}")}
  end
end

phx-value-* acrescenta valores ao evento. O navegador manda também o value do elemento; num <button> sem value, uma string vazia. Trilha.clicar/3 faz o mesmo. Um campo com phx-blur manda, quando perde o foco, o texto que estava nele:

{bindings, _} = Trilha.abrir_lv("/bindings")
{bindings, _} = Trilha.clicar(bindings, "ajustar", %{"quanto" => "5"})
{bindings, _} = Trilha.desfocar(bindings, "saiu", "Ana")
Trilha.desconectar(bindings)

Trilha.anotacoes()

Os valores chegam sempre como strings, como os parâmetros de um formulário: "5", não 5. Converta no servidor, e valide, porque qualquer um pode mandar qualquer coisa por esse WebSocket.

Formulários

Em formulários, dois bindings fazem quase tudo: phx-change, a cada mudança num campo, e phx-submit, no envio. Nos dois, o navegador manda o formulário inteiro, codificado como numa URL, e o servidor o decodifica no mesmo formato de params de um controller: %{"meta" => %{"valor" => "95"}}.

Trilha.pergunta(:form_target)

O guia Form bindings do LiveView mostra o padrão: o formulário é um assign criado com to_form/2, phx-change valida e atribui um formulário novo com os erros, e phx-submit grava. O componente <.input> dos CoreComponents só mostra o erro de um campo depois que o usuário mexeu nele, o mesmo used_input? do notebook 08.

Sua vez: a temperatura desejada

Acrescente ao termostato um formulário para a temperatura desejada:

  • no mount/3, atribua target: nil e form: to_form(%{"valor" => ""}, as: :meta); os campos chegam como meta[valor];
  • no template, <.form for={@form} phx-change="validar" phx-submit="definir"> com <.input field={@form[:valor]} type="number" label="Target (°F)" /> e um botão; acima do formulário, Target: 72°F quando @target não for nil;
  • handle_event("validar", %{"meta" => meta}, socket) atribui um formulário novo, to_form(meta, as: :meta, errors: erros), em que erros é [] se meta["valor"] for um inteiro de 50 a 90, e [valor: {"must be between 50 and 90", []}] caso contrário;
  • handle_event("definir", ...) faz a mesma validação e, se não houver erro, atribui o inteiro a :target.
Trilha.solucao(:meta)
Trilha.conferir(:meta)

Com o exercício feito, veja as duas mensagens de formulário e as respostas. Sem os handle_event/3 de "validar" e "definir", a LiveView cai, como no patch.

{lv, _} = Trilha.abrir_lv("/thermostat")
{lv, resposta_validar} = Trilha.mudar(lv, "validar", %{"meta" => %{"valor" => "95"}}, "meta[valor]")
{lv, resposta_definir} = Trilha.submeter(lv, "definir", %{"meta" => %{"valor" => "72"}})
Trilha.desconectar(lv)

Trilha.json(%{"1. validar com 95" => resposta_validar, "2. definir com 72" => resposta_definir})

No log, o HANDLE EVENT "validar" mostra os parâmetros já decodificados, com "_target" => ["meta", "valor"].

As respostas são maiores que as dos cliques, por dois motivos. Primeiro, o assign @form inteiro mudou, então voltam todas as partes dinâmicas do formulário: o value, as classes, os atributos. Os estáticos (as tags e o texto fixo) continuam no navegador. Segundo, a lista de erros do <.input> é um :for, e um :for vem como "k", uma comprehension com chave: cada item numerado, mais "kc", a quantidade de itens. No validar com 95, "kc": 1 e o item traz must be between 50 and 90; no definir com 72, "kc": 0, a lista ficou vazia e o erro sumiu. O Target: 72°F chega como um trecho novo, com os estáticos dele em "p".

Começando com os geradores

Na documentação: Getting Started

O Phoenix tem geradores de código para LiveView. O guia sugere:

$ mix phx.gen.live Blog Post posts title:string body:text

Ele gera um CRUD completo em LiveView, parecido com o do mix phx.gen.html do notebook 08. Num projeto 1.8.16 sem autenticação, a saída é:

* creating lib/hello_web/live/post_live/show.ex
* creating lib/hello_web/live/post_live/index.ex
* creating lib/hello_web/live/post_live/form.ex
* creating test/hello_web/live/post_live_test.exs
* creating lib/hello/blog/post.ex
* creating priv/repo/migrations/20261111031035_create_posts.exs
* creating lib/hello/blog.ex
* injecting lib/hello/blog.ex
* creating test/hello/blog_test.exs
* injecting test/hello/blog_test.exs
* creating test/support/fixtures/blog_fixtures.ex
* injecting test/support/fixtures/blog_fixtures.ex

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

    live "/posts", PostLive.Index, :index
    live "/posts/new", PostLive.Form, :new
    live "/posts/:id", PostLive.Show, :show
    live "/posts/:id/edit", PostLive.Form, :edit

O contexto Blog e o schema são iguais aos do phx.gen.html. No lugar do controller e dos templates, vêm três LiveViews: a lista, a página de um post e o formulário, que serve para criar e editar. O notebook 17 estuda esse código: streams, navegação entre LiveViews e formulários com validação ao digitar.

Para aprender mais, a documentação do Phoenix LiveView tem guias para cada assunto deste notebook.

Sem roteiro: a vitrine do Brechó ao vivo

Na vitrine do Brechó, duas pessoas podem estar olhando a mesma peça. Quando uma compra, a peça deveria sumir da tela da outra na hora, sem ela recarregar a página.

O Brechó até aqui

O contexto Hello.Vitrine do notebook 08 (o ProdutoController de lá não é necessário aqui): a tabela produtos, o schema e o contexto, com vender/1, que só vende uma peça que ainda não foi vendida.

priv/repo/migrations/20250320000000_create_produtos.exs (a do notebook 07)

defmodule Hello.Repo.Migrations.CreateProdutos do
  use Ecto.Migration

  def change do
    create table(:produtos) do
      add :nome, :string, null: false
      add :preco_centavos, :integer, null: false
      add :vendido, :boolean, default: false, null: false

      timestamps(type: :utc_datetime)
    end
  end
end

Trilha.migracao(20250320000000, Hello.Repo.Migrations.CreateProdutos)
Ecto.Migrator.run(Hello.Repo, Trilha.migracoes(), :up, all: true)

lib/hello/vitrine/produto.ex e lib/hello/vitrine.ex

defmodule Hello.Vitrine.Produto do
  use Ecto.Schema
  import Ecto.Changeset

  schema "produtos" do
    field :nome, :string
    field :preco_centavos, :integer
    field :vendido, :boolean, default: false

    timestamps(type: :utc_datetime)
  end

  @doc false
  def changeset(produto, attrs) do
    produto
    |> cast(attrs, [:nome, :preco_centavos])
    |> validate_required([:nome, :preco_centavos])
    |> validate_number(:preco_centavos, greater_than: 0)
  end
end

defmodule Hello.Vitrine do
  @moduledoc """
  A vitrine do Brechó: o que está à venda e a venda de cada peça.
  """

  import Ecto.Query, warn: false
  alias Hello.Repo

  alias Hello.Vitrine.Produto

  def listar_disponiveis do
    Repo.all(from p in Produto, where: not p.vendido, order_by: p.preco_centavos)
  end

  def buscar_produto!(id), do: Repo.get!(Produto, id)

  def cadastrar_produto(attrs) do
    %Produto{}
    |> Produto.changeset(attrs)
    |> Repo.insert()
  end

  def change_produto(%Produto{} = produto, attrs \\ %{}) do
    Produto.changeset(produto, attrs)
  end

  @doc """
  Marca a peça como vendida, se ninguém a vendeu antes.

  A verificação e a mudança acontecem num só UPDATE, então duas vendas
  simultâneas da mesma peça nunca dão certo ao mesmo tempo.
  """
  def vender(%Produto{id: id}) do
    consulta = from p in Produto, where: p.id == ^id and not p.vendido, select: p

    case Repo.update_all(consulta, set: [vendido: true, updated_at: DateTime.utc_now(:second)]) do
      {1, [produto]} -> {:ok, produto}
      {0, []} -> {:error, :ja_vendido}
    end
  end
end

Algumas peças à venda:

for {nome, preco} <- [{"Jaqueta jeans", 8000}, {"Vestido floral", 4500}, {"Bota de couro", 12000}] do
  {:ok, _} = Hello.Vitrine.cadastrar_produto(%{nome: nome, preco_centavos: preco})
end

Hello.Vitrine.listar_disponiveis() |> Enum.map(& &1.nome)

O desafio

Escreva a LiveView HelloWeb.VitrineLive, em /vitrine:

  • a rota live "/vitrine", VitrineLive, na célula do router lá em cima;
  • a página lista Vitrine.listar_disponiveis() num assign :produtos, com nome, preço e um botão phx-click="comprar" com phx-value-id da peça;
  • handle_event("comprar", %{"id" => id}, socket) chama Vitrine.vender/1. Se der certo, publica {:vendido, id} no tópico "vitrine" e mostra uma flash :info com o nome da peça. Se a peça já tiver sido vendida, mostra a flash :error Esta peça já foi vendida.;
  • toda vitrine conectada assina o tópico "vitrine" e, ao receber {:vendido, id}, tira a peça de :produtos.

Use <Layouts.app flash={@flash}> em volta do conteúdo, para as flashes aparecerem. Teste com duas abas do navegador lado a lado.

lib/hello_web/live/vitrine_live.ex

defmodule HelloWeb.VitrineLive do
  use HelloWeb, :live_view

  alias Hello.Vitrine

  # Sua vez: a vitrine ao vivo. Liste as peças disponíveis, cada uma com um botão
  # "Comprar", e avise as outras vitrines abertas quando uma peça for vendida.

  def render(assigns) do
    ~H"""
    <Layouts.app flash={@flash}>
      <.header>Vitrine do Brechó</.header>
    </Layouts.app>
    """
  end

  def mount(_params, _session, socket) do
    {:ok, assign(socket, :produtos, Vitrine.listar_disponiveis())}
  end
end

Para experimentar com o navegador simulado, depois de escrever a LiveView:

{ana, _} = Trilha.abrir_lv("/vitrine", como: "ana")
{bia, _} = Trilha.abrir_lv("/vitrine", como: "bia")
[primeira | _] = Hello.Vitrine.listar_disponiveis()

{ana, resposta_ana} = Trilha.clicar(ana, "comprar", %{"id" => to_string(primeira.id)})
{bia, empurrado_bia} = Trilha.receber(bia)
{bia, resposta_bia} = Trilha.clicar(bia, "comprar", %{"id" => to_string(primeira.id)})
Trilha.desconectar(ana)
Trilha.desconectar(bia)

Trilha.json(%{"1. ana compra" => resposta_ana, "2. empurrado para a bia" => empurrado_bia, "3. bia tenta comprar" => resposta_bia})

A resposta ao clique da ana traz a flash. O que a bia recebe sem clicar é o :for da lista de novo, com as peças que sobraram ("kc" diminuiu um). E a tentativa da bia volta com a flash de erro. Sem a rota /vitrine, a célula mostra NoRouteError.

Trilha.solucao(:brecho)
Trilha.conferir(:brecho)

A última conferência põe cinco compradores clicando ao mesmo tempo na mesma peça. Só um consegue porque Vitrine.vender/1 confere e marca a peça num único UPDATE, como no notebook 08: cada LiveView é um processo, e cinco processos podem chegar ao banco no mesmo instante.

No seu projeto

  1. Um projeto gerado por mix phx.new já tem tudo ligado: o socket "/live" no endpoint, o bloco live_view em lib/my_app_web.ex e o LiveSocket em assets/js/app.js.
  2. Crie lib/my_app_web/live/thermostat_live.ex com o código do guia e acrescente live "/thermostat", ThermostatLive ao scope "/" do router.
  3. Rode mix phx.server e abra http://localhost:4000/thermostat. Nas ferramentas do navegador, aba Network, filtro WS, veja as mensagens a cada clique.
  4. Para ver os processos e o estado no servidor, abra o LiveDashboard em http://localhost:4000/dev/dashboard, aba Processes: cada página aberta aparece como um processo com o nome da LiveView. Ou rode o projeto com iex -S mix phx.server e chame :observer.start().
  5. Experimente o gerador: mix phx.gen.live Blog Post posts title:string body:text, acrescente as rotas que ele imprime e rode mix ecto.migrate.

Duas diferenças entre o notebook e o projeto. No projeto, o signing_salt do LiveView fica em config/config.exs, na configuração do endpoint; aqui ele entra na célula que inicia a aplicação. E o JavaScript do projeto é compilado pelo esbuild a partir de assets/js/app.js; aqui, o root layout carrega os arquivos prontos dos pacotes.

Revisão

  • Uma LiveView é um processo que guarda o estado nos assigns do socket, recebe eventos e mensagens, e manda ao navegador só o que mudou.
  • A primeira renderização é uma requisição HTTP comum, com o HTML completo. Depois, o JavaScript abre um WebSocket e faz o join; a resposta separa estáticos, mandados uma vez, de dinâmicos, numerados pela posição.
  • mount/3 roda duas vezes, a primeira sem conexão. O que só faz sentido conectado vai em if connected?(socket).
  • handle_event/3 trata cliques, formulários e outros bindings (phx-click, phx-value-*, phx-change, phx-submit, phx-blur); handle_params/3 trata a URL, inclusive nos live patches; handle_info/2 trata mensagens, como as do PubSub, e pode atualizar todas as páginas abertas de uma vez.
  • Cada aba é um processo com o seu estado. Operações que dependem de exclusividade, como vender a última peça, precisam de garantia no banco, não na LiveView.

No próximo notebook, o código que o mix phx.gen.live gera: streams, navegação e formulários.