From 3356970cac523563f1657fd7984ac0fcbe93593b Mon Sep 17 00:00:00 2001 From: Marlus Saraiva Date: Fri, 23 Apr 2021 07:03:42 -0300 Subject: [PATCH] Support function components via component/3 (#1428) --- lib/phoenix_live_view/engine.ex | 2 + lib/phoenix_live_view/helpers.ex | 54 ++++ test/phoenix_live_view/controller_test.exs | 15 ++ test/phoenix_live_view/diff_test.exs | 235 +++++++++++++++--- .../integrations/function_components_test.exs | 130 ++++++++++ test/support/controller.ex | 13 + test/support/layout_view.ex | 19 ++ test/support/live_views/components.ex | 10 + 8 files changed, 446 insertions(+), 32 deletions(-) create mode 100644 test/phoenix_live_view/integrations/function_components_test.exs diff --git a/lib/phoenix_live_view/engine.ex b/lib/phoenix_live_view/engine.ex index 13cac2ff6..7d3538359 100644 --- a/lib/phoenix_live_view/engine.ex +++ b/lib/phoenix_live_view/engine.ex @@ -902,6 +902,8 @@ defmodule Phoenix.LiveView.Engine do defp classify_taint(:live_component, [_, _, [do: _]]), do: :render defp classify_taint(:live_component, [_, _, _, [do: _]]), do: :render + defp classify_taint(:component, [_, [do: _]]), do: :render + defp classify_taint(:component, [_, _, [do: _]]), do: :render defp classify_taint(:render_layout, [_, _, _, [do: _]]), do: :render defp classify_taint(:alias, [_]), do: :always diff --git a/lib/phoenix_live_view/helpers.ex b/lib/phoenix_live_view/helpers.ex index 268019a7f..5e3ef7e59 100644 --- a/lib/phoenix_live_view/helpers.ex +++ b/lib/phoenix_live_view/helpers.ex @@ -230,6 +230,45 @@ defmodule Phoenix.LiveView.Helpers do end end + @doc """ + Renders a stateless component defined by a function. + + Takes two optional arguments, assigns and a do block that will be used as + the @inner_block. + + All of the `assigns` given are forwarded directly to the function as + the first only argument. + + ## Examples + + The function can either local: + + <%= component(&weather_component/1, city: "Kraków") %> + + Or remote: + + <%= component(&MyApp.Weather.component/1, city: "Kraków") %> + + """ + defmacro component(func, assigns \\ [], do_block \\ []) do + {do_block, assigns} = + case {do_block, assigns} do + {[do: do_block], _} -> {do_block, assigns} + {_, [do: do_block]} -> {do_block, []} + {_, _} -> {nil, assigns} + end + + {assigns, inner_block} = rewrite_do(do_block, assigns, __CALLER__) + + quote do + Phoenix.LiveView.Helpers.__component__( + unquote(func), + unquote(assigns), + unquote(inner_block) + ) + end + end + defp rewrite_do(nil, opts, _caller), do: {opts, nil} defp rewrite_do([{:->, meta, _} | _] = do_block, opts, _caller) do @@ -313,6 +352,7 @@ defmodule Phoenix.LiveView.Helpers do assigns = if inner, do: Map.put(assigns, :inner_block, inner), else: assigns id = assigns[:id] + # TODO: Deprecate stateless live component if is_nil(id) and (function_exported?(component, :handle_event, 3) or function_exported?(component, :preload, 1)) do @@ -328,6 +368,20 @@ defmodule Phoenix.LiveView.Helpers do raise "expected #{inspect(module)} to be a component, but it is a #{kind}" end + @doc false + def __component__(func, assigns, inner) + when is_function(func) and is_list(assigns) or is_map(assigns) do + assigns = Map.new(assigns) + assigns = if inner, do: Map.put(assigns, :inner_block, inner), else: assigns + + func.(assigns) + end + + def __component__(func, assigns) + when is_list(assigns) or is_map(assigns) do + raise ArgumentError, "component/3 expected an anonymous function, got: #{inspect(func)}" + end + @doc """ Renders the `@inner_block` assign of a component with the given `argument`. diff --git a/test/phoenix_live_view/controller_test.exs b/test/phoenix_live_view/controller_test.exs index 7a002cbdf..083c43b5c 100644 --- a/test/phoenix_live_view/controller_test.exs +++ b/test/phoenix_live_view/controller_test.exs @@ -24,4 +24,19 @@ defmodule Phoenix.LiveView.ControllerTest do conn = get(conn, "/controller/live-render-4") assert html_response(conn, 200) =~ "title: Dashboard" end + + test "renders function components from dead view", %{conn: conn} do + conn = get(conn, "/controller/render-with-function-component") + assert html_response(conn, 200) =~ "RENDER:COMPONENT:from component" + end + + test "renders function components from dead layout", %{conn: conn} do + conn = get(conn, "/controller/render-layout-with-function-component") + assert html_response(conn, 200) =~ """ + LAYOUT:COMPONENT:from layout + + Hello + + """ + end end diff --git a/test/phoenix_live_view/diff_test.exs b/test/phoenix_live_view/diff_test.exs index 614e7e8f7..b0979f3da 100644 --- a/test/phoenix_live_view/diff_test.exs +++ b/test/phoenix_live_view/diff_test.exs @@ -377,6 +377,54 @@ defmodule Phoenix.LiveView.DiffTest do end end + defmodule BlockNoArgsComponent do + use Phoenix.LiveComponent + + def mount(socket) do + {:ok, assign(socket, id: "DEFAULT")} + end + + def render(%{do: _}), do: raise("unexpected :do assign") + + def render(assigns) do + ~L""" + HELLO <%= @id %> <%= render_block(@inner_block) %> + HELLO <%= @id %> <%= render_block(@inner_block) %> + """ + end + end + + defmodule FunctionComponent do + def render_only(assigns) do + ~L""" + RENDER ONLY <%= @from %> + """ + end + + def render_with_block_no_args(assigns) do + ~L""" + HELLO <%= @id %> <%= render_block(@inner_block) %> + HELLO <%= @id %> <%= render_block(@inner_block) %> + """ + end + + def render_with_block(assigns) do + ~L""" + HELLO <%= @id %> <%= render_block(@inner_block, 1) %> + HELLO <%= @id %> <%= render_block(@inner_block, 2) %> + """ + end + + def render_with_live_component(assigns) do + ~L""" + COMPONENT + <%= live_component :fake_socket, BlockComponent, id: "WORLD" do %> + WITH VALUE <%= @value %> + <% end %> + """ + end + end + defmodule TreeComponent do use Phoenix.LiveComponent @@ -520,8 +568,8 @@ defmodule Phoenix.LiveView.DiffTest do assigns = %{socket: %Socket{}} rendered = ~L""" - <%= live_component @socket, BlockComponent do %> - WITH VALUE <%= @value %> + <%= live_component @socket, BlockNoArgsComponent do %> + INSIDE BLOCK <% end %> """ @@ -530,16 +578,165 @@ defmodule Phoenix.LiveView.DiffTest do assert full_render == %{ 0 => %{ 0 => "", - 1 => %{0 => "1", :s => ["\n WITH VALUE ", "\n"]}, + 1 => %{s: ["\n INSIDE BLOCK\n"]}, 2 => "", - 3 => %{0 => "2", :s => ["\n WITH VALUE ", "\n"]}, + 3 => %{s: ["\n INSIDE BLOCK\n"]}, :s => ["HELLO ", " ", "\nHELLO ", " ", "\n"] }, :s => ["", "\n"] } {_socket, full_render, _components} = render(rendered, socket.fingerprints, components) - assert full_render == %{0 => %{0 => "", 1 => %{0 => "1"}, 2 => "", 3 => %{0 => "2"}}} + assert full_render == %{0 => %{0 => "", 2 => ""}} + end + end + + describe "function components" do + test "render only" do + assigns = %{socket: %Socket{}} + + rendered = ~L""" + <%= component &FunctionComponent.render_only/1, from: :component %> + """ + + {socket, full_render, components} = render(rendered) + + assert full_render == %{ + 0 => %{ + 0 => "component", + :s => ["RENDER ONLY ", "\n"] + }, + :s => ["", "\n"] + } + + assert socket.fingerprints != {rendered.fingerprint, %{}} + assert components == Diff.new_components() + end + + test "block tracking without args" do + assigns = %{socket: %Socket{}} + + rendered = ~L""" + <%= component &FunctionComponent.render_with_block_no_args/1, id: "DEFAULT" do %> + INSIDE BLOCK + <% end %> + """ + + {socket, full_render, components} = render(rendered) + + assert full_render == %{ + 0 => %{ + 0 => "DEFAULT", + 1 => %{s: ["\n INSIDE BLOCK\n"]}, + 2 => "DEFAULT", + 3 => %{s: ["\n INSIDE BLOCK\n"]}, + :s => ["HELLO ", " ", "\nHELLO ", " ", "\n"] + }, + :s => ["", "\n"] + } + + {_socket, full_render, _components} = render(rendered, socket.fingerprints, components) + assert full_render == %{0 => %{0 => "DEFAULT", 2 => "DEFAULT"}} + end + + defp function_tracking(assigns) do + ~L""" + <%= component &FunctionComponent.render_with_block/1, id: @id do %> + <% value -> %> + WITH VALUE <%= value %> - <%= @value %> + <% end %> + """ + end + + test "block tracking with args and parent assign" do + assigns = %{socket: %Socket{}, value: 123, id: "DEFAULT"} + + {socket, full_render, components} = render(function_tracking(assigns)) + + assert full_render == %{ + 0 => %{ + 0 => "DEFAULT", + 1 => %{0 => "1", :s => ["\n WITH VALUE ", " - ", "\n"], 1 => "123"}, + 2 => "DEFAULT", + 3 => %{0 => "2", :s => ["\n WITH VALUE ", " - ", "\n"], 1 => "123"}, + :s => ["HELLO ", " ", "\nHELLO ", " ", "\n"] + }, + :s => ["", "\n"] + } + + {_socket, full_render, _components} = + render(function_tracking(assigns), socket.fingerprints, components) + + assert full_render == %{ + 0 => %{ + 0 => "DEFAULT", + 1 => %{0 => "1", 1 => "123"}, + 2 => "DEFAULT", + 3 => %{0 => "2", 1 => "123"} + } + } + + assigns = Map.put(assigns, :__changed__, %{}) + + {_socket, full_render, _components} = + render(function_tracking(assigns), socket.fingerprints, components) + + assert full_render == %{} + + assigns = Map.put(assigns, :__changed__, %{id: true}) + + {_socket, full_render, _components} = + render(function_tracking(assigns), socket.fingerprints, components) + + assert full_render == %{ + 0 => %{ + 0 => "DEFAULT", + 1 => %{0 => "1"}, + 2 => "DEFAULT", + 3 => %{0 => "2"} + } + } + + assigns = Map.put(assigns, :__changed__, %{value: true}) + + {_socket, full_render, _components} = + render(function_tracking(assigns), socket.fingerprints, components) + + assert full_render == %{ + 0 => %{ + 0 => "DEFAULT", + 1 => %{0 => "1", 1 => "123"}, + 2 => "DEFAULT", + 3 => %{0 => "2", 1 => "123"} + } + } + end + + test "with live_component" do + assigns = %{socket: %Socket{}} + + rendered = ~L""" + <%= component &FunctionComponent.render_with_live_component/1 %> + """ + + {socket, full_render, components} = render(rendered) + + assert full_render == %{ + 0 => %{0 => 1, :s => ["COMPONENT\n", "\n"]}, + :c => %{ + 1 => %{ + 0 => "WORLD", + 1 => %{0 => "1", :s => ["\n WITH VALUE ", "\n"]}, + 2 => "WORLD", + 3 => %{0 => "2", :s => ["\n WITH VALUE ", "\n"]}, + :s => ["HELLO ", " ", "\nHELLO ", " ", "\n"] + } + }, + :s => ["", "\n"] + } + + {_socket, full_render, _components} = render(rendered, socket.fingerprints, components) + assert full_render == %{0 => %{0 => 1}} end end @@ -1172,33 +1369,6 @@ defmodule Phoenix.LiveView.DiffTest do assert full_render == %{0 => 1} end - test "explicit block tracking" do - assigns = %{socket: %Socket{}} - - rendered = ~L""" - <%= live_component @socket, BlockComponent, id: "WORLD" do %> - <% extra -> %> - WITH EXTRA <%= inspect(extra) %> - <% end %> - """ - - {_socket, full_render, _components} = render(rendered) - - assert full_render == %{ - 0 => 1, - :c => %{ - 1 => %{ - 0 => "WORLD", - 1 => %{0 => "[value: 1]", :s => ["\n WITH EXTRA ", "\n"]}, - 2 => "WORLD", - 3 => %{0 => "[value: 2]", :s => ["\n WITH EXTRA ", "\n"]}, - :s => ["HELLO ", " ", "\nHELLO ", " ", "\n"] - } - }, - :s => ["", "\n"] - } - end - defp tracking(assigns) do ~L""" <%= live_component @socket, BlockComponent, %{id: "TRACKING"} do %> @@ -1208,6 +1378,7 @@ defmodule Phoenix.LiveView.DiffTest do """ end + # TODO: Change this to "with args and parent assign" once we deprecate implicit assigns test "block tracking with child and parent assigns" do assigns = %{socket: %Socket{}, parent_value: 123} {socket, full_render, components} = render(tracking(assigns)) diff --git a/test/phoenix_live_view/integrations/function_components_test.exs b/test/phoenix_live_view/integrations/function_components_test.exs new file mode 100644 index 000000000..c3f835e0c --- /dev/null +++ b/test/phoenix_live_view/integrations/function_components_test.exs @@ -0,0 +1,130 @@ +defmodule Phoenix.LiveView.FunctionComponentsTest do + use ExUnit.Case, async: false + + import Phoenix.LiveViewTest + alias Phoenix.LiveViewTest.Endpoint + + @endpoint Endpoint + + setup do + {:ok, conn: Phoenix.ConnTest.build_conn()} + end + + defmodule RenderOnly do + use Phoenix.LiveComponent + + def render(assigns) do + ~L""" + <%= component &hello/1, name: "WORLD" %> + """ + end + + defp hello(assigns) do + ~L""" + Hello <%= @name %> + """ + end + end + + defmodule RenderWithBlock do + use Phoenix.LiveComponent + + def render(assigns) do + ~L""" + <%= component &hello/1, name: "WORLD" do %> + THE INNER BLOCK + <% end %> + """ + end + + def hello(assigns) do + ~L""" + Hello <%= @name %> + <%= render_block @inner_block %> + """ + end + end + + defmodule RenderWithBlockPassingArgs do + use Phoenix.LiveComponent + + def render(assigns) do + ~L""" + <%= component &hello/1, name: "WORLD" do %> + <% [arg1: arg1, arg2: arg2] -> %> + THE INNER BLOCK + ARG1: <%= arg1 %> + ARG2: <%= arg2 %> + <% end %> + """ + end + + def hello(assigns) do + ~L""" + Hello <%= @name %> + <%= render_block @inner_block, arg1: 1, arg2: 2 %> + """ + end + end + + defmodule RenderWithLiveComponent do + use Phoenix.LiveComponent + + def render(assigns) do + ~L""" + <%= component &render_with_live_component/1 %> + """ + end + + def render_with_live_component(assigns) do + ~L""" + COMPONENT + <%= live_component :fake_socket, RenderWithBlockPassingArgs %> + """ + end + end + + test "render component" do + assert render_component(RenderOnly, %{}) == """ + Hello WORLD + + """ + end + + test "render component with block" do + assert render_component(RenderWithBlock, %{}) == """ + Hello WORLD + + THE INNER BLOCK + + + """ + end + + test "render component with block passing args" do + assert render_component(RenderWithBlockPassingArgs, %{}) == """ + Hello WORLD + + THE INNER BLOCK + ARG1: 1 + ARG2: 2 + + + """ + end + + test "render component with live_component" do + assert render_component(RenderWithLiveComponent, %{}) == """ + COMPONENT + Hello WORLD + + THE INNER BLOCK + ARG1: 1 + ARG2: 2 + + + + + """ + end +end diff --git a/test/support/controller.ex b/test/support/controller.ex index 1b8c0949f..59d0b4c73 100644 --- a/test/support/controller.ex +++ b/test/support/controller.ex @@ -24,6 +24,19 @@ defmodule Phoenix.LiveViewTest.Controller do |> live_render(Phoenix.LiveViewTest.DashboardLive) end + def incoming(conn, %{"type" => "render-with-function-component"}) do + conn + |> put_view(Phoenix.LiveViewTest.LayoutView) + |> render("with-function-component.html") + end + + def incoming(conn, %{"type" => "render-layout-with-function-component"}) do + conn + |> put_view(Phoenix.LiveViewTest.LayoutView) + |> put_root_layout({Phoenix.LiveViewTest.LayoutView, "layout-with-function-component.html"}) + |> render("hello.html") + end + def not_found(conn, _) do conn |> put_status(:not_found) diff --git a/test/support/layout_view.ex b/test/support/layout_view.ex index 79d5a8c97..5de2d4077 100644 --- a/test/support/layout_view.ex +++ b/test/support/layout_view.ex @@ -29,6 +29,25 @@ defmodule Phoenix.LiveViewTest.LayoutView do """ end + def render("with-function-component.html", assigns) do + ~L""" + RENDER:<%= component(&Phoenix.LiveViewTest.FunctionComponent.render/1, value: "from component") %> + """ + end + + def render("layout-with-function-component.html", assigns) do + ~L""" + LAYOUT:<%= component(&Phoenix.LiveViewTest.FunctionComponent.render/1, value: "from layout") %> + <%= @inner_content %> + """ + end + + def render("hello.html", assigns) do + ~L""" + Hello + """ + end + def render("styled.html", assigns) do ~L""" diff --git a/test/support/live_views/components.ex b/test/support/live_views/components.ex index 74dc8c9d1..94f083c66 100644 --- a/test/support/live_views/components.ex +++ b/test/support/live_views/components.ex @@ -1,3 +1,13 @@ +defmodule Phoenix.LiveViewTest.FunctionComponent do + import Phoenix.LiveView.Helpers + + def render(assigns) do + ~L""" + COMPONENT:<%= @value %> + """ + end +end + defmodule Phoenix.LiveViewTest.StatefulComponent do use Phoenix.LiveComponent