Skip to content

Commit ff122ce

Browse files
authored
Fix: Omit hidden input before checkbox group (#314)
Omit hidden 'reset' input that appears before checkbox group if any checkboxes are checked. ## What changed? - Better preserve DOM order of form elements better when building form data. ## Why make these changes? A hidden input before a checkbox group can be used to reset the value if all checkboxes are unchecked. However, if checkboxes are checked, then the hidden input should be effectively ignored. Plug/Phoenix can handle this if the field values arrive in DOM order. In forms like: ```html <input type="hidden" name="list" value="" /> <input type="checkbox" name="list[]" value="one" /> <input type="checkbox" name="list[]" value="two" /> ``` PhoenixTest previously collected controls by selector category and stored `FormData` in a map. That lost DOM order and collapsed repeated field names, which could cause the hidden scalar entry to interfere with the checkbox array payload. This commit is only a partial improvement. The ideal solution would be to submit form data in the exact DOM order a browser would, end to end.
1 parent 49edda3 commit ff122ce

11 files changed

Lines changed: 238 additions & 100 deletions

File tree

lib/phoenix_test/element/form.ex

Lines changed: 41 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -69,70 +69,58 @@ defmodule PhoenixTest.Element.Form do
6969

7070
def has_action?(form), do: Utils.present?(form.action)
7171

72-
@simple_value_types ~w(
73-
date
74-
datetime-local
75-
email
76-
month
77-
number
78-
password
79-
range
80-
search
81-
tel
82-
text
83-
time
84-
url
85-
week
86-
)
87-
88-
@hidden_inputs "input[type='hidden']"
89-
@checked_radio_buttons "input:not([disabled])[type='radio'][value]:checked"
90-
@checked_checkboxes "input:not([disabled])[type='checkbox'][value]:checked"
91-
@pre_filled_default_text_inputs "input:not([disabled]):not([type])[value]"
92-
93-
@pre_filled_simple_value_inputs Enum.map_join(
94-
@simple_value_types,
95-
",",
96-
&"input:not([disabled])[type='#{&1}'][value]"
97-
)
72+
@enabled_controls ":is(input, textarea, select):not([disabled])[name]"
9873

9974
defp form_data(form) do
100-
FormData.new()
101-
|> FormData.add_data(form_data(@hidden_inputs, form))
102-
|> FormData.add_data(form_data(@checked_radio_buttons, form))
103-
|> FormData.add_data(form_data(@checked_checkboxes, form))
104-
|> FormData.add_data(form_data(@pre_filled_simple_value_inputs, form))
105-
|> FormData.add_data(form_data(@pre_filled_default_text_inputs, form))
106-
|> FormData.add_data(form_data_textarea(form))
107-
|> FormData.add_data(form_data_select(form))
75+
form
76+
|> Html.all(@enabled_controls)
77+
|> Enum.reduce(FormData.new(), &append_form_field(&2, Html.tag(&1), &1))
10878
end
10979

110-
defp form_data(selector, form) do
111-
form
112-
|> Html.all(selector)
113-
|> Enum.map(&to_form_field/1)
80+
def put_button_data(form, nil), do: form
81+
82+
def put_button_data(form, %Button{} = button) do
83+
Map.update!(form, :form_data, &FormData.add_data(&1, button))
11484
end
11585

116-
defp form_data_textarea(form) do
117-
form
118-
|> Html.all("textarea:not([disabled])")
119-
|> Enum.map(&to_form_field/1)
86+
defp append_form_field(form_data, "input", element) do
87+
name = Html.attribute(element, "name")
88+
value = Html.attribute(element, "value")
89+
type = Html.attribute(element, "type")
90+
91+
cond do
92+
!value ->
93+
form_data
94+
95+
type == "hidden" ->
96+
FormData.add_data(form_data, name, value)
97+
98+
type in ~w(radio checkbox) and Html.attribute(element, "checked") ->
99+
FormData.add_data(form_data, name, value)
100+
101+
type in ~w(radio checkbox) ->
102+
form_data
103+
104+
true ->
105+
FormData.add_data(form_data, name, value)
106+
end
120107
end
121108

122-
defp form_data_select(form) do
123-
form
124-
|> Html.all("select:not([disabled])")
125-
|> Enum.flat_map(fn select ->
126-
select
127-
|> Html.selected_options()
128-
|> Enum.map(&to_form_field(select, &1))
129-
end)
109+
defp append_form_field(form_data, "textarea", element) do
110+
FormData.add_data(form_data, to_form_field(element))
130111
end
131112

132-
def put_button_data(form, nil), do: form
113+
defp append_form_field(form_data, "select", select) do
114+
values =
115+
select
116+
|> Html.selected_options()
117+
|> Enum.map(&element_value/1)
133118

134-
def put_button_data(form, %Button{} = button) do
135-
Map.update!(form, :form_data, &FormData.add_data(&1, button))
119+
case values do
120+
[] -> form_data
121+
[value] -> FormData.add_data(form_data, Html.attribute(select, "name"), value)
122+
many -> FormData.add_data(form_data, Html.attribute(select, "name"), many)
123+
end
136124
end
137125

138126
defp to_form_field(element) do

lib/phoenix_test/form_data.ex

Lines changed: 68 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ defmodule PhoenixTest.FormData do
55
alias PhoenixTest.Element.Field
66
alias PhoenixTest.Element.Select
77

8-
defstruct data: %{}
8+
defstruct data: []
99

1010
def new, do: %__MODULE__{}
1111

@@ -35,82 +35,106 @@ defmodule PhoenixTest.FormData do
3535

3636
def add_data(%__MODULE__{} = form_data, name, value) do
3737
if allows_multiple_values?(name) do
38-
new_data =
39-
Map.update(form_data.data, name, List.wrap(value), fn existing_value ->
40-
if value in existing_value do
41-
existing_value
42-
else
43-
existing_value ++ List.wrap(value)
44-
end
45-
end)
38+
existing_values = values_for_name(form_data.data, name)
39+
40+
new_entries =
41+
value
42+
|> List.wrap()
43+
|> Enum.reject(&(&1 in existing_values))
44+
|> Enum.map(&{name, &1})
4645

47-
%__MODULE__{form_data | data: new_data}
46+
%__MODULE__{form_data | data: form_data.data ++ new_entries}
4847
else
49-
%__MODULE__{form_data | data: Map.put(form_data.data, name, value)}
48+
put_data(form_data, name, value)
5049
end
5150
end
5251

53-
def merge(%__MODULE__{data: data1}, %__MODULE__{data: data2}) do
54-
data =
55-
Map.merge(data1, data2, fn k, v1, v2 ->
56-
if allows_multiple_values?(k) do
57-
Enum.uniq(v1 ++ v2)
58-
else
59-
v2
60-
end
61-
end)
62-
63-
%__MODULE__{data: data}
52+
def merge(%__MODULE__{} = form_data1, %__MODULE__{} = form_data2) do
53+
form_data2
54+
|> field_names()
55+
|> Enum.reduce(form_data1, fn name, acc ->
56+
if allows_multiple_values?(name) do
57+
add_data(acc, name, get_data(form_data2, name))
58+
else
59+
put_data(acc, name, get_data(form_data2, name))
60+
end
61+
end)
6462
end
6563

66-
def override(%__MODULE__{data: data1}, %__MODULE__{data: data2}) do
67-
%__MODULE__{data: Map.merge(data1, data2)}
64+
def override(%__MODULE__{} = form_data1, %__MODULE__{} = form_data2) do
65+
form_data2
66+
|> field_names()
67+
|> Enum.reduce(form_data1, fn name, acc ->
68+
put_data(acc, name, get_data(form_data2, name))
69+
end)
6870
end
6971

7072
def get_data(%__MODULE__{data: data}, name) do
71-
Map.get(data, name)
73+
values = values_for_name(data, name)
74+
75+
cond do
76+
values == [] -> nil
77+
allows_multiple_values?(name) or length(values) > 1 -> values
78+
true -> hd(values)
79+
end
7280
end
7381

7482
def put_data(%__MODULE__{} = form_data, name, value) when is_nil(name) or is_nil(value), do: form_data
7583

7684
def put_data(%__MODULE__{} = form_data, name, value) do
77-
%__MODULE__{form_data | data: Map.put(form_data.data, name, value)}
85+
new_entries =
86+
value
87+
|> List.wrap()
88+
|> Enum.reject(&is_nil/1)
89+
|> Enum.map(&{name, &1})
90+
91+
%__MODULE__{form_data | data: replace_entries(form_data.data, name, new_entries)}
7892
end
7993

8094
defp allows_multiple_values?(field_name), do: String.ends_with?(field_name, "[]")
8195

8296
def filter(%__MODULE__{data: data}, fun) do
83-
data =
84-
data
85-
|> Enum.filter(fn {name, value} -> fun.(%{name: name, value: value}) end)
86-
|> Map.new()
87-
88-
%__MODULE__{data: data}
97+
%__MODULE__{
98+
data:
99+
Enum.filter(data, fn {name, value} ->
100+
fun.(%{name: name, value: value})
101+
end)
102+
}
89103
end
90104

91105
def empty?(%__MODULE__{data: data}) do
92106
Enum.empty?(data)
93107
end
94108

95-
def has_data?(%__MODULE__{data: data}, name, value) do
96-
field_data = Map.get(data, name, [])
109+
def has_data?(%__MODULE__{} = form_data, name, value) do
110+
field_data = get_data(form_data, name)
97111

98112
value == field_data or value in List.wrap(field_data)
99113
end
100114

101115
def field_names(%__MODULE__{data: data}) do
102-
Map.keys(data)
116+
data
117+
|> Enum.map(&elem(&1, 0))
118+
|> Enum.uniq()
103119
end
104120

105-
def to_list(%__MODULE__{data: data}) do
106-
data
107-
|> Enum.map(fn
108-
{key, values} when is_list(values) ->
109-
Enum.map(values, &{key, &1})
121+
def to_list(%__MODULE__{data: data}), do: data
110122

111-
{_key, _value} = field ->
112-
field
113-
end)
114-
|> List.flatten()
123+
defp replace_entries(data, name, new_entries) do
124+
case Enum.find_index(data, fn {n, _} -> n == name end) do
125+
nil ->
126+
data ++ new_entries
127+
128+
first_index ->
129+
filtered_data = Enum.reject(data, fn {n, _} -> n == name end)
130+
{before, after_entries} = Enum.split(filtered_data, first_index)
131+
before ++ new_entries ++ after_entries
132+
end
133+
end
134+
135+
defp values_for_name(data, name) do
136+
data
137+
|> Enum.filter(fn {n, _} -> n == name end)
138+
|> Enum.map(fn {_, v} -> v end)
115139
end
116140
end

lib/phoenix_test/html.ex

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,13 @@ defmodule PhoenixTest.Html do
9595
end
9696
end
9797

98+
def tag(%LazyHTML{} = html) do
99+
case element(html) do
100+
{tag, _, _} -> to_string(tag)
101+
_ -> nil
102+
end
103+
end
104+
98105
defp normalize_whitespace(string) do
99106
String.replace(string, ~r/[\s]+/, " ")
100107
end

lib/phoenix_test/query.ex

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -505,8 +505,8 @@ defmodule PhoenixTest.Query do
505505
end
506506

507507
defp selected_option_texts(element) do
508-
case Html.element(element) do
509-
{"select", _attrs, _children} ->
508+
case Html.tag(element) do
509+
"select" ->
510510
element
511511
|> Html.selected_options()
512512
|> Enum.map(&Html.element_text/1)

test/phoenix_test/element/form_test.exs

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -245,6 +245,26 @@ defmodule PhoenixTest.Element.FormTest do
245245

246246
assert FormData.has_data?(form.form_data, "checkbox", "checked")
247247
end
248+
249+
test "preserves successful control DOM order in submission entries" do
250+
html = """
251+
<form id="form">
252+
<input type="hidden" name="mixed_items" value="" />
253+
<input type="checkbox" name="mixed_items[]" value="one" checked />
254+
<input type="text" name="after" value="later" />
255+
<input type="checkbox" name="mixed_items[]" value="two" checked />
256+
</form>
257+
"""
258+
259+
form = Form.find!(html, "form")
260+
261+
assert FormData.to_list(form.form_data) == [
262+
{"mixed_items", ""},
263+
{"mixed_items[]", "one"},
264+
{"after", "later"},
265+
{"mixed_items[]", "two"}
266+
]
267+
end
248268
end
249269

250270
describe "form.submit_button" do

test/phoenix_test/form_data_test.exs

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -190,6 +190,29 @@ defmodule PhoenixTest.FormDataTest do
190190

191191
assert FormData.to_list(form_data) == [{"items[]", ""}]
192192
end
193+
194+
test "preserves field order when user input for checkbox group overrides hidden input" do
195+
base =
196+
FormData.new()
197+
|> FormData.add_data("mixed_items", "")
198+
|> FormData.add_data("mixed_items[]", ["one", "two"])
199+
|> FormData.add_data("name", "default")
200+
201+
override =
202+
FormData.new()
203+
|> FormData.put_data("mixed_items[]", ["one", "two", "three"])
204+
|> FormData.put_data("name", "Bilbo")
205+
206+
form_data = FormData.override(base, override)
207+
208+
assert FormData.to_list(form_data) == [
209+
{"mixed_items", ""},
210+
{"mixed_items[]", "one"},
211+
{"mixed_items[]", "two"},
212+
{"mixed_items[]", "three"},
213+
{"name", "Bilbo"}
214+
]
215+
end
193216
end
194217

195218
describe "filter" do
@@ -249,7 +272,7 @@ defmodule PhoenixTest.FormDataTest do
249272

250273
list = FormData.to_list(form_data)
251274

252-
assert list == [{"email", "frodo@fellowship.com"}, {"name", "frodo"}]
275+
assert list == [{"name", "frodo"}, {"email", "frodo@fellowship.com"}]
253276
end
254277

255278
test "preserves select options ordering" do

test/phoenix_test/form_payload_test.exs

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ defmodule PhoenixTest.FormPayloadTest do
22
use ExUnit.Case, async: true
33

44
alias PhoenixTest.Element.Form
5+
alias PhoenixTest.FormData
56
alias PhoenixTest.FormPayload
67

78
describe "new" do
@@ -132,6 +133,28 @@ defmodule PhoenixTest.FormPayloadTest do
132133

133134
assert %{"checkbox" => "unchecked"} = FormPayload.new(form.form_data)
134135
end
136+
137+
test "preserves array values when hidden scalar and array entries for multiple fields are interleaved" do
138+
form_data =
139+
FormData.new()
140+
|> FormData.add_data("preferences[color]", "")
141+
|> FormData.add_data("preferences[color][]", "blue")
142+
|> FormData.add_data("preferences[size]", "")
143+
|> FormData.add_data("preferences[size][]", "medium")
144+
|> FormData.add_data("preferences[tag]", "")
145+
|> FormData.add_data("preferences[tag][]", [
146+
"new",
147+
"sale"
148+
])
149+
150+
assert %{
151+
"preferences" => %{
152+
"color" => ["blue"],
153+
"size" => ["medium"],
154+
"tag" => ["new", "sale"]
155+
}
156+
} = FormPayload.new(form_data)
157+
end
135158
end
136159

137160
describe "add_form_data" do

0 commit comments

Comments
 (0)