kevinschweikert

kevinschweikert

Hi together,

i want to build a filter UI where the user can select some items with a checkbox and then the results get filtered based on the selected checkboxes. For example, a use case where we have some categories in a DB and give the user a checkbox for each category to filter for. So far so good and i had no problem implementing that.
But i want to have another checkbox which lets the user select all the categories at once or deselect every category at once. Additionally, the behaviour should be, that when a user deselects one category while the select all box is checked it should become unchecked and vice versa, when the user selects every category by hand, the select all checkbox should be checked as well.

My first step was to define a schema for the form which looks something like this:

  defp parse_filter(default_filter, attrs \\ %{}) do
    fields = %{
      all: :boolean,
      selected: {:array, :integer}
    }

    {default_filter, fields}
    |> Ecto.Changeset.cast(attrs, Map.keys(fields))
    |> Map.put(:action, :validate)
  end

Is store the filter params in the URL with:

  def handle_event("change-categories", %{"categories" => params}, socket) do
    form = parse_filter(socket.assigns.filter, params)
    filter = apply_filter(form)
    {:noreply, socket |> push_patch(to: ~p"/?#{filter}")}
  end
  def handle_params(params, _, socket) do
    form = parse_filter(socket.assigns.filter, params)
    filter = apply_filter(form)

    {:noreply,
     socket
     |> assign(
       filter: filter,
       filtered_categories: filter_categories(socket.assigns.categories, filter)
     )
     |> assign_form(form)}
  end

I’ve tried with some Ecto.Changeset.get_change/put_change in the validation with parse_filter/2 but all and selected would overwrite each other.

I’ve prepared a gist with my idea, but i can’t figure out if i am on the right track with my plan. Does anybody have a solution for this problem or can point me in the right direction?

Showing Posts 1 to 10

greven

greven

Here’s a snippet of what I have done in a multi-step form some years ago. Probably there is a more elegant solution for this, but this was done “in a rush” about 4 years ago, anyway, it might help you.

The amount of possible options here was like 4 and static, maybe if you have dynamic options it might not work that well with this approach.

defmodule Web.JobTypeStepComponentLive do

  @primary_key false
  embedded_schema do
    field(:job_types, {:array, :string})
  end

  @impl true
  def mount(socket) do
    socket = assign(socket, all_checked?: false)
    {:ok, socket}
  end

  @impl true
  def update(assigns, socket) do
    socket =
      socket
      |> assign(assigns)
      |> assign_job_types()
      |> assign_changeset()
      |> assign_job_type_options()

    {
      :ok,
      socket
      |> assign(all_checked?: all_checked?(socket))
    }
  end

  @impl true
  def handle_event("validate", params, socket) do
    params = Map.get(params, "job_type_step_component", %{"job_types" => []})
    all_checked? = all_checked?(socket, params["job_types"])

    changeset =
      %__MODULE__{}
      |> changeset(params)
      |> Map.put(:action, :validate)

    {:noreply, assign(socket, changeset: changeset, all_checked?: all_checked?)}
  end

  @impl true
  def handle_event("toggle_all", _payload, socket) do
    all_ids =
      socket.assigns.job_type_options
      |> Enum.map(&(elem(&1, 1) |> to_string()))

    all_checked? = all_checked?(socket)
    new_selection = if all_checked?, do: [], else: all_ids

    changeset =
      %__MODULE__{}
      |> changeset(%{"job_types" => new_selection})
      |> Map.put(:action, :validate)

    {:noreply, assign(socket, changeset: changeset, all_checked?: !all_checked?)}
  end

  defp all_checked?(socket) do
    current_ids = get_field(socket.assigns.changeset, :job_types) || []
    all_checked?(socket, current_ids)
  end

  defp all_checked?(socket, current_ids) do
    all_ids =
      socket.assigns.job_type_options
      |> Enum.map(&(elem(&1, 0) |> to_string()))

    length(current_ids) == length(all_ids)
  end

@impl true
  def render(assigns) do
    ~H"""
       (...)

          <div class="flex justify-center">
          <button type="button" class="btn btn--link btn--compact" phx-click="toggle_all" phx-target= 
               {@myself}>
            <%= if @all_checked?, do: gettext("Deselect all"), else: gettext("Select all") %>
          </button>
        </div>
    """
  end
end
kevinschweikert

kevinschweikert OP

