Repository: nerves-hub/nerves_hub_web
Pull request #2062: Add filtering sidebar component
Author: login: elinol
Created: 2025-04-23T06:42:39Z | Merged: 2025-04-25T15:41:26Z

Moves sidebar filtering into component.




Diff:
diff --git a/lib/nerves_hub/devices/filtering.ex b/lib/nerves_hub/devices/filtering.ex
index 8e76f490b..bec152173 100644
--- a/lib/nerves_hub/devices/filtering.ex
+++ b/lib/nerves_hub/devices/filtering.ex
@@ -38,6 +38,10 @@ defmodule NervesHub.Devices.Filtering do
     end
   end
 
+  def filter(query, _filters, :health_status, "unknown") do
+    where(query, [latest_health: lh], lh.status == ^"unknown" or is_nil(lh))
+  end
+
   def filter(query, _filters, :health_status, value) do
     where(query, [latest_health: lh], lh.status == ^value)
   end
@@ -83,7 +87,7 @@ defmodule NervesHub.Devices.Filtering do
     end
   end
 
-  def filter(query, _filters, :device_id, value) do
+  def filter(query, _filters, :identifier, value) do
     where(query, [d], ilike(d.identifier, ^"%#{value}%"))
   end
 
@@ -119,6 +123,23 @@ defmodule NervesHub.Devices.Filtering do
     end
   end
 
+  def filter(query, _filters, :search, value) when is_binary(value) and value != "" do
+    search_term = "%#{value}%"
+
+    query
+    |> where(
+      [d],
+      ilike(d.identifier, ^search_term) or
+        ilike(fragment("COALESCE(?->>'version', '')", d.firmware_metadata), ^search_term) or
+        ilike(fragment("COALESCE(?->>'platform', '')", d.firmware_metadata), ^search_term) or
+        fragment(
+          "string_array_to_string(COALESCE(?, ARRAY[]::text[]), ' ', ' ') ILIKE ?",
+          d.tags,
+          ^search_term
+        )
+    )
+  end
+
   # Ignore any undefined filter.
   # This will prevent error 500 responses on deprecated saved bookmarks etc.
   def filter(query, _filters, _key, _value) do
diff --git a/lib/nerves_hub/managed_deployments.ex b/lib/nerves_hub/managed_deployments.ex
index 0098eae21..bc01c4fb4 100644
--- a/lib/nerves_hub/managed_deployments.ex
+++ b/lib/nerves_hub/managed_deployments.ex
@@ -34,16 +34,14 @@ defmodule NervesHub.ManagedDeployments do
   @spec filter(Product.t(), map()) :: {[Product.t()], Flop.Meta.t()}
   def filter(product, opts \\ %{}) do
     opts = Map.reject(opts, fn {_key, val} -> is_nil(val) end)
+    pagination = Map.get(opts, :pagination, %{})
+    sorting = Map.get(opts, :sort, {:asc, :name})
 
-    sort = Map.get(opts, :sort, "name")
-    sort_direction = Map.get(opts, :sort_direction, "desc")
-
-    sort_opts = {String.to_existing_atom(sort_direction), String.to_atom(sort)}
     filters = Map.get(opts, :filters, %{})
 
     flop = %Flop{
-      page: String.to_integer(Map.get(opts, :page, "1")),
-      page_size: String.to_integer(Map.get(opts, :page_size, "25"))
+      page: pagination.page,
+      page_size: pagination.page_size
     }
 
     subquery =
@@ -60,7 +58,7 @@ defmodule NervesHub.ManagedDeployments do
     |> join(:left, [d], f in assoc(d, :firmware))
     |> where([d], d.product_id == ^product.id)
     |> Filtering.build_filters(filters)
-    |> sort_deployment_groups(sort_opts)
+    |> sort_deployment_groups(sorting)
     |> preload([_d, _dev, f], firmware: f)
     |> select_merge([_f, dev], %{device_count: dev.device_count})
     |> Flop.run(flop)
diff --git a/lib/nerves_hub/managed_deployments/filtering.ex b/lib/nerves_hub/managed_deployments/filtering.ex
index d8cd60826..5292799f9 100644
--- a/lib/nerves_hub/managed_deployments/filtering.ex
+++ b/lib/nerves_hub/managed_deployments/filtering.ex
@@ -24,4 +24,31 @@ defmodule NervesHub.ManagedDeployments.Filtering do
   def filter(query, _filters, :name, value) do
     where(query, [d], ilike(d.name, ^"%#{value}%"))
   end
+
+  def filter(query, _filters, :platform, value) do
+    where(query, [_d, _dev, f], f.platform == ^value)
+  end
+
+  def filter(query, _filters, :architecture, value) do
+    where(query, [_d, _dev, f], f.architecture == ^value)
+  end
+
+  def filter(query, _filters, :search, value) when is_binary(value) and value != "" do
+    search_term = "%#{value}%"
+
+    query
+    |> where(
+      [d, _dev, f],
+      ilike(d.name, ^search_term) or
+        ilike(f.platform, ^search_term) or
+        ilike(f.architecture, ^search_term) or
+        ilike(fragment(" COALESCE(?->>'tags', '')", d.conditions), ^search_term)
+    )
+  end
+
+  # Ignore any undefined filter.
+  # This will prevent error 500 responses on deprecated saved bookmarks etc.
+  def filter(query, _filters, _key, _value) do
+    query
+  end
 end
diff --git a/lib/nerves_hub_web/components/filter_sidebar.ex b/lib/nerves_hub_web/components/filter_sidebar.ex
new file mode 100644
index 000000000..2ca804980
--- /dev/null
+++ b/lib/nerves_hub_web/components/filter_sidebar.ex
@@ -0,0 +1,78 @@
+defmodule NervesHubWeb.Components.FilterSidebar do
+  use NervesHubWeb, :component
+
+  attr(:show, :boolean, required: true)
+  attr(:current_filters, :map, required: true)
+  attr(:on_toggle, :any, default: "toggle-filters")
+  attr(:on_update, :any, default: "update-filters")
+  attr(:on_reset, :any, default: "reset-filters")
+
+  slot(:filter, required: true) do
+    attr(:attr, :string, required: true, doc: "Filter attribute name")
+    attr(:label, :string, required: true)
+    attr(:type, :atom, required: true)
+    attr(:values, :list)
+  end
+
+  def render(assigns) do
+    # Convert filter attributes to atoms for getting current filter values
+    assigns =
+      update(assigns, :filter, fn filters ->
+        Enum.map(filters, fn %{attr: attr} = filter ->
+          %{filter | attr: String.to_existing_atom(attr)}
+        end)
+      end)
+
+    ~H"""
+    <div class="pointer-events-none fixed inset-y-0 right-0 flex max-w-full pl-10 sm:pl-16 z-40">
+      <div class={[
+        "pointer-events-auto w-screen max-w-80 mt-[55px] flex h-full flex-col border-t border-l border-zinc-700 bg-base-900 shadow-filter-slider transition-transform",
+        !@show && "translate-x-full",
+        !@show && "invisible"
+      ]}>
+        <div class="h-0 flex-1 overflow-y-auto">
+          <div class="flex items-center h-14 px-4 py-3 border-b border-zinc-700">
+            <h4 class="text-base font-semibold">Filters</h4>
+
+            <button class="ml-auto p-1.5" type="button" phx-click={@on_toggle} phx-value-toggle={to_string(@show)}>
+              <svg xmlns="http://www.w3.org/2000/svg" class="w-5 h-5" viewBox="0 0 20 20" fill="none">
+                <path
+                  d="M10.0002 9.99998L5.8335 5.83331M10.0002 9.99998L14.1668 14.1666M10.0002 9.99998L14.1668 5.83331M10.0002 9.99998L5.8335 14.1666"
+                  stroke="#A1A1AA"
+                  stroke-width="1.2"
+                  stroke-linecap="round"
+                  stroke-linejoin="round"
+                />
+              </svg>
+            </button>
+          </div>
+
+          <div class="flex flex-1 flex-col pb-4">
+            <form id="filter-form" class="px-4 grow" phx-change={@on_update}>
+              <div :for={filter <- @filter} class="mt-6">
+                <label class="sidebar-label" for={"input_#{filter.attr}"}>{filter.label}</label>
+                <%= case filter.type do %>
+                  <% :text -> %>
+                    <input class="sidebar-text-input" type="text" name={filter.attr} id={"input_#{filter.attr}"} value={@current_filters[filter.attr]} phx-debounce="500" />
+                  <% :select -> %>
+                    <select class="sidebar-select" name={filter.attr} id={"input_#{filter.attr}"}>
+                      <option :for={{label, value} <- filter.values} value={value} selected={@current_filters[filter.attr] == value}>
+                        {label}
+                      </option>
+                    </select>
+                  <% :number -> %>
+                    <input class="sidebar-text-input" type="number" name={filter.attr} id={"input_#{filter.attr}"} value={@current_filters[filter.attr]} phx-debounce="100" />
+                <% end %>
+              </div>
+            </form>
+          </div>
+        </div>
+
+        <div class="flex shrink-0 justify-end h-16 px-4 py-4 mb-14 border-t border-zinc-700">
+          <button class="sidebar-button" type="button" phx-click={@on_reset}>Reset Filters</button>
+        </div>
+      </div>
+    </div>
+    """
+  end
+end
diff --git a/lib/nerves_hub_web/live/deployment_groups/index-new.html.heex b/lib/nerves_hub_web/live/deployment_groups/index-new.html.heex
index 19c13b75a..3a23cec78 100644
--- a/lib/nerves_hub_web/live/deployment_groups/index-new.html.heex
+++ b/lib/nerves_hub_web/live/deployment_groups/index-new.html.heex
@@ -22,11 +22,11 @@
         <input
           type="text"
           class="col-start-1 row-start-1 block h-full w-full bg-base-900 text-base-400 border border-base-600 rounded text-sm px-3 py-1 gap-2 font-normal h-5"
