Integration test for slots

Format integration test project

Hide slots assign in temple prefixed key

Won't compile temple related assigns when calling Utils.runtime_attrs

Update component docs with slots usage
This commit is contained in:
Mitchell Hanberg 2021-04-16 00:16:00 -04:00
parent f7197ede4a
commit 851f6415fe
26 changed files with 457 additions and 80 deletions

View file

@ -1,5 +1,5 @@
locals_without_parens = ~w[
temple c
temple c slot
html head title style script
noscript template
body section nav article aside h1 h2 h3 h4 h5 h6

View file

@ -191,3 +191,8 @@ To include Temple's formatter configuration, add `:temple` to your `.formatter.e
inputs: ["*.{ex,exs}", "priv/*/seeds.exs", "{config,lib,test}/**/*.{ex,exs,lexs}"],
]
```
## Related
- [Introducing Temple: An elegant HTML library for Elixir and Phoenix](https://www.mitchellhanberg.com/introducing-temple-an-elegant-html-library-for-elixir-and-phoenix/)
- [Temple, AST, and Protocols](https://www.mitchellhanberg.com/temple-ast-and-protocols/)

View file

@ -29,6 +29,5 @@ config :wallaby,
otp_app: :temple_demo,
screenshot_on_failure: true
# Print only warnings and errors during test
config :logger, level: :warn

View file

@ -38,6 +38,7 @@ defmodule TempleDemoWeb do
alias TempleDemoWeb.Component.Outer
alias TempleDemoWeb.Component.Flash
alias TempleDemoWeb.Component.Form
# Include shared imports and aliases for views
unquote(view_helpers())

View file

@ -0,0 +1,13 @@
defmodule TempleDemoWeb.Component.Form do
use Temple.Component
render do
f = Phoenix.HTML.Form.form_for(@changeset, @action)
f
slot(:f, f: f)
"</form>"
end
end

View file

@ -5,7 +5,6 @@ defmodule TempleDemoWeb.Endpoint do
plug Phoenix.Ecto.SQL.Sandbox
end
# The session will be stored in the cookie and signed,
# this means its contents can be read but not tampered with.
# Set :encryption_salt if you would also like to encrypt it.

View file

@ -12,6 +12,7 @@ section class: "phx-hero" do
p do
"Peace-of-mind from prototype to staging"
end
_ ->
p do
"Peace-of-mind from prototype to production"

View file

@ -1,27 +1,29 @@
form_for @changeset, @action, fn f ->
if @changeset.action do
c Flash, type: :info do
p do: "Oops, something went wrong! Please check the errors below."
c Form, changeset: @changeset, action: @action do
slot :f, %{f: f} do
if @changeset.action do
c Flash, type: :info do
p do: "Oops, something went wrong! Please check the errors below."
end
end
label f, :title
text_input f, :title
error_tag(f, :title)
label f, :body
textarea f, :body
error_tag(f, :body)
label f, :published_at
datetime_select f, :published_at
error_tag(f, :published_at)
label f, :author
text_input f, :author
error_tag(f, :author)
div do
submit "Save"
end
end
label f, :title
text_input f, :title
error_tag(f, :title)
label f, :body
textarea f, :body
error_tag(f, :body)
label f, :published_at
datetime_select f, :published_at
error_tag(f, :published_at)
label f, :author
text_input f, :author
error_tag(f, :author)
div do
submit "Save"
end
end

View file

@ -11,16 +11,20 @@ table do
tbody do
for post <- @posts do
tr do
td do: post.title
td do: post.body
td do: post.published_at
td do: post.author
tr do
td do: post.title
td do: post.body
td do: post.published_at
td do: post.author
td do
link "Show", to: Routes.post_path(@conn, :show, post)
link "Edit", to: Routes.post_path(@conn, :edit, post)
link "Delete", to: Routes.post_path(@conn, :delete, post),
method: :delete, data: [confirm: "Are you sure?"]
link "Delete",
to: Routes.post_path(@conn, :delete, post),
method: :delete,
data: [confirm: "Are you sure?"]
end
end
end

View file

@ -1,19 +1,22 @@
h1 do: "Show Post"
ul do
ul do
li do: [strong(do: "Title"), @post.title]
li do
strong do: "Body"
Phoenix.HTML.Format.text_to_html @post.body, attributes: [class: "whitespace-pre"]
end
Phoenix.HTML.Format.text_to_html(@post.body, attributes: [class: "whitespace-pre"])
end
li do
strong do: "Published at"
@post.published_at
end
end
li do
strong do: "Author"
@post.author
end
end
span do
link "Edit", to: Routes.post_path(@conn, :edit, @post)

View file

@ -6,7 +6,7 @@ defmodule TempleDemoWeb.PostView do
defcomp Headers do
thead id: PostView.thing() do
tr do
tr do
@inner_content
end
end

View file

@ -10,6 +10,5 @@ defmodule TempleDemo.Repo.Migrations.CreatePosts do
timestamps()
end
end
end

View file

@ -6,8 +6,18 @@ defmodule TempleDemo.BlogTest do
describe "posts" do
alias TempleDemo.Blog.Post
@valid_attrs %{author: "some author", body: "some body", published_at: ~N[2010-04-17 14:00:00], title: "some title"}
@update_attrs %{author: "some updated author", body: "some updated body", published_at: ~N[2011-05-18 15:01:01], title: "some updated title"}
@valid_attrs %{
author: "some author",
body: "some body",
published_at: ~N[2010-04-17 14:00:00],
title: "some title"
}
@update_attrs %{
author: "some updated author",
body: "some updated body",
published_at: ~N[2011-05-18 15:01:01],
title: "some updated title"
}
@invalid_attrs %{author: nil, body: nil, published_at: nil, title: nil}
def post_fixture(attrs \\ %{}) do

View file

@ -3,8 +3,18 @@ defmodule TempleDemoWeb.PostControllerTest do
alias TempleDemo.Blog
@create_attrs %{author: "some author", body: "some body", published_at: ~N[2010-04-17 14:00:00], title: "some title"}
@update_attrs %{author: "some updated author", body: "some updated body", published_at: ~N[2011-05-18 15:01:01], title: "some updated title"}
@create_attrs %{
author: "some author",
body: "some body",
published_at: ~N[2010-04-17 14:00:00],
title: "some title"
}
@update_attrs %{
author: "some updated author",
body: "some updated body",
published_at: ~N[2011-05-18 15:01:01],
title: "some updated title"
}
@invalid_attrs %{author: nil, body: nil, published_at: nil, title: nil}
def fixture(:post) do
@ -75,6 +85,7 @@ defmodule TempleDemoWeb.PostControllerTest do
test "deletes chosen post", %{conn: conn, post: post} do
conn = delete(conn, Routes.post_path(conn, :delete, post))
assert redirected_to(conn) == Routes.post_path(conn, :index)
assert_error_sent 404, fn ->
get(conn, Routes.post_path(conn, :show, post))
end

View file

@ -56,6 +56,46 @@ defmodule Temple.Component do
end
end
```
## Slots
Components can use slots, which are named placeholders that can be called like functions to be able to pass them data. This is very useful
when a component needs to pass data from the inside of the component back to the caller, like when rendering a form in LiveView.
The definition of a slot happens at the call site of the component and you utilize that slot from inside of the component module.
```elixir
defmodule Form do
use Temple.Component
render do
form = form_for(@changeset, @action, assigns)
form
slot(:f, form: form)
"</form>"
end
end
# lib/my_app_web/templates/post/new.html.lexs
c Form, changeset: @changeset,
action: @action,
class: "form-control",
phx_submit: :save,
phx_change: :validate do
slot :f, %{form: f} do
label f do
"Widget Name"
text_input f, :name, class: "text-input"
end
submit "Save!"
end
end
```
"""
defmacro __using__(_) do

View file

@ -6,6 +6,7 @@ defmodule Temple.Parser do
alias Temple.Parser.TempleNamespaceNonvoid
alias Temple.Parser.TempleNamespaceVoid
alias Temple.Parser.Components
alias Temple.Parser.Slot
alias Temple.Parser.NonvoidElementsAliases
alias Temple.Parser.VoidElementsAliases
alias Temple.Parser.AnonymousFunctions
@ -18,6 +19,7 @@ defmodule Temple.Parser do
%Empty{}
| %Text{}
| %Components{}
| %Slot{}
| %NonvoidElementsAliases{}
| %VoidElementsAliases{}
| %AnonymousFunctions{}
@ -83,21 +85,23 @@ defmodule Temple.Parser do
def void_elements_aliases, do: @void_elements_aliases
def void_elements_lookup, do: @void_elements_lookup
def parsers(),
do: [
Temple.Parser.Empty,
Temple.Parser.Text,
Temple.Parser.TempleNamespaceNonvoid,
Temple.Parser.TempleNamespaceVoid,
Temple.Parser.Components,
Temple.Parser.NonvoidElementsAliases,
Temple.Parser.VoidElementsAliases,
Temple.Parser.AnonymousFunctions,
Temple.Parser.RightArrow,
Temple.Parser.DoExpressions,
Temple.Parser.Match,
Temple.Parser.Default
def parsers() do
[
Empty,
Text,
TempleNamespaceNonvoid,
TempleNamespaceVoid,
Components,
Slot,
NonvoidElementsAliases,
VoidElementsAliases,
AnonymousFunctions,
RightArrow,
DoExpressions,
Match,
Default
]
end
def parse({:__block__, _, asts}) do
parse(asts)
@ -113,6 +117,7 @@ defmodule Temple.Parser do
{_, false} <- {TempleNamespaceNonvoid, TempleNamespaceNonvoid.applicable?(ast)},
{_, false} <- {TempleNamespaceVoid, TempleNamespaceVoid.applicable?(ast)},
{_, false} <- {Components, Components.applicable?(ast)},
{_, false} <- {Slot, Slot.applicable?(ast)},
{_, false} <- {NonvoidElementsAliases, NonvoidElementsAliases.applicable?(ast)},
{_, false} <- {VoidElementsAliases, VoidElementsAliases.applicable?(ast)},
{_, false} <- {AnonymousFunctions, AnonymousFunctions.applicable?(ast)},

View file

@ -2,7 +2,7 @@ defmodule Temple.Parser.Components do
@moduledoc false
@behaviour Temple.Parser
defstruct module: nil, assigns: [], children: []
defstruct module: nil, assigns: [], children: [], slots: []
@impl Temple.Parser
def applicable?({:c, _, _}) do
@ -19,22 +19,68 @@ defmodule Temple.Parser.Components do
{do_and_else, assigns} = Temple.Parser.Utils.consolidate_blocks(do_and_else, args)
{default_slot, named_slots} =
if children = do_and_else[:do] do
Macro.postwalk(
children,
%{},
fn
{:slot, _, [name | args]}, named_slots ->
{assigns, slot} = split_assigns_and_children(args, Macro.escape(%{}))
{nil, Map.put(named_slots, name, %{assigns: assigns, slot: slot})}
node, named_slots ->
{node, named_slots}
end
)
else
{nil, %{}}
end
children =
if do_and_else[:do] == nil do
if default_slot == nil do
[]
else
Temple.Parser.parse(do_and_else[:do])
Temple.Parser.parse(default_slot)
end
slots =
for {name, %{slot: slot, assigns: assigns}} <- named_slots do
Temple.Ast.new(
Temple.Parser.Slottable,
name: name,
content: Temple.Parser.parse(slot),
assigns: assigns
)
end
Temple.Ast.new(__MODULE__,
module: Macro.expand_once(component_module, __ENV__),
assigns: assigns,
slots: slots,
children: children
)
end
defp split_assigns_and_children(args, empty) do
case args do
[assigns, [do: block]] ->
{assigns, block}
[[do: block]] ->
{empty, block}
[assigns] ->
{assigns, nil}
_ ->
{empty, nil}
end
end
defimpl Temple.Generator do
def to_eex(%{module: module, assigns: assigns, children: []}) do
def to_eex(%{module: module, assigns: assigns, children: [], slots: slots}) do
[
"<%= Phoenix.View.render",
" ",
@ -42,22 +88,45 @@ defmodule Temple.Parser.Components do
", ",
":self,",
" ",
"[{:__temple_slots__, %{",
for slot <- slots do
[
to_string(slot.name),
": ",
"fn #{Macro.to_string(slot.assigns)} -> %>",
for(child <- slot.content, do: Temple.Generator.to_eex(child)),
"<% end, "
]
end,
"}} | ",
Macro.to_string(assigns),
"]",
" ",
"%>"
]
end
def to_eex(%{module: module, assigns: assigns, children: children}) do
def to_eex(%{module: module, assigns: assigns, children: children, slots: slots}) do
[
"<%= Phoenix.View.render_layout ",
Macro.to_string(module),
", ",
":self",
", ",
Macro.to_string(assigns),
":self,",
" ",
"do %>",
"[{:__temple_slots__, %{",
for slot <- slots do
[
to_string(slot.name),
": ",
"fn #{Macro.to_string(slot.assigns)} -> %>",
for(child <- slot.content, do: Temple.Generator.to_eex(child)),
"<% end, "
]
end,
"}} | ",
Macro.to_string(assigns),
"]",
" do %>",
"\n",
for(child <- children, do: Temple.Generator.to_eex(child)),
"\n",

30
lib/temple/parser/slot.ex Normal file
View file

@ -0,0 +1,30 @@
defmodule Temple.Parser.Slot do
@moduledoc false
@behaviour Temple.Parser
defstruct name: nil, args: []
@impl true
def applicable?({:slot, _, _}) do
true
end
def applicable?(_), do: false
@impl true
def run({:slot, _, [slot_name | [args]]}) do
Temple.Ast.new(__MODULE__, name: slot_name, args: args)
end
defimpl Temple.Generator do
def to_eex(%{name: name, args: args}) do
[
"<%= @__temple_slots__.",
to_string(name),
".(",
Macro.to_string(quote(do: Enum.into(unquote(args), %{}))),
") %>"
]
end
end
end

View file

@ -0,0 +1,3 @@
defmodule Temple.Parser.Slottable do
defstruct content: nil, assigns: Macro.escape(%{}), name: nil
end

View file

@ -34,7 +34,7 @@ defmodule Temple.Parser.Utils do
def runtime_attrs(attrs) do
{:safe,
for {name, value} <- attrs, into: "" do
for {name, value} <- attrs, name != :__temple_slots__, into: "" do
name = snake_to_kebab(name)
" " <> name <> "=\"" <> to_string(value) <> "\""

View file

@ -3,7 +3,7 @@ defmodule Temple.ComponentTest do
use Temple
use Temple.Support.Utils
# `Phoenix.View.render_layout/4` is a phoenix function used for rendering partials that contain inner_content.
# `Phoenix.View.render_layout/4` is a phoenix function used for rendering partials that contain inner_content.
# These are usually layouts, but components that contain children are basically the same thing
test "renders components using Phoenix.View.render_layout" do
result =
@ -20,7 +20,7 @@ defmodule Temple.ComponentTest do
end
assert result ==
~s{<div class="font-bold">Hello, world</div><%= Phoenix.View.render_layout Temple.Components.Component, :self, [] do %><aside class="foobar">I'm a component!</aside><% end %>}
~s|<div class="font-bold">Hello, world</div><%= Phoenix.View.render_layout Temple.Components.Component, :self, [{:__temple_slots__, %{}} \| []] do %><aside class="foobar">I'm a component!</aside><% end %>|
assert evaluate_template(result) ==
~s{<div class="font-bold">Hello, world</div><div><aside class="foobar">I'm a component!</aside></div>}
@ -39,7 +39,7 @@ defmodule Temple.ComponentTest do
end
assert result ==
~s{<div class="font-bold">Hello, world</div><%= Phoenix.View.render_layout Temple.Components.Component2, :self, [class: "bg-red"] do %>I'm a component!<% end %>}
~s|<div class="font-bold">Hello, world</div><%= Phoenix.View.render_layout Temple.Components.Component2, :self, [{:__temple_slots__, %{}} \| [class: "bg-red"]] do %>I'm a component!<% end %>|
assert evaluate_template(result) ==
~s{<div class="font-bold">Hello, world</div><div class="bg-red">I'm a component!</div>}
@ -60,7 +60,7 @@ defmodule Temple.ComponentTest do
end
assert result ==
~s{<div class="font-bold">Hello, world</div><% class = "bg-red" %><%= Phoenix.View.render_layout Temple.Components.Component2, :self, [class: class] do %>I'm a component!<% end %>}
~s|<div class="font-bold">Hello, world</div><% class = "bg-red" %><%= Phoenix.View.render_layout Temple.Components.Component2, :self, [{:__temple_slots__, %{}} \| [class: class]] do %>I'm a component!<% end %>|
end
test "function components can use other components" do
@ -76,7 +76,7 @@ defmodule Temple.ComponentTest do
end
assert result ==
~s{<%= Phoenix.View.render_layout Temple.Components.Outer, :self, [] do %>outer!\n<% end %><%= Phoenix.View.render_layout Temple.Components.Inner, :self, [outer_id: "set by root inner"] do %>inner!\n<% end %>}
~s|<%= Phoenix.View.render_layout Temple.Components.Outer, :self, [{:__temple_slots__, %{}} \| []] do %>outer!\n<% end %><%= Phoenix.View.render_layout Temple.Components.Inner, :self, [{:__temple_slots__, %{}} \| [outer_id: "set by root inner"]] do %>inner!\n<% end %>|
assert evaluate_template(result) == ~s"""
<div id="inner" outer-id="from-outer">outer!</div>
@ -105,7 +105,7 @@ defmodule Temple.ComponentTest do
end
assert result ==
~s{<%= Phoenix.View.render_layout Temple.Components.WithFuncs, :self, [foo: :bar] do %>doo doo<% end %>}
~s|<%= Phoenix.View.render_layout Temple.Components.WithFuncs, :self, [{:__temple_slots__, %{}} \| [foo: :bar]] do %>doo doo<% end %>|
assert evaluate_template(result) == ~s{<div class="barbarbar">doo doo</div>}
end
@ -117,8 +117,33 @@ defmodule Temple.ComponentTest do
end
assert result ==
~s{<%= Phoenix.View.render Temple.Components.VoidComponent, :self, [foo: :bar] %>}
~s|<%= Phoenix.View.render Temple.Components.VoidComponent, :self, [{:__temple_slots__, %{}} \| [foo: :bar]] %>|
assert evaluate_template(result) == ~s{<div class="void!!">bar</div>}
end
test "components can have named slots" do
assigns = %{name: "bob"}
result =
temple do
c Temple.Components.WithSlot do
slot :header, %{value: val} do
div do
"the value is #{val}"
end
end
button class: "btn", phx_click: :toggle do
@name
end
end
end
assert result ==
~s|<%= Phoenix.View.render_layout Temple.Components.WithSlot, :self, [{:__temple_slots__, %{header: fn %{value: val} -> %>\n<div>\n<%= "the value is \#{val}" %>\n</div><% end, }} \| []] do %>\n<button class="btn" phx-click="toggle">\n<%= @name %>\n\n</button>\n<% end %>|
assert evaluate_template(result, assigns) ==
~s{<div><div>the value is Header</div><div class="wrapped"><button class="btn" phx-click="toggle">bob</button></div></div>}
end
end

