Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Send JS command values on phx-submit #3688

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 14 additions & 12 deletions assets/js/phoenix_live_view/view.js
Original file line number Diff line number Diff line change
Expand Up @@ -67,8 +67,8 @@ export let prependFormDataKey = (key, prefix) => {
return baseKey
}

let serializeForm = (form, metadata, onlyNames = []) => {
const {submitter, ...meta} = metadata
let serializeForm = (form, opts, onlyNames = []) => {
const {submitter} = opts

// We must inject the submitter in the order that it exists in the DOM
// relative to other inputs. For example, for checkbox groups, the order must be maintained.
Expand Down Expand Up @@ -119,8 +119,6 @@ let serializeForm = (form, metadata, onlyNames = []) => {
submitter.parentElement.removeChild(injectedElement)
}

for(let metaKey in meta){ params.append(metaKey, meta[metaKey]) }

return params.toString()
}

Expand Down Expand Up @@ -1171,12 +1169,13 @@ export default class View {
], phxEvent, "change", opts)
}
let formData
let meta = this.extractMeta(inputEl.form)
if(inputEl instanceof HTMLButtonElement){ meta.submitter = inputEl }
let meta = this.extractMeta(inputEl.form, {}, opts.value)
let serializeOpts = {}
if(inputEl instanceof HTMLButtonElement){ serializeOpts.submitter = inputEl }
if(inputEl.getAttribute(this.binding("change"))){
formData = serializeForm(inputEl.form, {_target: opts._target, ...meta}, [inputEl.name])
formData = serializeForm(inputEl.form, serializeOpts, [inputEl.name])
} else {
formData = serializeForm(inputEl.form, {_target: opts._target, ...meta})
formData = serializeForm(inputEl.form, serializeOpts)
}
if(DOM.isUploadInput(inputEl) && inputEl.files && inputEl.files.length > 0){
LiveUploader.trackFiles(inputEl, Array.from(inputEl.files))
Expand All @@ -1187,6 +1186,7 @@ export default class View {
type: "form",
event: phxEvent,
value: formData,
meta: {_target: opts._target, ...meta},
uploads: uploads,
cid: cid
}
Expand Down Expand Up @@ -1300,22 +1300,24 @@ export default class View {
if(LiveUploader.inputsAwaitingPreflight(formEl).length > 0){
return this.undoRefs(ref, phxEvent)
}
let meta = this.extractMeta(formEl)
let formData = serializeForm(formEl, {submitter, ...meta})
let meta = this.extractMeta(formEl, {}, opts.value)
let formData = serializeForm(formEl, {submitter})
this.pushWithReply(proxyRefGen, "event", {
type: "form",
event: phxEvent,
value: formData,
meta: meta,
cid: cid
}).then(({resp}) => onReply(resp))
})
} else if(!(formEl.hasAttribute(PHX_REF_SRC) && formEl.classList.contains("phx-submit-loading"))){
let meta = this.extractMeta(formEl)
let formData = serializeForm(formEl, {submitter, ...meta})
let meta = this.extractMeta(formEl, {}, opts.value)
let formData = serializeForm(formEl, {submitter})
this.pushWithReply(refGenerator, "event", {
type: "form",
event: phxEvent,
value: formData,
meta: meta,

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For the phx-value-* and JS.push/2 :value attributes, should we put inside a value or values key in meta?

Example:

<form phx-submit={JS.push("...", value: %{js_push_value: 1})} phx-value-attribute="2"></form>

# would have this meta:

meta: {
  _target: ...,
  js_push_value: 1,
  attribute: "2"
}

# but maybe it should be

meta: {
  _target: ...,
  value: {
    js_push_value: 1,
    attribute: "2"
  }
}

To not mix these values with possibly other meta keys that the submit might have.
What do you think?

cid: cid
}).then(({resp}) => onReply(resp))
}
Expand Down
85 changes: 81 additions & 4 deletions assets/test/js_test.js
Original file line number Diff line number Diff line change
Expand Up @@ -529,6 +529,41 @@ describe("JS", () => {
JS.exec(event, "change", form.getAttribute("phx-change"), view, input, args)
})

