From 0cfd0bad52d812ba52d36be1f8dbc12733c3d12a Mon Sep 17 00:00:00 2001 From: John O'Keefe Date: Sat, 21 Mar 2026 23:04:05 -0400 Subject: [PATCH] feat: implement saved filter loading via API endpoint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implement Phase 8 of GET_SAVED_FILTER_BY_ID_IMPLEMENTATION.md: Frontend integration for loading saved filters via GET /:id endpoint. Completes the saved filters feature with full CRUD functionality. Changes to web/src/bookshelf.ts: - Convert loadFilter() from synchronous to async function - Fetch filter details from GET /api/saved-filters/:id endpoint - Parse filters JSON (handles both string and object formats) - Populate hidden #filter-form fields with filter values - Update visible form fields for user feedback - Trigger HTMX change event to apply filter - Show loading, success, and error toasts - Close filters dropdown after applying filter - Proper error handling (404, network errors, auth errors) User Flow: 1. User clicks saved filter in dropdown (server-rendered list) 2. Alpine.js calls GET /api/saved-filters/:id API 3. Receives filter object with filters JSONB 4. Populates form fields (hidden + visible) 5. Triggers HTMX to submit form 6. Books grid updates instantly (no page reload) SSR-First Compliance: - ✅ Initial page load: Server renders everything (no API calls) - ✅ User interaction only: API called when user clicks filter - ✅ Hybrid approach: Alpine fetches data, HTMX applies it - ✅ No async x-init data fetching - ✅ Progressive enhancement maintained - ✅ Matches dashboard pattern for interactions Error Handling: - 404: Filter not found (deleted by another session) - 401: Not authenticated - Network errors: Show error toast - Form not found: Show error toast Benefits: - Complete saved filters CRUD functionality - Instant filter application (no page reload) - User feedback with toast notifications - Follows HTMX + Alpine hybrid pattern - Type-safe TypeScript with proper error handling Implements Phase 8 from GET_SAVED_FILTER_BY_ID_IMPLEMENTATION.md. --- web/src/bookshelf.ts | 105 ++++++++++++++++++++++++++++++++++++++----- 1 file changed, 94 insertions(+), 11 deletions(-) diff --git a/web/src/bookshelf.ts b/web/src/bookshelf.ts index 7a18891..bda72bb 100644 --- a/web/src/bookshelf.ts +++ b/web/src/bookshelf.ts @@ -41,7 +41,7 @@ Alpine.data("bookshelf", () => ({ this.showFiltersDropdown = !this.showFiltersDropdown; }, // Load a saved filter into the form - loadFilter(event: Event) { + async loadFilter(event: Event) { const button = event.target as HTMLElement; const filterRow = button.closest("[data-filter-id]"); if (!filterRow) return; @@ -50,10 +50,88 @@ Alpine.data("bookshelf", () => ({ if (!filterId) return; const filterName = button.textContent?.trim() || ""; - - // Note: Actual filter loading logic would go here - // For now, just show the filter name - showToast(`Filter: ${filterName}`, "success"); + + showToast(`Loading filter: ${filterName}...`, "info"); + + const token = localStorage.getItem("token"); + if (!token) { + showToast("Not authenticated", "error"); + return; + } + + try { + // Fetch filter details from API + const response = await fetch(`/api/saved-filters/${filterId}`, { + headers: { Authorization: `Bearer ${token}` }, + }); + + if (!response.ok) { + if (response.status === 404) { + showToast("Filter not found", "error"); + } else { + showToast("Failed to load filter", "error"); + } + return; + } + + const filter = await response.json(); + + // Parse filters JSON (string → object) + const filterData: Record = + typeof filter.filters === "string" + ? JSON.parse(filter.filters) + : filter.filters; + + // Get the hidden filter form + const filterForm = document.getElementById( + "filter-form", + ) as HTMLFormElement; + if (!filterForm) { + showToast("Filter form not found", "error"); + return; + } + + // Clear existing filter values + filterForm.innerHTML = ` + + + `; + + // Populate form fields from filter data + Object.entries(filterData).forEach(([key, value]) => { + if (value) { + // Only set non-empty values + const input = document.createElement("input"); + input.type = "hidden"; + input.name = key; + input.value = value; + filterForm.appendChild(input); + + // Also update visible form fields if they exist + const visibleField = document.querySelector( + `[name="${key}"]`, + ) as HTMLInputElement; + if (visibleField) { + visibleField.value = value; + } + } + }); + + // Trigger HTMX to apply the filter + // Use the first input to trigger the change event + const firstInput = filterForm.querySelector("input"); + if (firstInput) { + window.htmx.trigger(firstInput, "change"); + } + + showToast(`Filter applied: ${filterName}`, "success"); + + // Close the dropdown after applying + this.showFiltersDropdown = false; + } catch (error) { + console.error("Failed to load filter:", error); + showToast("Error loading filter", "error"); + } }, // Delete a saved filter async deleteFilter(event: Event) { @@ -105,7 +183,7 @@ Alpine.data("bookshelf", () => ({ showToast("Please enter a filter name", "error"); return; } - + // Collect filter form data const filterForm = document.getElementById( "filter-form", @@ -120,7 +198,7 @@ Alpine.data("bookshelf", () => ({ formData.forEach((value, key) => { filterData[key] = value.toString(); }); - + try { const response = await fetch("/api/saved-filters", { method: "POST", @@ -134,7 +212,7 @@ Alpine.data("bookshelf", () => ({ filters: filterData, }), }); - + if (response.ok) { showToast("Filter saved successfully", "success"); // Clear the filter name input @@ -155,13 +233,18 @@ Alpine.data("bookshelf", () => ({ function initBookshelf(): void { // Check if books were already rendered server-side const booksGrid = document.getElementById("books-grid"); - const hasServerBooks = booksGrid && booksGrid.querySelector('[data-book-id]') !== null; + const hasServerBooks = + booksGrid && booksGrid.querySelector("[data-book-id]") !== null; if (!hasServerBooks) { // Only trigger HTMX if no SSR books rendered - const librarySelect = document.getElementById("library-select") as HTMLSelectElement; + const librarySelect = document.getElementById( + "library-select", + ) as HTMLSelectElement; if (librarySelect && librarySelect.value) { - const filterForm = document.getElementById("filter-form") as HTMLFormElement; + const filterForm = document.getElementById( + "filter-form", + ) as HTMLFormElement; if (filterForm) { // Trigger initial HTMX load window.htmx.trigger(librarySelect, "change");