diff --git a/assets/js/hooks/apps_search.js b/assets/js/hooks/apps_search.js new file mode 100644 index 00000000000..42274a8f1cf --- /dev/null +++ b/assets/js/hooks/apps_search.js @@ -0,0 +1,86 @@ +/** + * A hook for client-side app filtering to avoid server round-trips. + * + * This hook filters app cards in real-time based on search input and folder selection. + */ +const AppsSearch = { + mounted() { + this.searchInput = this.el.querySelector("#search-app"); + this.folderSelect = this.el.querySelector("#select-app-folder"); + this.noResultsMessage = this.el.querySelector("[data-no-results]"); + + this.searchInput.addEventListener("input", () => { + this.applyFilters(); + }); + + if (this.folderSelect) { + this.folderSelect.addEventListener("change", () => { + this.applyFilters(); + }); + } + + this.refreshElements(); + this.applyFilters(); + }, + + updated() { + this.refreshElements(); + this.applyFilters(); + }, + + refreshElements() { + this.appCards = Array.from(this.el.querySelectorAll("[data-app-card]")); + this.appGroups = Array.from(this.el.querySelectorAll("[data-app-group]")); + }, + + applyFilters() { + const searchTerm = this.searchInput.value.toLowerCase().trim(); + const selectedFolder = this.folderSelect ? this.folderSelect.value : ""; + + let visibleCount = 0; + + this.appCards.forEach((card) => { + const appName = card.dataset.appName.toLowerCase(); + const appSlug = card.dataset.appSlug.toLowerCase(); + const appFolderId = card.dataset.appFolderId || ""; + + const matchesSearch = + searchTerm === "" || + appName.includes(searchTerm) || + appSlug.includes(searchTerm); + + const matchesFolder = + selectedFolder === "" || appFolderId === selectedFolder; + + if (matchesSearch && matchesFolder) { + card.style.display = ""; + visibleCount++; + } else { + card.style.display = "none"; + } + }); + + this.appGroups.forEach((group) => { + const visibleCardsInGroup = group.querySelectorAll( + "[data-app-card]:not([style*='display: none'])", + ); + const count = visibleCardsInGroup.length; + + if (count === 0) { + group.style.display = "none"; + } else { + group.style.display = ""; + const countElement = group.querySelector("[data-group-count]"); + if (countElement) { + countElement.textContent = `(${count})`; + } + } + }); + + if (this.noResultsMessage) { + this.noResultsMessage.style.display = visibleCount === 0 ? "" : "none"; + } + }, +}; + +export default AppsSearch; diff --git a/assets/js/hooks/index.js b/assets/js/hooks/index.js index c33c410dc35..64810f7cea6 100644 --- a/assets/js/hooks/index.js +++ b/assets/js/hooks/index.js @@ -1,4 +1,5 @@ import AppAuth from "./app_auth"; +import AppsSearch from "./apps_search"; import AudioInput from "./audio_input"; import Cell from "./cell"; import CellEditor from "./cell_editor"; @@ -25,6 +26,7 @@ import CustomViewSettings from "./custom_view_settings"; export default { AppAuth, + AppsSearch, AudioInput, Cell, CellEditor, diff --git a/lib/livebook_web/live/apps_live.ex b/lib/livebook_web/live/apps_live.ex index 4bca26031b2..11f5519623b 100644 --- a/lib/livebook_web/live/apps_live.ex +++ b/lib/livebook_web/live/apps_live.ex @@ -19,15 +19,13 @@ defmodule LivebookWeb.AppsLive do {:ok, socket |> assign( - search_term: "", - selected_app_folder: "", apps: Livebook.Apps.list_authorized_apps(socket.assigns.current_user), empty_apps_path?: empty_apps_path?, logout_enabled?: Livebook.Config.logout_enabled?() and socket.assigns.current_user.email != nil ) |> load_app_folders() - |> apply_filters()} + |> group_apps()} end @impl true @@ -70,7 +68,7 @@ defmodule LivebookWeb.AppsLive do <%= if @apps != [] do %> -
+
@@ -82,28 +80,25 @@ defmodule LivebookWeb.AppsLive do id="search-app" name="search_term" placeholder="Search apps..." - value={@search_term} - phx-keyup="search" - phx-debounce="300" + value="" class="w-full pl-10 pr-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-all" />
-
- <.select_field - id="select-app-folder" - name="app_folder" - prompt="Select a folder..." - value={@selected_app_folder} - options={@app_folder_options} - /> -
+ <.select_field + id="select-app-folder" + name="app_folder" + prompt="Select a folder..." + value="" + options={@app_folder_options} + />