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:
+143
-84
@@ -4,11 +4,65 @@ import { Alpine } from "./alpine";
|
|||||||
import { showToast } from "./toast";
|
import { showToast } from "./toast";
|
||||||
|
|
||||||
function clearFilters(): void {
|
function clearFilters(): void {
|
||||||
|
// Clear all form state (reuses helper)
|
||||||
|
clearFormWithoutSubmit();
|
||||||
|
|
||||||
|
// Trigger form submit with cleared filters
|
||||||
const filterForm = document.getElementById("filter-form") as HTMLFormElement;
|
const filterForm = document.getElementById("filter-form") as HTMLFormElement;
|
||||||
if (!filterForm) return;
|
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();
|
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(
|
const limitInput = filterForm.querySelector(
|
||||||
'input[name="limit"]',
|
'input[name="limit"]',
|
||||||
) as HTMLInputElement;
|
) as HTMLInputElement;
|
||||||
@@ -17,16 +71,15 @@ function clearFilters(): void {
|
|||||||
) as HTMLInputElement;
|
) as HTMLInputElement;
|
||||||
if (limitInput) limitInput.value = "50";
|
if (limitInput) limitInput.value = "50";
|
||||||
if (offsetInput) offsetInput.value = "0";
|
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.js component
|
||||||
Alpine.data("bookshelf", () => ({
|
Alpine.data("bookshelf", () => ({
|
||||||
// Component state
|
// Component state
|
||||||
showSaveModal: false,
|
showSaveModal: false,
|
||||||
filterName: "",
|
|
||||||
showFiltersDropdown: false,
|
showFiltersDropdown: false,
|
||||||
|
hasCoverState: null as boolean | null,
|
||||||
|
|
||||||
// Standalone function references (don't access component state)
|
// Standalone function references (don't access component state)
|
||||||
clearFilters,
|
clearFilters,
|
||||||
@@ -40,6 +93,33 @@ Alpine.data("bookshelf", () => ({
|
|||||||
toggleFiltersDropdown() {
|
toggleFiltersDropdown() {
|
||||||
this.showFiltersDropdown = !this.showFiltersDropdown;
|
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
|
// Load a saved filter into the form
|
||||||
async loadFilter(event: Event) {
|
async loadFilter(event: Event) {
|
||||||
const button = event.target as HTMLElement;
|
const button = event.target as HTMLElement;
|
||||||
@@ -91,29 +171,55 @@ Alpine.data("bookshelf", () => ({
|
|||||||
return;
|
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
|
// Don't clear form - just update values
|
||||||
// Populate form fields from filter data
|
// Populate form fields from filter data
|
||||||
Object.entries(filterData).forEach(([key, value]) => {
|
Object.entries(filterData).forEach(([key, value]) => {
|
||||||
if (value) {
|
if (key === "has_cover") {
|
||||||
// Only set non-empty values
|
const bookshelfComponent = document.querySelector(
|
||||||
// Update visible form fields if they exist
|
'[x-data*="bookshelf"]',
|
||||||
const visibleField = filterForm.querySelector(
|
);
|
||||||
`[name="${key}"]`,
|
if (bookshelfComponent && (bookshelfComponent as any)._x_dataStack) {
|
||||||
) as HTMLInputElement;
|
const data = (bookshelfComponent as any)._x_dataStack[0];
|
||||||
if (visibleField) {
|
if (data && typeof data.hasCoverState !== "undefined") {
|
||||||
if (visibleField.type === "checkbox") {
|
// Convert string value to boolean|null
|
||||||
visibleField.checked = value === "true";
|
if (value === "true") {
|
||||||
} else {
|
data.hasCoverState = true;
|
||||||
visibleField.value = value;
|
} 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");
|
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
|
// Autocomplete helper function
|
||||||
async fetchFieldValues(
|
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 };
|
||||||
|
|||||||
Reference in New Issue
Block a user