Ah thank you! I have seen it and it would be a solution for the problem, but i would really like a form based solution, so i can use this filter in combination with some more filter options which are already working in a form. But if i can’t figure out a solution i will definitely use this!

sa-mm

sa-mm

This might get you part of the way there or at least give you some ideas about where you want to go:

    <.form id="categories" for={@categories_form} phx-change="change-categories">
    <label>
    <%= if length(@categories) == length(@filtered_categories) do %>
    <input type="checkbox" phx-click={Phoenix.LiveView.JS.set_attribute({"checked", "false"}, to: "input")} checked={true} />
    <% else %>
    <input type="checkbox" phx-click={Phoenix.LiveView.JS.set_attribute({"checked", "true"}, to: "input")} checked={false}  />
    <% end %>
    Show all
    </label>
      <.checkgroup class="selected" field={@categories_form[:selected]} options={@options} label="Categories" />
    </.form>

I’m not entirely sure what you mean by “form based solution,” so maybe referencing the categories and filtered_categories assigns isn’t acceptable, and deselecting the Select All doesn’t work because the browser doesn’t want to give up focus (I think).

One thing that looks like it might cause trouble is that you’re reading the previous filters off the assigns when you call parse_filter. Maybe you want just one source of truth for the filters, which you’ll get from handle_params.

cmo

cmo

You can use a checkbox for the Select All input and handle the toggling of that in a hook. This method does not require network trips for selecting/deselecting and will return to the original selection when cancel is clicked, or the dropdown is closed. There is a JS.dispatch("cancel") on the close trigger.

<div phx-hook="ColumnOptionsFilter" ...>
  <Form ...>
    ...
    <Field name="__select_all__">
      <Checkbox
        value={Enum.all?(@options, & &1[:selected])}
        opts={
           "phx-click": JS.dispatch("select-all"),
           role: "menuitemcheckbox"
        }
      />
      <Label>Select All</Label>
    </Field>
    ... other inputs
  </Form>
</div>

The Hook:

const ColumnOptionFilter = {
  checkboxes() {
    return Array.from(
      this.el.querySelectorAll(`#${this.el.id}-options input[type=checkbox]`)
    );
  },

  mounted() {
    this.state = {
      checkboxes: null,
      initialSelection: null,
      selectAllCheckbox: null,
    };

    this.state.checkboxes = this.checkboxes();
    this.state.initialSelection = selection(this.state.checkboxes);
    this.state.selectAllCheckbox = this.el.querySelector(
      `#${this.el.id}-form___select_all__`
    );

    if (!this.state.selectAllCheckbox) {
      throw new Error(
        `Expected a checkbox with id '#${this.el.id}-select-all'`
      );
    }

    this.el.addEventListener("select-all", (e) => {
      const allSelected = areAllSelected(this.state.checkboxes);
      this.state.checkboxes.forEach(
        (checkbox) => (checkbox.checked = !allSelected)
      );
    });

    this.el.addEventListener("cancel", (e) => {
      this.state.checkboxes.forEach((checkbox) =>
        this.state.initialSelection.includes(checkbox.name)
          ? (checkbox.checked = true)
          : (checkbox.checked = false)
      );
      this.state.selectAllCheckbox.value = areAllSelected(
        this.state.checkboxes
      );
    });
  },
  updated() {
    this.state.checkboxes = this.checkboxes();
    this.state.initialSelection = selection(this.state.checkboxes);
    this.state.selectAllCheckbox.value = areAllSelected(this.state.checkboxes);
  },
};

function selection(checkboxes) {
  return checkboxes
    .filter((checkbox) => checkbox.checked === true)
    .map((checkbox) => checkbox.name);
}

function areAllSelected(all) {
  return all.reduce((acc, checkbox) => {
    return checkbox.checked === true && acc;
  }, true);
}

export { ColumnOptionFilter };

The component takes a list of options of the form %{id: 1, value: "Some Value", available: true/false, selected: true/false}

LostKobrakai

LostKobrakai

You weren’t far off: Revisions · Phoenix LiveView filter toogle all problem · GitHub

Needed a bit working around the fact that empty lists cannot be represented in url encoding.

kevinschweikert

kevinschweikert OP

Thank you @LostKobrakai! I was so close in my attempts to solve this. I wrote some kind of adjust_by_latest_changes/2 function, but i didn’t think about handling the third case when there are no changes in all.