View file

@ -1,6 +1,7 @@
defmodule Temple.Parser.ComponentsTest do
use ExUnit.Case, async: false
alias Temple.Parser.Components
alias Temple.Parser.Slottable
use Temple.Support.Utils
describe "applicable?/1" do
@ -104,6 +105,32 @@ defmodule Temple.Parser.ComponentsTest do
children: []
} = ast
end
test "gathers all slots" do
raw_ast =
quote do
c SomeModule, foo: :bar do
slot :foo, %{form: form} do
"in the slot"
end
end
end
ast = Components.run(raw_ast)
assert %Components{
module: SomeModule,
assigns: [foo: :bar],
slots: [
%Slottable{
name: :foo,
content: [%Temple.Parser.Text{}],
assigns: {:%{}, _, [form: _]}
}
],
children: []
} = ast
end
end
describe "Temple.Generator.to_eex/1" do
@ -121,7 +148,53 @@ defmodule Temple.Parser.ComponentsTest do
|> Temple.Generator.to_eex()
assert result |> :erlang.iolist_to_binary() ==
~s|<%= Phoenix.View.render_layout SomeModule, :self, [foo: :bar] do %>\nI'm a component!\n<% end %>|
~s|<%= Phoenix.View.render_layout SomeModule, :self, [{:__temple_slots__, %{}} \| [foo: :bar]] do %>\nI'm a component!\n<% end %>|
end
test "emits eex for void component with slots" do
raw_ast =
quote do
c SomeModule, foo: :bar do
slot :foo, %{form: form} do
div do
"in the slot"
end
end
end
end
result =
raw_ast
|> Components.run()
|> Temple.Generator.to_eex()
assert result |> :erlang.iolist_to_binary() ==
~s|<%= Phoenix.View.render SomeModule, :self, [{:__temple_slots__, %{foo: fn %{form: form} -> %><div>\nin the slot\n\n</div><% end, }} \| [foo: :bar]] %>|
end
test "emits eex for nonvoid component with slots" do
raw_ast =
quote do
c SomeModule, foo: :bar do
slot :foo, %{form: form} do
div do
"in the slot"
end
end
div do
"inner content"
end
end
end
result =
raw_ast
|> Components.run()
|> Temple.Generator.to_eex()
assert result |> :erlang.iolist_to_binary() ==
~s|<%= Phoenix.View.render_layout SomeModule, :self, [{:__temple_slots__, %{foo: fn %{form: form} -> %><div>\nin the slot\n\n</div><% end, }} \| [foo: :bar]] do %>\n<div>\ninner content</div>\n<% end %>|
end
test "emits eex for void component" do
@ -136,7 +209,7 @@ defmodule Temple.Parser.ComponentsTest do
|> Temple.Generator.to_eex()
assert result |> :erlang.iolist_to_binary() ==
~s|<%= Phoenix.View.render SomeModule, :self, [foo: :bar] %>|
~s|<%= Phoenix.View.render SomeModule, :self, [{:__temple_slots__, %{}} \| [foo: :bar]] %>|
end
end
end

