From dd590d6d104b449554157450319b2354dd646727 Mon Sep 17 00:00:00 2001 From: Benjamin Milde Date: Wed, 18 Jan 2023 20:50:06 +0100 Subject: [PATCH 1/5] Add `<.inputs_for />` --- lib/phoenix_component.ex | 90 +++++++++++++ test/phoenix_component/components_test.exs | 147 +++++++++++++++++++++ 2 files changed, 237 insertions(+) diff --git a/lib/phoenix_component.ex b/lib/phoenix_component.ex index 1ba0cf25f..714dc7eaf 100644 --- a/lib/phoenix_component.ex +++ b/lib/phoenix_component.ex @@ -2014,6 +2014,96 @@ defmodule Phoenix.Component do defp form_method(method) when method in ~w(get post), do: {method, nil} defp form_method(method) when is_binary(method), do: {"post", method} + @doc """ + Renders nested form inputs for associations or embeds. + + [INSERT LVATTRDOCS] + + This function is built on top of `Phoenix.HTML.Form.inputs_for/3`. + + For more information about options and how to build inputs, see `Phoenix.HTML.Form`. + + ## Examples + + ```heex + <.form + :let={f} + for={@changeset} + phx-change="change_name" + > + <.inputs_for :let={f_nested} field={{:f, :nested}}> + <%= text_input f_nested, :name %> + + + ``` + """ + attr.(:field, :any, + required: true, + doc: "A %Phoenix.HTML.Form{}/field name tuple, for example: {f, :email}." + ) + + attr.(:id, :string, + doc: """ + The id to be used in the form, defaults to the concatenation of the given + field to the parent form id. + """ + ) + + attr.(:as, :atom, + doc: """ + The name to be used in the form, defaults to the concatenation of the given + field to the parent form name. + """ + ) + + attr.(:default, :any, doc: "The value to use if none is available.") + + attr.(:prepend, :list, + doc: """ + The values to prepend when rendering. This only applies if the field value + is a list and no parameters were sent through the form. + """ + ) + + attr.(:append, :list, + doc: """ + The values to append when rendering. This only applies if the field value + is a list and no parameters were sent through the form. + """ + ) + + attr.(:skip_hidden, :boolean, + default: false, + doc: """ + Skip the automatic rendering of hidden fields to allow for more tight control + over the generated markup. You can access `form.hidden` or use + `Phoenix.HTML.Form.hidden_inputs_for/1` to generate them manually. + """ + ) + + slot.(:inner_block, required: true, doc: "The content rendered for each nested form.") + + def inputs_for(assigns) do + {form, field} = assigns[:field] || raise ArgumentError, "missing :field assign to inputs_for" + + opts = + assigns + |> Map.take([:id, :as, :default, :append, :prepend]) + |> Enum.reject(fn {_, v} -> v == nil end) + + assigns = + assigns + |> assign(:field, nil) + |> assign(:inputs, Phoenix.HTML.Form.inputs_for(form, field, opts)) + + ~H""" + <%= for finner <- @inputs do %> + <%= unless @skip_hidden, do: Phoenix.HTML.Form.hidden_inputs_for(finner) %> + <%= render_slot(@inner_block, finner) %> + <% end %> + """ + end + @doc """ Generates a link for live and href navigation. diff --git a/test/phoenix_component/components_test.exs b/test/phoenix_component/components_test.exs index d58a5eee3..1f2aa5921 100644 --- a/test/phoenix_component/components_test.exs +++ b/test/phoenix_component/components_test.exs @@ -456,6 +456,153 @@ defmodule Phoenix.LiveView.ComponentsTest do end end + describe "inputs_for" do + test "raises when missing required assigns" do + assert_raise ArgumentError, ~r/missing :field assign/, fn -> + assigns = %{} + + template = ~H""" + <.form :let={_f} for={:myform}> + <.inputs_for :let={finner}> + <%= text_input finner, :foo %> + + + """ + + parse(template) + end + end + + test "generates nested inputs with no options" do + assigns = %{} + + template = ~H""" + <.form :let={f} for={:myform}> + <.inputs_for :let={finner} field={{f, :inner}}> + <%= text_input finner, :foo %> + + + """ + + html = parse(template) + + assert [ + {"form", [], + [ + {"input", + [ + {"id", "myform_inner_foo"}, + {"name", "myform[inner][foo]"}, + {"type", "text"} + ], []} + ]} + ] = html + end + + test "with naming options" do + assigns = %{} + + template = ~H""" + <.form :let={f} for={:myform}> + <.inputs_for :let={finner} field={{f, :inner}} id="test" as={:name}> + <%= text_input finner, :foo %> + + + """ + + html = parse(template) + + assert [ + {"form", [], + [ + {"input", + [ + {"id", "test_foo"}, + {"name", "name[foo]"}, + {"type", "text"} + ], []} + ]} + ] = html + end + + test "with default map option" do + assigns = %{} + + template = ~H""" + <.form :let={f} for={:myform}> + <.inputs_for :let={finner} field={{f, :inner}} default={%{foo: "123"}}> + <%= text_input finner, :foo %> + + + """ + + html = parse(template) + + assert [ + {"form", [], + [ + {"input", + [ + {"id", "myform_inner_foo"}, + {"name", "myform[inner][foo]"}, + {"type", "text"}, + {"value", "123"} + ], []} + ]} + ] = html + end + + test "with default list and list related options" do + assigns = %{} + + template = ~H""" + <.form :let={f} for={:myform}> + <.inputs_for + :let={finner} + field={{f, :inner}} + default={[%{foo: "456"}]} + prepend={[%{foo: "123"}]} + append={[%{foo: "789"}]} + > + <%= text_input finner, :foo %> + + + """ + + html = parse(template) + + assert [ + {"form", [], + [ + { + "input", + [ + {"id", "myform_inner_0_foo"}, + {"name", "myform[inner][0][foo]"}, + {"type", "text"}, + {"value", "123"} + ], + [] + }, + {"input", + [ + {"id", "myform_inner_1_foo"}, + {"name", "myform[inner][1][foo]"}, + {"type", "text"}, + {"value", "456"} + ], []}, + {"input", + [ + {"id", "myform_inner_2_foo"}, + {"name", "myform[inner][2][foo]"}, + {"type", "text"}, + {"value", "789"} + ], []} + ]} + ] = html + end + end + describe "live_file_input/1" do test "renders attributes" do assigns = %{ From 3c0129a27ad02cde419abf04632c9f29e93c7223 Mon Sep 17 00:00:00 2001 From: Benjamin Milde Date: Sat, 21 Jan 2023 20:41:47 +0100 Subject: [PATCH 2/5] Update documentation on inputs_for --- guides/client/form-bindings.md | 21 ++++----------------- 1 file changed, 4 insertions(+), 17 deletions(-) diff --git a/guides/client/form-bindings.md b/guides/client/form-bindings.md index 222267b7a..e0d7be2cf 100644 --- a/guides/client/form-bindings.md +++ b/guides/client/form-bindings.md @@ -169,28 +169,15 @@ requires explicitly setting the `:value` in your markup, for example: ## Nested inputs -Nested inputs are handled using `inputs_for` form helpers. There are two versions -of `inputs_for` - one that takes an anonymous function and one that doesn't. The version -that takes an anonymous function won't work properly with LiveView as it prevents rendering -of LiveComponents. Instead of using this: +Nested inputs are handled using `.inputs_for` function component. By default +it will add the necessary hidden input fields for tracking ids of Ecto associations. ```heex -<%= inputs_for f, :friend, fn fp -> %> - <%= text_input fp, :url %> -<% end %> -``` - -you should use this: - -```heex -<%= for fp <- inputs_for(f, :friends) do %> - <%= hidden_inputs_for(fp) %> +<.inputs_for :let={fp} field={{f, :friends}}> <%= text_input fp, :name %> -<% end %> + ``` -Note that you will need to include a call to `hidden_inputs_for` as the version of inputs_for that does not take an anonymous function also does not automatically generate any necessary hidden fields for tracking ids of Ecto associations. - ## File inputs LiveView forms support [reactive file inputs](uploads.md), From 454f33e1c3a654b80556367d9e433b7b52fa327c Mon Sep 17 00:00:00 2001 From: Benjamin Milde Date: Sat, 21 Jan 2023 21:24:22 +0100 Subject: [PATCH 3/5] Migrate more stuff from phoenix_html --- lib/phoenix_component.ex | 105 +++++++++++++++++++-- test/phoenix_component/components_test.exs | 84 +++++++++++++++++ 2 files changed, 182 insertions(+), 7 deletions(-) diff --git a/lib/phoenix_component.ex b/lib/phoenix_component.ex index 714dc7eaf..69617a4ef 100644 --- a/lib/phoenix_component.ex +++ b/lib/phoenix_component.ex @@ -2021,7 +2021,7 @@ defmodule Phoenix.Component do This function is built on top of `Phoenix.HTML.Form.inputs_for/3`. - For more information about options and how to build inputs, see `Phoenix.HTML.Form`. + For more information about options and how to build inputs, see `Phoenix.HTML.Form`. ## Examples @@ -2076,8 +2076,8 @@ defmodule Phoenix.Component do default: false, doc: """ Skip the automatic rendering of hidden fields to allow for more tight control - over the generated markup. You can access `form.hidden` or use - `Phoenix.HTML.Form.hidden_inputs_for/1` to generate them manually. + over the generated markup. You can access `form.hidden` or use `.hidden_inputs/1` + to generate them manually. """ ) @@ -2086,24 +2086,115 @@ defmodule Phoenix.Component do def inputs_for(assigns) do {form, field} = assigns[:field] || raise ArgumentError, "missing :field assign to inputs_for" - opts = + options = assigns |> Map.take([:id, :as, :default, :append, :prepend]) |> Enum.reject(fn {_, v} -> v == nil end) + options = + form.options + |> Keyword.take([:multipart]) + |> Keyword.merge(options) + assigns = assigns |> assign(:field, nil) - |> assign(:inputs, Phoenix.HTML.Form.inputs_for(form, field, opts)) + |> assign(:forms, form.impl.to_form(form.source, form, field, options)) ~H""" - <%= for finner <- @inputs do %> - <%= unless @skip_hidden, do: Phoenix.HTML.Form.hidden_inputs_for(finner) %> + <%= for finner <- @forms do %> + <.hidden_inputs :if={!@skip_hidden} form={finner} /> <%= render_slot(@inner_block, finner) %> <% end %> """ end + @doc """ + Renders hidden inputs for a form. + + [INSERT LVATTRDOCS] + + This function is built on top of `Phoenix.HTML.Form.hidden_inputs_for/1`. + + ## Examples + + ```heex + <.form + :let={f} + for={@changeset} + phx-change="change_name" + > + <.inputs_for :let={f_nested} field={{:f, :nested}} skip_hidden> + <%= text_input f_nested, :name %> + <.hidden_inputs form={f_nested} /> + + + ``` + """ + + attr.(:form, Phoenix.HTML.Form, + required: true, + doc: "A %Phoenix.HTML.Form{} struct." + ) + + def hidden_inputs(%{form: form} = assigns) do + inputs = + Enum.flat_map(form.hidden, fn + {field, values} when is_list(values) -> + id = input_id(form, field) + name = input_name(form, field) + + values + |> Enum.with_index() + |> Enum.map(fn {value, index} -> + %{ + id: id <> "_" <> Integer.to_string(index), + name: name <> "[]", + value: value + } + end) + + {field, value} -> + [ + %{ + id: input_id(form, field), + name: input_name(form, field), + value: value + } + ] + end) + + assigns = assign(assigns, :inputs, inputs) + + ~H""" + <%= for opts <- @inputs do %> + + <% end %> + """ + end + + @spec input_name(Phoenix.HTML.Form.t() | atom, atom | String.t()) :: String.t() + defp input_name(form_or_name, field) + + defp input_name(%{name: nil}, field), do: to_string(field) + + defp input_name(%{name: name}, field) when is_atom(field) or is_binary(field), + do: "#{name}[#{field}]" + + defp input_name(name, field) when (is_atom(name) and is_atom(field)) or is_binary(field), + do: "#{name}[#{field}]" + + @spec input_id(Phoenix.HTML.Form.t() | atom, atom | String.t()) :: String.t() + defp input_id(%{id: nil}, field), do: "#{field}" + + defp input_id(%{id: id}, field) when is_atom(field) or is_binary(field) do + "#{id}_#{field}" + end + + defp input_id(name, field) when (is_atom(name) and is_atom(field)) or is_binary(field) do + "#{name}_#{field}" + end + @doc """ Generates a link for live and href navigation. diff --git a/test/phoenix_component/components_test.exs b/test/phoenix_component/components_test.exs index 1f2aa5921..e25bcf680 100644 --- a/test/phoenix_component/components_test.exs +++ b/test/phoenix_component/components_test.exs @@ -603,6 +603,90 @@ defmodule Phoenix.LiveView.ComponentsTest do end end + describe "hidden_inputs/1" do + test "doesn't render anything if there are no hidden fields" do + assigns = %{} + + template = ~H""" + <.form :let={f} for={:myform}> + <.inputs_for :let={finner} field={{f, :inner}} default={%{foo: "123"}} skip_hidden> + <.hidden_inputs form={finner} /> + + + """ + + html = parse(template) + + assert [ + {"form", [], []} + ] = html + end + + test "renders hidden input for single value hidden field" do + assigns = %{} + + template = ~H""" + <.form :let={f} for={:myform}> + <.inputs_for :let={finner} field={{f, :inner}} default={%{id: 12, foo: "123"}} skip_hidden> + <.hidden_inputs form={Map.put(finner, :hidden, [id: finner.data.id])} /> + + + """ + + html = parse(template) + + assert [ + {"form", [], + [ + {"input", + [ + {"type", "hidden"}, + {"id", "myform_inner_id"}, + {"name", "myform[inner][id]"}, + {"value", "12"} + ], []} + ]} + ] = html + end + + test "renders hidden input for multiple value hidden field" do + assigns = %{} + + template = ~H""" + <.form :let={f} for={:myform}> + <.inputs_for :let={finner} field={{f, :inner}} default={%{type: ["abc", "def"], foo: "123"}} skip_hidden> + <.hidden_inputs form={Map.put(finner, :hidden, [type: finner.data.type])} /> + + + """ + + html = parse(template) + + assert [ + {"form", [], + [ + { + "input", + [ + {"type", "hidden"}, + {"id", "myform_inner_type_0"}, + {"name", "myform[inner][type][]"}, + {"value", "abc"} + ], + [] + }, + {"input", + [ + {"type", "hidden"}, + {"id", "myform_inner_type_1"}, + {"name", "myform[inner][type][]"}, + {"value", "def"} + ], []} + ]} + ] = html + end + end + describe "live_file_input/1" do test "renders attributes" do assigns = %{ From 38335ef903238312c04e503bce54f0758306a691 Mon Sep 17 00:00:00 2001 From: Benjamin Milde Date: Sat, 21 Jan 2023 22:19:10 +0100 Subject: [PATCH 4/5] Simplify implementation --- lib/phoenix_component.ex | 87 ++++------------------ test/phoenix_component/components_test.exs | 84 --------------------- 2 files changed, 14 insertions(+), 157 deletions(-) diff --git a/lib/phoenix_component.ex b/lib/phoenix_component.ex index 69617a4ef..484fe5418 100644 --- a/lib/phoenix_component.ex +++ b/lib/phoenix_component.ex @@ -2103,74 +2103,26 @@ defmodule Phoenix.Component do ~H""" <%= for finner <- @forms do %> - <.hidden_inputs :if={!@skip_hidden} form={finner} /> + <%= unless @skip_hidden do %> + <%= for {name, value_or_values} <- finner.hidden, + name = name_for_value_or_values(finner, name, value_or_values), + value <- List.wrap(value_or_values) do %> + + <% end %> + <% end %> <%= render_slot(@inner_block, finner) %> <% end %> """ end - @doc """ - Renders hidden inputs for a form. - - [INSERT LVATTRDOCS] - - This function is built on top of `Phoenix.HTML.Form.hidden_inputs_for/1`. - - ## Examples - - ```heex - <.form - :let={f} - for={@changeset} - phx-change="change_name" - > - <.inputs_for :let={f_nested} field={{:f, :nested}} skip_hidden> - <%= text_input f_nested, :name %> - <.hidden_inputs form={f_nested} /> - - - ``` - """ - - attr.(:form, Phoenix.HTML.Form, - required: true, - doc: "A %Phoenix.HTML.Form{} struct." - ) - - def hidden_inputs(%{form: form} = assigns) do - inputs = - Enum.flat_map(form.hidden, fn - {field, values} when is_list(values) -> - id = input_id(form, field) - name = input_name(form, field) - - values - |> Enum.with_index() - |> Enum.map(fn {value, index} -> - %{ - id: id <> "_" <> Integer.to_string(index), - name: name <> "[]", - value: value - } - end) - - {field, value} -> - [ - %{ - id: input_id(form, field), - name: input_name(form, field), - value: value - } - ] - end) - - assigns = assign(assigns, :inputs, inputs) + @spec name_for_value_or_values(Phoenix.HTML.Form.t() | atom, atom | String.t(), term) :: + String.t() + defp name_for_value_or_values(form, field, values) when is_list(values) do + input_name(form, field) <> "[]" + end - ~H""" - <%= for opts <- @inputs do %> - - <% end %> - """ + defp name_for_value_or_values(form, field, _value) do + input_name(form, field) end @spec input_name(Phoenix.HTML.Form.t() | atom, atom | String.t()) :: String.t() @@ -2184,17 +2136,6 @@ defmodule Phoenix.Component do defp input_name(name, field) when (is_atom(name) and is_atom(field)) or is_binary(field), do: "#{name}[#{field}]" - @spec input_id(Phoenix.HTML.Form.t() | atom, atom | String.t()) :: String.t() - defp input_id(%{id: nil}, field), do: "#{field}" - - defp input_id(%{id: id}, field) when is_atom(field) or is_binary(field) do - "#{id}_#{field}" - end - - defp input_id(name, field) when (is_atom(name) and is_atom(field)) or is_binary(field) do - "#{name}_#{field}" - end - @doc """ Generates a link for live and href navigation. diff --git a/test/phoenix_component/components_test.exs b/test/phoenix_component/components_test.exs index e25bcf680..1f2aa5921 100644 --- a/test/phoenix_component/components_test.exs +++ b/test/phoenix_component/components_test.exs @@ -603,90 +603,6 @@ defmodule Phoenix.LiveView.ComponentsTest do end end - describe "hidden_inputs/1" do - test "doesn't render anything if there are no hidden fields" do - assigns = %{} - - template = ~H""" - <.form :let={f} for={:myform}> - <.inputs_for :let={finner} field={{f, :inner}} default={%{foo: "123"}} skip_hidden> - <.hidden_inputs form={finner} /> - - - """ - - html = parse(template) - - assert [ - {"form", [], []} - ] = html - end - - test "renders hidden input for single value hidden field" do - assigns = %{} - - template = ~H""" - <.form :let={f} for={:myform}> - <.inputs_for :let={finner} field={{f, :inner}} default={%{id: 12, foo: "123"}} skip_hidden> - <.hidden_inputs form={Map.put(finner, :hidden, [id: finner.data.id])} /> - - - """ - - html = parse(template) - - assert [ - {"form", [], - [ - {"input", - [ - {"type", "hidden"}, - {"id", "myform_inner_id"}, - {"name", "myform[inner][id]"}, - {"value", "12"} - ], []} - ]} - ] = html - end - - test "renders hidden input for multiple value hidden field" do - assigns = %{} - - template = ~H""" - <.form :let={f} for={:myform}> - <.inputs_for :let={finner} field={{f, :inner}} default={%{type: ["abc", "def"], foo: "123"}} skip_hidden> - <.hidden_inputs form={Map.put(finner, :hidden, [type: finner.data.type])} /> - - - """ - - html = parse(template) - - assert [ - {"form", [], - [ - { - "input", - [ - {"type", "hidden"}, - {"id", "myform_inner_type_0"}, - {"name", "myform[inner][type][]"}, - {"value", "abc"} - ], - [] - }, - {"input", - [ - {"type", "hidden"}, - {"id", "myform_inner_type_1"}, - {"name", "myform[inner][type][]"}, - {"value", "def"} - ], []} - ]} - ] = html - end - end - describe "live_file_input/1" do test "renders attributes" do assigns = %{ From 4fed52e405dde86f73a2a7552937d004157c4bfd Mon Sep 17 00:00:00 2001 From: Benjamin Milde Date: Sat, 21 Jan 2023 22:53:38 +0100 Subject: [PATCH 5/5] Apply feedback --- lib/phoenix_component.ex | 22 +++------------------- 1 file changed, 3 insertions(+), 19 deletions(-) diff --git a/lib/phoenix_component.ex b/lib/phoenix_component.ex index 484fe5418..176a2bf39 100644 --- a/lib/phoenix_component.ex +++ b/lib/phoenix_component.ex @@ -2086,10 +2086,7 @@ defmodule Phoenix.Component do def inputs_for(assigns) do {form, field} = assigns[:field] || raise ArgumentError, "missing :field assign to inputs_for" - options = - assigns - |> Map.take([:id, :as, :default, :append, :prepend]) - |> Enum.reject(fn {_, v} -> v == nil end) + options = assigns |> Map.take([:id, :as, :default, :append, :prepend]) |> Keyword.new() options = form.options @@ -2115,27 +2112,14 @@ defmodule Phoenix.Component do """ end - @spec name_for_value_or_values(Phoenix.HTML.Form.t() | atom, atom | String.t(), term) :: - String.t() defp name_for_value_or_values(form, field, values) when is_list(values) do - input_name(form, field) <> "[]" + Phoenix.HTML.Form.input_name(form, field) <> "[]" end defp name_for_value_or_values(form, field, _value) do - input_name(form, field) + Phoenix.HTML.Form.input_name(form, field) end - @spec input_name(Phoenix.HTML.Form.t() | atom, atom | String.t()) :: String.t() - defp input_name(form_or_name, field) - - defp input_name(%{name: nil}, field), do: to_string(field) - - defp input_name(%{name: name}, field) when is_atom(field) or is_binary(field), - do: "#{name}[#{field}]" - - defp input_name(name, field) when (is_atom(name) and is_atom(field)) or is_binary(field), - do: "#{name}[#{field}]" - @doc """ Generates a link for live and href navigation.