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), diff --git a/lib/phoenix_component.ex b/lib/phoenix_component.ex index 1ba0cf25f..176a2bf39 100644 --- a/lib/phoenix_component.ex +++ b/lib/phoenix_component.ex @@ -2014,6 +2014,112 @@ 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 `.hidden_inputs/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" + + options = assigns |> Map.take([:id, :as, :default, :append, :prepend]) |> Keyword.new() + + options = + form.options + |> Keyword.take([:multipart]) + |> Keyword.merge(options) + + assigns = + assigns + |> assign(:field, nil) + |> assign(:forms, form.impl.to_form(form.source, form, field, options)) + + ~H""" + <%= for finner <- @forms do %> + <%= 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 + + defp name_for_value_or_values(form, field, values) when is_list(values) do + Phoenix.HTML.Form.input_name(form, field) <> "[]" + end + + defp name_for_value_or_values(form, field, _value) do + Phoenix.HTML.Form.input_name(form, 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 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 = %{