48
test/parser/slot_test.exs Normal file
View file

@ -0,0 +1,48 @@
defmodule Temple.Parser.SlotTest do
use ExUnit.Case, async: false
alias Temple.Parser.Slot
describe "applicable?/1" do
test "runs when using the `c` ast with a block" do
ast =
quote do
slot :header, value: "yolo"
end
assert Slot.applicable?(ast)
end
end
describe "run/2" do
test "adds a node to the buffer" do
raw_ast =
quote do
slot :header, value: "yolo"
end
ast = Slot.run(raw_ast)
assert %Slot{
name: :header,
args: [value: "yolo"]
} == ast
end
end
describe "Temple.Generator.to_eex/1" do
test "emits eex for a slot" do
raw_ast =
quote do
slot :header, value: Form.form_for(changeset, action)
end
result =
raw_ast
|> Slot.run()
|> Temple.Generator.to_eex()
assert result |> :erlang.iolist_to_binary() ==
~s|<%= @__temple_slots__.header.(Enum.into([value: Form.form_for(changeset, action)], %{})) %>|
end
end
end

View file

@ -0,0 +1,24 @@
defmodule Temple.Parser.UtilsTest do
use ExUnit.Case, async: true
alias Temple.Parser.Utils
describe "runtime_attrs/1" do
test "compiles keyword lists and maps into html attributes" do
attrs_map = %{
class: "text-red",
id: "form1",
__temple_slots__: %{}
}
attrs_kw = [
class: "text-red",
id: "form1",
__temple_slots__: %{}
]
assert {:safe, ~s| class="text-red" id="form1"|} == Utils.runtime_attrs(attrs_map)
assert {:safe, ~s| class="text-red" id="form1"|} == Utils.runtime_attrs(attrs_kw)
end
end
end

View file

@ -0,0 +1,13 @@
defmodule Temple.Components.WithSlot do
use Temple.Component
render do
div do
slot :header, value: "Header"
div class: "wrapped" do
@inner_content
end
end
end
end

View file

@ -20,10 +20,10 @@ defmodule Temple.Support.Utils do
Kernel.=~(a, b)
end
def evaluate_template(template) do
def evaluate_template(template, assigns \\ %{}) do
template
|> EEx.compile_string(engine: Phoenix.HTML.Engine)
|> Code.eval_quoted([])
|> Code.eval_quoted(assigns: assigns)
|> elem(0)
|> Phoenix.HTML.safe_to_string()
end