I had to change the solution with the empty list params to something like this, to prevent overriding my preselected categories in handle_params.

def handle_event("change-categories", %{"categories" => params}, socket) do
    form = parse_filter(socket.assigns.filter, params)
    form =
      parse_filter(socket.assigns.filter, params)
      |> adjust_by_latest_changes(Enum.map(socket.assigns.categories, & &1.id))

    # empty lists are not url encoded so we simulate an empty list with a list with an empty string
     selected = if filter.selected == [], do: [""], else: filter.selected

    filter = apply_filter(form)
    {:noreply, socket |> push_patch(to: ~p"/?#{filter}")}
    {:noreply, socket |> push_patch(to: ~p"/?#{[all: filter.all, selected: selected]}")}
  end

and in handle_params provide some default params with:

def handle_params(params, _, socket) do
    # default params if none are set
    params = Map.merge(%{"selected" => socket.assigns.filter.selected}, params)

    form = parse_filter(socket.assigns.filter, params)
    filter = apply_filter(form)

    {:noreply,
     socket
     |> assign(
       filter: filter,
       filtered_categories: filter_categories(socket.assigns.categories, filter)
     )
     |> assign_form(form)}
  end

And finally i had to remove the line |> Ecto.Changeset.change(selected: []) because it is already handled in the params workaround. Or is there a better way to do this?

I will update my gist with these changes. Thank you all for your time and ideas! I’m very thankful for your help!

LostKobrakai

LostKobrakai

What does “preselected” mean exactly? I’d suggest modeling that instead of changing how you deal with params everywhere.

kevinschweikert

kevinschweikert OP

It’s not clear from my gist but in the application i get the categories from the DB and they have a boolean field preselected. That’s whats basically happening in on_mount when we set the first filter with

categories_form = parse_filter(%{all: false, selected: []}, %{all: false, selected: [0]})

And i want to have (in this example) the category with the ID == 0 already selected. But on the first visit from the user there are no params set so it will override the filter in handle_params with an empty list when we use |> Ecto.Changeset.change(selected: [])

LostKobrakai

LostKobrakai

So handle_params being called the first time is “special”. I’d have an assign preselected, which you fill in mount, in handle_params you adjust the result of parse_filter with all preselected categories and at the end of handle_params you set preselected: [] to essentially disable preselection going forward.

Where Next? Top

Trending in Questions Top

Blokh
Hey guys, I’ve got a huge CSV ( around 10 GB ) that needs to be processed hourly Do you guys have any suggestions what is the best prac...
New
kszambelanczyk
Hello! Could someone please give me a help/sample code, how to delete a file from s3 using waffle/waffle_ecto from Phoenix app. I creat...
New
Onor.io
I have what I’ve heard referred to as a “lookup table” in my database. This is a way of assigning codes to common values. One common lo...
New
Trolleger
What approach to take when sending live updates to “random” users Hi! I have a question, I have a little chat app, and when I create a DM...
New
RemyXRenard
I’m seeing that a list inside a Kino.DataTable will be interpreted as a charlist, even if the Kino.configure() is set to charlists: :as_l...
New
matt-savvy
Anyone here using Honeybadger? My Honeybadger account is being overwhelmed with noise from some bots. Seeing a lot of Bandit.HTTPError...
New
samoloth
Hi, I’ve just set up an application with ash_authentication. There is only magic link strategy for now, so there is no confirmation add o...
New

Other Trending Topics Top

mudasobwa
I am happy to introduce the very α version of the new programming language compiled to BEAM. Welcome Cure. It has literally three kille...
New
garrison
Hobbes is a low-level distributed database for the Elixir programming language. Hobbes provides a simple, safe, and scalable storage lay...
New
mcass19
ExRatatui lets you cook up rich terminal UIs in Elixir, powered by Rust’s ratatui via Rustler NIFs. Build interactive terminal applicatio...
New
Damirados
Hello everyone. After busy few months I am happy to announce v0.1.0 of Emerge &amp; Solve. They are GUI (Emerge) and State management (S...
New
netoum
Corex is an accessible, unstyled UI component library for Phoenix that integrates Zag.js state machines using Vanilla JavaScript and Live...
New
wintermeyer
There are three potential reasons for members of this forum to have a look at https://vutuv.de You are tired or annoyed of LinkedIn. Yo...
New

We're in Beta

About us Mission Statement

Options

Thread Display Mode




Thread Preview

Skip Thread Previews