Trilha Phoenix · Componentes e HEEx
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"}
],
consolidate_protocols: false,
config: [
phoenix: [json_library: Jason, plug_init_mode: :runtime],
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]
]}
]
]
)
defmodule Trilha do
@moduledoc """
Apoio deste notebook: faz requisições ao endpoint, mostra o resultado,
renderiza componentes, confere os exercícios e guarda as perguntas e as soluções.
Não faz parte do Phoenix. Você não precisa ler este módulo para seguir o guia.
"""
@endpoint HelloWeb.Endpoint
@router HelloWeb.Router
# --- requisições -----------------------------------------------------------
@doc "Despacha uma requisição para o endpoint, sem passar pela rede."
def requisicao(metodo \\ :get, caminho) do
descartar_respostas_antigas()
conn =
Phoenix.ConnTest.build_conn()
|> Phoenix.ConnTest.dispatch(@endpoint, metodo, caminho, nil)
# 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 "O que o router decide para uma requisição, sem executá-la."
def rota(metodo \\ :get, caminho) do
so_caminho = caminho |> String.split("?") |> hd()
Phoenix.Router.route_info(@router, verbo(metodo), so_caminho, "localhost")
rescue
_ -> :error
end
defp verbo(metodo), do: metodo |> to_string() |> String.upcase()
@doc "Faz a requisição e mostra o que o router decidiu e como o conn terminou."
def mostrar(metodo \\ :get, caminho) do
if is_binary(caminho) and String.starts_with?(caminho, "/") do
Kino.Markdown.new(
Enum.join(
["### #{verbo(metodo)} #{caminho}", md_rota(rota(metodo, caminho)), md_resultado(requisicao(metodo, caminho))],
"\n\n"
)
)
else
Kino.Markdown.new("O caminho precisa começar com `/`, por exemplo `/hello`.")
end
end
defp md_rota(:error) do
"**Router** — `Phoenix.Router.route_info/4` devolveu `:error`: nenhuma rota casa com esse verbo e esse caminho."
end
defp md_rota(info) do
tabela(
"**Router** — o que `Phoenix.Router.route_info/4` devolve",
[
{":route", info.route},
{":plug", info.plug},
{":plug_opts", info.plug_opts},
{":path_params", info.path_params},
{":pipe_through", info.pipe_through}
]
)
end
defp md_resultado({:erro, erro}) do
"**Resultado** — a requisição levantou uma exceção:\n\n" <>
cerca(Exception.format_banner(:error, erro), "")
end
defp md_resultado({:ok, conn}) do
tabela(
"**O `conn` no fim da viagem**",
[
{"conn.status", conn.status},
{"conn.assigns", conn.assigns},
{"conn.private.phoenix_view", conn.private[:phoenix_view]},
{"conn.private.phoenix_template", conn.private[:phoenix_template]},
{"conn.private.phoenix_root_layout", conn.private[:phoenix_root_layout]},
{"content-type", Plug.Conn.get_resp_header(conn, "content-type")}
]
) <> "\n\n**`conn.resp_body`**\n\n" <> cerca(to_string(conn.resp_body), "html")
end
defp tabela(titulo, pares) do
linhas =
Enum.map(pares, fn {campo, valor} ->
texto = valor |> inspect(limit: 30, printable_limit: 300) |> String.replace("|", "\\|") |> String.replace("`", "'")
"| `#{campo}` | `#{texto}` |"
end)
Enum.join([titulo, "", "| Campo | Valor |", "| --- | --- |" | linhas], "\n")
end
defp cerca(texto, linguagem) do
marca = String.duplicate("`", 3)
marca <> linguagem <> "\n" <> String.trim(texto) <> "\n" <> marca
end
# --- componentes -------------------------------------------------------------
@doc "Converte o resultado de um ~H (ou uma string) em HTML."
def html(%Phoenix.LiveView.Rendered{} = rendered) do
rendered |> Phoenix.HTML.Safe.to_iodata() |> IO.iodata_to_binary()
end
def html(texto) when is_binary(texto), do: texto
@doc "Mostra lado a lado o resultado como o navegador desenha e o HTML gerado."
def ver(conteudo) do
codigo = conteudo |> html() |> String.trim()
Kino.Layout.grid(
[
Kino.Layout.grid([Kino.Markdown.new("**Na página**"), Kino.HTML.new(codigo)]),
Kino.Layout.grid([Kino.Markdown.new("**HTML gerado**"), Kino.Markdown.new(cerca(codigo, "html"))])
],
columns: 2
)
end
@doc "Faz GET no caminho e mostra a página renderizada ao lado do HTML."
def pagina(caminho) do
case requisicao(:get, caminho) do
{:ok, conn} -> ver(to_string(conn.resp_body))
{:erro, erro} -> Kino.Markdown.new(explicar(erro))
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
%{
rendered: %{
texto: "a célula a seguir chama `HelloWeb.HelloHTML.show/1` diretamente, passando um mapa de assigns. O que ela devolve?",
opcoes: [
a: "Uma string com o HTML pronto.",
b: "Uma struct `%Phoenix.LiveView.Rendered{}`, que guarda separadas as partes fixas e as partes dinâmicas do template.",
c: "Um `%Plug.Conn{}` com o HTML em `resp_body`.",
d: "Nada: templates só funcionam dentro de uma requisição."
],
correta: :b,
explicacao:
"Um componente de função é uma função comum: recebe um mapa de assigns e devolve o resultado do `~H`. Esse resultado ainda não é texto. É uma struct que separa o HTML fixo (`static`) das partes interpoladas (`dynamic`), e o Phoenix só a converte em HTML no fim da renderização. Essa separação é o que permite ao LiveView reenviar apenas as partes que mudaram."
},
attr_faltando: %{
texto: "um template chama `<HelloWeb.HelloHTML.greet />` sem o atributo `messenger`, declarado com `required: true`. O que acontece?",
opcoes: [
a: "O módulo não compila: atributo obrigatório faltando é erro de compilação.",
b: "O módulo compila com um aviso; na hora de renderizar, o template quebra porque `@messenger` não está nos assigns.",
c: "O template renderiza `Hello World, from !`.",
d: "O Phoenix preenche `messenger` com `nil` e renderiza normalmente."
],
correta: :b,
explicacao:
"As declarações `attr` são conferidas em tempo de compilação, mas o resultado é um aviso (warning), não um erro: o módulo compila. Na renderização, `@messenger` procura a chave `:messenger` nos assigns, não acha e levanta `KeyError`. Os avisos do compilador mostram o problema antes de alguém abrir a página."
},
eex_tags: %{
texto: "no template da próxima célula, a linha `<%= if @admin? do %>` tem o sinal `=`, e as linhas `<% else %>` e `<% end %>` não têm. Qual é a diferença?",
opcoes: [
a: "Nenhuma; o `=` é opcional.",
b: "`<%= ... %>` escreve no HTML o valor da expressão; `<% ... %>` não escreve nada. `else` e `end` são só partes do `if` aberto na primeira linha.",
c: "`<% ... %>` é um comentário.",
d: "`<%= ... %>` só aceita strings."
],
correta: :b,
explicacao:
"Em EEx, e portanto em HEEx, `<%= %>` avalia a expressão e insere o resultado; `<% %>` não insere. `<% else %>` e `<% end %>` não são expressões completas: continuam e fecham o `if` aberto em `<%= if ... do %>`. Em Elixir, `if` é uma expressão cujo valor é o bloco que rodou, e é esse valor que entra na página. Com `for`, o valor é a lista dos blocos gerados."
},
escape: %{
texto: "o template tem `{\"<b>Bold?</b>\"}`. O que o navegador mostra?",
opcoes: [
a: "A palavra **Bold?** em negrito.",
b: "O texto `<b>Bold?</b>`, com os sinais de menor e maior visíveis.",
c: "Nada: o HEEx descarta tags dentro de strings.",
d: "O template não compila."
],
correta: :b,
explicacao:
"A interpolação `{...}` escapa o valor: `<` vira `<` e `>` vira `>` no HTML enviado, e o navegador mostra os sinais em vez de interpretar a tag. É essa a proteção contra injeção de HTML. Um nome de usuário como `<script>...</script>` aparece como texto. `raw/1` desliga a proteção e só serve para conteúdo em que você confia."
},
atributo_false: %{
texto: "com o assign `bloqueado` igual a `false`, o que o HEEx gera para `<input disabled={@bloqueado} />`?",
opcoes: [
a: "`<input disabled=\"false\">`",
b: "`<input>`, sem o atributo.",
c: "`<input disabled>`",
d: "Um erro, porque `disabled` não aceita booleanos."
],
correta: :b,
explicacao:
"Em HTML, a simples presença de um atributo booleano já o liga: `disabled=\"false\"` desabilitaria o campo. Por isso o HEEx trata `false` e `nil` como ausência do atributo, e `true` como o atributo sem valor (`disabled`). Qualquer outro valor é escrito entre aspas, escapado."
},
core_use: %{
texto: "`HelloWeb.CoreComponents` começa com `use Phoenix.Component`, enquanto os demais módulos de HTML usam `use HelloWeb, :html`. Por quê?",
opcoes: [
a: "Por desempenho: `use HelloWeb, :html` deixa a compilação mais lenta.",
b: "Porque `use HelloWeb, :html` importa `HelloWeb.CoreComponents`. Dentro do próprio CoreComponents, o módulo tentaria importar a si mesmo enquanto ainda está sendo compilado.",
c: "Porque CoreComponents não pode declarar atributos.",
d: "É só convenção, sem efeito prático."
],
correta: :b,
explicacao:
"Para importar um módulo, o compilador precisa dele já compilado. Se CoreComponents fizesse `use HelloWeb, :html`, a compilação dele esperaria por ela mesma: um impasse (deadlock). `use Phoenix.Component` traz `~H`, `attr` e `slot` sem esse import."
},
global: %{
texto: "o `button` de CoreComponents declara `attr :rest, :global`. Um template chama `<.button phx-click=\"salvar\" foo=\"bar\">Salvar</.button>`. O que acontece?",
opcoes: [
a: "Os dois atributos são descartados, porque não foram declarados com `attr`.",
b: "`phx-click` é aceito pelo atributo global e chega ao `<button>`; `foo` gera um aviso de compilação, mas também chega ao HTML.",
c: "A compilação falha por causa de `foo`.",
d: "Só `foo` chega ao HTML."
],
correta: :b,
explicacao:
"Um atributo `:global` recolhe os atributos HTML globais que o componente não declarou (`id`, `class`, `title`, `data-*`, `aria-*`, os `phx-*` do LiveView e os que estiverem em `include:`), e o componente os espalha na tag com `{@rest}`. Um nome fora dessa lista, como `foo`, gera o aviso `undefined attribute`. Ele ainda vai para o HTML, mas o aviso costuma indicar um erro de digitação."
},
inner_content: %{
texto: "a página `/hello/Frank` é um documento HTML completo, mas o template `show` não tem `<html>` nem `<head>`. Quem coloca essas tags?",
opcoes: [
a: "O layout da aplicação, `Layouts.app`.",
b: "O root layout, `Layouts.root`, escolhido pelo plug `put_root_layout` da pipeline `:browser`. O template renderizado entra no lugar de `{@inner_content}`.",
c: "O navegador, que completa o HTML que falta.",
d: "O endpoint."
],
correta: :b,
explicacao:
"São duas camadas. O template chama `<Layouts.app>`, um componente como outro qualquer, que envolve o conteúdo com o cabeçalho e as mensagens flash. O resultado é inserido no root layout, em `{@inner_content}`. O root layout não é chamado pelo template: quem o define é o plug `put_root_layout` no router, e ele é o mesmo para controllers e LiveViews."
}
}
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
%{
greet: ~S'''
defmodule HelloWeb.HelloHTML do
use HelloWeb, :html
def index(assigns) do
~H"""
<section>
<h2>Hello World, from Phoenix!</h2>
</section>
"""
end
attr :messenger, :string, required: true
def greet(assigns) do
~H"""
<h2>Hello World, from {@messenger}!</h2>
"""
end
def show(assigns) do
~H"""
<Layouts.app flash={@flash}>
<section>
<.greet messenger={@messenger} />
</section>
</Layouts.app>
"""
end
end
''',
lista: ~S'''
# No HelloController:
def index(conn, _params) do
render(conn, :index, messengers: ["Frank", "Ana", "Bia"])
end
# No HelloHTML:
def index(assigns) do
~H"""
<section>
<h2>Hello World, from Phoenix!</h2>
<ul>
<li :for={messenger <- @messengers}>
<a href={~p"/hello/#{messenger}"}>{messenger}</a>
</li>
</ul>
<p :if={@messengers == []}>Ninguém por aqui.</p>
</section>
"""
end
''',
admin: ~S'''
# Em HelloWeb.Layouts, depois de app/1:
attr :flash, :map, default: %{}
slot :inner_block, required: true
def admin(assigns) do
~H"""
<header>
<strong>Hello</strong> · Painel administrativo
</header>
<main>
<.flash_group flash={@flash} />
{render_slot(@inner_block)}
</main>
"""
end
# No router, dentro do scope "/":
get "/admin", PageController, :admin
# No PageController:
def admin(conn, _params) do
render(conn, :admin)
end
# No PageHTML:
def admin(assigns) do
~H"""
<Layouts.admin flash={@flash}>
<h1>Administração</h1>
</Layouts.admin>
"""
end
''',
produtos: ~S'''
# No router, dentro do scope "/":
get "/produtos", ProdutoController, :index
# O ProdutoController fica como estava na célula.
defmodule HelloWeb.ProdutoHTML do
use HelloWeb, :html
attr :name, :string, required: true
attr :price, :integer, required: true
attr :sold, :boolean, default: false
slot :actions
def product_card(assigns) do
~H"""
<article class="produto">
<h3>{@name}</h3>
<p>R$ {@price}</p>
<span :if={@sold}>Vendido</span>
<div :if={@actions != []}>{render_slot(@actions)}</div>
</article>
"""
end
def index(assigns) do
~H"""
<Layouts.app flash={@flash}>
<h1>Brechó</h1>
<.product_card :for={p <- @produtos} name={p.name} price={p.price} sold={p.sold}>
<:actions :if={!p.sold}>
<.button>Comprar</.button>
</:actions>
</.product_card>
</Layouts.app>
"""
end
end
'''
}
end
# --- conferência dos exercícios ----------------------------------------------
@doc "Confere um exercício fazendo requisições e renderizando componentes 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, "O teste levantou uma exceção: `" <> Exception.format_banner(:error, erro) <> "`"}
end
defp verificacoes do
%{
greet: [
{"`HelloWeb.HelloHTML` define a função `greet/1`.", fn -> exporta(HelloWeb.HelloHTML, :greet) end},
{"`greet` declara o atributo `messenger`.",
fn ->
case componente(HelloWeb.HelloHTML, :greet) do
%{attrs: attrs} ->
if Enum.any?(attrs, &(&1.name == :messenger)),
do: :ok,
else: {:erro, "Declare `attr :messenger, :string, required: true` logo acima de `def greet`."}
nil ->
{:erro, "`greet` não tem declarações. Escreva `attr :messenger, :string, required: true` logo acima de `def greet`."}
end
end},
{"`greet(%{messenger: \"Ana\"})` gera o `<h2>` com `Hello World, from Ana!`.",
fn ->
renderiza_com(HelloWeb.HelloHTML, :greet, %{messenger: "Ana"}, ["<h2>", "Hello World, from Ana!"])
end},
{"`GET /hello/Frank` continua mostrando `Hello World, from Frank!`.", fn -> contem("/hello/Frank", "Hello World, from Frank!") end}
],
lista: [
{"`GET /hello` responde com status 200.", fn -> status("/hello", 200) end},
{"`GET /hello` tem links para `/hello/Frank`, `/hello/Ana` e `/hello/Bia`.",
fn ->
Enum.reduce_while(["Frank", "Ana", "Bia"], :ok, fn nome, _ ->
case contem("/hello", "href=\"/hello/#{nome}\"") do
:ok -> {:cont, :ok}
erro -> {:halt, erro}
end
end)
end},
{"Com `messengers: []`, o template mostra `Ninguém por aqui.`.",
fn -> renderiza_com(HelloWeb.HelloHTML, :index, %{messengers: [], flash: %{}}, ["Ninguém por aqui."]) end},
{"Com `messengers: [\"Zé\"]`, o template mostra o link e não mostra `Ninguém por aqui.`.",
fn ->
case renderizar(HelloWeb.HelloHTML, :index, %{messengers: ["Zé"], flash: %{}}) do
{:ok, html} ->
cond do
String.contains?(html, "Ninguém por aqui.") ->
{:erro, "A mensagem apareceu mesmo com a lista cheia. Confira a condição do `:if`."}
not String.contains?(html, "/hello/Z") ->
{:erro, "Não achei o link para `/hello/Zé`. Use `:for` para gerar um `<a>` por messenger."}
true ->
:ok
end
{:erro, erro} ->
{:erro, explicar(erro)}
end
end}
],
admin: [
{"`HelloWeb.Layouts` define `admin/1`.", fn -> exporta(HelloWeb.Layouts, :admin) end},
{"`GET /admin` responde com status 200.", fn -> status("/admin", 200) end},
{"A página mostra `Painel administrativo`.", fn -> contem("/admin", "Painel administrativo") end},
{"A página não usa o layout da aplicação.",
fn ->
case requisicao(:get, "/admin") do
{:ok, conn} ->
if String.contains?(to_string(conn.resp_body), "layout da aplicação"),
do: {:erro, "A página ainda mostra o cabeçalho de `Layouts.app`. O template deve chamar `<Layouts.admin>`."},
else: :ok
{:erro, erro} ->
{:erro, explicar(erro)}
end
end},
{"A página continua dentro do root layout.", fn -> contem("/admin", "</html>") end}
],
produtos: [
{"Existe um componente `product_card` com os atributos `name`, `price` e `sold` e o slot `actions`.",
fn ->
case product_card() do
nil ->
{:erro, "Não achei `product_card` com declarações `attr` em `HelloWeb.ProdutoHTML` nem em `HelloWeb.CoreComponents`."}
%{attrs: attrs, slots: slots} ->
nomes = Enum.map(attrs, & &1.name)
cond do
not Enum.all?([:name, :price, :sold], &(&1 in nomes)) ->
{:erro, "Os atributos declarados são `#{inspect(nomes)}`; faltam alguns de `[:name, :price, :sold]`."}
not Enum.any?(slots, &(&1.name == :actions)) ->
{:erro, "Declare o slot com `slot :actions`."}
true ->
:ok
end
end
end},
{"`sold` é `:boolean` com valor padrão `false`.",
fn ->
sold = Enum.find(product_card().attrs, &(&1.name == :sold))
if sold.type == :boolean and Keyword.get(sold.opts, :default) == false,
do: :ok,
else: {:erro, "Hoje está `attr :sold, #{inspect(sold.type)}, #{inspect(sold.opts)}`. O esperado é `attr :sold, :boolean, default: false`."}
end},
{"`GET /produtos` responde com status 200.", fn -> status("/produtos", 200) end},
{"A página mostra os três produtos.",
fn ->
Enum.reduce_while(["Jaqueta jeans", "Vitrola antiga", "Luminária de mesa"], :ok, fn nome, _ ->
case contem("/produtos", nome) do
:ok -> {:cont, :ok}
erro -> {:halt, erro}
end
end)
end},
{"A página mostra o preço no formato `R$ 120`.", fn -> contem("/produtos", "R$ 120") end},
{"`Vendido` aparece uma vez, só na vitrola.", fn -> conta("/produtos", "Vendido", 1) end},
{"`Comprar` aparece duas vezes, só nos itens à venda.", fn -> conta("/produtos", "Comprar", 2) end}
]
}
end
defp exporta(modulo, funcao) do
if Code.ensure_loaded?(modulo) and function_exported?(modulo, funcao, 1),
do: :ok,
else: {:erro, "`#{inspect(modulo)}` não define `#{funcao}/1`. Defina a função e avalie a célula."}
end
defp componente(modulo, nome) do
if Code.ensure_loaded?(modulo) and function_exported?(modulo, :__components__, 0),
do: modulo.__components__()[nome]
end
defp product_card do
componente(HelloWeb.ProdutoHTML, :product_card) || componente(HelloWeb.CoreComponents, :product_card)
end
defp renderizar(modulo, funcao, assigns) do
{:ok, modulo |> apply(funcao, [assigns]) |> html()}
rescue
erro -> {:erro, erro}
end
defp renderiza_com(modulo, funcao, assigns, trechos) do
case renderizar(modulo, funcao, assigns) do
{:ok, codigo} ->
case Enum.reject(trechos, &String.contains?(codigo, &1)) do
[] -> :ok
faltam -> {:erro, "O HTML gerado não contém #{Enum.map_join(faltam, " nem ", &"`#{&1}`")}:\n\n `#{String.slice(codigo, 0, 300)}`"}
end
{:erro, erro} ->
{:erro, explicar(erro)}
end
end
defp status(caminho, esperado) do
case requisicao(:get, caminho) do
{:ok, %{status: ^esperado}} -> :ok
{:ok, conn} -> {:erro, "A resposta veio com status `#{inspect(conn.status)}`."}
{:erro, erro} -> {:erro, explicar(erro)}
end
end
defp contem(caminho, texto) do
case requisicao(:get, caminho) do
{:ok, conn} ->
if String.contains?(to_string(conn.resp_body), texto),
do: :ok,
else: {:erro, "A resposta de `GET #{caminho}` (status `#{inspect(conn.status)}`) não contém `#{texto}`."}
{:erro, erro} ->
{:erro, explicar(erro)}
end
end
defp conta(caminho, texto, esperado) do
case requisicao(:get, caminho) do
{:ok, conn} ->
vezes = conn.resp_body |> to_string() |> String.split(texto) |> length() |> Kernel.-(1)
if vezes == esperado,
do: :ok,
else: {:erro, "`#{texto}` aparece #{vezes} vez(es) na página; o esperado é #{esperado}."}
{:erro, erro} ->
{:erro, explicar(erro)}
end
end
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(%KeyError{key: chave}) when is_atom(chave) do
"Etapa: **view**. O template usa `@#{chave}`, mas esse assign não chegou até ele. Confira o que o controller passa no `render/3`, ou os atributos passados ao componente."
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}`. O átomo no fim da rota é o nome da action."
String.ends_with?(nome, "HTML") ->
"Etapa: **view**. Procurei `#{nome}.#{funcao}/#{aridade}` e não achei. Defina o módulo da view e a função do template."
nome == "HelloWeb.Layouts" ->
"Etapa: **view**. O template chama `<Layouts.#{funcao}>`, mas `HelloWeb.Layouts` não define `#{funcao}/1`."
true ->
"A requisição levantou `UndefinedFunctionError` para `#{nome}.#{funcao}/#{aridade}`."
end
end
defp explicar(erro), do: "A requisição levantou uma exceção:\n\n `" <> Exception.format_banner(:error, erro) <> "`"
end
Como usar este notebook
Este notebook acompanha o guia Components and HEEx da documentação do Phoenix 1.8, na mesma ordem e com o link de cada seção. A aplicação Hello parte do ponto em que o notebook do ciclo de vida da requisição terminou, com as páginas /hello e /hello/:messenger, e fica no ar em http://localhost:4010. Se outro notebook da trilha estiver com o runtime ligado, desconecte-o antes, porque todos usam a mesma porta.
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 e avisos fazem parte do conteúdo. Neste guia há muitos avisos do compilador (warnings). Eles aparecem na saída da célula, acima do resultado. Leia cada um.
Uma novidade: Trilha.ver/1 recebe o resultado de um template e mostra, lado a lado, o que o navegador desenha e o HTML que o Phoenix gerou. Trilha.pagina/1 faz o mesmo com uma página inteira da aplicação.
Avalie a primeira célula, no topo, antes de tudo.
A aplicação Hello dentro do notebook
As células desta seção definem a aplicação. Em relação ao primeiro notebook, ela ganhou três coisas que um projeto gerado por mix phx.new já traz: as rotas verificadas (~p), a importação de Phoenix.HTML nos templates e uma versão reduzida de HelloWeb.CoreComponents.
A ordem das células importa. O router e o endpoint vêm antes dos módulos de HTML porque o ~p confere cada caminho no router durante a compilação e consulta o endpoint ao montar o caminho. Em um projeto, o compilador do Mix resolve essa ordem sozinho.
lib/hello_web.ex: o bloco html é o que use HelloWeb, :html injeta em layouts, views e componentes. Repare em html_helpers: é ali que CoreComponents é importado em todos os templates.
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 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 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
get "/hello", HelloController, :index
get "/hello/:messenger", HelloController, :show
end
end
lib/hello_web/endpoint.ex
defmodule HelloWeb.Endpoint do
use Phoenix.Endpoint, otp_app: :hello
@session_options [
store: :cookie,
key: "_hello_key",
signing_salt: "trilha00",
same_site: "Lax"
]
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: dois componentes, no lugar dos muitos do arquivo gerado. A seção CoreComponents volta a eles.
defmodule HelloWeb.CoreComponents do
use Phoenix.Component
@doc "Um botão. Atributos HTML globais (id, data-*, phx-*...) são repassados à tag."
attr :type, :string, default: "button"
attr :class, :any, default: nil
attr :rest, :global, include: ~w(disabled form name value)
slot :inner_block, required: true
def button(assigns) do
~H"""
<button type={@type} class={["btn", @class]} {@rest}>
{render_slot(@inner_block)}
</button>
"""
end
@doc "Mostra as mensagens flash :info e :error."
attr :flash, :map, required: true
def flash_group(assigns) do
~H"""
<div id="flash-group">
<p :if={msg = Phoenix.Flash.get(@flash, :info)} class="flash info" role="alert">{msg}</p>
<p :if={msg = Phoenix.Flash.get(@flash, :error)} class="flash error" role="alert">{msg}</p>
</div>
"""
end
end
lib/hello_web/components/layouts.ex: o root layout e o layout da aplicação. No projeto gerado, o root layout fica em layouts/root.html.heex; aqui ele é uma função, o que é equivalente.
defmodule HelloWeb.Layouts do
use HelloWeb, :html
def root(assigns) do
~H"""
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>Hello</title>
</head>
<body>
{@inner_content}
</body>
</html>
"""
end
attr :flash, :map, default: %{}
slot :inner_block, required: true
def app(assigns) do
~H"""
<header>
<strong>Hello</strong> · layout da aplicação
</header>
<main>
<.flash_group flash={@flash} />
{render_slot(@inner_block)}
</main>
"""
end
end
lib/hello_web/controllers/error_html.ex, error_json.ex, page_controller.ex e page_html.ex
defmodule HelloWeb.ErrorHTML do
use HelloWeb, :html
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
lib/hello_web/controllers/hello_controller.ex
defmodule HelloWeb.HelloController do
use HelloWeb, :controller
def index(conn, _params) do
render(conn, :index)
end
def show(conn, %{"messenger" => messenger}) do
render(conn, :show, messenger: messenger)
end
end
lib/hello_web/controllers/hello_html.ex: é nesta célula que você vai trabalhar na maior parte do notebook.
defmodule HelloWeb.HelloHTML do
use HelloWeb, :html
def index(assigns) do
~H"""
<section>
<h2>Hello World, from Phoenix!</h2>
</section>
"""
end
def show(assigns) do
~H"""
<Layouts.app flash={@flash}>
<section>
<h2>Hello World, from {@messenger}!</h2>
</section>
</Layouts.app>
"""
end
end
A célula abaixo inicia o endpoint, o equivalente a mix phx.server.
{:ok, _pid} = Kino.start_child(HelloWeb.Endpoint)
Kino.Markdown.new("Aplicação no ar em <http://localhost:4010>. Abra <http://localhost:4010/hello/Frank> em outra aba.")
Se a célula falhar com :eaddrinuse, a porta 4010 já está ocupada. Troque o número em http: [..., port: 4010] na primeira célula do notebook e reconecte o runtime.
Componentes de função
Na documentação: Function components
O endpoint recebe a requisição, o router a encaminha a um controller, e o controller chama um módulo de view para renderizar um template. A interface entre o controller e a view é simples: o controller chama uma função da view com os assigns da conexão, e a função devolve um template HEEx. Qualquer função que recebe um parâmetro assigns e devolve um template HEEx é um componente de função (function component).
Todo o HTML do Phoenix é renderizado por dois pacotes separados do framework, porque o Phoenix também serve para APIs JSON e outros formatos:
phoenix_htmldefine as peças básicas para escrever HTML com segurança. Nos templates, ele aparece pelo móduloPhoenix.HTML, importado por padrão.phoenix_live_viewé a biblioteca de interfaces em tempo real. Além do LiveView, ela define a linguagem de templates HEEx, os componentes de função e os comandos JS, usados por qualquer aplicação que renderiza HTML no servidor, com ou sem LiveView.
A documentação dos componentes de função está no módulo Phoenix.Component.
Um template é uma função
Trilha.pergunta(:rendered)
HelloWeb.HelloHTML.show(%{messenger: "Frank", flash: %{}})
Nenhuma requisição foi feita: a célula chamou o template como uma função comum, com um mapa de assigns. O valor devolvido é uma %Phoenix.LiveView.Rendered{}. O campo static guarda os trechos fixos de HTML, e dynamic guarda uma função que calcula as partes que dependem dos assigns. Neste template, static tem só duas strings vazias: o template inteiro é uma única chamada ao componente <Layouts.app>, que é uma parte dinâmica. O Phoenix só junta tudo em texto no fim da renderização. Trilha.ver/1 faz essa conversão e mostra o resultado:
HelloWeb.HelloHTML.show(%{messenger: "Frank", flash: %{}})
|> Trilha.ver()
O template show já chama um componente: <Layouts.app>, definido em HelloWeb.Layouts (no projeto, lib/hello_web/components/layouts.ex). Volte à célula de Layouts e veja que app/1 é uma função como show/1: recebe assigns e devolve ~H. Até aqui, todos os componentes de função que você escreveu também funcionaram como templates.
Sua vez: extrair um componente
O guia propõe tirar a linha <h2>Hello World, from {@messenger}!</h2> do template show e colocá-la em uma função própria. Na célula do HelloHTML:
-
Antes do
show, declare o atributo e defina o componente:attr :messenger, :string, required: true def greet(assigns) do ~H""" <h2>Hello World, from {@messenger}!</h2> """ end -
No template
show, troque o<h2>por<.greet messenger={@messenger} />.
attr/3 (de Phoenix.Component) declara os atributos que o componente aceita: nome, tipo e opções. A declaração vale para a função definida logo abaixo dela.
Trilha.conferir(:greet)
Abra http://localhost:4010/hello/Frank. A página está igual, mas agora o template show chama dois componentes de função.
Trilha.solucao(:greet)
Chamada local e chamada remota
O template show usa duas sintaxes:
<Layouts.app ...>chama um componente definido em outro módulo. Segue as mesmas regras de uma chamada de função em Elixir: oalias HelloWeb.Layoutsvem douse HelloWeb, :html.<.greet ...>chama um componente do mesmo módulo do template. O nome do módulo é omitido, e a chamada começa com um ponto.
Fora do HelloHTML, é preciso o nome completo. A célula abaixo usa o greet que você acabou de escrever, a partir de outro módulo:
defmodule Exemplo.Remoto do
use HelloWeb, :html
def pagina(assigns) do
~H"""
<HelloWeb.HelloHTML.greet messenger="Ana" />
"""
end
end
Exemplo.Remoto.pagina(%{})
|> Trilha.ver()
Atributos obrigatórios
Trilha.pergunta(:attr_faltando)
A célula abaixo chama o greet sem o atributo. Ela compila, mas leia o aviso na saída:
defmodule Exemplo.SemAtributo do
use HelloWeb, :html
def pagina(assigns) do
~H"""
<HelloWeb.HelloHTML.greet />
"""
end
end
O aviso é missing required attribute "messenger" for component HelloWeb.HelloHTML.greet/1, com a linha da chamada. Agora renderize:
Exemplo.SemAtributo.pagina(%{})
|> Trilha.ver()
A renderização falha com KeyError: key :messenger not found. O aviso da compilação anunciava exatamente esse erro.
Quando o atributo é opcional, a declaração usa :default no lugar de required: true:
attr :messenger, :string, default: nil
Experimente: na célula do HelloHTML, troque required: true por default: "Phoenix" e avalie a célula. Avalie de novo as duas células acima: o aviso some, e o componente mostra Hello World, from Phoenix!. Depois volte para required: true.
Slots: o conteúdo entre as tags
O guia usa <Layouts.app> sem explicar como o conteúdo entre <Layouts.app ...> e </Layouts.app> chega ao layout. Releia a célula de Layouts:
attr :flash, :map, default: %{}
slot :inner_block, required: true
def app(assigns) do
~H"""
...
{render_slot(@inner_block)}
...
"""
end
Tudo o que fica entre as tags de abertura e de fechamento de um componente vira o slot inner_block, declarado com slot/2. render_slot/1 decide onde esse conteúdo aparece. Um componente também pode ter slots com nome, escritos com dois-pontos:
defmodule Exemplo.Caixa do
use Phoenix.Component
slot :titulo
slot :inner_block, required: true
def caixa(assigns) do
~H"""
<div class="caixa">
<h3 :if={@titulo != []}>{render_slot(@titulo)}</h3>
{render_slot(@inner_block)}
</div>
"""
end
def exemplo(assigns) do
~H"""
<.caixa>
<:titulo>Aviso</:titulo>
<p>Tudo o que não está dentro de um slot com nome é o inner_block.</p>
</.caixa>
"""
end
end
Exemplo.Caixa.exemplo(%{})
|> Trilha.ver()
Um slot não usado chega ao componente como lista vazia, por isso o :if={@titulo != []}. A referência completa está em Phoenix.Component.slot/3.
HEEx
Na documentação: HEEx
Componentes e arquivos de template usam a linguagem HEEx, de "HTML + Embedded Elixir". Código Elixir entre chaves, {...}, é interpolado no corpo e nos atributos das tags, e @name acessa a chave name dos assigns.
Blocos arbitrários usam <%= ... %>, o que é comum em construções com bloco, como condicionais e laços.
Trilha.pergunta(:eex_tags)
A célula abaixo escreve um template fora de um módulo. Para isso, ela importa Phoenix.Component (que define o sigil ~H) e cria uma variável assigns, que o ~H exige.
import Phoenix.Component
assigns = %{messenger: "Frank", admin?: true}
~H"""
<%= if @admin? do %>
<p>Some condition is true for user: {@messenger}</p>
<% else %>
<p>Some condition is false for user: {@messenger}</p>
<% end %>
<table>
<tr>
<th>Number</th>
<th>Power</th>
</tr>
<%= for number <- 1..10 do %>
<tr>
<td>{number}</td>
<td>{number * number}</td>
</tr>
<% end %>
</table>
"""
|> Trilha.ver()
Troque admin?: true por false e avalie de novo. Depois troque 1..10 por 1..3.
Extensões de HTML
Na documentação: HTML extensions
Além de interpolar expressões Elixir, o HEEx entende HTML.
Trilha.pergunta(:escape)
import Phoenix.Component
import Phoenix.HTML
assigns = %{}
~H"""
<p>Com escape: {"<b>Bold?</b>"}</p>
<p>Com raw: {raw("<b>Bold?</b>")}</p>
"""
|> Trilha.ver()
No HTML gerado, a primeira linha tem <b>: os sinais foram escapados, e o navegador mostra a tag como texto. A segunda, com raw/1, passou intacta e virou negrito. Use raw com extremo cuidado, e nunca com algo que veio do usuário. Em um template dentro de use HelloWeb, :html, raw está disponível porque o bloco html_helpers importa Phoenix.HTML; aqui, a célula precisou importar por conta própria.
O HEEx também valida o HTML e a sintaxe de interpolação dos atributos. Um atributo recebe um valor Elixir com key={value}, e alguns valores têm tratamento especial.
Trilha.pergunta(:atributo_false)
import Phoenix.Component
assigns = %{
bloqueado: false,
class: "destaque",
username: "Ana",
many_attributes: [id: "saudacao", "data-role": "banner"]
}
~H"""
<div title="My div" class={@class} {@many_attributes}>
<p>Hello {@username}</p>
</div>
<input disabled={@bloqueado} title={nil} />
<input disabled={true} />
<p class={["mensagem", @bloqueado && "bloqueada", "grande"]}>Classes em lista</p>
"""
|> Trilha.ver()
Confira no HTML gerado:
{@many_attributes}, sozinho dentro da tag, espalha uma lista de pares (ou um mapa) como atributos: virouid="saudacao" data-role="banner". É assim que se passa um número variável de atributos.disabled={false}etitle={nil}sumiram.disabled={true}virou sódisabled.classrecebeu uma lista. O HEEx juntou os itens com espaço e descartou ofalseproduzido por@bloqueado && "bloqueada".
Troque bloqueado: false por true e avalie de novo. Repare nas duas mudanças: o primeiro input ganha disabled, e a classe bloqueada aparece no <p>.
Erro como conteúdo: HTML malformado
O HEEx confere a estrutura das tags em tempo de compilação. Avalie a célula abaixo:
defmodule Exemplo.Quebrado do
use Phoenix.Component
def pagina(assigns) do
~H"""
<div title="My div">
<p>Hello {@username}</p>
</div-typo>
"""
end
end
A célula não compila. O erro é um ParseError com a mensagem unmatched closing tag. Expected </div> for <div> at line ..., got: </div-typo>, apontando a linha e a coluna do problema. Agora apague a linha do </div-typo> e avalie de novo: a mensagem muda para end of template reached without closing tag for <div>. Por fim, escreva </div> no lugar e a célula compila.
Esse é o tipo de erro que, em um template de texto puro, só apareceria no navegador, como uma página com o layout quebrado.
:if e :for
O HEEx tem uma forma curta para if e for: os atributos especiais :if e :for. Em vez de
<%= if @some_condition do %>
<div>...</div>
<% end %>
escreve-se
<div :if={@some_condition}>...</div>
E um laço pode ser escrito assim:
<ul>
<li :for={item <- @items}>{item.name}</li>
</ul>
import Phoenix.Component
assigns = %{
some_condition: true,
items: [
%{name: "Frank", online: true},
%{name: "Ana", online: false},
%{name: "Bia", online: true}
]
}
~H"""
<div :if={@some_condition}>Só aparece quando some_condition é true.</div>
<ul>
<li :for={item <- @items}>{item.name}</li>
</ul>
<ul>
<li :for={item <- @items} :if={item.online}>{item.name} (online)</li>
</ul>
"""
|> Trilha.ver()
Na segunda lista, :for e :if estão na mesma tag. O :if é avaliado para cada item e funciona como filtro: a Ana, offline, ficou de fora.
Sua vez: a lista de messengers
A página /hello vai listar quem pode mandar hello, com um link para a página de cada um.
- Na célula do
HelloController, faça a actionindexpassar o assignmessengerscom a lista["Frank", "Ana", "Bia"]. - Na célula do
HelloHTML, mude o templateindex. Dentro da<section>, depois do<h2>:- uma
<ul>com um<li>por messenger, usando:for; dentro de cada<li>, um link<a href={~p"/hello/#{messenger}"}>com o nome; - um
<p>com o textoNinguém por aqui.que só aparece quando a lista está vazia, usando:if.
- uma
Trilha.conferir(:lista)
A conferência também chama o template diretamente, com listas que o controller nunca passaria, para testar o :if. Abra http://localhost:4010/hello e clique nos links.
Trilha.solucao(:lista)
CoreComponents
Na documentação: CoreComponents
Uma aplicação Phoenix nova tem o módulo core_components.ex na pasta components. Ele é um bom exemplo de componentes de função reutilizados pela aplicação inteira, o que mantém a interface consistente à medida que a aplicação cresce.
Em lib/hello_web.ex, o bloco html (no notebook, html_helpers) importa CoreComponents em todas as views HTML. É por isso que o Layouts.app deste notebook pode chamar <.flash_group> com a sintaxe local, sem o nome do módulo.
Trilha.pergunta(:core_use)
Os geradores de código do Phoenix (mix phx.gen.html, mix phx.gen.live) também contam com esses componentes para montar telas rapidamente.
O button da célula de CoreComponents mostra um recurso que o arquivo gerado usa bastante: o atributo :global.
attr :rest, :global, include: ~w(disabled form name value)
Trilha.pergunta(:global)
defmodule Exemplo.Botoes do
use HelloWeb, :html
def pagina(assigns) do
~H"""
<.button>Simples</.button>
<.button type="submit" class="grande" phx-click="salvar" disabled>Salvar</.button>
<.button foo="bar">Com erro de digitação</.button>
"""
end
end
Exemplo.Botoes.pagina(%{})
|> Trilha.ver()
Leia o aviso acima do resultado: undefined attribute "foo" for component HelloWeb.CoreComponents.button/1. No HTML gerado, phx-click e disabled chegaram ao <button> pelo {@rest}, class virou btn grande porque o componente declara class à parte e o combina com "btn", e foo também passou, apesar do aviso.
Para ir mais fundo:
- abra o
core_components.exde um projeto gerado e leia os componentesbutton,input,tableeflash; cada um tem@doc,attreslot; - leia a documentação de
Phoenix.Componente do sigil~H; - se precisar de componentes além dos mínimos que o Phoenix traz, o projeto LiveView mantém uma lista de sistemas de componentes.
Layouts
Na documentação: Layouts
Trilha.pergunta(:inner_content)
Toda aplicação Phoenix tem um componente chamado root layout. É nele que ficam as tags <head> e <body> da página. O root layout é configurado no router, na pipeline :browser:
plug :put_root_layout, html: {HelloWeb.Layouts, :root}
Em um projeto novo, o template fica em lib/hello_web/components/layouts/root.html.heex. Perto do fim do <body> está a linha {@inner_content}: é ali que os templates entram depois de renderizados. O root layout é reaproveitado por controllers e por LiveViews.
Todo o resto que muda conforme a página, como menu e barra lateral, é implementado com componentes de função. O menu da aplicação costuma ficar no componente app de layouts.ex, chamado em cada template:
<Layouts.app flash={@flash}>
...
</Layouts.app>
Veja as duas camadas na página /hello/Frank. Na tabela, conn.private.phoenix_root_layout mostra o root layout escolhido pelo plug; no corpo, procure o <!DOCTYPE html> (do root layout), o <header> (do Layouts.app) e o <h2> (do greet).
Trilha.mostrar(:get, "/hello/Frank")
Trilha.pagina("/hello/Frank")
Sua vez: um layout de administração
O mecanismo é flexível. Para um layout de administração, basta acrescentar uma função ao módulo Layouts e chamar Layouts.admin no lugar de Layouts.app:
<Layouts.admin flash={@flash}>
...
</Layouts.admin>
- Na célula de
Layouts, definaadmin/1, com as mesmas declarações deapp/1(attr :flasheslot :inner_block). O cabeçalho deve dizerPainel administrativoe não deve conter o textolayout da aplicação. - Na célula do router, acrescente
get "/admin", PageController, :admindentro doscope "/". - Na célula das páginas, acrescente a action
admin/2aoPageControllere o templateadmin/1aoPageHTML, usando<Layouts.admin>.
Trilha.conferir(:admin)
Abra http://localhost:4010/admin e depois http://localhost:4010/. As duas páginas compartilham o root layout e diferem no layout que o template escolheu.
Trilha.solucao(:admin)
Versões anteriores do Phoenix usavam um mecanismo de layouts aninhados, configurado com a opção :layouts em Phoenix.Controller e :layout em Phoenix.LiveView. Ele é desencorajado em aplicações novas. Se encontrar esse mecanismo em código antigo, o equivalente hoje é o template chamar o componente de layout, como você acabou de fazer.
Sem roteiro: o cartão de produto do Brechó
O Brechó precisa de uma vitrine. Crie a página /produtos com um componente de cartão reutilizável:
- o componente se chama
product_carde fica emHelloWeb.ProdutoHTML(ou emHelloWeb.CoreComponents, se quiser usá-lo em outras páginas); - ele declara os atributos
name(string, obrigatório),price(inteiro, obrigatório) esold(booleano, com padrãofalse), e um slotactions; - o cartão mostra o nome, o preço no formato
R$ 120e, só quandosoldfor verdadeiro, o textoVendido; - o conteúdo do slot
actionsaparece no cartão. A página passa um botãoComprar(use o<.button>de CoreComponents) só para os itens que não foram vendidos; - a página usa
Layouts.app, e o router ganha a rotaget "/produtos", ProdutoController, :index.
O controller já está pronto na célula abaixo. Complete a view e edite o router.
defmodule HelloWeb.ProdutoController do
use HelloWeb, :controller
@produtos [
%{name: "Jaqueta jeans", price: 120, sold: false},
%{name: "Vitrola antiga", price: 450, sold: true},
%{name: "Luminária de mesa", price: 60, sold: false}
]
def index(conn, _params) do
render(conn, :index, produtos: @produtos)
end
end
defmodule HelloWeb.ProdutoHTML do
use HelloWeb, :html
# Declare aqui os atributos e o slot, e defina product_card/1.
def index(assigns) do
~H"""
<Layouts.app flash={@flash}>
<h1>Brechó</h1>
<%!-- Um product_card por produto. --%>
</Layouts.app>
"""
end
end
Trilha.conferir(:produtos)
Trilha.pagina("/produtos")
Trilha.solucao(:produtos)
No seu projeto
Repita o guia em um projeto gerado por mix phx.new hello. A correspondência é esta:
| Neste notebook | No projeto |
|---|---|
Célula do HelloHTML |
lib/hello_web/controllers/hello_html.ex |
Função show/1 com ~H |
lib/hello_web/controllers/hello_html/show.html.heex |
Célula de CoreComponents |
lib/hello_web/components/core_components.ex |
Função root/1 de Layouts |
lib/hello_web/components/layouts/root.html.heex |
Função app/1 de Layouts |
app/1 em lib/hello_web/components/layouts.ex |
| Aviso na saída da célula | Aviso no terminal do mix phx.server ou do mix compile |
Trilha.ver(...) |
A página no navegador e o "ver código-fonte" |
Roteiro:
- Em
hello_html.ex, mantenhaembed_templates "hello_html/*"e acrescente oattre ogreet/1no próprio módulo. Os templates.html.heexda pastahello_htmlchamam<.greet>como aqui, porque viram funções do mesmo módulo. - No
show.html.heex, remova omessenger={@messenger}e salve. Leia o aviso no terminal. Depois quebre uma tag de fechamento e veja o erro no terminal e no navegador. - Abra
lib/hello_web/components/core_components.ex. Ache ouse Phoenix.Componentno topo, obuttoncomattr :rest, :globale oflash_groupque oLayouts.appchama. - Abra
lib/hello_web/components/layouts.exelayouts/root.html.heex. Ache o{@inner_content}e orender_slot(@inner_block). Crie oadmin/1ao lado doapp/1. - Faça a vitrine do Brechó com o
product_card, desta vez com o template da página emproduto_html/index.html.heex.
Revisão
Escreva em uma célula de texto, com as suas palavras: o que é um componente de função, como se declara o que ele aceita, o que o HEEx faz com um valor interpolado e como uma página chega ao navegador com os dois layouts. Depois compare com os pontos abaixo.
- Um componente de função é uma função que recebe
assignse devolve um template~H. Templates de views, layouts e componentes pequenos são todos a mesma coisa. - O resultado de
~Hé uma%Phoenix.LiveView.Rendered{}, com as partes fixas e as dinâmicas separadas; o Phoenix a converte em HTML no fim. attrdeclara os atributos eslotdeclara os slots. Atributo obrigatório faltando ou atributo desconhecido geram aviso de compilação, não erro.<.nome>chama um componente do mesmo módulo;<Modulo.nome>chama um de outro módulo.- O conteúdo entre as tags de um componente é o slot
inner_block, exibido comrender_slot/1. {...}interpola e escapa;raw/1desliga o escape.<%= %>insere o valor de uma expressão;<% %>não insere.- Em atributos,
falseenilremovem o atributo,trueo deixa sem valor, e listas emclasssão juntadas sem os itens falsos. - O HEEx confere a estrutura das tags na compilação.
:ife:forsão as formas curtas de condicional e laço; juntos na mesma tag, o:iffiltra os itens.CoreComponentsreúne os componentes reutilizáveis, é importado em todas as views e usause Phoenix.Componentpara não importar a si mesmo.- O root layout, escolhido por
put_root_layout, envolve toda página por meio de{@inner_content}. Os demais layouts são componentes que cada template chama.
Próximo guia da trilha: Ecto, a camada de dados, em que a vitrine do Brechó passa a ler os produtos de um banco de verdade.