fix: refactor filter loading and clearing to prevent stale field data

Major refactoring of bookshelf filter logic:

Filter loading improvements:
- Add clearFormWithoutSubmit() helper to reset form without submission
- Refactor clearFilters() to reuse clearFormWithoutSubmit() helper
  Reduces code duplication from 38 lines to 8 lines
- Update loadFilter() to call clearFormWithoutSubmit() before populating
  This ensures all stale data from previous filter is cleared
- Move has_cover handling before empty value check
  Fixes issue where has_cover=false was being skipped
- Remove automatic HTMX trigger note from cycleHasCover()

Fixed issues:
- Author field staying populated when switching to filter without author
- has_cover tristate button not updating when switching between filters
- has_cover button not updating from "Has Cover" to "Any" when loading filter without has_cover
- General stale data retention when loading different saved filters

HTMX event handling:
- Add event listener in initBookshelf() for htmx:afterSwap events
- Listens on #saved-filters-list element (the swap target)
- Calls afterFilterSave() to close modal and show success toast
- Properly handles Alpine component state access

All filter operations now work correctly with proper state management
and no visual artifacts from previous filters.
This commit is contained in:
2026-03-28 00:46:14 -04:00
parent 533760e0d8
commit 486214d8a5
+143 -84
View File
@@ -4,11 +4,65 @@ import { Alpine } from "./alpine";
import { showToast } from "./toast";
function clearFilters(): void {
// Clear all form state (reuses helper)
clearFormWithoutSubmit();
// Trigger form submit with cleared filters
const filterForm = document.getElementById("filter-form") as HTMLFormElement;
if (!filterForm) return;
// Reset all form fields
window.htmx.trigger(filterForm, "submit");
}
// Remove dynamically created hidden inputs from filter form
// Preserves: limit, offset, has_cover (static/managed inputs)
function cleanupDynamicHiddenInputs(): void {
const filterForm = document.getElementById("filter-form") as HTMLFormElement;
if (!filterForm) return;
// Names of inputs to preserve (static or Alpine-managed)
const preserveNames = ["limit", "offset", "has_cover"];
// Find all hidden inputs
const hiddenInputs = filterForm.querySelectorAll('input[type="hidden"]');
hiddenInputs.forEach((input) => {
const name = (input as HTMLInputElement).name;
// Remove if it's not in our preserve list and has a name
if (name && !preserveNames.includes(name)) {
input.remove();
}
});
}
// Clear form fields WITHOUT submitting
// Used by loadFilter to reset state before applying new filter
function clearFormWithoutSubmit(): void {
const filterForm = document.getElementById("filter-form") as HTMLFormElement;
if (!filterForm) return;
// Reset all form fields to their HTML defaults
filterForm.reset();
// Manually reset pagination values
// Remove has_cover input entirely
const hasCoverInput = document.querySelector(
'[name="has_cover"]',
) as HTMLInputElement;
if (hasCoverInput) {
hasCoverInput.remove();
}
// Reset Alpine hasCoverState to null
const bookshelfComponent = document.querySelector('[x-data*="bookshelf"]');
if (bookshelfComponent && (bookshelfComponent as any)._x_dataStack) {
const data = (bookshelfComponent as any)._x_dataStack[0];
if (data && typeof data.hasCoverState !== "undefined") {
data.hasCoverState = null;
}
}
// Remove any hidden inputs from previous filter loads
cleanupDynamicHiddenInputs();
// Reset pagination to defaults
const limitInput = filterForm.querySelector(
'input[name="limit"]',
) as HTMLInputElement;
@@ -17,16 +71,15 @@ function clearFilters(): void {
) as HTMLInputElement;
if (limitInput) limitInput.value = "50";
if (offsetInput) offsetInput.value = "0";
// Trigger form submit with cleared filters
window.htmx.trigger(filterForm, "submit");
}
// NOTE: No form submit here!
}
// Alpine.js component
Alpine.data("bookshelf", () => ({
// Component state
showSaveModal: false,
filterName: "",
showFiltersDropdown: false,
hasCoverState: null as boolean | null,
// Standalone function references (don't access component state)
clearFilters,
@@ -40,6 +93,33 @@ Alpine.data("bookshelf", () => ({
toggleFiltersDropdown() {
this.showFiltersDropdown = !this.showFiltersDropdown;
},
// Cycle through has_cover states: null -> true -> false -> null
cycleHasCover(): void {
if (this.hasCoverState === null) {
this.hasCoverState = true;
} else if (this.hasCoverState === true) {
this.hasCoverState = false;
} else {
this.hasCoverState = null;
}
// NOTE: We don't trigger form submission here
// The user must click "Search" or press Enter to apply the filter
},
// Handle post-HTMX swap cleanup for saved filters
afterFilterSave(): void {
// Hide empty state if it exists
const filtersList = document.getElementById("saved-filters-list");
if (filtersList) {
const emptyState = filtersList.nextElementSibling;
if (emptyState && emptyState.textContent?.includes("No saved filters")) {
emptyState.classList.add("hidden");
}
}
// Close modal
this.showSaveModal = false;
// Show success toast
showToast("Filter saved successfully", "success");
},
// Load a saved filter into the form
async loadFilter(event: Event) {
const button = event.target as HTMLElement;
@@ -91,29 +171,55 @@ Alpine.data("bookshelf", () => ({
return;
}
// Clean up any hidden inputs from previous filter loads
cleanupDynamicHiddenInputs();
// Clear all form state before applying new filter
clearFormWithoutSubmit();
// Don't clear form - just update values
// Populate form fields from filter data
Object.entries(filterData).forEach(([key, value]) => {
if (value) {
// Only set non-empty values
// Update visible form fields if they exist
const visibleField = filterForm.querySelector(
`[name="${key}"]`,
) as HTMLInputElement;
if (visibleField) {
if (visibleField.type === "checkbox") {
visibleField.checked = value === "true";
} else {
visibleField.value = value;
if (key === "has_cover") {
const bookshelfComponent = document.querySelector(
'[x-data*="bookshelf"]',
);
if (bookshelfComponent && (bookshelfComponent as any)._x_dataStack) {
const data = (bookshelfComponent as any)._x_dataStack[0];
if (data && typeof data.hasCoverState !== "undefined") {
// Convert string value to boolean|null
if (value === "true") {
data.hasCoverState = true;
} else if (value === "false") {
data.hasCoverState = false;
} else {
data.hasCoverState = null;
}
}
} else {
// Create hidden input if visible field doesn't exist
const input = document.createElement("input");
input.type = "hidden";
input.name = key;
input.value = value;
filterForm.appendChild(input);
}
return; // Don't process has_cover like other fields
}
if (!value) return; // Skip empty values
// Special handling for has_cover - update Alpine state directly
// Handle all other fields normally
const visibleField = filterForm.querySelector(
`[name="${key}"]`,
) as HTMLInputElement;
if (visibleField) {
if (visibleField.type === "checkbox") {
visibleField.checked = value === "true";
} else {
visibleField.value = value;
}
} else {
// Create hidden input if visible field doesn't exist
const input = document.createElement("input");
input.type = "hidden";
input.name = key;
input.value = value;
filterForm.appendChild(input);
}
});
@@ -164,65 +270,6 @@ Alpine.data("bookshelf", () => ({
showToast("Error deleting filter", "error");
}
},
// Inline method - saves the filter and closes modal
async saveFilter(event: Event) {
event.preventDefault();
const token = localStorage.getItem("token");
if (!token) {
showToast("Not authenticated", "error");
return;
}
// Get filter name from component state
const filterName = this.filterName;
if (!filterName) {
showToast("Please enter a filter name", "error");
return;
}
// Collect filter form data
const filterForm = document.getElementById(
"filter-form",
) as HTMLFormElement;
if (!filterForm) {
showToast("Filter form not found", "error");
return;
}
const formData = new FormData(filterForm);
const filterData: Record<string, string> = {};
formData.forEach((value, key) => {
filterData[key] = value.toString();
});
try {
const response = await fetch("/api/saved-filters", {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({
name: filterName,
resource_type: "media-items",
filters: filterData,
}),
});
if (response.ok) {
showToast("Filter saved successfully", "success");
// Clear the filter name input
this.filterName = "";
// Close the modal
this.showSaveModal = false;
} else {
showToast("Failed to save filter", "error");
}
} catch (error) {
console.error("Failed to save filter:", error);
showToast("Error saving filter", "error");
}
},
// Autocomplete helper function
async fetchFieldValues(
@@ -333,6 +380,18 @@ function initBookshelf(): void {
}
}
}
// Setup listener for HTMX swap events on the filters list
const filtersList = document.getElementById("saved-filters-list");
if (filtersList) {
filtersList.addEventListener("htmx:afterSwap", () => {
const bookshelfElem = document.querySelector("[x-data*='bookshelf']");
if (!bookshelfElem) return;
const bookshelf = (bookshelfElem as any)._x_dataStack?.[0];
if (!bookshelf) return;
bookshelf.afterFilterSave();
});
}
}
export { clearFilters };
export { clearFilters, cleanupDynamicHiddenInputs, clearFormWithoutSubmit };