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

Add update_selection/1 to dynamically change the selection #69

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
20 changes: 18 additions & 2 deletions lib/live_select.ex
Original file line number Diff line number Diff line change
Expand Up @@ -127,11 +127,27 @@ defmodule LiveSelect do
send_update(LiveSelect.Component, id: live_select_id, value: new_selection)
```

`new_selection` must be a single element in `:single` mode, a list in `:tags` mode. If it's `nil`, the selection will be cleared.
After updating the selection, `LiveSelect` will trigger a change event in the form.
`new_selection` must be a single element in `:single` mode, a list in `:tags` mode. If it's `nil`, the selection will be cleared.
After updating the selection, `LiveSelect` will trigger a change event in the form.

To set a custom id for the component to use with `Phoenix.LiveView.send_update/3`, you can pass the `id` assign to `live_select/1`.

## Dynamically updating the selection

You can also update the selection dynamically by passing an 1 arity function that receives the current selection to `:update_selection`:

Copy link
Owner

@maxmarcon maxmarcon Jun 10, 2024

Choose a reason for hiding this comment

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

I would say:

You can dynamically update the selection by using the :update_selection assign. :update_selection must be a 1-arity function that receives the current selection and returns the new one:

```
send_update(LiveSelect.Component, id: live_select_id, update_selection: fn current_selection -> Enum.filter(current_selection, &String.length(&1.label) > 3))
```

In this case, only the values with a label longer than 3 characters will be kept in the selection.

Another example that appends values to the current selection:
Copy link
Owner

Choose a reason for hiding this comment

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

I don't think we need the second example


```
values_to_append = [1, 2, 3]
send_update(LiveSelect.Component, id: live_select_id, update_selection: fn current_selection -> current_selection ++ values_to_append end)
```

## Examples

Expand Down
42 changes: 37 additions & 5 deletions lib/live_select/component.ex
Original file line number Diff line number Diff line change
Expand Up @@ -151,7 +151,7 @@ defmodule LiveSelect.Component do
socket,
:selection,
fn selection, %{options: options, mode: mode, value_mapper: value_mapper} ->
update_selection(
set_selection(
field.value,
selection,
options,
Expand All @@ -168,7 +168,19 @@ defmodule LiveSelect.Component do
if Map.has_key?(assigns, :value) do
update(socket, :selection, fn
selection, %{options: options, value: value, mode: mode, value_mapper: value_mapper} ->
update_selection(value, selection, options, mode, value_mapper)
set_selection(value, selection, options, mode, value_mapper)
end)
|> client_select(%{input_event: true})
else
socket
end

socket =
if Map.has_key?(assigns, :update_selection) do
update(socket, :selection, fn
selection,
%{update_selection: update_fn, options: options, mode: mode, value_mapper: value_mapper} ->
update_selection(update_fn, selection, options, mode, value_mapper)
Copy link
Owner

Choose a reason for hiding this comment

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

My suggestion is to replace this line with:

update_selection(update_fn.(selection), selection, options, mode, value_mapper)

Where update_selection is the old update_selection function, we don't rename it and we don't add the new update_selection function.

We won't have dupe detection but I think it's overkill. We don't have it anyway when you pass the selection explicitly with :value, why have it in the case of :update_selection? The caller can easily check for dupes and remove them if they want to

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I ran into a problem with this and still need to think of a way to figure it out. The update_fn.(selection) may leave mixed entries (current + new) and it fails when trying to ran the existing ones with the value_mapper again. This is why the function I wrote splits it and only "normalize" the new entries.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Sorry that was not clear, my function fixes the problem by splitting it and selectively running value_mapper only on new values. If we are to revert and use the og update_selection we must address the value_mapper that may encounter already mapped values.

Copy link
Owner

@maxmarcon maxmarcon Jun 12, 2024

Choose a reason for hiding this comment

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

Thanks for clarifying. Ok, but I don't think the problem is actually fixed by your function. Take this example:

current_selection = [%{value: 1, label: "one"}, %{value: 2, label: "two"}]
value_mapper = fn n -> %{value: n, label: to_string(n)} end

update_fn = fn current_selection -> current_selection ++ [2, 3] end

With this arguments, your update_selection function
will fail to spot that 2 is already in the selection, because it's looking for unmapped values (2,3) in the list of mapped values (line 547).

I can't think of an elegant solution for this. Can you?

My impulse would be to say: it's the responsibility of the writer of update_fn to leave the selection in a state that can be mapped using the value_mapper they provided.
Same for dupe detection.

Copy link
Owner

Choose a reason for hiding this comment

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

Ok I thought about it and I think another, perhaps better option would to not call value_mapper at all when updating the selection programmatically. This makes sense because the caller can map the selection themselves if they want to (i.e. they can provide the selection already in the right format). value_mapper was intended anyway for the case where the user has no way to map the selection (i.e. when it's coming from existing values in the form).

So something like:

update_selection(update_fn.(selection), selection, options, mode, & &1)

end)
|> client_select(%{input_event: true})
else
Expand Down Expand Up @@ -365,6 +377,7 @@ defmodule LiveSelect.Component do
:clear_button,
:hide_dropdown,
:value_mapper,
:update_selection,
# for backwards compatibility
:form
]
Expand Down Expand Up @@ -514,19 +527,38 @@ defmodule LiveSelect.Component do
})
end

defp update_selection(nil, _current_selection, _options, _mode, _value_mapper), do: []
defp set_selection(nil, _current_selection, _options, _mode, _value_mapper), do: []
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Renamed this function to not cause confusion with the new option, as this function will effectively update the selection as a whole.

Copy link
Owner

Choose a reason for hiding this comment

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

Let us not rename it (see comment above)


defp update_selection(value, current_selection, options, :single, value_mapper) do
defp set_selection(value, current_selection, options, :single, value_mapper) do
List.wrap(normalize_selection_value(value, options ++ current_selection, value_mapper))
end

defp update_selection(value, current_selection, options, :tags, value_mapper) do
defp set_selection(value, current_selection, options, :tags, value_mapper) do
value = if Enumerable.impl_for(value), do: value, else: [value]

Enum.map(value, &normalize_selection_value(&1, options ++ current_selection, value_mapper))
|> Enum.reject(&is_nil/1)
end

defp update_selection(update_fn, current_selection, options, _mode, value_mapper)
when is_function(update_fn, 1) do
new_selection = update_fn.(current_selection)

{existing, new} = Enum.split_with(new_selection, &(&1 in current_selection))

new =
Enum.map(new, &normalize_selection_value(&1, options, value_mapper))
|> Enum.reject(&is_nil/1)

Enum.uniq(existing ++ new)
Copy link
Contributor Author

Choose a reason for hiding this comment

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

This forces uniqueness. Otherwise appending would allow duplicate values and I couldn't think of a reason to allow it.

Copy link
Owner

Choose a reason for hiding this comment

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

Let's remove this new function, see comment above

end

defp update_selection(_update_fn, _current_selection, _options, _mode, _value_mapper) do
raise """
Option for `:update_selection` must be a function with arity 1
"""
end

defp normalize_selection_value(%Ecto.Changeset{action: :replace}, _options, _value_mapper),
do: nil

Expand Down
39 changes: 39 additions & 0 deletions test/live_select_tags_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -379,6 +379,45 @@ defmodule LiveSelectTagsTest do
assert_selected_multiple(live, [%{label: "C", value: 3}, %{label: "E", value: 5}])
end

test "can dynamically change the selection - append example", %{conn: conn} do
{:ok, live, _html} = live(conn, "/?mode=tags")

stub_options(~w(A B C))

type(live, "ABC")

select_nth_option(live, 1)

assert_selected_multiple(live, ~w(A))

send_update(live, update_selection: fn selection -> selection ++ ["B"] end)

assert_selected_multiple(live, ~w(A B))

send_update(live, update_selection: fn selection -> selection ++ ["C"] end)

assert_selected_multiple(live, ~w(A B C))

# Avoids duplicates
send_update(live, update_selection: fn selection -> selection ++ ["C"] end)

assert_selected_multiple(live, ~w(A B C))
end

test "can dynamically change the selection - filter example", %{conn: conn} do
{:ok, live, _html} = live(conn, "/?mode=tags")

send_update(live, value: ~w(A B))

assert_selected_multiple(live, ~w(A B))

send_update(live,
update_selection: fn selection -> Enum.filter(selection, &(&1.label == "A")) end
)

assert_selected_multiple(live, ~w(A))
end

test "can render custom clear button", %{conn: conn} do
{:ok, live, _html} = live(conn, "/live_component_test")

Expand Down
22 changes: 22 additions & 0 deletions test/live_select_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -621,6 +621,28 @@
assert_selected(live, :D, 4)
end

test "can dynamically update selection values", %{conn: conn} do
stub_options(A: 1)

{:ok, live, _html} = live(conn, "/")

send_update(live, value: 1, options: [A: 1])

assert_selected(live, :A, 1)

send_update(live, update_selection: fn sel -> Enum.filter(sel, &(&1.value == 1)) end)

assert_selected(live, :A, 1)

send_update(live, update_selection: fn sel -> Enum.filter(sel, &(&1.value == 2)) end)

refute_selected(live)

send_update(live, update_selection: fn sel -> sel ++ [A: 1] end)

assert_selected(live, :A, 1)
end

test "renders custom :option slots", %{conn: conn} do
{:ok, live, _html} = live(conn, "/live_component_test")

Expand Down Expand Up @@ -730,7 +752,7 @@
assert_option_active(
live,
1,
get_in(expected_class(), [@style || default_style(), :active_option]) || ""

Check warning on line 755 in test/live_select_test.exs

View workflow job for this annotation

GitHub Actions / Build and test with elixir 1.14.0 otp 25.0

this check/guard will always yield the same result

Check warning on line 755 in test/live_select_test.exs

View workflow job for this annotation

GitHub Actions / Build and test with elixir 1.15.0 otp 26.0

this check/guard will always yield the same result
)
end

Expand Down
Loading