Powered by AppSignal & Oban Pro

Trilha Phoenix · Componentes e HEEx

06_componentes_e_heex.livemd

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_html define as peças básicas para escrever HTML com segurança. Nos templates, ele aparece pelo módulo Phoenix.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:

  1. 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
    
  2. 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: o alias HelloWeb.Layouts vem do use 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 &lt;b&gt;: 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: virou id="saudacao" data-role="banner". É assim que se passa um número variável de atributos.
  • disabled={false} e title={nil} sumiram. disabled={true} virou só disabled.
  • class recebeu uma lista. O HEEx juntou os itens com espaço e descartou o false produzido 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.

  1. Na célula do HelloController, faça a action index passar o assign messengers com a lista ["Frank", "Ana", "Bia"].
  2. Na célula do HelloHTML, mude o template index. 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 texto Ninguém por aqui. que só aparece quando a lista está vazia, usando :if.
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.ex de um projeto gerado e leia os componentes button, input, table e flash; cada um tem @doc, attr e slot;
  • leia a documentação de Phoenix.Component e 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>
  1. Na célula de Layouts, defina admin/1, com as mesmas declarações de app/1 (attr :flash e slot :inner_block). O cabeçalho deve dizer Painel administrativo e não deve conter o texto layout da aplicação.
  2. Na célula do router, acrescente get "/admin", PageController, :admin dentro do scope "/".
  3. Na célula das páginas, acrescente a action admin/2 ao PageController e o template admin/1 ao PageHTML, 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_card e fica em HelloWeb.ProdutoHTML (ou em HelloWeb.CoreComponents, se quiser usá-lo em outras páginas);
  • ele declara os atributos name (string, obrigatório), price (inteiro, obrigatório) e sold (booleano, com padrão false), e um slot actions;
  • o cartão mostra o nome, o preço no formato R$ 120 e, só quando sold for verdadeiro, o texto Vendido;
  • o conteúdo do slot actions aparece no cartão. A página passa um botão Comprar (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 rota get "/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:

  1. Em hello_html.ex, mantenha embed_templates "hello_html/*" e acrescente o attr e o greet/1 no próprio módulo. Os templates .html.heex da pasta hello_html chamam <.greet> como aqui, porque viram funções do mesmo módulo.
  2. No show.html.heex, remova o messenger={@messenger} e salve. Leia o aviso no terminal. Depois quebre uma tag de fechamento e veja o erro no terminal e no navegador.
  3. Abra lib/hello_web/components/core_components.ex. Ache o use Phoenix.Component no topo, o button com attr :rest, :global e o flash_group que o Layouts.app chama.
  4. Abra lib/hello_web/components/layouts.ex e layouts/root.html.heex. Ache o {@inner_content} e o render_slot(@inner_block). Crie o admin/1 ao lado do app/1.
  5. Faça a vitrine do Brechó com o product_card, desta vez com o template da página em produto_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 assigns e 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.
  • attr declara os atributos e slot declara 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 com render_slot/1.
  • {...} interpola e escapa; raw/1 desliga o escape. <%= %> insere o valor de uma expressão; <% %> não insere.
  • Em atributos, false e nil removem o atributo, true o deixa sem valor, e listas em class são juntadas sem os itens falsos.
  • O HEEx confere a estrutura das tags na compilação.
  • :if e :for são as formas curtas de condicional e laço; juntos na mesma tag, o :if filtra os itens.
  • CoreComponents reúne os componentes reutilizáveis, é importado em todas as views e usa use Phoenix.Component para 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.