Trilha Phoenix · LiveView
Mix.install(
[
{:phoenix, "~> 1.8.0"},
{:phoenix_live_view, "~> 1.1"},
{:phoenix_ecto, "~> 4.6"},
{:ecto_sql, "~> 3.13"},
{:postgrex, ">= 0.0.0"},
{:bandit, "~> 1.5"},
{:jason, "~> 1.4"},
{:kino, "~> 0.14"},
{:swoosh, "~> 1.16"},
{:pbkdf2_elixir, "~> 2.0"}
],
consolidate_protocols: false,
config: [
phoenix: [json_library: Jason, plug_init_mode: :runtime],
swoosh: [api_client: false],
hello: [
{HelloWeb.Endpoint,
[
adapter: Bandit.PhoenixAdapter,
http: [ip: {127, 0, 0, 1}, port: 4010],
server: true,
debug_errors: true,
secret_key_base: String.duplicate("trilha-phoenix-", 5),
render_errors: [formats: [html: HelloWeb.ErrorHTML, json: HelloWeb.ErrorJSON], layout: false],
pubsub_server: Hello.PubSub
]},
{Hello.Mailer, [adapter: Swoosh.Adapters.Local]}
]
]
)
defmodule Trilha do
@moduledoc """
Apoio deste notebook: faz requisições ao endpoint, abre LiveViews por um
WebSocket de verdade (como o navegador faz) e mostra as mensagens que passam
por ele, encontra os processos das LiveViews no servidor, verifica a conexão
com o PostgreSQL, confere os exercícios e guarda as perguntas e as soluções.
Não faz parte do Phoenix nem do Ecto. Você não precisa ler este módulo para seguir o guia.
"""
@endpoint HelloWeb.Endpoint
@router HelloWeb.Router
@repo Hello.Repo
# --- banco de dados ------------------------------------------------------------
@doc "Tenta uma conexão com o PostgreSQL usando a configuração do Hello.Repo."
def verificar_postgres(extra \\ []) do
config = Keyword.merge(apply(@repo, :config, []), extra)
onde = "`#{config[:username]}@#{config[:hostname]}:#{config[:port] || 5432}`"
# Conecta ao banco "postgres", que sempre existe: o banco da trilha talvez ainda não tenha sido criado.
resultado =
try do
Postgrex.Protocol.connect(Postgrex.Utils.default_opts(Keyword.put(config, :database, "postgres")))
rescue
erro -> {:error, erro}
end
case resultado do
{:ok, estado} ->
Postgrex.Protocol.disconnect(%RuntimeError{message: "fim da verificação"}, estado)
Kino.Markdown.new("**Conectou** ao PostgreSQL em #{onde}. Pode seguir.")
{:error, erro} ->
Kino.Markdown.new(
"**Não conectou** ao PostgreSQL em #{onde}. A mensagem do Postgrex:\n\n" <>
cerca(Exception.message(erro), "") <> "\n\n" <> dica_conexao(erro)
)
end
end
defp dica_conexao(%Postgrex.Error{postgres: %{pg_code: "28P01"}}) do
"**Senha recusada.** Confira `username` e `password` na célula `config/dev.exs`. " <>
"Com autenticação por senha, o PostgreSQL dá esta mesma resposta quando o usuário não existe."
end
defp dica_conexao(%Postgrex.Error{postgres: %{pg_code: "28000", message: mensagem}}) do
cond do
mensagem =~ ~r/ident|peer/i ->
"**O servidor não aceita senha para esta conexão.** Ele está configurado para autenticação `ident` ou `peer`, " <>
"o padrão de algumas distribuições Linux, como o Fedora. O `LEIA-ME.md` da trilha mostra como trocar para senha no `pg_hba.conf`."
mensagem =~ "does not exist" ->
"**O usuário não existe no PostgreSQL.** Crie-o no `psql` ou troque `username` na célula `config/dev.exs`."
true ->
"**O servidor recusou a conexão.** Leia a mensagem acima e confira o `pg_hba.conf` do servidor."
end
end
defp dica_conexao(%DBConnection.ConnectionError{message: mensagem}) do
cond do
mensagem =~ "econnrefused" ->
"**Nada responde nesse endereço.** O servidor PostgreSQL está parado, ou escuta em outra porta. O `LEIA-ME.md` mostra como iniciá-lo."
mensagem =~ "nxdomain" ->
"**O nome do host não existe.** Confira `hostname` na célula `config/dev.exs`."
true ->
"**A conexão falhou antes de chegar ao PostgreSQL.** Confira `hostname` e `port` na célula `config/dev.exs`."
end
end
defp dica_conexao(_erro), do: "Confira a configuração na célula `config/dev.exs`."
@doc "Apaga todas as tabelas do banco da trilha, inclusive schema_migrations, e esquece os visitantes."
def recomecar do
sql("DROP SCHEMA public CASCADE")
sql("CREATE SCHEMA public")
esquecer_visitantes()
Kino.Markdown.new(
"O banco `#{apply(@repo, :config, [])[:database]}` está vazio: nenhuma tabela, nenhuma migration registrada."
)
end
@doc "Registra uma migration com a versão que, no projeto, viria do nome do arquivo."
def migracao(versao, modulo) do
:persistent_term.put({__MODULE__, :migracoes}, Map.put(mapa_migracoes(), versao, modulo))
nome = modulo |> Module.split() |> List.last() |> Macro.underscore()
Kino.Markdown.new("No projeto, esta migration seria o arquivo `priv/repo/migrations/#{versao}_#{nome}.exs`.")
end
@doc "As migrations registradas, como pares {versão, módulo}, em ordem de versão."
def migracoes, do: mapa_migracoes() |> Enum.sort()
defp mapa_migracoes, do: :persistent_term.get({__MODULE__, :migracoes}, %{})
@doc "Lista as tabelas e sequências do banco, como o comando \\d do psql."
def relacoes do
resultado =
sql("""
SELECT n.nspname AS "Schema", c.relname AS "Name",
CASE c.relkind WHEN 'r' THEN 'table' WHEN 'S' THEN 'sequence' WHEN 'v' THEN 'view' END AS "Type",
pg_get_userbyid(c.relowner) AS "Owner"
FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace
WHERE n.nspname = 'public' AND c.relkind IN ('r', 'S', 'v')
ORDER BY c.relname
""")
if resultado.rows == [] do
Kino.Markdown.new("`\\d`: o banco não tem nenhuma tabela.")
else
Kino.Markdown.new("**`\\d`**\n\n" <> tabela_markdown(resultado))
end
end
@doc "Mostra as colunas, os índices e as chaves estrangeiras de uma tabela, como o comando \\d TABELA do psql."
def descrever(tabela) do
colunas =
sql(
"""
SELECT a.attname AS "Column", format_type(a.atttypid, a.atttypmod) AS "Type",
CASE WHEN a.attnotnull THEN 'not null' ELSE '' END AS "Nullable",
coalesce(pg_get_expr(d.adbin, d.adrelid), '') AS "Default"
FROM pg_attribute a
LEFT JOIN pg_attrdef d ON d.adrelid = a.attrelid AND d.adnum = a.attnum
WHERE a.attrelid = to_regclass($1) AND a.attnum > 0 AND NOT a.attisdropped
ORDER BY a.attnum
""",
[tabela]
)
if colunas.rows == [] do
Kino.Markdown.new("`\\d #{tabela}`: a tabela `#{tabela}` não existe.")
else
indices = sql("SELECT indexdef FROM pg_indexes WHERE schemaname = 'public' AND tablename = $1 ORDER BY indexname", [tabela])
lista = Enum.map_join(indices.rows, "\n", fn [definicao] -> "* `#{definicao}`" end)
chaves =
sql(
"""
SELECT conname, pg_get_constraintdef(oid) FROM pg_constraint
WHERE conrelid = to_regclass($1) AND contype = 'f' ORDER BY conname
""",
[tabela]
)
fks =
if chaves.rows == [],
do: "",
else: "\n\n**Chaves estrangeiras**\n\n" <> Enum.map_join(chaves.rows, "\n", fn [nome, definicao] -> "* `#{nome}` #{definicao}" end)
Kino.Markdown.new(
"**`\\d #{tabela}`**\n\n" <>
tabela_markdown(colunas) <> "\n\n**Índices**\n\n" <> if(lista == "", do: "nenhum", else: lista) <> fks
)
end
end
@doc "Executa um SQL e mostra o resultado como tabela."
def consultar(consulta, parametros \\ []) do
resultado = sql(consulta, parametros)
Kino.Markdown.new(cerca(consulta, "sql") <> "\n\n" <> tabela_markdown(resultado))
end
defp sql(consulta, parametros \\ []) do
apply(@repo, :query!, [consulta, parametros, [log: false]])
end
defp tabela_markdown(%{columns: colunas, rows: linhas}) do
cabecalho = "| " <> Enum.join(colunas, " | ") <> " |"
separador = "| " <> Enum.map_join(colunas, " | ", fn _ -> "---" end) <> " |"
corpo =
Enum.map(linhas, fn linha ->
"| " <> Enum.map_join(linha, " | ", &celula/1) <> " |"
end)
Enum.join([cabecalho, separador | corpo], "\n")
end
defp celula(nil), do: ""
defp celula(""), do: ""
defp celula(valor) when is_binary(valor), do: "`" <> String.replace(valor, "|", "\\|") <> "`"
defp celula(%Decimal{} = valor), do: "`" <> Decimal.to_string(valor) <> "`"
defp celula(valor), do: "`" <> (valor |> inspect() |> String.replace("|", "\\|")) <> "`"
# --- requisições -----------------------------------------------------------
@doc "Despacha uma requisição para o endpoint, sem passar pela rede."
def requisicao(metodo \\ :get, caminho, parametros \\ nil, conn \\ Phoenix.ConnTest.build_conn()) do
descartar_respostas_antigas()
conn = Phoenix.ConnTest.dispatch(conn, @endpoint, metodo, caminho, parametros)
# Para NoRouteError (e só para ela), o Phoenix envia a página de erro e
# devolve o conn sem relançar a exceção. Reconstruímos a exceção aqui.
if conn.state == :sent and conn.status == nil do
{:erro, Phoenix.Router.NoRouteError.exception(conn: conn, router: @router)}
else
{:ok, conn}
end
rescue
erro -> {:erro, desembrulhar(erro)}
end
# Cada resposta enviada pelo adaptador de testes deixa duas mensagens no
# processo que fez a requisição. Se ficarem acumuladas, o Phoenix acha que a
# próxima página de erro já foi enviada e não a renderiza.
defp descartar_respostas_antigas do
receive do
{ref, {status, cabecalhos, _corpo}} when is_reference(ref) and is_integer(status) and is_list(cabecalhos) ->
descartar_respostas_antigas()
{:plug_conn, :sent} ->
descartar_respostas_antigas()
after
0 -> :ok
end
end
defp desembrulhar(%Plug.Conn.WrapperError{reason: motivo}), do: desembrulhar(motivo)
defp desembrulhar(erro), do: erro
@doc "Faz a requisição e mostra o status, o content-type e o corpo da resposta."
def mostrar(metodo \\ :get, caminho, parametros \\ nil) do
titulo = "### #{metodo |> to_string() |> String.upcase()} #{caminho}"
corpo =
case requisicao(metodo, caminho, parametros) do
{:ok, conn} ->
tipo = conn |> Plug.Conn.get_resp_header("content-type") |> Enum.join()
"**Status** `#{conn.status}` · **content-type** `#{tipo}`\n\n" <>
cerca(to_string(conn.resp_body), if(tipo =~ "json", do: "json", else: "html"))
{:erro, erro} ->
"**A requisição levantou uma exceção:**\n\n" <> cerca(Exception.format_banner(:error, erro), "")
end
Kino.Markdown.new(titulo <> "\n\n" <> corpo)
end
# --- navegador ---------------------------------------------------------------
@doc """
Faz uma requisição como um navegador: envia os cookies que o visitante `quem`
recebeu antes (a sessão, onde ficam a flash e o login) e segue os redirects.
`quem` é um nome qualquer, como "ana"; cada nome tem os seus próprios cookies.
Devolve `{:ok, conn}` com a última resposta, ou `{:erro, exceção}`.
"""
def visitar(quem, metodo, caminho, parametros \\ nil) do
quem |> seguir(metodo, caminho, parametros, []) |> List.last() |> elem(1)
end
@doc "Como `visitar/4`, mas mostra cada passo, a flash e o texto da página final."
def navegar(quem, metodo \\ :get, caminho, parametros \\ nil) do
passos = seguir(quem, metodo, caminho, parametros, [])
linhas =
passos
|> Enum.with_index(1)
|> Enum.map(fn {{requisicao, resultado}, i} -> linha_passo(i, requisicao, resultado) end)
cabecalho = [
"### #{quem}: #{rotulo(metodo, caminho)}",
"",
"| # | Requisição | Status | Redireciona para |",
"| --- | --- | --- | --- |"
]
Kino.Markdown.new(Enum.join(cabecalho ++ linhas, "\n") <> "\n\n" <> resumo_final(List.last(passos)))
end
@doc "Esquece os cookies de todos os visitantes, como se cada um abrisse um navegador novo."
def esquecer_visitantes do
for {{__MODULE__, :cookies, _} = chave, _} <- :persistent_term.get(), do: :persistent_term.erase(chave)
:ok
end
defp seguir(quem, metodo, caminho, parametros, feitos) do
conn = Enum.reduce(cookies(quem), Phoenix.ConnTest.build_conn(), fn {nome, valor}, c -> Plug.Test.put_req_cookie(c, nome, valor) end)
resultado = requisicao(metodo, caminho, parametros, conn)
feitos = feitos ++ [{rotulo(metodo, caminho), resultado}]
with {:ok, resp} <- resultado do
guardar_cookies(quem, resp)
end
with {:ok, resp} <- resultado,
true <- resp.status in 300..399 and length(feitos) < 6,
[destino] <- Plug.Conn.get_resp_header(resp, "location"),
true <- String.starts_with?(destino, "/") do
seguir(quem, :get, destino, nil, feitos)
else
_ -> feitos
end
end
defp cookies(quem), do: :persistent_term.get({__MODULE__, :cookies, quem}, %{})
defp guardar_cookies(quem, resp) do
novos =
Enum.reduce(resp.resp_cookies, cookies(quem), fn {nome, opcoes}, acc ->
if opcoes[:max_age] == 0, do: Map.delete(acc, nome), else: Map.put(acc, nome, opcoes[:value])
end)
:persistent_term.put({__MODULE__, :cookies, quem}, novos)
end
defp rotulo(metodo, caminho), do: "#{metodo |> to_string() |> String.upcase()} #{caminho}"
defp linha_passo(i, requisicao, {:ok, conn}) do
destino = conn |> Plug.Conn.get_resp_header("location") |> List.first("")
"| #{i} | `#{requisicao}` | `#{conn.status}` | #{if destino == "", do: "", else: "`#{destino}`"} |"
end
defp linha_passo(i, requisicao, {:erro, erro}) do
"| #{i} | `#{requisicao}` | exceção: `#{erro.__struct__ |> inspect()}` | |"
end
defp resumo_final({_requisicao, {:ok, conn}}) do
flash =
case conn.assigns[:flash] do
mapa when is_map(mapa) and map_size(mapa) > 0 ->
"**Flash:** " <> Enum.map_join(mapa, " · ", fn {tipo, msg} -> "#{tipo}: \"#{msg}\"" end) <> "\n\n"
_ ->
""
end
flash <> "**Texto da página** (sem as tags):\n\n" <> cerca(texto(to_string(conn.resp_body)), "")
end
defp resumo_final({_requisicao, {:erro, erro}}) do
"**A requisição levantou uma exceção:**\n\n" <> cerca(Exception.format_banner(:error, erro), "")
end
@doc "O texto de uma página HTML, sem as tags: o que se lê no navegador."
def texto(html) do
conteudo =
case Regex.run(~r{<main[^>]*>(.*)</main>}s, html) do
[_, dentro] -> dentro
nil -> html
end
conteudo
|> String.replace(~r{<(script|style|head)[^>]*>.*?</\1>}s, "")
|> String.replace(~r{<input[^>]*type="hidden"[^>]*>}, "")
|> String.replace(~r{<option[^>]*\sselected[^>]*>}, "[x] ")
|> String.replace(~r{<option[^>]*>}, "[ ] ")
|> then(&Regex.replace(~r{<input[^>]*\svalue="([^"]*)"[^>]*>}, &1, fn _, valor -> if valor == "", do: " ", else: " [" <> valor <> "] " end))
|> String.replace(~r{</?(p|div|li|tr|h\d|header|footer|ul|table|thead|tbody|form|label|select|option|section|br)[^>]*>}, "\n")
|> String.replace(~r{</t[dh]>}, " ")
|> String.replace(~r{<[^>]+>}, "")
|> String.replace(["&", "<", ">", """, "'", " "], fn
"&" -> "&"
"<" -> "<"
">" -> ">"
""" -> "\""
"'" -> "'"
" " -> " "
end)
|> String.split("\n")
|> Enum.map(&(&1 |> String.replace(~r/[ \t]+/, " ") |> String.trim()))
|> Enum.reject(&(&1 == ""))
|> Enum.join("\n")
end
defp cerca(texto, linguagem) do
marca = String.duplicate("`", 3)
marca <> linguagem <> "\n" <> String.trim(texto) <> "\n" <> marca
end
# --- curl ------------------------------------------------------------------------
@doc """
Roda um comando curl de verdade contra o servidor do notebook, por uma conexão
TCP, e mostra a resposta como o curl mostraria. Entende -i, -X, -H e -d.
"""
def curl(comando) do
{requisicao, resposta} = executar_curl(comando)
corpo = resumir_corpo(resposta)
cabeca = resposta.bruta |> String.split("\n\n", parts: 2) |> hd()
saida = if requisicao.incluir_cabecalhos, do: cabeca <> "\n\n" <> corpo, else: corpo
Kino.Markdown.new(cerca("$ " <> String.trim(comando), "console") <> "\n\n" <> cerca(saida, ""))
end
# A página de depuração do Plug.Debugger tem dezenas de KB; mostramos só o começo.
defp resumir_corpo(%{corpo: corpo} = resposta) when byte_size(corpo) > 3000 do
tipo = resposta.cabecalhos |> List.keyfind("content-type", 0, {nil, ""}) |> elem(1)
resumo =
cond do
tipo =~ "text/html" ->
titulo =
case Regex.run(~r{<title>(.*?)</title>}s, corpo) do
[_, t] -> String.trim(t)
nil -> "?"
end
"<!-- página HTML de #{byte_size(corpo)} bytes, título: #{titulo} -->"
true ->
corpo |> String.split("\n") |> Enum.take(12) |> Enum.join("\n")
end
resumo <> "\n[... #{byte_size(corpo)} bytes no total; o Trilha.curl mostra só o começo]"
end
defp resumir_corpo(%{corpo: corpo}), do: corpo
@doc "Como curl/1, mas devolve %{status:, cabecalhos:, corpo:, json:} para conferências."
def curl!(comando), do: comando |> executar_curl() |> elem(1)
defp executar_curl(comando) do
requisicao = interpretar_curl(comando)
{requisicao, enviar_http(requisicao)}
end
defp interpretar_curl(comando) do
palavras =
Regex.scan(~r/'([^']*)'|"((?:[^"\\]|\\.)*)"|(\S+)/, String.replace(comando, "\\\n", " "))
|> Enum.map(fn
[_, simples] -> simples
[_, "", duplas] -> String.replace(duplas, ~s(\\"), ~s("))
[_, "", "", solta] -> solta
end)
|> Enum.drop_while(&(&1 in ["$", "curl"]))
inicial = %{metodo: nil, url: nil, cabecalhos: [], dados: nil, incluir_cabecalhos: false}
requisicao = ler_opcoes_curl(palavras, inicial)
metodo = requisicao.metodo || if(requisicao.dados, do: "POST", else: "GET")
%{requisicao | metodo: metodo}
end
defp ler_opcoes_curl([], acc), do: acc
defp ler_opcoes_curl(["-H", cabecalho | resto], acc), do: ler_opcoes_curl(resto, %{acc | cabecalhos: acc.cabecalhos ++ [cabecalho]})
defp ler_opcoes_curl(["-d", dados | resto], acc), do: ler_opcoes_curl(resto, %{acc | dados: dados})
defp ler_opcoes_curl(["-X", metodo | resto], acc), do: ler_opcoes_curl(resto, %{acc | metodo: metodo})
defp ler_opcoes_curl(["-" <> letras | resto], acc) when letras in ["i", "iX", "Xi"] do
acc = %{acc | incluir_cabecalhos: true}
case {String.contains?(letras, "X"), resto} do
{true, [metodo | resto]} -> ler_opcoes_curl(resto, %{acc | metodo: metodo})
{false, _} -> ler_opcoes_curl(resto, acc)
end
end
defp ler_opcoes_curl([url | resto], acc), do: ler_opcoes_curl(resto, %{acc | url: url})
defp enviar_http(requisicao) do
porta = apply(@endpoint, :config, [:http])[:port]
%URI{path: caminho, query: consulta} = URI.parse(requisicao.url)
alvo = (caminho || "/") <> if(consulta, do: "?" <> consulta, else: "")
corpo = requisicao.dados || ""
tipo =
if requisicao.dados && !Enum.any?(requisicao.cabecalhos, &(&1 =~ ~r/^content-type:/i)),
do: ["Content-Type: application/x-www-form-urlencoded"],
else: []
# Como o curl: um cabeçalho passado com -H substitui o padrão de mesmo nome.
padrao =
Enum.reject(["User-Agent: curl/8", "Accept: */*"], fn linha ->
nome = linha |> String.split(":") |> hd() |> String.downcase()
Enum.any?(requisicao.cabecalhos, &(&1 |> String.split(":") |> hd() |> String.downcase() == nome))
end)
linhas =
["#{requisicao.metodo} #{alvo} HTTP/1.1", "Host: localhost:#{porta}"] ++
padrao ++ requisicao.cabecalhos ++ tipo ++
if(corpo != "", do: ["Content-Length: #{byte_size(corpo)}"], else: []) ++ ["Connection: close", "", corpo]
socket =
case :gen_tcp.connect(~c"127.0.0.1", porta, [:binary, active: false]) do
{:ok, socket} ->
socket
{:error, motivo} ->
raise "nada respondeu na porta #{porta} (#{inspect(motivo)}). Avalie a célula que inicia o HelloWeb.Endpoint."
end
:ok = :gen_tcp.send(socket, Enum.join(linhas, "\r\n"))
bruta = receber_tudo(socket, "")
:gen_tcp.close(socket)
[cabeca, corpo_resposta] = String.split(bruta, "\r\n\r\n", parts: 2)
[linha_status | linhas_cabecalho] = String.split(cabeca, "\r\n")
[_, status | _] = String.split(linha_status, " ")
cabecalhos =
Enum.map(linhas_cabecalho, fn linha ->
[nome, valor] = String.split(linha, ": ", parts: 2)
{String.downcase(nome), valor}
end)
json =
case Jason.decode(corpo_resposta) do
{:ok, valor} -> valor
_ -> nil
end
%{
status: String.to_integer(status),
cabecalhos: cabecalhos,
corpo: corpo_resposta,
json: json,
bruta: String.replace(cabeca, "\r\n", "\n") <> "\n\n" <> corpo_resposta
}
end
defp receber_tudo(socket, acc) do
case :gen_tcp.recv(socket, 0, 5000) do
{:ok, dados} -> receber_tudo(socket, acc <> dados)
{:error, :closed} -> acc
end
end
# --- LiveView pelo fio ------------------------------------------------------------
@doc """
Abre uma LiveView como o navegador faz: um GET normal (a renderização estática,
com os cookies do visitante `como:`), depois uma conexão WebSocket em
/live/websocket e o "phx_join" no tópico da LiveView.
Devolve `{lv, resposta_do_join}`. A resposta traz a primeira renderização
completa: estáticos e dinâmicos.
"""
def abrir_lv(caminho, opcoes \\ []) do
porta = apply(@endpoint, :config, [:http])[:port]
quem = Keyword.get(opcoes, :como, "lv-#{System.unique_integer([:positive])}")
# 1. A renderização estática, por HTTP.
conn =
case visitar(quem, :get, caminho) do
{:ok, conn} -> conn
{:erro, erro} -> raise erro
end
html = to_string(conn.resp_body)
csrf = atributo_html(html, ~r/<meta name="csrf-token" content="([^"]+)"/)
id =
atributo_html(html, ~r/<div[^>]*\sid="(phx-[^"]+)"[^>]*data-phx-main/) ||
atributo_html(html, ~r/<div[^>]*data-phx-main[^>]*\sid="(phx-[^"]+)"/)
sessao = atributo_html(html, ~r/data-phx-session="([^"]+)"/)
estatico = atributo_html(html, ~r/data-phx-static="([^"]*)"/)
if is_nil(id) or is_nil(sessao),
do: raise("a página #{caminho} não tem uma LiveView (status #{conn.status})")
# 2. O WebSocket, com o cookie da sessão: o LiveView confere o token CSRF contra ele.
cookie = Enum.map_join(cookies(quem), "; ", fn {nome, valor} -> "#{nome}=#{valor}" end)
{:ok, socket} = :gen_tcp.connect(~c"127.0.0.1", porta, [:binary, active: false])
chave = Base.encode64(:crypto.strong_rand_bytes(16))
pedido =
"GET /live/websocket?_csrf_token=#{URI.encode_www_form(csrf)}&vsn=2.0.0 HTTP/1.1\r\n" <>
"Host: localhost:#{porta}\r\nUpgrade: websocket\r\nConnection: Upgrade\r\n" <>
"Sec-WebSocket-Key: #{chave}\r\nSec-WebSocket-Version: 13\r\nCookie: #{cookie}\r\n\r\n"
:ok = :gen_tcp.send(socket, pedido)
{:ok, resposta} = :gen_tcp.recv(socket, 0, 5000)
[cabeca, resto] = String.split(resposta, "\r\n\r\n", parts: 2)
unless cabeca =~ "101", do: raise("o servidor recusou o WebSocket:\n" <> cabeca)
lv = %{socket: socket, buffer: resto, topico: "lv:" <> id, join_ref: "1", ref: 1, quem: quem, porta: porta}
# 3. O join no tópico da LiveView.
url = "http://localhost:#{porta}#{caminho}"
payload = %{"url" => url, "params" => %{"_csrf_token" => csrf, "_mounts" => 0}, "session" => sessao, "static" => estatico}
enviar_quadro(lv, [lv.join_ref, "1", lv.topico, "phx_join", payload])
esperar_resposta(%{lv | ref: 2}, "1")
end
@doc """
Um clique (phx-click). Como o navegador, manda os phx-value-* do elemento
mais o `value` dele (num `<button>`, uma string vazia). Devolve `{lv, resposta}`.
"""
def clicar(lv, evento, valores \\ %{}) do
enviar_evento(lv, "click", evento, Map.merge(%{"value" => ""}, valores))
end
@doc "phx-blur: o campo perdeu o foco. Manda o valor que estava no campo."
def desfocar(lv, evento, valor), do: enviar_evento(lv, "blur", evento, %{"value" => valor})
@doc """
phx-change: um campo do formulário mudou. `campos` é o formulário inteiro, como
mapa (`%{"meta" => %{"valor" => "95"}}`); `alvo` é o nome do campo que mudou
(`"meta[valor]"`). O navegador manda o formulário codificado como numa URL.
"""
def mudar(lv, evento, campos, alvo) do
enviar(lv, "event", %{
"type" => "form",
"event" => evento,
"value" => Plug.Conn.Query.encode(campos),
"meta" => %{"_target" => alvo}
})
end
@doc "phx-submit: o formulário foi enviado."
def submeter(lv, evento, campos) do
enviar(lv, "event", %{"type" => "form", "event" => evento, "value" => Plug.Conn.Query.encode(campos), "meta" => %{}})
end
@doc "Um evento qualquer, montado à mão: tipo (\"click\", \"blur\"...), nome e valor."
def enviar_evento(lv, tipo, evento, valor) do
enviar(lv, "event", %{"type" => tipo, "event" => evento, "value" => valor})
end
@doc """
Um live patch, como o de `<.link patch={...}>`: a URL muda, a LiveView continua
no mesmo processo e o servidor chama `handle_params/3`.
"""
def patch(lv, caminho) do
enviar(lv, "live_patch", %{"url" => "http://localhost:#{lv.porta}#{caminho}"})
end
defp enviar(lv, evento, payload) do
ref = Integer.to_string(lv.ref)
enviar_quadro(lv, [lv.join_ref, ref, lv.topico, evento, payload])
esperar_resposta(%{lv | ref: lv.ref + 1}, ref)
end
@doc """
Espera uma mensagem que o servidor empurra sem ter sido pedida (um diff vindo
de `handle_info/2`, por exemplo). Devolve `{lv, diff}`, `{lv, {evento, payload}}`
para outras mensagens, ou `{lv, :nada}` se nada chegar no tempo dado.
"""
def receber(lv, tempo \\ 1000) do
case ler_mensagem(lv, tempo) do
{lv, [_, nil, _topico, "diff", diff]} -> {lv, diff}
{lv, [_, _ref, _topico, evento, payload]} -> {lv, {evento, payload}}
{lv, nil} -> {lv, :nada}
end
end
@doc "Fecha a conexão. O processo da LiveView no servidor termina."
def desconectar(lv) do
:gen_tcp.close(lv.socket)
:ok
end
@doc "Mostra uma mensagem do fio como JSON, como aparece nas ferramentas do navegador."
def json(valor), do: Kino.Markdown.new(cerca(Jason.encode!(valor, pretty: true), "json"))
@doc "O pid do processo que roda a LiveView desta conexão, no servidor."
def processo(lv) do
Enum.find(processos_lv(), fn pid -> estado_canal(pid)[:topic] == lv.topico end)
end
@doc "Os assigns da LiveView desta conexão, lidos de dentro do processo dela."
def estado(lv) do
case processo(lv) do
nil -> raise "a LiveView desta conexão não está rodando (o processo terminou?)"
pid -> assigns_de(pid)
end
end
@doc "Uma tabela com os processos de LiveView de um módulo e o estado de cada um."
def processos(modulo) do
linhas =
for pid <- processos_lv(), match?({^modulo, :mount, 3}, inicial(pid)) do
assigns = assigns_de(pid) |> Map.drop([:flash, :live_action]) |> inspect()
"| `#{inspect(pid)}` | `#{String.replace(assigns, "|", "\\|")}` |"
end
if linhas == [] do
Kino.Markdown.new("Nenhum processo de `#{inspect(modulo)}` rodando agora.")
else
Kino.Markdown.new(Enum.join(["| Processo | Assigns |", "| --- | --- |" | linhas], "\n"))
end
end
defp processos_lv do
Enum.filter(Process.list(), fn pid -> match?({_, :mount, 3}, inicial(pid)) end)
end
# O LiveView marca cada processo com {módulo, :mount, 3}, para ferramentas como o Observer.
defp inicial(pid) do
case Process.info(pid, :dictionary) do
{:dictionary, dicionario} -> Keyword.get(dicionario, :"$initial_call")
nil -> nil
end
end
defp estado_canal(pid) do
:sys.get_state(pid, 1000)
catch
:exit, _ -> %{}
end
defp assigns_de(pid) do
case estado_canal(pid) do
%{socket: %{assigns: assigns}} -> Map.delete(assigns, :__changed__)
_ -> %{}
end
end
@doc """
Guarda uma anotação, de qualquer processo. As LiveViews deste notebook a usam
para mostrar de onde cada chamada veio. `anotacoes/0` mostra e limpa a lista.
"""
def anotar(termo) do
:persistent_term.put({__MODULE__, :anotacoes}, lista_anotacoes() ++ [termo])
:ok
end
def anotacoes do
lista = lista_anotacoes()
:persistent_term.put({__MODULE__, :anotacoes}, [])
if lista == [] do
Kino.Markdown.new("Nenhuma anotação.")
else
linhas = lista |> Enum.with_index(1) |> Enum.map(fn {termo, i} -> "| #{i} | `#{inspect(termo)}` |" end)
Kino.Markdown.new(Enum.join(["| # | Anotação |", "| --- | --- |" | linhas], "\n"))
end
end
defp lista_anotacoes, do: :persistent_term.get({__MODULE__, :anotacoes}, [])
defp esperar_resposta(lv, ref) do
case ler_mensagem(lv, 5000) do
{lv, [_, ^ref, _, "phx_reply", %{"status" => "ok", "response" => resposta}]} ->
{lv, resposta}
{_lv, [_, ^ref, _, "phx_reply", outra]} ->
raise "o servidor respondeu #{inspect(outra)}"
{_lv, [_, _, _, "phx_error", _]} ->
raise "a LiveView caiu ao tratar a mensagem; o erro está no log acima"
{_lv, nil} ->
raise "o servidor não respondeu (a LiveView caiu? o erro está no log acima)"
{lv, _outra} ->
esperar_resposta(lv, ref)
end
end
defp enviar_quadro(lv, mensagem) do
dados = Jason.encode!(mensagem)
mascara = :crypto.strong_rand_bytes(4)
tamanho = byte_size(dados)
cabeca =
cond do
tamanho < 126 -> <<1::1, 0::3, 1::4, 1::1, tamanho::7>>
tamanho < 65_536 -> <<1::1, 0::3, 1::4, 1::1, 126::7, tamanho::16>>
true -> <<1::1, 0::3, 1::4, 1::1, 127::7, tamanho::64>>
end
:ok = :gen_tcp.send(lv.socket, [cabeca, mascara, mascarar(dados, mascara)])
end
# O cliente mascara cada byte com uma chave de 4 bytes (exigência do protocolo WebSocket).
defp mascarar(dados, <<a, b, c, d>>) do
chave = {a, b, c, d}
for {byte, i} <- Enum.with_index(:binary.bin_to_list(dados)), into: <<>> do
<<Bitwise.bxor(byte, elem(chave, rem(i, 4)))>>
end
end
# Lê um quadro de texto do servidor (sem máscara) e decodifica o JSON.
defp ler_mensagem(lv, tempo) do
case quadro(lv.buffer) do
{:ok, texto, resto} ->
{%{lv | buffer: resto}, Jason.decode!(texto)}
:incompleto ->
case :gen_tcp.recv(lv.socket, 0, tempo) do
{:ok, dados} -> ler_mensagem(%{lv | buffer: lv.buffer <> dados}, tempo)
{:error, _} -> {lv, nil}
end
end
end
defp quadro(<<_fin::1, _::3, _op::4, 0::1, 126::7, n::16, dados::binary-size(n), resto::binary>>), do: {:ok, dados, resto}
defp quadro(<<_fin::1, _::3, _op::4, 0::1, 127::7, n::64, dados::binary-size(n), resto::binary>>), do: {:ok, dados, resto}
defp quadro(<<_fin::1, _::3, _op::4, 0::1, n::7, dados::binary-size(n), resto::binary>>) when n < 126, do: {:ok, dados, resto}
defp quadro(_), do: :incompleto
defp atributo_html(html, regex) do
case Regex.run(regex, html) do
[_, valor] -> String.replace(valor, "&", "&")
nil -> nil
end
end
# --- perguntas -------------------------------------------------------------
@doc "Uma pergunta de múltipla escolha, respondida antes de ver a explicação."
def pergunta(id) do
%{texto: texto, opcoes: opcoes, correta: correta, explicacao: explicacao} = Map.fetch!(perguntas(), id)
quadro = Kino.Frame.new()
letras = Enum.map(opcoes, fn {letra, _texto} -> {letra, to_string(letra)} end)
lista = Enum.map_join(opcoes, "\n", fn {letra, opcao} -> "* **#{letra})** #{opcao}" end)
formulario =
Kino.Control.form(
[resposta: Kino.Input.select("Sua resposta", [{nil, "Escolha"} | letras])],
submit: "Conferir"
)
Kino.listen(formulario, fn %{data: %{resposta: resposta}} ->
retorno =
cond do
resposta == nil -> "Escolha uma opção antes de conferir."
resposta == correta -> "**Certo.** " <> explicacao
true -> "**Ainda não.** Releia o trecho acima e tente outra opção."
end
Kino.Frame.render(quadro, Kino.Markdown.new(retorno))
end)
Kino.Layout.grid([Kino.Markdown.new("**Antes de continuar:** " <> texto <> "\n\n" <> lista), formulario, quadro])
end
defp perguntas do
%{
alternativas: %{
texto:
"numa aplicação renderizada no servidor, sem LiveView, o usuário clica num botão e só um número da página deveria mudar. Quais são os caminhos?",
opcoes: [
a: "Nenhum: uma página renderizada no servidor não muda depois de carregada.",
b: "Enviar um formulário e renderizar a página inteira de novo, ou escrever JavaScript no navegador que chama uma API e muda o número.",
c: "O navegador recalcula o HTML sozinho, sem falar com o servidor.",
d: "Mandar o servidor reiniciar a conexão HTTP."
],
correta: :b,
explicacao:
"São os dois caminhos que o guia cita: o ciclo completo de requisição e resposta, com a página inteira de novo, ou levar a lógica para o cliente, com um framework JavaScript e uma API. O LiveView é um terceiro caminho: o estado fica no servidor, e o servidor manda ao navegador só as partes do HTML que mudaram."
},
primeira_renderizacao: %{
texto: "um buscador (ou o `curl`, que não roda JavaScript) pede a página de uma LiveView. O que ele recebe?",
opcoes: [
a: "Uma página vazia, que só ganha conteúdo depois que o JavaScript conecta.",
b: "O HTML completo, renderizado no servidor, como numa página de controller.",
c: "Um JSON com o estado da LiveView.",
d: "Um erro, porque uma LiveView só funciona por WebSocket."
],
correta: :b,
explicacao:
"Toda LiveView é renderizada primeiro por uma requisição HTTP comum. A página chega pronta, o que é bom para a primeira pintura na tela e para buscadores. Só depois o JavaScript abre a conexão persistente, e daí em diante só trafegam eventos e diffs."
},
requisicoes: %{
texto:
"um contador com um botão `+`. O usuário clica 10 vezes. Quantas requisições HTTP o navegador faz com um controller (formulário com POST e redirect) e com uma LiveView, contando o carregamento da página?",
opcoes: [
a: "11 com o controller, 11 com a LiveView.",
b: "21 com o controller; 2 com a LiveView.",
c: "11 com o controller; 1 com a LiveView.",
d: "21 com o controller; 12 com a LiveView."
],
correta: :b,
explicacao:
"Com o controller, cada clique é um POST e, por causa do redirect, mais um GET: 20, mais o GET inicial. Com a LiveView, há o GET da página e o pedido de upgrade para WebSocket, que também é uma requisição HTTP. Os 10 cliques viajam como mensagens dentro dessa conexão aberta."
},
diff: %{
texto: "a temperatura está em 71 e o usuário clica de novo em `+`. O que o servidor manda pelo WebSocket?",
opcoes: [
a: "O HTML inteiro da LiveView, com 72 no lugar de 71.",
b: "Só o valor que mudou, na posição dele: algo como `{\"0\": \"72\"}`.",
c: "Os estáticos e o novo valor: `{\"s\": [...], \"0\": \"72\"}`.",
d: "Nada: o navegador soma 1 sozinho."
],
correta: :b,
explicacao:
"Os estáticos já estão no navegador desde o join e nunca mudam. O servidor renderiza de novo, compara e manda só as partes dinâmicas que mudaram, identificadas pela posição. É isso que o guia chama de diffs pelo fio."
},
estado_onde: %{
texto: "a página do termostato está aberta e o usuário clicou três vezes. Onde está guardado o valor 73?",
opcoes: [
a: "Num cookie do navegador.",
b: "No banco de dados.",
c: "Nos assigns do socket, dentro de um processo Elixir que existe só para essa página, no servidor.",
d: "No JavaScript da página; o servidor não guarda nada entre um clique e outro."
],
correta: :c,
explicacao:
"Cada página aberta tem o seu processo no servidor, e o estado dela mora ali, como dados imutáveis do Elixir. Fechar a conexão termina o processo, e o estado vai junto. Por isso o próximo GET começa de novo em 70."
},
so_umidade: %{
texto:
"a `ClimaLive` mostra a temperatura (posição `0`) e a umidade (posição `1`). O usuário clica em `+ humidity`. O que vem no diff?",
opcoes: [
a: "As duas posições, `0` e `1`, porque a página renderizou de novo.",
b: "Só a posição `1`, com o novo valor da umidade.",
c: "O HTML dos dois parágrafos.",
d: "Nada, porque a temperatura não mudou."
],
correta: :b,
explicacao:
"O HEEx sabe quais assigns cada parte dinâmica usa. Quando só `@humidity` muda, só a expressão que o usa é calculada e enviada. O mapa `__changed__` do socket é o que guarda o que mudou desde a última renderização."
},
mount_duas: %{
texto:
"um `mount/3` chama `Phoenix.PubSub.subscribe/2` sem conferir `connected?(socket)`. O que acontece na renderização estática?",
opcoes: [
a: "Nada, porque o mount estático não roda.",
b: "O processo da requisição HTTP assina o tópico à toa: ele só renderiza o HTML, não é a LiveView e não trata as mensagens.",
c: "A página dá erro.",
d: "A LiveView passa a receber cada mensagem em dobro."
],
correta: :b,
explicacao:
"O mount roda duas vezes: uma no processo que atende o GET, para gerar o HTML, e outra no processo da LiveView, quando o WebSocket conecta. Só o segundo vive o bastante para receber mensagens. Por isso o padrão é `if connected?(socket), do: subscribe(...)`. Trabalho caro no mount também roda duas vezes."
},
patch_processo: %{
texto:
"com o termostato aberto, o usuário clica num `<.link patch={~p\"/thermostat?unit=C\"}>`. O que acontece no servidor?",
opcoes: [
a: "Uma requisição HTTP nova e um mount novo, num processo novo.",
b: "O mesmo processo recebe a URL nova e chama `handle_params/3`, sem mount.",
c: "O processo termina e outro começa, pelo mesmo WebSocket.",
d: "Nada: só o navegador muda a URL."
],
correta: :b,
explicacao:
"Um live patch manda pela conexão aberta uma mensagem `live_patch` com a URL nova. A LiveView continua no mesmo processo, com o mesmo estado, e o servidor chama `handle_params/3` com os parâmetros novos. O navegador atualiza a barra de endereço."
},
form_target: %{
texto:
"num `phx-change`, o navegador manda o formulário inteiro a cada mudança. Como a LiveView sabe qual campo o usuário acabou de mexer?",
opcoes: [
a: "Não sabe; precisa comparar com os valores anteriores.",
b: "Pelo parâmetro `\"_target\"`, que traz o caminho do campo, como `[\"meta\", \"valor\"]`.",
c: "Pelo nome do evento.",
d: "Pelo diff que o navegador manda."
],
correta: :b,
explicacao:
"Junto com os campos, o cliente manda `_target` com o nome do campo que disparou o evento, e o LiveView o transforma numa lista de chaves. É útil para validar só o campo que mudou ou para não mostrar erros de campos que o usuário ainda não tocou."
}
}
end
# --- soluções --------------------------------------------------------------
@doc "Um botão que revela a solução de um exercício."
def solucao(id) do
quadro = Kino.Frame.new()
botao = Kino.Control.button("Mostrar a solução")
Kino.listen(botao, fn _evento ->
Kino.Frame.render(quadro, Kino.Markdown.new(cerca(Map.fetch!(solucoes(), id), "elixir")))
end)
Kino.Layout.grid([botao, quadro])
end
defp solucoes do
%{
rota: ~S'''
# Na célula lib/hello_web/router.ex, no scope "/":
scope "/", HelloWeb do
pipe_through :browser
get "/", PageController, :home
live "/clima", ClimaLive
live "/montagem", MontagemLive
live "/bindings", BindingsLive
live "/thermostat", ThermostatLive
end
''',
menos: ~S'''
defmodule HelloWeb.ThermostatLive do
use HelloWeb, :live_view
def render(assigns) do
~H"""
Current temperature: {@temperature}°F
<button phx-click="inc_temperature">+</button>
<button phx-click="dec_temperature">-</button>
"""
end
def mount(_params, _session, socket) do
temperature = 70 # Let's assume a fixed temperature for now
{:ok, assign(socket, :temperature, temperature)}
end
def handle_event("inc_temperature", _params, socket) do
{:noreply, update(socket, :temperature, &(&1 + 1))}
end
def handle_event("dec_temperature", _params, socket) do
{:noreply, update(socket, :temperature, &max(&1 - 1, 50))}
end
end
''',
unidade: ~S'''
defmodule HelloWeb.ThermostatLive do
use HelloWeb, :live_view
def render(assigns) do
~H"""
Current temperature: {display(@temperature, @unit)}°{@unit}
<button phx-click="inc_temperature">+</button>
<button phx-click="dec_temperature">-</button>
<p>
<.link patch={~p"/thermostat"}>°F</.link>
<.link patch={~p"/thermostat?unit=C"}>°C</.link>
</p>
"""
end
def mount(_params, _session, socket) do
temperature = 70 # Let's assume a fixed temperature for now
{:ok, assign(socket, temperature: temperature, unit: "F")}
end
def handle_params(params, _uri, socket) do
unit = if params["unit"] == "C", do: "C", else: "F"
{:noreply, assign(socket, :unit, unit)}
end
def handle_event("inc_temperature", _params, socket) do
{:noreply, update(socket, :temperature, &(&1 + 1))}
end
def handle_event("dec_temperature", _params, socket) do
{:noreply, update(socket, :temperature, &max(&1 - 1, 50))}
end
defp display(temperature, "F"), do: temperature
defp display(temperature, "C"), do: round((temperature - 32) * 5 / 9)
end
''',
leituras: ~S'''
defmodule HelloWeb.ThermostatLive do
use HelloWeb, :live_view
def render(assigns) do
~H"""
Current temperature: {display(@temperature, @unit)}°{@unit}
<button phx-click="inc_temperature">+</button>
<button phx-click="dec_temperature">-</button>
<p>
<.link patch={~p"/thermostat"}>°F</.link>
<.link patch={~p"/thermostat?unit=C"}>°C</.link>
</p>
"""
end
def mount(_params, _session, socket) do
temperature = 70 # Let's assume a fixed temperature for now
if connected?(socket) do
Phoenix.PubSub.subscribe(Hello.PubSub, "leituras")
end
{:ok, assign(socket, temperature: temperature, unit: "F")}
end
def handle_params(params, _uri, socket) do
unit = if params["unit"] == "C", do: "C", else: "F"
{:noreply, assign(socket, :unit, unit)}
end
def handle_event("inc_temperature", _params, socket) do
{:noreply, update(socket, :temperature, &(&1 + 1))}
end
def handle_event("dec_temperature", _params, socket) do
{:noreply, update(socket, :temperature, &max(&1 - 1, 50))}
end
def handle_info({:leitura, graus}, socket) do
{:noreply, assign(socket, :temperature, graus)}
end
defp display(temperature, "F"), do: temperature
defp display(temperature, "C"), do: round((temperature - 32) * 5 / 9)
end
''',
meta: ~S'''
defmodule HelloWeb.ThermostatLive do
use HelloWeb, :live_view
def render(assigns) do
~H"""
Current temperature: {display(@temperature, @unit)}°{@unit}
<button phx-click="inc_temperature">+</button>
<button phx-click="dec_temperature">-</button>
<p>
<.link patch={~p"/thermostat"}>°F</.link>
<.link patch={~p"/thermostat?unit=C"}>°C</.link>
</p>
<p :if={@target}>Target: {@target}°F</p>
<.form for={@form} phx-change="validar" phx-submit="definir">
<.input field={@form[:valor]} type="number" label="Target (°F)" />
<button>Definir</button>
</.form>
"""
end
def mount(_params, _session, socket) do
temperature = 70 # Let's assume a fixed temperature for now
if connected?(socket) do
Phoenix.PubSub.subscribe(Hello.PubSub, "leituras")
end
{:ok,
assign(socket,
temperature: temperature,
unit: "F",
target: nil,
form: to_form(%{"valor" => ""}, as: :meta)
)}
end
def handle_params(params, _uri, socket) do
unit = if params["unit"] == "C", do: "C", else: "F"
{:noreply, assign(socket, :unit, unit)}
end
def handle_event("inc_temperature", _params, socket) do
{:noreply, update(socket, :temperature, &(&1 + 1))}
end
def handle_event("dec_temperature", _params, socket) do
{:noreply, update(socket, :temperature, &max(&1 - 1, 50))}
end
def handle_event("validar", %{"meta" => meta}, socket) do
{:noreply, assign(socket, :form, form_da_meta(meta))}
end
def handle_event("definir", %{"meta" => meta}, socket) do
form = form_da_meta(meta)
if form.errors == [] do
{:noreply, assign(socket, target: String.to_integer(meta["valor"]), form: form)}
else
{:noreply, assign(socket, :form, form)}
end
end
def handle_info({:leitura, graus}, socket) do
{:noreply, assign(socket, :temperature, graus)}
end
defp display(temperature, "F"), do: temperature
defp display(temperature, "C"), do: round((temperature - 32) * 5 / 9)
defp form_da_meta(meta) do
errors =
case Integer.parse(meta["valor"] || "") do
{valor, ""} when valor in 50..90 -> []
_ -> [valor: {"must be between 50 and 90", []}]
end
to_form(meta, as: :meta, errors: errors)
end
end
''',
brecho: ~S'''
# No router, dentro do scope "/":
live "/vitrine", VitrineLive
# lib/hello_web/live/vitrine_live.ex
defmodule HelloWeb.VitrineLive do
use HelloWeb, :live_view
alias Hello.Vitrine
def render(assigns) do
~H"""
<Layouts.app flash={@flash}>
<.header>Vitrine do Brechó</.header>
<p :if={@produtos == []}>Nada à venda agora.</p>
<ul>
<li :for={produto <- @produtos} id={"produto-#{produto.id}"}>
{produto.nome} · {produto.preco_centavos} centavos
<button phx-click="comprar" phx-value-id={produto.id}>Comprar</button>
</li>
</ul>
</Layouts.app>
"""
end
def mount(_params, _session, socket) do
if connected?(socket) do
Phoenix.PubSub.subscribe(Hello.PubSub, "vitrine")
end
{:ok, assign(socket, :produtos, Vitrine.listar_disponiveis())}
end
def handle_event("comprar", %{"id" => id}, socket) do
case Vitrine.vender(Vitrine.buscar_produto!(id)) do
{:ok, produto} ->
Phoenix.PubSub.broadcast(Hello.PubSub, "vitrine", {:vendido, produto.id})
{:noreply, put_flash(socket, :info, "#{produto.nome} é sua!")}
{:error, :ja_vendido} ->
{:noreply, put_flash(socket, :error, "Esta peça já foi vendida.")}
end
end
def handle_info({:vendido, id}, socket) do
{:noreply, update(socket, :produtos, fn produtos -> Enum.reject(produtos, &(&1.id == id)) end)}
end
end
'''
}
end
# --- conferência dos exercícios ----------------------------------------------
@doc "Confere um exercício consultando o banco e fazendo requisições de verdade."
def conferir(id) do
resultado =
Enum.reduce_while(Map.fetch!(verificacoes(), id), [], fn {descricao, teste}, feitos ->
case executar(teste) do
:ok -> {:cont, ["- Passou: " <> descricao | feitos]}
{:erro, dica} -> {:halt, ["- **Ainda não:** " <> descricao <> "\n\n " <> dica | feitos]}
end
end)
Kino.Markdown.new(resultado |> Enum.reverse() |> Enum.join("\n"))
end
defp executar(teste) do
teste.()
rescue
erro -> {:erro, explicar(erro)}
end
defp verificacoes do
%{
rota: [
{"`GET /thermostat` responde 200 com uma LiveView (`data-phx-main`) e a temperatura 70.",
fn ->
case requisicao(:get, "/thermostat") do
{:ok, %{status: 200} = conn} ->
corpo = to_string(conn.resp_body)
cond do
not (corpo =~ "data-phx-main") ->
{:erro, "A página respondeu 200, mas não traz uma LiveView. A rota usa `live`, e não `get`?"}
not (corpo =~ "70") ->
{:erro, "A página não mostra a temperatura 70."}
true ->
:ok
end
{:ok, conn} ->
{:erro, "A resposta veio com status `#{conn.status}`."}
{:erro, erro} ->
{:erro, explicar(erro)}
end
end},
{"O WebSocket conecta e o join traz a temperatura 70.",
fn ->
com_lv("/thermostat", fn lv, entrada ->
cond do
not (Jason.encode!(entrada) =~ "70") -> {:erro, "O join não trouxe a temperatura 70: `#{Jason.encode!(entrada)}`."}
estado(lv)[:temperature] != 70 -> {:erro, "O assign `:temperature` deveria começar em 70."}
true -> :ok
end
end)
end}
],
menos: [
{"A página tem um botão com `phx-click=\"dec_temperature\"`.",
fn ->
corpo_tem("/thermostat", ~s(phx-click="dec_temperature"), "Acrescente ao `render/1` um `<button phx-click=\"dec_temperature\">-</button>`.")
end},
{"Um clique em `dec_temperature` leva 70 a 69.",
fn ->
com_lv("/thermostat", fn lv, _ ->
{lv, _} = clicar(lv, "dec_temperature")
temperatura(lv, 69, "Depois de um clique, `@temperature` deveria ser 69. O `handle_event(\"dec_temperature\", ...)` diminui 1?")
end)
end},
{"Trinta cliques param em 50.",
fn ->
com_lv("/thermostat", fn lv, _ ->
lv = Enum.reduce(1..30, lv, fn _, lv -> lv |> clicar("dec_temperature") |> elem(0) end)
temperatura(lv, 50, "A temperatura deveria parar em 50. Use `max/2` no `update/3`: `&max(&1 - 1, 50)`.")
end)
end},
{"Depois disso, o `+` continua funcionando: 51.",
fn ->
com_lv("/thermostat", fn lv, _ ->
lv = Enum.reduce(1..30, lv, fn _, lv -> lv |> clicar("dec_temperature") |> elem(0) end)
{lv, _} = clicar(lv, "inc_temperature")
temperatura(lv, 51, "Depois de chegar a 50, um `+` deveria levar a 51.")
end)
end}
],
unidade: [
{"`GET /thermostat?unit=C` mostra `21°C` (70°F em Celsius, arredondado).",
fn ->
corpo_tem(
"/thermostat?unit=C",
"21°C",
"A página em Celsius deveria mostrar `21°C`: `round((70 - 32) * 5 / 9)` é 21. A unidade vem de `params[\"unit\"]` em `handle_params/3`."
)
end},
{"Um live patch para `/thermostat?unit=C` troca `@unit` para `\"C\"` no mesmo processo.",
fn ->
com_lv("/thermostat", fn lv, _ ->
antes = processo(lv)
{lv, _} = patch(lv, "/thermostat?unit=C")
cond do
processo(lv) != antes -> {:erro, "O processo mudou: um live patch não deveria remontar a LiveView."}
estado(lv)[:unit] != "C" -> {:erro, "Depois do patch, `@unit` vale `#{inspect(estado(lv)[:unit])}`. O `handle_params/3` deve atribuir `:unit`."}
true -> :ok
end
end)
end},
{"Um live patch de volta para `/thermostat` volta para `\"F\"`.",
fn ->
com_lv("/thermostat?unit=C", fn lv, _ ->
{lv, _} = patch(lv, "/thermostat")
if estado(lv)[:unit] == "F",
do: :ok,
else: {:erro, "Sem `unit` na URL, `@unit` deveria ser `\"F\"`; veio `#{inspect(estado(lv)[:unit])}`."}
end)
end}
],
leituras: [
{"O mount estático não assina o tópico \"leituras\"; só o conectado assina.",
fn ->
requisicao(:get, "/thermostat")
assinou = self() in Enum.map(Registry.lookup(Hello.PubSub, "leituras"), &elem(&1, 0))
Phoenix.PubSub.unsubscribe(Hello.PubSub, "leituras")
if assinou,
do: {:erro, "A renderização estática assinou o tópico. Cerque o `subscribe` com `if connected?(socket)`."},
else: :ok
end},
{"Duas conexões abertas recebem a leitura publicada em \"leituras\", sem nenhum clique.",
fn ->
{a, _} = abrir_lv("/thermostat")
{b, _} = abrir_lv("/thermostat")
try do
Phoenix.PubSub.broadcast(Hello.PubSub, "leituras", {:leitura, 64})
{a, diff_a} = receber(a)
{b, diff_b} = receber(b)
cond do
diff_a == :nada or diff_b == :nada ->
{:erro, "Nenhum diff chegou. No `mount/3`, assine o tópico \"leituras\" quando `connected?(socket)` for verdadeiro."}
estado(a)[:temperature] != 64 or estado(b)[:temperature] != 64 ->
{:erro, "O diff chegou, mas a temperatura não virou 64. O `handle_info/2` deve casar `{:leitura, graus}` e atribuir `:temperature`."}
true ->
:ok
end
after
desconectar(a)
desconectar(b)
end
end}
],
meta: [
{"`validar` com 95 mostra o erro `must be between 50 and 90`.",
fn ->
com_lv("/thermostat", fn lv, _ ->
{_lv, resposta} = mudar(lv, "validar", %{"meta" => %{"valor" => "95"}}, "meta[valor]")
if Jason.encode!(resposta) =~ "must be between 50 and 90",
do: :ok,
else: {:erro, "O diff não trouxe a mensagem de erro. Veio: `#{Jason.encode!(resposta)}`."}
end)
end},
{"`validar` com 72 não deixa erro no formulário.",
fn ->
com_lv("/thermostat", fn lv, _ ->
{lv, _} = mudar(lv, "validar", %{"meta" => %{"valor" => "72"}}, "meta[valor]")
case estado(lv)[:form] do
%Phoenix.HTML.Form{errors: []} -> :ok
%Phoenix.HTML.Form{errors: erros} -> {:erro, "Com 72, o formulário ficou com erros: `#{inspect(erros)}`."}
outro -> {:erro, "O assign `:form` deveria ser um formulário (`to_form/2`); veio `#{inspect(outro)}`."}
end
end)
end},
{"`definir` com 72 guarda 72 em `@target`, e a página mostra o valor.",
fn ->
com_lv("/thermostat", fn lv, _ ->
{lv, resposta} = submeter(lv, "definir", %{"meta" => %{"valor" => "72"}})
cond do
estado(lv)[:target] != 72 -> {:erro, "`@target` vale `#{inspect(estado(lv)[:target])}`; deveria ser o inteiro 72."}
not (Jason.encode!(resposta) =~ "72") -> {:erro, "O diff não mostra 72. O template exibe `@target`?"}
true -> :ok
end
end)
end},
{"`definir` com 40 não muda a meta.",
fn ->
com_lv("/thermostat", fn lv, _ ->
{lv, _} = submeter(lv, "definir", %{"meta" => %{"valor" => "72"}})
{lv, _} = submeter(lv, "definir", %{"meta" => %{"valor" => "40"}})
if estado(lv)[:target] == 72,
do: :ok,
else: {:erro, "Depois de definir 40, `@target` vale `#{inspect(estado(lv)[:target])}`; um valor inválido não deveria mudar a meta."}
end)
end}
],
brecho: [
{"`GET /vitrine` lista as peças disponíveis e não as vendidas.",
fn ->
com_pecas(fn [a, _b, vendida] ->
case requisicao(:get, "/vitrine") do
{:ok, %{status: 200} = conn} ->
corpo = to_string(conn.resp_body)
cond do
not (corpo =~ "data-phx-main") -> {:erro, "`/vitrine` não é uma LiveView. A rota usa `live`?"}
not (corpo =~ a.nome) -> {:erro, "A vitrine não mostra `#{a.nome}`, que está à venda. Liste `Vitrine.listar_disponiveis()`."}
corpo =~ vendida.nome -> {:erro, "A vitrine mostra `#{vendida.nome}`, que já foi vendida."}
true -> :ok
end
{:ok, conn} ->
{:erro, "`GET /vitrine` respondeu `#{conn.status}`."}
{:erro, erro} ->
{:erro, explicar(erro)}
end
end)
end},
{"Cada peça tem um botão `phx-click=\"comprar\"` com `phx-value-id`.",
fn ->
com_pecas(fn [a | _] ->
{:ok, conn} = requisicao(:get, "/vitrine")
corpo = to_string(conn.resp_body)
if corpo =~ ~s(phx-click="comprar") and corpo =~ ~s(phx-value-id="#{a.id}"),
do: :ok,
else: {:erro, "Esperava um botão com `phx-click=\"comprar\"` e `phx-value-id=\"#{a.id}\"` para `#{a.nome}`."}
end)
end},
{"Quando a ana compra, a vitrine aberta da bia recebe um diff e a peça some dela.",
fn ->
com_pecas(fn [a | _] ->
{ana, _} = abrir_lv("/vitrine", como: "ana")
{bia, _} = abrir_lv("/vitrine", como: "bia")
try do
{ana, _} = clicar(ana, "comprar", %{"id" => to_string(a.id)})
{bia, empurrado} = receber(bia)
ids_bia = Enum.map(estado(bia)[:produtos] || [], & &1.id)
ids_ana = Enum.map(estado(ana)[:produtos] || [], & &1.id)
cond do
not vendida?(a.id) -> {:erro, "Depois do clique, `#{a.nome}` não ficou vendida. O `handle_event(\"comprar\", ...)` chama `Vitrine.vender/1`?"}
empurrado == :nada -> {:erro, "A vitrine da bia não recebeu nada. Depois de vender, publique no tópico \"vitrine\", e assine esse tópico no mount conectado."}
a.id in ids_bia -> {:erro, "A bia recebeu uma mensagem, mas `@produtos` dela ainda tem a peça. O `handle_info/2` deve tirá-la da lista."}
a.id in ids_ana -> {:erro, "A vitrine da própria ana ainda mostra a peça comprada."}
true -> :ok
end
after
desconectar(ana)
desconectar(bia)
end
end)
end},
{"Comprar uma peça já vendida mostra a flash de erro `Esta peça já foi vendida.`",
fn ->
com_pecas(fn [_a, b, _] ->
{ana, _} = abrir_lv("/vitrine", como: "ana")
{bia, _} = abrir_lv("/vitrine", como: "bia")
try do
{_ana, _} = clicar(ana, "comprar", %{"id" => to_string(b.id)})
{bia, _} = clicar(bia, "comprar", %{"id" => to_string(b.id)})
case estado(bia)[:flash] do
%{"error" => "Esta peça já foi vendida."} -> :ok
flash -> {:erro, "A flash da bia ficou `#{inspect(flash)}`; esperava o erro `Esta peça já foi vendida.`"}
end
after
desconectar(ana)
desconectar(bia)
end
end)
end},
{"A compra que dá certo mostra uma flash de informação com o nome da peça.",
fn ->
com_pecas(fn [a | _] ->
com_lv("/vitrine", fn lv, _ ->
{lv, _} = clicar(lv, "comprar", %{"id" => to_string(a.id)})
case estado(lv)[:flash] do
%{"info" => mensagem} -> if mensagem =~ a.nome, do: :ok, else: {:erro, "A flash `#{mensagem}` não cita `#{a.nome}`."}
flash -> {:erro, "Depois da compra, a flash ficou `#{inspect(flash)}`; esperava uma `:info` com o nome da peça."}
end
end)
end)
end},
{"Cinco compradores clicam ao mesmo tempo na mesma peça: só um consegue.",
fn ->
com_pecas(fn [a | _] ->
resultados =
1..5
|> Enum.map(fn i ->
Task.async(fn ->
{lv, _} = abrir_lv("/vitrine", como: "comprador#{i}")
try do
{lv, _} = clicar(lv, "comprar", %{"id" => to_string(a.id)})
estado(lv)[:flash]
after
desconectar(lv)
end
end)
end)
|> Task.await_many(15_000)
sucessos = Enum.count(resultados, &match?(%{"info" => _}, &1))
if sucessos == 1,
do: :ok,
else: {:erro, "#{sucessos} compradores receberam a flash de sucesso; só um deveria. Use o resultado de `Vitrine.vender/1`."}
end)
end}
]
}
end
# --- ajudantes da conferência --------------------------------------------------
# Abre a LiveView, roda o teste e sempre fecha a conexão.
defp com_lv(caminho, teste) do
{lv, entrada} = abrir_lv(caminho)
try do
teste.(lv, entrada)
after
desconectar(lv)
end
end
defp corpo_tem(caminho, trecho, dica) do
case requisicao(:get, caminho) do
{:ok, %{status: 200} = conn} ->
if to_string(conn.resp_body) =~ trecho, do: :ok, else: {:erro, dica}
{:ok, conn} ->
{:erro, "`GET #{caminho}` respondeu `#{conn.status}`."}
{:erro, erro} ->
{:erro, explicar(erro)}
end
end
defp temperatura(lv, esperado, dica) do
case estado(lv)[:temperature] do
^esperado -> :ok
outro -> {:erro, dica <> " Veio `#{inspect(outro)}`."}
end
end
# Cria três peças de conferência (a terceira já vendida), roda o teste e as apaga.
defp com_pecas(teste) do
unless Code.ensure_loaded?(Hello.Vitrine),
do: raise("o contexto `Hello.Vitrine` não existe: avalie as células de \"O Brechó até aqui\"")
sufixo = System.unique_integer([:positive])
pecas =
for {nome, preco} <- [{"Conferência Casaco #{sufixo}", 9000}, {"Conferência Saia #{sufixo}", 3000}, {"Conferência Chapéu #{sufixo}", 2000}] do
{:ok, peca} = apply(Hello.Vitrine, :cadastrar_produto, [%{nome: nome, preco_centavos: preco}])
peca
end
{:ok, _} = apply(Hello.Vitrine, :vender, [List.last(pecas)])
try do
teste.(pecas)
after
sql("DELETE FROM produtos WHERE nome LIKE 'Conferência%'")
end
end
defp vendida?(id), do: coluna("SELECT vendido FROM produtos WHERE id = $1", [id]) == [true]
defp coluna(consulta, parametros), do: consulta |> sql(parametros) |> Map.fetch!(:rows) |> Enum.map(&hd/1)
defp explicar(%Phoenix.Router.NoRouteError{} = erro) do
"Etapa: **router**. `#{Exception.message(erro)}`. Acrescente a rota `live` na célula do router e avalie-a de novo."
end
defp explicar(%UndefinedFunctionError{module: modulo, function: funcao, arity: aridade}) do
"`#{inspect(modulo)}.#{funcao}/#{aridade}` não existe. O módulo foi definido e a célula foi avaliada?"
end
defp explicar(%RuntimeError{message: "a LiveView caiu" <> _ = mensagem}) do
"A" <> String.slice(mensagem, 1..-1//1) <> ". O mais comum: falta a cláusula de `handle_event/3` (ou o `handle_params/3`) para essa mensagem, " <>
"e o processo cai com `FunctionClauseError` ou `UndefinedFunctionError`."
end
defp explicar(%RuntimeError{message: mensagem}) do
"#{mensagem}."
end
defp explicar(%Postgrex.Error{postgres: %{code: :undefined_table, message: mensagem}}) do
"O PostgreSQL respondeu `#{mensagem}`. As células de \"O Brechó até aqui\" já rodaram?"
end
defp explicar(%MatchError{term: termo}) do
"Um casamento de padrões falhou com o valor `#{inspect(termo, limit: 8)}`."
end
defp explicar(erro), do: "Levantou uma exceção:\n\n `" <> Exception.format_banner(:error, erro) <> "`"
end
Como usar este notebook
Este notebook acompanha o guia LiveView da documentação do Phoenix 1.8, na mesma ordem e com o link de cada seção. O guia é curto e aponta, no fim, para a documentação do Phoenix LiveView. Onde ele só cita um assunto (o ciclo de vida, os bindings, os formulários), o notebook vai um pouco mais fundo, com links para os guias do LiveView 1.2.
As convenções são as de sempre: uma célula equivale a um arquivo, as perguntas "Antes de continuar" vêm antes da explicação, e os erros fazem parte do conteúdo.
O que muda neste módulo: agora as páginas reagem no navegador. Abra http://localhost:4010/thermostat (depois de criar a rota) e clique; o número muda sem recarregar a página. Para ver o que trafega entre o navegador e o servidor sem sair do notebook, o módulo Trilha traz um navegador simulado que fala o protocolo do LiveView por um WebSocket de verdade:
Trilha.abrir_lv("/caminho")faz o GET da página, abre o WebSocket e entra na LiveView; devolve{lv, resposta};Trilha.clicar(lv, "evento"),Trilha.mudar/4,Trilha.submeter/3,Trilha.desfocar/3eTrilha.patch/2mandam o que o navegador mandaria num clique, num formulário, num campo que perde o foco ou num link de patch;Trilha.receber(lv)espera uma mensagem que o servidor empurra sem ter sido pedida;Trilha.json/1mostra as mensagens como JSON, do jeito que aparecem nas ferramentas do navegador (aba Network, conexão WS);Trilha.processo(lv),Trilha.estado(lv)eTrilha.processos(Modulo)mostram o processo de cada LiveView no servidor e o estado guardado nele.
A instalação é a dos notebooks 13 a 15. Se você vem do 12, o Livebook faz uma instalação nova na primeira avaliação, uma vez só. O banco de dados só entra no desafio do fim, mas a aplicação o inicia como nos outros notebooks.
A aplicação fica em http://localhost:4010. Se outro notebook da trilha estiver com o runtime ligado, desconecte-o antes. Avalie a primeira célula, no topo, antes de tudo.
A aplicação Hello dentro do notebook
config/dev.exs: a configuração do banco no ambiente de desenvolvimento, a mesma do notebook 07. Se o seu PostgreSQL usa outra senha ou outra porta, troque aqui e avalie a célula de novo.
Application.put_env(:hello, Hello.Repo,
username: System.get_env("PGUSER", "postgres"),
password: System.get_env("PGPASSWORD", "postgres"),
hostname: System.get_env("PGHOST", "localhost"),
port: String.to_integer(System.get_env("PGPORT", "5432")),
database: "trilha_phoenix_dev",
show_sensitive_data_on_connection_error: true,
pool_size: 10,
stacktrace: true
)
lib/hello/repo.ex
defmodule Hello.Repo do
use Ecto.Repo,
otp_app: :hello,
adapter: Ecto.Adapters.Postgres
end
lib/hello_web.ex: como no projeto gerado, o bloco html_helpers importa os CoreComponents e dá o apelido JS ao Phoenix.LiveView.JS. A novidade deste módulo é o bloco live_view: use Phoenix.LiveView mais os mesmos html_helpers. É ele que use HelloWeb, :live_view traz para cada LiveView.
defmodule HelloWeb do
def static_paths, do: ~w(assets fonts images favicon.ico robots.txt)
def router do
quote do
use Phoenix.Router, helpers: false
import Plug.Conn
import Phoenix.Controller
import Phoenix.LiveView.Router
end
end
def controller do
quote do
use Phoenix.Controller, formats: [:html, :json]
import Plug.Conn
unquote(verified_routes())
end
end
def live_view do
quote do
use Phoenix.LiveView
unquote(html_helpers())
end
end
def html do
quote do
use Phoenix.Component
import Phoenix.Controller,
only: [get_csrf_token: 0, view_module: 1, view_template: 1]
unquote(html_helpers())
end
end
defp html_helpers do
quote do
import Phoenix.HTML
import HelloWeb.CoreComponents
alias Phoenix.LiveView.JS
alias HelloWeb.Layouts
unquote(verified_routes())
end
end
def verified_routes do
quote do
use Phoenix.VerifiedRoutes,
endpoint: HelloWeb.Endpoint,
router: HelloWeb.Router,
statics: HelloWeb.static_paths()
end
end
defmacro __using__(which) when is_atom(which) do
apply(__MODULE__, which, [])
end
end
lib/hello_web/router.ex: as rotas live das LiveViews de demonstração já estão aqui; a do termostato é o primeiro exercício. Como as LiveViews são definidas mais abaixo, a primeira avaliação mostra avisos __live__/0 is undefined, um por rota, como os init/1 is undefined dos notebooks anteriores.
defmodule HelloWeb.Router do
use HelloWeb, :router
pipeline :browser do
plug :accepts, ["html"]
plug :fetch_session
plug :fetch_live_flash
plug :put_root_layout, html: {HelloWeb.Layouts, :root}
plug :protect_from_forgery
plug :put_secure_browser_headers
end
pipeline :api do
plug :accepts, ["json"]
end
scope "/", HelloWeb do
pipe_through :browser
get "/", PageController, :home
# As LiveViews de demonstração deste notebook:
live "/clima", ClimaLive
live "/montagem", MontagemLive
live "/bindings", BindingsLive
# Sua vez: a rota do termostato vem aqui.
end
end
lib/hello_web/endpoint.ex: duas novidades. A linha socket "/live", Phoenix.LiveView.Socket cria o endereço /live/websocket, onde o navegador conecta depois de carregar uma LiveView; connect_info: [session: ...] deixa a LiveView ler a sessão do cookie, que o LiveView usa para conferir o token CSRF. E os Plug.Static servem os arquivos JavaScript que, num projeto, o esbuild empacota no app.js: o phoenix_html.js, o phoenix.min.js (cliente de sockets) e o phoenix_live_view.min.js (cliente do LiveView).
defmodule HelloWeb.Endpoint do
use Phoenix.Endpoint, otp_app: :hello
@session_options [
store: :cookie,
key: "_hello_key",
signing_salt: "trilha00",
same_site: "Lax"
]
socket "/live", Phoenix.LiveView.Socket,
websocket: [connect_info: [session: @session_options]],
longpoll: [connect_info: [session: @session_options]]
plug Plug.Static, at: "/assets", from: {:phoenix_html, "priv/static"}, only: ~w(phoenix_html.js)
plug Plug.Static, at: "/assets", from: {:phoenix, "priv/static"}, only: ~w(phoenix.min.js)
plug Plug.Static, at: "/assets", from: {:phoenix_live_view, "priv/static"}, only: ~w(phoenix_live_view.min.js)
plug Plug.RequestId
plug Plug.Telemetry, event_prefix: [:phoenix, :endpoint]
plug Plug.Parsers,
parsers: [:urlencoded, :multipart, :json],
pass: ["*/*"],
json_decoder: Phoenix.json_library()
plug Plug.MethodOverride
plug Plug.Head
plug Plug.Session, @session_options
plug HelloWeb.Router
end
lib/hello_web/components/core_components.ex: os componentes que um projeto novo traz e que os templates gerados usam: flash, button, input, header, table, list e icon. O código é o do arquivo gerado pelo Phoenix 1.8.16, com uma diferença: no projeto, as mensagens de erro passam pelo Gettext, que traduz textos; aqui, translate_error/1 só preenche os valores na mensagem.
defmodule HelloWeb.CoreComponents do
use Phoenix.Component
alias Phoenix.LiveView.JS
@doc "Renders flash notices."
attr :id, :string, doc: "the optional id of flash container"
attr :flash, :map, default: %{}, doc: "the map of flash messages to display"
attr :title, :string, default: nil
attr :kind, :atom, values: [:info, :error], doc: "used for styling and flash lookup"
attr :rest, :global, doc: "the arbitrary HTML attributes to add to the flash container"
slot :inner_block, doc: "the optional inner block that renders the flash message"
def flash(assigns) do
assigns = assign_new(assigns, :id, fn -> "flash-#{assigns.kind}" end)
~H"""
<div
:if={msg = render_slot(@inner_block) || Phoenix.Flash.get(@flash, @kind)}
id={@id}
phx-click={JS.push("lv:clear-flash", value: %{key: @kind}) |> hide("##{@id}")}
role="alert"
class="toast toast-top toast-end z-50"
{@rest}
>
<div class={[
"alert w-80 sm:w-96 max-w-80 sm:max-w-96 text-wrap",
@kind == :info && "alert-info",
@kind == :error && "alert-error"
]}>
<.icon :if={@kind == :info} name="hero-information-circle" class="size-5 shrink-0" />
<.icon :if={@kind == :error} name="hero-exclamation-circle" class="size-5 shrink-0" />
<div>
<p :if={@title} class="font-semibold">{@title}</p>
<p>{msg}</p>
</div>
<div class="flex-1" />
<button type="button" class="group self-start cursor-pointer" aria-label="close">
<.icon name="hero-x-mark" class="size-5 opacity-40 group-hover:opacity-70" />
</button>
</div>
</div>
"""
end
@doc "Renders a button with navigation support."
attr :rest, :global, include: ~w(href navigate patch method download name value disabled)
attr :class, :any
attr :variant, :string, values: ~w(primary)
slot :inner_block, required: true
def button(%{rest: rest} = assigns) do
variants = %{"primary" => "btn-primary", nil => "btn-primary btn-soft"}
assigns =
assign_new(assigns, :class, fn ->
["btn", Map.fetch!(variants, assigns[:variant])]
end)
if rest[:href] || rest[:navigate] || rest[:patch] do
~H"""
<.link class={@class} {@rest}>
{render_slot(@inner_block)}
</.link>
"""
else
~H"""
<button class={@class} {@rest}>
{render_slot(@inner_block)}
</button>
"""
end
end
@doc "Renders an input with label and error messages."
attr :id, :any, default: nil
attr :name, :any
attr :label, :string, default: nil
attr :value, :any
attr :type, :string,
default: "text",
values: ~w(checkbox color date datetime-local email file month number password
search select tel text textarea time url week hidden)
attr :field, Phoenix.HTML.FormField,
doc: "a form field struct retrieved from the form, for example: @form[:email]"
attr :errors, :list, default: []
attr :checked, :boolean, doc: "the checked flag for checkbox inputs"
attr :prompt, :string, default: nil, doc: "the prompt for select inputs"
attr :options, :list, doc: "the options to pass to Phoenix.HTML.Form.options_for_select/2"
attr :multiple, :boolean, default: false, doc: "the multiple flag for select inputs"
attr :class, :any, default: nil, doc: "the input class to use over defaults"
attr :error_class, :any, default: nil, doc: "the input error class to use over defaults"
attr :rest, :global,
include: ~w(accept autocomplete capture cols disabled form list max maxlength min minlength
multiple pattern placeholder readonly required rows size step)
def input(%{field: %Phoenix.HTML.FormField{} = field} = assigns) do
errors = if Phoenix.Component.used_input?(field), do: field.errors, else: []
assigns
|> assign(field: nil, id: assigns.id || field.id)
|> assign(:errors, Enum.map(errors, &translate_error(&1)))
|> assign_new(:name, fn -> if assigns.multiple, do: field.name <> "[]", else: field.name end)
|> assign_new(:value, fn -> field.value end)
|> input()
end
def input(%{type: "hidden"} = assigns) do
~H"""
<input type="hidden" id={@id} name={@name} value={@value} {@rest} />
"""
end
def input(%{type: "checkbox"} = assigns) do
assigns =
assign_new(assigns, :checked, fn ->
Phoenix.HTML.Form.normalize_value("checkbox", assigns[:value])
end)
~H"""
<div class="fieldset mb-2">
<label for={@id}>
<input
type="hidden"
name={@name}
value="false"
disabled={@rest[:disabled]}
form={@rest[:form]}
/>
<span class="label">
<input
type="checkbox"
id={@id}
name={@name}
value="true"
checked={@checked}
class={@class || "checkbox checkbox-sm"}
{@rest}
/>{@label}
</span>
</label>
<.error :for={msg <- @errors}>{msg}</.error>
</div>
"""
end
def input(%{type: "select"} = assigns) do
~H"""
<div class="fieldset mb-2">
<label for={@id}>
<span :if={@label} class="label mb-1">{@label}</span>
<select
id={@id}
name={@name}
class={[@class || "w-full select", @errors != [] && (@error_class || "select-error")]}
multiple={@multiple}
{@rest}
>
<option :if={@prompt} value="">{@prompt}</option>
{Phoenix.HTML.Form.options_for_select(@options, @value)}
</select>
</label>
<.error :for={msg <- @errors}>{msg}</.error>
</div>
"""
end
def input(%{type: "textarea"} = assigns) do
~H"""
<div class="fieldset mb-2">
<label for={@id}>
<span :if={@label} class="label mb-1">{@label}</span>
<textarea
id={@id}
name={@name}
class={[
@class || "w-full textarea",
@errors != [] && (@error_class || "textarea-error")
]}
{@rest}
>{Phoenix.HTML.Form.normalize_value("textarea", @value)}</textarea>
</label>
<.error :for={msg <- @errors}>{msg}</.error>
</div>
"""
end
# All other inputs text, datetime-local, url, password, etc. are handled here...
def input(assigns) do
~H"""
<div class="fieldset mb-2">
<label for={@id}>
<span :if={@label} class="label mb-1">{@label}</span>
<input
type={@type}
name={@name}
id={@id}
value={Phoenix.HTML.Form.normalize_value(@type, @value)}
class={[
@class || "w-full input",
@errors != [] && (@error_class || "input-error")
]}
{@rest}
/>
</label>
<.error :for={msg <- @errors}>{msg}</.error>
</div>
"""
end
# Helper used by inputs to generate form errors
defp error(assigns) do
~H"""
<p class="mt-1.5 flex gap-2 items-center text-sm text-error">
<.icon name="hero-exclamation-circle" class="size-5" />
{render_slot(@inner_block)}
</p>
"""
end
@doc "Renders a header with title."
slot :inner_block, required: true
slot :subtitle
slot :actions
def header(assigns) do
~H"""
<header class={[@actions != [] && "flex items-center justify-between gap-6", "pb-4"]}>
<div>
<h1 class="text-lg font-semibold leading-8">
{render_slot(@inner_block)}
</h1>
<p :if={@subtitle != []} class="text-sm text-base-content/70">
{render_slot(@subtitle)}
</p>
</div>
<div class="flex-none">{render_slot(@actions)}</div>
</header>
"""
end
@doc "Renders a table with generic styling."
attr :id, :string, required: true
attr :rows, :list, required: true
attr :row_id, :any, default: nil, doc: "the function for generating the row id"
attr :row_click, :any, default: nil, doc: "the function for handling phx-click on each row"
attr :row_item, :any,
default: &Function.identity/1,
doc: "the function for mapping each row before calling the :col and :action slots"
slot :col, required: true do
attr :label, :string
end
slot :action, doc: "the slot for showing user actions in the last table column"
def table(assigns) do
assigns =
with %{rows: %Phoenix.LiveView.LiveStream{}} <- assigns do
assign(assigns, row_id: assigns.row_id || fn {id, _item} -> id end)
end
~H"""
<table class="table table-zebra">
<thead>
<tr>
<th :for={col <- @col}>{col[:label]}</th>
<th :if={@action != []}>
<span class="sr-only">Actions</span>
</th>
</tr>
</thead>
<tbody id={@id} phx-update={is_struct(@rows, Phoenix.LiveView.LiveStream) && "stream"}>
<tr :for={row <- @rows} id={@row_id && @row_id.(row)}>
<td
:for={col <- @col}
phx-click={@row_click && @row_click.(row)}
class={@row_click && "hover:cursor-pointer"}
>
{render_slot(col, @row_item.(row))}
</td>
<td :if={@action != []} class="w-0 font-semibold">
<div class="flex gap-4">
<%= for action <- @action do %>
{render_slot(action, @row_item.(row))}
<% end %>
</div>
</td>
</tr>
</tbody>
</table>
"""
end
@doc "Renders a data list."
slot :item, required: true do
attr :title, :string, required: true
end
def list(assigns) do
~H"""
<ul class="list">
<li :for={item <- @item} class="list-row">
<div class="list-col-grow">
<div class="font-bold">{item.title}</div>
<div>{render_slot(item)}</div>
</div>
</li>
</ul>
"""
end
@doc "Renders a [Heroicon](https://heroicons.com)."
attr :name, :string, required: true
attr :class, :any, default: "size-4"
def icon(%{name: "hero-" <> _} = assigns) do
~H"""
<span class={[@name, @class]} />
"""
end
## JS Commands
def show(js \\ %JS{}, selector) do
JS.show(js,
to: selector,
time: 300,
transition:
{"transition-all ease-out duration-300",
"opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95",
"opacity-100 translate-y-0 sm:scale-100"}
)
end
def hide(js \\ %JS{}, selector) do
JS.hide(js,
to: selector,
time: 200,
transition:
{"transition-all ease-in duration-200", "opacity-100 translate-y-0 sm:scale-100",
"opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"}
)
end
@doc """
Fills in the error message values. In the generated project this function
translates the message with Gettext.
"""
def translate_error({msg, opts}) do
Enum.reduce(opts, msg, fn {key, value}, acc ->
String.replace(acc, "%{#{key}}", fn _ -> to_string(value) end)
end)
end
@doc "Translates the errors for a field from a keyword list of errors."
def translate_errors(errors, field) when is_list(errors) do
for {^field, {msg, opts}} <- errors, do: translate_error({msg, opts})
end
end
lib/hello_web/components/layouts.ex: o root layout carrega o Tailwind e o daisyUI de uma CDN, para as páginas terem a aparência de um projeto gerado, e os três arquivos JavaScript servidos pelo endpoint. O último <script> faz o que o app.js de um projeto faz: cria o LiveSocket e conecta. A seção "Exemplo básico" volta a ele.
defmodule HelloWeb.Layouts do
use HelloWeb, :html
def root(assigns) do
~H"""
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="csrf-token" content={get_csrf_token()} />
<title>Hello · Phoenix Framework</title>
<link href="https://cdn.jsdelivr.net/npm/daisyui@5" rel="stylesheet" type="text/css" />
<script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4">
</script>
<script defer src={~p"/assets/phoenix_html.js"}>
</script>
<script src={~p"/assets/phoenix.min.js"}>
</script>
<script src={~p"/assets/phoenix_live_view.min.js"}>
</script>
<script>
window.addEventListener("DOMContentLoaded", () => {
const csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute("content")
const liveSocket = new LiveView.LiveSocket("/live", Phoenix.Socket, {params: {_csrf_token: csrfToken}})
liveSocket.connect()
window.liveSocket = liveSocket
})
</script>
</head>
<body>
{@inner_content}
</body>
</html>
"""
end
attr :flash, :map, required: true, doc: "the map of flash messages"
attr :current_scope, :map, default: nil, doc: "the current scope"
slot :inner_block, required: true
def app(assigns) do
~H"""
<header class="navbar px-4 sm:px-6 lg:px-8">
<div class="flex-1">
<a href="/" class="flex-1 flex w-fit items-center gap-2">
<span class="text-sm font-semibold">Hello · Phoenix v{Application.spec(:phoenix, :vsn)}</span>
</a>
</div>
</header>
<main class="px-4 py-20 sm:px-6 lg:px-8">
<div class="mx-auto max-w-2xl space-y-4">
{render_slot(@inner_block)}
</div>
</main>
<.flash_group flash={@flash} />
"""
end
attr :flash, :map, required: true, doc: "the map of flash messages"
attr :id, :string, default: "flash-group", doc: "the optional id of flash container"
def flash_group(assigns) do
~H"""
<div id={@id} aria-live="polite">
<.flash kind={:info} flash={@flash} />
<.flash kind={:error} flash={@flash} />
</div>
"""
end
end
lib/hello_web/controllers/: as páginas de erro e a página inicial.
defmodule HelloWeb.ErrorHTML do
def render(template, _assigns) do
Phoenix.Controller.status_message_from_template(template)
end
end
defmodule HelloWeb.ErrorJSON do
def render(template, _assigns) do
%{errors: %{detail: Phoenix.Controller.status_message_from_template(template)}}
end
end
defmodule HelloWeb.PageController do
use HelloWeb, :controller
def home(conn, _params) do
render(conn, :home)
end
end
defmodule HelloWeb.PageHTML do
use HelloWeb, :html
def home(assigns) do
~H"""
<Layouts.app flash={@flash}>
<h1>Welcome to Phoenix!</h1>
</Layouts.app>
"""
end
end
Antes de começar: o PostgreSQL
Este notebook usa o mesmo PostgreSQL do notebook 07, com o banco trilha_phoenix_dev, mas só no desafio do fim. A célula abaixo tenta uma conexão e, se falhar, traduz a mensagem em uma dica. Os comandos para instalar e configurar o PostgreSQL estão no LEIA-ME.md da trilha.
Trilha.verificar_postgres()
Crie o banco, se ainda não existir (é o mix ecto.create). {:error, :already_up} quer dizer que ele já existe, e serve do mesmo jeito.
Ecto.Adapters.Postgres.storage_up(Hello.Repo.config())
lib/hello/application.ex: a árvore de supervisão inicia o Repo, o Phoenix.PubSub e o endpoint. O PubSub é o sistema de mensagens do Phoenix; as LiveViews deste notebook o usam para receber mensagens. Antes de iniciar o endpoint, a célula acrescenta à configuração dele o signing_salt do LiveView, usado para assinar os tokens que a página leva até o WebSocket. Num projeto, essa linha fica em config/config.exs; aqui ela entra em tempo de execução para não mudar a lista do Mix.install (o Livebook reaproveita a instalação só se a lista for idêntica).
{:ok, _} = Kino.start_child(Hello.Repo)
{:ok, _} = Kino.start_child({Phoenix.PubSub, name: Hello.PubSub})
config = Application.get_env(:hello, HelloWeb.Endpoint)
Application.put_env(:hello, HelloWeb.Endpoint, Keyword.put(config, :live_view, signing_salt: "trilhaLV"))
{:ok, _} = Kino.start_child(HelloWeb.Endpoint)
Kino.Markdown.new("Repo conectado e aplicação no ar em <http://localhost:4010>.")
Comece do zero: a célula abaixo apaga todas as tabelas do banco trilha_phoenix_dev, para que os números de id sejam os mesmos do texto. Avalie-a sempre que reabrir o notebook. Ela só mexe no banco da trilha.
Trilha.recomecar()
Por que LiveView
Na documentação: LiveView
Até aqui, toda interação seguiu o ciclo de vida da requisição: o router encontra a rota, um controller trata a requisição e uma view devolve a resposta no formato certo. Mas e as páginas interativas?
Trilha.pergunta(:alternativas)
Numa aplicação renderizada no servidor, mudar o conteúdo da página exige enviar um formulário e renderizar a página nova, ou levar a lógica para o cliente, com um framework JavaScript (jQuery, React, Vue...) e uma API para ele conversar com o servidor.
O Phoenix LiveView propõe outro caminho: todo o estado fica no servidor, e a página continua sendo HTML renderizado no servidor, mas com uma experiência interativa e em tempo real. É uma alternativa aos frameworks JavaScript no cliente, com pouquíssimo JavaScript escrito por você.
O que é uma LiveView
Na documentação: What is a LiveView?
LiveViews são processos que recebem eventos, atualizam o seu estado e mandam para a página as atualizações como diffs.
O modelo de programação é declarativo. Em vez de dizer "quando o evento X acontecer, mude Y na página", os eventos são mensagens comuns que podem mudar o estado. Quando o estado muda, a LiveView renderiza de novo as partes relevantes do template e as manda ao navegador, que atualiza a página do jeito mais eficiente possível.
O estado de uma LiveView é feito de estruturas de dados do Elixir, funcionais e imutáveis. Os eventos vêm do navegador ou de mensagens internas da aplicação, em geral publicadas pelo Phoenix.PubSub.
Trilha.pergunta(:primeira_renderizacao)
Toda LiveView é renderizada primeiro de forma estática, por uma requisição HTTP comum. Isso deixa a primeira pintura na tela rápida e ajuda buscadores e indexadores. Depois, o navegador abre uma conexão persistente com o servidor, e por ela passam os eventos e as mudanças da página. Como cada interação não precisa autenticar, decodificar, carregar e codificar tudo de novo, há menos trabalho e menos dados por evento. O guia resume assim: LiveView é "diffs over the wire", diffs pelo fio.
Na seção "Exemplo básico", você vai ver cada uma dessas peças funcionando: a renderização estática, a conexão, os diffs e o processo.
LiveView × controller e view
Na documentação: LiveView vs Controller + View
Controllers e LiveViews servem a propósitos parecidos, mas funcionam de jeitos bem diferentes:
| Controller + view | LiveView |
|---|---|
| Cada requisição e resposta HTTP é uma transação separada. | O primeiro carregamento usa o ciclo de vida normal; depois, uma conexão bidirecional, feita com Phoenix Channels. |
| Cada carregamento de página ou envio de formulário é um ciclo completo. | Os eventos viajam pela conexão aberta, não em requisições separadas. |
| Sem estado: os dados ficam fora, no banco ou na sessão. | Um processo mantém o estado durante toda a interação. |
| A view é um módulo à parte, que renderiza com os dados do controller. | Controller e view se juntam num modelo só. |
| Atualizar parte da página exige recarregá-la ou escrever JavaScript. | Mudanças de estado renderizam de novo só as partes que mudaram; pouco JavaScript. |
Trilha.pergunta(:requisicoes)
Exemplo básico
Na documentação: Basic example
Uma aplicação Phoenix nova já vem com LiveView. O exemplo do guia é um termostato:
lib/hello_web/live/thermostat_live.ex
defmodule HelloWeb.ThermostatLive do
use HelloWeb, :live_view
def render(assigns) do
~H"""
Current temperature: {@temperature}°F
<button phx-click="inc_temperature">+</button>
"""
end
def mount(_params, _session, socket) do
temperature = 70 # Let's assume a fixed temperature for now
{:ok, assign(socket, :temperature, temperature)}
end
def handle_event("inc_temperature", _params, socket) do
{:noreply, update(socket, :temperature, &(&1 + 1))}
end
end
A LiveView mostra o ciclo de vida básico:
mount/3inicializa o estado quando a LiveView começa: atribui70a:temperature;render/1define o que aparece na tela, com um template HEEx, como nos componentes do notebook 06;handle_event/3responde aos eventos que vêm do navegador: o botão temphx-click="inc_temperature", e cada clique soma 1.
use HelloWeb, :live_view é o bloco live_view de lib/hello_web.ex, lá em cima: use Phoenix.LiveView mais os mesmos html_helpers das views HTML.
Sua vez: a rota
Para ligar a LiveView, o guia acrescenta ao router uma rota live:
scope "/", MyAppWeb do
pipe_through :browser
...
live "/thermostat", ThermostatLive
end
Na célula lib/hello_web/router.ex, lá em cima, há um comentário marcando onde a rota entra. Acrescente-a e avalie a célula do router de novo.
Trilha.solucao(:rota)
Trilha.conferir(:rota)
Ao avaliar o router, pode aparecer o aviso HelloWeb.ThermostatLive.__live__/0 is undefined. Ele aparece quando o router compila antes da LiveView existir, como na primeira avaliação do notebook, de cima para baixo. É o mesmo caso do aviso init/1 is undefined dos notebooks anteriores: a rota só procura o módulo quando uma requisição chega.
Todas as células desta seção usam a rota. Sem ela, cada uma mostra NoRouteError, e as que dependem da variável lv não compilam.
O app.js
Depois de renderizada, a LiveView vira uma resposta HTML comum. Num projeto, o arquivo assets/js/app.js traz:
import {Socket} from "phoenix"
import {LiveSocket} from "phoenix_live_view"
let csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute("content")
let liveSocket = new LiveSocket("/live", Socket, {params: {_csrf_token: csrfToken}})
liveSocket.connect()
Com isso, o cliente JavaScript conecta por WebSocket, e mount/3 é chamado de novo, agora dentro de um processo da LiveView. Neste notebook, o mesmo código está no <script> do root layout, e os dois arquivos vêm direto dos pacotes phoenix e phoenix_live_view.
Abra http://localhost:4010/thermostat no navegador e clique em +. Se quiser ver o fio, abra as ferramentas do navegador, aba Network, filtre por WS e clique na conexão websocket: cada clique aparece como uma mensagem enviada e uma recebida. As células abaixo mostram as mesmas mensagens.
A renderização estática
Primeiro, o que um cliente sem JavaScript recebe:
Trilha.curl("curl -i http://localhost:4010/thermostat")
É uma página HTML completa, com Current temperature: 70°F e o botão, renderizada no servidor como uma página de controller. A diferença está no <div> que envolve o conteúdo:
id="phx-..."edata-phx-mainidentificam a LiveView principal da página;data-phx-sessioné um token assinado que diz qual LiveView montar (repare emThermostatLivese decodificar o meio do token de Base64);data-phx-staticleva dados que não mudam entre conexões.
O JavaScript lê esses atributos para pedir ao servidor, pelo WebSocket, a mesma LiveView.
A conexão e o join
Trilha.abrir_lv/1 faz o que o navegador faz depois de carregar a página: abre o WebSocket em /live/websocket e manda uma mensagem phx_join com os tokens acima. A resposta do join é a primeira renderização completa, no formato que o cliente JavaScript entende:
{lv, entrada} = Trilha.abrir_lv("/thermostat")
Trilha.json(entrada)
O template foi separado em duas partes:
- estáticos, a lista de pedaços de texto que nunca mudam (
"Current temperature: "e"°F\n<button ...>+</button>"). Eles vêm em"p", uma tabela de estáticos compartilhados, e"s": 0aponta para a posição 0 dela; - dinâmicos, as expressões entre
{}, numeradas pela posição no template:"0": "70"é o valor de{@temperature}.
O HTML é os estáticos intercalados com os dinâmicos: "Current temperature: ", "70", "°F...". No log, CONNECTED TO Phoenix.LiveView.Socket registra a conexão e MOUNT HelloWeb.ThermostatLive, o mount dentro do processo da LiveView.
Um clique, um diff
Trilha.pergunta(:diff)
Trilha.clicar/2 manda o que o navegador manda quando você clica no botão: um evento click chamado inc_temperature.
{lv, diff} = Trilha.clicar(lv, "inc_temperature")
Trilha.json(diff)
A resposta é {"diff": {"0": "71"}}: só a posição 0, com o valor novo. Os estáticos não voltam, porque o navegador já os tem. Clique de novo:
{lv, diff} = Trilha.clicar(lv, "inc_temperature")
Trilha.json(diff)
Uma LiveView é um processo
Trilha.pergunta(:estado_onde)
Cada LiveView conectada é um processo Elixir no servidor, e o estado dela, os assigns, mora dentro dele. Trilha.processos/1 procura os processos de um módulo e mostra os assigns de cada um:
Trilha.processos(HelloWeb.ThermostatLive)
A temperatura 72 está no servidor, não no navegador. Se você abriu o termostato no navegador, o processo dele aparece também, com a temperatura que você deixou lá. Cada aba é um processo, com o seu próprio estado.
Quando a conexão fecha, o processo termina, e o estado vai junto:
pid = Trilha.processo(lv)
vivo_antes = Process.alive?(pid)
Trilha.desconectar(lv)
Process.sleep(100)
%{pid: pid, vivo_antes: vivo_antes, vivo_depois: Process.alive?(pid)}
Recarregar a página no navegador faz o mesmo: o processo antigo termina, um novo começa, e a temperatura volta a 70.
Sua vez: o botão de diminuir
Acrescente ao termostato um botão -, com phx-click="dec_temperature", e o handle_event/3 que diminui 1 grau. A temperatura nunca deve ficar abaixo de 50. Edite a célula thermostat_live.ex, avalie-a e teste no navegador (recarregue a página) ou com Trilha.abrir_lv/1 e Trilha.clicar/2.
Trilha.solucao(:menos)
Trilha.conferir(:menos)
Redefinir o módulo de uma LiveView com a página aberta no navegador não muda o processo que já está rodando: ele continua com o código antigo até reconectar. Recarregue a página depois de avaliar a célula.
Conceitos-chave
Na documentação: Key concepts
Socket e estado
O socket da LiveView é a estrutura que guarda todo o estado dela. É imutável e contém os assigns, os dados disponíveis no template. Onde os controllers têm conn, as LiveViews têm socket.
Mudanças no socket, com assign/3 ou update/3, provocam uma nova renderização. Como o socket é imutável, essas funções devolvem um socket novo, e é ele que o callback devolve em {:ok, socket} ou {:noreply, socket}. O estado inteiro fica no servidor; para o cliente vão só os diffs.
Para ver o "só o que mudou" com mais de um assign, esta LiveView mostra temperatura e umidade. A rota /clima já está no router.
lib/hello_web/live/clima_live.ex
defmodule HelloWeb.ClimaLive do
use HelloWeb, :live_view
def render(assigns) do
~H"""
<p>Temperature: {@temperature}°F</p>
<p>Humidity: {@humidity}%</p>
<button phx-click="inc_temperature">+ temperature</button>
<button phx-click="inc_humidity">+ humidity</button>
"""
end
def mount(_params, _session, socket) do
{:ok, assign(socket, temperature: 70, humidity: 40)}
end
def handle_event("inc_temperature", _params, socket) do
{:noreply, update(socket, :temperature, &(&1 + 1))}
end
def handle_event("inc_humidity", _params, socket) do
{:noreply, update(socket, :humidity, &(&1 + 1))}
end
end
{clima, entrada} = Trilha.abrir_lv("/clima")
Trilha.json(entrada)
Duas posições dinâmicas: "0" é a temperatura e "1" é a umidade.
Trilha.pergunta(:so_umidade)
{clima, diff} = Trilha.clicar(clima, "inc_humidity")
Trilha.desconectar(clima)
Trilha.json(diff)
Só "1". O HEEx sabe, na hora de compilar, quais assigns cada expressão usa. Quando @humidity muda, só a expressão {@humidity} é calculada de novo; a da temperatura nem roda. O guia Assigns and HEEx templates do LiveView explica esse rastreamento de mudanças e as armadilhas dele (por exemplo, variáveis criadas dentro do template desligam a otimização).
O ciclo de vida
Uma LiveView tem alguns momentos importantes, cada um com o seu callback:
mount/3inicializa a LiveView com os parâmetros, a sessão e o socket;handle_params/3responde a mudanças na URL e atualiza o estado de acordo;handle_event/3responde às interações que vêm do navegador;handle_info/2responde a mensagens comuns de processos.
O mount acontece duas vezes
Esta LiveView anota, a cada mount/3, se o socket está conectado e qual processo está rodando. Trilha.anotar/1 guarda a anotação numa lista que qualquer processo pode alimentar; não é parte do LiveView.
lib/hello_web/live/montagem_live.ex
defmodule HelloWeb.MontagemLive do
use HelloWeb, :live_view
def render(assigns) do
~H"""
Mounted {if @conectada, do: "connected", else: "disconnected"} in {inspect(@pid)}
"""
end
def mount(_params, _session, socket) do
Trilha.anotar(%{connected?: connected?(socket), pid: self()})
{:ok, assign(socket, conectada: connected?(socket), pid: self())}
end
end
{montagem, _} = Trilha.abrir_lv("/montagem")
Trilha.desconectar(montagem)
Trilha.anotacoes()
Dois mounts, em dois processos:
connected?: false, no processo que atendeu o GET. Ele renderiza o HTML estático e acabou. Aqui, comoTrilhafaz a requisição por dentro, sem rede, esse processo é o da própria célula; no navegador, é o processo do servidor HTTP que atende a requisição.connected?: true, num processo novo, criado quando o WebSocket conectou. É ele que vive enquanto a página está aberta.
Trilha.pergunta(:mount_duas)
O guia do LiveView recomenda: tudo o que só faz sentido com a página conectada (assinar tópicos, iniciar timers) fica dentro de if connected?(socket). E trabalho caro no mount/3, como uma consulta pesada, roda duas vezes; vale pensar se precisa estar ali.
handle_params e a URL
handle_params/3 é chamado logo depois do mount/3 e de novo toda vez que a URL muda por um live patch: um <.link patch={...}> clicado, ou um push_patch/2 no servidor. Ele recebe os parâmetros da URL, como um controller recebe params.
Trilha.pergunta(:patch_processo)
O guia do LiveView sobre navegação separa dois tipos: o patch, que fica na mesma LiveView e só chama handle_params/3, e o navigate, que troca de LiveView sem recarregar a página, montando uma nova pela mesma conexão.
Sua vez: Celsius ou Fahrenheit
Faça o termostato mostrar a temperatura em Celsius quando a URL tiver ?unit=C:
- no
mount/3, atribua tambémunit: "F"; - defina
handle_params(params, _uri, socket), que atribui:unitcom"C"separams["unit"]for"C", e"F"em qualquer outro caso; - no template, mostre
Current temperature: 21°Cem vez de70°Fquando a unidade for C. A conversão éround((temperature - 32) * 5 / 9), e@temperaturecontinua guardando Fahrenheit; - acrescente dois links de patch,
<.link patch={~p"/thermostat"}>°F</.link>e<.link patch={~p"/thermostat?unit=C"}>°C</.link>.
Trilha.solucao(:unidade)
Trilha.conferir(:unidade)
Com o exercício feito, veja o que um live patch manda pelo fio e o que volta. Sem handle_params/3, a LiveView não sabe tratar o patch e cai; a célula avisa, e o erro aparece no log.
{lv, _} = Trilha.abrir_lv("/thermostat")
antes = Trilha.processo(lv)
{lv, resposta} = Trilha.patch(lv, "/thermostat?unit=C")
mesmo_processo = Trilha.processo(lv) == antes
Trilha.desconectar(lv)
Kino.Layout.grid([Kino.Markdown.new("**Mesmo processo depois do patch:** `#{mesmo_processo}`"), Trilha.json(resposta)])
O navegador mandou uma mensagem live_patch com a URL nova; no log aparece HANDLE PARAMS in HelloWeb.ThermostatLive com Parameters: %{"unit" => "C"}. A resposta é um diff com as posições que mudaram: o número e a unidade. No navegador, clique nos links °C e °F: a barra de endereço muda, a página não recarrega, e a temperatura que você ajustou com + e - continua a mesma, porque o processo é o mesmo.
handle_info e o PubSub
handle_info/2 recebe qualquer mensagem enviada ao processo da LiveView. O caso mais comum é o Phoenix.PubSub: a LiveView assina um tópico e, quando alguém publica nele, a mensagem chega ao handle_info/2. Se o estado mudar, o servidor empurra um diff para o navegador, sem que o usuário tenha feito nada. É assim que páginas LiveView mostram dados em tempo real.
Sua vez: leituras do sensor
Faça o termostato receber as leituras de um sensor:
- no
mount/3, quandoconnected?(socket), assine o tópico"leituras"doHello.PubSubcomPhoenix.PubSub.subscribe/2; - defina
handle_info({:leitura, graus}, socket), que atribuigrausa:temperature.
Trilha.solucao(:leituras)
Trilha.conferir(:leituras)
Com o exercício feito, publique uma leitura com duas páginas abertas. Se o termostato estiver aberto no navegador, ele muda também.
{a, _} = Trilha.abrir_lv("/thermostat")
{b, _} = Trilha.abrir_lv("/thermostat")
Phoenix.PubSub.broadcast(Hello.PubSub, "leituras", {:leitura, 64})
{a, diff_a} = Trilha.receber(a)
{b, diff_b} = Trilha.receber(b)
Trilha.desconectar(a)
Trilha.desconectar(b)
Trilha.json(%{"conexão a" => diff_a, "conexão b" => diff_b})
As duas conexões receberam {"0": "64"}. Diferente das respostas a cliques, essa mensagem chega sozinha, como um evento diff empurrado pelo servidor; quem publicou foi esta célula, que nem sabe quantas páginas estão abertas.
Bindings do DOM
Na documentação: DOM Bindings
O LiveView oferece atributos HTML, os bindings, que mandam eventos para o servidor quando algo acontece no navegador:
<button phx-click="inc_temperature">+</button>
<form phx-submit="save">...</form>
<input phx-blur="validate">
Cada evento chega ao handle_event/3. A lista completa (cliques, teclado, foco, rolagem, intervalo de repetição com phx-debounce e phx-throttle) está no guia Bindings do LiveView.
O que vem em params depende do binding. Esta LiveView, com a rota /bindings já no router, anota cada evento que recebe:
lib/hello_web/live/bindings_live.ex
defmodule HelloWeb.BindingsLive do
use HelloWeb, :live_view
def render(assigns) do
~H"""
<button phx-click="ajustar" phx-value-quanto="5">+5</button>
<button phx-click="ajustar" phx-value-quanto="-5">-5</button>
<input type="text" name="apelido" phx-blur="saiu" placeholder="Type, then click outside" />
<p>Last event: {@ultimo}</p>
"""
end
def mount(_params, _session, socket) do
{:ok, assign(socket, :ultimo, "none")}
end
def handle_event(evento, params, socket) do
Trilha.anotar({evento, params})
{:noreply, assign(socket, :ultimo, "#{evento} #{inspect(params)}")}
end
end
phx-value-* acrescenta valores ao evento. O navegador manda também o value do elemento; num <button> sem value, uma string vazia. Trilha.clicar/3 faz o mesmo. Um campo com phx-blur manda, quando perde o foco, o texto que estava nele:
{bindings, _} = Trilha.abrir_lv("/bindings")
{bindings, _} = Trilha.clicar(bindings, "ajustar", %{"quanto" => "5"})
{bindings, _} = Trilha.desfocar(bindings, "saiu", "Ana")
Trilha.desconectar(bindings)
Trilha.anotacoes()
Os valores chegam sempre como strings, como os parâmetros de um formulário: "5", não 5. Converta no servidor, e valide, porque qualquer um pode mandar qualquer coisa por esse WebSocket.
Formulários
Em formulários, dois bindings fazem quase tudo: phx-change, a cada mudança num campo, e phx-submit, no envio. Nos dois, o navegador manda o formulário inteiro, codificado como numa URL, e o servidor o decodifica no mesmo formato de params de um controller: %{"meta" => %{"valor" => "95"}}.
Trilha.pergunta(:form_target)
O guia Form bindings do LiveView mostra o padrão: o formulário é um assign criado com to_form/2, phx-change valida e atribui um formulário novo com os erros, e phx-submit grava. O componente <.input> dos CoreComponents só mostra o erro de um campo depois que o usuário mexeu nele, o mesmo used_input? do notebook 08.
Sua vez: a temperatura desejada
Acrescente ao termostato um formulário para a temperatura desejada:
- no
mount/3, atribuatarget: nileform: to_form(%{"valor" => ""}, as: :meta); os campos chegam comometa[valor]; - no template,
<.form for={@form} phx-change="validar" phx-submit="definir">com<.input field={@form[:valor]} type="number" label="Target (°F)" />e um botão; acima do formulário,Target: 72°Fquando@targetnão fornil; handle_event("validar", %{"meta" => meta}, socket)atribui um formulário novo,to_form(meta, as: :meta, errors: erros), em queerrosé[]semeta["valor"]for um inteiro de 50 a 90, e[valor: {"must be between 50 and 90", []}]caso contrário;handle_event("definir", ...)faz a mesma validação e, se não houver erro, atribui o inteiro a:target.
Trilha.solucao(:meta)
Trilha.conferir(:meta)
Com o exercício feito, veja as duas mensagens de formulário e as respostas. Sem os handle_event/3 de "validar" e "definir", a LiveView cai, como no patch.
{lv, _} = Trilha.abrir_lv("/thermostat")
{lv, resposta_validar} = Trilha.mudar(lv, "validar", %{"meta" => %{"valor" => "95"}}, "meta[valor]")
{lv, resposta_definir} = Trilha.submeter(lv, "definir", %{"meta" => %{"valor" => "72"}})
Trilha.desconectar(lv)
Trilha.json(%{"1. validar com 95" => resposta_validar, "2. definir com 72" => resposta_definir})
No log, o HANDLE EVENT "validar" mostra os parâmetros já decodificados, com "_target" => ["meta", "valor"].
As respostas são maiores que as dos cliques, por dois motivos. Primeiro, o assign @form inteiro mudou, então voltam todas as partes dinâmicas do formulário: o value, as classes, os atributos. Os estáticos (as tags e o texto fixo) continuam no navegador. Segundo, a lista de erros do <.input> é um :for, e um :for vem como "k", uma comprehension com chave: cada item numerado, mais "kc", a quantidade de itens. No validar com 95, "kc": 1 e o item traz must be between 50 and 90; no definir com 72, "kc": 0, a lista ficou vazia e o erro sumiu. O Target: 72°F chega como um trecho novo, com os estáticos dele em "p".
Começando com os geradores
Na documentação: Getting Started
O Phoenix tem geradores de código para LiveView. O guia sugere:
$ mix phx.gen.live Blog Post posts title:string body:text
Ele gera um CRUD completo em LiveView, parecido com o do mix phx.gen.html do notebook 08. Num projeto 1.8.16 sem autenticação, a saída é:
* creating lib/hello_web/live/post_live/show.ex
* creating lib/hello_web/live/post_live/index.ex
* creating lib/hello_web/live/post_live/form.ex
* creating test/hello_web/live/post_live_test.exs
* creating lib/hello/blog/post.ex
* creating priv/repo/migrations/20261111031035_create_posts.exs
* creating lib/hello/blog.ex
* injecting lib/hello/blog.ex
* creating test/hello/blog_test.exs
* injecting test/hello/blog_test.exs
* creating test/support/fixtures/blog_fixtures.ex
* injecting test/support/fixtures/blog_fixtures.ex
Add the live routes to your browser scope in lib/hello_web/router.ex:
live "/posts", PostLive.Index, :index
live "/posts/new", PostLive.Form, :new
live "/posts/:id", PostLive.Show, :show
live "/posts/:id/edit", PostLive.Form, :edit
O contexto Blog e o schema são iguais aos do phx.gen.html. No lugar do controller e dos templates, vêm três LiveViews: a lista, a página de um post e o formulário, que serve para criar e editar. O notebook 17 estuda esse código: streams, navegação entre LiveViews e formulários com validação ao digitar.
Para aprender mais, a documentação do Phoenix LiveView tem guias para cada assunto deste notebook.
Sem roteiro: a vitrine do Brechó ao vivo
Na vitrine do Brechó, duas pessoas podem estar olhando a mesma peça. Quando uma compra, a peça deveria sumir da tela da outra na hora, sem ela recarregar a página.
O Brechó até aqui
O contexto Hello.Vitrine do notebook 08 (o ProdutoController de lá não é necessário aqui): a tabela produtos, o schema e o contexto, com vender/1, que só vende uma peça que ainda não foi vendida.
priv/repo/migrations/20250320000000_create_produtos.exs (a do notebook 07)
defmodule Hello.Repo.Migrations.CreateProdutos do
use Ecto.Migration
def change do
create table(:produtos) do
add :nome, :string, null: false
add :preco_centavos, :integer, null: false
add :vendido, :boolean, default: false, null: false
timestamps(type: :utc_datetime)
end
end
end
Trilha.migracao(20250320000000, Hello.Repo.Migrations.CreateProdutos)
Ecto.Migrator.run(Hello.Repo, Trilha.migracoes(), :up, all: true)
lib/hello/vitrine/produto.ex e lib/hello/vitrine.ex
defmodule Hello.Vitrine.Produto do
use Ecto.Schema
import Ecto.Changeset
schema "produtos" do
field :nome, :string
field :preco_centavos, :integer
field :vendido, :boolean, default: false
timestamps(type: :utc_datetime)
end
@doc false
def changeset(produto, attrs) do
produto
|> cast(attrs, [:nome, :preco_centavos])
|> validate_required([:nome, :preco_centavos])
|> validate_number(:preco_centavos, greater_than: 0)
end
end
defmodule Hello.Vitrine do
@moduledoc """
A vitrine do Brechó: o que está à venda e a venda de cada peça.
"""
import Ecto.Query, warn: false
alias Hello.Repo
alias Hello.Vitrine.Produto
def listar_disponiveis do
Repo.all(from p in Produto, where: not p.vendido, order_by: p.preco_centavos)
end
def buscar_produto!(id), do: Repo.get!(Produto, id)
def cadastrar_produto(attrs) do
%Produto{}
|> Produto.changeset(attrs)
|> Repo.insert()
end
def change_produto(%Produto{} = produto, attrs \\ %{}) do
Produto.changeset(produto, attrs)
end
@doc """
Marca a peça como vendida, se ninguém a vendeu antes.
A verificação e a mudança acontecem num só UPDATE, então duas vendas
simultâneas da mesma peça nunca dão certo ao mesmo tempo.
"""
def vender(%Produto{id: id}) do
consulta = from p in Produto, where: p.id == ^id and not p.vendido, select: p
case Repo.update_all(consulta, set: [vendido: true, updated_at: DateTime.utc_now(:second)]) do
{1, [produto]} -> {:ok, produto}
{0, []} -> {:error, :ja_vendido}
end
end
end
Algumas peças à venda:
for {nome, preco} <- [{"Jaqueta jeans", 8000}, {"Vestido floral", 4500}, {"Bota de couro", 12000}] do
{:ok, _} = Hello.Vitrine.cadastrar_produto(%{nome: nome, preco_centavos: preco})
end
Hello.Vitrine.listar_disponiveis() |> Enum.map(& &1.nome)
O desafio
Escreva a LiveView HelloWeb.VitrineLive, em /vitrine:
- a rota
live "/vitrine", VitrineLive, na célula do router lá em cima; - a página lista
Vitrine.listar_disponiveis()num assign:produtos, com nome, preço e um botãophx-click="comprar"comphx-value-idda peça; handle_event("comprar", %{"id" => id}, socket)chamaVitrine.vender/1. Se der certo, publica{:vendido, id}no tópico"vitrine"e mostra uma flash:infocom o nome da peça. Se a peça já tiver sido vendida, mostra a flash:errorEsta peça já foi vendida.;- toda vitrine conectada assina o tópico
"vitrine"e, ao receber{:vendido, id}, tira a peça de:produtos.
Use <Layouts.app flash={@flash}> em volta do conteúdo, para as flashes aparecerem. Teste com duas abas do navegador lado a lado.
lib/hello_web/live/vitrine_live.ex
defmodule HelloWeb.VitrineLive do
use HelloWeb, :live_view
alias Hello.Vitrine
# Sua vez: a vitrine ao vivo. Liste as peças disponíveis, cada uma com um botão
# "Comprar", e avise as outras vitrines abertas quando uma peça for vendida.
def render(assigns) do
~H"""
<Layouts.app flash={@flash}>
<.header>Vitrine do Brechó</.header>
</Layouts.app>
"""
end
def mount(_params, _session, socket) do
{:ok, assign(socket, :produtos, Vitrine.listar_disponiveis())}
end
end
Para experimentar com o navegador simulado, depois de escrever a LiveView:
{ana, _} = Trilha.abrir_lv("/vitrine", como: "ana")
{bia, _} = Trilha.abrir_lv("/vitrine", como: "bia")
[primeira | _] = Hello.Vitrine.listar_disponiveis()
{ana, resposta_ana} = Trilha.clicar(ana, "comprar", %{"id" => to_string(primeira.id)})
{bia, empurrado_bia} = Trilha.receber(bia)
{bia, resposta_bia} = Trilha.clicar(bia, "comprar", %{"id" => to_string(primeira.id)})
Trilha.desconectar(ana)
Trilha.desconectar(bia)
Trilha.json(%{"1. ana compra" => resposta_ana, "2. empurrado para a bia" => empurrado_bia, "3. bia tenta comprar" => resposta_bia})
A resposta ao clique da ana traz a flash. O que a bia recebe sem clicar é o :for da lista de novo, com as peças que sobraram ("kc" diminuiu um). E a tentativa da bia volta com a flash de erro. Sem a rota /vitrine, a célula mostra NoRouteError.
Trilha.solucao(:brecho)
Trilha.conferir(:brecho)
A última conferência põe cinco compradores clicando ao mesmo tempo na mesma peça. Só um consegue porque Vitrine.vender/1 confere e marca a peça num único UPDATE, como no notebook 08: cada LiveView é um processo, e cinco processos podem chegar ao banco no mesmo instante.
No seu projeto
- Um projeto gerado por
mix phx.newjá tem tudo ligado: osocket "/live"no endpoint, o blocolive_viewemlib/my_app_web.exe oLiveSocketemassets/js/app.js. - Crie
lib/my_app_web/live/thermostat_live.excom o código do guia e acrescentelive "/thermostat", ThermostatLiveao scope"/"do router. - Rode
mix phx.servere abra http://localhost:4000/thermostat. Nas ferramentas do navegador, aba Network, filtro WS, veja as mensagens a cada clique. - Para ver os processos e o estado no servidor, abra o LiveDashboard em http://localhost:4000/dev/dashboard, aba Processes: cada página aberta aparece como um processo com o nome da LiveView. Ou rode o projeto com
iex -S mix phx.servere chame:observer.start(). - Experimente o gerador:
mix phx.gen.live Blog Post posts title:string body:text, acrescente as rotas que ele imprime e rodemix ecto.migrate.
Duas diferenças entre o notebook e o projeto. No projeto, o signing_salt do LiveView fica em config/config.exs, na configuração do endpoint; aqui ele entra na célula que inicia a aplicação. E o JavaScript do projeto é compilado pelo esbuild a partir de assets/js/app.js; aqui, o root layout carrega os arquivos prontos dos pacotes.
Revisão
- Uma LiveView é um processo que guarda o estado nos assigns do socket, recebe eventos e mensagens, e manda ao navegador só o que mudou.
- A primeira renderização é uma requisição HTTP comum, com o HTML completo. Depois, o JavaScript abre um WebSocket e faz o join; a resposta separa estáticos, mandados uma vez, de dinâmicos, numerados pela posição.
mount/3roda duas vezes, a primeira sem conexão. O que só faz sentido conectado vai emif connected?(socket).handle_event/3trata cliques, formulários e outros bindings (phx-click,phx-value-*,phx-change,phx-submit,phx-blur);handle_params/3trata a URL, inclusive nos live patches;handle_info/2trata mensagens, como as do PubSub, e pode atualizar todas as páginas abertas de uma vez.- Cada aba é um processo com o seu estado. Operações que dependem de exclusividade, como vender a última peça, precisam de garantia no banco, não na LiveView.
No próximo notebook, o código que o mix phx.gen.live gera: streams, navegação e formulários.