Skip to content

Commit 0b03316

Browse files
authored
Allow deleting fields with ordinal inputs (#312)
Commit f9d1bb9 (PR #208) had to revert an attempt to handle deleting fields in ordinal inputs (something like `inputs_for`). This commit adds the test setup from previous attempt to reproduce error and introduce a fix. The fix is slightly complex (not sure if we can do better) but it compares the current HTML fields with those that have been changed (based on what we have in memory) so long as they're index-based. Otherwise, things remain the same (which prevents the previous regression)
1 parent 0bbadd3 commit 0b03316

6 files changed

Lines changed: 233 additions & 5 deletions

File tree

lib/phoenix_test/form_data.ex

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,10 @@ defmodule PhoenixTest.FormData do
9898
value == field_data or value in List.wrap(field_data)
9999
end
100100

101+
def field_names(%__MODULE__{data: data}) do
102+
Map.keys(data)
103+
end
104+
101105
def to_list(%__MODULE__{data: data}) do
102106
data
103107
|> Enum.map(fn

lib/phoenix_test/live.ex

Lines changed: 64 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -518,8 +518,7 @@ defmodule PhoenixTest.Live do
518518
def submit_form(session, selector, form_data, additional_data \\ FormData.new()) do
519519
form = Form.find!(session.current_operation.html, selector)
520520

521-
form_data = remove_data_for_fields_that_have_been_removed(form_data, form)
522-
form_data = FormData.override(form.form_data, form_data)
521+
form_data = select_form_data_to_submit(form, form_data)
523522

524523
additional_data =
525524
if form.submit_button do
@@ -548,14 +547,74 @@ defmodule PhoenixTest.Live do
548547
end
549548
end
550549

551-
defp remove_data_for_fields_that_have_been_removed(form_data, form) do
552-
element_names = Form.form_element_names(form)
550+
defp select_form_data_to_submit(form, form_data) do
551+
element_names_present_in_final_form = Form.form_element_names(form)
552+
form_data = remove_data_for_fields_that_have_been_removed(form_data, element_names_present_in_final_form)
553+
554+
FormData.override(form.form_data, form_data)
555+
end
556+
557+
defp remove_data_for_fields_that_have_been_removed(form_data, element_names_present_in_final_form) do
558+
element_names_set = MapSet.new(element_names_present_in_final_form)
559+
560+
removed_index_groups =
561+
form_data
562+
|> FormData.field_names()
563+
|> Enum.filter(&name_index_based_and_not_in_current_form_names?(&1, element_names_set))
564+
|> MapSet.new(&index_group_key/1)
553565

554566
FormData.filter(form_data, fn %{name: name} ->
555-
name in element_names
567+
keep_field?(name, element_names_set, removed_index_groups)
556568
end)
557569
end
558570

571+
defp keep_field?(name, element_names_set, removed_index_groups) do
572+
cond do
573+
not MapSet.member?(element_names_set, name) ->
574+
false
575+
576+
not index_based_field?(name) ->
577+
true
578+
579+
true ->
580+
not MapSet.member?(removed_index_groups, index_group_key(name))
581+
end
582+
end
583+
584+
defp name_index_based_and_not_in_current_form_names?(name, element_names_set) do
585+
index_based_field?(name) and not MapSet.member?(element_names_set, name)
586+
end
587+
588+
defp index_based_field?(name) do
589+
Regex.match?(~r/\[\d+\]/, name)
590+
end
591+
592+
defp index_group_key(name) do
593+
case bracket_segments(name) do
594+
[] ->
595+
name
596+
597+
[root | segments] ->
598+
normalized_segments = Enum.map(segments, &normalize_segment/1)
599+
root <> Enum.join(normalized_segments)
600+
end
601+
end
602+
603+
defp normalize_segment(segment) do
604+
if numeric_segment?(segment), do: "[]", else: "[#{segment}]"
605+
end
606+
607+
defp bracket_segments(name) do
608+
String.split(name, ["[", "]"], trim: true)
609+
end
610+
611+
defp numeric_segment?(segment) do
612+
case Integer.parse(segment) do
613+
{_integer, ""} -> true
614+
_ -> false
615+
end
616+
end
617+
559618
def open_browser(%{view: view} = session, open_fun \\ &Phoenix.LiveViewTest.open_browser/1) do
560619
open_fun.(view)
561620
session

test/phoenix_test/form_data_test.exs

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -222,6 +222,24 @@ defmodule PhoenixTest.FormDataTest do
222222
end
223223
end
224224

225+
describe "field_names" do
226+
test "returns unique field names from form data" do
227+
form_data =
228+
FormData.new()
229+
|> FormData.add_data("email", "first@example.com")
230+
|> FormData.add_data("email", "second@example.com")
231+
|> FormData.add_data("items[]", "one")
232+
|> FormData.add_data("items[]", "two")
233+
234+
names =
235+
form_data
236+
|> FormData.field_names()
237+
|> Enum.sort()
238+
239+
assert names == ["email", "items[]"]
240+
end
241+
end
242+
225243
describe "to_list" do
226244
test "transforms FormData into a list" do
227245
form_data =

test/phoenix_test/live_test.exs

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -964,6 +964,25 @@ defmodule PhoenixTest.LiveTest do
964964
end
965965
end
966966

967+
describe "general form logic" do
968+
test "handles inputs_for ordinal inputs", %{conn: conn} do
969+
conn
970+
|> visit("/live/ordinal_inputs")
971+
|> fill_in("Title", with: "Fellowship")
972+
|> click_button("Add Email")
973+
|> fill_in("#mailing_list_emails_0_email", "Email", with: "Bow")
974+
|> click_button("Add Email")
975+
|> fill_in("#mailing_list_emails_1_email", "Email", with: "Muffins")
976+
|> click_button("Add Email")
977+
|> fill_in("#mailing_list_emails_2_email", "Email", with: "Arrows")
978+
|> click_link("a[phx-value-index='1']", "Remove")
979+
|> submit()
980+
|> assert_has("[data-role=email]", text: "Bow")
981+
|> assert_has("[data-role=email]", text: "Arrows")
982+
|> refute_has("[data-role=email]", text: "Muffins")
983+
end
984+
end
985+
967986
describe "upload/4" do
968987
test "uploads an image", %{conn: conn} do
969988
conn
Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
defmodule PhoenixTest.WebApp.MailingList do
2+
@moduledoc false
3+
use Ecto.Schema
4+
5+
import Ecto.Changeset
6+
7+
embedded_schema do
8+
field(:title, :string)
9+
10+
embeds_many :emails, Email, on_replace: :delete do
11+
field(:email, :string)
12+
end
13+
end
14+
15+
def changeset(list, attrs) do
16+
list
17+
|> cast(attrs, [:title])
18+
|> cast_embed(:emails,
19+
with: &email_changeset/2,
20+
sort_param: :emails_sort,
21+
drop_param: :emails_drop
22+
)
23+
end
24+
25+
def email_changeset(email_notification, attrs) do
26+
cast(email_notification, attrs, [:email])
27+
end
28+
end
29+
30+
defmodule PhoenixTest.WebApp.OrdinalInputsLive do
31+
@moduledoc false
32+
use Phoenix.LiveView
33+
use Phoenix.Component
34+
35+
import PhoenixTest.WebApp.Components
36+
37+
alias PhoenixTest.WebApp.MailingList
38+
39+
def mount(_params, _session, socket) do
40+
changeset = MailingList.changeset(%MailingList{}, %{})
41+
42+
{:ok,
43+
assign(socket,
44+
changeset: changeset,
45+
form: to_form(changeset),
46+
submitted: false,
47+
emails: []
48+
)}
49+
end
50+
51+
def render(assigns) do
52+
~H"""
53+
<.form for={@form} phx-change="validate" phx-submit="submit">
54+
<.input field={@form[:title]} label="Title" />
55+
<.inputs_for :let={ef} field={@form[:emails]}>
56+
<input type="hidden" name="mailing_list[emails_sort][]" value={ef.index} />
57+
<.input label="Email" type="text" field={ef[:email]} placeholder="email" />
58+
<a class="underline" phx-click="remove-email" phx-value-index={ef.index}>
59+
Remove
60+
</a>
61+
</.inputs_for>
62+
63+
<input type="hidden" name="mailing_list[emails_drop][]" />
64+
65+
<button phx-click="add-email">Add Email</button>
66+
<button type="submit">Submit</button>
67+
</.form>
68+
69+
<div>
70+
<%= if @submitted do %>
71+
<h3>Submitted Values:</h3>
72+
<div>Title: {@form.params["title"]}</div>
73+
<%= for email <- @emails do %>
74+
<div data-role="email">{email}</div>
75+
<% end %>
76+
<% end %>
77+
</div>
78+
"""
79+
end
80+
81+
def handle_event("validate", %{"mailing_list" => params}, socket) do
82+
changeset = MailingList.changeset(%MailingList{}, params)
83+
{:noreply, assign(socket, changeset: changeset, form: to_form(changeset))}
84+
end
85+
86+
def handle_event("add-email", _params, socket) do
87+
changeset = socket.assigns.changeset
88+
89+
new_emails =
90+
Ecto.Changeset.get_field(changeset, :emails) ++ [%MailingList.Email{}]
91+
92+
updated_changeset =
93+
MailingList.changeset(%{changeset.data | emails: new_emails}, %{})
94+
95+
{:noreply, assign(socket, changeset: updated_changeset, form: to_form(updated_changeset))}
96+
end
97+
98+
def handle_event("remove-email", %{"index" => index}, socket) do
99+
index = String.to_integer(index)
100+
current_emails = Ecto.Changeset.get_field(socket.assigns.changeset, :emails)
101+
102+
new_emails = List.delete_at(current_emails, index)
103+
104+
updated_changeset =
105+
MailingList.changeset(%{socket.assigns.changeset.data | emails: new_emails}, %{})
106+
107+
{:noreply, assign(socket, changeset: updated_changeset, form: to_form(updated_changeset))}
108+
end
109+
110+
def handle_event("submit", %{"mailing_list" => params}, socket) do
111+
changeset = MailingList.changeset(%MailingList{}, params)
112+
113+
emails =
114+
changeset
115+
|> Ecto.Changeset.get_field(:emails)
116+
|> Enum.map(fn email -> email.email end)
117+
|> Enum.reject(&is_nil/1)
118+
119+
{:noreply,
120+
assign(socket,
121+
changeset: changeset,
122+
form: to_form(changeset),
123+
submitted: true,
124+
emails: emails
125+
)}
126+
end
127+
end

test/support/web_app/router.ex

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ defmodule PhoenixTest.WebApp.Router do
4242
live "/live/page_2", Page2Live
4343
live "/live/async_page", AsyncPageLive
4444
live "/live/async_page_2", AsyncPage2Live
45+
live "/live/ordinal_inputs", OrdinalInputsLive
4546
live "/live/dynamic_form", DynamicFormLive
4647
live "/live/simple_ordinal_inputs", SimpleOrdinalInputsLive
4748
live "/live/dynamic_inputs_add_remove", DynamicInputsAddRemoveLive

0 commit comments

Comments
 (0)