-          name="name"
+          name="search"
           id="deployment_group_search_top"
           placeholder="Search..."
           title="Search"
-          value={@current_filters[:name]}
+          value={@current_filters[:search]}
           phx-debounce="500"
         />
 
@@ -44,6 +44,9 @@
     <.button type="link" navigate={~p"/org/#{@org}/#{@product}/deployments/newz"} aria-label="Add a new deployment group">
       <.icon name="add" /> Create Deployment Group
     </.button>
+    <.button type="button" phx-click="toggle-filters" phx-value-toggle={to_string(@show_filters)}>
+      <.icon name="filter" /> Filters
+    </.button>
   </div>
 
   <%= if Enum.empty?(@entries) do %>
@@ -146,4 +149,10 @@
   <% end %>
 </div>
 
-<Pager.render_with_page_sizes pager={@pager_meta} page_sizes={[25, 50, 100]} />
+<FilterSidebar.render show={@show_filters} current_filters={@current_filters}>
+  <:filter attr="name" label="Name" type={:text} />
+  <:filter attr="platform" label="Platform" type={:select} values={[{"All", ""} | Enum.map(@platforms, &{if(&1, do: &1, else: "Unknown"), &1})]} />
+  <:filter attr="architecture" label="Architecture" type={:select} values={[{"All", ""} | Enum.map(@architectures, &{if(&1, do: &1, else: "Unknown"), &1})]} />
+</FilterSidebar.render>
+
+<Pager.render_with_page_sizes pager={@pager_meta} page_sizes={@paginate_opts.page_sizes} />
diff --git a/lib/nerves_hub_web/live/deployment_groups/index.ex b/lib/nerves_hub_web/live/deployment_groups/index.ex
index 62649b845..93a5e2b6f 100644
--- a/lib/nerves_hub_web/live/deployment_groups/index.ex
+++ b/lib/nerves_hub_web/live/deployment_groups/index.ex
@@ -1,22 +1,49 @@
 defmodule NervesHubWeb.Live.DeploymentGroups.Index do
   use NervesHubWeb, :updated_live_view
 
+  alias NervesHub.Firmwares
   alias NervesHub.Firmwares.Firmware
   alias NervesHub.ManagedDeployments
   alias NervesHub.ManagedDeployments.DeploymentGroup
 
   alias NervesHubWeb.Components.Pager
   alias NervesHubWeb.Components.Sorting
+  alias NervesHubWeb.Components.FilterSidebar
 
-  @pagination_opts ["page_number", "page_size", "sort", "sort_direction"]
   @default_filters %{
-    name: ""
+    name: "",
+    platform: "",
+    architecture: "",
+    search: ""
   }
 
   @filter_types %{
-    name: :string
+    name: :string,
+    platform: :string,
+    architecture: :string,
+    search: :string
   }
 
+  @default_page 1
+  @default_page_size 25
+
+  @default_pagination %{
+    page_number: @default_page,
+    page_size: @default_page_size,
+    page_sizes: [25, 50, 100],
+    total_pages: 0
+  }
+
+  @pagination_types %{
+    page_number: :integer,
+    page_size: :integer,
+    page_sizes: {:array, :integer},
+    total_pages: :integer
+  }
+
+  @default_sorting %{sort_direction: "asc", sort: "name"}
+  @sort_types %{sort_direction: :string, sort: :string}
+
   @impl Phoenix.LiveView
   def mount(_params, _session, %{assigns: %{product: product}} = socket) do
     deployment_groups = ManagedDeployments.get_deployment_groups_by_product(product)
@@ -31,22 +58,36 @@ defmodule NervesHubWeb.Live.DeploymentGroups.Index do
 
     socket
     |> page_title("Deployments - #{product.name}")
+    |> assign(:paginate_opts, @default_pagination)
+    |> assign(:sort_direction, @default_sorting.sort_direction)
+    |> assign(:current_sort, @default_sorting.sort)
     |> sidebar_tab(:deployments)
     |> assign(:deployment_groups, deployment_groups)
+    |> assign(:platforms, Firmwares.get_unique_platforms(product))
+    |> assign(:architectures, Firmwares.get_unique_architectures(product))
     |> assign(:counts, counts)
     |> assign(:current_filters, @default_filters)
     |> assign(:currently_filtering, false)
+    |> assign(:show_filters, false)
     |> ok()
   end
 
   @impl Phoenix.LiveView
   def handle_params(params, _url, socket) do
     filters = Map.merge(@default_filters, filter_changes(params))
+    pagination_changes = pagination_changes(params)
+    pagination_opts = Map.merge(@default_pagination, pagination_changes)
 
     socket
     |> assign(:params, params)
+    |> assign(:current_sort, Map.get(params, "sort", @default_sorting.sort))
+    |> assign(
+      :sort_direction,
+      Map.get(params, "sort_direction", @default_sorting.sort_direction)
+    )
     |> assign(:current_filters, filters)
     |> assign(:currently_filtering, filters != @default_filters)
+    |> assign(:paginate_opts, pagination_opts)
     |> assign_deployment_groups_with_pagination()
     |> noreply()
   end
@@ -69,6 +110,11 @@ defmodule NervesHubWeb.Live.DeploymentGroups.Index do
     |> noreply()
   end
 
