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:
+90
-7
@@ -41,7 +41,7 @@ Alpine.data("bookshelf", () => ({
|
|||||||
this.showFiltersDropdown = !this.showFiltersDropdown;
|
this.showFiltersDropdown = !this.showFiltersDropdown;
|
||||||
},
|
},
|
||||||
// Load a saved filter into the form
|
// Load a saved filter into the form
|
||||||
loadFilter(event: Event) {
|
async loadFilter(event: Event) {
|
||||||
const button = event.target as HTMLElement;
|
const button = event.target as HTMLElement;
|
||||||
const filterRow = button.closest("[data-filter-id]");
|
const filterRow = button.closest("[data-filter-id]");
|
||||||
if (!filterRow) return;
|
if (!filterRow) return;
|
||||||
@@ -51,9 +51,87 @@ Alpine.data("bookshelf", () => ({
|
|||||||
|
|
||||||
const filterName = button.textContent?.trim() || "";
|
const filterName = button.textContent?.trim() || "";
|
||||||
|
|
||||||
// Note: Actual filter loading logic would go here
|
showToast(`Loading filter: ${filterName}...`, "info");
|
||||||
// For now, just show the filter name
|
|
||||||
showToast(`Filter: ${filterName}`, "success");
|
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
|
// Delete a saved filter
|
||||||
async deleteFilter(event: Event) {
|
async deleteFilter(event: Event) {
|
||||||
@@ -155,13 +233,18 @@ Alpine.data("bookshelf", () => ({
|
|||||||
function initBookshelf(): void {
|
function initBookshelf(): void {
|
||||||
// Check if books were already rendered server-side
|
// Check if books were already rendered server-side
|
||||||
const booksGrid = document.getElementById("books-grid");
|
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) {
|
if (!hasServerBooks) {
|
||||||
// Only trigger HTMX if no SSR books rendered
|
// 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) {
|
if (librarySelect && librarySelect.value) {
|
||||||
const filterForm = document.getElementById("filter-form") as HTMLFormElement;
|
const filterForm = document.getElementById(
|
||||||
|
"filter-form",
|
||||||
|
) as HTMLFormElement;
|
||||||
if (filterForm) {
|
if (filterForm) {
|
||||||
// Trigger initial HTMX load
|
// Trigger initial HTMX load
|
||||||
window.htmx.trigger(librarySelect, "change");
|
window.htmx.trigger(librarySelect, "change");
|
||||||
|
|||||||
Reference in New Issue
Block a user