test("form change event with phx-value and JS command value", done => {
let view = setupView(`
<div id="modal" class="modal">modal</div>
<form id="my-form"
phx-change='[["push", {"event": "validate", "_target": "username", "value": {"command_value": "command","nested":{"array":[1,2]}}}]]'
phx-submit="submit"
phx-value-attribute_value="attribute"
>
<input type="text" name="username" id="username" phx-click=''></div>
</form>
`)
let form = document.querySelector("#my-form")
let input = document.querySelector("#username")
view.pushWithReply = (refGen, event, payload) => {
expect(payload).toEqual({
"cid": null,
"event": "validate",
"type": "form",
"value": "_unused_username=&username=",
"meta": {
"_target": "username",
"command_value": "command",
"nested": {
"array": [1, 2]
},
"attribute_value": "attribute"
},
"uploads": {}
})
return Promise.resolve({resp: done()})
}
let args = ["push", {_target: input.name, dispatcher: input}]
JS.exec(event, "change", form.getAttribute("phx-change"), view, input, args)
})

test("form change event with string event", done => {
let view = setupView(`
<div id="modal" class="modal">modal</div>
Expand All @@ -553,7 +588,8 @@ describe("JS", () => {
event: "validate",
type: "form",
uploads: {},
value: "_unused_username=&username=&_unused_other=&other=&_target=username"
value: "_unused_username=&username=&_unused_other=&other=",
meta: {"_target": "username"}
})
return Promise.resolve({resp: done()})
}
Expand Down Expand Up @@ -584,7 +620,8 @@ describe("JS", () => {
event: "username_changed",
type: "form",
uploads: {},
value: "_unused_username=&username=&_target=username"
value: "_unused_username=&username=",
meta: {"_target": "username"}
})
return Promise.resolve({resp: done()})
}
Expand Down Expand Up @@ -615,7 +652,8 @@ describe("JS", () => {
event: "username_changed",
type: "form",
uploads: {},
value: "_unused_username=&username=&_target=username"
value: "_unused_username=&username=",
meta: {"_target": "username"}
})
return Promise.resolve({resp: done()})
}
Expand All @@ -634,7 +672,46 @@ describe("JS", () => {
let form = document.querySelector("#my-form")

view.pushWithReply = (refGen, event, payload) => {
expect(payload).toEqual({"cid": null, "event": "save", "type": "form", "value": "username=&desc="})
expect(payload).toEqual({
"cid": null,
"event": "save",
"type": "form",
"value": "username=&desc=",
"meta": {}
})
return Promise.resolve({resp: done()})
}
JS.exec(event, "submit", form.getAttribute("phx-submit"), view, form, ["push", {}])
})

test("submit event with phx-value and JS command value", done => {
let view = setupView(`
<div id="modal" class="modal">modal</div>
<form id="my-form"
phx-change="validate"
phx-submit='[["push", {"event": "save", "value": {"command_value": "command","nested":{"array":[1,2]}}}]]'
phx-value-attribute_value="attribute"
>
<input type="text" name="username" id="username" />
<input type="text" name="desc" id="desc" phx-change="desc_changed" />
</form>
`)
let form = document.querySelector("#my-form")

view.pushWithReply = (refGen, event, payload) => {
expect(payload).toEqual({
"cid": null,
"event": "save",
"type": "form",
"value": "username=&desc=",
"meta": {
"command_value": "command",
"nested": {
"array": [1, 2]
},
"attribute_value": "attribute"
}
})
return Promise.resolve({resp: done()})
}
JS.exec(event, "submit", form.getAttribute("phx-submit"), view, form, ["push", {}])
Expand Down
94 changes: 87 additions & 7 deletions assets/test/view_test.js
Original file line number Diff line number Diff line change
Expand Up @@ -214,7 +214,7 @@ describe("View + DOM", function(){
})

test("pushInput", function(){
expect.assertions(3)
expect.assertions(4)

let liveSocket = new LiveSocket("/live", Socket)
let el = liveViewDOM()
Expand All @@ -225,7 +225,8 @@ describe("View + DOM", function(){
push(_evt, payload, _timeout){
expect(payload.type).toBe("form")
expect(payload.event).toBeDefined()
expect(payload.value).toBe("increment=1&_unused_note=&note=2&_target=increment")
expect(payload.value).toBe("increment=1&_unused_note=&note=2")
expect(payload.meta).toEqual({"_target": "increment"})
return {
receive(){ return this }
}
Expand All @@ -236,6 +237,45 @@ describe("View + DOM", function(){
view.pushInput(input, el, null, "validate", {_target: input.name})
})

test("pushInput with with phx-value and JS command value", function(){
expect.assertions(4)

let liveSocket = new LiveSocket("/live", Socket)
let el = liveViewDOM(`
<form id="my-form" phx-value-attribute_value="attribute">
<label for="plus">Plus</label>
<input id="plus" value="1" name="increment" />
<textarea id="note" name="note">2</textarea>
<input type="checkbox" phx-click="toggle_me" />
<button phx-click="inc_temperature">Inc Temperature</button>
</form>
`)
let input = el.querySelector("input")
simulateUsedInput(input)
let view = simulateJoinedView(el, liveSocket)
let channelStub = {
push(_evt, payload, _timeout){
expect(payload.type).toBe("form")
expect(payload.event).toBeDefined()
expect(payload.value).toBe("increment=1&_unused_note=&note=2")
expect(payload.meta).toEqual({
"_target": "increment",
"attribute_value": "attribute",
"nested": {
"command_value": "command",
"array": [1, 2]
}
})
return {
receive(){ return this }
}
}
}
view.channel = channelStub
let optValue = {nested: {command_value: "command", array: [1, 2]}}
view.pushInput(input, el, null, "validate", {_target: input.name, value: optValue})
})

test("getFormsForRecovery", function(){
let view, html, liveSocket = new LiveSocket("/live", Socket)

Expand Down Expand Up @@ -296,6 +336,44 @@ describe("View + DOM", function(){
view.submitForm(form, form, {target: form})
})

test("payload includes phx-value and JS command value", function(){
expect.assertions(4)

let liveSocket = new LiveSocket("/live", Socket)
let el = liveViewDOM(`
<form id="my-form" phx-value-attribute_value="attribute">
<label for="plus">Plus</label>
<input id="plus" value="1" name="increment" />
<textarea id="note" name="note">2</textarea>
<input type="checkbox" phx-click="toggle_me" />
<button phx-click="inc_temperature">Inc Temperature</button>
</form>
`)
let form = el.querySelector("form")

let view = simulateJoinedView(el, liveSocket)
let channelStub = {
push(_evt, payload, _timeout){
expect(payload.type).toBe("form")
expect(payload.event).toBeDefined()
expect(payload.value).toBe("increment=1&note=2")
expect(payload.meta).toEqual({
"attribute_value": "attribute",
"nested": {
"command_value": "command",
"array": [1, 2]
}
})
return {
receive(){ return this }
}
}
}
view.channel = channelStub
let opts = {value: {nested: {command_value: "command", array: [1, 2]}}}
view.submitForm(form, form, {target: form}, undefined, opts)
})

test("payload includes submitter when name is provided", function(){
let btn = document.createElement("button")
btn.setAttribute("type", "submit")
Expand All @@ -320,7 +398,7 @@ describe("View + DOM", function(){
submitWithButton(btn, "increment=1&note=2")
})

function submitWithButton(btn, queryString, appendTo){
function submitWithButton(btn, queryString, appendTo, opts={}){
let liveSocket = new LiveSocket("/live", Socket)
let el = liveViewDOM()
let form = el.querySelector("form")
Expand All @@ -343,7 +421,7 @@ describe("View + DOM", function(){
}

view.channel = channelStub
view.submitForm(form, form, {target: form}, btn)
view.submitForm(form, form, {target: form}, btn, opts)
}

test("disables elements after submission", function(){
Expand Down Expand Up @@ -1097,13 +1175,15 @@ describe("View + Component", function(){
Array.from(view.el.querySelectorAll("input")).forEach(input => simulateUsedInput(input))
let channelStub = {
validate: "",
nextValidate(payload){
nextValidate(payload, meta){
this.meta = meta
this.validate = Object.entries(payload)
.map(([key, value]) => `${encodeURIComponent(key)}=${value ? encodeURIComponent(value) : ""}`)
.join("&")
},
push(_evt, payload, _timeout){
expect(payload.value).toBe(this.validate)
expect(payload.meta).toEqual(this.meta)
return {
receive(status, cb){
if(status === "ok"){
Expand Down Expand Up @@ -1134,12 +1214,12 @@ describe("View + Component", function(){

let first_name = view.el.querySelector("#first_name")
let last_name = view.el.querySelector("#last_name")
view.channel.nextValidate({"user[first_name]": null, "user[last_name]": null, "_target": "user[first_name]"})
view.channel.nextValidate({"user[first_name]": null, "user[last_name]": null}, {"_target": "user[first_name]"})
// we have to set this manually since it's set by a change event that would require more plumbing with the liveSocket in the test to hook up
DOM.putPrivate(first_name, "phx-has-focused", true)
view.pushInput(first_name, el, null, "validate", {_target: first_name.name})
window.requestAnimationFrame(() => {
view.channel.nextValidate({"user[first_name]": null, "user[last_name]": null, "_target": "user[last_name]"})
view.channel.nextValidate({"user[first_name]": null, "user[last_name]": null}, {"_target": "user[last_name]"})
view.pushInput(last_name, el, null, "validate", {_target: last_name.name})
window.requestAnimationFrame(() => {
done()
Expand Down
13 changes: 10 additions & 3 deletions lib/phoenix_live_view/channel.ex
Original file line number Diff line number Diff line change
Expand Up @@ -245,7 +245,7 @@ defmodule Phoenix.LiveView.Channel do

def handle_info(%Message{topic: topic, event: "event"} = msg, %{topic: topic} = state) do
%{"value" => raw_val, "event" => event, "type" => type} = payload = msg.payload
val = decode_event_type(type, raw_val)
val = decode_event_type(type, raw_val, msg.payload)

if cid = msg.payload["cid"] do
component_handle(state, cid, msg.ref, fn component_socket, component ->
Expand Down Expand Up @@ -777,13 +777,14 @@ defmodule Phoenix.LiveView.Channel do
)
end

defp decode_event_type("form", url_encoded) do
defp decode_event_type("form", url_encoded, raw_payload) do
url_encoded
|> Plug.Conn.Query.decode()
|> decode_merge_target()
|> maybe_merge_meta(raw_payload)
end

defp decode_event_type(_, value), do: value
defp decode_event_type(_, value, _raw_payload), do: value

defp decode_merge_target(%{"_target" => target} = params) when is_list(target), do: params

Expand All @@ -794,6 +795,12 @@ defmodule Phoenix.LiveView.Channel do

defp decode_merge_target(%{} = params), do: params

defp maybe_merge_meta(value, %{"meta" => meta}) when is_map(value) do
Map.merge(value, meta)
end

defp maybe_merge_meta(value, _raw_payload), do: value

defp gather_keys(%{} = map, acc) do
case Enum.at(map, 0) do
{key, val} -> gather_keys(val, [key | acc])
Expand Down
Loading