1import { Controller } from "@hotwired/stimulus" 2import { debounce } from "utils/debounce" 3 4export default class extends Controller { 5 static targets = ["item", "noResults", "showMoreContainer", "listContainer", "totalCount", "sortSelect", "searchInput"] 6 static values = { initialItems: Number } 7 8 connect() { 9 console.log("School search controller connected with", this.itemTargets.length, "items"); 10 this.searchQuery = ""; 11 this.sortCriteria = this.hasSortSelectTarget ? this.sortSelectTarget.value : 'score_desc'; 12 13 // Set the default sort select value to match our default sortCriteria 14 if (this.hasSortSelectTarget) { 15 this.sortSelectTarget.value = 'score_desc'; 16 } 17 18 this.itemsPerBatch = Number.MAX_SAFE_INTEGER; // Show all items at once, effectively disabling pagination 19 this.currentlyVisibleItems = 0; 20 21 // Store all items as an array of objects with data for sorting 22 this.allSchoolData = this.itemTargets.map(item => { 23 return { 24 element: item, 25 name: item.dataset.name || "", 26 region: item.dataset.region || "", 27 category: item.dataset.category || this.extractCategory(item), 28 score: parseFloat(item.dataset.score || 0) 29 }; 30 }); 31 32 console.log("Initial data loaded:", this.allSchoolData.length, "schools"); 33 this.debouncedFilter = debounce(this.applyFiltersAndSort.bind(this), 300); 34 35 // Wait for next frame to apply initial rendering 36 // This gives the browser time to properly layout elements first 37 requestAnimationFrame(() => { 38 this.applyFiltersAndSort(); // Initial render 39 }); 40 41 // Hide the show more button since we're showing all items 42 if (this.hasShowMoreContainerTarget) { 43 this.showMoreContainerTarget.classList.add('hidden'); 44 } 45 } 46 47 extractCategory(itemElement) { 48 // Use data-category attribute if present 49 if (itemElement.dataset.category) { 50 return itemElement.dataset.category.toUpperCase(); 51 } 52 53 // Fallback to extracting from HTML content 54 const categorySpan = itemElement.querySelector('span[class*="bg-accent-"]'); 55 if (categorySpan && categorySpan.textContent) { 56 const match = categorySpan.textContent.match(/Category\s+([A-Z0-9_-]+)/i); 57 return match ? match[1].toUpperCase() : "Z"; 58 } 59 return "Z"; 60 } 61 62 filter(event) { 63 console.log("Search input:", event.target.value); 64 this.searchQuery = event.target.value.toLowerCase(); 65 this.debouncedFilter(); 66 } 67 68 sortResults() { 69 this.sortCriteria = this.sortSelectTarget.value; 70 this.applyFiltersAndSort(); 71 } 72 73 clearSearchAndFilters() { 74 if (this.hasSearchInputTarget) { 75 this.searchInputTarget.value = ''; 76 } 77 this.searchQuery = ''; 78 if (this.hasSortSelectTarget) { 79 this.sortSelectTarget.value = 'score_desc'; // Reset to default sort 80 } 81 this.sortCriteria = 'score_desc'; 82 this.applyFiltersAndSort(); 83 } 84 85 applyFiltersAndSort() { 86 console.log("Applying filters with search query:", this.searchQuery); 87 this.currentlyVisibleItems = 0; // Reset for new filter/sort application 88 let itemsToProcess = [...this.allSchoolData]; // Start with a copy of all data 89 90 // 1. Apply Search Filter 91 if (this.searchQuery) { 92 const beforeCount = itemsToProcess.length; 93 itemsToProcess = itemsToProcess.filter(school => 94 school.name.includes(this.searchQuery) || school.region.includes(this.searchQuery) 95 ); 96 console.log(`Filtered from ${beforeCount} to ${itemsToProcess.length} schools matching "${this.searchQuery}"`); 97 } 98 99 // 2. Apply Sorting 100 const [sortKey, sortOrder] = this.sortCriteria.split('_'); 101 itemsToProcess.sort((a, b) => { 102 let valA = a[sortKey]; 103 let valB = b[sortKey]; 104 105 // Basic case-insensitive string sort 106 if (typeof valA === 'string' && typeof valB === 'string') { 107 valA = valA.toLowerCase(); 108 valB = valB.toLowerCase(); 109 } 110 111 if (valA < valB) return sortOrder === 'asc' ? -1 : 1; 112 if (valA > valB) return sortOrder === 'asc' ? 1 : -1; 113 return 0; 114 }); 115 116 this.renderVisibleItems(itemsToProcess); 117 this.updateTotalCountDisplayAfterFilter(itemsToProcess); 118 } 119 120 renderVisibleItems(sortedAndFilteredSchoolData) { 121 // Hide all original DOM elements first 122 this.allSchoolData.forEach(school => school.element.classList.add('hidden')); 123 124 // Detach all items from their current parents (listContainers
124) 125 // and re-append them in the new sorted order to the correct container. 126 const mobileListContainer = this.listContainerTargets.find(c => c.id === 'schoolsListMobile'); 127 const desktopListContainer = this.listContainerTargets.find(c => !c.id); // Desktop tbody has no id 128 129 if (mobileListContainer) mobileListContainer.innerHTML = ''; // Clear previous items 130 if (desktopListContainer) desktopListContainer.innerHTML = ''; // Clear previous items 131 132 let itemsShownThisRenderCycle = 0; 133 134 // Show all items at once - no pagination 135 sortedAndFilteredSchoolData.forEach((schoolData) => { 136 schoolData.element.classList.remove('hidden'); 137 138 if (mobileListContainer && schoolData.element.tagName !== 'TR') { 139 mobileListContainer.appendChild(schoolData.element); 140 } else if (desktopListContainer && schoolData.element.tagName === 'TR') { 141 desktopListContainer.appendChild(schoolData.element); 142 } 143 itemsShownThisRenderCycle++; 144 }); 145 146 this.currentlyVisibleItems = itemsShownThisRenderCycle; 147 148 const hasAnyMatchingItems = sortedAndFilteredSchoolData.length > 0; 149 const isInitialLoadAndEmpty = this.allSchoolData.length === 0; 150 151 if (this.hasNoResultsTarget) { 152 // Show "No Results" if: EITHER the initial data from server was empty, 153 // OR if there are no items matching the current client-side filters/search. 154 const showNoResults = isInitialLoadAndEmpty || (!hasAnyMatchingItems && (this.searchQuery !== '' || this.allSchoolData.length > 0)); 155 this.noResultsTarget.classList.toggle('hidden', !showNoResults); 156 } 157 158 // Always hide the "Show More" button since we're showing all items 159 if (this.hasShowMoreContainerTarget) { 160 this.showMoreContainerTarget.classList.add('hidden'); 161 } 162 } 163 164 updateTotalCountDisplayAfterFilter(filteredItems) { 165 if (this.hasTotalCountTarget) { 166 const totalMatching = filteredItems.length; 167 const visibleNow = totalMatching; // All items are visible now 168 let textContent = ``; 169 if (this.allSchoolData.length === 0) { 170 textContent = "(0 schools found)"; 171 } else if (this.searchQuery || (this.sortCriteria !== 'score_desc' && this.allSchoolData.length !== totalMatching)) { 172 textContent = `(${totalMatching} schools matching criteria - Total ${this.allSchoolData.length} schools)`; 173 } else { 174 textContent = `(${this.allSchoolData.length} schools)`; 175 } 176 this.totalCountTarget.textContent = textContent; 177 } 178 } 179 180 // We can keep this method for compatibility, but it won't be used 181 showMore() { 182 // Method left for compatibility, but we're now showing all items at once 183 console.log("Show more clicked - but all items are already visible"); 184 } 185}
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.