Skip to content

Commit 50e2ebc

Browse files
authored
add phx-no-used-check to forms (#3911)
adds phx-no-used-check to forms and inputs
1 parent a065353 commit 50e2ebc

File tree

4 files changed

+197
-89
lines changed

4 files changed

+197
-89
lines changed

assets/js/phoenix_live_view/constants.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,7 @@ export const PHX_LV_PID = "data-phx-pid";
8383
export const PHX_KEY = "key";
8484
export const PHX_PRIVATE = "phxPrivate";
8585
export const PHX_AUTO_RECOVER = "auto-recover";
86+
export const PHX_NO_USED_CHECK = "no-used-check";
8687
export const PHX_LV_DEBUG = "phx:live-socket:debug";
8788
export const PHX_LV_PROFILE = "phx:live-socket:profiling";
8889
export const PHX_LV_LATENCY_SIM = "phx:live-socket:latency-sim";

assets/js/phoenix_live_view/view.js

Lines changed: 108 additions & 88 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ import {
3737
PHX_VIEWPORT_BOTTOM,
3838
MAX_CHILD_JOIN_ATTEMPTS,
3939
PHX_LV_PID,
40+
PHX_NO_USED_CHECK,
4041
} from "./constants";
4142

4243
import {
@@ -73,90 +74,6 @@ export const prependFormDataKey = (key, prefix) => {
7374
return baseKey;
7475
};
7576

76-
const serializeForm = (form, opts, onlyNames = []) => {
77-
const { submitter } = opts;
78-
79-
// We must inject the submitter in the order that it exists in the DOM
80-
// relative to other inputs. For example, for checkbox groups, the order must be maintained.
81-
let injectedElement;
82-
if (submitter && submitter.name) {
83-
const input = document.createElement("input");
84-
input.type = "hidden";
85-
// set the form attribute if the submitter has one;
86-
// this can happen if the element is outside the actual form element
87-
const formId = submitter.getAttribute("form");
88-
if (formId) {
89-
input.setAttribute("form", formId);
90-
}
91-
input.name = submitter.name;
92-
input.value = submitter.value;
93-
submitter.parentElement.insertBefore(input, submitter);
94-
injectedElement = input;
95-
}
96-
97-
const formData = new FormData(form);
98-
const toRemove = [];
99-
100-
formData.forEach((val, key, _index) => {
101-
if (val instanceof File) {
102-
toRemove.push(key);
103-
}
104-
});
105-
106-
// Cleanup after building fileData
107-
toRemove.forEach((key) => formData.delete(key));
108-
109-
const params = new URLSearchParams();
110-
111-
const { inputsUnused, onlyHiddenInputs } = Array.from(form.elements).reduce(
112-
(acc, input) => {
113-
const { inputsUnused, onlyHiddenInputs } = acc;
114-
const key = input.name;
115-
if (!key) {
116-
return acc;
117-
}
118-
119-
if (inputsUnused[key] === undefined) {
120-
inputsUnused[key] = true;
121-
}
122-
if (onlyHiddenInputs[key] === undefined) {
123-
onlyHiddenInputs[key] = true;
124-
}
125-
126-
const isUsed =
127-
DOM.private(input, PHX_HAS_FOCUSED) ||
128-
DOM.private(input, PHX_HAS_SUBMITTED);
129-
const isHidden = input.type === "hidden";
130-
inputsUnused[key] = inputsUnused[key] && !isUsed;
131-
onlyHiddenInputs[key] = onlyHiddenInputs[key] && isHidden;
132-
133-
return acc;
134-
},
135-
{ inputsUnused: {}, onlyHiddenInputs: {} },
136-
);
137-
138-
for (const [key, val] of formData.entries()) {
139-
if (onlyNames.length === 0 || onlyNames.indexOf(key) >= 0) {
140-
const isUnused = inputsUnused[key];
141-
const hidden = onlyHiddenInputs[key];
142-
if (isUnused && !(submitter && submitter.name == key) && !hidden) {
143-
params.append(prependFormDataKey(key, "_unused_"), "");
144-
}
145-
if (typeof val === "string") {
146-
params.append(key, val);
147-
}
148-
}
149-
}
150-
151-
// remove the injected element again
152-
// (it would be removed by the next dom patch anyway, but this is cleaner)
153-
if (submitter && injectedElement) {
154-
submitter.parentElement.removeChild(injectedElement);
155-
}
156-
157-
return params.toString();
158-
};
159-
16077
export default class View {
16178
static closestView(el) {
16279
const liveViewEl = el.closest(PHX_VIEW_SELECTOR);
@@ -1566,6 +1483,107 @@ export default class View {
15661483
return meta;
15671484
}
15681485

1486+
serializeForm(form, opts, onlyNames = []) {
1487+
const { submitter } = opts;
1488+
1489+
// We must inject the submitter in the order that it exists in the DOM
1490+
// relative to other inputs. For example, for checkbox groups, the order must be maintained.
1491+
let injectedElement;
1492+
if (submitter && submitter.name) {
1493+
const input = document.createElement("input");
1494+
input.type = "hidden";
1495+
// set the form attribute if the submitter has one;
1496+
// this can happen if the element is outside the actual form element
1497+
const formId = submitter.getAttribute("form");
1498+
if (formId) {
1499+
input.setAttribute("form", formId);
1500+
}
1501+
input.name = submitter.name;
1502+
input.value = submitter.value;
1503+
submitter.parentElement.insertBefore(input, submitter);
1504+
injectedElement = input;
1505+
}
1506+
1507+
const formData = new FormData(form);
1508+
const toRemove = [];
1509+
1510+
formData.forEach((val, key, _index) => {
1511+
if (val instanceof File) {
1512+
toRemove.push(key);
1513+
}
1514+
});
1515+
1516+
// Cleanup after building fileData
1517+
toRemove.forEach((key) => formData.delete(key));
1518+
1519+
const params = new URLSearchParams();
1520+
1521+
const { inputsUnused, onlyHiddenInputs } = Array.from(form.elements).reduce(
1522+
(acc, input) => {
1523+
const { inputsUnused, onlyHiddenInputs } = acc;
1524+
const key = input.name;
1525+
if (!key) {
1526+
return acc;
1527+
}
1528+
1529+
if (inputsUnused[key] === undefined) {
1530+
inputsUnused[key] = true;
1531+
}
1532+
if (onlyHiddenInputs[key] === undefined) {
1533+
onlyHiddenInputs[key] = true;
1534+
}
1535+
1536+
const inputSkipUnusedField = input.hasAttribute(
1537+
this.binding(PHX_NO_USED_CHECK),
1538+
);
1539+
1540+
const isUsed =
1541+
DOM.private(input, PHX_HAS_FOCUSED) ||
1542+
DOM.private(input, PHX_HAS_SUBMITTED) ||
1543+
inputSkipUnusedField;
1544+
1545+
const isHidden = input.type === "hidden";
1546+
inputsUnused[key] = inputsUnused[key] && !isUsed;
1547+
onlyHiddenInputs[key] = onlyHiddenInputs[key] && isHidden;
1548+
1549+
return acc;
1550+
},
1551+
{ inputsUnused: {}, onlyHiddenInputs: {} },
1552+
);
1553+
1554+
const formSkipUnusedFields = form.hasAttribute(
1555+
this.binding(PHX_NO_USED_CHECK),
1556+
);
1557+
1558+
for (const [key, val] of formData.entries()) {
1559+
if (onlyNames.length === 0 || onlyNames.indexOf(key) >= 0) {
1560+
const isUnused = inputsUnused[key];
1561+
const hidden = onlyHiddenInputs[key];
1562+
const skipUnusedCheck = formSkipUnusedFields;
1563+
1564+
if (
1565+
!skipUnusedCheck &&
1566+
isUnused &&
1567+
!(submitter && submitter.name == key) &&
1568+
!hidden
1569+
) {
1570+
params.append(prependFormDataKey(key, "_unused_"), "");
1571+
}
1572+
if (typeof val === "string") {
1573+
params.append(key, val);
1574+
}
1575+
}
1576+
}
1577+
1578+
// remove the injected element again
1579+
// (it would be removed by the next dom patch anyway, but this is cleaner)
1580+
if (submitter && injectedElement) {
1581+
submitter.parentElement.removeChild(injectedElement);
1582+
}
1583+
1584+
return params.toString();
1585+
}
1586+
15691587
pushEvent(type, el, targetCtx, phxEvent, meta, opts = {}, onReply) {
15701588
this.pushWithReply(
15711589
(maybePayload) =>
@@ -1627,9 +1645,11 @@ export default class View {
16271645
serializeOpts.submitter = inputEl;
16281646
}
16291647
if (inputEl.getAttribute(this.binding("change"))) {
1630-
formData = serializeForm(inputEl.form, serializeOpts, [inputEl.name]);
1648+
formData = this.serializeForm(inputEl.form, serializeOpts, [
1649+
inputEl.name,
1650+
]);
16311651
} else {
1632-
formData = serializeForm(inputEl.form, serializeOpts);
1652+
formData = this.serializeForm(inputEl.form, serializeOpts);
16331653
}
16341654
if (
16351655
DOM.isUploadInput(inputEl) &&
@@ -1806,7 +1826,7 @@ export default class View {
18061826
return this.undoRefs(ref, phxEvent);
18071827
}
18081828
const meta = this.extractMeta(formEl, {}, opts.value);
1809-
const formData = serializeForm(formEl, { submitter });
1829+
const formData = this.serializeForm(formEl, { submitter });
18101830
this.pushWithReply(proxyRefGen, "event", {
18111831
type: "form",
18121832
event: phxEvent,
@@ -1824,7 +1844,7 @@ export default class View {
18241844
)
18251845
) {
18261846
const meta = this.extractMeta(formEl, {}, opts.value);
1827-
const formData = serializeForm(formEl, { submitter });
1847+
const formData = this.serializeForm(formEl, { submitter });
18281848
this.pushWithReply(refGenerator, "event", {
18291849
type: "form",
18301850
event: phxEvent,

test/e2e/support/form_live.ex

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -137,12 +137,18 @@ defmodule Phoenix.LiveViewTest.E2E.FormLive do
137137
phx-submit="save"
138138
phx-change={@params["phx-change"]}
139139
phx-auto-recover={@params["phx-auto-recover"]}
140+
phx-no-used-check={@params["phx-no-used-check-form"]}
140141
phx-target={assigns[:"phx-target"]}
141142
class="myformclass"
142143
>
143144
<input type="text" name="a" readonly value={@params["a"]} />
144145
<input type="text" name="b" value={@params["b"]} />
145-
<input type="text" name="c" value={@params["c"]} />
146+
<input
147+
type="text"
148+
name="c"
149+
value={@params["c"]}
150+
phx-no-used-check={@params["phx-no-used-check-input"]}
151+
/>
146152
<select name="d">
147153
{Phoenix.HTML.Form.options_for_select(["foo", "bar", "baz"], @params["d"])}
148154
</select>

test/e2e/tests/forms.spec.js

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -656,3 +656,84 @@ test("phx-no-feedback is applied correctly for backwards-compatible-shims", asyn
656656
await syncLV(page);
657657
await expect(page.locator("[data-feedback-container]")).toBeHidden();
658658
});
659+
660+
test("phx-no-used-check on a form is applied correctly and no unused fields are sent", async ({
661+
page,
662+
}) => {
663+
const webSocketEvents = [];
664+
665+
page.on("websocket", (ws) => {
666+
ws.on("framesent", (event) =>
667+
webSocketEvents.push({ type: "sent", payload: event.payload }),
668+
);
669+
});
670+
671+
await page.goto("/form?phx-no-used-check-form");
672+
await syncLV(page);
673+
674+
await page.locator("input[name=b]").fill("test");
675+
// blur, otherwise the input would not be morphed anyway
676+
await page.locator("input[name=b]").blur();
677+
await syncLV(page);
678+
679+
// With phx-no-used-check on the form, no _unused_ parameters should be sent
680+
expect(webSocketEvents).toEqual(
681+
expect.arrayContaining([
682+
{
683+
type: "sent",
684+
payload: expect.stringMatching(/event.*a=foo&b=test&c=baz&d=foo/),
685+
},
686+
]),
687+
);
688+
689+
// Ensure no _unused_ parameters are present in any sent events
690+
const sentEvents = webSocketEvents.filter((event) => event.type === "sent");
691+
sentEvents.forEach((event) => {
692+
expect(event.payload).not.toMatch(/_unused_/);
693+
});
694+
});
695+
696+
test("phx-no-used-check on an input is applied correctly and no unused fields are sent for that specific input", async ({
697+
page,
698+
}) => {
699+
const webSocketEvents = [];
700+
701+
page.on("websocket", (ws) => {
702+
ws.on("framesent", (event) =>
703+
webSocketEvents.push({ type: "sent", payload: event.payload }),
704+
);
705+
});
706+
707+
await page.goto("/form?phx-no-used-check-input");
708+
await syncLV(page);
709+
710+
await page.locator("input[name=b]").fill("test");
711+
// blur, otherwise the input would not be morphed anyway
712+
await page.locator("input[name=b]").blur();
713+
await syncLV(page);
714+
715+
// Check that the form data and unused parameters are sent correctly
716+
expect(webSocketEvents).toEqual(
717+
expect.arrayContaining([
718+
{
719+
type: "sent",
720+
payload: expect.stringMatching(
721+
/event.*_unused_a=&a=foo&b=test&c=baz&_unused_d=&d=foo/,
722+
),
723+
},
724+
]),
725+
);
726+
727+
// Verify specific unused parameter behavior
728+
const sentEvents = webSocketEvents.filter((event) => event.type === "sent");
729+
const formDataEvent = sentEvents.find((event) =>
730+
event.payload.includes("a=foo"),
731+
);
732+
733+
// a and d should have _unused_ parameters (untouched, no phx-no-used-check)
734+
expect(formDataEvent.payload).toMatch(/_unused_a=/);
735+
expect(formDataEvent.payload).toMatch(/_unused_d=/);
736+
// b and c should NOT have _unused_ parameters (b touched, c has phx-no-used-check)
737+
expect(formDataEvent.payload).not.toMatch(/_unused_b=/);
738+
expect(formDataEvent.payload).not.toMatch(/_unused_c=/);
739+
});

0 commit comments

Comments
 (0)