Powered by AppSignal & Oban Pro

Trilha Phoenix · O gerador `phx.gen.live`

17_phx_gen_live.livemd

Trilha Phoenix · O gerador phx.gen.live

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: verifica a conexão com o PostgreSQL, registra migrations,
  faz requisições ao endpoint como um navegador faria, conversa com as LiveViews
  pelo WebSocket como o cliente JavaScript, 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),
  depois uma conexão WebSocket em /live/websocket e o "phx_join" no tópico da
  LiveView. Devolve `{lv, resposta_do_join}`; `lv` é a conexão, para usar com as
  outras funções desta seção. `como:` escolhe o visitante (os cookies dele).
  """
  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, com os cookies do visitante.
    conn =
      case visitar(quem, :get, caminho) do
        {:ok, conn} -> conn
        {:erro, erro} -> raise "o GET #{caminho} falhou antes de chegar a uma LiveView: " <> Exception.message(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}, caminho final #{conn.request_path})")

    # 2. O WebSocket, com o cookie da sessão (o LiveView confere o 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: nil,
      ref: 1,
      quem: quem,
      porta: porta,
      csrf: csrf,
      sessao: sessao,
      estatico: estatico,
      caminho: caminho
    }

    # 3. O join no tópico da LiveView.
    entrar_no_topico(lv, %{"url" => url_completa(lv, caminho)})
  end

  @doc "Envia um evento de clique (phx-click) e devolve {lv, resposta}."
  def clicar(lv, evento, valor \\ %{}), do: enviar_evento(lv, "click", evento, valor)

  @doc "Envia um evento qualquer e devolve {lv, resposta}."
  def enviar_evento(lv, tipo, evento, valor, meta \\ nil) do
    ref = Integer.to_string(lv.ref)
    payload = %{"type" => tipo, "event" => evento, "value" => valor}
    payload = if meta, do: Map.put(payload, "meta", meta), else: payload
    enviar_quadro(lv, [lv.join_ref, ref, lv.topico, "event", payload])
    esperar_resposta(%{lv | ref: lv.ref + 1}, ref)
  end

  @doc """
  Simula a digitação num formulário com `phx-change`, como o cliente JS faz:
  `campos` é uma lista de `{nome, valor}` com todos os campos do formulário;
  `tocados` são os nomes dos campos em que a pessoa já mexeu. Os outros vão com
  o prefixo `_unused_`, que é o que faz `used_input?/1` esconder os erros deles.
  O último campo tocado vai em `meta._target`.
  """
  def digitar(lv, evento, campos, tocados) do
    valor =
      Enum.flat_map(campos, fn {nome, v} ->
        if nome in tocados, do: [{nome, v}], else: [{prefixar_unused(nome), ""}, {nome, v}]
      end)
      |> URI.encode_query()

    enviar_evento(lv, "form", evento, valor, %{"_target" => List.last(tocados) || "undefined"})
  end

  @doc "Simula o envio de um formulário com `phx-submit`. Devolve {lv, resposta}."
  def submeter(lv, evento, campos) do
    enviar_evento(lv, "form", evento, URI.encode_query(campos), %{})
  end

  # "post[title]" vira "post[_unused_title]", como o prependFormDataKey do cliente JS.
  defp prefixar_unused(nome) do
    case Regex.run(~r/^(.*)\[([^\[\]]+)\]$/, nome) do
      [_, base, campo] -> "#{base}[_unused_#{campo}]"
      nil -> "_unused_" <> nome
    end
  end

  @doc """
  Navega para outra LiveView sem recarregar a página, como `<.link navigate={...}>`
  ou `JS.navigate/1`: sai do tópico atual e faz um novo join com `redirect`.
  """
  def navegar_lv(lv, caminho, flash \\ nil) do
    ref = Integer.to_string(lv.ref)
    enviar_quadro(lv, [lv.join_ref, ref, lv.topico, "phx_leave", %{}])
    {lv, _} = esperar_resposta(%{lv | ref: lv.ref + 1}, ref)
    payload = %{"redirect" => url_completa(lv, caminho)}
    payload = if flash, do: Map.put(payload, "flash", flash), else: payload
    entrar_no_topico(%{lv | caminho: caminho}, payload)
  end

  @doc "Muda a URL na mesma LiveView, como `<.link patch={...}>`: dispara `handle_params/3`."
  def patch_lv(lv, caminho) do
    ref = Integer.to_string(lv.ref)
    enviar_quadro(lv, [lv.join_ref, ref, lv.topico, "live_patch", %{"url" => url_completa(lv, caminho)}])
    esperar_resposta(%{lv | ref: lv.ref + 1, caminho: caminho}, ref)
  end

  @doc """
  Segue um redirecionamento que veio numa resposta do servidor, como o cliente JS
  faz: `live_redirect` (de `push_navigate`) vira um novo join na mesma conexão,
  com a flash; `live_patch` (de `push_patch`) só troca a URL.
  """
  def seguir_lv(lv, %{"live_redirect" => %{"to" => para} = opcoes}), do: navegar_lv(lv, para, opcoes["flash"])
  def seguir_lv(lv, %{"live_patch" => %{"to" => para}}), do: {%{lv | caminho: para}, %{"live_patch" => para}}
  def seguir_lv(lv, resposta), do: {lv, resposta}

  @doc "Espera uma mensagem empurrada pelo servidor (um diff que não veio de evento do cliente)."
  def receber(lv, tempo \\ 1000) do
    case ler_mensagem(lv, tempo) do
      {lv, [_, nil, _topico, "diff", diff]} -> {lv, diff}
      {lv, nil} -> {lv, :nada}
      {lv, outra} -> {lv, outra}
    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 "Os textos que aparecem numa mensagem do fio (útil para procurar uma mensagem de erro)."
  def textos(valor) when is_map(valor), do: valor |> Map.values() |> Enum.flat_map(&textos/1)
  def textos(valor) when is_list(valor), do: Enum.flat_map(valor, &textos/1)
  def textos(valor) when is_binary(valor), do: [valor]
  def textos(_), do: []

  defp entrar_no_topico(lv, extra) do
    ref = Integer.to_string(lv.ref)

    payload =
      Map.merge(
        %{"params" => %{"_csrf_token" => lv.csrf, "_mounts" => 0}, "session" => lv.sessao, "static" => lv.estatico},
        extra
      )

    lv = %{lv | join_ref: ref, ref: lv.ref + 1}
    enviar_quadro(lv, [ref, ref, lv.topico, "phx_join", payload])
    esperar_resposta(lv, ref)
  end

  defp url_completa(lv, caminho), do: "http://localhost:#{lv.porta}#{caminho}"

  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", %{"status" => "error", "response" => resposta}]} -> {lv, %{"erro" => resposta}}
      {_lv, [_, _, _, "phx_error", _]} -> raise "o processo da LiveView caiu: o servidor mandou phx_error no tópico. O motivo está no log do servidor."
      {_lv, nil} -> raise "o servidor não respondeu (o processo da LiveView caiu? veja o 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

  defp mascarar(dados, mascara) do
    tamanho = byte_size(dados)
    repetida = :binary.copy(mascara, div(tamanho, 4) + 1) |> binary_part(0, tamanho)
    :crypto.exor(dados, repetida)
  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, :timeout} -> {lv, nil}
          {:error, :closed} -> {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] -> valor |> String.replace(""", "\"") |> String.replace("&amp;", "&")
      nil -> nil
    end
  end

  @doc """
  Um resumo de uma mensagem do fio: o tamanho do JSON, o título da página, as
  operações de stream (o que entra e o que sai da lista) e os textos que mudaram.
  A mensagem completa continua disponível com `Trilha.json/1`.
  """
  def resumo_lv(resposta) do
    linhas =
      cond do
        redir = resposta["live_redirect"] ->
          ["**`live_redirect`** para `#{redir["to"]}` (`kind: #{redir["kind"]}`)" <>
             if(redir["flash"], do: ", com a flash assinada num token", else: "") <>
             ". Nenhum HTML veio junto: o cliente faz um novo join, na mesma conexão."]

        redir = resposta["redirect"] ->
          ["**`redirect`** para `#{redir["to"]}`: o cliente faz uma requisição HTTP nova (a página recarrega)."]

        erro = resposta["erro"] ->
          ["**O servidor recusou:** `#{inspect(erro)}`"]

        true ->
          arvore = resposta["rendered"] || resposta["diff"] || resposta
          tipo = if resposta["rendered"], do: "renderização completa (join)", else: "diff"
          titulo = if t = arvore["t"], do: ["título da página: **#{t}**"], else: []

          streams =
            for {ref, ordem, deletes, reset} <- streams_do(arvore) do
              entra = Enum.map_join(ordem, ", ", &"`#{&1}`")
              sai = Enum.map_join(deletes, ", ", &"`#{&1}`")
              reset = if reset, do: " (reset: a lista é refeita)", else: ""

              "stream `#{ref}`#{reset}: " <>
                Enum.join(Enum.reject(["entram #{entra}", "saem #{sai}"], &String.ends_with?(&1, " ")), "; ")
            end

          textos =
            arvore
            |> textos_dinamicos()
            |> Enum.map(&String.trim/1)
            |> Enum.reject(&(&1 == "" or String.starts_with?(&1, ["phx-", "<"]) or &1 =~ ~r/^\w[\w-]*=/ or classes_css?(&1)))
            |> Enum.uniq()
            |> Enum.take(12)

          cabeca = ["**#{tipo}**, #{byte_size(Jason.encode!(resposta))} bytes de JSON"] ++ titulo

          if resposta["rendered"] do
            html = html_lv(resposta)

            flash =
              for tipo <- ["info", "error"],
                  [_, msg] <- [Regex.run(~r/id="flash-#{tipo}".*?<p>(.*?)<\/p>/s, html)],
                  do: "flash #{tipo}: **#{desescapar_html(msg)}**"

            cabeca ++ flash ++ streams ++ ["texto da página, montado a partir do join:\n\n" <> cerca(texto(html), "")]
          else
            cabeca ++ streams ++ if(textos == [], do: [], else: ["textos que mudaram: " <> Enum.map_join(textos, " · ", &"`#{desescapar_html(&1)}`")])
          end
      end

    Kino.Markdown.new(Enum.map_join(linhas, "\n", &("* " <> &1)))
  end

  @doc """
  Monta o HTML de uma resposta de join, como o cliente JS faz: cada nó tem os
  estáticos (`"s"`, uma lista ou um índice nos compartilhados `"p"`) e os
  dinâmicos nas chaves numéricas; compreensões (`"k"`) repetem os estáticos para
  cada item. Serve para joins, que trazem a árvore inteira; um diff só traz o
  que mudou.
  """
  def html_lv(%{"rendered" => arvore}), do: montar_no(arvore, arvore["p"] || %{})

  defp montar_no(texto, _p) when is_binary(texto), do: texto

  defp montar_no(%{"s" => s} = no, p) do
    estaticos = if is_integer(s), do: Map.fetch!(p, Integer.to_string(s)), else: s

    case no do
      %{"k" => itens} ->
        itens
        |> Map.delete("kc")
        |> Enum.sort_by(fn {chave, _} -> String.to_integer(chave) end)
        |> Enum.map_join(fn {_, dinamicos} -> intercalar(estaticos, dinamicos, p) end)

      _ ->
        intercalar(estaticos, no, p)
    end
  end

  defp montar_no(_, _p), do: ""

  defp intercalar(estaticos, dinamicos, p) do
    ultimo = length(estaticos) - 1

    estaticos
    |> Enum.with_index()
    |> Enum.map_join(fn
      {estatico, ^ultimo} -> estatico
      {estatico, i} -> estatico <> montar_no(dinamicos[Integer.to_string(i)], p)
    end)
  end

  @doc """
  As operações de stream de uma mensagem do fio, como `{ref, ids_que_entram, ids_que_saem, reset?}`.
  Os ids que entram vêm na ordem em que aparecem na página (a ordem da compreensão).
  """
  def streams_do(mapa) when is_map(mapa) do
    proprios =
      case mapa["stream"] do
        [ref, inserts, deletes | resto] ->
          ordem = ids_na_ordem(mapa["k"])
          inseridos = Enum.map(inserts, fn [id | _] -> id end)
          ordem = if ordem == [], do: Enum.uniq(inseridos), else: ordem
          [{ref, ordem, deletes, resto == [true]}]

        _ ->
          []
      end

    proprios ++ (mapa |> Map.drop(["stream", "k"]) |> Map.values() |> Enum.flat_map(&streams_do/1))
  end

  def streams_do(lista) when is_list(lista), do: Enum.flat_map(lista, &streams_do/1)
  def streams_do(_), do: []

  # Cada item da compreensão é um mapa; o primeiro atributo id="..." dele é o id do item na página.
  defp ids_na_ordem(%{} = k) do
    k
    |> Map.delete("kc")
    |> Enum.sort_by(fn {chave, _} -> String.to_integer(chave) end)
    |> Enum.flat_map(fn {_, item} ->
      case Enum.find_value(textos_dinamicos(item), &Regex.run(~r/\sid="([^"]+)"/, &1)) do
        [_, id] -> [id]
        nil -> []
      end
    end)
  end

  defp ids_na_ordem(_), do: []

  # "w-full input input-error", "hero-plus size-4", "1.8.16": classes CSS e versões, não texto da página.
  defp classes_css?(texto) do
    texto =~ ~r/^\d+(\.\d+)+$/ or
      (texto =~ ~r/^[a-z0-9:\/\.\[\]_-]+( [a-z0-9:\/\.\[\]_-]+)*$/ and texto =~ ~r/[-:]/)
  end

  # Só os valores dinâmicos (chaves numéricas), sem os estáticos ("s" e "p").
  defp textos_dinamicos(mapa) when is_map(mapa) do
    mapa
    |> Map.drop(["s", "p", "r", "t", "stream", "kc"])
    |> Map.values()
    |> Enum.flat_map(&textos_dinamicos/1)
  end

  defp textos_dinamicos(lista) when is_list(lista), do: Enum.flat_map(lista, &textos_dinamicos/1)
  defp textos_dinamicos(texto) when is_binary(texto), do: [texto]
  defp textos_dinamicos(_), do: []

  defp desescapar_html(texto) do
    texto
    |> String.replace("'", "'")
    |> String.replace(""", "\"")
    |> String.replace("<", "<")
    |> String.replace(">", ">")
    |> String.replace("&amp;", "&")
  end

  @doc """
  Roda `fun` e conta quais callbacks de LiveView o servidor executou, em qual
  processo, e se a LiveView já estava conectada. Usa os eventos de telemetria que
  o LiveView emite. Devolve `{resultado_de_fun, tabela}`.
  """
  def callbacks(fun) do
    eu = self()
    id = {__MODULE__, make_ref()}
    nomes = [:mount, :handle_params, :handle_event, :render]

    :telemetry.attach_many(
      id,
      Enum.map(nomes, &[:phoenix, :live_view, &1, :start]),
      &__MODULE__.avisar_callback/4,
      {eu, id}
    )

    resultado =
      try do
        fun.()
      after
        Process.sleep(100)
        :telemetry.detach(id)
      end

    eventos = coletar_callbacks(id, [])

    case for({_, _, pid, true, _} <- eventos, do: pid) |> List.last() do
      nil -> :ok
      pid -> :persistent_term.put({__MODULE__, :ultimo_lv}, pid)
    end

    linhas =
      eventos
      |> Enum.with_index(1)
      |> Enum.map(fn {{nome, view, pid, conectado, evento}, i} ->
        detalhe = if evento, do: " `#{evento}`", else: ""
        "| #{i} | `#{nome}`#{detalhe} | `#{inspect(view)}` | `#{inspect(pid)}` | `#{conectado}` |"
      end)

    tabela =
      if linhas == [],
        do: Kino.Markdown.new("Nenhum callback de LiveView rodou."),
        else:
          Kino.Markdown.new(
            Enum.join(["| # | Callback | LiveView | Processo | `connected?` |", "| --- | --- | --- | --- | --- |" | linhas], "\n")
          )

    {resultado, tabela}
  end

  @doc false
  def avisar_callback([_, _, nome, _], _medidas, meta, {eu, id}) do
    socket = meta.socket
    send(eu, {id, nome, socket.view, self(), socket.transport_pid != nil, meta[:event]})
  end

  @doc """
  Os assigns guardados no processo da última LiveView conectada que `callbacks/1`
  viu: o estado do servidor, lido direto do processo.
  """
  def assigns_lv do
    case :persistent_term.get({__MODULE__, :ultimo_lv}, nil) do
      pid when is_pid(pid) ->
        if Process.alive?(pid),
          do: :sys.get_state(pid).socket.assigns,
          else: raise("a última LiveView vista por Trilha.callbacks/1 já terminou; abra-a de novo")

      nil ->
        raise "nenhuma LiveView conectada foi vista ainda: abra uma com Trilha.callbacks(fn -> Trilha.abrir_lv(...) end)"
    end
  end

  defp coletar_callbacks(id, acc) do
    receive do
      {^id, nome, view, pid, conectado, evento} -> coletar_callbacks(id, [{nome, view, pid, conectado, evento} | acc])
    after
      0 -> Enum.reverse(acc)
    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
    %{
      arquivos: %{
        texto:
          "o `mix phx.gen.html` do notebook 08 gerou um controller, um módulo `ProductHTML` e cinco templates. O que o `mix phx.gen.live` gera no lugar deles?",
        opcoes: [
          a: "Os mesmos arquivos, mais um arquivo JavaScript para cada página.",
          b: "Três LiveViews (`Index`, `Show` e `Form`), cada uma com o seu `render/1`; não há controller nem view separada.",
          c: "Uma única LiveView que trata todas as páginas.",
          d: "Um controller que devolve JSON e um componente para cada página."
        ],
        correta: :b,
        explicacao:
          "Uma LiveView junta o papel do controller (receber o pedido, chamar o contexto) e o da view (o `render/1` com o template `~H`). O gerador cria uma LiveView por página: a listagem, a página do post e um formulário que serve tanto para criar quanto para editar. O contexto (`Blog`), o schema e a migration são os mesmos que o `phx.gen.html` geraria."
      },
      mount_duas_vezes: %{
        texto:
          "você abre `/posts` no navegador. Quantas vezes o `mount/3` do `PostLive.Index` roda, e em quantos processos diferentes?",
        opcoes: [
          a: "Uma vez, no processo da LiveView.",
          b: "Duas vezes, no mesmo processo.",
          c: "Duas vezes, em dois processos: uma na requisição HTTP (a renderização estática) e outra no processo da LiveView, depois que o WebSocket conecta.",
          d: "Uma vez por usuário conectado ao servidor."
        ],
        correta: :c,
        explicacao:
          "A primeira renderização é uma requisição HTTP comum: o `mount/3` roda no processo da requisição, com `connected?(socket)` falso, e o HTML completo vai para o navegador (bom para a primeira pintura e para buscadores). Depois o JavaScript abre o WebSocket e faz o join; aí nasce o processo da LiveView, que chama `mount/3` de novo, agora conectado, e fica vivo enquanto a página estiver aberta. Por isso assinaturas de PubSub ficam dentro de `if connected?(socket)`."
      },
      stream_memoria: %{
        texto:
          "o `mount/3` do `Index` chama `stream(:posts, Blog.list_posts())`. Depois da primeira renderização, onde estão os posts?",
        opcoes: [
          a: "Nos assigns do processo da LiveView, como uma lista, para as próximas renderizações.",
          b: "Só no navegador (no DOM). O processo da LiveView guarda a configuração do stream, mas não os itens, que foram enviados e descartados.",
          c: "No banco e em lugar nenhum mais: o navegador recebe só os ids.",
          d: "Num cache do Phoenix compartilhado entre todas as LiveViews."
        ],
        correta: :b,
        explicacao:
          "Um stream é uma coleção que o servidor envia e esquece: depois que os itens são renderizados e mandados ao cliente, o `LiveStream` nos assigns fica vazio (`inserts: []`). O navegador mantém os elementos no DOM, cada um com um `id` (`posts-1`, `posts-2`...). Para mudar a lista, o servidor manda só operações: insira este item, remova aquele id. Com milhares de linhas, a memória do processo não cresce com a lista."
      },
      tocados: %{
        texto:
          "no formulário de post novo, você apaga o que digitou no título (fica vazio) e ainda não clicou no corpo. O `phx-change=\"validate\"` roda o changeset, que acusa os dois campos em branco. Quais erros aparecem na tela?",
        opcoes: [
          a: "Os dois: o changeset é inválido nos dois campos.",
          b: "Nenhum: erros só aparecem depois do `phx-submit`.",
          c: "Só o do título. O cliente marca os campos em que você ainda não mexeu, e o `<.input>` esconde os erros deles.",
          d: "Só o do corpo, porque é o próximo campo."
        ],
        correta: :c,
        explicacao:
          "O cliente JavaScript envia, para cada campo ainda não usado, uma chave com o prefixo `_unused_` (aqui, `post[_unused_body]`). `Phoenix.Component.used_input?/1` olha essas chaves, e o `<.input>` do `CoreComponents` só mostra os erros dos campos usados. O changeset continua inválido nos dois campos; é a tela que não incomoda a pessoa antes da hora."
      },
      salvar_fio: %{
        texto:
          "o `save` grava o post e chama `push_navigate(to: ~p\"/posts\")`. O que o servidor manda pelo WebSocket como resposta ao evento?",
        opcoes: [
          a: "O HTML completo da listagem.",
          b: "Um diff com a listagem nova.",
          c: "Só uma instrução `live_redirect` com o destino e a flash. O navegador então faz um novo join, na mesma conexão, e o `Index` monta do zero.",
          d: "Um redirecionamento HTTP 302."
        ],
        correta: :c,
        explicacao:
          "`push_navigate/2` termina a LiveView atual e responde ao evento com `live_redirect` (destino, tipo `push` e a flash assinada num token). O cliente troca a URL com a History API e faz um novo `phx_join` na mesma conexão WebSocket, sem requisição HTTP e sem recarregar a página. É o `mount/3` da LiveView de destino que gera o conteúdo novo."
      },
      navegacao: %{
        texto:
          "na listagem, a ordem muda com `<.link patch={~p\"/posts?ordem=title\"}>`. Na mesma página, o título de cada post leva à página dele com `navigate`. O que roda no servidor em cada clique?",
        opcoes: [
          a: "Nos dois, uma requisição HTTP nova e um `mount/3`.",
          b: "`patch`: só `handle_params/3`, na mesma LiveView. `navigate`: um `mount/3` numa LiveView nova, pela mesma conexão, sem HTTP.",
          c: "`patch`: um `mount/3` novo. `navigate`: só `handle_params/3`.",
          d: "Nos dois, só `handle_event/3`."
        ],
        correta: :b,
        explicacao:
          "`patch` muda a URL sem trocar de LiveView: o mesmo processo recebe a URL nova em `handle_params/3` e manda só o diff. `navigate` troca de LiveView: o processo antigo termina e outro monta, mas a conexão WebSocket e o layout da página continuam. Um `href` comum (ou um link para fora do `live_session`) recarrega tudo: requisição HTTP, renderização estática, novo WebSocket e dois `mount/3`."
      },
      sem_pubsub: %{
        texto:
          "a Ana e a Bia estão com `/posts` aberto. A Ana cria um post pelo formulário. O que acontece na tela da Bia, com o código gerado (sem escopo)?",
        opcoes: [
          a: "O post aparece na hora: LiveView sincroniza as telas sozinha.",
          b: "Nada, até a Bia recarregar a página. Cada LiveView é um processo com o próprio estado, e ninguém avisou o processo da Bia.",
          c: "A página da Bia recarrega sozinha.",
          d: "A LiveView da Bia cai com um erro."
        ],
        correta: :b,
        explicacao:
          "O processo da LiveView da Bia montou a lista quando ela abriu a página e só muda quando recebe um evento ou uma mensagem. Para atualizar em tempo real, alguém tem de mandar uma mensagem: o contexto publica no `Phoenix.PubSub` e cada `Index` conectado assina o tópico e trata a mensagem em `handle_info/2`. Os geradores com escopo (notebook 14) já criam esse código; o gerado sem escopo, não."
      },
      escopo: %{
        texto:
          "num projeto com `mix phx.gen.auth --live` (escopo padrão do usuário), o mesmo `mix phx.gen.live Blog Post posts ...` gera código diferente. O que muda?",
        opcoes: [
          a: "Nada: o escopo só vale para controllers.",
          b: "As rotas entram num `live_session` com `on_mount` que exige login; cada LiveView passa `@current_scope` ao contexto; a tabela ganha `user_id`; e o `Index` assina um tópico de PubSub por usuário.",
          c: "Só a migration muda, ganhando `user_id`.",
          d: "As LiveViews passam a guardar o usuário em cookies."
        ],
        correta: :b,
        explicacao:
          "Com um escopo padrão na configuração, o gerador escreve código como o do notebook 14, agora em LiveView: o `live_session :require_authenticated_user` com `on_mount: [{HelloWeb.UserAuth, :require_authenticated}]` garante o `@current_scope` antes do `mount/3`, as funções do contexto recebem o escopo e filtram por `user_id`, e o `Index` chama `Blog.subscribe_posts(current_scope)` para receber só as mudanças do próprio usuário."
      }
    }
  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
    %{
      rotas: ~S'''
      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

          live "/posts", PostLive.Index, :index
          live "/posts/new", PostLive.Form, :new
          live "/posts/:id", PostLive.Show, :show
          live "/posts/:id/edit", PostLive.Form, :edit
        end
      end
      ''',
      validacao: ~S'''
        def changeset(post, attrs) do
          post
          |> cast(attrs, [:title, :body])
          |> validate_required([:title, :body])
          |> validate_length(:title, min: 3)
        end
      ''',
      contador: ~S'''
      defmodule HelloWeb.PostLive.Form do
        use HelloWeb, :live_view

        alias Hello.Blog
        alias Hello.Blog.Post

        @impl true
        def render(assigns) do
          ~H"""
          <Layouts.app flash={@flash}>
            <.header>
              {@page_title}
              <:subtitle>Use this form to manage post records in your database.</:subtitle>
            </.header>

            <.form for={@form} id="post-form" phx-change="validate" phx-submit="save">
              <.input field={@form[:title]} type="text" label="Title" />
              <.input field={@form[:body]} type="textarea" label="Body" />
              <p id="contagem">{@palavras} palavras</p>
              <footer>
                <.button phx-disable-with="Saving..." variant="primary">Save Post</.button>
                <.button navigate={return_path(@return_to, @post)}>Cancel</.button>
              </footer>
            </.form>
          </Layouts.app>
          """
        end

        @impl true
        def mount(params, _session, socket) do
          {:ok,
           socket
           |> assign(:return_to, return_to(params["return_to"]))
           |> apply_action(socket.assigns.live_action, params)}
        end

        defp return_to("show"), do: "show"
        defp return_to(_), do: "index"

        defp apply_action(socket, :edit, %{"id" => id}) do
          post = Blog.get_post!(id)

          socket
          |> assign(:page_title, "Edit Post")
          |> assign(:post, post)
          |> assign(:palavras, contar_palavras(post.body))
          |> assign(:form, to_form(Blog.change_post(post)))
        end

        defp apply_action(socket, :new, _params) do
          post = %Post{}

          socket
          |> assign(:page_title, "New Post")
          |> assign(:post, post)
          |> assign(:palavras, 0)
          |> assign(:form, to_form(Blog.change_post(post)))
        end

        @impl true
        def handle_event("validate", %{"post" => post_params}, socket) do
          changeset = Blog.change_post(socket.assigns.post, post_params)
          {:noreply,
           socket
           |> assign(form: to_form(changeset, action: :validate))
           |> assign(:palavras, contar_palavras(post_params["body"]))}
        end

        def handle_event("save", %{"post" => post_params}, socket) do
          save_post(socket, socket.assigns.live_action, post_params)
        end

        defp save_post(socket, :edit, post_params) do
          case Blog.update_post(socket.assigns.post, post_params) do
            {:ok, post} ->
              {:noreply,
               socket
               |> put_flash(:info, "Post updated successfully")
               |> push_navigate(to: return_path(socket.assigns.return_to, post))}

            {:error, %Ecto.Changeset{} = changeset} ->
              {:noreply, assign(socket, form: to_form(changeset))}
          end
        end

        defp save_post(socket, :new, post_params) do
          case Blog.create_post(post_params) do
            {:ok, post} ->
              {:noreply,
               socket
               |> put_flash(:info, "Post created successfully")
               |> push_navigate(to: return_path(socket.assigns.return_to, post))}

            {:error, %Ecto.Changeset{} = changeset} ->
              {:noreply, assign(socket, form: to_form(changeset))}
          end
        end

        defp contar_palavras(texto), do: texto |> to_string() |> String.split() |> length()

        defp return_path("index", _post), do: ~p"/posts"
        defp return_path("show", post), do: ~p"/posts/#{post}"
      end
      ''',
      ordem: ~S'''
      defmodule HelloWeb.PostLive.Index do
        use HelloWeb, :live_view

        alias Hello.Blog

        @impl true
        def render(assigns) do
          ~H"""
          <Layouts.app flash={@flash}>
            <.header>
              Listing Posts
              <:subtitle>
                Ordem: <.link patch={~p"/posts"}>mais antigos primeiro</.link>
                · <.link patch={~p"/posts?ordem=title"}>por título</.link>
              </:subtitle>
              <:actions>
                <.button variant="primary" navigate={~p"/posts/new"}>
                  <.icon name="hero-plus" /> New Post
                </.button>
              </:actions>
            </.header>

            <.table
              id="posts"
              rows={@streams.posts}
              row_click={fn {_id, post} -> JS.navigate(~p"/posts/#{post}") end}
            >
              <:col :let={{_id, post}} label="Title">{post.title}</:col>
              <:col :let={{_id, post}} label="Body">{post.body}</:col>
              <:action :let={{_id, post}}>
                <div class="sr-only">
                  <.link navigate={~p"/posts/#{post}"}>Show</.link>
                </div>
                <.link navigate={~p"/posts/#{post}/edit"}>Edit</.link>
              </:action>
              <:action :let={{id, post}}>
                <.link
                  phx-click={JS.push("delete", value: %{id: post.id}) |> hide("##{id}")}
                  data-confirm="Are you sure?"
                >
                  Delete
                </.link>
              </:action>
            </.table>
          </Layouts.app>
          """
        end

        @impl true
        def mount(_params, _session, socket) do
          {:ok, assign(socket, :page_title, "Listing Posts")}
        end

        @impl true
        def handle_params(params, _url, socket) do
          ordem = params["ordem"]
          {:noreply, stream(socket, :posts, ordenar(list_posts(), ordem), reset: true)}
        end

        defp ordenar(posts, "title"), do: Enum.sort_by(posts, & &1.title)
        defp ordenar(posts, _), do: Enum.sort_by(posts, & &1.id)

        @impl true
        def handle_event("delete", %{"id" => id}, socket) do
          post = Blog.get_post!(id)
          {:ok, _} = Blog.delete_post(post)

          {:noreply, stream_delete(socket, :posts, post)}
        end

        defp list_posts() do
          Blog.list_posts()
        end
      end
      ''',
      tempo_real: ~S'''
      # lib/hello/blog.ex: as partes novas
        @doc """
        Subscribes to notifications about any post changes.

        The broadcasted messages match the pattern:

          * {:created, %Post{}}
          * {:updated, %Post{}}
          * {:deleted, %Post{}}
        """
        def subscribe_posts do
          Phoenix.PubSub.subscribe(Hello.PubSub, "posts")
        end

        defp broadcast({:ok, %Post{} = post} = resultado, evento) do
          Phoenix.PubSub.broadcast(Hello.PubSub, "posts", {evento, post})
          resultado
        end

        defp broadcast(erro, _evento), do: erro

        def create_post(attrs) do
          %Post{}
          |> Post.changeset(attrs)
          |> Repo.insert()
          |> broadcast(:created)
        end

        # update_post/2: ... |> Repo.update() |> broadcast(:updated)
        # delete_post/1: post |> Repo.delete() |> broadcast(:deleted)

      # lib/hello_web/live/post_live/index.ex: o mount/3 e o handle_info/2
        def mount(_params, _session, socket) do
          if connected?(socket), do: Blog.subscribe_posts()

          {:ok, assign(socket, :page_title, "Listing Posts")}
        end

        @impl true
        def handle_info({type, %Hello.Blog.Post{} = post}, socket) when type in [:created, :updated] do
          {:noreply, stream_insert(socket, :posts, post)}
        end

        def handle_info({:deleted, %Hello.Blog.Post{} = post}, socket) do
          {:noreply, stream_delete(socket, :posts, post)}
        end
      ''',
      brecho: ~S'''
      defmodule HelloWeb.PainelLive do
        use HelloWeb, :live_view

        alias Hello.Vitrine
        alias Hello.Vitrine.Produto

        @impl true
        def render(assigns) do
          ~H"""
          <Layouts.app flash={@flash}>
            <.header>Painel da vitrine</.header>

            <.form for={@form} id="produto-form" phx-change="validar" phx-submit="cadastrar">
              <.input field={@form[:nome]} type="text" label="Nome" />
              <.input field={@form[:preco_centavos]} type="number" label="Preço (centavos)" />
              <.button variant="primary">Cadastrar</.button>
            </.form>

            <.table id="produtos" rows={@streams.produtos}>
              <:col :let={{_id, produto}} label="Peça">{produto.nome}</:col>
              <:col :let={{_id, produto}} label="Preço">{produto.preco_centavos} centavos</:col>
              <:action :let={{_id, produto}}>
                <.link phx-click="vender" phx-value-id={produto.id}>Vender</.link>
              </:action>
            </.table>
          </Layouts.app>
          """
        end

        @impl true
        def mount(_params, _session, socket) do
          {:ok,
           socket
           |> assign(:form, to_form(Vitrine.change_produto(%Produto{})))
           |> stream(:produtos, Vitrine.listar_disponiveis())}
        end

        @impl true
        def handle_event("validar", %{"produto" => attrs}, socket) do
          changeset = Vitrine.change_produto(%Produto{}, attrs)
          {:noreply, assign(socket, :form, to_form(changeset, action: :validate))}
        end

        def handle_event("cadastrar", %{"produto" => attrs}, socket) do
          case Vitrine.cadastrar_produto(attrs) do
            {:ok, produto} ->
              {:noreply,
               socket
               |> put_flash(:info, "#{produto.nome} cadastrado.")
               |> stream_insert(:produtos, produto)
               |> assign(:form, to_form(Vitrine.change_produto(%Produto{})))}

            {:error, changeset} ->
              {:noreply, assign(socket, :form, to_form(changeset))}
          end
        end

        def handle_event("vender", %{"id" => id}, socket) do
          produto = Vitrine.buscar_produto!(id)

          case Vitrine.vender(produto) do
            {:ok, produto} ->
              {:noreply,
               socket
               |> put_flash(:info, "#{produto.nome} vendido.")
               |> stream_delete(:produtos, produto)}

            {:error, :ja_vendido} ->
              {:noreply,
               socket
               |> put_flash(:error, "Esta peça já foi vendida.")
               |> stream_delete(:produtos, produto)}
          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
    %{
      rotas: [
        {"`GET /posts` vai para `PostLive.Index`, com a action `:index`.", fn -> rota_live("/posts", HelloWeb.PostLive.Index, :index) end},
        {"`GET /posts/new` vai para `PostLive.Form`, com a action `:new`.", fn -> rota_live("/posts/new", HelloWeb.PostLive.Form, :new) end},
        {"`GET /posts/42` vai para `PostLive.Show`, com a action `:show`.", fn -> rota_live("/posts/42", HelloWeb.PostLive.Show, :show) end},
        {"`GET /posts/42/edit` vai para `PostLive.Form`, com a action `:edit`.", fn -> rota_live("/posts/42/edit", HelloWeb.PostLive.Form, :edit) end}
      ],
      validacao: [
        {"`Post.changeset/2` recusa um título de 2 letras.",
         fn ->
           case changeset_post(%{"title" => "Oi", "body" => "corpo"}).errors[:title] do
             {_, opcoes} ->
               if opcoes[:validation] == :length and opcoes[:kind] == :min and opcoes[:count] == 3,
                 do: :ok,
                 else: {:erro, "O erro de `title` veio com `#{inspect(opcoes)}`. Use `validate_length(:title, min: 3)`."}

             nil ->
               {:erro, "O changeset aceitou o título `\"Oi\"`. Acrescente `validate_length(:title, min: 3)` na célula do `Hello.Blog.Post` e avalie-a."}
           end
         end},
        {"Um título de 3 letras passa.",
         fn ->
           cs = changeset_post(%{"title" => "Olá", "body" => "corpo"})
           if cs.valid?, do: :ok, else: {:erro, "O changeset recusou `\"Olá\"`: `#{inspect(cs.errors)}`. O mínimo é 3, inclusive."}
         end},
        {"No formulário, o erro aparece enquanto se digita (`phx-change`).",
         fn ->
           com_lv("/posts/new", fn lv ->
             {_lv, r} = digitar(lv, "validate", [{"post[title]", "Oi"}, {"post[body]", ""}], ["post[title]"])

             if Enum.any?(textos(r), &(&1 =~ "should be at least 3 character")),
               do: :ok,
               else: {:erro, "O diff do `validate` não trouxe a mensagem `should be at least 3 character(s)`. O `Form` usa `Blog.change_post/2`, que chama o `Post.changeset/2`: confira se a célula do schema foi avaliada."}
           end)
         end}
      ],
      contador: [
        {"A página de um post novo mostra `0 palavras` num elemento com `id=\"contagem\"`.",
         fn ->
           html = pagina("/posts/new")

           cond do
             not (html =~ ~s(id="contagem")) -> {:erro, "A página `/posts/new` não tem um elemento com `id=\"contagem\"`."}
             not (texto(html) =~ ~r/\b0 palavras/) -> {:erro, "A contagem não mostra `0 palavras` num post novo. Atribua `:palavras` em `apply_action(socket, :new, _)`."}
             true -> :ok
           end
         end},
        {"A página de edição conta as palavras do post salvo.",
         fn ->
           com_post(%{"title" => "Conferência contagem", "body" => "um dois três"}, fn post ->
             if texto(pagina("/posts/#{post.id}/edit")) =~ ~r/\b3 palavras/,
               do: :ok,
               else: {:erro, "Para um post com corpo `\"um dois três\"`, a edição deveria mostrar `3 palavras`. Atribua `:palavras` em `apply_action(socket, :edit, _)`, a partir de `post.body`."}
           end)
         end},
        {"A contagem muda enquanto se digita no corpo.",
         fn ->
           com_lv("/posts/new", fn lv ->
             {lv, r1} = digitar(lv, "validate", [{"post[title]", ""}, {"post[body]", "um dois três quatro"}], ["post[body]"])
             {_lv, r2} = digitar(lv, "validate", [{"post[title]", ""}, {"post[body]", "um"}], ["post[body]"])

             cond do
               "4" not in textos(r1) -> {:erro, "Depois de digitar 4 palavras, o diff do `validate` não trouxe o número `4`. Atualize `:palavras` no `handle_event(\"validate\", ...)`."}
               "1" not in textos(r2) -> {:erro, "Depois de apagar até sobrar 1 palavra, o diff não trouxe o número `1`."}
               true -> :ok
             end
           end)
         end}
      ],
      ordem: [
        {"`PostLive.Index` define `handle_params/3`.", fn -> exporta(HelloWeb.PostLive.Index, handle_params: 3) end},
        {"Um `patch` para `/posts?ordem=title` refaz a lista em ordem alfabética.",
         fn ->
           com_posts(["Zebra conf", "Abelha conf", "Macaco conf"], fn [zebra, abelha, macaco] ->
             com_lv("/posts", fn lv ->
               {_lv, r} = patch_lv(lv, "/posts?ordem=title")
               conferir_ordem(r, [abelha, macaco, zebra], "por título")
             end)
           end)
         end},
        {"Um `patch` de volta para `/posts` volta à ordem de criação.",
         fn ->
           com_posts(["Zebra conf", "Abelha conf", "Macaco conf"], fn [zebra, abelha, macaco] ->
             com_lv("/posts?ordem=title", fn lv ->
               {_lv, r} = patch_lv(lv, "/posts")
               conferir_ordem(r, [zebra, abelha, macaco], "de criação")
             end)
           end)
         end},
        {"O cabeçalho da listagem tem um link com `patch` para `/posts?ordem=title`.",
         fn ->
           html = pagina("/posts")

           if html =~ ~s(href="/posts?ordem=title") and html =~ ~s(data-phx-link="patch"),
             do: :ok,
             else: {:erro, "A página `/posts` não tem `<.link patch={~p\"/posts?ordem=title\"}>`. Com `navigate` ou `href`, o clique faria um `mount/3` novo."}
         end}
      ],
      tempo_real: [
        {"`Hello.Blog` define `subscribe_posts/0`.", fn -> exporta(Hello.Blog, subscribe_posts: 0) end},
        {"Um post criado pelo contexto aparece em duas listagens abertas, sem clique.",
         fn ->
           com_lv("/posts", fn ana ->
             com_lv("/posts", fn bia ->
               {:ok, post} = apply(Hello.Blog, :create_post, [%{"title" => "Conferência ao vivo", "body" => "x"}])

               try do
                 {_, d1} = receber(ana)
                 {_, d2} = receber(bia)
                 id = "posts-#{post.id}"

                 cond do
                   d1 == :nada or d2 == :nada ->
                     {:erro, "Nenhum diff chegou às listagens abertas. O `Index` assina o tópico no `mount/3` (dentro de `if connected?(socket)`) e o `create_post/1` publica `{:created, post}`?"}

                   not entrou?(d1, id) or not entrou?(d2, id) ->
                     {:erro, "Chegou um diff, mas sem `#{id}` entrando no stream. Use `stream_insert(socket, :posts, post)` no `handle_info/2`."}

                   true ->
                     :ok
                 end
               after
                 apply(Hello.Blog, :delete_post, [post])
               end
             end)
           end)
         end},
        {"Editar o post pelo contexto atualiza a listagem aberta.",
         fn ->
           com_post(%{"title" => "Conferência antes", "body" => "x"}, fn post ->
             com_lv("/posts", fn lv ->
               {:ok, _} = apply(Hello.Blog, :update_post, [post, %{"title" => "Conferência depois"}])
               {_, d} = receber(lv)

               if d != :nada and "Conferência depois" in textos(d),
                 do: :ok,
                 else: {:erro, "A listagem não recebeu o título novo. O `update_post/2` publica `{:updated, post}`, e o `handle_info/2` trata `:updated` com `stream_insert/3`?"}
             end)
           end)
         end},
        {"Apagar o post pelo contexto o tira da listagem aberta.",
         fn ->
           {:ok, post} = apply(Hello.Blog, :create_post, [%{"title" => "Conferência some", "body" => "x"}])

           com_lv("/posts", fn lv ->
             {:ok, _} = apply(Hello.Blog, :delete_post, [post])
             {_, d} = receber(lv)

             if d != :nada and "posts-#{post.id}" in Enum.flat_map(streams_do(d), fn {_, _, saem, _} -> saem end),
               do: :ok,
               else: {:erro, "A listagem não recebeu a remoção de `posts-#{post.id}`. O `delete_post/1` publica `{:deleted, post}`, e o `handle_info/2` chama `stream_delete/3`?"}
           end)
         end}
      ],
      brecho: [
        {"`/painel` é uma LiveView e lista só as peças à venda.",
         fn ->
           limpando_produtos(fn ->
             casaco = produto!("Conf Casaco", 5000)
             chapeu = produto!("Conf Chapéu", 3000)
             {:ok, _} = apply(Hello.Vitrine, :vender, [chapeu])

             com_lv("/painel", fn lv ->
               {_lv, entrada} = {lv, lv.join}
               ids = Enum.flat_map(streams_do(entrada), fn {_, entram, _, _} -> entram end)

               cond do
                 "produtos-#{casaco.id}" not in ids -> {:erro, "A lista não trouxe a peça à venda `produtos-#{casaco.id}`. Use `stream(:produtos, Vitrine.listar_disponiveis())`."}
                 "produtos-#{chapeu.id}" in ids -> {:erro, "A lista trouxe uma peça vendida. Use `Vitrine.listar_disponiveis/0`."}
                 true -> :ok
               end
             end)
           end)
         end},
        {"Digitar preço 0 mostra o erro e não grava nada.",
         fn ->
           limpando_produtos(fn ->
             com_lv("/painel", fn lv ->
               campos = [{"produto[nome]", "Conf Bota"}, {"produto[preco_centavos]", "0"}]
               {_lv, r} = digitar(lv, "validar", campos, ["produto[nome]", "produto[preco_centavos]"])

               cond do
                 not Enum.any?(textos(r), &(&1 =~ "must be greater than 0")) ->
                   {:erro, "O diff do evento `validar` não trouxe `must be greater than 0`. Use `to_form(changeset, action: :validate)`."}

                 contar_produtos("Conf Bota") > 0 ->
                   {:erro, "O `validar` gravou a peça. Ele só deve montar o changeset."}

                 true ->
                   :ok
               end
             end)
           end)
         end},
        {"Cadastrar uma peça válida grava e a põe na lista.",
         fn ->
           limpando_produtos(fn ->
             com_lv("/painel", fn lv ->
               {_lv, r} = submeter(lv, "cadastrar", [{"produto[nome]", "Conf Bota"}, {"produto[preco_centavos]", "12000"}])

               case coluna("SELECT id FROM produtos WHERE nome = 'Conf Bota' AND preco_centavos = 12000") do
                 [id] ->
                   if entrou?(r, "produtos-#{id}"),
                     do: :ok,
                     else: {:erro, "A peça foi gravada, mas não entrou no stream. Use `stream_insert(socket, :produtos, produto)`."}

                 [] ->
                   {:erro, "Nenhuma peça `Conf Bota` foi gravada. O evento `cadastrar` chama `Vitrine.cadastrar_produto/1` com `params[\"produto\"]`?"}
               end
             end)
           end)
         end},
        {"Cadastrar sem nome não grava e mostra o erro.",
         fn ->
           limpando_produtos(fn ->
             com_lv("/painel", fn lv ->
               {_lv, r} = submeter(lv, "cadastrar", [{"produto[nome]", ""}, {"produto[preco_centavos]", "999"}])

               cond do
                 coluna("SELECT id FROM produtos WHERE preco_centavos = 999") != [] -> {:erro, "Uma peça sem nome foi gravada."}
                 not Enum.any?(textos(r), &(&1 =~ "be blank")) -> {:erro, "O diff não trouxe o erro `can't be blank`. Em `{:error, changeset}`, atribua `to_form(changeset)` ao formulário."}
                 true -> :ok
               end
             end)
           end)
         end},
        {"Vender tira a peça da lista e a marca como vendida.",
         fn ->
           limpando_produtos(fn ->
             peca = produto!("Conf Saia", 2500)

             com_lv("/painel", fn lv ->
               {_lv, r} = clicar(lv, "vender", %{"id" => to_string(peca.id)})
               saem = Enum.flat_map(streams_do(r), fn {_, _, saem, _} -> saem end)

               cond do
                 coluna("SELECT vendido FROM produtos WHERE id = $1", [peca.id]) != [true] -> {:erro, "A peça não ficou com `vendido = true`. Use `Vitrine.vender/1`."}
                 "produtos-#{peca.id}" not in saem -> {:erro, "A peça foi vendida, mas não saiu da lista. Use `stream_delete(socket, :produtos, produto)`."}
                 true -> :ok
               end
             end)
           end)
         end},
        {"Quem tenta vender uma peça que outra pessoa acabou de vender recebe o aviso.",
         fn ->
           limpando_produtos(fn ->
             peca = produto!("Conf Lenço", 1500)

             com_lv("/painel", fn ana ->
               com_lv("/painel", fn bia ->
                 {_ana, _} = clicar(ana, "vender", %{"id" => to_string(peca.id)})
                 {_bia, r} = clicar(bia, "vender", %{"id" => to_string(peca.id)})

                 if Enum.any?(textos(r), &(&1 =~ "vendida")),
                   do: :ok,
                   else: {:erro, "A segunda venda não mostrou a flash `Esta peça já foi vendida.`. Trate `{:error, :ja_vendido}` com `put_flash(:error, ...)`."}
               end)
             end)
           end)
         end}
      ]
    }
  end

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

  defp rota_live(caminho, modulo, acao) do
    case Phoenix.Router.route_info(@router, "GET", caminho, "localhost") do
      %{phoenix_live_view: {^modulo, ^acao, _, _}} ->
        :ok

      %{phoenix_live_view: {outro, outra_acao, _, _}} ->
        {:erro, "`#{caminho}` vai para `#{inspect(outro)}` com `#{inspect(outra_acao)}`; o esperado é `#{inspect(modulo)}` com `#{inspect(acao)}`. Confira a ordem: `/posts/new` precisa vir antes de `/posts/:id`."}

      %{} ->
        {:erro, "`#{caminho}` existe, mas não é uma rota `live`."}

      :error ->
        {:erro, "Nenhuma rota casa com `GET #{caminho}`. Acrescente as rotas `live` na célula do router desta seção e avalie-a."}
    end
  end

  defp changeset_post(params), do: apply(Hello.Blog.Post, :changeset, [struct(Hello.Blog.Post), params])

  # O HTML da renderização estática de uma página (um GET comum).
  defp pagina(caminho) do
    case visitar("conferencia", :get, caminho) do
      {:ok, conn} -> to_string(conn.resp_body)
      {:erro, erro} -> raise erro
    end
  end

  # Abre a LiveView, roda a conferência e fecha a conexão. `lv.join` guarda a resposta do join.
  defp com_lv(caminho, fun) do
    {lv, entrada} = abrir_lv(caminho, como: "conferencia-#{System.unique_integer([:positive])}")

    try do
      fun.(Map.put(lv, :join, entrada))
    after
      desconectar(lv)
    end
  end

  defp com_post(attrs, fun) do
    {:ok, post} = apply(Hello.Blog, :create_post, [attrs])

    try do
      fun.(post)
    after
      sql("DELETE FROM posts WHERE id = $1", [post.id])
    end
  end

  defp com_posts(titulos, fun) do
    posts =
      Enum.map(titulos, fn titulo ->
        {:ok, post} = apply(Hello.Blog, :create_post, [%{"title" => titulo, "body" => "conferência"}])
        post
      end)

    try do
      fun.(posts)
    after
      sql("DELETE FROM posts WHERE id = ANY($1)", [Enum.map(posts, & &1.id)])
    end
  end

  defp conferir_ordem(resposta, esperados, nome) do
    ids = Enum.map(esperados, &"posts-#{&1.id}")

    case streams_do(resposta) do
      [] ->
        {:erro, "A resposta do `patch` não trouxe nenhuma operação de stream. O `handle_params/3` precisa chamar `stream(socket, :posts, ..., reset: true)`."}

      streams ->
        {_, entram, _, reset} = List.last(streams)
        obtidos = Enum.filter(entram, &(&1 in ids))

        cond do
          not reset -> {:erro, "O stream não foi refeito (`reset: true` faltando): os itens novos seriam só acrescentados à lista antiga."}
          obtidos != ids -> {:erro, "Na ordem #{nome}, o esperado é #{Enum.map_join(ids, ", ", &"`#{&1}`")}; veio #{Enum.map_join(obtidos, ", ", &"`#{&1}`")}."}
          true -> :ok
        end
    end
  end

  defp entrou?(resposta, id), do: Enum.any?(streams_do(resposta), fn {_, entram, _, _} -> id in entram end)

  defp produto!(nome, preco) do
    {:ok, produto} = apply(Hello.Vitrine, :cadastrar_produto, [%{"nome" => nome, "preco_centavos" => preco}])
    produto
  end

  defp contar_produtos(nome), do: length(coluna("SELECT id FROM produtos WHERE nome = $1", [nome]))

  defp limpando_produtos(teste) do
    teste.()
  after
    if coluna("SELECT table_name FROM information_schema.tables WHERE table_name = 'produtos'") != [] do
      sql("DELETE FROM produtos WHERE nome LIKE 'Conf%' OR preco_centavos = 999")
    end
  end

  defp exporta(modulo, funcoes) do
    faltando = Enum.reject(funcoes, fn {nome, aridade} -> Code.ensure_loaded?(modulo) and function_exported?(modulo, nome, aridade) end)

    if faltando == [],
      do: :ok,
      else: {:erro, "Falta definir em `#{inspect(modulo)}`: #{Enum.map_join(faltando, ", ", fn {n, a} -> "`#{n}/#{a}`" end)}."}
  end

  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)}`. Adicione a rota na célula do router e avalie-a de novo."
  end

  defp explicar(%UndefinedFunctionError{module: modulo, function: :__live__}) do
    "A rota aponta para `#{inspect(modulo)}`, mas esse módulo não existe (ou não é uma LiveView). Defina-o e avalie a célula."
  end

  defp explicar(%UndefinedFunctionError{module: modulo, function: funcao, arity: aridade}) do
    nome = inspect(modulo)

    cond do
      String.ends_with?(nome, "Controller") and funcao == :init ->
        "Etapa: **controller**. A rota aponta para `#{nome}`, mas esse módulo não existe. Defina-o e avalie a célula."

      String.ends_with?(nome, "Controller") ->
        "Etapa: **controller**. `#{nome}` existe, mas não define `#{funcao}/#{aridade}`."

      String.ends_with?(nome, "HTML") ->
        "Etapa: **view**. O `render` procurou `#{nome}.#{funcao}/#{aridade}` e não achou."

      true ->
        "`#{nome}.#{funcao}/#{aridade}` não existe. O módulo foi definido e a célula foi avaliada?"
    end
  end

  defp explicar(%Postgrex.Error{postgres: %{code: :undefined_table, message: mensagem}}) do
    "O PostgreSQL respondeu `#{mensagem}`. A migration que cria a tabela já rodou?"
  end

  defp explicar(%Ecto.ConstraintError{constraint: restricao}) do
    "O banco recusou a operação pela restrição `#{restricao}`, e o changeset não a declara: a violação virou exceção em vez de erro no changeset."
  end

  defp explicar(%Phoenix.ActionClauseError{}) do
    "Etapa: **controller**. Nenhuma cláusula da action casou com os parâmetros recebidos."
  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 continua o 16. O guia LiveView do Phoenix termina com uma sugestão, na seção Getting Started: rodar mix phx.gen.live Blog Post posts title:string body:text, que gera um CRUD completo em LiveView, "parecido com o mix phx.gen.html". É o que este notebook faz, com calma: o código gerado pelo Phoenix 1.8.16, arquivo por arquivo, e os guias do LiveView que explicam cada peça (streams, formulários, navegação), com os links para a documentação do LiveView.

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 é o jeito de olhar a aplicação. Uma LiveView conversa com o navegador por um WebSocket, e o Trilha traz um cliente WebSocket de verdade, o mesmo do notebook 16:

  • Trilha.abrir_lv("/posts") faz o que o navegador faz: um GET normal, depois a conexão em /live/websocket e o phx_join no tópico da LiveView; devolve {lv, resposta_do_join};
  • Trilha.clicar/3, Trilha.digitar/4 e Trilha.submeter/3 mandam eventos como o JavaScript do LiveView manda (phx-click, phx-change, phx-submit);
  • Trilha.navegar_lv/2 e Trilha.patch_lv/2 imitam os links com navigate e patch;
  • Trilha.receber/2 espera uma mensagem que o servidor manda sem ninguém pedir;
  • Trilha.resumo_lv/1 resume uma mensagem do fio (o texto da página, as operações de stream, os textos que mudaram) e Trilha.json/1 a mostra inteira;
  • Trilha.callbacks/1 conta quais callbacks da LiveView rodaram, em qual processo.

A aplicação fica em http://localhost:4010, e agora as páginas funcionam no navegador de verdade: clique, digite e abra duas abas. Nas ferramentas do navegador (aba Network, filtro WS), os quadros do WebSocket são os mesmos que as células mostram.

A primeira célula usa a mesma lista de dependências dos notebooks 13 a 16; se você já abriu algum deles, o Livebook reaproveita a instalação.

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, e o bloco live_view (o use HelloWeb, :live_view das LiveViews) junta use Phoenix.LiveView com esses mesmos helpers.

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

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
  end
end

lib/hello_web/endpoint.ex: socket "/live" é a porta de entrada do WebSocket das LiveViews, igual à do projeto gerado. A diferença está nos arquivos JavaScript: no projeto, o Plug.Static serve o app.js compilado, que junta phoenix_html, phoenix e phoenix_live_view. Aqui não há compilação de assets, então três Plug.Static servem direto os arquivos prontos que vêm nos pacotes.

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 e o layout da aplicação. No projeto, o root layout carrega o app.css e o app.js compilados. Aqui ele carrega o Tailwind e o daisyUI de uma CDN (sem internet, as páginas funcionam sem estilo), os três arquivos JavaScript e um <script> que cria o LiveSocket e conecta: é o trecho do app.js que o guia mostra, com LiveView.LiveSocket e Phoenix.Socket no lugar dos import.

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. 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. Antes do endpoint, a célula põe na configuração dele o signing_salt do LiveView, usado para assinar os tokens data-phx-session. No projeto, ele fica em config/config.exs (live_view: [signing_salt: ...]); aqui entra em tempo de execução, para que a lista de dependências da primeira célula continue idêntica à dos notebooks 13 a 16 e o Livebook reaproveite a instalação.

{: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()

O gerador mix phx.gen.live

Na documentação: Getting Started

O guia sugere:

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

Os argumentos são os mesmos do mix phx.gen.html do notebook 08: o contexto (Blog), o schema (Post), a tabela (posts) e os campos. Num projeto 1.8.16 sem autenticação, a saída é esta:

* 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


Remember to update your repository by running migrations:

    $ mix ecto.migrate
Trilha.pergunta(:arquivos)

A parte de dados é igual à do phx.gen.html: a migration, o schema Hello.Blog.Post e o contexto Hello.Blog, com list_posts/0, get_post!/1, create_post/1, update_post/2, delete_post/1 e change_post/2. A parte web são três LiveViews, uma por página. O nome de arquivo da migration traz a data em que o gerador rodou; aqui, a versão registrada é outra, fixa, para os resultados serem sempre os mesmos.

priv/repo/migrations/20250315120000_create_posts.exs

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

  def change do
    create table(:posts) do
      add :title, :string
      add :body, :text

      timestamps(type: :utc_datetime)
    end
  end
end

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

lib/hello/blog/post.ex

defmodule Hello.Blog.Post do
  use Ecto.Schema
  import Ecto.Changeset

  schema "posts" do
    field :title, :string
    field :body, :string

    timestamps(type: :utc_datetime)
  end

  @doc false
  def changeset(post, attrs) do
    post
    |> cast(attrs, [:title, :body])
    |> validate_required([:title, :body])
  end
end

lib/hello/blog.ex: o contexto, igual ao que o phx.gen.html gera.

defmodule Hello.Blog do
  @moduledoc """
  The Blog context.
  """

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

  alias Hello.Blog.Post

  @doc """
  Returns the list of posts.

  ## Examples

      iex> list_posts()
      [%Post{}, ...]

  """
  def list_posts do
    Repo.all(Post)
  end

  @doc """
  Gets a single post.

  Raises `Ecto.NoResultsError` if the Post does not exist.

  ## Examples

      iex> get_post!(123)
      %Post{}

      iex> get_post!(456)
      ** (Ecto.NoResultsError)

  """
  def get_post!(id), do: Repo.get!(Post, id)

  @doc """
  Creates a post.

  ## Examples

      iex> create_post(%{field: value})
      {:ok, %Post{}}

      iex> create_post(%{field: bad_value})
      {:error, %Ecto.Changeset{}}

  """
  def create_post(attrs) do
    %Post{}
    |> Post.changeset(attrs)
    |> Repo.insert()
  end

  @doc """
  Updates a post.

  ## Examples

      iex> update_post(post, %{field: new_value})
      {:ok, %Post{}}

      iex> update_post(post, %{field: bad_value})
      {:error, %Ecto.Changeset{}}

  """
  def update_post(%Post{} = post, attrs) do
    post
    |> Post.changeset(attrs)
    |> Repo.update()
  end

  @doc """
  Deletes a post.

  ## Examples

      iex> delete_post(post)
      {:ok, %Post{}}

      iex> delete_post(post)
      {:error, %Ecto.Changeset{}}

  """
  def delete_post(%Post{} = post) do
    Repo.delete(post)
  end

  @doc """
  Returns an `%Ecto.Changeset{}` for tracking post changes.

  ## Examples

      iex> change_post(post)
      %Ecto.Changeset{data: %Post{}}

  """
  def change_post(%Post{} = post, attrs \\ %{}) do
    Post.changeset(post, attrs)
  end
end

Sua vez: as rotas

O gerador imprimiu quatro rotas live. Uma rota live liga um caminho a uma LiveView; o terceiro argumento é a live action, um átomo que a LiveView recebe em socket.assigns.live_action. É assim que um único módulo, o PostLive.Form, serve tanto para /posts/new (:new) quanto para /posts/:id/edit (:edit).

Na célula abaixo, acrescente as quatro rotas ao scope "/", depois da rota da página inicial, e avalie a célula. A ordem importa: /posts/new tem de vir antes de /posts/:id, senão o router acha que new é um id.

Esta célula do router fica antes das LiveViews de propósito: os caminhos escritos com ~p dentro delas são conferidos contra o router quando cada módulo compila.

lib/hello_web/router.ex

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

    # Acrescente aqui as quatro rotas live do gerador.
  end
end
Trilha.solucao(:rotas)
Trilha.conferir(:rotas)

A conferência pergunta ao router para onde vai cada caminho (com Phoenix.Router.route_info/4), sem precisar das LiveViews, que vêm a seguir. Enquanto elas não existirem, a célula do router avisa HelloWeb.PostLive.Index.__live__/0 is undefined: é o mesmo aviso que um router dá para um controller ainda não definido.

A listagem: PostLive.Index

Na documentação do LiveView: Bindings e stream/4

lib/hello_web/live/post_live/index.ex

defmodule HelloWeb.PostLive.Index do
  use HelloWeb, :live_view

  alias Hello.Blog

  @impl true
  def render(assigns) do
    ~H"""
    <Layouts.app flash={@flash}>
      <.header>
        Listing Posts
        <:actions>
          <.button variant="primary" navigate={~p"/posts/new"}>
            <.icon name="hero-plus" /> New Post
          </.button>
        </:actions>
      </.header>

      <.table
        id="posts"
        rows={@streams.posts}
        row_click={fn {_id, post} -> JS.navigate(~p"/posts/#{post}") end}
      >
        <:col :let={{_id, post}} label="Title">{post.title}</:col>
        <:col :let={{_id, post}} label="Body">{post.body}</:col>
        <:action :let={{_id, post}}>
          <div class="sr-only">
            <.link navigate={~p"/posts/#{post}"}>Show</.link>
          </div>
          <.link navigate={~p"/posts/#{post}/edit"}>Edit</.link>
        </:action>
        <:action :let={{id, post}}>
          <.link
            phx-click={JS.push("delete", value: %{id: post.id}) |> hide("##{id}")}
            data-confirm="Are you sure?"
          >
            Delete
          </.link>
        </:action>
      </.table>
    </Layouts.app>
    """
  end

  @impl true
  def mount(_params, _session, socket) do
    {:ok,
     socket
     |> assign(:page_title, "Listing Posts")
     |> stream(:posts, list_posts())}
  end

  @impl true
  def handle_event("delete", %{"id" => id}, socket) do
    post = Blog.get_post!(id)
    {:ok, _} = Blog.delete_post(post)

    {:noreply, stream_delete(socket, :posts, post)}
  end

  defp list_posts() do
    Blog.list_posts()
  end
end

O módulo tem as três partes do termostato do notebook 16:

  • mount/3 prepara o estado: o título da página e um stream com os posts;
  • render/1 é o template, com os mesmos componentes do phx.gen.html (<.header>, <.table>, <.button>); a diferença é que rows={@streams.posts} recebe o stream, e cada linha chega como uma tupla {id, post};
  • handle_event/3 trata o único evento da página, "delete".

Dois atributos da tabela são JavaScript que o servidor escreve, com o módulo Phoenix.LiveView.JS:

  • row_click={fn {_id, post} -> JS.navigate(~p"/posts/#{post}") end}: clicar numa linha navega para a página do post, sem recarregar;
  • phx-click={JS.push("delete", value: %{id: post.id}) |> hide("##{id}")}: o link "Delete" manda o evento "delete" com o id e, ao mesmo tempo, esconde a linha no navegador, sem esperar a resposta. data-confirm pede confirmação antes.

Para ver a página com conteúdo, crie alguns posts pelo contexto:

for {titulo, corpo} <- [
      {"Banana", "um dois"},
      {"Abacaxi", "três"},
      {"Caju", "quatro cinco seis"},
      {"Rascunho", "para apagar"}
    ] do
  {:ok, _post} = Hello.Blog.create_post(%{title: titulo, body: corpo})
end

Trilha.consultar("SELECT id, title, body FROM posts ORDER BY id")

Abra http://localhost:4010/posts no navegador. A primeira resposta é um GET comum, com o HTML completo. Dentro dele está o elemento raiz da LiveView, com os dados que o JavaScript usa para conectar:

{:ok, conn} = Trilha.visitar("ana", :get, "/posts")

conn.resp_body
|> to_string()
|> then(&Regex.run(~r/<div[^>]*data-phx-main[^>]*>/, &1))
|> hd()
|> String.slice(0, 400)

data-phx-session é um token assinado com o módulo da LiveView e a sessão; data-phx-static guarda dados da renderização estática. No join, o cliente devolve os dois ao servidor.

Trilha.pergunta(:mount_duas_vezes)

Trilha.callbacks/1 roda um trecho de código e lista os callbacks de LiveView que o servidor executou, usando os eventos de telemetria do LiveView. Abra a listagem como a Ana:

{{lv, entrada}, tabela} = Trilha.callbacks(fn -> Trilha.abrir_lv("/posts", como: "ana") end)
tabela

O primeiro mount roda no processo que atendeu o GET, com connected? falso: é a renderização estática. O segundo roda num processo novo, criado pelo join do WebSocket, com connected? verdadeiro; esse processo fica vivo enquanto a página estiver aberta. O render da tabela é o do processo conectado (a telemetria do LiveView só emite esse evento para renderizações conectadas). Se você já fez o exercício de ordenação, mais abaixo, aparece também handle_params depois de cada mount.

O que o join trouxe:

Trilha.resumo_lv(entrada)

O resumo monta o HTML a partir da resposta (estáticos e dinâmicos, como o notebook 16 mostrou) e extrai o texto. A linha do stream diz que entraram posts-1 a posts-4: são os id dos elementos <tr> da tabela, que o stream gera a partir do nome (posts) e do id de cada registro.

Streams

Trilha.pergunta(:stream_memoria)

Trilha.assigns_lv/0 lê os assigns direto do processo da LiveView que acabou de conectar:

Trilha.assigns_lv()

Em streams.posts há um %Phoenix.LiveView.LiveStream{} com inserts: []. Os quatro posts foram renderizados, enviados e esquecidos pelo servidor; quem os guarda é o navegador, no DOM, dentro do <tbody phx-update="stream">. Para mudar a lista, o servidor manda só operações. Apague o "Rascunho", como o link "Delete" faria:

{lv, resposta} = Trilha.clicar(lv, "delete", %{"id" => 4})
Trilha.json(resposta)

O diff inteiro tem pouco mais de cem bytes. A lista "stream" é [ref, inserções, remoções]: nenhuma inserção, e a remoção do elemento posts-4. O resto da página não viaja. Confira no banco:

Trilha.consultar("SELECT id, title FROM posts ORDER BY id")

Para ir mais fundo: stream/4, stream_insert/4 e stream_delete/3 na documentação.

O formulário: PostLive.Form

Na documentação do LiveView: Form bindings

lib/hello_web/live/post_live/form.ex

defmodule HelloWeb.PostLive.Form do
  use HelloWeb, :live_view

  alias Hello.Blog
  alias Hello.Blog.Post

  @impl true
  def render(assigns) do
    ~H"""
    <Layouts.app flash={@flash}>
      <.header>
        {@page_title}
        <:subtitle>Use this form to manage post records in your database.</:subtitle>
      </.header>

      <.form for={@form} id="post-form" phx-change="validate" phx-submit="save">
        <.input field={@form[:title]} type="text" label="Title" />
        <.input field={@form[:body]} type="textarea" label="Body" />
        <footer>
          <.button phx-disable-with="Saving..." variant="primary">Save Post</.button>
          <.button navigate={return_path(@return_to, @post)}>Cancel</.button>
        </footer>
      </.form>
    </Layouts.app>
    """
  end

  @impl true
  def mount(params, _session, socket) do
    {:ok,
     socket
     |> assign(:return_to, return_to(params["return_to"]))
     |> apply_action(socket.assigns.live_action, params)}
  end

  defp return_to("show"), do: "show"
  defp return_to(_), do: "index"

  defp apply_action(socket, :edit, %{"id" => id}) do
    post = Blog.get_post!(id)

    socket
    |> assign(:page_title, "Edit Post")
    |> assign(:post, post)
    |> assign(:form, to_form(Blog.change_post(post)))
  end

  defp apply_action(socket, :new, _params) do
    post = %Post{}

    socket
    |> assign(:page_title, "New Post")
    |> assign(:post, post)
    |> assign(:form, to_form(Blog.change_post(post)))
  end

  @impl true
  def handle_event("validate", %{"post" => post_params}, socket) do
    changeset = Blog.change_post(socket.assigns.post, post_params)
    {:noreply, assign(socket, form: to_form(changeset, action: :validate))}
  end

  def handle_event("save", %{"post" => post_params}, socket) do
    save_post(socket, socket.assigns.live_action, post_params)
  end

  defp save_post(socket, :edit, post_params) do
    case Blog.update_post(socket.assigns.post, post_params) do
      {:ok, post} ->
        {:noreply,
         socket
         |> put_flash(:info, "Post updated successfully")
         |> push_navigate(to: return_path(socket.assigns.return_to, post))}

      {:error, %Ecto.Changeset{} = changeset} ->
        {:noreply, assign(socket, form: to_form(changeset))}
    end
  end

  defp save_post(socket, :new, post_params) do
    case Blog.create_post(post_params) do
      {:ok, post} ->
        {:noreply,
         socket
         |> put_flash(:info, "Post created successfully")
         |> push_navigate(to: return_path(socket.assigns.return_to, post))}

      {:error, %Ecto.Changeset{} = changeset} ->
        {:noreply, assign(socket, form: to_form(changeset))}
    end
  end

  defp return_path("index", _post), do: ~p"/posts"
  defp return_path("show", post), do: ~p"/posts/#{post}"
end

Uma LiveView para duas páginas:

  • mount/3 olha socket.assigns.live_action, que vem da rota (:new ou :edit), e chama apply_action/3, que prepara o título da página, o post (novo ou buscado no banco) e o formulário;
  • to_form/1 transforma o changeset num %Phoenix.HTML.Form{}, que o <.form for={@form}> e cada <.input field={@form[:title]}> usam;
  • return_to guarda para onde voltar depois de salvar: a listagem, ou a página do post quando a edição veio de lá (?return_to=show).

O formulário tem dois bindings:

  • phx-change="validate": a cada mudança num campo, o cliente manda o formulário inteiro no evento "validate"; o servidor monta o changeset de novo e devolve o formulário com os erros, marcado com action: :validate (sem essa ação, <.input> não mostra erro nenhum);
  • phx-submit="save": no envio, o evento "save" grava e navega.

Abra o formulário de post novo:

{form_lv, entrada} = Trilha.abrir_lv("/posts/new", como: "ana")
Trilha.resumo_lv(entrada)
Trilha.pergunta(:tocados)

Trilha.digitar/4 recebe o evento, todos os campos com os valores atuais e a lista dos campos em que a pessoa já mexeu. Simule a pessoa que escreveu no título, apagou tudo e ainda não clicou no corpo:

{form_lv, resposta} =
  Trilha.digitar(form_lv, "validate", [{"post[title]", ""}, {"post[body]", ""}], ["post[title]"])

Trilha.json(resposta)

O diff traz o campo de título com a classe input-error e uma mensagem can&#39;t be blank (o apóstrofo vem escapado como HTML); o corpo continua com a classe w-full textarea, sem erro. O changeset acusou os dois campos, mas o cliente mandou post[_unused_body] junto, e used_input?/1 diz que o corpo ainda não foi usado. Agora a pessoa escreveu um título e passou pelo corpo:

{form_lv, resposta} =
  Trilha.digitar(form_lv, "validate", [{"post[title]", "Uva"}, {"post[body]", ""}], ["post[title]", "post[body]"])

Trilha.json(resposta)

Agora o título voltou à classe w-full input, e o corpo ganhou textarea-error e a mensagem. Repare também no que não veio: os rótulos, os nomes dos campos e o resto da página continuam iguais, e por isso não viajam.

Trilha.pergunta(:salvar_fio)

Envie o formulário completo:

{form_lv, resposta} = Trilha.submeter(form_lv, "save", [{"post[title]", "Uva"}, {"post[body]", "Fruta pequena"}])
Trilha.resumo_lv(resposta)

A resposta ao save não traz HTML: só a instrução live_redirect, com o destino /posts e a flash dentro de um token assinado. É o que push_navigate/2 produz. Trilha.seguir_lv/2 faz o que o navegador faz com ela, um novo join na mesma conexão:

{form_lv, entrada} = Trilha.seguir_lv(form_lv, resposta)
Trilha.resumo_lv(entrada)

A listagem montou do zero, com o post novo e a flash "Post created successfully". Tudo isso pela conexão WebSocket que já estava aberta: nenhuma requisição HTTP.

Trilha.desconectar(form_lv)

Sua vez: um título de pelo menos 3 letras

O changeset do Post só exige os dois campos. Na célula do Hello.Blog.Post, acrescente validate_length(:title, min: 3) ao changeset/2 e avalie a célula. Depois experimente no navegador, em http://localhost:4010/posts/new: o aviso aparece enquanto você digita, sem enviar nada.

O PostLive.Form não muda: ele chama Blog.change_post/2, que chama o Post.changeset/2 novo. A validação mora no schema, e a LiveView só mostra o resultado.

Trilha.solucao(:validacao)
Trilha.conferir(:validacao)

Sua vez: contando palavras

Um phx-change não serve só para erros. Na célula do HelloWeb.PostLive.Form, mostre quantas palavras o corpo tem, atualizando enquanto a pessoa digita:

  1. no render/1, logo depois do campo do corpo, acrescente <p id="contagem">{@palavras} palavras</p>;
  2. em cada apply_action/3, atribua :palavras: 0 para um post novo, e a contagem de post.body na edição;
  3. no handle_event("validate", ...), atribua :palavras a partir de post_params["body"].

Para contar, texto |> to_string() |> String.split() |> length() serve (to_string/1 cuida do nil). Uma função privada com esse código evita repeti-lo; ponha-a depois de todas as cláusulas de handle_event/3, para não separá-las.

Trilha.solucao(:contador)
Trilha.conferir(:contador)

O diff de cada tecla carrega só o número novo: o texto " palavras" é estático e já está no navegador.

A página do post: PostLive.Show

lib/hello_web/live/post_live/show.ex

defmodule HelloWeb.PostLive.Show do
  use HelloWeb, :live_view

  alias Hello.Blog

  @impl true
  def render(assigns) do
    ~H"""
    <Layouts.app flash={@flash}>
      <.header>
        Post {@post.id}
        <:subtitle>This is a post record from your database.</:subtitle>
        <:actions>
          <.button navigate={~p"/posts"}>
            <.icon name="hero-arrow-left" />
          </.button>
          <.button variant="primary" navigate={~p"/posts/#{@post}/edit?return_to=show"}>
            <.icon name="hero-pencil-square" /> Edit post
          </.button>
        </:actions>
      </.header>

      <.list>
        <:item title="Title">{@post.title}</:item>
        <:item title="Body">{@post.body}</:item>
      </.list>
    </Layouts.app>
    """
  end

  @impl true
  def mount(%{"id" => id}, _session, socket) do
    {:ok,
     socket
     |> assign(:page_title, "Show Post")
     |> assign(:post, Blog.get_post!(id))}
  end
end

A mais simples das três: mount/3 busca o post pelo id da URL. Os botões usam navigate para voltar à listagem e para ir à edição com ?return_to=show, de modo que o formulário, depois de salvar, volte para cá.

Navegação ao vivo

Na documentação do LiveView: Live navigation

Há três jeitos de um link mudar de página numa aplicação com LiveView:

No template O que acontece
<.link href={...}> Uma requisição HTTP comum: a página recarrega, com renderização estática, WebSocket novo e dois mount/3.
<.link navigate={...}> ou JS.navigate/1 Troca de LiveView sem recarregar: a LiveView atual termina e outra faz join na mesma conexão; um mount/3. Só funciona entre rotas do mesmo live_session.
<.link patch={...}> ou push_patch/2 Fica na mesma LiveView: só muda a URL, e o processo recebe handle_params/3.
Trilha.pergunta(:navegacao)

Da listagem para a página do Banana, com navigate, como o clique na linha da tabela faz:

{lv, _} = Trilha.abrir_lv("/posts", como: "ana")
{{lv, entrada}, tabela} = Trilha.callbacks(fn -> Trilha.navegar_lv(lv, "/posts/1") end)
Kino.Layout.grid([tabela, Trilha.resumo_lv(entrada)])

Um mount só, já conectado, num processo novo. Nenhuma renderização estática, porque não houve requisição HTTP: o cliente saiu do tópico da listagem (phx_leave) e fez um join com redirect no lugar de url.

O mesmo caminho por href, que equivale a abrir a URL de novo:

{{lv2, _}, tabela} = Trilha.callbacks(fn -> Trilha.abrir_lv("/posts/1", como: "ana") end)
Trilha.desconectar(lv2)
tabela

Dois mount, como na primeira visita.

E patch? Um patch não troca de LiveView: manda live_patch com a URL nova, e a LiveView atual chama handle_params/3. A listagem gerada não define esse callback. Volte à listagem e tente:

{lv, _} = Trilha.navegar_lv(lv, "/posts")
{lv, resposta} = Trilha.patch_lv(lv, "/posts?ordem=title")
Trilha.resumo_lv(resposta)

O processo da LiveView caiu, e o servidor mandou phx_error no tópico, que é o aviso do Phoenix de que o processo do outro lado do canal morreu. O log do servidor mostra o motivo (no Livebook ele aparece na saída da célula, ou nos logs do runtime): function HelloWeb.PostLive.Index.handle_params/3 is undefined or private. No navegador, o cliente perceberia a queda e reconectaria, montando a página de novo. O LiveView só chama handle_params/3 se o módulo o definir; para receber patches, a LiveView precisa dele.

Sua vez: ordenar com patch

Faça a listagem aceitar ?ordem=title, mudando a ordem sem trocar de LiveView. Na célula do HelloWeb.PostLive.Index:

  1. tire o stream(:posts, ...) do mount/3 e defina handle_params(params, _url, socket), que roda logo depois do mount/3 e de novo a cada patch. Ele lê params["ordem"] e chama stream(socket, :posts, posts_ordenados, reset: true): "title" ordena pelo título; sem o parâmetro, pela ordem de criação (o id). O reset: true manda o navegador refazer a lista; sem ele, os itens seriam acrescentados aos que já estão lá;
  2. no <.header>, acrescente um <:subtitle> com dois links: <.link patch={~p"/posts"}> e <.link patch={~p"/posts?ordem=title"}>.

A regra é a do guia de navegação: o que depende da URL é carregado em handle_params/3, e o mount/3 fica com o que não muda.

Trilha.solucao(:ordem)
Trilha.conferir(:ordem)

Com a solução avaliada, repita o patch: agora é só handle_params e render, no mesmo processo, e o stream é refeito na ordem dos títulos. Sem a solução, a célula cai como a anterior.

{lv, _} = Trilha.abrir_lv("/posts", como: "ana")
{{lv, resposta}, tabela} = Trilha.callbacks(fn -> Trilha.patch_lv(lv, "/posts?ordem=title") end)
Kino.Layout.grid([tabela, Trilha.resumo_lv(resposta)])

Tempo real com PubSub

Na documentação do LiveView: handle_info/2

Trilha.pergunta(:sem_pubsub)

Abra duas listagens, como a Ana e a Bia em dois navegadores, e crie um post pelo contexto:

{ana, _} = Trilha.abrir_lv("/posts", como: "ana")
{bia, _} = Trilha.abrir_lv("/posts", como: "bia")

{:ok, damasco} = Hello.Blog.create_post(%{title: "Damasco", body: "seco"})

{ana, da_ana} = Trilha.receber(ana)
{bia, da_bia} = Trilha.receber(bia)
{da_ana, da_bia}

{:nada, :nada}: nenhuma das duas listagens ficou sabendo. (Se você já fez o exercício abaixo, chegam dois diffs, cada um com o post novo entrando no stream.) O notebook 16 mostrou o caminho: o processo da LiveView assina um tópico do Phoenix.PubSub e trata as mensagens em handle_info/2. Os geradores com escopo (notebook 14) já escrevem esse código; o gerado sem escopo, não.

{:ok, _} = Hello.Blog.delete_post(damasco)

Trilha.desconectar(ana)
Trilha.desconectar(bia)

Sua vez: a lista ao vivo

  1. Na célula do Hello.Blog, acrescente subscribe_posts/0, que chama Phoenix.PubSub.subscribe(Hello.PubSub, "posts"), e faça create_post/1, update_post/2 e delete_post/1 publicarem, quando derem certo, {:created, post}, {:updated, post} e {:deleted, post} no mesmo tópico, com Phoenix.PubSub.broadcast/3. Uma função privada que recebe o resultado ({:ok, post} ou o erro) e o devolve intacto encaixa no fim de cada pipeline.
  2. Na célula do HelloWeb.PostLive.Index, assine o tópico no mount/3, dentro de if connected?(socket) (a renderização estática não tem processo para receber mensagens), e defina handle_info/2: :created e :updated viram stream_insert(socket, :posts, post) (com um id que já está na lista, o elemento é substituído), e :deleted vira stream_delete(socket, :posts, post).

Abra http://localhost:4010/posts em duas abas e crie, edite e apague posts numa delas.

Trilha.solucao(:tempo_real)
Trilha.conferir(:tempo_real)

Os geradores com escopo fazem um pouco diferente: o handle_info/2 deles relê a lista inteira com reset: true a cada mensagem, o que é mais simples e funciona com qualquer ordenação, ao custo de reenviar a lista.

E com escopo?

Este notebook usou um projeto sem autenticação, e o gerador fez código sem escopo. Num projeto com mix phx.gen.auth --live, que gera a autenticação em LiveView e marca o escopo do usuário como padrão, o mesmo comando gera outra coisa:

Trilha.pergunta(:escopo)
  • as rotas entram num live_session :require_authenticated_user, on_mount: [{HelloWeb.UserAuth, :require_authenticated}]; o hook on_mount roda antes do mount/3 de cada LiveView do bloco e põe o @current_scope no socket (ou redireciona para o login);
  • toda função do contexto recebe o escopo (Blog.list_posts(socket.assigns.current_scope)), como no notebook 14;
  • o Index assina Blog.subscribe_posts(current_scope), um tópico por usuário, e o handle_info/2 refaz a lista com reset: true.

O guia Scopes mostra esse código. O notebook 13 usou a variante da autenticação com controllers; a variante LiveView tem o mesmo contexto, as mesmas tabelas e as mesmas rotas, com LiveViews no lugar das páginas de login, cadastro e configurações.

Sem roteiro: o painel da vitrine do Brechó

No notebook 08, a vitrine do Brechó virou o contexto Hello.Vitrine, com páginas feitas por um controller. Agora ela ganha um painel em LiveView, numa página só: a lista das peças à venda, um formulário que valida enquanto se digita e um botão de vender em cada linha.

O Brechó até aqui

As células abaixo são o estado do Brechó no fim do notebook 08: a tabela, o contexto e o controller. Avalie-as em ordem.

priv/repo/migrations/20250320000000_create_produtos.exs

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

lib/hello_web/router.ex: as rotas dos posts, as do controller da vitrine e a do painel. A rota do painel já está aqui; o módulo é com você.

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

    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 Brechó até aqui (notebook 08): a vitrine com controller.
    get "/produtos", ProdutoController, :index
    post "/produtos", ProdutoController, :create
    post "/produtos/:id/vender", ProdutoController, :vender

    # O desafio: o painel da vitrine, com LiveView.
    live "/painel", PainelLive
  end
end

lib/hello_web/controllers/produto_controller.ex e produto_html.ex

defmodule HelloWeb.ProdutoController do
  use HelloWeb, :controller

  alias Hello.Vitrine

  def index(conn, _params) do
    render(conn, :index, produtos: Vitrine.listar_disponiveis())
  end

  def create(conn, %{"produto" => attrs}) do
    case Vitrine.cadastrar_produto(attrs) do
      {:ok, produto} ->
        conn |> put_status(201) |> json(%{id: produto.id})

      {:error, changeset} ->
        erros = Ecto.Changeset.traverse_errors(changeset, fn {msg, _opts} -> msg end)
        conn |> put_status(422) |> json(%{errors: erros})
    end
  end

  def vender(conn, %{"id" => id}) do
    case Vitrine.vender(Vitrine.buscar_produto!(id)) do
      {:ok, produto} ->
        conn |> put_flash(:info, "#{produto.nome} vendido.") |> redirect(to: ~p"/produtos")

      {:error, :ja_vendido} ->
        conn |> put_flash(:error, "Esta peça já foi vendida.") |> redirect(to: ~p"/produtos")
    end
  end
end

defmodule HelloWeb.ProdutoHTML do
  use HelloWeb, :html

  def index(assigns) do
    ~H"""
    <Layouts.app flash={@flash}>
      <.header>Vitrine do Brechó</.header>
      <.table id="produtos" rows={@produtos}>
        <:col :let={produto} label="Peça">{produto.nome}</:col>
        <:col :let={produto} label="Preço">{produto.preco_centavos} centavos</:col>
        <:action :let={produto}>
          <.link href={~p"/produtos/#{produto}/vender"} method="post">Vender</.link>
        </:action>
      </.table>
    </Layouts.app>
    """
  end
end
for {nome, preco} <- [{"Jaqueta jeans", 8000}, {"Vestido floral", 4500}] do
  {:ok, _} = Hello.Vitrine.cadastrar_produto(%{nome: nome, preco_centavos: preco})
end

Trilha.consultar("SELECT id, nome, preco_centavos, vendido FROM produtos ORDER BY id")

O desafio

Escreva HelloWeb.PainelLive, a LiveView da rota /painel:

  • a lista das peças à venda (Vitrine.listar_disponiveis/0) num stream chamado :produtos, mostrada com <.table>;
  • um formulário com id="produto-form", os campos nome e preco_centavos (um to_form/1 do Vitrine.change_produto/2 gera os nomes produto[nome] e produto[preco_centavos]), phx-change="validar" e phx-submit="cadastrar". Ao validar, os erros aparecem enquanto se digita (um preço 0 mostra must be greater than 0) e nada é gravado. Ao cadastrar, a peça é gravada, entra na lista e o formulário volta a ficar vazio; com dados inválidos, nada é gravado e os erros aparecem;
  • em cada linha, um link phx-click="vender" com phx-value-id={produto.id}, que vende a peça com Vitrine.vender/1 e a tira da lista. Se outra pessoa vendeu antes, mostre a flash de erro Esta peça já foi vendida. (a mesma mensagem do controller) e tire a peça da lista também.

A célula abaixo tem um esqueleto que compila; substitua-o. Abra http://localhost:4010/painel em duas abas para testar a venda simultânea.

lib/hello_web/live/painel_live.ex

defmodule HelloWeb.PainelLive do
  use HelloWeb, :live_view

  def render(assigns) do
    ~H"""
    <Layouts.app flash={@flash}>
      <.header>Painel da vitrine</.header>
      <p>Em construção.</p>
    </Layouts.app>
    """
  end

  def mount(_params, _session, socket) do
    {:ok, socket}
  end
end
Trilha.solucao(:brecho)
Trilha.conferir(:brecho)

Para ir além: a lista de cada painel aberto ainda não muda quando outra pessoa cadastra ou vende uma peça. Com o que você fez nos posts (subscribe no mount/3 conectado, broadcast no contexto, handle_info/2 com stream_insert/3 e stream_delete/3), a vitrine fica ao vivo para todos.

No seu projeto

  1. Gere o CRUD: mix phx.gen.live Blog Post posts title:string body:text.
  2. Copie as quatro rotas live que o gerador imprime para o scope "/" do lib/hello_web/router.ex, com /posts/new antes de /posts/:id.
  3. mix ecto.migrate e mix phx.server; abra http://localhost:4000/posts.
  4. Nas ferramentas do navegador, aba Network, filtre por WS e clique na conexão websocket: a aba de mensagens mostra o phx_join, as respostas e os diffs, no mesmo formato que este notebook mostrou.
  5. Rode mix test test/hello_web/live/post_live_test.exs. Os testes gerados usam Phoenix.LiveViewTest (live/2, form/3, render_change/1, render_submit/1, follow_redirect/3), que no projeto funciona porque o mix.exs gerado inclui a biblioteca lazy_html no ambiente de teste. Este notebook não a tem, por isso usou um cliente WebSocket próprio.
  6. Faça os exercícios no projeto: a validação de título (os testes gerados usam "some title", que passa), a contagem de palavras, a ordenação com patch e a lista ao vivo. Depois de mudar o Index e o Form, rode os testes de novo.

Num projeto com mix phx.gen.auth --live, o mesmo mix phx.gen.live gera a versão com escopo: confira o live_session e o on_mount no router.

Revisão

  • mix phx.gen.live gera a mesma camada de dados do phx.gen.html (migration, schema, contexto) e três LiveViews: Index, Show e Form. Rotas live ligam caminhos a LiveViews, e a live action diz ao Form se é :new ou :edit.
  • Toda LiveView monta duas vezes: na renderização estática (requisição HTTP, connected? falso) e no processo que nasce com o join do WebSocket. O processo fica vivo enquanto a página estiver aberta.
  • Streams mandam coleções ao navegador sem guardá-las no servidor. Depois, só viajam operações: entra posts-5, sai posts-4. reset: true refaz a lista.
  • phx-change valida enquanto se digita (to_form(changeset, action: :validate)), e os campos ainda não usados não mostram erros (_unused_). phx-submit grava; push_navigate/2 responde com live_redirect, e o cliente faz um novo join na mesma conexão.
  • href recarrega a página; navigate troca de LiveView pela mesma conexão; patch fica na mesma LiveView e chama handle_params/3, que só roda se a LiveView o definir.
  • Para várias telas mudarem juntas, o contexto publica no PubSub e cada LiveView conectada assina o tópico e trata a mensagem em handle_info/2.

Com este notebook termina o módulo 9. O 16 mostrou o que é uma LiveView: um processo com estado, que renderiza uma vez e depois troca diffs com o navegador. O 17 aplicou isso a um CRUD completo, com streams, formulários, navegação e tempo real. O próximo módulo da trilha trata dos Channels, a camada de tempo real sobre a qual o LiveView foi construído.