feat: implement saved filter loading via API endpoint

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.
This commit is contained in:
2026-03-21 23:04:05 -04:00
parent c3a98fb067
commit 0cfd0bad52
+94 -11
View File
@@ -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<string, string> =
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 = `
<input type="hidden" name="limit" value="50"/>
<input type="hidden" name="offset" value="0"/>
`;
// 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");