From af56055d6562d03223984769d8faf708d33ddb23 Mon Sep 17 00:00:00 2001 From: Max Marcon Date: Tue, 5 Dec 2023 09:57:08 +0100 Subject: [PATCH] form recovery --- assets/js/live_select.js | 7 ++ lib/live_select/component.ex | 77 ++++++++++++------- .../live_select_web/live/showcase_live.ex | 3 +- priv/static/live_select.min.js | 2 +- test/live_select/component_test.exs | 33 ++++---- test/live_select_tags_test.exs | 20 +++++ test/live_select_test.exs | 49 +++++++++--- test/support/helpers.ex | 16 ++-- 8 files changed, 142 insertions(+), 65 deletions(-) diff --git a/assets/js/live_select.js b/assets/js/live_select.js index 466f13a..03bf972 100644 --- a/assets/js/live_select.js +++ b/assets/js/live_select.js @@ -89,6 +89,8 @@ export default { } }) this.handleEvent("select", ({id, selection, mode, input_event, parent_event}) => { + this.selection = selection + console.log("selection: ", this.selection) if (this.el.id === id) { if (mode === "single") { const label = selection.length > 0 ? selection[0].label : null @@ -117,6 +119,11 @@ export default { updated() { this.maybeStyleClearButton() this.attachDomEventHandlers() + }, + reconnected() { + if (this.selection && this.selection.length > 0) { + this.pushEventTo(this.el, "options_recovery", this.selection) + } } } } diff --git a/lib/live_select/component.ex b/lib/live_select/component.ex index 08f7fdd..e2dcfec 100644 --- a/lib/live_select/component.ex +++ b/lib/live_select/component.ex @@ -162,7 +162,7 @@ defmodule LiveSelect.Component do socket = if Map.has_key?(assigns, :value) do update(socket, :selection, fn - selection, %{options: options, mode: mode, value: value} -> + selection, %{options: options, value: value, mode: mode} -> update_selection(value, selection, options, mode) end) |> client_select(%{input_event: true}) @@ -209,6 +209,24 @@ defmodule LiveSelect.Component do {:noreply, socket} end + @impl true + def handle_event("options_recovery", options, socket) do + options = + for %{"label" => label, "value" => value} <- options do + %{label: label, value: value} + end + + {:noreply, + assign(socket, + options: options, + selection: + Enum.map(socket.assigns.selection, fn %{value: value} -> + Enum.find(options, fn %{value: option_value} -> option_value == value end) + end) + |> Enum.filter(& &1) + )} + end + @impl true def handle_event("options_clear", _params, socket) do socket = @@ -367,7 +385,7 @@ defmodule LiveSelect.Component do extra_params ) when is_binary(current_text) do - {:ok, option} = normalize(current_text) + {:ok, option} = normalize_option(current_text) if already_selected?(option, socket.assigns.selection) do socket @@ -478,30 +496,24 @@ defmodule LiveSelect.Component do defp update_selection(nil, _current_selection, _options, _mode), do: [] defp update_selection(value, current_selection, options, :single) do - if option = Enum.find(options ++ current_selection, fn %{value: val} -> value == val end) do - [option] - else - case normalize(value) do - {:ok, option} -> List.wrap(option) - :error -> invalid_option(value, :selection) - end - end + List.wrap(normalize_selection_value(value, options ++ current_selection)) end defp update_selection(value, current_selection, options, :tags) do value = if Enumerable.impl_for(value), do: value, else: [value] - value - |> Enum.map( - &if option = Enum.find(options ++ current_selection, fn %{value: value} -> value == &1 end) do - option - else - case normalize(&1) do - {:ok, option} -> option - :error -> invalid_option(&1, :selection) - end + Enum.map(value, &normalize_selection_value(&1, options ++ current_selection)) + end + + defp normalize_selection_value(selection_value, options) do + if option = Enum.find(options, fn %{value: value} -> selection_value == value end) do + option + else + case normalize_option(selection_value) do + {:ok, option} -> option + :error -> %{label: "", value: selection_value} end - ) + end end defp normalize_options(options) when is_map(options) do @@ -511,18 +523,21 @@ defmodule LiveSelect.Component do defp normalize_options(options) do options |> Enum.map( - &case normalize(&1) do + &case normalize_option(&1) do {:ok, option} -> option - :error -> invalid_option(&1, :option) + :error -> invalid_option(&1) end ) end - defp normalize(option_or_selection) do - case option_or_selection do + defp normalize_option(option) do + case option do nil -> {:ok, nil} + "" -> + {:ok, nil} + %{key: key, value: _value} = option -> {:ok, Map.put_new(option, :label, key)} @@ -530,8 +545,12 @@ defmodule LiveSelect.Component do {:ok, Map.put_new(option, :label, value)} option when is_list(option) -> - Map.new(option) - |> normalize() + if Keyword.keyword?(option) do + Map.new(option) + |> normalize_option() + else + :error + end {label, value} -> {:ok, %{label: label, value: value}} @@ -544,10 +563,10 @@ defmodule LiveSelect.Component do end end - defp invalid_option(option, what) do + defp invalid_option(option) do raise """ - invalid #{if what == :selection, do: "element in selection", else: "element in options"}: #{inspect(option)} - elements of #{what} can be: + invalid element in options: #{inspect(option)} + elements can be: atoms, strings or numbers maps or keywords with keys: (:label, :value) or (:key, :value) and an optional key :tag_label diff --git a/lib/support/live_select_web/live/showcase_live.ex b/lib/support/live_select_web/live/showcase_live.ex index c7ab036..c4c5b15 100644 --- a/lib/support/live_select_web/live/showcase_live.ex +++ b/lib/support/live_select_web/live/showcase_live.ex @@ -343,7 +343,7 @@ defmodule LiveSelectWeb.ShowcaseLive do "submit" -> mode = socket.assigns.settings_form.data.mode - selected = get_in(params,~w(my_form city_search)) + selected = get_in(params, ~w(my_form city_search)) selected_text = get_in(params, ~w(my_form city_search_text_input)) {cities, locations} = extract_cities_and_locations(mode, selected_text, selected) @@ -364,6 +364,7 @@ defmodule LiveSelectWeb.ShowcaseLive do "change" -> params = update_in(params, ~w(my_form city_search), fn + nil -> nil selection when is_list(selection) -> Enum.map(selection, &decode/1) selection -> decode(selection) end) diff --git a/priv/static/live_select.min.js b/priv/static/live_select.min.js index 986f2cd..772b041 100644 --- a/priv/static/live_select.min.js +++ b/priv/static/live_select.min.js @@ -1 +1 @@ -function h(e,t){let i;return(...s)=>{clearTimeout(i),i=setTimeout(()=>{e.apply(this,s)},t)}}export default{LiveSelect:{textInput(){return this.el.querySelector("input[type=text]")},debounceMsec(){return parseInt(this.el.dataset.debounce)},updateMinLen(){return parseInt(this.el.dataset.updateMinLen)},maybeStyleClearButton(){const e=this.el.querySelector("button[phx-click=clear]");e&&(this.textInput().parentElement.style.position="relative",e.style.position="absolute",e.style.top="0px",e.style.bottom="0px",e.style.right="5px",e.style.display="block")},pushEventToParent(e,t){const i=this.el.dataset.phxTarget;i?this.pushEventTo(i,e,t):this.pushEvent(e,t)},attachDomEventHandlers(){this.textInput().onkeydown=t=>{t.code==="Enter"&&t.preventDefault(),this.pushEventTo(this.el,"keydown",{key:t.code})},this.changeEvents=h((t,i,s)=>{this.pushEventTo(this.el,"change",{text:s}),this.pushEventToParent("live_select_change",{id:this.el.id,field:i,text:s})},this.debounceMsec()),this.textInput().oninput=t=>{const i=t.target.value.trim(),s=this.el.dataset.field;i.length>=this.updateMinLen()?this.changeEvents(this.el.id,s,i):this.pushEventTo(this.el,"options_clear",{})};const e=this.el.querySelector("ul");e&&(e.onmousedown=t=>{const i=t.target.closest("div[data-idx]");i&&(this.pushEventTo(this.el,"option_click",{idx:i.dataset.idx}),t.preventDefault())}),this.el.querySelectorAll("button[data-idx]").forEach(t=>{t.onclick=i=>{this.pushEventTo(this.el,"option_remove",{idx:t.dataset.idx})}})},setInputValue(e){this.textInput().value=e},inputEvent(e,t){const i=t==="single"?"input.single-mode":e.length===0?"input[data-live-select-empty]":"input[type=hidden]";this.el.querySelector(i).dispatchEvent(new Event("input",{bubbles:!0}))},mounted(){this.maybeStyleClearButton(),this.handleEvent("parent_event",({id:e,event:t,payload:i})=>{this.el.id===e&&this.pushEventToParent(t,i)}),this.handleEvent("select",({id:e,selection:t,mode:i,input_event:s,parent_event:n})=>{if(this.el.id===e){if(i==="single"){const l=t.length>0?t[0].label:null;this.setInputValue(l)}else this.setInputValue(null);s&&this.inputEvent(t,i),n&&this.pushEventToParent(n,{id:e})}}),this.handleEvent("active",({id:e,idx:t})=>{if(this.el.id===e){const i=this.el.querySelector(`div[data-idx="${t}"]`);i&&i.scrollIntoView({block:"nearest"})}}),this.attachDomEventHandlers()},updated(){this.maybeStyleClearButton(),this.attachDomEventHandlers()}}}; +function o(e,t){let i;return(...s)=>{clearTimeout(i),i=setTimeout(()=>{e.apply(this,s)},t)}}export default{LiveSelect:{textInput(){return this.el.querySelector("input[type=text]")},debounceMsec(){return parseInt(this.el.dataset.debounce)},updateMinLen(){return parseInt(this.el.dataset.updateMinLen)},maybeStyleClearButton(){const e=this.el.querySelector("button[phx-click=clear]");e&&(this.textInput().parentElement.style.position="relative",e.style.position="absolute",e.style.top="0px",e.style.bottom="0px",e.style.right="5px",e.style.display="block")},pushEventToParent(e,t){const i=this.el.dataset.phxTarget;i?this.pushEventTo(i,e,t):this.pushEvent(e,t)},attachDomEventHandlers(){this.textInput().onkeydown=t=>{t.code==="Enter"&&t.preventDefault(),this.pushEventTo(this.el,"keydown",{key:t.code})},this.changeEvents=o((t,i,s)=>{this.pushEventTo(this.el,"change",{text:s}),this.pushEventToParent("live_select_change",{id:this.el.id,field:i,text:s})},this.debounceMsec()),this.textInput().oninput=t=>{const i=t.target.value.trim(),s=this.el.dataset.field;i.length>=this.updateMinLen()?this.changeEvents(this.el.id,s,i):this.pushEventTo(this.el,"options_clear",{})};const e=this.el.querySelector("ul");e&&(e.onmousedown=t=>{const i=t.target.closest("div[data-idx]");i&&(this.pushEventTo(this.el,"option_click",{idx:i.dataset.idx}),t.preventDefault())}),this.el.querySelectorAll("button[data-idx]").forEach(t=>{t.onclick=i=>{this.pushEventTo(this.el,"option_remove",{idx:t.dataset.idx})}})},setInputValue(e){this.textInput().value=e},inputEvent(e,t){const i=t==="single"?"input.single-mode":e.length===0?"input[data-live-select-empty]":"input[type=hidden]";this.el.querySelector(i).dispatchEvent(new Event("input",{bubbles:!0}))},mounted(){this.maybeStyleClearButton(),this.handleEvent("parent_event",({id:e,event:t,payload:i})=>{this.el.id===e&&this.pushEventToParent(t,i)}),this.handleEvent("select",({id:e,selection:t,mode:i,input_event:s,parent_event:n})=>{if(this.selection=t,console.log("selection: ",this.selection),this.el.id===e){if(i==="single"){const l=t.length>0?t[0].label:null;this.setInputValue(l)}else this.setInputValue(null);s&&this.inputEvent(t,i),n&&this.pushEventToParent(n,{id:e})}}),this.handleEvent("active",({id:e,idx:t})=>{if(this.el.id===e){const i=this.el.querySelector(`div[data-idx="${t}"]`);i&&i.scrollIntoView({block:"nearest"})}}),this.attachDomEventHandlers()},updated(){this.maybeStyleClearButton(),this.attachDomEventHandlers()},reconnected(){this.selection&&this.selection.length>0&&this.pushEventTo(this.el,"options_recovery",this.selection)}}}; diff --git a/test/live_select/component_test.exs b/test/live_select/component_test.exs index ca06083..f999952 100644 --- a/test/live_select/component_test.exs +++ b/test/live_select/component_test.exs @@ -168,15 +168,6 @@ defmodule LiveSelect.ComponentTest do assert_selected_static(component, "C", 3) end - - @tag source: %{"city_search" => [{"B", 1}]} - test "raises if initial selection is in the wrong format", %{form: form} do - assert_raise RuntimeError, ~r/invalid element in selection/, fn -> - render_component(&LiveSelect.live_select/1, - field: form[:city_search] - ) - end - end end describe "in tags mode" do @@ -284,15 +275,23 @@ defmodule LiveSelect.ComponentTest do %{label: "C", value: 3} ]) end + end - @tag source: %{"city_search" => [%{B: 1, C: 2}]} - test "raises if initial selection is in the wrong format", %{form: form} do - assert_raise RuntimeError, ~r/invalid element in selection/, fn -> - render_component(&LiveSelect.live_select/1, - mode: :tags, - field: form[:city_search] - ) - end + test "raises if options are passed in the wrong format (1)", %{form: form} do + assert_raise RuntimeError, ~r/invalid element in option/, fn -> + render_component(&LiveSelect.live_select/1, + field: form[:city_search], + options: [[10, 20]] + ) + end + end + + test "raises if options are passed in the wrong format (2)", %{form: form} do + assert_raise RuntimeError, ~r/invalid element in option/, fn -> + render_component(&LiveSelect.live_select/1, + field: form[:city_search], + options: [%{x: 10, y: 20}] + ) end end diff --git a/test/live_select_tags_test.exs b/test/live_select_tags_test.exs index 40526cd..667edf9 100644 --- a/test/live_select_tags_test.exs +++ b/test/live_select_tags_test.exs @@ -476,4 +476,24 @@ defmodule LiveSelectTagsTest do %{value: 4, label: "D"} ]) end + + test "form recovery", %{conn: conn} do + {:ok, live, _html} = live(conn, "/?mode=tags") + + values = [value1 = [10, 20], value2 = %{"x" => 10, "y" => 20}, value3 = "C"] + + render_change(live, "change", %{"my_form" => %{"city_search" => Jason.encode!(values)}}) + + render_hook(element(live, selectors()[:container]), "options_recovery", [ + %{label: "A", value: value1}, + %{label: "B", value: value2}, + %{label: "C", value: value3} + ]) + + assert_selected_multiple_static(live, [ + %{label: "A", value: value1}, + %{label: "B", value: value2}, + %{label: "C", value: value3} + ]) + end end diff --git a/test/live_select_test.exs b/test/live_select_test.exs index 6b9e70a..5e02eaf 100644 --- a/test/live_select_test.exs +++ b/test/live_select_test.exs @@ -639,24 +639,51 @@ defmodule LiveSelectTest do assert tag =~ "with custom slot" end - test "selection can be updated from the form", %{conn: conn} do - stub_options(%{ - "A" => 1, - "B" => 2, - "C" => 3 - }) + test "selection can be cleared from the form", %{conn: conn} do + {:ok, live, _html} = live(conn, "/") + + render_change(live, "change", %{"my_form" => %{"city_search" => ""}}) + + assert_clear_static(live) + end + test "form recovery (1)", %{conn: conn} do {:ok, live, _html} = live(conn, "/") - type(live, "ABC") + value = [10, 20] + render_change(live, "change", %{"my_form" => %{"city_search" => Jason.encode!(value)}}) - select_nth_option(live, 2) + render_hook(element(live, selectors()[:container]), "options_recovery", [ + %{label: "A", value: value} + ]) - assert_selected(live, "B", 2) + assert_selected_static(live, "A", value) + end + + test "form recovery (2)", %{conn: conn} do + {:ok, live, _html} = live(conn, "/") + + value = %{"x" => 10, "y" => 20} + render_change(live, "change", %{"my_form" => %{"city_search" => Jason.encode!(value)}}) + + render_hook(element(live, selectors()[:container]), "options_recovery", [ + %{label: "A", value: value} + ]) + + assert_selected_static(live, "A", value) + end + + test "form recovery (3)", %{conn: conn} do + {:ok, live, _html} = live(conn, "/") + + value = "A" + render_change(live, "change", %{"my_form" => %{"city_search" => value}}) - render_change(live, "change", %{"my_form" => %{"city_search" => 1}}) + render_hook(element(live, selectors()[:container]), "options_recovery", [ + %{label: "A", value: value} + ]) - assert_selected_static(live, "A", 1) + assert_selected_static(live, "A", value) end for style <- [:daisyui, :tailwind, :none, nil] do diff --git a/test/support/helpers.ex b/test/support/helpers.ex index 77450fe..3c1aaf5 100644 --- a/test/support/helpers.ex +++ b/test/support/helpers.ex @@ -385,6 +385,16 @@ defmodule LiveSelect.TestHelpers do end def assert_clear(live, input_event \\ true) do + assert_clear_static(live) + + assert_push_event(live, "select", %{ + id: @component_id, + selection: [], + input_event: ^input_event + }) + end + + def assert_clear_static(live) do assert live |> element(@selectors[:text_input]) |> render() @@ -397,12 +407,6 @@ defmodule LiveSelect.TestHelpers do |> render() |> Floki.parse_fragment!() |> Floki.attribute("value") == [] - - assert_push_event(live, "select", %{ - id: @component_id, - selection: [], - input_event: ^input_event - }) end def assert_option_removeable(live, n) do