Trilha Phoenix · O gerador phx.gen.live
Mix.install(
[
{:phoenix, "~> 1.8.0"},
{:phoenix_live_view, "~> 1.1"},
{:phoenix_ecto, "~> 4.6"},
{:ecto_sql, "~> 3.13"},
{:postgrex, ">= 0.0.0"},
{:bandit, "~> 1.5"},
{:jason, "~> 1.4"},
{:kino, "~> 0.14"},
{:swoosh, "~> 1.16"},
{:pbkdf2_elixir, "~> 2.0"}
],
consolidate_protocols: false,
config: [
phoenix: [json_library: Jason, plug_init_mode: :runtime],
swoosh: [api_client: false],
hello: [
{HelloWeb.Endpoint,
[
adapter: Bandit.PhoenixAdapter,
http: [ip: {127, 0, 0, 1}, port: 4010],
server: true,
debug_errors: true,
secret_key_base: String.duplicate("trilha-phoenix-", 5),
render_errors: [formats: [html: HelloWeb.ErrorHTML, json: HelloWeb.ErrorJSON], layout: false],
pubsub_server: Hello.PubSub
]},
{Hello.Mailer, [adapter: Swoosh.Adapters.Local]}
]
]
)
defmodule Trilha do
@moduledoc """
Apoio deste notebook: verifica a conexão com o PostgreSQL, registra migrations,
faz requisições ao endpoint como um navegador faria, conversa com as LiveViews
pelo WebSocket como o cliente JavaScript, confere os exercícios e guarda as
perguntas e as soluções.
Não faz parte do Phoenix nem do Ecto. Você não precisa ler este módulo para seguir o guia.
"""
@endpoint HelloWeb.Endpoint
@router HelloWeb.Router
@repo Hello.Repo
# --- banco de dados ------------------------------------------------------------
@doc "Tenta uma conexão com o PostgreSQL usando a configuração do Hello.Repo."
def verificar_postgres(extra \\ []) do
config = Keyword.merge(apply(@repo, :config, []), extra)
onde = "`#{config[:username]}@#{config[:hostname]}:#{config[:port] || 5432}`"
# Conecta ao banco "postgres", que sempre existe: o banco da trilha talvez ainda não tenha sido criado.
resultado =
try do
Postgrex.Protocol.connect(Postgrex.Utils.default_opts(Keyword.put(config, :database, "postgres")))
rescue
erro -> {:error, erro}
end
case resultado do
{:ok, estado} ->
Postgrex.Protocol.disconnect(%RuntimeError{message: "fim da verificação"}, estado)
Kino.Markdown.new("**Conectou** ao PostgreSQL em #{onde}. Pode seguir.")
{:error, erro} ->
Kino.Markdown.new(
"**Não conectou** ao PostgreSQL em #{onde}. A mensagem do Postgrex:\n\n" <>
cerca(Exception.message(erro), "") <> "\n\n" <> dica_conexao(erro)
)
end
end
defp dica_conexao(%Postgrex.Error{postgres: %{pg_code: "28P01"}}) do
"**Senha recusada.** Confira `username` e `password` na célula `config/dev.exs`. " <>
"Com autenticação por senha, o PostgreSQL dá esta mesma resposta quando o usuário não existe."
end
defp dica_conexao(%Postgrex.Error{postgres: %{pg_code: "28000", message: mensagem}}) do
cond do
mensagem =~ ~r/ident|peer/i ->
"**O servidor não aceita senha para esta conexão.** Ele está configurado para autenticação `ident` ou `peer`, " <>
"o padrão de algumas distribuições Linux, como o Fedora. O `LEIA-ME.md` da trilha mostra como trocar para senha no `pg_hba.conf`."
mensagem =~ "does not exist" ->
"**O usuário não existe no PostgreSQL.** Crie-o no `psql` ou troque `username` na célula `config/dev.exs`."
true ->
"**O servidor recusou a conexão.** Leia a mensagem acima e confira o `pg_hba.conf` do servidor."
end
end
defp dica_conexao(%DBConnection.ConnectionError{message: mensagem}) do
cond do
mensagem =~ "econnrefused" ->
"**Nada responde nesse endereço.** O servidor PostgreSQL está parado, ou escuta em outra porta. O `LEIA-ME.md` mostra como iniciá-lo."
mensagem =~ "nxdomain" ->
"**O nome do host não existe.** Confira `hostname` na célula `config/dev.exs`."
true ->
"**A conexão falhou antes de chegar ao PostgreSQL.** Confira `hostname` e `port` na célula `config/dev.exs`."
end
end
defp dica_conexao(_erro), do: "Confira a configuração na célula `config/dev.exs`."
@doc "Apaga todas as tabelas do banco da trilha, inclusive schema_migrations, e esquece os visitantes."
def recomecar do
sql("DROP SCHEMA public CASCADE")
sql("CREATE SCHEMA public")
esquecer_visitantes()
Kino.Markdown.new(
"O banco `#{apply(@repo, :config, [])[:database]}` está vazio: nenhuma tabela, nenhuma migration registrada."
)
end
@doc "Registra uma migration com a versão que, no projeto, viria do nome do arquivo."
def migracao(versao, modulo) do
:persistent_term.put({__MODULE__, :migracoes}, Map.put(mapa_migracoes(), versao, modulo))
nome = modulo |> Module.split() |> List.last() |> Macro.underscore()
Kino.Markdown.new("No projeto, esta migration seria o arquivo `priv/repo/migrations/#{versao}_#{nome}.exs`.")
end
@doc "As migrations registradas, como pares {versão, módulo}, em ordem de versão."
def migracoes, do: mapa_migracoes() |> Enum.sort()
defp mapa_migracoes, do: :persistent_term.get({__MODULE__, :migracoes}, %{})
@doc "Lista as tabelas e sequências do banco, como o comando \\d do psql."
def relacoes do
resultado =
sql("""
SELECT n.nspname AS "Schema", c.relname AS "Name",
CASE c.relkind WHEN 'r' THEN 'table' WHEN 'S' THEN 'sequence' WHEN 'v' THEN 'view' END AS "Type",
pg_get_userbyid(c.relowner) AS "Owner"
FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace
WHERE n.nspname = 'public' AND c.relkind IN ('r', 'S', 'v')
ORDER BY c.relname
""")
if resultado.rows == [] do
Kino.Markdown.new("`\\d`: o banco não tem nenhuma tabela.")
else
Kino.Markdown.new("**`\\d`**\n\n" <> tabela_markdown(resultado))
end
end
@doc "Mostra as colunas, os índices e as chaves estrangeiras de uma tabela, como o comando \\d TABELA do psql."
def descrever(tabela) do
colunas =
sql(
"""
SELECT a.attname AS "Column", format_type(a.atttypid, a.atttypmod) AS "Type",
CASE WHEN a.attnotnull THEN 'not null' ELSE '' END AS "Nullable",
coalesce(pg_get_expr(d.adbin, d.adrelid), '') AS "Default"
FROM pg_attribute a
LEFT JOIN pg_attrdef d ON d.adrelid = a.attrelid AND d.adnum = a.attnum
WHERE a.attrelid = to_regclass($1) AND a.attnum > 0 AND NOT a.attisdropped
ORDER BY a.attnum
""",
[tabela]
)
if colunas.rows == [] do
Kino.Markdown.new("`\\d #{tabela}`: a tabela `#{tabela}` não existe.")
else
indices = sql("SELECT indexdef FROM pg_indexes WHERE schemaname = 'public' AND tablename = $1 ORDER BY indexname", [tabela])
lista = Enum.map_join(indices.rows, "\n", fn [definicao] -> "* `#{definicao}`" end)
chaves =
sql(
"""
SELECT conname, pg_get_constraintdef(oid) FROM pg_constraint
WHERE conrelid = to_regclass($1) AND contype = 'f' ORDER BY conname
""",
[tabela]
)
fks =
if chaves.rows == [],
do: "",
else: "\n\n**Chaves estrangeiras**\n\n" <> Enum.map_join(chaves.rows, "\n", fn [nome, definicao] -> "* `#{nome}` #{definicao}" end)
Kino.Markdown.new(
"**`\\d #{tabela}`**\n\n" <>
tabela_markdown(colunas) <> "\n\n**Índices**\n\n" <> if(lista == "", do: "nenhum", else: lista) <> fks
)
end
end
@doc "Executa um SQL e mostra o resultado como tabela."
def consultar(consulta, parametros \\ []) do
resultado = sql(consulta, parametros)
Kino.Markdown.new(cerca(consulta, "sql") <> "\n\n" <> tabela_markdown(resultado))
end
defp sql(consulta, parametros \\ []) do
apply(@repo, :query!, [consulta, parametros, [log: false]])
end
defp tabela_markdown(%{columns: colunas, rows: linhas}) do
cabecalho = "| " <> Enum.join(colunas, " | ") <> " |"
separador = "| " <> Enum.map_join(colunas, " | ", fn _ -> "---" end) <> " |"
corpo =
Enum.map(linhas, fn linha ->
"| " <> Enum.map_join(linha, " | ", &celula/1) <> " |"
end)
Enum.join([cabecalho, separador | corpo], "\n")
end
defp celula(nil), do: ""
defp celula(""), do: ""
defp celula(valor) when is_binary(valor), do: "`" <> String.replace(valor, "|", "\\|") <> "`"
defp celula(%Decimal{} = valor), do: "`" <> Decimal.to_string(valor) <> "`"
defp celula(valor), do: "`" <> (valor |> inspect() |> String.replace("|", "\\|")) <> "`"
# --- requisições -----------------------------------------------------------
@doc "Despacha uma requisição para o endpoint, sem passar pela rede."
def requisicao(metodo \\ :get, caminho, parametros \\ nil, conn \\ Phoenix.ConnTest.build_conn()) do
descartar_respostas_antigas()
conn = Phoenix.ConnTest.dispatch(conn, @endpoint, metodo, caminho, parametros)
# Para NoRouteError (e só para ela), o Phoenix envia a página de erro e
# devolve o conn sem relançar a exceção. Reconstruímos a exceção aqui.
if conn.state == :sent and conn.status == nil do
{:erro, Phoenix.Router.NoRouteError.exception(conn: conn, router: @router)}
else
{:ok, conn}
end
rescue
erro -> {:erro, desembrulhar(erro)}
end
# Cada resposta enviada pelo adaptador de testes deixa duas mensagens no
# processo que fez a requisição. Se ficarem acumuladas, o Phoenix acha que a
# próxima página de erro já foi enviada e não a renderiza.
defp descartar_respostas_antigas do
receive do
{ref, {status, cabecalhos, _corpo}} when is_reference(ref) and is_integer(status) and is_list(cabecalhos) ->
descartar_respostas_antigas()
{:plug_conn, :sent} ->
descartar_respostas_antigas()
after
0 -> :ok
end
end
defp desembrulhar(%Plug.Conn.WrapperError{reason: motivo}), do: desembrulhar(motivo)
defp desembrulhar(erro), do: erro
@doc "Faz a requisição e mostra o status, o content-type e o corpo da resposta."
def mostrar(metodo \\ :get, caminho, parametros \\ nil) do
titulo = "### #{metodo |> to_string() |> String.upcase()} #{caminho}"
corpo =
case requisicao(metodo, caminho, parametros) do
{:ok, conn} ->
tipo = conn |> Plug.Conn.get_resp_header("content-type") |> Enum.join()
"**Status** `#{conn.status}` · **content-type** `#{tipo}`\n\n" <>
cerca(to_string(conn.resp_body), if(tipo =~ "json", do: "json", else: "html"))
{:erro, erro} ->
"**A requisição levantou uma exceção:**\n\n" <> cerca(Exception.format_banner(:error, erro), "")
end
Kino.Markdown.new(titulo <> "\n\n" <> corpo)
end
# --- navegador ---------------------------------------------------------------
@doc """
Faz uma requisição como um navegador: envia os cookies que o visitante `quem`
recebeu antes (a sessão, onde ficam a flash e o login) e segue os redirects.
`quem` é um nome qualquer, como "ana"; cada nome tem os seus próprios cookies.
Devolve `{:ok, conn}` com a última resposta, ou `{:erro, exceção}`.
"""
def visitar(quem, metodo, caminho, parametros \\ nil) do
quem |> seguir(metodo, caminho, parametros, []) |> List.last() |> elem(1)
end
@doc "Como `visitar/4`, mas mostra cada passo, a flash e o texto da página final."
def navegar(quem, metodo \\ :get, caminho, parametros \\ nil) do
passos = seguir(quem, metodo, caminho, parametros, [])
linhas =
passos
|> Enum.with_index(1)
|> Enum.map(fn {{requisicao, resultado}, i} -> linha_passo(i, requisicao, resultado) end)
cabecalho = [
"### #{quem}: #{rotulo(metodo, caminho)}",
"",
"| # | Requisição | Status | Redireciona para |",
"| --- | --- | --- | --- |"
]
Kino.Markdown.new(Enum.join(cabecalho ++ linhas, "\n") <> "\n\n" <> resumo_final(List.last(passos)))
end
@doc "Esquece os cookies de todos os visitantes, como se cada um abrisse um navegador novo."
def esquecer_visitantes do
for {{__MODULE__, :cookies, _} = chave, _} <- :persistent_term.get(), do: :persistent_term.erase(chave)
:ok
end
defp seguir(quem, metodo, caminho, parametros, feitos) do
conn = Enum.reduce(cookies(quem), Phoenix.ConnTest.build_conn(), fn {nome, valor}, c -> Plug.Test.put_req_cookie(c, nome, valor) end)
resultado = requisicao(metodo, caminho, parametros, conn)
feitos = feitos ++ [{rotulo(metodo, caminho), resultado}]
with {:ok, resp} <- resultado do
guardar_cookies(quem, resp)
end
with {:ok, resp} <- resultado,
true <- resp.status in 300..399 and length(feitos) < 6,
[destino] <- Plug.Conn.get_resp_header(resp, "location"),
true <- String.starts_with?(destino, "/") do
seguir(quem, :get, destino, nil, feitos)
else
_ -> feitos
end
end
defp cookies(quem), do: :persistent_term.get({__MODULE__, :cookies, quem}, %{})
defp guardar_cookies(quem, resp) do
novos =
Enum.reduce(resp.resp_cookies, cookies(quem), fn {nome, opcoes}, acc ->
if opcoes[:max_age] == 0, do: Map.delete(acc, nome), else: Map.put(acc, nome, opcoes[:value])
end)
:persistent_term.put({__MODULE__, :cookies, quem}, novos)
end
defp rotulo(metodo, caminho), do: "#{metodo |> to_string() |> String.upcase()} #{caminho}"
defp linha_passo(i, requisicao, {:ok, conn}) do
destino = conn |> Plug.Conn.get_resp_header("location") |> List.first("")
"| #{i} | `#{requisicao}` | `#{conn.status}` | #{if destino == "", do: "", else: "`#{destino}`"} |"
end
defp linha_passo(i, requisicao, {:erro, erro}) do
"| #{i} | `#{requisicao}` | exceção: `#{erro.__struct__ |> inspect()}` | |"
end
defp resumo_final({_requisicao, {:ok, conn}}) do
flash =
case conn.assigns[:flash] do
mapa when is_map(mapa) and map_size(mapa) > 0 ->
"**Flash:** " <> Enum.map_join(mapa, " · ", fn {tipo, msg} -> "#{tipo}: \"#{msg}\"" end) <> "\n\n"
_ ->
""
end
flash <> "**Texto da página** (sem as tags):\n\n" <> cerca(texto(to_string(conn.resp_body)), "")
end
defp resumo_final({_requisicao, {:erro, erro}}) do
"**A requisição levantou uma exceção:**\n\n" <> cerca(Exception.format_banner(:error, erro), "")
end
@doc "O texto de uma página HTML, sem as tags: o que se lê no navegador."
def texto(html) do
conteudo =
case Regex.run(~r{<main[^>]*>(.*)</main>}s, html) do
[_, dentro] -> dentro
nil -> html
end
conteudo
|> String.replace(~r{<(script|style|head)[^>]*>.*?</\1>}s, "")
|> String.replace(~r{<input[^>]*type="hidden"[^>]*>}, "")
|> String.replace(~r{<option[^>]*\sselected[^>]*>}, "[x] ")
|> String.replace(~r{<option[^>]*>}, "[ ] ")
|> then(&Regex.replace(~r{<input[^>]*\svalue="([^"]*)"[^>]*>}, &1, fn _, valor -> if valor == "", do: " ", else: " [" <> valor <> "] " end))
|> String.replace(~r{</?(p|div|li|tr|h\d|header|footer|ul|table|thead|tbody|form|label|select|option|section|br)[^>]*>}, "\n")
|> String.replace(~r{</t[dh]>}, " ")
|> String.replace(~r{<[^>]+>}, "")
|> String.replace(["&", "<", ">", """, "'", " "], 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),
depois uma conexão WebSocket em /live/websocket e o "phx_join" no tópico da
LiveView. Devolve `{lv, resposta_do_join}`; `lv` é a conexão, para usar com as
outras funções desta seção. `como:` escolhe o visitante (os cookies dele).
"""
def abrir_lv(caminho, opcoes \\ []) do
porta = apply(@endpoint, :config, [:http])[:port]
quem = Keyword.get(opcoes, :como, "lv-#{System.unique_integer([:positive])}")
# 1. A renderização estática, por HTTP, com os cookies do visitante.
conn =
case visitar(quem, :get, caminho) do
{:ok, conn} -> conn
{:erro, erro} -> raise "o GET #{caminho} falhou antes de chegar a uma LiveView: " <> Exception.message(erro)
end
html = to_string(conn.resp_body)
csrf = atributo_html(html, ~r/<meta name="csrf-token" content="([^"]+)"/)
id =
atributo_html(html, ~r/<div[^>]*\sid="(phx-[^"]+)"[^>]*data-phx-main/) ||
atributo_html(html, ~r/<div[^>]*data-phx-main[^>]*\sid="(phx-[^"]+)"/)
sessao = atributo_html(html, ~r/data-phx-session="([^"]+)"/)
estatico = atributo_html(html, ~r/data-phx-static="([^"]*)"/)
if is_nil(id) or is_nil(sessao),
do: raise("a página #{caminho} não tem uma LiveView (status #{conn.status}, caminho final #{conn.request_path})")
# 2. O WebSocket, com o cookie da sessão (o LiveView confere o CSRF contra ele).
cookie = Enum.map_join(cookies(quem), "; ", fn {nome, valor} -> "#{nome}=#{valor}" end)
{:ok, socket} = :gen_tcp.connect(~c"127.0.0.1", porta, [:binary, active: false])
chave = Base.encode64(:crypto.strong_rand_bytes(16))
pedido =
"GET /live/websocket?_csrf_token=#{URI.encode_www_form(csrf)}&vsn=2.0.0 HTTP/1.1\r\n" <>
"Host: localhost:#{porta}\r\nUpgrade: websocket\r\nConnection: Upgrade\r\n" <>
"Sec-WebSocket-Key: #{chave}\r\nSec-WebSocket-Version: 13\r\nCookie: #{cookie}\r\n\r\n"
:ok = :gen_tcp.send(socket, pedido)
{:ok, resposta} = :gen_tcp.recv(socket, 0, 5000)
[cabeca, resto] = String.split(resposta, "\r\n\r\n", parts: 2)
unless cabeca =~ "101", do: raise("o servidor recusou o WebSocket:\n" <> cabeca)
lv = %{
socket: socket,
buffer: resto,
topico: "lv:" <> id,
join_ref: nil,
ref: 1,
quem: quem,
porta: porta,
csrf: csrf,
sessao: sessao,
estatico: estatico,
caminho: caminho
}
# 3. O join no tópico da LiveView.
entrar_no_topico(lv, %{"url" => url_completa(lv, caminho)})
end
@doc "Envia um evento de clique (phx-click) e devolve {lv, resposta}."
def clicar(lv, evento, valor \\ %{}), do: enviar_evento(lv, "click", evento, valor)
@doc "Envia um evento qualquer e devolve {lv, resposta}."
def enviar_evento(lv, tipo, evento, valor, meta \\ nil) do
ref = Integer.to_string(lv.ref)
payload = %{"type" => tipo, "event" => evento, "value" => valor}
payload = if meta, do: Map.put(payload, "meta", meta), else: payload
enviar_quadro(lv, [lv.join_ref, ref, lv.topico, "event", payload])
esperar_resposta(%{lv | ref: lv.ref + 1}, ref)
end
@doc """
Simula a digitação num formulário com `phx-change`, como o cliente JS faz:
`campos` é uma lista de `{nome, valor}` com todos os campos do formulário;
`tocados` são os nomes dos campos em que a pessoa já mexeu. Os outros vão com
o prefixo `_unused_`, que é o que faz `used_input?/1` esconder os erros deles.
O último campo tocado vai em `meta._target`.
"""
def digitar(lv, evento, campos, tocados) do
valor =
Enum.flat_map(campos, fn {nome, v} ->
if nome in tocados, do: [{nome, v}], else: [{prefixar_unused(nome), ""}, {nome, v}]
end)
|> URI.encode_query()
enviar_evento(lv, "form", evento, valor, %{"_target" => List.last(tocados) || "undefined"})
end
@doc "Simula o envio de um formulário com `phx-submit`. Devolve {lv, resposta}."
def submeter(lv, evento, campos) do
enviar_evento(lv, "form", evento, URI.encode_query(campos), %{})
end
# "post[title]" vira "post[_unused_title]", como o prependFormDataKey do cliente JS.
defp prefixar_unused(nome) do
case Regex.run(~r/^(.*)\[([^\[\]]+)\]$/, nome) do
[_, base, campo] -> "#{base}[_unused_#{campo}]"
nil -> "_unused_" <> nome
end
end
@doc """
Navega para outra LiveView sem recarregar a página, como `<.link navigate={...}>`
ou `JS.navigate/1`: sai do tópico atual e faz um novo join com `redirect`.
"""
def navegar_lv(lv, caminho, flash \\ nil) do
ref = Integer.to_string(lv.ref)
enviar_quadro(lv, [lv.join_ref, ref, lv.topico, "phx_leave", %{}])
{lv, _} = esperar_resposta(%{lv | ref: lv.ref + 1}, ref)
payload = %{"redirect" => url_completa(lv, caminho)}
payload = if flash, do: Map.put(payload, "flash", flash), else: payload
entrar_no_topico(%{lv | caminho: caminho}, payload)
end
@doc "Muda a URL na mesma LiveView, como `<.link patch={...}>`: dispara `handle_params/3`."
def patch_lv(lv, caminho) do
ref = Integer.to_string(lv.ref)
enviar_quadro(lv, [lv.join_ref, ref, lv.topico, "live_patch", %{"url" => url_completa(lv, caminho)}])
esperar_resposta(%{lv | ref: lv.ref + 1, caminho: caminho}, ref)
end
@doc """
Segue um redirecionamento que veio numa resposta do servidor, como o cliente JS
faz: `live_redirect` (de `push_navigate`) vira um novo join na mesma conexão,
com a flash; `live_patch` (de `push_patch`) só troca a URL.
"""
def seguir_lv(lv, %{"live_redirect" => %{"to" => para} = opcoes}), do: navegar_lv(lv, para, opcoes["flash"])
def seguir_lv(lv, %{"live_patch" => %{"to" => para}}), do: {%{lv | caminho: para}, %{"live_patch" => para}}
def seguir_lv(lv, resposta), do: {lv, resposta}
@doc "Espera uma mensagem empurrada pelo servidor (um diff que não veio de evento do cliente)."
def receber(lv, tempo \\ 1000) do
case ler_mensagem(lv, tempo) do
{lv, [_, nil, _topico, "diff", diff]} -> {lv, diff}
{lv, nil} -> {lv, :nada}
{lv, outra} -> {lv, outra}
end
end
@doc "Fecha a conexão: o processo da LiveView no servidor termina."
def desconectar(lv) do
:gen_tcp.close(lv.socket)
:ok
end
@doc "Mostra uma mensagem do fio como JSON, como aparece nas ferramentas do navegador."
def json(valor), do: Kino.Markdown.new(cerca(Jason.encode!(valor, pretty: true), "json"))
@doc "Os textos que aparecem numa mensagem do fio (útil para procurar uma mensagem de erro)."
def textos(valor) when is_map(valor), do: valor |> Map.values() |> Enum.flat_map(&textos/1)
def textos(valor) when is_list(valor), do: Enum.flat_map(valor, &textos/1)
def textos(valor) when is_binary(valor), do: [valor]
def textos(_), do: []
defp entrar_no_topico(lv, extra) do
ref = Integer.to_string(lv.ref)
payload =
Map.merge(
%{"params" => %{"_csrf_token" => lv.csrf, "_mounts" => 0}, "session" => lv.sessao, "static" => lv.estatico},
extra
)
lv = %{lv | join_ref: ref, ref: lv.ref + 1}
enviar_quadro(lv, [ref, ref, lv.topico, "phx_join", payload])
esperar_resposta(lv, ref)
end
defp url_completa(lv, caminho), do: "http://localhost:#{lv.porta}#{caminho}"
defp esperar_resposta(lv, ref) do
case ler_mensagem(lv, 5000) do
{lv, [_, ^ref, _, "phx_reply", %{"status" => "ok", "response" => resposta}]} -> {lv, resposta}
{lv, [_, ^ref, _, "phx_reply", %{"status" => "error", "response" => resposta}]} -> {lv, %{"erro" => resposta}}
{_lv, [_, _, _, "phx_error", _]} -> raise "o processo da LiveView caiu: o servidor mandou phx_error no tópico. O motivo está no log do servidor."
{_lv, nil} -> raise "o servidor não respondeu (o processo da LiveView caiu? veja o log acima)"
{lv, _outra} -> esperar_resposta(lv, ref)
end
end
defp enviar_quadro(lv, mensagem) do
dados = Jason.encode!(mensagem)
mascara = :crypto.strong_rand_bytes(4)
tamanho = byte_size(dados)
cabeca =
cond do
tamanho < 126 -> <<1::1, 0::3, 1::4, 1::1, tamanho::7>>
tamanho < 65_536 -> <<1::1, 0::3, 1::4, 1::1, 126::7, tamanho::16>>
true -> <<1::1, 0::3, 1::4, 1::1, 127::7, tamanho::64>>
end
:ok = :gen_tcp.send(lv.socket, [cabeca, mascara, mascarar(dados, mascara)])
end
defp mascarar(dados, mascara) do
tamanho = byte_size(dados)
repetida = :binary.copy(mascara, div(tamanho, 4) + 1) |> binary_part(0, tamanho)
:crypto.exor(dados, repetida)
end
# Lê um quadro de texto do servidor (sem máscara) e decodifica o JSON.
defp ler_mensagem(lv, tempo) do
case quadro(lv.buffer) do
{:ok, texto, resto} ->
{%{lv | buffer: resto}, Jason.decode!(texto)}
:incompleto ->
case :gen_tcp.recv(lv.socket, 0, tempo) do
{:ok, dados} -> ler_mensagem(%{lv | buffer: lv.buffer <> dados}, tempo)
{:error, :timeout} -> {lv, nil}
{:error, :closed} -> {lv, nil}
end
end
end
defp quadro(<<_fin::1, _::3, _op::4, 0::1, 126::7, n::16, dados::binary-size(n), resto::binary>>), do: {:ok, dados, resto}
defp quadro(<<_fin::1, _::3, _op::4, 0::1, 127::7, n::64, dados::binary-size(n), resto::binary>>), do: {:ok, dados, resto}
defp quadro(<<_fin::1, _::3, _op::4, 0::1, n::7, dados::binary-size(n), resto::binary>>) when n < 126, do: {:ok, dados, resto}
defp quadro(_), do: :incompleto
defp atributo_html(html, regex) do
case Regex.run(regex, html) do
[_, valor] -> valor |> String.replace(""", "\"") |> String.replace("&", "&")
nil -> nil
end
end
@doc """
Um resumo de uma mensagem do fio: o tamanho do JSON, o título da página, as
operações de stream (o que entra e o que sai da lista) e os textos que mudaram.
A mensagem completa continua disponível com `Trilha.json/1`.
"""
def resumo_lv(resposta) do
linhas =
cond do
redir = resposta["live_redirect"] ->
["**`live_redirect`** para `#{redir["to"]}` (`kind: #{redir["kind"]}`)" <>
if(redir["flash"], do: ", com a flash assinada num token", else: "") <>
". Nenhum HTML veio junto: o cliente faz um novo join, na mesma conexão."]
redir = resposta["redirect"] ->
["**`redirect`** para `#{redir["to"]}`: o cliente faz uma requisição HTTP nova (a página recarrega)."]
erro = resposta["erro"] ->
["**O servidor recusou:** `#{inspect(erro)}`"]
true ->
arvore = resposta["rendered"] || resposta["diff"] || resposta
tipo = if resposta["rendered"], do: "renderização completa (join)", else: "diff"
titulo = if t = arvore["t"], do: ["título da página: **#{t}**"], else: []
streams =
for {ref, ordem, deletes, reset} <- streams_do(arvore) do
entra = Enum.map_join(ordem, ", ", &"`#{&1}`")
sai = Enum.map_join(deletes, ", ", &"`#{&1}`")
reset = if reset, do: " (reset: a lista é refeita)", else: ""
"stream `#{ref}`#{reset}: " <>
Enum.join(Enum.reject(["entram #{entra}", "saem #{sai}"], &String.ends_with?(&1, " ")), "; ")
end
textos =
arvore
|> textos_dinamicos()
|> Enum.map(&String.trim/1)
|> Enum.reject(&(&1 == "" or String.starts_with?(&1, ["phx-", "<"]) or &1 =~ ~r/^\w[\w-]*=/ or classes_css?(&1)))
|> Enum.uniq()
|> Enum.take(12)
cabeca = ["**#{tipo}**, #{byte_size(Jason.encode!(resposta))} bytes de JSON"] ++ titulo
if resposta["rendered"] do
html = html_lv(resposta)
flash =
for tipo <- ["info", "error"],
[_, msg] <- [Regex.run(~r/id="flash-#{tipo}".*?<p>(.*?)<\/p>/s, html)],
do: "flash #{tipo}: **#{desescapar_html(msg)}**"
cabeca ++ flash ++ streams ++ ["texto da página, montado a partir do join:\n\n" <> cerca(texto(html), "")]
else
cabeca ++ streams ++ if(textos == [], do: [], else: ["textos que mudaram: " <> Enum.map_join(textos, " · ", &"`#{desescapar_html(&1)}`")])
end
end
Kino.Markdown.new(Enum.map_join(linhas, "\n", &("* " <> &1)))
end
@doc """
Monta o HTML de uma resposta de join, como o cliente JS faz: cada nó tem os
estáticos (`"s"`, uma lista ou um índice nos compartilhados `"p"`) e os
dinâmicos nas chaves numéricas; compreensões (`"k"`) repetem os estáticos para
cada item. Serve para joins, que trazem a árvore inteira; um diff só traz o
que mudou.
"""
def html_lv(%{"rendered" => arvore}), do: montar_no(arvore, arvore["p"] || %{})
defp montar_no(texto, _p) when is_binary(texto), do: texto
defp montar_no(%{"s" => s} = no, p) do
estaticos = if is_integer(s), do: Map.fetch!(p, Integer.to_string(s)), else: s
case no do
%{"k" => itens} ->
itens
|> Map.delete("kc")
|> Enum.sort_by(fn {chave, _} -> String.to_integer(chave) end)
|> Enum.map_join(fn {_, dinamicos} -> intercalar(estaticos, dinamicos, p) end)
_ ->
intercalar(estaticos, no, p)
end
end
defp montar_no(_, _p), do: ""
defp intercalar(estaticos, dinamicos, p) do
ultimo = length(estaticos) - 1
estaticos
|> Enum.with_index()
|> Enum.map_join(fn
{estatico, ^ultimo} -> estatico
{estatico, i} -> estatico <> montar_no(dinamicos[Integer.to_string(i)], p)
end)
end
@doc """
As operações de stream de uma mensagem do fio, como `{ref, ids_que_entram, ids_que_saem, reset?}`.
Os ids que entram vêm na ordem em que aparecem na página (a ordem da compreensão).
"""
def streams_do(mapa) when is_map(mapa) do
proprios =
case mapa["stream"] do
[ref, inserts, deletes | resto] ->
ordem = ids_na_ordem(mapa["k"])
inseridos = Enum.map(inserts, fn [id | _] -> id end)
ordem = if ordem == [], do: Enum.uniq(inseridos), else: ordem
[{ref, ordem, deletes, resto == [true]}]
_ ->
[]
end
proprios ++ (mapa |> Map.drop(["stream", "k"]) |> Map.values() |> Enum.flat_map(&streams_do/1))
end
def streams_do(lista) when is_list(lista), do: Enum.flat_map(lista, &streams_do/1)
def streams_do(_), do: []
# Cada item da compreensão é um mapa; o primeiro atributo id="..." dele é o id do item na página.
defp ids_na_ordem(%{} = k) do
k
|> Map.delete("kc")
|> Enum.sort_by(fn {chave, _} -> String.to_integer(chave) end)
|> Enum.flat_map(fn {_, item} ->
case Enum.find_value(textos_dinamicos(item), &Regex.run(~r/\sid="([^"]+)"/, &1)) do
[_, id] -> [id]
nil -> []
end
end)
end
defp ids_na_ordem(_), do: []
# "w-full input input-error", "hero-plus size-4", "1.8.16": classes CSS e versões, não texto da página.
defp classes_css?(texto) do
texto =~ ~r/^\d+(\.\d+)+$/ or
(texto =~ ~r/^[a-z0-9:\/\.\[\]_-]+( [a-z0-9:\/\.\[\]_-]+)*$/ and texto =~ ~r/[-:]/)
end
# Só os valores dinâmicos (chaves numéricas), sem os estáticos ("s" e "p").
defp textos_dinamicos(mapa) when is_map(mapa) do
mapa
|> Map.drop(["s", "p", "r", "t", "stream", "kc"])
|> Map.values()
|> Enum.flat_map(&textos_dinamicos/1)
end
defp textos_dinamicos(lista) when is_list(lista), do: Enum.flat_map(lista, &textos_dinamicos/1)
defp textos_dinamicos(texto) when is_binary(texto), do: [texto]
defp textos_dinamicos(_), do: []
defp desescapar_html(texto) do
texto
|> String.replace("'", "'")
|> String.replace(""", "\"")
|> String.replace("<", "<")
|> String.replace(">", ">")
|> String.replace("&", "&")
end
@doc """
Roda `fun` e conta quais callbacks de LiveView o servidor executou, em qual
processo, e se a LiveView já estava conectada. Usa os eventos de telemetria que
o LiveView emite. Devolve `{resultado_de_fun, tabela}`.
"""
def callbacks(fun) do
eu = self()
id = {__MODULE__, make_ref()}
nomes = [:mount, :handle_params, :handle_event, :render]
:telemetry.attach_many(
id,
Enum.map(nomes, &[:phoenix, :live_view, &1, :start]),
&__MODULE__.avisar_callback/4,
{eu, id}
)
resultado =
try do
fun.()
after
Process.sleep(100)
:telemetry.detach(id)
end
eventos = coletar_callbacks(id, [])
case for({_, _, pid, true, _} <- eventos, do: pid) |> List.last() do
nil -> :ok
pid -> :persistent_term.put({__MODULE__, :ultimo_lv}, pid)
end
linhas =
eventos
|> Enum.with_index(1)
|> Enum.map(fn {{nome, view, pid, conectado, evento}, i} ->
detalhe = if evento, do: " `#{evento}`", else: ""
"| #{i} | `#{nome}`#{detalhe} | `#{inspect(view)}` | `#{inspect(pid)}` | `#{conectado}` |"
end)
tabela =
if linhas == [],
do: Kino.Markdown.new("Nenhum callback de LiveView rodou."),
else:
Kino.Markdown.new(
Enum.join(["| # | Callback | LiveView | Processo | `connected?` |", "| --- | --- | --- | --- | --- |" | linhas], "\n")
)
{resultado, tabela}
end
@doc false
def avisar_callback([_, _, nome, _], _medidas, meta, {eu, id}) do
socket = meta.socket
send(eu, {id, nome, socket.view, self(), socket.transport_pid != nil, meta[:event]})
end
@doc """
Os assigns guardados no processo da última LiveView conectada que `callbacks/1`
viu: o estado do servidor, lido direto do processo.
"""
def assigns_lv do
case :persistent_term.get({__MODULE__, :ultimo_lv}, nil) do
pid when is_pid(pid) ->
if Process.alive?(pid),
do: :sys.get_state(pid).socket.assigns,
else: raise("a última LiveView vista por Trilha.callbacks/1 já terminou; abra-a de novo")
nil ->
raise "nenhuma LiveView conectada foi vista ainda: abra uma com Trilha.callbacks(fn -> Trilha.abrir_lv(...) end)"
end
end
defp coletar_callbacks(id, acc) do
receive do
{^id, nome, view, pid, conectado, evento} -> coletar_callbacks(id, [{nome, view, pid, conectado, evento} | acc])
after
0 -> Enum.reverse(acc)
end
end
# --- perguntas -------------------------------------------------------------
@doc "Uma pergunta de múltipla escolha, respondida antes de ver a explicação."
def pergunta(id) do
%{texto: texto, opcoes: opcoes, correta: correta, explicacao: explicacao} = Map.fetch!(perguntas(), id)
quadro = Kino.Frame.new()
letras = Enum.map(opcoes, fn {letra, _texto} -> {letra, to_string(letra)} end)
lista = Enum.map_join(opcoes, "\n", fn {letra, opcao} -> "* **#{letra})** #{opcao}" end)
formulario =
Kino.Control.form(
[resposta: Kino.Input.select("Sua resposta", [{nil, "Escolha"} | letras])],
submit: "Conferir"
)
Kino.listen(formulario, fn %{data: %{resposta: resposta}} ->
retorno =
cond do
resposta == nil -> "Escolha uma opção antes de conferir."
resposta == correta -> "**Certo.** " <> explicacao
true -> "**Ainda não.** Releia o trecho acima e tente outra opção."
end
Kino.Frame.render(quadro, Kino.Markdown.new(retorno))
end)
Kino.Layout.grid([Kino.Markdown.new("**Antes de continuar:** " <> texto <> "\n\n" <> lista), formulario, quadro])
end
defp perguntas do
%{
arquivos: %{
texto:
"o `mix phx.gen.html` do notebook 08 gerou um controller, um módulo `ProductHTML` e cinco templates. O que o `mix phx.gen.live` gera no lugar deles?",
opcoes: [
a: "Os mesmos arquivos, mais um arquivo JavaScript para cada página.",
b: "Três LiveViews (`Index`, `Show` e `Form`), cada uma com o seu `render/1`; não há controller nem view separada.",
c: "Uma única LiveView que trata todas as páginas.",
d: "Um controller que devolve JSON e um componente para cada página."
],
correta: :b,
explicacao:
"Uma LiveView junta o papel do controller (receber o pedido, chamar o contexto) e o da view (o `render/1` com o template `~H`). O gerador cria uma LiveView por página: a listagem, a página do post e um formulário que serve tanto para criar quanto para editar. O contexto (`Blog`), o schema e a migration são os mesmos que o `phx.gen.html` geraria."
},
mount_duas_vezes: %{
texto:
"você abre `/posts` no navegador. Quantas vezes o `mount/3` do `PostLive.Index` roda, e em quantos processos diferentes?",
opcoes: [
a: "Uma vez, no processo da LiveView.",
b: "Duas vezes, no mesmo processo.",
c: "Duas vezes, em dois processos: uma na requisição HTTP (a renderização estática) e outra no processo da LiveView, depois que o WebSocket conecta.",
d: "Uma vez por usuário conectado ao servidor."
],
correta: :c,
explicacao:
"A primeira renderização é uma requisição HTTP comum: o `mount/3` roda no processo da requisição, com `connected?(socket)` falso, e o HTML completo vai para o navegador (bom para a primeira pintura e para buscadores). Depois o JavaScript abre o WebSocket e faz o join; aí nasce o processo da LiveView, que chama `mount/3` de novo, agora conectado, e fica vivo enquanto a página estiver aberta. Por isso assinaturas de PubSub ficam dentro de `if connected?(socket)`."
},
stream_memoria: %{
texto:
"o `mount/3` do `Index` chama `stream(:posts, Blog.list_posts())`. Depois da primeira renderização, onde estão os posts?",
opcoes: [
a: "Nos assigns do processo da LiveView, como uma lista, para as próximas renderizações.",
b: "Só no navegador (no DOM). O processo da LiveView guarda a configuração do stream, mas não os itens, que foram enviados e descartados.",
c: "No banco e em lugar nenhum mais: o navegador recebe só os ids.",
d: "Num cache do Phoenix compartilhado entre todas as LiveViews."
],
correta: :b,
explicacao:
"Um stream é uma coleção que o servidor envia e esquece: depois que os itens são renderizados e mandados ao cliente, o `LiveStream` nos assigns fica vazio (`inserts: []`). O navegador mantém os elementos no DOM, cada um com um `id` (`posts-1`, `posts-2`...). Para mudar a lista, o servidor manda só operações: insira este item, remova aquele id. Com milhares de linhas, a memória do processo não cresce com a lista."
},
tocados: %{
texto:
"no formulário de post novo, você apaga o que digitou no título (fica vazio) e ainda não clicou no corpo. O `phx-change=\"validate\"` roda o changeset, que acusa os dois campos em branco. Quais erros aparecem na tela?",
opcoes: [
a: "Os dois: o changeset é inválido nos dois campos.",
b: "Nenhum: erros só aparecem depois do `phx-submit`.",
c: "Só o do título. O cliente marca os campos em que você ainda não mexeu, e o `<.input>` esconde os erros deles.",
d: "Só o do corpo, porque é o próximo campo."
],
correta: :c,
explicacao:
"O cliente JavaScript envia, para cada campo ainda não usado, uma chave com o prefixo `_unused_` (aqui, `post[_unused_body]`). `Phoenix.Component.used_input?/1` olha essas chaves, e o `<.input>` do `CoreComponents` só mostra os erros dos campos usados. O changeset continua inválido nos dois campos; é a tela que não incomoda a pessoa antes da hora."
},
salvar_fio: %{
texto:
"o `save` grava o post e chama `push_navigate(to: ~p\"/posts\")`. O que o servidor manda pelo WebSocket como resposta ao evento?",
opcoes: [
a: "O HTML completo da listagem.",
b: "Um diff com a listagem nova.",
c: "Só uma instrução `live_redirect` com o destino e a flash. O navegador então faz um novo join, na mesma conexão, e o `Index` monta do zero.",
d: "Um redirecionamento HTTP 302."
],
correta: :c,
explicacao:
"`push_navigate/2` termina a LiveView atual e responde ao evento com `live_redirect` (destino, tipo `push` e a flash assinada num token). O cliente troca a URL com a History API e faz um novo `phx_join` na mesma conexão WebSocket, sem requisição HTTP e sem recarregar a página. É o `mount/3` da LiveView de destino que gera o conteúdo novo."
},
navegacao: %{
texto:
"na listagem, a ordem muda com `<.link patch={~p\"/posts?ordem=title\"}>`. Na mesma página, o título de cada post leva à página dele com `navigate`. O que roda no servidor em cada clique?",
opcoes: [
a: "Nos dois, uma requisição HTTP nova e um `mount/3`.",
b: "`patch`: só `handle_params/3`, na mesma LiveView. `navigate`: um `mount/3` numa LiveView nova, pela mesma conexão, sem HTTP.",
c: "`patch`: um `mount/3` novo. `navigate`: só `handle_params/3`.",
d: "Nos dois, só `handle_event/3`."
],
correta: :b,
explicacao:
"`patch` muda a URL sem trocar de LiveView: o mesmo processo recebe a URL nova em `handle_params/3` e manda só o diff. `navigate` troca de LiveView: o processo antigo termina e outro monta, mas a conexão WebSocket e o layout da página continuam. Um `href` comum (ou um link para fora do `live_session`) recarrega tudo: requisição HTTP, renderização estática, novo WebSocket e dois `mount/3`."
},
sem_pubsub: %{
texto:
"a Ana e a Bia estão com `/posts` aberto. A Ana cria um post pelo formulário. O que acontece na tela da Bia, com o código gerado (sem escopo)?",
opcoes: [
a: "O post aparece na hora: LiveView sincroniza as telas sozinha.",
b: "Nada, até a Bia recarregar a página. Cada LiveView é um processo com o próprio estado, e ninguém avisou o processo da Bia.",
c: "A página da Bia recarrega sozinha.",
d: "A LiveView da Bia cai com um erro."
],
correta: :b,
explicacao:
"O processo da LiveView da Bia montou a lista quando ela abriu a página e só muda quando recebe um evento ou uma mensagem. Para atualizar em tempo real, alguém tem de mandar uma mensagem: o contexto publica no `Phoenix.PubSub` e cada `Index` conectado assina o tópico e trata a mensagem em `handle_info/2`. Os geradores com escopo (notebook 14) já criam esse código; o gerado sem escopo, não."
},
escopo: %{
texto:
"num projeto com `mix phx.gen.auth --live` (escopo padrão do usuário), o mesmo `mix phx.gen.live Blog Post posts ...` gera código diferente. O que muda?",
opcoes: [
a: "Nada: o escopo só vale para controllers.",
b: "As rotas entram num `live_session` com `on_mount` que exige login; cada LiveView passa `@current_scope` ao contexto; a tabela ganha `user_id`; e o `Index` assina um tópico de PubSub por usuário.",
c: "Só a migration muda, ganhando `user_id`.",
d: "As LiveViews passam a guardar o usuário em cookies."
],
correta: :b,
explicacao:
"Com um escopo padrão na configuração, o gerador escreve código como o do notebook 14, agora em LiveView: o `live_session :require_authenticated_user` com `on_mount: [{HelloWeb.UserAuth, :require_authenticated}]` garante o `@current_scope` antes do `mount/3`, as funções do contexto recebem o escopo e filtram por `user_id`, e o `Index` chama `Blog.subscribe_posts(current_scope)` para receber só as mudanças do próprio usuário."
}
}
end
# --- soluções --------------------------------------------------------------
@doc "Um botão que revela a solução de um exercício."
def solucao(id) do
quadro = Kino.Frame.new()
botao = Kino.Control.button("Mostrar a solução")
Kino.listen(botao, fn _evento ->
Kino.Frame.render(quadro, Kino.Markdown.new(cerca(Map.fetch!(solucoes(), id), "elixir")))
end)
Kino.Layout.grid([botao, quadro])
end
defp solucoes do
%{
rotas: ~S'''
defmodule HelloWeb.Router do
use HelloWeb, :router
pipeline :browser do
plug :accepts, ["html"]
plug :fetch_session
plug :fetch_live_flash
plug :put_root_layout, html: {HelloWeb.Layouts, :root}
plug :protect_from_forgery
plug :put_secure_browser_headers
end
pipeline :api do
plug :accepts, ["json"]
end
scope "/", HelloWeb do
pipe_through :browser
get "/", PageController, :home
live "/posts", PostLive.Index, :index
live "/posts/new", PostLive.Form, :new
live "/posts/:id", PostLive.Show, :show
live "/posts/:id/edit", PostLive.Form, :edit
end
end
''',
validacao: ~S'''
def changeset(post, attrs) do
post
|> cast(attrs, [:title, :body])
|> validate_required([:title, :body])
|> validate_length(:title, min: 3)
end
''',
contador: ~S'''
defmodule HelloWeb.PostLive.Form do
use HelloWeb, :live_view
alias Hello.Blog
alias Hello.Blog.Post
@impl true
def render(assigns) do
~H"""
<Layouts.app flash={@flash}>
<.header>
{@page_title}
<:subtitle>Use this form to manage post records in your database.</:subtitle>
</.header>
<.form for={@form} id="post-form" phx-change="validate" phx-submit="save">
<.input field={@form[:title]} type="text" label="Title" />
<.input field={@form[:body]} type="textarea" label="Body" />
<p id="contagem">{@palavras} palavras</p>
<footer>
<.button phx-disable-with="Saving..." variant="primary">Save Post</.button>
<.button navigate={return_path(@return_to, @post)}>Cancel</.button>
</footer>
</.form>
</Layouts.app>
"""
end
@impl true
def mount(params, _session, socket) do
{:ok,
socket
|> assign(:return_to, return_to(params["return_to"]))
|> apply_action(socket.assigns.live_action, params)}
end
defp return_to("show"), do: "show"
defp return_to(_), do: "index"
defp apply_action(socket, :edit, %{"id" => id}) do
post = Blog.get_post!(id)
socket
|> assign(:page_title, "Edit Post")
|> assign(:post, post)
|> assign(:palavras, contar_palavras(post.body))
|> assign(:form, to_form(Blog.change_post(post)))
end
defp apply_action(socket, :new, _params) do
post = %Post{}
socket
|> assign(:page_title, "New Post")
|> assign(:post, post)
|> assign(:palavras, 0)
|> assign(:form, to_form(Blog.change_post(post)))
end
@impl true
def handle_event("validate", %{"post" => post_params}, socket) do
changeset = Blog.change_post(socket.assigns.post, post_params)
{:noreply,
socket
|> assign(form: to_form(changeset, action: :validate))
|> assign(:palavras, contar_palavras(post_params["body"]))}
end
def handle_event("save", %{"post" => post_params}, socket) do
save_post(socket, socket.assigns.live_action, post_params)
end
defp save_post(socket, :edit, post_params) do
case Blog.update_post(socket.assigns.post, post_params) do
{:ok, post} ->
{:noreply,
socket
|> put_flash(:info, "Post updated successfully")
|> push_navigate(to: return_path(socket.assigns.return_to, post))}
{:error, %Ecto.Changeset{} = changeset} ->
{:noreply, assign(socket, form: to_form(changeset))}
end
end
defp save_post(socket, :new, post_params) do
case Blog.create_post(post_params) do
{:ok, post} ->
{:noreply,
socket
|> put_flash(:info, "Post created successfully")
|> push_navigate(to: return_path(socket.assigns.return_to, post))}
{:error, %Ecto.Changeset{} = changeset} ->
{:noreply, assign(socket, form: to_form(changeset))}
end
end
defp contar_palavras(texto), do: texto |> to_string() |> String.split() |> length()
defp return_path("index", _post), do: ~p"/posts"
defp return_path("show", post), do: ~p"/posts/#{post}"
end
''',
ordem: ~S'''
defmodule HelloWeb.PostLive.Index do
use HelloWeb, :live_view
alias Hello.Blog
@impl true
def render(assigns) do
~H"""
<Layouts.app flash={@flash}>
<.header>
Listing Posts
<:subtitle>
Ordem: <.link patch={~p"/posts"}>mais antigos primeiro</.link>
· <.link patch={~p"/posts?ordem=title"}>por título</.link>
</:subtitle>
<:actions>
<.button variant="primary" navigate={~p"/posts/new"}>
<.icon name="hero-plus" /> New Post
</.button>
</:actions>
</.header>
<.table
id="posts"
rows={@streams.posts}
row_click={fn {_id, post} -> JS.navigate(~p"/posts/#{post}") end}
>
<:col :let={{_id, post}} label="Title">{post.title}</:col>
<:col :let={{_id, post}} label="Body">{post.body}</:col>
<:action :let={{_id, post}}>
<div class="sr-only">
<.link navigate={~p"/posts/#{post}"}>Show</.link>
</div>
<.link navigate={~p"/posts/#{post}/edit"}>Edit</.link>
</:action>
<:action :let={{id, post}}>
<.link
phx-click={JS.push("delete", value: %{id: post.id}) |> hide("##{id}")}
data-confirm="Are you sure?"
>
Delete
</.link>
</:action>
</.table>
</Layouts.app>
"""
end
@impl true
def mount(_params, _session, socket) do
{:ok, assign(socket, :page_title, "Listing Posts")}
end
@impl true
def handle_params(params, _url, socket) do
ordem = params["ordem"]
{:noreply, stream(socket, :posts, ordenar(list_posts(), ordem), reset: true)}
end
defp ordenar(posts, "title"), do: Enum.sort_by(posts, & &1.title)
defp ordenar(posts, _), do: Enum.sort_by(posts, & &1.id)
@impl true
def handle_event("delete", %{"id" => id}, socket) do
post = Blog.get_post!(id)
{:ok, _} = Blog.delete_post(post)
{:noreply, stream_delete(socket, :posts, post)}
end
defp list_posts() do
Blog.list_posts()
end
end
''',
tempo_real: ~S'''
# lib/hello/blog.ex: as partes novas
@doc """
Subscribes to notifications about any post changes.
The broadcasted messages match the pattern:
* {:created, %Post{}}
* {:updated, %Post{}}
* {:deleted, %Post{}}
"""
def subscribe_posts do
Phoenix.PubSub.subscribe(Hello.PubSub, "posts")
end
defp broadcast({:ok, %Post{} = post} = resultado, evento) do
Phoenix.PubSub.broadcast(Hello.PubSub, "posts", {evento, post})
resultado
end
defp broadcast(erro, _evento), do: erro
def create_post(attrs) do
%Post{}
|> Post.changeset(attrs)
|> Repo.insert()
|> broadcast(:created)
end
# update_post/2: ... |> Repo.update() |> broadcast(:updated)
# delete_post/1: post |> Repo.delete() |> broadcast(:deleted)
# lib/hello_web/live/post_live/index.ex: o mount/3 e o handle_info/2
def mount(_params, _session, socket) do
if connected?(socket), do: Blog.subscribe_posts()
{:ok, assign(socket, :page_title, "Listing Posts")}
end
@impl true
def handle_info({type, %Hello.Blog.Post{} = post}, socket) when type in [:created, :updated] do
{:noreply, stream_insert(socket, :posts, post)}
end
def handle_info({:deleted, %Hello.Blog.Post{} = post}, socket) do
{:noreply, stream_delete(socket, :posts, post)}
end
''',
brecho: ~S'''
defmodule HelloWeb.PainelLive do
use HelloWeb, :live_view
alias Hello.Vitrine
alias Hello.Vitrine.Produto
@impl true
def render(assigns) do
~H"""
<Layouts.app flash={@flash}>
<.header>Painel da vitrine</.header>
<.form for={@form} id="produto-form" phx-change="validar" phx-submit="cadastrar">
<.input field={@form[:nome]} type="text" label="Nome" />
<.input field={@form[:preco_centavos]} type="number" label="Preço (centavos)" />
<.button variant="primary">Cadastrar</.button>
</.form>
<.table id="produtos" rows={@streams.produtos}>
<:col :let={{_id, produto}} label="Peça">{produto.nome}</:col>
<:col :let={{_id, produto}} label="Preço">{produto.preco_centavos} centavos</:col>
<:action :let={{_id, produto}}>
<.link phx-click="vender" phx-value-id={produto.id}>Vender</.link>
</:action>
</.table>
</Layouts.app>
"""
end
@impl true
def mount(_params, _session, socket) do
{:ok,
socket
|> assign(:form, to_form(Vitrine.change_produto(%Produto{})))
|> stream(:produtos, Vitrine.listar_disponiveis())}
end
@impl true
def handle_event("validar", %{"produto" => attrs}, socket) do
changeset = Vitrine.change_produto(%Produto{}, attrs)
{:noreply, assign(socket, :form, to_form(changeset, action: :validate))}
end
def handle_event("cadastrar", %{"produto" => attrs}, socket) do
case Vitrine.cadastrar_produto(attrs) do
{:ok, produto} ->
{:noreply,
socket
|> put_flash(:info, "#{produto.nome} cadastrado.")
|> stream_insert(:produtos, produto)
|> assign(:form, to_form(Vitrine.change_produto(%Produto{})))}
{:error, changeset} ->
{:noreply, assign(socket, :form, to_form(changeset))}
end
end
def handle_event("vender", %{"id" => id}, socket) do
produto = Vitrine.buscar_produto!(id)
case Vitrine.vender(produto) do
{:ok, produto} ->
{:noreply,
socket
|> put_flash(:info, "#{produto.nome} vendido.")
|> stream_delete(:produtos, produto)}
{:error, :ja_vendido} ->
{:noreply,
socket
|> put_flash(:error, "Esta peça já foi vendida.")
|> stream_delete(:produtos, produto)}
end
end
end
''',
}
end
# --- conferência dos exercícios ----------------------------------------------
@doc "Confere um exercício consultando o banco e fazendo requisições de verdade."
def conferir(id) do
resultado =
Enum.reduce_while(Map.fetch!(verificacoes(), id), [], fn {descricao, teste}, feitos ->
case executar(teste) do
:ok -> {:cont, ["- Passou: " <> descricao | feitos]}
{:erro, dica} -> {:halt, ["- **Ainda não:** " <> descricao <> "\n\n " <> dica | feitos]}
end
end)
Kino.Markdown.new(resultado |> Enum.reverse() |> Enum.join("\n"))
end
defp executar(teste) do
teste.()
rescue
erro -> {:erro, explicar(erro)}
end
defp verificacoes do
%{
rotas: [
{"`GET /posts` vai para `PostLive.Index`, com a action `:index`.", fn -> rota_live("/posts", HelloWeb.PostLive.Index, :index) end},
{"`GET /posts/new` vai para `PostLive.Form`, com a action `:new`.", fn -> rota_live("/posts/new", HelloWeb.PostLive.Form, :new) end},
{"`GET /posts/42` vai para `PostLive.Show`, com a action `:show`.", fn -> rota_live("/posts/42", HelloWeb.PostLive.Show, :show) end},
{"`GET /posts/42/edit` vai para `PostLive.Form`, com a action `:edit`.", fn -> rota_live("/posts/42/edit", HelloWeb.PostLive.Form, :edit) end}
],
validacao: [
{"`Post.changeset/2` recusa um título de 2 letras.",
fn ->
case changeset_post(%{"title" => "Oi", "body" => "corpo"}).errors[:title] do
{_, opcoes} ->
if opcoes[:validation] == :length and opcoes[:kind] == :min and opcoes[:count] == 3,
do: :ok,
else: {:erro, "O erro de `title` veio com `#{inspect(opcoes)}`. Use `validate_length(:title, min: 3)`."}
nil ->
{:erro, "O changeset aceitou o título `\"Oi\"`. Acrescente `validate_length(:title, min: 3)` na célula do `Hello.Blog.Post` e avalie-a."}
end
end},
{"Um título de 3 letras passa.",
fn ->
cs = changeset_post(%{"title" => "Olá", "body" => "corpo"})
if cs.valid?, do: :ok, else: {:erro, "O changeset recusou `\"Olá\"`: `#{inspect(cs.errors)}`. O mínimo é 3, inclusive."}
end},
{"No formulário, o erro aparece enquanto se digita (`phx-change`).",
fn ->
com_lv("/posts/new", fn lv ->
{_lv, r} = digitar(lv, "validate", [{"post[title]", "Oi"}, {"post[body]", ""}], ["post[title]"])
if Enum.any?(textos(r), &(&1 =~ "should be at least 3 character")),
do: :ok,
else: {:erro, "O diff do `validate` não trouxe a mensagem `should be at least 3 character(s)`. O `Form` usa `Blog.change_post/2`, que chama o `Post.changeset/2`: confira se a célula do schema foi avaliada."}
end)
end}
],
contador: [
{"A página de um post novo mostra `0 palavras` num elemento com `id=\"contagem\"`.",
fn ->
html = pagina("/posts/new")
cond do
not (html =~ ~s(id="contagem")) -> {:erro, "A página `/posts/new` não tem um elemento com `id=\"contagem\"`."}
not (texto(html) =~ ~r/\b0 palavras/) -> {:erro, "A contagem não mostra `0 palavras` num post novo. Atribua `:palavras` em `apply_action(socket, :new, _)`."}
true -> :ok
end
end},
{"A página de edição conta as palavras do post salvo.",
fn ->
com_post(%{"title" => "Conferência contagem", "body" => "um dois três"}, fn post ->
if texto(pagina("/posts/#{post.id}/edit")) =~ ~r/\b3 palavras/,
do: :ok,
else: {:erro, "Para um post com corpo `\"um dois três\"`, a edição deveria mostrar `3 palavras`. Atribua `:palavras` em `apply_action(socket, :edit, _)`, a partir de `post.body`."}
end)
end},
{"A contagem muda enquanto se digita no corpo.",
fn ->
com_lv("/posts/new", fn lv ->
{lv, r1} = digitar(lv, "validate", [{"post[title]", ""}, {"post[body]", "um dois três quatro"}], ["post[body]"])
{_lv, r2} = digitar(lv, "validate", [{"post[title]", ""}, {"post[body]", "um"}], ["post[body]"])
cond do
"4" not in textos(r1) -> {:erro, "Depois de digitar 4 palavras, o diff do `validate` não trouxe o número `4`. Atualize `:palavras` no `handle_event(\"validate\", ...)`."}
"1" not in textos(r2) -> {:erro, "Depois de apagar até sobrar 1 palavra, o diff não trouxe o número `1`."}
true -> :ok
end
end)
end}
],
ordem: [
{"`PostLive.Index` define `handle_params/3`.", fn -> exporta(HelloWeb.PostLive.Index, handle_params: 3) end},
{"Um `patch` para `/posts?ordem=title` refaz a lista em ordem alfabética.",
fn ->
com_posts(["Zebra conf", "Abelha conf", "Macaco conf"], fn [zebra, abelha, macaco] ->
com_lv("/posts", fn lv ->
{_lv, r} = patch_lv(lv, "/posts?ordem=title")
conferir_ordem(r, [abelha, macaco, zebra], "por título")
end)
end)
end},
{"Um `patch` de volta para `/posts` volta à ordem de criação.",
fn ->
com_posts(["Zebra conf", "Abelha conf", "Macaco conf"], fn [zebra, abelha, macaco] ->
com_lv("/posts?ordem=title", fn lv ->
{_lv, r} = patch_lv(lv, "/posts")
conferir_ordem(r, [zebra, abelha, macaco], "de criação")
end)
end)
end},
{"O cabeçalho da listagem tem um link com `patch` para `/posts?ordem=title`.",
fn ->
html = pagina("/posts")
if html =~ ~s(href="/posts?ordem=title") and html =~ ~s(data-phx-link="patch"),
do: :ok,
else: {:erro, "A página `/posts` não tem `<.link patch={~p\"/posts?ordem=title\"}>`. Com `navigate` ou `href`, o clique faria um `mount/3` novo."}
end}
],
tempo_real: [
{"`Hello.Blog` define `subscribe_posts/0`.", fn -> exporta(Hello.Blog, subscribe_posts: 0) end},
{"Um post criado pelo contexto aparece em duas listagens abertas, sem clique.",
fn ->
com_lv("/posts", fn ana ->
com_lv("/posts", fn bia ->
{:ok, post} = apply(Hello.Blog, :create_post, [%{"title" => "Conferência ao vivo", "body" => "x"}])
try do
{_, d1} = receber(ana)
{_, d2} = receber(bia)
id = "posts-#{post.id}"
cond do
d1 == :nada or d2 == :nada ->
{:erro, "Nenhum diff chegou às listagens abertas. O `Index` assina o tópico no `mount/3` (dentro de `if connected?(socket)`) e o `create_post/1` publica `{:created, post}`?"}
not entrou?(d1, id) or not entrou?(d2, id) ->
{:erro, "Chegou um diff, mas sem `#{id}` entrando no stream. Use `stream_insert(socket, :posts, post)` no `handle_info/2`."}
true ->
:ok
end
after
apply(Hello.Blog, :delete_post, [post])
end
end)
end)
end},
{"Editar o post pelo contexto atualiza a listagem aberta.",
fn ->
com_post(%{"title" => "Conferência antes", "body" => "x"}, fn post ->
com_lv("/posts", fn lv ->
{:ok, _} = apply(Hello.Blog, :update_post, [post, %{"title" => "Conferência depois"}])
{_, d} = receber(lv)
if d != :nada and "Conferência depois" in textos(d),
do: :ok,
else: {:erro, "A listagem não recebeu o título novo. O `update_post/2` publica `{:updated, post}`, e o `handle_info/2` trata `:updated` com `stream_insert/3`?"}
end)
end)
end},
{"Apagar o post pelo contexto o tira da listagem aberta.",
fn ->
{:ok, post} = apply(Hello.Blog, :create_post, [%{"title" => "Conferência some", "body" => "x"}])
com_lv("/posts", fn lv ->
{:ok, _} = apply(Hello.Blog, :delete_post, [post])
{_, d} = receber(lv)
if d != :nada and "posts-#{post.id}" in Enum.flat_map(streams_do(d), fn {_, _, saem, _} -> saem end),
do: :ok,
else: {:erro, "A listagem não recebeu a remoção de `posts-#{post.id}`. O `delete_post/1` publica `{:deleted, post}`, e o `handle_info/2` chama `stream_delete/3`?"}
end)
end}
],
brecho: [
{"`/painel` é uma LiveView e lista só as peças à venda.",
fn ->
limpando_produtos(fn ->
casaco = produto!("Conf Casaco", 5000)
chapeu = produto!("Conf Chapéu", 3000)
{:ok, _} = apply(Hello.Vitrine, :vender, [chapeu])
com_lv("/painel", fn lv ->
{_lv, entrada} = {lv, lv.join}
ids = Enum.flat_map(streams_do(entrada), fn {_, entram, _, _} -> entram end)
cond do
"produtos-#{casaco.id}" not in ids -> {:erro, "A lista não trouxe a peça à venda `produtos-#{casaco.id}`. Use `stream(:produtos, Vitrine.listar_disponiveis())`."}
"produtos-#{chapeu.id}" in ids -> {:erro, "A lista trouxe uma peça vendida. Use `Vitrine.listar_disponiveis/0`."}
true -> :ok
end
end)
end)
end},
{"Digitar preço 0 mostra o erro e não grava nada.",
fn ->
limpando_produtos(fn ->
com_lv("/painel", fn lv ->
campos = [{"produto[nome]", "Conf Bota"}, {"produto[preco_centavos]", "0"}]
{_lv, r} = digitar(lv, "validar", campos, ["produto[nome]", "produto[preco_centavos]"])
cond do
not Enum.any?(textos(r), &(&1 =~ "must be greater than 0")) ->
{:erro, "O diff do evento `validar` não trouxe `must be greater than 0`. Use `to_form(changeset, action: :validate)`."}
contar_produtos("Conf Bota") > 0 ->
{:erro, "O `validar` gravou a peça. Ele só deve montar o changeset."}
true ->
:ok
end
end)
end)
end},
{"Cadastrar uma peça válida grava e a põe na lista.",
fn ->
limpando_produtos(fn ->
com_lv("/painel", fn lv ->
{_lv, r} = submeter(lv, "cadastrar", [{"produto[nome]", "Conf Bota"}, {"produto[preco_centavos]", "12000"}])
case coluna("SELECT id FROM produtos WHERE nome = 'Conf Bota' AND preco_centavos = 12000") do
[id] ->
if entrou?(r, "produtos-#{id}"),
do: :ok,
else: {:erro, "A peça foi gravada, mas não entrou no stream. Use `stream_insert(socket, :produtos, produto)`."}
[] ->
{:erro, "Nenhuma peça `Conf Bota` foi gravada. O evento `cadastrar` chama `Vitrine.cadastrar_produto/1` com `params[\"produto\"]`?"}
end
end)
end)
end},
{"Cadastrar sem nome não grava e mostra o erro.",
fn ->
limpando_produtos(fn ->
com_lv("/painel", fn lv ->
{_lv, r} = submeter(lv, "cadastrar", [{"produto[nome]", ""}, {"produto[preco_centavos]", "999"}])
cond do
coluna("SELECT id FROM produtos WHERE preco_centavos = 999") != [] -> {:erro, "Uma peça sem nome foi gravada."}
not Enum.any?(textos(r), &(&1 =~ "be blank")) -> {:erro, "O diff não trouxe o erro `can't be blank`. Em `{:error, changeset}`, atribua `to_form(changeset)` ao formulário."}
true -> :ok
end
end)
end)
end},
{"Vender tira a peça da lista e a marca como vendida.",
fn ->
limpando_produtos(fn ->
peca = produto!("Conf Saia", 2500)
com_lv("/painel", fn lv ->
{_lv, r} = clicar(lv, "vender", %{"id" => to_string(peca.id)})
saem = Enum.flat_map(streams_do(r), fn {_, _, saem, _} -> saem end)
cond do
coluna("SELECT vendido FROM produtos WHERE id = $1", [peca.id]) != [true] -> {:erro, "A peça não ficou com `vendido = true`. Use `Vitrine.vender/1`."}
"produtos-#{peca.id}" not in saem -> {:erro, "A peça foi vendida, mas não saiu da lista. Use `stream_delete(socket, :produtos, produto)`."}
true -> :ok
end
end)
end)
end},
{"Quem tenta vender uma peça que outra pessoa acabou de vender recebe o aviso.",
fn ->
limpando_produtos(fn ->
peca = produto!("Conf Lenço", 1500)
com_lv("/painel", fn ana ->
com_lv("/painel", fn bia ->
{_ana, _} = clicar(ana, "vender", %{"id" => to_string(peca.id)})
{_bia, r} = clicar(bia, "vender", %{"id" => to_string(peca.id)})
if Enum.any?(textos(r), &(&1 =~ "vendida")),
do: :ok,
else: {:erro, "A segunda venda não mostrou a flash `Esta peça já foi vendida.`. Trate `{:error, :ja_vendido}` com `put_flash(:error, ...)`."}
end)
end)
end)
end}
]
}
end
# --- ajudantes da conferência --------------------------------------------------
defp rota_live(caminho, modulo, acao) do
case Phoenix.Router.route_info(@router, "GET", caminho, "localhost") do
%{phoenix_live_view: {^modulo, ^acao, _, _}} ->
:ok
%{phoenix_live_view: {outro, outra_acao, _, _}} ->
{:erro, "`#{caminho}` vai para `#{inspect(outro)}` com `#{inspect(outra_acao)}`; o esperado é `#{inspect(modulo)}` com `#{inspect(acao)}`. Confira a ordem: `/posts/new` precisa vir antes de `/posts/:id`."}
%{} ->
{:erro, "`#{caminho}` existe, mas não é uma rota `live`."}
:error ->
{:erro, "Nenhuma rota casa com `GET #{caminho}`. Acrescente as rotas `live` na célula do router desta seção e avalie-a."}
end
end
defp changeset_post(params), do: apply(Hello.Blog.Post, :changeset, [struct(Hello.Blog.Post), params])
# O HTML da renderização estática de uma página (um GET comum).
defp pagina(caminho) do
case visitar("conferencia", :get, caminho) do
{:ok, conn} -> to_string(conn.resp_body)
{:erro, erro} -> raise erro
end
end
# Abre a LiveView, roda a conferência e fecha a conexão. `lv.join` guarda a resposta do join.
defp com_lv(caminho, fun) do
{lv, entrada} = abrir_lv(caminho, como: "conferencia-#{System.unique_integer([:positive])}")
try do
fun.(Map.put(lv, :join, entrada))
after
desconectar(lv)
end
end
defp com_post(attrs, fun) do
{:ok, post} = apply(Hello.Blog, :create_post, [attrs])
try do
fun.(post)
after
sql("DELETE FROM posts WHERE id = $1", [post.id])
end
end
defp com_posts(titulos, fun) do
posts =
Enum.map(titulos, fn titulo ->
{:ok, post} = apply(Hello.Blog, :create_post, [%{"title" => titulo, "body" => "conferência"}])
post
end)
try do
fun.(posts)
after
sql("DELETE FROM posts WHERE id = ANY($1)", [Enum.map(posts, & &1.id)])
end
end
defp conferir_ordem(resposta, esperados, nome) do
ids = Enum.map(esperados, &"posts-#{&1.id}")
case streams_do(resposta) do
[] ->
{:erro, "A resposta do `patch` não trouxe nenhuma operação de stream. O `handle_params/3` precisa chamar `stream(socket, :posts, ..., reset: true)`."}
streams ->
{_, entram, _, reset} = List.last(streams)
obtidos = Enum.filter(entram, &(&1 in ids))
cond do
not reset -> {:erro, "O stream não foi refeito (`reset: true` faltando): os itens novos seriam só acrescentados à lista antiga."}
obtidos != ids -> {:erro, "Na ordem #{nome}, o esperado é #{Enum.map_join(ids, ", ", &"`#{&1}`")}; veio #{Enum.map_join(obtidos, ", ", &"`#{&1}`")}."}
true -> :ok
end
end
end
defp entrou?(resposta, id), do: Enum.any?(streams_do(resposta), fn {_, entram, _, _} -> id in entram end)
defp produto!(nome, preco) do
{:ok, produto} = apply(Hello.Vitrine, :cadastrar_produto, [%{"nome" => nome, "preco_centavos" => preco}])
produto
end
defp contar_produtos(nome), do: length(coluna("SELECT id FROM produtos WHERE nome = $1", [nome]))
defp limpando_produtos(teste) do
teste.()
after
if coluna("SELECT table_name FROM information_schema.tables WHERE table_name = 'produtos'") != [] do
sql("DELETE FROM produtos WHERE nome LIKE 'Conf%' OR preco_centavos = 999")
end
end
defp exporta(modulo, funcoes) do
faltando = Enum.reject(funcoes, fn {nome, aridade} -> Code.ensure_loaded?(modulo) and function_exported?(modulo, nome, aridade) end)
if faltando == [],
do: :ok,
else: {:erro, "Falta definir em `#{inspect(modulo)}`: #{Enum.map_join(faltando, ", ", fn {n, a} -> "`#{n}/#{a}`" end)}."}
end
defp coluna(consulta, parametros \\ []), do: consulta |> sql(parametros) |> Map.fetch!(:rows) |> Enum.map(&hd/1)
defp explicar(%Phoenix.Router.NoRouteError{} = erro) do
"Etapa: **router**. `#{Exception.message(erro)}`. Adicione a rota na célula do router e avalie-a de novo."
end
defp explicar(%UndefinedFunctionError{module: modulo, function: :__live__}) do
"A rota aponta para `#{inspect(modulo)}`, mas esse módulo não existe (ou não é uma LiveView). Defina-o e avalie a célula."
end
defp explicar(%UndefinedFunctionError{module: modulo, function: funcao, arity: aridade}) do
nome = inspect(modulo)
cond do
String.ends_with?(nome, "Controller") and funcao == :init ->
"Etapa: **controller**. A rota aponta para `#{nome}`, mas esse módulo não existe. Defina-o e avalie a célula."
String.ends_with?(nome, "Controller") ->
"Etapa: **controller**. `#{nome}` existe, mas não define `#{funcao}/#{aridade}`."
String.ends_with?(nome, "HTML") ->
"Etapa: **view**. O `render` procurou `#{nome}.#{funcao}/#{aridade}` e não achou."
true ->
"`#{nome}.#{funcao}/#{aridade}` não existe. O módulo foi definido e a célula foi avaliada?"
end
end
defp explicar(%Postgrex.Error{postgres: %{code: :undefined_table, message: mensagem}}) do
"O PostgreSQL respondeu `#{mensagem}`. A migration que cria a tabela já rodou?"
end
defp explicar(%Ecto.ConstraintError{constraint: restricao}) do
"O banco recusou a operação pela restrição `#{restricao}`, e o changeset não a declara: a violação virou exceção em vez de erro no changeset."
end
defp explicar(%Phoenix.ActionClauseError{}) do
"Etapa: **controller**. Nenhuma cláusula da action casou com os parâmetros recebidos."
end
defp explicar(%MatchError{term: termo}) do
"Um casamento de padrões falhou com o valor `#{inspect(termo, limit: 8)}`."
end
defp explicar(erro), do: "Levantou uma exceção:\n\n `" <> Exception.format_banner(:error, erro) <> "`"
end
Como usar este notebook
Este notebook continua o 16. O guia LiveView do Phoenix termina com uma sugestão, na seção Getting Started: rodar mix phx.gen.live Blog Post posts title:string body:text, que gera um CRUD completo em LiveView, "parecido com o mix phx.gen.html". É o que este notebook faz, com calma: o código gerado pelo Phoenix 1.8.16, arquivo por arquivo, e os guias do LiveView que explicam cada peça (streams, formulários, navegação), com os links para a documentação do LiveView.
As convenções são as de sempre: uma célula equivale a um arquivo, as perguntas "Antes de continuar" vêm antes da explicação, e os erros fazem parte do conteúdo. O que muda é o jeito de olhar a aplicação. Uma LiveView conversa com o navegador por um WebSocket, e o Trilha traz um cliente WebSocket de verdade, o mesmo do notebook 16:
Trilha.abrir_lv("/posts")faz o que o navegador faz: um GET normal, depois a conexão em/live/websockete ophx_joinno tópico da LiveView; devolve{lv, resposta_do_join};Trilha.clicar/3,Trilha.digitar/4eTrilha.submeter/3mandam eventos como o JavaScript do LiveView manda (phx-click,phx-change,phx-submit);Trilha.navegar_lv/2eTrilha.patch_lv/2imitam os links comnavigateepatch;Trilha.receber/2espera uma mensagem que o servidor manda sem ninguém pedir;Trilha.resumo_lv/1resume uma mensagem do fio (o texto da página, as operações de stream, os textos que mudaram) eTrilha.json/1a mostra inteira;Trilha.callbacks/1conta quais callbacks da LiveView rodaram, em qual processo.
A aplicação fica em http://localhost:4010, e agora as páginas funcionam no navegador de verdade: clique, digite e abra duas abas. Nas ferramentas do navegador (aba Network, filtro WS), os quadros do WebSocket são os mesmos que as células mostram.
A primeira célula usa a mesma lista de dependências dos notebooks 13 a 16; se você já abriu algum deles, o Livebook reaproveita a instalação.
A aplicação Hello dentro do notebook
config/dev.exs: a configuração do banco no ambiente de desenvolvimento, a mesma do notebook 07. Se o seu PostgreSQL usa outra senha ou outra porta, troque aqui e avalie a célula de novo.
Application.put_env(:hello, Hello.Repo,
username: System.get_env("PGUSER", "postgres"),
password: System.get_env("PGPASSWORD", "postgres"),
hostname: System.get_env("PGHOST", "localhost"),
port: String.to_integer(System.get_env("PGPORT", "5432")),
database: "trilha_phoenix_dev",
show_sensitive_data_on_connection_error: true,
pool_size: 10,
stacktrace: true
)
lib/hello/repo.ex
defmodule Hello.Repo do
use Ecto.Repo,
otp_app: :hello,
adapter: Ecto.Adapters.Postgres
end
lib/hello_web.ex: como no projeto gerado, o bloco html_helpers importa os CoreComponents e dá o apelido JS ao Phoenix.LiveView.JS, e o bloco live_view (o use HelloWeb, :live_view das LiveViews) junta use Phoenix.LiveView com esses mesmos helpers.
defmodule HelloWeb do
def static_paths, do: ~w(assets fonts images favicon.ico robots.txt)
def router do
quote do
use Phoenix.Router, helpers: false
import Plug.Conn
import Phoenix.Controller
import Phoenix.LiveView.Router
end
end
def controller do
quote do
use Phoenix.Controller, formats: [:html, :json]
import Plug.Conn
unquote(verified_routes())
end
end
def live_view do
quote do
use Phoenix.LiveView
unquote(html_helpers())
end
end
def html do
quote do
use Phoenix.Component
import Phoenix.Controller,
only: [get_csrf_token: 0, view_module: 1, view_template: 1]
unquote(html_helpers())
end
end
defp html_helpers do
quote do
import Phoenix.HTML
import HelloWeb.CoreComponents
alias Phoenix.LiveView.JS
alias HelloWeb.Layouts
unquote(verified_routes())
end
end
def verified_routes do
quote do
use Phoenix.VerifiedRoutes,
endpoint: HelloWeb.Endpoint,
router: HelloWeb.Router,
statics: HelloWeb.static_paths()
end
end
defmacro __using__(which) when is_atom(which) do
apply(__MODULE__, which, [])
end
end
lib/hello_web/router.ex
defmodule HelloWeb.Router do
use HelloWeb, :router
pipeline :browser do
plug :accepts, ["html"]
plug :fetch_session
plug :fetch_live_flash
plug :put_root_layout, html: {HelloWeb.Layouts, :root}
plug :protect_from_forgery
plug :put_secure_browser_headers
end
pipeline :api do
plug :accepts, ["json"]
end
scope "/", HelloWeb do
pipe_through :browser
get "/", PageController, :home
end
end
lib/hello_web/endpoint.ex: socket "/live" é a porta de entrada do WebSocket das LiveViews, igual à do projeto gerado. A diferença está nos arquivos JavaScript: no projeto, o Plug.Static serve o app.js compilado, que junta phoenix_html, phoenix e phoenix_live_view. Aqui não há compilação de assets, então três Plug.Static servem direto os arquivos prontos que vêm nos pacotes.
defmodule HelloWeb.Endpoint do
use Phoenix.Endpoint, otp_app: :hello
@session_options [
store: :cookie,
key: "_hello_key",
signing_salt: "trilha00",
same_site: "Lax"
]
socket "/live", Phoenix.LiveView.Socket,
websocket: [connect_info: [session: @session_options]],
longpoll: [connect_info: [session: @session_options]]
plug Plug.Static, at: "/assets", from: {:phoenix_html, "priv/static"}, only: ~w(phoenix_html.js)
plug Plug.Static, at: "/assets", from: {:phoenix, "priv/static"}, only: ~w(phoenix.min.js)
plug Plug.Static, at: "/assets", from: {:phoenix_live_view, "priv/static"}, only: ~w(phoenix_live_view.min.js)
plug Plug.RequestId
plug Plug.Telemetry, event_prefix: [:phoenix, :endpoint]
plug Plug.Parsers,
parsers: [:urlencoded, :multipart, :json],
pass: ["*/*"],
json_decoder: Phoenix.json_library()
plug Plug.MethodOverride
plug Plug.Head
plug Plug.Session, @session_options
plug HelloWeb.Router
end
lib/hello_web/components/core_components.ex: os componentes que um projeto novo traz e que os templates gerados usam: flash, button, input, header, table, list e icon. O código é o do arquivo gerado pelo Phoenix 1.8.16, com uma diferença: no projeto, as mensagens de erro passam pelo Gettext, que traduz textos; aqui, translate_error/1 só preenche os valores na mensagem.
defmodule HelloWeb.CoreComponents do
use Phoenix.Component
alias Phoenix.LiveView.JS
@doc "Renders flash notices."
attr :id, :string, doc: "the optional id of flash container"
attr :flash, :map, default: %{}, doc: "the map of flash messages to display"
attr :title, :string, default: nil
attr :kind, :atom, values: [:info, :error], doc: "used for styling and flash lookup"
attr :rest, :global, doc: "the arbitrary HTML attributes to add to the flash container"
slot :inner_block, doc: "the optional inner block that renders the flash message"
def flash(assigns) do
assigns = assign_new(assigns, :id, fn -> "flash-#{assigns.kind}" end)
~H"""
<div
:if={msg = render_slot(@inner_block) || Phoenix.Flash.get(@flash, @kind)}
id={@id}
phx-click={JS.push("lv:clear-flash", value: %{key: @kind}) |> hide("##{@id}")}
role="alert"
class="toast toast-top toast-end z-50"
{@rest}
>
<div class={[
"alert w-80 sm:w-96 max-w-80 sm:max-w-96 text-wrap",
@kind == :info && "alert-info",
@kind == :error && "alert-error"
]}>
<.icon :if={@kind == :info} name="hero-information-circle" class="size-5 shrink-0" />
<.icon :if={@kind == :error} name="hero-exclamation-circle" class="size-5 shrink-0" />
<div>
<p :if={@title} class="font-semibold">{@title}</p>
<p>{msg}</p>
</div>
<div class="flex-1" />
<button type="button" class="group self-start cursor-pointer" aria-label="close">
<.icon name="hero-x-mark" class="size-5 opacity-40 group-hover:opacity-70" />
</button>
</div>
</div>
"""
end
@doc "Renders a button with navigation support."
attr :rest, :global, include: ~w(href navigate patch method download name value disabled)
attr :class, :any
attr :variant, :string, values: ~w(primary)
slot :inner_block, required: true
def button(%{rest: rest} = assigns) do
variants = %{"primary" => "btn-primary", nil => "btn-primary btn-soft"}
assigns =
assign_new(assigns, :class, fn ->
["btn", Map.fetch!(variants, assigns[:variant])]
end)
if rest[:href] || rest[:navigate] || rest[:patch] do
~H"""
<.link class={@class} {@rest}>
{render_slot(@inner_block)}
</.link>
"""
else
~H"""
<button class={@class} {@rest}>
{render_slot(@inner_block)}
</button>
"""
end
end
@doc "Renders an input with label and error messages."
attr :id, :any, default: nil
attr :name, :any
attr :label, :string, default: nil
attr :value, :any
attr :type, :string,
default: "text",
values: ~w(checkbox color date datetime-local email file month number password
search select tel text textarea time url week hidden)
attr :field, Phoenix.HTML.FormField,
doc: "a form field struct retrieved from the form, for example: @form[:email]"
attr :errors, :list, default: []
attr :checked, :boolean, doc: "the checked flag for checkbox inputs"
attr :prompt, :string, default: nil, doc: "the prompt for select inputs"
attr :options, :list, doc: "the options to pass to Phoenix.HTML.Form.options_for_select/2"
attr :multiple, :boolean, default: false, doc: "the multiple flag for select inputs"
attr :class, :any, default: nil, doc: "the input class to use over defaults"
attr :error_class, :any, default: nil, doc: "the input error class to use over defaults"
attr :rest, :global,
include: ~w(accept autocomplete capture cols disabled form list max maxlength min minlength
multiple pattern placeholder readonly required rows size step)
def input(%{field: %Phoenix.HTML.FormField{} = field} = assigns) do
errors = if Phoenix.Component.used_input?(field), do: field.errors, else: []
assigns
|> assign(field: nil, id: assigns.id || field.id)
|> assign(:errors, Enum.map(errors, &translate_error(&1)))
|> assign_new(:name, fn -> if assigns.multiple, do: field.name <> "[]", else: field.name end)
|> assign_new(:value, fn -> field.value end)
|> input()
end
def input(%{type: "hidden"} = assigns) do
~H"""
<input type="hidden" id={@id} name={@name} value={@value} {@rest} />
"""
end
def input(%{type: "checkbox"} = assigns) do
assigns =
assign_new(assigns, :checked, fn ->
Phoenix.HTML.Form.normalize_value("checkbox", assigns[:value])
end)
~H"""
<div class="fieldset mb-2">
<label for={@id}>
<input
type="hidden"
name={@name}
value="false"
disabled={@rest[:disabled]}
form={@rest[:form]}
/>
<span class="label">
<input
type="checkbox"
id={@id}
name={@name}
value="true"
checked={@checked}
class={@class || "checkbox checkbox-sm"}
{@rest}
/>{@label}
</span>
</label>
<.error :for={msg <- @errors}>{msg}</.error>
</div>
"""
end
def input(%{type: "select"} = assigns) do
~H"""
<div class="fieldset mb-2">
<label for={@id}>
<span :if={@label} class="label mb-1">{@label}</span>
<select
id={@id}
name={@name}
class={[@class || "w-full select", @errors != [] && (@error_class || "select-error")]}
multiple={@multiple}
{@rest}
>
<option :if={@prompt} value="">{@prompt}</option>
{Phoenix.HTML.Form.options_for_select(@options, @value)}
</select>
</label>
<.error :for={msg <- @errors}>{msg}</.error>
</div>
"""
end
def input(%{type: "textarea"} = assigns) do
~H"""
<div class="fieldset mb-2">
<label for={@id}>
<span :if={@label} class="label mb-1">{@label}</span>
<textarea
id={@id}
name={@name}
class={[
@class || "w-full textarea",
@errors != [] && (@error_class || "textarea-error")
]}
{@rest}
>{Phoenix.HTML.Form.normalize_value("textarea", @value)}</textarea>
</label>
<.error :for={msg <- @errors}>{msg}</.error>
</div>
"""
end
# All other inputs text, datetime-local, url, password, etc. are handled here...
def input(assigns) do
~H"""
<div class="fieldset mb-2">
<label for={@id}>
<span :if={@label} class="label mb-1">{@label}</span>
<input
type={@type}
name={@name}
id={@id}
value={Phoenix.HTML.Form.normalize_value(@type, @value)}
class={[
@class || "w-full input",
@errors != [] && (@error_class || "input-error")
]}
{@rest}
/>
</label>
<.error :for={msg <- @errors}>{msg}</.error>
</div>
"""
end
# Helper used by inputs to generate form errors
defp error(assigns) do
~H"""
<p class="mt-1.5 flex gap-2 items-center text-sm text-error">
<.icon name="hero-exclamation-circle" class="size-5" />
{render_slot(@inner_block)}
</p>
"""
end
@doc "Renders a header with title."
slot :inner_block, required: true
slot :subtitle
slot :actions
def header(assigns) do
~H"""
<header class={[@actions != [] && "flex items-center justify-between gap-6", "pb-4"]}>
<div>
<h1 class="text-lg font-semibold leading-8">
{render_slot(@inner_block)}
</h1>
<p :if={@subtitle != []} class="text-sm text-base-content/70">
{render_slot(@subtitle)}
</p>
</div>
<div class="flex-none">{render_slot(@actions)}</div>
</header>
"""
end
@doc "Renders a table with generic styling."
attr :id, :string, required: true
attr :rows, :list, required: true
attr :row_id, :any, default: nil, doc: "the function for generating the row id"
attr :row_click, :any, default: nil, doc: "the function for handling phx-click on each row"
attr :row_item, :any,
default: &Function.identity/1,
doc: "the function for mapping each row before calling the :col and :action slots"
slot :col, required: true do
attr :label, :string
end
slot :action, doc: "the slot for showing user actions in the last table column"
def table(assigns) do
assigns =
with %{rows: %Phoenix.LiveView.LiveStream{}} <- assigns do
assign(assigns, row_id: assigns.row_id || fn {id, _item} -> id end)
end
~H"""
<table class="table table-zebra">
<thead>
<tr>
<th :for={col <- @col}>{col[:label]}</th>
<th :if={@action != []}>
<span class="sr-only">Actions</span>
</th>
</tr>
</thead>
<tbody id={@id} phx-update={is_struct(@rows, Phoenix.LiveView.LiveStream) && "stream"}>
<tr :for={row <- @rows} id={@row_id && @row_id.(row)}>
<td
:for={col <- @col}
phx-click={@row_click && @row_click.(row)}
class={@row_click && "hover:cursor-pointer"}
>
{render_slot(col, @row_item.(row))}
</td>
<td :if={@action != []} class="w-0 font-semibold">
<div class="flex gap-4">
<%= for action <- @action do %>
{render_slot(action, @row_item.(row))}
<% end %>
</div>
</td>
</tr>
</tbody>
</table>
"""
end
@doc "Renders a data list."
slot :item, required: true do
attr :title, :string, required: true
end
def list(assigns) do
~H"""
<ul class="list">
<li :for={item <- @item} class="list-row">
<div class="list-col-grow">
<div class="font-bold">{item.title}</div>
<div>{render_slot(item)}</div>
</div>
</li>
</ul>
"""
end
@doc "Renders a [Heroicon](https://heroicons.com)."
attr :name, :string, required: true
attr :class, :any, default: "size-4"
def icon(%{name: "hero-" <> _} = assigns) do
~H"""
<span class={[@name, @class]} />
"""
end
## JS Commands
def show(js \\ %JS{}, selector) do
JS.show(js,
to: selector,
time: 300,
transition:
{"transition-all ease-out duration-300",
"opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95",
"opacity-100 translate-y-0 sm:scale-100"}
)
end
def hide(js \\ %JS{}, selector) do
JS.hide(js,
to: selector,
time: 200,
transition:
{"transition-all ease-in duration-200", "opacity-100 translate-y-0 sm:scale-100",
"opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"}
)
end
@doc """
Fills in the error message values. In the generated project this function
translates the message with Gettext.
"""
def translate_error({msg, opts}) do
Enum.reduce(opts, msg, fn {key, value}, acc ->
String.replace(acc, "%{#{key}}", fn _ -> to_string(value) end)
end)
end
@doc "Translates the errors for a field from a keyword list of errors."
def translate_errors(errors, field) when is_list(errors) do
for {^field, {msg, opts}} <- errors, do: translate_error({msg, opts})
end
end
lib/hello_web/components/layouts.ex: o root layout e o layout da aplicação. No projeto, o root layout carrega o app.css e o app.js compilados. Aqui ele carrega o Tailwind e o daisyUI de uma CDN (sem internet, as páginas funcionam sem estilo), os três arquivos JavaScript e um <script> que cria o LiveSocket e conecta: é o trecho do app.js que o guia mostra, com LiveView.LiveSocket e Phoenix.Socket no lugar dos import.
defmodule HelloWeb.Layouts do
use HelloWeb, :html
def root(assigns) do
~H"""
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="csrf-token" content={get_csrf_token()} />
<title>Hello · Phoenix Framework</title>
<link href="https://cdn.jsdelivr.net/npm/daisyui@5" rel="stylesheet" type="text/css" />
<script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4">
</script>
<script defer src={~p"/assets/phoenix_html.js"}>
</script>
<script src={~p"/assets/phoenix.min.js"}>
</script>
<script src={~p"/assets/phoenix_live_view.min.js"}>
</script>
<script>
window.addEventListener("DOMContentLoaded", () => {
const csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute("content")
const liveSocket = new LiveView.LiveSocket("/live", Phoenix.Socket, {params: {_csrf_token: csrfToken}})
liveSocket.connect()
window.liveSocket = liveSocket
})
</script>
</head>
<body>
{@inner_content}
</body>
</html>
"""
end
attr :flash, :map, required: true, doc: "the map of flash messages"
attr :current_scope, :map, default: nil, doc: "the current scope"
slot :inner_block, required: true
def app(assigns) do
~H"""
<header class="navbar px-4 sm:px-6 lg:px-8">
<div class="flex-1">
<a href="/" class="flex-1 flex w-fit items-center gap-2">
<span class="text-sm font-semibold">Hello · Phoenix v{Application.spec(:phoenix, :vsn)}</span>
</a>
</div>
</header>
<main class="px-4 py-20 sm:px-6 lg:px-8">
<div class="mx-auto max-w-2xl space-y-4">
{render_slot(@inner_block)}
</div>
</main>
<.flash_group flash={@flash} />
"""
end
attr :flash, :map, required: true, doc: "the map of flash messages"
attr :id, :string, default: "flash-group", doc: "the optional id of flash container"
def flash_group(assigns) do
~H"""
<div id={@id} aria-live="polite">
<.flash kind={:info} flash={@flash} />
<.flash kind={:error} flash={@flash} />
</div>
"""
end
end
lib/hello_web/controllers/: as páginas de erro e a página inicial.
defmodule HelloWeb.ErrorHTML do
def render(template, _assigns) do
Phoenix.Controller.status_message_from_template(template)
end
end
defmodule HelloWeb.ErrorJSON do
def render(template, _assigns) do
%{errors: %{detail: Phoenix.Controller.status_message_from_template(template)}}
end
end
defmodule HelloWeb.PageController do
use HelloWeb, :controller
def home(conn, _params) do
render(conn, :home)
end
end
defmodule HelloWeb.PageHTML do
use HelloWeb, :html
def home(assigns) do
~H"""
<Layouts.app flash={@flash}>
<h1>Welcome to Phoenix!</h1>
</Layouts.app>
"""
end
end
Antes de começar: o PostgreSQL
Este notebook usa o mesmo PostgreSQL do notebook 07, com o banco trilha_phoenix_dev. A célula abaixo tenta uma conexão e, se falhar, traduz a mensagem em uma dica. Os comandos para instalar e configurar o PostgreSQL estão no LEIA-ME.md da trilha.
Trilha.verificar_postgres()
Crie o banco, se ainda não existir (é o mix ecto.create). {:error, :already_up} quer dizer que ele já existe, e serve do mesmo jeito.
Ecto.Adapters.Postgres.storage_up(Hello.Repo.config())
lib/hello/application.ex: a árvore de supervisão inicia o Repo, o Phoenix.PubSub e o endpoint. Antes do endpoint, a célula põe na configuração dele o signing_salt do LiveView, usado para assinar os tokens data-phx-session. No projeto, ele fica em config/config.exs (live_view: [signing_salt: ...]); aqui entra em tempo de execução, para que a lista de dependências da primeira célula continue idêntica à dos notebooks 13 a 16 e o Livebook reaproveite a instalação.
{:ok, _} = Kino.start_child(Hello.Repo)
{:ok, _} = Kino.start_child({Phoenix.PubSub, name: Hello.PubSub})
config = Application.get_env(:hello, HelloWeb.Endpoint)
Application.put_env(:hello, HelloWeb.Endpoint, Keyword.put(config, :live_view, signing_salt: "trilhaLV"))
{:ok, _} = Kino.start_child(HelloWeb.Endpoint)
Kino.Markdown.new("Repo conectado e aplicação no ar em <http://localhost:4010>.")
Comece do zero: a célula abaixo apaga todas as tabelas do banco trilha_phoenix_dev, para que os números de id sejam os mesmos do texto. Avalie-a sempre que reabrir o notebook. Ela só mexe no banco da trilha.
Trilha.recomecar()
O gerador mix phx.gen.live
Na documentação: Getting Started
O guia sugere:
$ mix phx.gen.live Blog Post posts title:string body:text
Os argumentos são os mesmos do mix phx.gen.html do notebook 08: o contexto (Blog), o schema (Post), a tabela (posts) e os campos. Num projeto 1.8.16 sem autenticação, a saída é esta:
* creating lib/hello_web/live/post_live/show.ex
* creating lib/hello_web/live/post_live/index.ex
* creating lib/hello_web/live/post_live/form.ex
* creating test/hello_web/live/post_live_test.exs
* creating lib/hello/blog/post.ex
* creating priv/repo/migrations/20261111031035_create_posts.exs
* creating lib/hello/blog.ex
* injecting lib/hello/blog.ex
* creating test/hello/blog_test.exs
* injecting test/hello/blog_test.exs
* creating test/support/fixtures/blog_fixtures.ex
* injecting test/support/fixtures/blog_fixtures.ex
Add the live routes to your browser scope in lib/hello_web/router.ex:
live "/posts", PostLive.Index, :index
live "/posts/new", PostLive.Form, :new
live "/posts/:id", PostLive.Show, :show
live "/posts/:id/edit", PostLive.Form, :edit
Remember to update your repository by running migrations:
$ mix ecto.migrate
Trilha.pergunta(:arquivos)
A parte de dados é igual à do phx.gen.html: a migration, o schema Hello.Blog.Post e o contexto Hello.Blog, com list_posts/0, get_post!/1, create_post/1, update_post/2, delete_post/1 e change_post/2. A parte web são três LiveViews, uma por página. O nome de arquivo da migration traz a data em que o gerador rodou; aqui, a versão registrada é outra, fixa, para os resultados serem sempre os mesmos.
priv/repo/migrations/20250315120000_create_posts.exs
defmodule Hello.Repo.Migrations.CreatePosts do
use Ecto.Migration
def change do
create table(:posts) do
add :title, :string
add :body, :text
timestamps(type: :utc_datetime)
end
end
end
Trilha.migracao(20250315120000, Hello.Repo.Migrations.CreatePosts)
Ecto.Migrator.run(Hello.Repo, Trilha.migracoes(), :up, all: true)
lib/hello/blog/post.ex
defmodule Hello.Blog.Post do
use Ecto.Schema
import Ecto.Changeset
schema "posts" do
field :title, :string
field :body, :string
timestamps(type: :utc_datetime)
end
@doc false
def changeset(post, attrs) do
post
|> cast(attrs, [:title, :body])
|> validate_required([:title, :body])
end
end
lib/hello/blog.ex: o contexto, igual ao que o phx.gen.html gera.
defmodule Hello.Blog do
@moduledoc """
The Blog context.
"""
import Ecto.Query, warn: false
alias Hello.Repo
alias Hello.Blog.Post
@doc """
Returns the list of posts.
## Examples
iex> list_posts()
[%Post{}, ...]
"""
def list_posts do
Repo.all(Post)
end
@doc """
Gets a single post.
Raises `Ecto.NoResultsError` if the Post does not exist.
## Examples
iex> get_post!(123)
%Post{}
iex> get_post!(456)
** (Ecto.NoResultsError)
"""
def get_post!(id), do: Repo.get!(Post, id)
@doc """
Creates a post.
## Examples
iex> create_post(%{field: value})
{:ok, %Post{}}
iex> create_post(%{field: bad_value})
{:error, %Ecto.Changeset{}}
"""
def create_post(attrs) do
%Post{}
|> Post.changeset(attrs)
|> Repo.insert()
end
@doc """
Updates a post.
## Examples
iex> update_post(post, %{field: new_value})
{:ok, %Post{}}
iex> update_post(post, %{field: bad_value})
{:error, %Ecto.Changeset{}}
"""
def update_post(%Post{} = post, attrs) do
post
|> Post.changeset(attrs)
|> Repo.update()
end
@doc """
Deletes a post.
## Examples
iex> delete_post(post)
{:ok, %Post{}}
iex> delete_post(post)
{:error, %Ecto.Changeset{}}
"""
def delete_post(%Post{} = post) do
Repo.delete(post)
end
@doc """
Returns an `%Ecto.Changeset{}` for tracking post changes.
## Examples
iex> change_post(post)
%Ecto.Changeset{data: %Post{}}
"""
def change_post(%Post{} = post, attrs \\ %{}) do
Post.changeset(post, attrs)
end
end
Sua vez: as rotas
O gerador imprimiu quatro rotas live. Uma rota live liga um caminho a uma LiveView; o terceiro argumento é a live action, um átomo que a LiveView recebe em socket.assigns.live_action. É assim que um único módulo, o PostLive.Form, serve tanto para /posts/new (:new) quanto para /posts/:id/edit (:edit).
Na célula abaixo, acrescente as quatro rotas ao scope "/", depois da rota da página inicial, e avalie a célula. A ordem importa: /posts/new tem de vir antes de /posts/:id, senão o router acha que new é um id.
Esta célula do router fica antes das LiveViews de propósito: os caminhos escritos com ~p dentro delas são conferidos contra o router quando cada módulo compila.
lib/hello_web/router.ex
defmodule HelloWeb.Router do
use HelloWeb, :router
pipeline :browser do
plug :accepts, ["html"]
plug :fetch_session
plug :fetch_live_flash
plug :put_root_layout, html: {HelloWeb.Layouts, :root}
plug :protect_from_forgery
plug :put_secure_browser_headers
end
pipeline :api do
plug :accepts, ["json"]
end
scope "/", HelloWeb do
pipe_through :browser
get "/", PageController, :home
# Acrescente aqui as quatro rotas live do gerador.
end
end
Trilha.solucao(:rotas)
Trilha.conferir(:rotas)
A conferência pergunta ao router para onde vai cada caminho (com Phoenix.Router.route_info/4), sem precisar das LiveViews, que vêm a seguir. Enquanto elas não existirem, a célula do router avisa HelloWeb.PostLive.Index.__live__/0 is undefined: é o mesmo aviso que um router dá para um controller ainda não definido.
A listagem: PostLive.Index
Na documentação do LiveView: Bindings e stream/4
lib/hello_web/live/post_live/index.ex
defmodule HelloWeb.PostLive.Index do
use HelloWeb, :live_view
alias Hello.Blog
@impl true
def render(assigns) do
~H"""
<Layouts.app flash={@flash}>
<.header>
Listing Posts
<:actions>
<.button variant="primary" navigate={~p"/posts/new"}>
<.icon name="hero-plus" /> New Post
</.button>
</:actions>
</.header>
<.table
id="posts"
rows={@streams.posts}
row_click={fn {_id, post} -> JS.navigate(~p"/posts/#{post}") end}
>
<:col :let={{_id, post}} label="Title">{post.title}</:col>
<:col :let={{_id, post}} label="Body">{post.body}</:col>
<:action :let={{_id, post}}>
<div class="sr-only">
<.link navigate={~p"/posts/#{post}"}>Show</.link>
</div>
<.link navigate={~p"/posts/#{post}/edit"}>Edit</.link>
</:action>
<:action :let={{id, post}}>
<.link
phx-click={JS.push("delete", value: %{id: post.id}) |> hide("##{id}")}
data-confirm="Are you sure?"
>
Delete
</.link>
</:action>
</.table>
</Layouts.app>
"""
end
@impl true
def mount(_params, _session, socket) do
{:ok,
socket
|> assign(:page_title, "Listing Posts")
|> stream(:posts, list_posts())}
end
@impl true
def handle_event("delete", %{"id" => id}, socket) do
post = Blog.get_post!(id)
{:ok, _} = Blog.delete_post(post)
{:noreply, stream_delete(socket, :posts, post)}
end
defp list_posts() do
Blog.list_posts()
end
end
O módulo tem as três partes do termostato do notebook 16:
mount/3prepara o estado: o título da página e um stream com os posts;render/1é o template, com os mesmos componentes dophx.gen.html(<.header>,<.table>,<.button>); a diferença é querows={@streams.posts}recebe o stream, e cada linha chega como uma tupla{id, post};handle_event/3trata o único evento da página,"delete".
Dois atributos da tabela são JavaScript que o servidor escreve, com o módulo Phoenix.LiveView.JS:
row_click={fn {_id, post} -> JS.navigate(~p"/posts/#{post}") end}: clicar numa linha navega para a página do post, sem recarregar;phx-click={JS.push("delete", value: %{id: post.id}) |> hide("##{id}")}: o link "Delete" manda o evento"delete"com o id e, ao mesmo tempo, esconde a linha no navegador, sem esperar a resposta.data-confirmpede confirmação antes.
Para ver a página com conteúdo, crie alguns posts pelo contexto:
for {titulo, corpo} <- [
{"Banana", "um dois"},
{"Abacaxi", "três"},
{"Caju", "quatro cinco seis"},
{"Rascunho", "para apagar"}
] do
{:ok, _post} = Hello.Blog.create_post(%{title: titulo, body: corpo})
end
Trilha.consultar("SELECT id, title, body FROM posts ORDER BY id")
Abra http://localhost:4010/posts no navegador. A primeira resposta é um GET comum, com o HTML completo. Dentro dele está o elemento raiz da LiveView, com os dados que o JavaScript usa para conectar:
{:ok, conn} = Trilha.visitar("ana", :get, "/posts")
conn.resp_body
|> to_string()
|> then(&Regex.run(~r/<div[^>]*data-phx-main[^>]*>/, &1))
|> hd()
|> String.slice(0, 400)
data-phx-session é um token assinado com o módulo da LiveView e a sessão; data-phx-static guarda dados da renderização estática. No join, o cliente devolve os dois ao servidor.
Trilha.pergunta(:mount_duas_vezes)
Trilha.callbacks/1 roda um trecho de código e lista os callbacks de LiveView que o servidor executou, usando os eventos de telemetria do LiveView. Abra a listagem como a Ana:
{{lv, entrada}, tabela} = Trilha.callbacks(fn -> Trilha.abrir_lv("/posts", como: "ana") end)
tabela
O primeiro mount roda no processo que atendeu o GET, com connected? falso: é a renderização estática. O segundo roda num processo novo, criado pelo join do WebSocket, com connected? verdadeiro; esse processo fica vivo enquanto a página estiver aberta. O render da tabela é o do processo conectado (a telemetria do LiveView só emite esse evento para renderizações conectadas). Se você já fez o exercício de ordenação, mais abaixo, aparece também handle_params depois de cada mount.
O que o join trouxe:
Trilha.resumo_lv(entrada)
O resumo monta o HTML a partir da resposta (estáticos e dinâmicos, como o notebook 16 mostrou) e extrai o texto. A linha do stream diz que entraram posts-1 a posts-4: são os id dos elementos <tr> da tabela, que o stream gera a partir do nome (posts) e do id de cada registro.
Streams
Trilha.pergunta(:stream_memoria)
Trilha.assigns_lv/0 lê os assigns direto do processo da LiveView que acabou de conectar:
Trilha.assigns_lv()
Em streams.posts há um %Phoenix.LiveView.LiveStream{} com inserts: []. Os quatro posts foram renderizados, enviados e esquecidos pelo servidor; quem os guarda é o navegador, no DOM, dentro do <tbody phx-update="stream">. Para mudar a lista, o servidor manda só operações. Apague o "Rascunho", como o link "Delete" faria:
{lv, resposta} = Trilha.clicar(lv, "delete", %{"id" => 4})
Trilha.json(resposta)
O diff inteiro tem pouco mais de cem bytes. A lista "stream" é [ref, inserções, remoções]: nenhuma inserção, e a remoção do elemento posts-4. O resto da página não viaja. Confira no banco:
Trilha.consultar("SELECT id, title FROM posts ORDER BY id")
Para ir mais fundo: stream/4, stream_insert/4 e stream_delete/3 na documentação.
O formulário: PostLive.Form
Na documentação do LiveView: Form bindings
lib/hello_web/live/post_live/form.ex
defmodule HelloWeb.PostLive.Form do
use HelloWeb, :live_view
alias Hello.Blog
alias Hello.Blog.Post
@impl true
def render(assigns) do
~H"""
<Layouts.app flash={@flash}>
<.header>
{@page_title}
<:subtitle>Use this form to manage post records in your database.</:subtitle>
</.header>
<.form for={@form} id="post-form" phx-change="validate" phx-submit="save">
<.input field={@form[:title]} type="text" label="Title" />
<.input field={@form[:body]} type="textarea" label="Body" />
<footer>
<.button phx-disable-with="Saving..." variant="primary">Save Post</.button>
<.button navigate={return_path(@return_to, @post)}>Cancel</.button>
</footer>
</.form>
</Layouts.app>
"""
end
@impl true
def mount(params, _session, socket) do
{:ok,
socket
|> assign(:return_to, return_to(params["return_to"]))
|> apply_action(socket.assigns.live_action, params)}
end
defp return_to("show"), do: "show"
defp return_to(_), do: "index"
defp apply_action(socket, :edit, %{"id" => id}) do
post = Blog.get_post!(id)
socket
|> assign(:page_title, "Edit Post")
|> assign(:post, post)
|> assign(:form, to_form(Blog.change_post(post)))
end
defp apply_action(socket, :new, _params) do
post = %Post{}
socket
|> assign(:page_title, "New Post")
|> assign(:post, post)
|> assign(:form, to_form(Blog.change_post(post)))
end
@impl true
def handle_event("validate", %{"post" => post_params}, socket) do
changeset = Blog.change_post(socket.assigns.post, post_params)
{:noreply, assign(socket, form: to_form(changeset, action: :validate))}
end
def handle_event("save", %{"post" => post_params}, socket) do
save_post(socket, socket.assigns.live_action, post_params)
end
defp save_post(socket, :edit, post_params) do
case Blog.update_post(socket.assigns.post, post_params) do
{:ok, post} ->
{:noreply,
socket
|> put_flash(:info, "Post updated successfully")
|> push_navigate(to: return_path(socket.assigns.return_to, post))}
{:error, %Ecto.Changeset{} = changeset} ->
{:noreply, assign(socket, form: to_form(changeset))}
end
end
defp save_post(socket, :new, post_params) do
case Blog.create_post(post_params) do
{:ok, post} ->
{:noreply,
socket
|> put_flash(:info, "Post created successfully")
|> push_navigate(to: return_path(socket.assigns.return_to, post))}
{:error, %Ecto.Changeset{} = changeset} ->
{:noreply, assign(socket, form: to_form(changeset))}
end
end
defp return_path("index", _post), do: ~p"/posts"
defp return_path("show", post), do: ~p"/posts/#{post}"
end
Uma LiveView para duas páginas:
mount/3olhasocket.assigns.live_action, que vem da rota (:newou:edit), e chamaapply_action/3, que prepara o título da página, o post (novo ou buscado no banco) e o formulário;to_form/1transforma o changeset num%Phoenix.HTML.Form{}, que o<.form for={@form}>e cada<.input field={@form[:title]}>usam;return_toguarda para onde voltar depois de salvar: a listagem, ou a página do post quando a edição veio de lá (?return_to=show).
O formulário tem dois bindings:
phx-change="validate": a cada mudança num campo, o cliente manda o formulário inteiro no evento"validate"; o servidor monta o changeset de novo e devolve o formulário com os erros, marcado comaction: :validate(sem essa ação,<.input>não mostra erro nenhum);phx-submit="save": no envio, o evento"save"grava e navega.
Abra o formulário de post novo:
{form_lv, entrada} = Trilha.abrir_lv("/posts/new", como: "ana")
Trilha.resumo_lv(entrada)
Trilha.pergunta(:tocados)
Trilha.digitar/4 recebe o evento, todos os campos com os valores atuais e a lista dos campos em que a pessoa já mexeu. Simule a pessoa que escreveu no título, apagou tudo e ainda não clicou no corpo:
{form_lv, resposta} =
Trilha.digitar(form_lv, "validate", [{"post[title]", ""}, {"post[body]", ""}], ["post[title]"])
Trilha.json(resposta)
O diff traz o campo de título com a classe input-error e uma mensagem can't be blank (o apóstrofo vem escapado como HTML); o corpo continua com a classe w-full textarea, sem erro. O changeset acusou os dois campos, mas o cliente mandou post[_unused_body] junto, e used_input?/1 diz que o corpo ainda não foi usado. Agora a pessoa escreveu um título e passou pelo corpo:
{form_lv, resposta} =
Trilha.digitar(form_lv, "validate", [{"post[title]", "Uva"}, {"post[body]", ""}], ["post[title]", "post[body]"])
Trilha.json(resposta)
Agora o título voltou à classe w-full input, e o corpo ganhou textarea-error e a mensagem. Repare também no que não veio: os rótulos, os nomes dos campos e o resto da página continuam iguais, e por isso não viajam.
Trilha.pergunta(:salvar_fio)
Envie o formulário completo:
{form_lv, resposta} = Trilha.submeter(form_lv, "save", [{"post[title]", "Uva"}, {"post[body]", "Fruta pequena"}])
Trilha.resumo_lv(resposta)
A resposta ao save não traz HTML: só a instrução live_redirect, com o destino /posts e a flash dentro de um token assinado. É o que push_navigate/2 produz. Trilha.seguir_lv/2 faz o que o navegador faz com ela, um novo join na mesma conexão:
{form_lv, entrada} = Trilha.seguir_lv(form_lv, resposta)
Trilha.resumo_lv(entrada)
A listagem montou do zero, com o post novo e a flash "Post created successfully". Tudo isso pela conexão WebSocket que já estava aberta: nenhuma requisição HTTP.
Trilha.desconectar(form_lv)
Sua vez: um título de pelo menos 3 letras
O changeset do Post só exige os dois campos. Na célula do Hello.Blog.Post, acrescente validate_length(:title, min: 3) ao changeset/2 e avalie a célula. Depois experimente no navegador, em http://localhost:4010/posts/new: o aviso aparece enquanto você digita, sem enviar nada.
O PostLive.Form não muda: ele chama Blog.change_post/2, que chama o Post.changeset/2 novo. A validação mora no schema, e a LiveView só mostra o resultado.
Trilha.solucao(:validacao)
Trilha.conferir(:validacao)
Sua vez: contando palavras
Um phx-change não serve só para erros. Na célula do HelloWeb.PostLive.Form, mostre quantas palavras o corpo tem, atualizando enquanto a pessoa digita:
- no
render/1, logo depois do campo do corpo, acrescente<p id="contagem">{@palavras} palavras</p>; - em cada
apply_action/3, atribua:palavras:0para um post novo, e a contagem depost.bodyna edição; - no
handle_event("validate", ...), atribua:palavrasa partir depost_params["body"].
Para contar, texto |> to_string() |> String.split() |> length() serve (to_string/1 cuida do nil). Uma função privada com esse código evita repeti-lo; ponha-a depois de todas as cláusulas de handle_event/3, para não separá-las.
Trilha.solucao(:contador)
Trilha.conferir(:contador)
O diff de cada tecla carrega só o número novo: o texto " palavras" é estático e já está no navegador.
A página do post: PostLive.Show
lib/hello_web/live/post_live/show.ex
defmodule HelloWeb.PostLive.Show do
use HelloWeb, :live_view
alias Hello.Blog
@impl true
def render(assigns) do
~H"""
<Layouts.app flash={@flash}>
<.header>
Post {@post.id}
<:subtitle>This is a post record from your database.</:subtitle>
<:actions>
<.button navigate={~p"/posts"}>
<.icon name="hero-arrow-left" />
</.button>
<.button variant="primary" navigate={~p"/posts/#{@post}/edit?return_to=show"}>
<.icon name="hero-pencil-square" /> Edit post
</.button>
</:actions>
</.header>
<.list>
<:item title="Title">{@post.title}</:item>
<:item title="Body">{@post.body}</:item>
</.list>
</Layouts.app>
"""
end
@impl true
def mount(%{"id" => id}, _session, socket) do
{:ok,
socket
|> assign(:page_title, "Show Post")
|> assign(:post, Blog.get_post!(id))}
end
end
A mais simples das três: mount/3 busca o post pelo id da URL. Os botões usam navigate para voltar à listagem e para ir à edição com ?return_to=show, de modo que o formulário, depois de salvar, volte para cá.
Navegação ao vivo
Na documentação do LiveView: Live navigation
Há três jeitos de um link mudar de página numa aplicação com LiveView:
| No template | O que acontece |
|---|---|
<.link href={...}> |
Uma requisição HTTP comum: a página recarrega, com renderização estática, WebSocket novo e dois mount/3. |
<.link navigate={...}> ou JS.navigate/1 |
Troca de LiveView sem recarregar: a LiveView atual termina e outra faz join na mesma conexão; um mount/3. Só funciona entre rotas do mesmo live_session. |
<.link patch={...}> ou push_patch/2 |
Fica na mesma LiveView: só muda a URL, e o processo recebe handle_params/3. |
Trilha.pergunta(:navegacao)
Da listagem para a página do Banana, com navigate, como o clique na linha da tabela faz:
{lv, _} = Trilha.abrir_lv("/posts", como: "ana")
{{lv, entrada}, tabela} = Trilha.callbacks(fn -> Trilha.navegar_lv(lv, "/posts/1") end)
Kino.Layout.grid([tabela, Trilha.resumo_lv(entrada)])
Um mount só, já conectado, num processo novo. Nenhuma renderização estática, porque não houve requisição HTTP: o cliente saiu do tópico da listagem (phx_leave) e fez um join com redirect no lugar de url.
O mesmo caminho por href, que equivale a abrir a URL de novo:
{{lv2, _}, tabela} = Trilha.callbacks(fn -> Trilha.abrir_lv("/posts/1", como: "ana") end)
Trilha.desconectar(lv2)
tabela
Dois mount, como na primeira visita.
E patch? Um patch não troca de LiveView: manda live_patch com a URL nova, e a LiveView atual chama handle_params/3. A listagem gerada não define esse callback. Volte à listagem e tente:
{lv, _} = Trilha.navegar_lv(lv, "/posts")
{lv, resposta} = Trilha.patch_lv(lv, "/posts?ordem=title")
Trilha.resumo_lv(resposta)
O processo da LiveView caiu, e o servidor mandou phx_error no tópico, que é o aviso do Phoenix de que o processo do outro lado do canal morreu. O log do servidor mostra o motivo (no Livebook ele aparece na saída da célula, ou nos logs do runtime): function HelloWeb.PostLive.Index.handle_params/3 is undefined or private. No navegador, o cliente perceberia a queda e reconectaria, montando a página de novo. O LiveView só chama handle_params/3 se o módulo o definir; para receber patches, a LiveView precisa dele.
Sua vez: ordenar com patch
Faça a listagem aceitar ?ordem=title, mudando a ordem sem trocar de LiveView. Na célula do HelloWeb.PostLive.Index:
- tire o
stream(:posts, ...)domount/3e definahandle_params(params, _url, socket), que roda logo depois domount/3e de novo a cada patch. Ele lêparams["ordem"]e chamastream(socket, :posts, posts_ordenados, reset: true):"title"ordena pelo título; sem o parâmetro, pela ordem de criação (oid). Oreset: truemanda o navegador refazer a lista; sem ele, os itens seriam acrescentados aos que já estão lá; - no
<.header>, acrescente um<:subtitle>com dois links:<.link patch={~p"/posts"}>e<.link patch={~p"/posts?ordem=title"}>.
A regra é a do guia de navegação: o que depende da URL é carregado em handle_params/3, e o mount/3 fica com o que não muda.
Trilha.solucao(:ordem)
Trilha.conferir(:ordem)
Com a solução avaliada, repita o patch: agora é só handle_params e render, no mesmo processo, e o stream é refeito na ordem dos títulos. Sem a solução, a célula cai como a anterior.
{lv, _} = Trilha.abrir_lv("/posts", como: "ana")
{{lv, resposta}, tabela} = Trilha.callbacks(fn -> Trilha.patch_lv(lv, "/posts?ordem=title") end)
Kino.Layout.grid([tabela, Trilha.resumo_lv(resposta)])
Tempo real com PubSub
Na documentação do LiveView: handle_info/2
Trilha.pergunta(:sem_pubsub)
Abra duas listagens, como a Ana e a Bia em dois navegadores, e crie um post pelo contexto:
{ana, _} = Trilha.abrir_lv("/posts", como: "ana")
{bia, _} = Trilha.abrir_lv("/posts", como: "bia")
{:ok, damasco} = Hello.Blog.create_post(%{title: "Damasco", body: "seco"})
{ana, da_ana} = Trilha.receber(ana)
{bia, da_bia} = Trilha.receber(bia)
{da_ana, da_bia}
{:nada, :nada}: nenhuma das duas listagens ficou sabendo. (Se você já fez o exercício abaixo, chegam dois diffs, cada um com o post novo entrando no stream.) O notebook 16 mostrou o caminho: o processo da LiveView assina um tópico do Phoenix.PubSub e trata as mensagens em handle_info/2. Os geradores com escopo (notebook 14) já escrevem esse código; o gerado sem escopo, não.
{:ok, _} = Hello.Blog.delete_post(damasco)
Trilha.desconectar(ana)
Trilha.desconectar(bia)
Sua vez: a lista ao vivo
- Na célula do
Hello.Blog, acrescentesubscribe_posts/0, que chamaPhoenix.PubSub.subscribe(Hello.PubSub, "posts"), e façacreate_post/1,update_post/2edelete_post/1publicarem, quando derem certo,{:created, post},{:updated, post}e{:deleted, post}no mesmo tópico, comPhoenix.PubSub.broadcast/3. Uma função privada que recebe o resultado ({:ok, post}ou o erro) e o devolve intacto encaixa no fim de cada pipeline. - Na célula do
HelloWeb.PostLive.Index, assine o tópico nomount/3, dentro deif connected?(socket)(a renderização estática não tem processo para receber mensagens), e definahandle_info/2::createde:updatedviramstream_insert(socket, :posts, post)(com um id que já está na lista, o elemento é substituído), e:deletedvirastream_delete(socket, :posts, post).
Abra http://localhost:4010/posts em duas abas e crie, edite e apague posts numa delas.
Trilha.solucao(:tempo_real)
Trilha.conferir(:tempo_real)
Os geradores com escopo fazem um pouco diferente: o handle_info/2 deles relê a lista inteira com reset: true a cada mensagem, o que é mais simples e funciona com qualquer ordenação, ao custo de reenviar a lista.
E com escopo?
Este notebook usou um projeto sem autenticação, e o gerador fez código sem escopo. Num projeto com mix phx.gen.auth --live, que gera a autenticação em LiveView e marca o escopo do usuário como padrão, o mesmo comando gera outra coisa:
Trilha.pergunta(:escopo)
- as rotas entram num
live_session :require_authenticated_user, on_mount: [{HelloWeb.UserAuth, :require_authenticated}]; o hookon_mountroda antes domount/3de cada LiveView do bloco e põe o@current_scopeno socket (ou redireciona para o login); - toda função do contexto recebe o escopo (
Blog.list_posts(socket.assigns.current_scope)), como no notebook 14; - o
IndexassinaBlog.subscribe_posts(current_scope), um tópico por usuário, e ohandle_info/2refaz a lista comreset: true.
O guia Scopes mostra esse código. O notebook 13 usou a variante da autenticação com controllers; a variante LiveView tem o mesmo contexto, as mesmas tabelas e as mesmas rotas, com LiveViews no lugar das páginas de login, cadastro e configurações.
Sem roteiro: o painel da vitrine do Brechó
No notebook 08, a vitrine do Brechó virou o contexto Hello.Vitrine, com páginas feitas por um controller. Agora ela ganha um painel em LiveView, numa página só: a lista das peças à venda, um formulário que valida enquanto se digita e um botão de vender em cada linha.
O Brechó até aqui
As células abaixo são o estado do Brechó no fim do notebook 08: a tabela, o contexto e o controller. Avalie-as em ordem.
priv/repo/migrations/20250320000000_create_produtos.exs
defmodule Hello.Repo.Migrations.CreateProdutos do
use Ecto.Migration
def change do
create table(:produtos) do
add :nome, :string, null: false
add :preco_centavos, :integer, null: false
add :vendido, :boolean, default: false, null: false
timestamps(type: :utc_datetime)
end
end
end
Trilha.migracao(20250320000000, Hello.Repo.Migrations.CreateProdutos)
Ecto.Migrator.run(Hello.Repo, Trilha.migracoes(), :up, all: true)
lib/hello/vitrine/produto.ex e lib/hello/vitrine.ex
defmodule Hello.Vitrine.Produto do
use Ecto.Schema
import Ecto.Changeset
schema "produtos" do
field :nome, :string
field :preco_centavos, :integer
field :vendido, :boolean, default: false
timestamps(type: :utc_datetime)
end
@doc false
def changeset(produto, attrs) do
produto
|> cast(attrs, [:nome, :preco_centavos])
|> validate_required([:nome, :preco_centavos])
|> validate_number(:preco_centavos, greater_than: 0)
end
end
defmodule Hello.Vitrine do
@moduledoc """
A vitrine do Brechó: o que está à venda e a venda de cada peça.
"""
import Ecto.Query, warn: false
alias Hello.Repo
alias Hello.Vitrine.Produto
def listar_disponiveis do
Repo.all(from p in Produto, where: not p.vendido, order_by: p.preco_centavos)
end
def buscar_produto!(id), do: Repo.get!(Produto, id)
def cadastrar_produto(attrs) do
%Produto{}
|> Produto.changeset(attrs)
|> Repo.insert()
end
def change_produto(%Produto{} = produto, attrs \\ %{}) do
Produto.changeset(produto, attrs)
end
@doc """
Marca a peça como vendida, se ninguém a vendeu antes.
A verificação e a mudança acontecem num só UPDATE, então duas vendas
simultâneas da mesma peça nunca dão certo ao mesmo tempo.
"""
def vender(%Produto{id: id}) do
consulta = from p in Produto, where: p.id == ^id and not p.vendido, select: p
case Repo.update_all(consulta, set: [vendido: true, updated_at: DateTime.utc_now(:second)]) do
{1, [produto]} -> {:ok, produto}
{0, []} -> {:error, :ja_vendido}
end
end
end
lib/hello_web/router.ex: as rotas dos posts, as do controller da vitrine e a do painel. A rota do painel já está aqui; o módulo é com você.
defmodule HelloWeb.Router do
use HelloWeb, :router
pipeline :browser do
plug :accepts, ["html"]
plug :fetch_session
plug :fetch_live_flash
plug :put_root_layout, html: {HelloWeb.Layouts, :root}
plug :protect_from_forgery
plug :put_secure_browser_headers
end
pipeline :api do
plug :accepts, ["json"]
end
scope "/", HelloWeb do
pipe_through :browser
get "/", PageController, :home
live "/posts", PostLive.Index, :index
live "/posts/new", PostLive.Form, :new
live "/posts/:id", PostLive.Show, :show
live "/posts/:id/edit", PostLive.Form, :edit
# O Brechó até aqui (notebook 08): a vitrine com controller.
get "/produtos", ProdutoController, :index
post "/produtos", ProdutoController, :create
post "/produtos/:id/vender", ProdutoController, :vender
# O desafio: o painel da vitrine, com LiveView.
live "/painel", PainelLive
end
end
lib/hello_web/controllers/produto_controller.ex e produto_html.ex
defmodule HelloWeb.ProdutoController do
use HelloWeb, :controller
alias Hello.Vitrine
def index(conn, _params) do
render(conn, :index, produtos: Vitrine.listar_disponiveis())
end
def create(conn, %{"produto" => attrs}) do
case Vitrine.cadastrar_produto(attrs) do
{:ok, produto} ->
conn |> put_status(201) |> json(%{id: produto.id})
{:error, changeset} ->
erros = Ecto.Changeset.traverse_errors(changeset, fn {msg, _opts} -> msg end)
conn |> put_status(422) |> json(%{errors: erros})
end
end
def vender(conn, %{"id" => id}) do
case Vitrine.vender(Vitrine.buscar_produto!(id)) do
{:ok, produto} ->
conn |> put_flash(:info, "#{produto.nome} vendido.") |> redirect(to: ~p"/produtos")
{:error, :ja_vendido} ->
conn |> put_flash(:error, "Esta peça já foi vendida.") |> redirect(to: ~p"/produtos")
end
end
end
defmodule HelloWeb.ProdutoHTML do
use HelloWeb, :html
def index(assigns) do
~H"""
<Layouts.app flash={@flash}>
<.header>Vitrine do Brechó</.header>
<.table id="produtos" rows={@produtos}>
<:col :let={produto} label="Peça">{produto.nome}</:col>
<:col :let={produto} label="Preço">{produto.preco_centavos} centavos</:col>
<:action :let={produto}>
<.link href={~p"/produtos/#{produto}/vender"} method="post">Vender</.link>
</:action>
</.table>
</Layouts.app>
"""
end
end
for {nome, preco} <- [{"Jaqueta jeans", 8000}, {"Vestido floral", 4500}] do
{:ok, _} = Hello.Vitrine.cadastrar_produto(%{nome: nome, preco_centavos: preco})
end
Trilha.consultar("SELECT id, nome, preco_centavos, vendido FROM produtos ORDER BY id")
O desafio
Escreva HelloWeb.PainelLive, a LiveView da rota /painel:
- a lista das peças à venda (
Vitrine.listar_disponiveis/0) num stream chamado:produtos, mostrada com<.table>; - um formulário com
id="produto-form", os camposnomeepreco_centavos(umto_form/1doVitrine.change_produto/2gera os nomesproduto[nome]eproduto[preco_centavos]),phx-change="validar"ephx-submit="cadastrar". Ao validar, os erros aparecem enquanto se digita (um preço 0 mostramust be greater than 0) e nada é gravado. Ao cadastrar, a peça é gravada, entra na lista e o formulário volta a ficar vazio; com dados inválidos, nada é gravado e os erros aparecem; - em cada linha, um link
phx-click="vender"comphx-value-id={produto.id}, que vende a peça comVitrine.vender/1e a tira da lista. Se outra pessoa vendeu antes, mostre a flash de erroEsta peça já foi vendida.(a mesma mensagem do controller) e tire a peça da lista também.
A célula abaixo tem um esqueleto que compila; substitua-o. Abra http://localhost:4010/painel em duas abas para testar a venda simultânea.
lib/hello_web/live/painel_live.ex
defmodule HelloWeb.PainelLive do
use HelloWeb, :live_view
def render(assigns) do
~H"""
<Layouts.app flash={@flash}>
<.header>Painel da vitrine</.header>
<p>Em construção.</p>
</Layouts.app>
"""
end
def mount(_params, _session, socket) do
{:ok, socket}
end
end
Trilha.solucao(:brecho)
Trilha.conferir(:brecho)
Para ir além: a lista de cada painel aberto ainda não muda quando outra pessoa cadastra ou vende uma peça. Com o que você fez nos posts (subscribe no mount/3 conectado, broadcast no contexto, handle_info/2 com stream_insert/3 e stream_delete/3), a vitrine fica ao vivo para todos.
No seu projeto
- Gere o CRUD:
mix phx.gen.live Blog Post posts title:string body:text. - Copie as quatro rotas
liveque o gerador imprime para o scope"/"dolib/hello_web/router.ex, com/posts/newantes de/posts/:id. mix ecto.migrateemix phx.server; abra http://localhost:4000/posts.- Nas ferramentas do navegador, aba Network, filtre por WS e clique na conexão
websocket: a aba de mensagens mostra ophx_join, as respostas e os diffs, no mesmo formato que este notebook mostrou. - Rode
mix test test/hello_web/live/post_live_test.exs. Os testes gerados usamPhoenix.LiveViewTest(live/2,form/3,render_change/1,render_submit/1,follow_redirect/3), que no projeto funciona porque omix.exsgerado inclui a bibliotecalazy_htmlno ambiente de teste. Este notebook não a tem, por isso usou um cliente WebSocket próprio. - Faça os exercícios no projeto: a validação de título (os testes gerados usam "some title", que passa), a contagem de palavras, a ordenação com
patche a lista ao vivo. Depois de mudar oIndexe oForm, rode os testes de novo.
Num projeto com mix phx.gen.auth --live, o mesmo mix phx.gen.live gera a versão com escopo: confira o live_session e o on_mount no router.
Revisão
mix phx.gen.livegera a mesma camada de dados dophx.gen.html(migration, schema, contexto) e três LiveViews:Index,ShoweForm. Rotasliveligam caminhos a LiveViews, e a live action diz aoFormse é:newou:edit.- Toda LiveView monta duas vezes: na renderização estática (requisição HTTP,
connected?falso) e no processo que nasce com o join do WebSocket. O processo fica vivo enquanto a página estiver aberta. - Streams mandam coleções ao navegador sem guardá-las no servidor. Depois, só viajam operações: entra
posts-5, saiposts-4.reset: truerefaz a lista. phx-changevalida enquanto se digita (to_form(changeset, action: :validate)), e os campos ainda não usados não mostram erros (_unused_).phx-submitgrava;push_navigate/2responde comlive_redirect, e o cliente faz um novo join na mesma conexão.hrefrecarrega a página;navigatetroca de LiveView pela mesma conexão;patchfica na mesma LiveView e chamahandle_params/3, que só roda se a LiveView o definir.- Para várias telas mudarem juntas, o contexto publica no PubSub e cada LiveView conectada assina o tópico e trata a mensagem em
handle_info/2.
Com este notebook termina o módulo 9. O 16 mostrou o que é uma LiveView: um processo com estado, que renderiza uma vez e depois troca diffs com o navegador. O 17 aplicou isso a um CRUD completo, com streams, formulários, navegação e tempo real. O próximo módulo da trilha trata dos Channels, a camada de tempo real sobre a qual o LiveView foi construído.