+  @impl Phoenix.LiveView
+  def handle_event("toggle-filters", %{"toggle" => toggle}, socket) do
+    {:noreply, assign(socket, :show_filters, toggle != "true")}
+  end
+
   @impl Phoenix.LiveView
   def handle_event(
         "update-filters",
@@ -80,6 +126,13 @@ defmodule NervesHubWeb.Live.DeploymentGroups.Index do
     |> noreply()
   end
 
+  @impl Phoenix.LiveView
+  def handle_event("reset-filters", _params, socket) do
+    socket
+    |> push_patch(to: self_path(socket, @default_filters))
+    |> noreply()
+  end
+
   # Handles event of user clicking the same field that is already sorted
   # For this case, we switch the sorting direction of same field
   @impl Phoenix.LiveView
@@ -107,37 +160,41 @@ defmodule NervesHubWeb.Live.DeploymentGroups.Index do
   end
 
   defp assign_deployment_groups_with_pagination(socket) do
-    %{assigns: %{product: product, params: params, current_filters: filters}} = socket
-
-    pagination_opts = Map.take(params, @pagination_opts)
+    %{
+      assigns: %{
+        product: product,
+        current_filters: filters,
+        paginate_opts: paginate_opts
+      }
+    } = socket
 
     opts = %{
-      page: pagination_opts["page_number"],
-      page_size: pagination_opts["page_size"],
-      sort: pagination_opts["sort"] || "name",
-      sort_direction: pagination_opts["sort_direction"] || "asc",
+      pagination: %{page: paginate_opts.page_number, page_size: paginate_opts.page_size},
+      sort:
+        {String.to_existing_atom(socket.assigns.sort_direction),
+         String.to_atom(socket.assigns.current_sort)},
       filters: filters
     }
 
     {entries, pager_meta} = ManagedDeployments.filter(product, opts)
 
     socket
-    |> assign(:current_sort, opts.sort)
-    |> assign(:sort_direction, opts.sort_direction)
     |> assign(:entries, entries)
     |> assign(:pager_meta, pager_meta)
   end
 
   defp self_path(socket, new_params) do
-    current_params =
-      socket.assigns.params
-      |> Map.reject(fn {key, _val} -> key in ["org_name", "product_name"] end)
+    params = Enum.into(stringify_keys(new_params), socket.assigns.params)
+    pagination = pagination_changes(params)
+    filter = filter_changes(params)
+    sort = sort_changes(params)
 
-    params =
-      stringify_keys(new_params)
-      |> Enum.into(current_params)
+    query =
+      filter
+      |> Map.merge(pagination)
+      |> Map.merge(sort)
 
-    ~p"/org/#{socket.assigns.org.name}/#{socket.assigns.product.name}/deployment_groups?#{params}"
+    ~p"/org/#{socket.assigns.org.name}/#{socket.assigns.product.name}/deployment_groups?#{query}"
   end
 
   defp stringify_keys(params) do
@@ -150,12 +207,24 @@ defmodule NervesHubWeb.Live.DeploymentGroups.Index do
     end
   end
 
+  defp sort_changes(params) do
+    Ecto.Changeset.cast({@default_sorting, @sort_types}, params, Map.keys(@default_sorting)).changes
+  end
+
   defp filter_changes(params) do
     Ecto.Changeset.cast({@default_filters, @filter_types}, params, Map.keys(@default_filters),
       empty_values: []
     ).changes
   end
 
+  defp pagination_changes(params) do
+    Ecto.Changeset.cast(
+      {@default_pagination, @pagination_types},
+      params,
+      Map.keys(@default_pagination)
+    ).changes
+  end
+
   defp firmware_simple_display_name(%Firmware{} = f) do
     "#{f.version} #{f.uuid}"
   end
diff --git a/lib/nerves_hub_web/live/devices/index-new.html.heex b/lib/nerves_hub_web/live/devices/index-new.html.heex
index 191cea09a..666e40f2c 100644
--- a/lib/nerves_hub_web/live/devices/index-new.html.heex
+++ b/lib/nerves_hub_web/live/devices/index-new.html.heex
@@ -29,11 +29,11 @@
         <input
           type="text"
           class="col-start-1 row-start-1 block h-full w-full bg-base-900 text-base-400 border border-base-600 rounded text-sm px-3 py-1 gap-2 font-normal h-5"
-          name="device_id"
+          name="search"
           id="device_search_top"
           placeholder="Search..."
           title="Search"
-          value={@current_filters[:device_id]}
+          value={@current_filters[:search]}
           phx-debounce="500"
         />
 
@@ -127,7 +127,7 @@
     <%= if Enum.empty?(devices) && !@currently_filtering do %>
       <!-- TODO: Go over empty-state with new design -->
       <div class="h-full pb-16 flex items-center justify-center">
-        <span class="text-xl font-medium text-neutral-50">{@product.name} doesn’t have any devices yet.</span>
+        <span class="text-xl font-medium text-neutral-50">{@product.name} doesn't have any devices yet.</span>
       </div>
     <% else %>
       <div id="device-list" class="listing hidden" phx-mounted={fade_in("#device-list")}>
@@ -323,175 +323,75 @@
   </.async_result>
 </div>
 
-<div class="pointer-events-none fixed inset-y-0 right-0 flex max-w-full pl-10 sm:pl-16 z-40">
-  <div class={[
-    "pointer-events-auto w-screen max-w-80 mt-[55px] flex h-full flex-col border-t border-l border-zinc-700 bg-base-900 shadow-filter-slider transition-transform",
-    !@show_filters && "translate-x-full",
-    !@show_filters && "invisible"
-  ]}>
-    <div class="h-0 flex-1 overflow-y-auto">
-      <div class="flex items-center h-14 px-4 py-3 border-b border-zinc-700">
-        <h4 class="text-base font-semibold">Filters</h4>
-
-        <button class="ml-auto p-1.5" type="button" phx-click="toggle-filters" phx-value-toggle={to_string(@show_filters)}>
-          <svg xmlns="http://www.w3.org/2000/svg" class="w-5 h-5" viewBox="0 0 20 20" fill="none">
-            <path
-              d="M10.0002 9.99998L5.8335 5.83331M10.0002 9.99998L14.1668 14.1666M10.0002 9.99998L14.1668 5.83331M10.0002 9.99998L5.8335 14.1666"
-              stroke="#A1A1AA"
-              stroke-width="1.2"
-              stroke-linecap="round"
-              stroke-linejoin="round"
-            />
-          </svg>
-        </button>
-      </div>
-
-      <div class="flex flex-1 flex-col pb-4">
-        <form id="filter-form" class="px-4 grow" phx-change="update-filters">
-          <div class="sidebar-search mt-6">
-            <input type="text" name="device_id" id="device_search" placeholder="Search..." value={@current_filters[:device_id]} phx-debounce="500" />
-            <svg class="ml-auto" xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 20 20" fill="none">
-              <path
-                d="M16.6668 16.6666L13.2916 13.2914M13.2916 13.2914C14.3472 12.2358 15.0002 10.7775 15.0002 9.16665C15.0002 5.94499 12.3885 3.33331 9.16683 3.33331C5.94517 3.33331 3.3335 5.94499 3.3335 9.16665C3.3335 12.3883 5.94517 15 9.16683 15C10.7777 15 12.236 14.3471 13.2916 13.2914Z"
-                stroke="#A1A1AA"
-                stroke-width="1.2"
-                stroke-linecap="round"
-                stroke-linejoin="round"
-              />
-            </svg>
-          </div>
-          <div class="mt-6">
-            <label class="sidebar-label" for="input_health_status">Health status</label>
-            <select class="sidebar-select" name="health_status" id="input_health_status">
-              <option {selected?(@current_filters, :health_status, "")} value="">All</option>
-              <option {selected?(@current_filters, :health_status, "unknown")} value="unknown">Unknown</option>
-              <option {selected?(@current_filters, :health_status, "healthy")} value="healthy">Healthy</option>
-              <option {selected?(@current_filters, :health_status, "warning")} value="warning">Warning</option>
-              <option {selected?(@current_filters, :health_status, "unhealthy")} value="unhealthy">Unhealthy</option>
-            </select>
-          </div>
-          <div class="mt-6">
-            <label class="sidebar-label" for="input_connection">Connection</label>
-            <select class="sidebar-select" name="connection" id="input_connection">
-              <option {selected?(@current_filters, :connection, "")} value="">All</option>
-              <option {selected?(@current_filters, :connection, "connected")} value="connected">Connected</option>
-              <option {selected?(@current_filters, :connection, "disconnected")} value="disconnected">Disconnected</option>
-              <option {selected?(@current_filters, :connection, "not_seen")} value="not_seen">Not Seen</option>
-            </select>
-          </div>
-          <div class="mt-6">
-            <label class="sidebar-label" for="connection_type">Connection Type</label>
-            <select class="sidebar-select" name="connection_type" id="connection_type">
-              <option {selected?(@current_filters, :connection_type, "")} value="">All</option>
-              <option {selected?(@current_filters, :connection_type, "cellular")} value="cellular">Cellular</option>
-              <option {selected?(@current_filters, :connection_type, "ethernet")} value="ethernet">Ethernet</option>
-              <option {selected?(@current_filters, :connection_type, "wifi")} value="wifi">WiFi</option>
-            </select>
-          </div>
-          <div class="mt-6">
-            <label class="sidebar-label" for="deployment_id">Deployment Group</label>
-            <select class="sidebar-select" name="deployment_id" id="deployment_id">
-              <option {selected?(@current_filters, :deployment_id, "")} value="">All</option>
-              <option {selected?(@current_filters, :deployment_id, nil)} value="-1">No Deployment Group</option>
-              <%= for deployment_group <- @deployment_groups do %>
-                <option {selected?(@current_filters, :deployment_id, deployment_group.id)} value={deployment_group.id}>{deployment_group.name}</option>
-              <% end %>
-            </select>
-          </div>
-          <div class="mt-6">
-            <label class="sidebar-label" for="input_health">Firmware Updates</label>
-            <select class="sidebar-select" name="updates" id="input_health">
-              <option {selected?(@current_filters, :updates, "")} value="">All</option>
-              <option {selected?(@current_filters, :updates, "enabled")} value="enabled">Enabled</option>
-              <option {selected?(@current_filters, :updates, "penalty-box")} value="penalty-box">Penalty Box</option>
-              <option {selected?(@current_filters, :updates, "disabled")} value="disabled">Disabled</option>
-            </select>
-          </div>
-          <div class="mt-6">
-            <label class="sidebar-label" for="input_firmware">Firmware</label>
-            <select class="sidebar-select" name="firmware_version" id="input_firmware">
-              <option {selected?(@current_filters, :firmware_version, "")} value="">All</option>
-              <%= for version <- @firmware_versions do %>
-                <option {selected?(@current_filters, :firmware_version, version)}>{version}</option>
-              <% end %>
-            </select>
-          </div>
-          <div class="mt-6">
-            <label class="sidebar-label" for="input_platform">Platform</label>
-            <select class="sidebar-select" name="platform" id="input_platform">
-              <option {selected?(@current_filters, :platform, "")} value="">All</option>
-              <%= for platform <- @platforms do %>
-                <option {selected?(@current_filters, :platform, platform)} value={platform}>{if platform, do: platform, else: "Unknown"}</option>
-              <% end %>
-            </select>
-          </div>
-          <div class="mt-6">
-            <label class="sidebar-label" for="input_tags">Tags</label>
-            <input class="sidebar-text-input" type="text" name="tag" id="input_tags" value={@current_filters[:tag]} phx-debounce="500" />
-          </div>
-          <div class="mt-6">
-            <label class="sidebar-label" for="has_no_tags">Untagged</label>
-            <select class="sidebar-select" name="has_no_tags" id="has_no_tags">
-              <option {selected?(@current_filters, :has_no_tags, false)} value="false">All</option>
-              <option {selected?(@current_filters, :has_no_tags, true)} value="true">Only untagged</option>
-            </select>
-          </div>
-
-          <div class="mt-6">
-            <label class="sidebar-label" for="alarm_status">Alarm Status</label>
-            <select class="sidebar-select" name="alarm_status" id="alarm_status">
-              <option {selected?(@current_filters, :alarm_status, "")} value="">All</option>
-              <option {selected?(@current_filters, :alarm_status, "with")} value="with">Has Alarms</option>
-              <option {selected?(@current_filters, :alarm_status, "without")} value="without">No alarms</option>
-            </select>
-          </div>
-          <div class="mt-6">
-            <label class="sidebar-label" for="alarm">Alarm</label>
-            <select class="sidebar-select" name="alarm" id="alarm">
-              <option {selected?(@current_filters, :alarm, "")} value="">All</option>
-              <%= for alarm <- @current_alarms do %>
-                <option {selected?(@current_filters, :alarm, alarm)}>{alarm}</option>
-              <% end %>
-            </select>
-          </div>
-          <div class="mt-6">
-            <div class="form-group">
-              <label class="sidebar-label" for="metrics">Metrics</label>
-              <select name="metrics_key" id="metrics_key" class="sidebar-select">
-                <option {selected?(@current_filters, :metrics_key, "")} value="">All</option>
-                <%= for key <- @metrics_keys do %>
-                  <option {selected?(@current_filters, :metrics_key, key)}>{key}</option>
-                <% end %>
-              </select>
-            </div>
-            <div :if={@current_filters.metrics_key != ""} class="mt-2">
-              <select name="metrics_operator" id="metrics_operator" class="sidebar-select">
-                <option {selected?(@current_filters, :metrics_operator, "gt")} value="gt">Greater Than</option>
-                <option {selected?(@current_filters, :metrics_operator, "lt")} value="lt">Less than</option>
-              </select>
-            </div>
-            <div :if={@current_filters.metrics_key != ""} class="mt-2">
-              <input type="number" name="metrics_value" id="metrics_value" class="sidebar-text-input" value={@current_filters[:metrics_value]} phx-debounce="100" />
-            </div>
-          </div>
-          <div class="mt-6">
-            <div class="form-group">
-              <label class="sidebar-label" for="is_pinned">Pinned</label>
-              <select class="sidebar-select" name="is_pinned" id="is_pinned">
-                <option {selected?(@current_filters, :is_pinned, false)} value="false">All</option>
-                <option {selected?(@current_filters, :is_pinned, true)} value="true">Only pinned devices</option>
-              </select>
-            </div>
-          </div>
-        </form>
-      </div>
-    </div>
-
-    <div class="flex shrink-0 justify-end h-16 px-4 py-4 mb-14 border-t border-zinc-700">
-      <button class="sidebar-button" type="button" phx-click="reset-filters">Reset Filters</button>
-    </div>
-  </div>
-</div>
+<FilterSidebar.render show={@show_filters} current_filters={@current_filters}>
+  <:filter attr="identifier" label="Identifier" type={:text} />
+  <:filter
+    attr="health_status"
+    label="Health Status"
+    type={:select}
+    values={[
+      {"All", ""},
+      {"Unknown", "unknown"},
+      {"Healthy", "healthy"},
+      {"Warning", "warning"},
+      {"Unhealthy", "unhealthy"}
+    ]}
+  />
+  <:filter
+    attr="connection"
+    label="Connection"
+    type={:select}
+    values={[
+      {"All", ""},
+      {"Connected", "connected"},
+      {"Disconnected", "disconnected"},
+      {"Not Seen", "not_seen"}
+    ]}
+  />
+  <:filter
+    attr="connection_type"
+    label="Connection Type"
+    type={:select}
+    values={[
+      {"All", ""},
+      {"Cellular", "cellular"},
+      {"Ethernet", "ethernet"},
+      {"WiFi", "wifi"}
+    ]}
+  />
+
+  <:filter
+    attr="deployment_id"
+    label="Deployment Group"
+    type={:select}
+    values={[
+      {"All", ""},
+      {"No Deployment Group", "-1"}
+      | Enum.map(@deployment_groups, &{&1.name, &1.id})
+    ]}
+  />
+  <:filter
+    attr="updates"
+    label="Firmware Updates"
+    type={:select}
+    values={[
+      {"All", ""},
+      {"Enabled", "enabled"},
+      {"Penalty Box", "penalty-box"},
+      {"Disabled", "disabled"}
+    ]}
+  />
+  <:filter attr="firmware_version" label="Firmware" type={:select} values={[{"All", ""} | Enum.map(@firmware_versions, &{&1, &1})]} />
+  <:filter attr="platform" label="Platform" type={:select} values={[{"All", ""} | Enum.map(@platforms, &{if(&1, do: &1, else: "Unknown"), &1})]} />
+  <:filter attr="tags" label="Tags" type={:text} />
+  <:filter attr="has_no_tags" label="Untagged" type={:select} values={[{"All", "false"}, {"Only untagged", "true"}]} />
+  <:filter attr="alarm_status" label="Alarm Status" type={:select} values={[{"All", ""}, {"Has Alarms", "with"}, {"No Alarms", "without"}]} />
+  <:filter attr="alarm" label="Alarm" type={:select} values={[{"All", ""} | Enum.map(@current_alarms, &{&1, &1})]} />
+  <:filter attr="metrics_key" label="Metrics" type={:select} values={[{"All", ""} | Enum.map(@metrics_keys, &{&1, &1})]} />
+  <:filter attr="metrics_operator" label="Metrics Operator" type={:select} values={[{"Greater Than", "gt"}, {"Less Than", "lt"}]} />
+  <:filter attr="metrics_value" label="Metrics Value" type={:number} />
+  <:filter attr="is_pinned" label="Pinned" type={:select} values={[{"All", "false"}, {"Only pinned devices", "true"}]} />
+</FilterSidebar.render>
 
 <div class="pointer-events-none fixed inset-y-0 right-0 flex max-w-full pl-10 mb-[119px] sm:pl-16 z-40">
   <div class={[
diff --git a/lib/nerves_hub_web/live/devices/index.ex b/lib/nerves_hub_web/live/devices/index.ex
index 6b8a59e6f..1e87d69ee 100644
--- a/lib/nerves_hub_web/live/devices/index.ex
+++ b/lib/nerves_hub_web/live/devices/index.ex
@@ -18,6 +18,7 @@ defmodule NervesHubWeb.Live.Devices.Index do
   alias Phoenix.Socket.Broadcast
 
   alias NervesHubWeb.Components.DeviceUpdateStatus
+  alias NervesHubWeb.Components.FilterSidebar
   alias NervesHubWeb.Components.HealthStatus
   alias NervesHubWeb.Components.Sorting
   alias NervesHubWeb.LayoutView.DateTimeFormat
@@ -33,7 +34,7 @@ defmodule NervesHubWeb.Live.Devices.Index do
     platform: "",
     healthy: "",
     health_status: "",
-    device_id: "",
+    identifier: "",
     tag: "",
     updates: "",
     has_no_tags: false,
@@ -43,7 +44,8 @@ defmodule NervesHubWeb.Live.Devices.Index do
     metrics_operator: "gt",
     metrics_value: "",
     deployment_id: "",
-    is_pinned: false
+    is_pinned: false,
+    search: ""
   }
 
   @filter_types %{
@@ -53,7 +55,7 @@ defmodule NervesHubWeb.Live.Devices.Index do
     platform: :string,
     healthy: :string,
     health_status: :string,
-    device_id: :string,
+    identifier: :string,
     tag: :string,
     updates: :string,
     has_no_tags: :boolean,
@@ -63,7 +65,8 @@ defmodule NervesHubWeb.Live.Devices.Index do
     metrics_operator: :string,
     metrics_value: :string,
     deployment_id: :string,
-    is_pinned: :boolean
+    is_pinned: :boolean,
+    search: :string
   }
 
   @default_page 1
diff --git a/lib/nerves_hub_web/live/devices/index.html.heex b/lib/nerves_hub_web/live/devices/index.html.heex
index 92664e5ea..b6286e5ef 100644
--- a/lib/nerves_hub_web/live/devices/index.html.heex
+++ b/lib/nerves_hub_web/live/devices/index.html.heex
@@ -69,8 +69,8 @@
         <h4 class="color-white mb-2">Filters</h4>
         <form id="filter-form" phx-change="update-filters" class="filter-form device-filters">
           <div class="form-group">
-            <label for="input_id">ID</label>
-            <input type="text" name="device_id" id="input_id" class="form-control" value={@current_filters[:device_id]} phx-debounce="500" />
+            <label for="input_identifier">ID</label>
+            <input type="text" name="identifier" id="input_identifier" class="form-control" value={@current_filters[:identifier]} phx-debounce="500" />
           </div>
           <div class="form-group">
             <label for="input_connection">Connection</label>
diff --git a/test/nerves_hub_web/live/deployment_groups/index_test.exs b/test/nerves_hub_web/live/deployment_groups/index_test.exs
index 3460bfc65..69a32220a 100644
--- a/test/nerves_hub_web/live/deployment_groups/index_test.exs
+++ b/test/nerves_hub_web/live/deployment_groups/index_test.exs
@@ -56,24 +56,129 @@ defmodule NervesHubWeb.Live.DeploymentGroups.IndexTest do
     |> assert_has("td div", text: "0")
   end
 
-  test "filter deployment groups on name", %{
-    conn: conn,
-    org: org,
-    product: product,
-    deployment_group: deployment_group
-  } do
-    conn
-    |> put_session("new_ui", true)
-    |> visit("/org/#{org.name}/#{product.name}/deployment_groups")
-    |> assert_has("h1", text: "Deployment Groups")
-    |> assert_has("a", text: deployment_group.name)
-    |> unwrap(fn view ->
-      render_change(view, "update-filters", %{"name" => deployment_group.name})
-    end)
-    |> assert_has("a", text: deployment_group.name)
-    |> unwrap(fn view ->
-      render_change(view, "update-filters", %{"name" => "blah"})
-    end)
-    |> refute_has("a", text: deployment_group.name)
+  describe "filtering" do
+    test "filter deployment groups on name", %{
+      conn: conn,
+      org: org,
+      product: product,
+      deployment_group: deployment_group
+    } do
+      conn
+      |> put_session("new_ui", true)
+      |> visit("/org/#{org.name}/#{product.name}/deployment_groups")
+      |> assert_has("h1", text: "Deployment Groups")
+      |> assert_has("a", text: deployment_group.name)
+      |> unwrap(fn view ->
+        render_change(view, "update-filters", %{"name" => deployment_group.name})
+      end)
+      |> assert_has("a", text: deployment_group.name)
+      |> unwrap(fn view ->
+        render_change(view, "update-filters", %{"name" => "blah"})
+      end)
+      |> refute_has("a", text: deployment_group.name)
+    end
+
+    test "filter deployment groups on platform", %{
+      conn: conn,
+      org: org,
+      product: product,
+      deployment_group: deployment_group
+    } do
+      platform = deployment_group.firmware.platform
+
+      conn
+      |> put_session("new_ui", true)
+      |> visit("/org/#{org.name}/#{product.name}/deployment_groups")
+      |> assert_has("td", text: platform)
+      |> unwrap(fn view ->
+        render_change(view, "update-filters", %{"platform" => platform})
+      end)
+      |> assert_has("td", text: platform)
+      |> unwrap(fn view ->
+        render_change(view, "update-filters", %{"platform" => "blah"})
+      end)
+      |> refute_has("td", text: platform)
+    end
+
+    test "filter deployment groups on architecture", %{
+      conn: conn,
+      org: org,
+      product: product,
+      deployment_group: deployment_group
+    } do
+      architecture = deployment_group.firmware.architecture
+
+      conn
+      |> put_session("new_ui", true)
+      |> visit("/org/#{org.name}/#{product.name}/deployment_groups")
+      |> assert_has("td", text: architecture)
+      |> unwrap(fn view ->
+        render_change(view, "update-filters", %{"architecture" => architecture})
+      end)
+      |> assert_has("td", text: architecture)
+      |> unwrap(fn view ->
+        render_change(view, "update-filters", %{"architecture" => "blah"})
+      end)
+      |> refute_has("td", text: architecture)
+    end
+
+    test "reset filters", %{
+      conn: conn,
+      org: org,
+      product: product,
+      deployment_group: deployment_group
+    } do
+      conn
+      |> put_session("new_ui", true)
+      |> visit("/org/#{org.name}/#{product.name}/deployment_groups")
+      |> assert_has("a", text: deployment_group.name)
+      |> unwrap(fn view ->
+        render_change(view, "update-filters", %{"name" => "blah"})
+      end)
+      |> refute_has("a", text: deployment_group.name)
+      |> unwrap(fn view ->
+        render_change(view, "reset-filters", %{})
+      end)
+      |> assert_has("a", text: deployment_group.name)
+    end
+  end
+
+  describe "pagination" do
+    test "no pagination when less than 25 deployment groups", %{
+      conn: conn,
+      org: org,
+      product: product
+    } do
+      conn
+      |> put_session("new_ui", true)
+      |> visit("/org/#{org.name}/#{product.name}/deployment_groups")
+      |> refute_has("button", text: "25", timeout: 1000)
+    end
+
+    test "pagination with more than 25 deployment groups", %{
+      conn: conn,
+      org: org,
+      product: product,
+      deployment_group: deployment_group
+    } do
+      for i <- 1..26 do
+        Fixtures.deployment_group_fixture(org, deployment_group.firmware, %{
+          name: "Deployment-group-#{i}"
+        })
+      end
+
+      deployment_groups = ManagedDeployments.get_deployment_groups_by_product(product)
+      [first_deployment_group | _] = deployment_groups |> Enum.sort_by(& &1.name)
+
+      conn
+      |> put_session("new_ui", true)
+      |> visit("/org/#{org.name}/#{product.name}/deployment_groups")
+      |> assert_has("a", text: first_deployment_group.name, timeout: 1000)
+      |> assert_has("button", text: "25", timeout: 1000)
+      |> assert_has("button", text: "50", timeout: 1000)
+      |> assert_has("button", text: "2", timeout: 1000)
+      |> click_button("button[phx-click='paginate'][phx-value-page='2']", "2")
+      |> refute_has("a", text: first_deployment_group.name, timeout: 1000)
+    end
   end
 end
diff --git a/test/nerves_hub_web/live/new_ui/devices/index_test.exs b/test/nerves_hub_web/live/new_ui/devices/index_test.exs
index cbf689112..fe82f6342 100644
--- a/test/nerves_hub_web/live/new_ui/devices/index_test.exs
+++ b/test/nerves_hub_web/live/new_ui/devices/index_test.exs
@@ -2,6 +2,7 @@ defmodule NervesHubWeb.Live.NewUI.Devices.IndexTest do
   use NervesHubWeb.ConnCase.Browser, async: false
   use Mimic
 
+  alias NervesHub.Devices
   alias NervesHub.Fixtures
   alias NervesHub.Repo
 
@@ -89,5 +90,105 @@ defmodule NervesHubWeb.Live.NewUI.Devices.IndexTest do
       |> assert_has("a", text: device.identifier, timeout: 1000)
       |> refute_has("a", text: device2.identifier)
     end
+
+    test "by health status", %{conn: conn, fixture: fixture} do
+      %{
+        device: device,
+        org: org,
+        product: product,
+        firmware: firmware
+      } = fixture
+
+      device2 = Fixtures.device_fixture(org, product, firmware)
+
+      {:ok, _} =
+        Devices.save_device_health(%{
+          "device_id" => device2.id,
+          "data" => %{},
+          "status" => :healthy,
+          "status_reasons" => %{}
+        })
+
+      conn
+      |> put_session("new_ui", true)
+      |> visit("/org/#{org.name}/#{product.name}/devices")
+      |> assert_has("a", text: device.identifier, timeout: 1000)
+      |> assert_has("a", text: device2.identifier)
+      |> select("Health Status", option: "Unknown")
+      # This also asserts that devices with nil health status are listed as unknown
+      |> assert_has("a", text: device.identifier, timeout: 1000)
+      |> refute_has("a", text: device2.identifier)
+      |> select("Health Status", option: "Healthy")
+      |> assert_has("a", text: device2.identifier, timeout: 1000)
+      |> refute_has("a", text: device.identifier)
+    end
+  end
+
+  describe "pagination" do
+    test "no pagination when less than 25 devices", %{conn: conn, fixture: fixture} do
+      %{
+        org: org,
+        product: product
+      } = fixture
+
+      conn
+      |> put_session("new_ui", true)
+      |> visit("/org/#{org.name}/#{product.name}/devices")
+      |> refute_has("button", text: "25", timeout: 1000)
+    end
+
+    test "pagination with more than 25 devices", %{conn: conn, fixture: fixture} do
+      %{
+        org: org,
+        product: product,
+        firmware: firmware
+      } = fixture
+
+      for _i <- 1..26 do
+        Fixtures.device_fixture(org, product, firmware)
+      end
+
+      devices = Devices.get_devices_by_org_id_and_product_id(org.id, product.id)
+      [first_device | _] = devices |> Enum.sort_by(& &1.identifier)
+
+      conn
+      |> put_session("new_ui", true)
+      |> visit("/org/#{org.name}/#{product.name}/devices")
+      |> assert_has("a", text: first_device.identifier, timeout: 1000)
+      |> assert_has("button", text: "25", timeout: 1000)
+      |> refute_has("button", text: "50", timeout: 1000)
+      |> assert_has("button", text: "2", timeout: 1000)
+      |> click_button("button[phx-click='paginate'][phx-value-page='2']", "2")
+      |> refute_has("a", text: first_device.identifier, timeout: 1000)
+      |> click_button("button[phx-click='paginate'][phx-value-page='1']", "1")
+      |> assert_has("a", text: first_device.identifier, timeout: 1000)
+    end
+
+    test "pagination with more than 50 devices", %{conn: conn, fixture: fixture} do
+      %{
+        org: org,
+        product: product,
+        firmware: firmware
+      } = fixture
+
+      for _i <- 1..51 do
+        Fixtures.device_fixture(org, product, firmware)
+      end
+
+      devices = Devices.get_devices_by_org_id_and_product_id(org.id, product.id)
+      [first_device | _] = devices |> Enum.sort_by(& &1.identifier)
+
+      conn
+      |> put_session("new_ui", true)
+      |> visit("/org/#{org.name}/#{product.name}/devices")
+      |> assert_has("a", text: first_device.identifier, timeout: 1000)
+      |> assert_has("button", text: "25", timeout: 1000)
+      |> assert_has("button", text: "50", timeout: 1000)
+      |> assert_has("button", text: "2", timeout: 1000)
+      |> click_button("button[phx-click='paginate'][phx-value-page='2']", "2")
+      |> refute_has("a", text: first_device.identifier, timeout: 1000)
+      |> click_button("button[phx-click='paginate'][phx-value-page='3']", "3")
+      |> refute_has("a", text: first_device.identifier, timeout: 1000)
+    end
   end
 end


Reviews:
user: login: nshoes
body: Nice work, this is dope. Some thoughts after testing locally:
- I don't think having a search field inside and outside the filter drawer makes sense. I'd vote to remove the one outside, happy to hear others input.
- In the filter drawer, we should be explicit with _what_ the search is... searching 😆 . Right now it's `name` for both devices and deployment groups, and the input label should reflect that.
- Thoughts on adding an event listener for closing the filter drawer with the escape key? I believe it's a common UI pattern and I enjoy it.
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-23T15:41:35Z
user: login: joshk
body: This is a solid direction.

If we want to take this one step further you could use https://hexdocs.pm/phoenix_live_view/Phoenix.LiveView.html#attach_hook/4 and move the implementation of the `handle_event`s to the function module.  We can also add this later if preferred.
state: DISMISSED
author_association: COLLABORATOR
submitted_at: 2025-04-23T21:50:56Z
user: login: nshoes
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-23T22:29:01Z
user: login: elinol
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-24T05:57:33Z
user: login: nshoes
body: Awesome! I love the general search, that's extremely helpful. One tiny nit from me and this is good to :shipit: 
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-04-24T14:56:35Z

Review comments:
diff_hunk: @@ -146,4 +149,30 @@
   <% end %>
 </div>
 
-<Pager.render_with_page_sizes pager={@pager_meta} page_sizes={[25, 50, 100]} />
+<FilterSidebar.render
+  show={@show_filters}
+  current_filters={@current_filters}
+  filter_options={
+    %{
+      name: %{
+        label: "Search",
+        type: :text
+      },
+      platform: %{
+        label: "Platform",
+        type: :select,
+        values: [{"All", ""} | Enum.map(@platforms, &{if(&1, do: &1, else: "Unknown"), &1})]
+      },
+      architecture: %{
+        label: "Architecture",
+        type: :select,
+        values: [{"All", ""} | Enum.map(@architectures, &{if(&1, do: &1, else: "Unknown"), &1})]
+      }
+    }
+  }
+  on_toggle="toggle-filters"
+  on_update="update-filters"
+  on_reset="reset-filters"
path: lib/nerves_hub_web/live/deployment_groups/index-new.html.heex
user: login: joshk
body: These three options seem to be the same between usage. I'd suggest we set a default value for them and thus they don't need to be specified each time.
created_at: 2025-04-23T21:38:12Z
author_association: COLLABORATOR
diff_hunk: @@ -146,4 +149,30 @@
   <% end %>
 </div>
 
-<Pager.render_with_page_sizes pager={@pager_meta} page_sizes={[25, 50, 100]} />
+<FilterSidebar.render
+  show={@show_filters}
+  current_filters={@current_filters}
+  filter_options={
+    %{
+      name: %{
+        label: "Search",
+        type: :text
+      },
+      platform: %{
+        label: "Platform",
+        type: :select,
+        values: [{"All", ""} | Enum.map(@platforms, &{if(&1, do: &1, else: "Unknown"), &1})]
+      },
+      architecture: %{
+        label: "Architecture",
+        type: :select,
+        values: [{"All", ""} | Enum.map(@architectures, &{if(&1, do: &1, else: "Unknown"), &1})]
+      }
+    }
+  }
path: lib/nerves_hub_web/live/deployment_groups/index-new.html.heex
user: login: joshk
body: While these filter options aren't extreme, the one used on the device list is massive and complex and an eye sore.

Instead, I'd recommend we use slots, which would be cleaner and provide better documentation.

- https://hexdocs.pm/phoenix_live_view/Phoenix.Component.html#module-slots
- https://hexdocs.pm/phoenix_live_view/Phoenix.Component.html#module-slot-attributes
- https://github.com/nerves-hub/nerves_hub_web/blob/main/lib/nerves_hub_web/components/core_components.ex#L559-L561
- https://github.com/nerves-hub/nerves_hub_web/blob/main/lib/nerves_hub_web/components/core_components.ex#L576

This is what it would possibly look like, which I think is a nicer API.

```elixir
<FilterSidebar.render show={@show_filters} current_filters={@current_filters}
  <:filter attr="name" label="Search" type={:text} />
  <:filter attr="platform" label="Platform" type={:select} values={[{"All", ""} | Enum.map(@platforms, &{if(&1, do: &1, else: "Unknown"), &1})]} />
  <:filter attr="architecture" label="Architecture" type={:select} values={[{"All", ""} | Enum.map(@architectures, &{if(&1, do: &1, else: "Unknown"), &1})]} />
</FilterSidebar.render >
```
created_at: 2025-04-23T21:46:52Z
author_association: COLLABORATOR
diff_hunk: @@ -146,4 +149,30 @@
   <% end %>
 </div>
 
-<Pager.render_with_page_sizes pager={@pager_meta} page_sizes={[25, 50, 100]} />
+<FilterSidebar.render
+  show={@show_filters}
+  current_filters={@current_filters}
+  filter_options={
+    %{
+      name: %{
+        label: "Search",
+        type: :text
+      },
+      platform: %{
+        label: "Platform",
+        type: :select,
+        values: [{"All", ""} | Enum.map(@platforms, &{if(&1, do: &1, else: "Unknown"), &1})]
+      },
+      architecture: %{
+        label: "Architecture",
+        type: :select,
+        values: [{"All", ""} | Enum.map(@architectures, &{if(&1, do: &1, else: "Unknown"), &1})]
+      }
+    }
+  }
path: lib/nerves_hub_web/live/deployment_groups/index-new.html.heex
user: login: nshoes
body: 🤤 🤤 🤤 🤤 
created_at: 2025-04-23T22:29:01Z
author_association: CONTRIBUTOR
diff_hunk: @@ -146,4 +149,30 @@
   <% end %>
 </div>
 
-<Pager.render_with_page_sizes pager={@pager_meta} page_sizes={[25, 50, 100]} />
+<FilterSidebar.render
+  show={@show_filters}
+  current_filters={@current_filters}
+  filter_options={
+    %{
+      name: %{
+        label: "Search",
+        type: :text
+      },
+      platform: %{
+        label: "Platform",
+        type: :select,
+        values: [{"All", ""} | Enum.map(@platforms, &{if(&1, do: &1, else: "Unknown"), &1})]
+      },
+      architecture: %{
+        label: "Architecture",
+        type: :select,
+        values: [{"All", ""} | Enum.map(@architectures, &{if(&1, do: &1, else: "Unknown"), &1})]
+      }
+    }
+  }
path: lib/nerves_hub_web/live/deployment_groups/index-new.html.heex
user: login: elinol
body: oh I like that!
created_at: 2025-04-24T05:57:33Z
author_association: CONTRIBUTOR
diff_hunk: @@ -323,175 +323,75 @@
   </.async_result>
 </div>
 
-<div class="pointer-events-none fixed inset-y-0 right-0 flex max-w-full pl-10 sm:pl-16 z-40">
-  <div class={[
-    "pointer-events-auto w-screen max-w-80 mt-[55px] flex h-full flex-col border-t border-l border-zinc-700 bg-base-900 shadow-filter-slider transition-transform",
-    !@show_filters && "translate-x-full",
-    !@show_filters && "invisible"
-  ]}>
-    <div class="h-0 flex-1 overflow-y-auto">
-      <div class="flex items-center h-14 px-4 py-3 border-b border-zinc-700">
-        <h4 class="text-base font-semibold">Filters</h4>
-
-        <button class="ml-auto p-1.5" type="button" phx-click="toggle-filters" phx-value-toggle={to_string(@show_filters)}>
-          <svg xmlns="http://www.w3.org/2000/svg" class="w-5 h-5" viewBox="0 0 20 20" fill="none">
-            <path
-              d="M10.0002 9.99998L5.8335 5.83331M10.0002 9.99998L14.1668 14.1666M10.0002 9.99998L14.1668 5.83331M10.0002 9.99998L5.8335 14.1666"
-              stroke="#A1A1AA"
-              stroke-width="1.2"
-              stroke-linecap="round"
-              stroke-linejoin="round"
-            />
-          </svg>
-        </button>
-      </div>
-
-      <div class="flex flex-1 flex-col pb-4">
-        <form id="filter-form" class="px-4 grow" phx-change="update-filters">
-          <div class="sidebar-search mt-6">
-            <input type="text" name="device_id" id="device_search" placeholder="Search..." value={@current_filters[:device_id]} phx-debounce="500" />
-            <svg class="ml-auto" xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 20 20" fill="none">
-              <path
-                d="M16.6668 16.6666L13.2916 13.2914M13.2916 13.2914C14.3472 12.2358 15.0002 10.7775 15.0002 9.16665C15.0002 5.94499 12.3885 3.33331 9.16683 3.33331C5.94517 3.33331 3.3335 5.94499 3.3335 9.16665C3.3335 12.3883 5.94517 15 9.16683 15C10.7777 15 12.236 14.3471 13.2916 13.2914Z"
-                stroke="#A1A1AA"
-                stroke-width="1.2"
-                stroke-linecap="round"
-                stroke-linejoin="round"
-              />
-            </svg>
-          </div>
-          <div class="mt-6">
-            <label class="sidebar-label" for="input_health_status">Health status</label>
-            <select class="sidebar-select" name="health_status" id="input_health_status">
-              <option {selected?(@current_filters, :health_status, "")} value="">All</option>
-              <option {selected?(@current_filters, :health_status, "unknown")} value="unknown">Unknown</option>
-              <option {selected?(@current_filters, :health_status, "healthy")} value="healthy">Healthy</option>
-              <option {selected?(@current_filters, :health_status, "warning")} value="warning">Warning</option>
-              <option {selected?(@current_filters, :health_status, "unhealthy")} value="unhealthy">Unhealthy</option>
-            </select>
-          </div>
-          <div class="mt-6">
-            <label class="sidebar-label" for="input_connection">Connection</label>
-            <select class="sidebar-select" name="connection" id="input_connection">
-              <option {selected?(@current_filters, :connection, "")} value="">All</option>
-              <option {selected?(@current_filters, :connection, "connected")} value="connected">Connected</option>
-              <option {selected?(@current_filters, :connection, "disconnected")} value="disconnected">Disconnected</option>
-              <option {selected?(@current_filters, :connection, "not_seen")} value="not_seen">Not Seen</option>
-            </select>
-          </div>
-          <div class="mt-6">
-            <label class="sidebar-label" for="connection_type">Connection Type</label>
-            <select class="sidebar-select" name="connection_type" id="connection_type">
-              <option {selected?(@current_filters, :connection_type, "")} value="">All</option>
-              <option {selected?(@current_filters, :connection_type, "cellular")} value="cellular">Cellular</option>
-              <option {selected?(@current_filters, :connection_type, "ethernet")} value="ethernet">Ethernet</option>
-              <option {selected?(@current_filters, :connection_type, "wifi")} value="wifi">WiFi</option>
-            </select>
-          </div>
-          <div class="mt-6">
-            <label class="sidebar-label" for="deployment_id">Deployment Group</label>
-            <select class="sidebar-select" name="deployment_id" id="deployment_id">
-              <option {selected?(@current_filters, :deployment_id, "")} value="">All</option>
-              <option {selected?(@current_filters, :deployment_id, nil)} value="-1">No Deployment Group</option>
-              <%= for deployment_group <- @deployment_groups do %>
-                <option {selected?(@current_filters, :deployment_id, deployment_group.id)} value={deployment_group.id}>{deployment_group.name}</option>
-              <% end %>
-            </select>
-          </div>
-          <div class="mt-6">
-            <label class="sidebar-label" for="input_health">Firmware Updates</label>
-            <select class="sidebar-select" name="updates" id="input_health">
-              <option {selected?(@current_filters, :updates, "")} value="">All</option>
-              <option {selected?(@current_filters, :updates, "enabled")} value="enabled">Enabled</option>
-              <option {selected?(@current_filters, :updates, "penalty-box")} value="penalty-box">Penalty Box</option>
-              <option {selected?(@current_filters, :updates, "disabled")} value="disabled">Disabled</option>
-            </select>
-          </div>
-          <div class="mt-6">
-            <label class="sidebar-label" for="input_firmware">Firmware</label>
-            <select class="sidebar-select" name="firmware_version" id="input_firmware">
-              <option {selected?(@current_filters, :firmware_version, "")} value="">All</option>
-              <%= for version <- @firmware_versions do %>
-                <option {selected?(@current_filters, :firmware_version, version)}>{version}</option>
-              <% end %>
-            </select>
-          </div>
-          <div class="mt-6">
-            <label class="sidebar-label" for="input_platform">Platform</label>
-            <select class="sidebar-select" name="platform" id="input_platform">
-              <option {selected?(@current_filters, :platform, "")} value="">All</option>
-              <%= for platform <- @platforms do %>
-                <option {selected?(@current_filters, :platform, platform)} value={platform}>{if platform, do: platform, else: "Unknown"}</option>
-              <% end %>
-            </select>
-          </div>
-          <div class="mt-6">
-            <label class="sidebar-label" for="input_tags">Tags</label>
-            <input class="sidebar-text-input" type="text" name="tag" id="input_tags" value={@current_filters[:tag]} phx-debounce="500" />
-          </div>
-          <div class="mt-6">
-            <label class="sidebar-label" for="has_no_tags">Untagged</label>
-            <select class="sidebar-select" name="has_no_tags" id="has_no_tags">
-              <option {selected?(@current_filters, :has_no_tags, false)} value="false">All</option>
-              <option {selected?(@current_filters, :has_no_tags, true)} value="true">Only untagged</option>
-            </select>
-          </div>
-
-          <div class="mt-6">
-            <label class="sidebar-label" for="alarm_status">Alarm Status</label>
-            <select class="sidebar-select" name="alarm_status" id="alarm_status">
-              <option {selected?(@current_filters, :alarm_status, "")} value="">All</option>
-              <option {selected?(@current_filters, :alarm_status, "with")} value="with">Has Alarms</option>
-              <option {selected?(@current_filters, :alarm_status, "without")} value="without">No alarms</option>
-            </select>
-          </div>
-          <div class="mt-6">
-            <label class="sidebar-label" for="alarm">Alarm</label>
-            <select class="sidebar-select" name="alarm" id="alarm">
-              <option {selected?(@current_filters, :alarm, "")} value="">All</option>
-              <%= for alarm <- @current_alarms do %>
-                <option {selected?(@current_filters, :alarm, alarm)}>{alarm}</option>
-              <% end %>
-            </select>
-          </div>
-          <div class="mt-6">
-            <div class="form-group">
-              <label class="sidebar-label" for="metrics">Metrics</label>
-              <select name="metrics_key" id="metrics_key" class="sidebar-select">
-                <option {selected?(@current_filters, :metrics_key, "")} value="">All</option>
-                <%= for key <- @metrics_keys do %>
-                  <option {selected?(@current_filters, :metrics_key, key)}>{key}</option>
-                <% end %>
-              </select>
-            </div>
-            <div :if={@current_filters.metrics_key != ""} class="mt-2">
-              <select name="metrics_operator" id="metrics_operator" class="sidebar-select">
-                <option {selected?(@current_filters, :metrics_operator, "gt")} value="gt">Greater Than</option>
-                <option {selected?(@current_filters, :metrics_operator, "lt")} value="lt">Less than</option>
-              </select>
-            </div>
-            <div :if={@current_filters.metrics_key != ""} class="mt-2">
-              <input type="number" name="metrics_value" id="metrics_value" class="sidebar-text-input" value={@current_filters[:metrics_value]} phx-debounce="100" />
-            </div>
-          </div>
-          <div class="mt-6">
-            <div class="form-group">
-              <label class="sidebar-label" for="is_pinned">Pinned</label>
-              <select class="sidebar-select" name="is_pinned" id="is_pinned">
-                <option {selected?(@current_filters, :is_pinned, false)} value="false">All</option>
-                <option {selected?(@current_filters, :is_pinned, true)} value="true">Only pinned devices</option>
-              </select>
-            </div>
-          </div>
-        </form>
-      </div>
-    </div>
-
-    <div class="flex shrink-0 justify-end h-16 px-4 py-4 mb-14 border-t border-zinc-700">
-      <button class="sidebar-button" type="button" phx-click="reset-filters">Reset Filters</button>
-    </div>
-  </div>
-</div>
+<FilterSidebar.render show={@show_filters} current_filters={@current_filters}>
+  <:filter attr="identifier" label="Device Identifier" type={:text} />
path: lib/nerves_hub_web/live/devices/index-new.html.heex
user: login: nshoes
body: ```suggestion
  <:filter attr="identifier" label="Identifier" type={:text} />
```
created_at: 2025-04-24T14:55:43Z
author_association: CONTRIBUTOR

PR comments:
user: login: elinol
created_at: 2025-04-23T10:15:50Z
body: @joshk @nshoes I will add some tests for pagination (which is missing all over I think) and maybe a couple on device filtering in new UI, but besides that this is ready for reviews.
author_association: CONTRIBUTOR
user: login: elinol
created_at: 2025-04-24T06:05:49Z
body: > Nice work, this is dope. Some thoughts after testing locally:
> 
> * I don't think having a search field inside and outside the filter drawer makes sense. I'd vote to remove the one outside, happy to hear others input.
> * In the filter drawer, we should be explicit with _what_ the search is... searching 😆 . Right now it's `name` for both devices and deployment groups, and the input label should reflect that.
> * Thoughts on adding an event listener for closing the filter drawer with the escape key? I believe it's a common UI pattern and I enjoy it.

I agree on the search input! If we keep it outside the filters I think it should be a search for everything and not just the name/identifier, typing rpi4 should give you all items on that platform for example. And inside the filters it should be labeled name or identifier like you said @nshoes 

I'm all for closing the drawer with esc or clicking outside or something, it frustrating to have to scroll the way back up when filtering devices.
author_association: CONTRIBUTOR
user: login: elinol
created_at: 2025-04-24T11:13:42Z
body: I added a more general search filter query to the "outside" search input, if you like it I'll write tests for it, otherwise I'll just revert the commit.
Note that the search input inside filter drawer is labeled `name` or `identifier` and will only filter on those.
@nshoes @joshk 

<img width="1403" alt="image" src="https://github.com/user-attachments/assets/b7ad32b5-1f3a-4c1f-a94c-68228c7d1bfe" />
<img width="1407" alt="image" src="https://github.com/user-attachments/assets/6eb83499-5e03-4501-8e1f-efd5603b3bbd" />

author_association: CONTRIBUTOR