Last active
January 31, 2025 09:18
-
-
Save AnandPilania/b7ccdfcdc19a5decc2dc46631eb6f140 to your computer and use it in GitHub Desktop.
Advanced Streaming Search with Dynamic Filtering in Laravel Blade
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
.filter-panel { | |
margin:1rem 0; | |
border-bottom:1px solid #eee; | |
padding-bottom:1rem; | |
} | |
.filter-controls { | |
display:flex; | |
gap:1rem; | |
margin-bottom:0.5rem; | |
} | |
.filter-select,.filter-button { | |
padding:0.5rem; | |
border:1px solid #ddd; | |
border-radius:4px; | |
} | |
.active-filters { | |
display:flex; | |
flex-wrap:wrap; | |
gap:0.5rem; | |
} | |
.active-filter { | |
background:#e0e0e0; | |
padding:0.25rem 0.5rem; | |
border-radius:4px; | |
display:flex; | |
align-items:center; | |
gap:0.5rem; | |
} | |
.active-filter button { | |
border:none; | |
background:none; | |
cursor:pointer; | |
padding:0; | |
font-size:1.2em; | |
} | |
.filter-modal { | |
display:none; | |
position:fixed; | |
top:0; | |
left:0; | |
right:0; | |
bottom:0; | |
background:rgba(0,0,0,0.5); | |
z-index:1000; | |
} | |
.filter-modal-content { | |
background:white; | |
margin:10% auto; | |
padding:2rem; | |
max-width:600px; | |
border-radius:8px; | |
max-height:80vh; | |
overflow-y:auto; | |
} | |
.filter-section { | |
margin-bottom:1.5rem; | |
} | |
.filter-options { | |
display:grid; | |
grid-template-columns:repeat(auto-fill,minmax(150px,1fr)); | |
gap:0.5rem; | |
} | |
.filter-actions { | |
margin-top:1rem; | |
display:flex; | |
gap:1rem; | |
justify-content:flex-end; | |
} | |
.filter-actions button { | |
padding:0.5rem 1rem; | |
border:1px solid #ddd; | |
border-radius:4px; | |
cursor:pointer; | |
} | |
#apply-filters { | |
background:#007bff; | |
color:white; | |
border-color:#0056b3; | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import './../css/streaming-search.css'; | |
class StreamingSearch { | |
constructor(searchUrl) { | |
this.searchUrl = searchUrl; | |
this.abortController = null; | |
this.domSearchConfig = { | |
selectors: ["a", "[data-searchable]"], | |
searchableAttributes: [ | |
"href", | |
"textContent", | |
"title", | |
"data-search-content", | |
], | |
}; | |
this.groups = new Map(); | |
this.allResults = new Map(); | |
this.currentFilters = new Set(); | |
this.resultsContainer = document.querySelector("#search-results"); | |
this.setupFilterPanel(); | |
} | |
setupFilterPanel() { | |
const filterPanel = document.createElement("div"); | |
filterPanel.className = "filter-panel"; | |
filterPanel.innerHTML = ` | |
<div class="filter-controls"> | |
<select id="filter-limit" class="filter-select"> | |
<option value="10">10 per group</option> | |
<option value="25">25 per group</option> | |
<option value="50">50 per group</option> | |
<option value="100">100 per group</option> | |
</select> | |
<button id="show-filters" class="filter-button">Filters</button> | |
</div> | |
<div id="active-filters" class="active-filters"></div> | |
<div id="filter-modal" class="filter-modal"> | |
<div class="filter-modal-content"> | |
<div class="filter-sections"></div> | |
<div class="filter-actions"> | |
<button id="apply-filters">Apply</button> | |
<button id="reset-filters">Reset</button> | |
</div> | |
</div> | |
</div> | |
`; | |
this.resultsContainer.parentNode.insertBefore( | |
filterPanel, | |
this.resultsContainer, | |
); | |
this.bindFilterEvents(); | |
} | |
bindFilterEvents() { | |
const modal = document.getElementById("filter-modal"); | |
const showFilters = document.getElementById("show-filters"); | |
const applyFilters = document.getElementById("apply-filters"); | |
const resetFilters = document.getElementById("reset-filters"); | |
const limitSelect = document.getElementById("filter-limit"); | |
showFilters.onclick = () => (modal.style.display = "block"); | |
modal.onclick = (e) => { | |
if (e.target === modal) modal.style.display = "none"; | |
}; | |
applyFilters.onclick = () => { | |
this.applyFilters(); | |
modal.style.display = "none"; | |
}; | |
resetFilters.onclick = () => { | |
this.resetFilters(); | |
this.updateActiveFilters(); | |
}; | |
limitSelect.onchange = () => this.renderResults(); | |
} | |
updateFilterPanel(results) { | |
const filterSections = document.querySelector(".filter-sections"); | |
filterSections.innerHTML = ""; | |
results.forEach((items, groupName) => { | |
const uniqueValues = this.extractFilterValues(items); | |
if (uniqueValues.size > 0) { | |
filterSections.appendChild( | |
this.createFilterSection(groupName, uniqueValues), | |
); | |
} | |
}); | |
} | |
extractFilterValues(items) { | |
const values = new Set(); | |
items.forEach((item) => { | |
Object.entries(item).forEach(([key, value]) => { | |
if (typeof value === "string" || typeof value === "number") { | |
values.add(`${key}:${value}`); | |
} | |
}); | |
}); | |
return values; | |
} | |
createFilterSection(groupName, values) { | |
const section = document.createElement("div"); | |
section.className = "filter-section"; | |
section.innerHTML = ` | |
<h4>${this.formatGroupName(groupName)}</h4> | |
<div class="filter-options"> | |
${Array.from(values) | |
.map( | |
(value) => ` | |
<label> | |
<input type="checkbox" value="${value}" | |
${this.currentFilters.has(value) ? "checked" : ""}> | |
${value.split(":")[1]} | |
</label> | |
`, | |
) | |
.join("")} | |
</div> | |
`; | |
return section; | |
} | |
updateActiveFilters() { | |
const container = document.getElementById("active-filters"); | |
container.innerHTML = Array.from(this.currentFilters) | |
.map( | |
(filter) => ` | |
<span class="active-filter"> | |
${filter.split(":")[1]} | |
<button data-filter="${filter}">×</button> | |
</span> | |
`, | |
) | |
.join(""); | |
container.onclick = (e) => { | |
if (e.target.tagName === "BUTTON") { | |
this.currentFilters.delete(e.target.dataset.filter); | |
this.updateActiveFilters(); | |
this.renderResults(); | |
} | |
}; | |
} | |
applyFilters() { | |
const checkboxes = document.querySelectorAll( | |
".filter-section input:checked", | |
); | |
this.currentFilters = new Set(Array.from(checkboxes).map((cb) => cb.value)); | |
this.updateActiveFilters(); | |
this.renderResults(); | |
} | |
resetFilters() { | |
this.currentFilters.clear(); | |
document | |
.querySelectorAll(".filter-section input") | |
.forEach((cb) => (cb.checked = false)); | |
this.renderResults(); | |
} | |
filterResults(items) { | |
if (this.currentFilters.size === 0) return items; | |
return items.filter((item) => { | |
return Array.from(this.currentFilters).some((filter) => { | |
const [key, value] = filter.split(":"); | |
return item[key] === value || item[key]?.toString() === value; | |
}); | |
}); | |
} | |
async search(query) { | |
await Promise.all([this.searchAPI(query), this.searchDOM(query)]); | |
} | |
async searchAPI(query) { | |
if (this.abortController) { | |
this.abortController.abort(); | |
} | |
this.clearResults(); | |
this.abortController = new AbortController(); | |
try { | |
const response = await fetch( | |
`${this.searchUrl}?query=${encodeURIComponent(query)}`, | |
{ | |
signal: this.abortController.signal, | |
}, | |
); | |
const reader = response.body.getReader(); | |
const decoder = new TextDecoder(); | |
let buffer = ""; | |
while (true) { | |
const { value, done } = await reader.read(); | |
if (done) break; | |
buffer += decoder.decode(value, { stream: true }); | |
try { | |
let data = JSON.parse(buffer); | |
this.handleResults(data); | |
buffer = ""; | |
} catch (e) { | |
if (e instanceof SyntaxError) { | |
continue; | |
} else { | |
throw e; | |
} | |
} | |
} | |
} catch (error) { | |
if (error.name !== "AbortError") { | |
console.error("Search error:", error); | |
} | |
} | |
} | |
async searchDOM(query) { | |
let domResultsContainer = document.getElementById("dom-results"); | |
if (!domResultsContainer) { | |
domResultsContainer = document.createElement("div"); | |
domResultsContainer.id = "dom-results"; | |
domResultsContainer.className = "results-container"; | |
document | |
.querySelector("#search-results") | |
.appendChild(domResultsContainer); | |
} | |
domResultsContainer.innerHTML = ""; | |
if (!query.trim()) { | |
return; | |
} | |
const elements = this.domSearchConfig.selectors | |
.map((selector) => Array.from(document.querySelectorAll(selector))) | |
.flat(); | |
const worker = new Worker( | |
URL.createObjectURL( | |
new Blob( | |
[ | |
` | |
onmessage = function(e) { | |
const { elements, query, searchableAttributes } = e.data; | |
const results = elements.map(element => { | |
for (const attr of searchableAttributes) { | |
const value = element[attr] || (element.attributes && element.attributes[attr]); | |
if (value && value.toLowerCase().includes(query.toLowerCase())) { | |
return { | |
text: element.textContent.trim(), | |
href: element.href || null, | |
matchedAttribute: attr, | |
matchedValue: value | |
}; | |
} | |
} | |
return null; | |
}).filter(Boolean); | |
postMessage(results); | |
}; | |
`, | |
], | |
{ type: "text/javascript" }, | |
), | |
), | |
); | |
worker.onmessage = (e) => { | |
const results = e.data; | |
this.handleResults({ DOM: results }); | |
worker.terminate(); | |
}; | |
worker.postMessage({ | |
elements: elements.map((el) => ({ | |
textContent: el.textContent, | |
href: el.href, | |
attributes: Object.fromEntries( | |
Array.from(el.attributes).map((attr) => [attr.name, attr.value]), | |
), | |
})), | |
query, | |
searchableAttributes: this.domSearchConfig.searchableAttributes, | |
}); | |
} | |
handleResults(data) { | |
Object.entries(data).forEach(([groupName, items]) => { | |
if (Number.isInteger(+groupName)) { | |
groupName = items["type"]; | |
items = items["data"]; | |
} | |
if (!Array.isArray(items)) return; | |
if (!this.allResults.has(groupName)) { | |
this.allResults.set(groupName, []); | |
} | |
this.allResults.get(groupName).push(...items); | |
this.updateFilterPanel(this.allResults); | |
this.renderResults(); | |
}); | |
} | |
renderResults() { | |
this.clearDisplayedResults(); | |
const limit = parseInt(document.getElementById("filter-limit").value); | |
this.allResults.forEach((items, groupName) => { | |
const filteredItems = this.filterResults(items); | |
const limitedItems = filteredItems.slice(0, limit); | |
if (limitedItems.length > 0) { | |
const groupElement = this.createGroupElement( | |
groupName, | |
filteredItems.length, | |
); | |
const ul = groupElement.querySelector("ul"); | |
limitedItems.forEach((item) => { | |
const li = document.createElement("li"); | |
const a = document.createElement("a"); | |
a.href = item.href ?? "#"; | |
a.textContent = this.getDisplayText(item); | |
li.appendChild(a); | |
ul.appendChild(li); | |
}); | |
this.resultsContainer.appendChild(groupElement); | |
} | |
}); | |
} | |
clearResults() { | |
this.resultsContainer.innerHTML = ""; | |
this.allResults.clear(); | |
this.currentFilters.clear(); | |
this.updateActiveFilters(); | |
} | |
clearDisplayedResults() { | |
this.resultsContainer.innerHTML = ""; | |
} | |
createGroupElement(groupName, totalCount) { | |
const div = document.createElement("div"); | |
div.className = "search-group"; | |
div.innerHTML = ` | |
<h3> | |
<span class="group-title">${this.formatGroupName(groupName)}</span> | |
<span class="group-count">(showing ${Math.min(parseInt(document.getElementById("filter-limit").value), totalCount)} of ${totalCount})</span> | |
</h3> | |
<ul></ul> | |
`; | |
return div; | |
} | |
formatGroupName(name) { | |
return name.charAt(0).toUpperCase() + name.slice(1); | |
} | |
getDisplayText(item) { | |
return ( | |
item.name || | |
item.title || | |
item.display_name || | |
item.text || | |
JSON.stringify(item) | |
); | |
} | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<head> | |
... @vite(['resources/js/streaming-search.js']) ... | |
</head> | |
<body> | |
... | |
<input type="text" id="search-input" placeholder="Search..."> | |
<div id="search-results"> | |
</div> | |
... | |
<script> | |
const search = new StreamingSearch("/api/search"); | |
const searchInput = document.querySelector("#search-input"); | |
let timeout = null; | |
searchInput.addEventListener("input", (e) = >{ | |
clearTimeout(timeout); | |
timeout = setTimeout(() = >{ | |
if (e.target.value.length >= 2) { | |
search.search(e.target.value); | |
} | |
}, | |
300); | |
}); | |
</script> | |
... | |
</body> |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Route::view('/search', 'search'); | |
// Sample search: users and webforms | |
Route::get('/api/search', function (Request $request) { | |
$request->validate([ | |
'query' => 'required|min:2', | |
]); | |
$searchTerm = $request->input('query'); | |
$streamHandler = static function (\Illuminate\Database\Eloquent\Builder $query, callable $resource, int $flushInterval = 100): Generator { | |
foreach ($query->cursor() as $index => $item) { | |
yield $resource($item)->resolve(); | |
if (($index + 1) % $flushInterval === 0) { | |
flush(); | |
} | |
} | |
flush(); | |
}; | |
$yieldUsers = fn ($searchTerm): Generator => $streamHandler( | |
\App\Models\User::query() | |
->where('name', 'LIKE', "%{$searchTerm}%") | |
->orWhere('email', 'LIKE', "%{$searchTerm}%"), | |
fn ($user) => new class($user) extends \Illuminate\Http\Resources\Json\JsonResource | |
{ | |
public function toArray($request): array | |
{ | |
return [ | |
'name' => $this->name, | |
'email' => $this->email, | |
]; | |
} | |
} | |
); | |
$yieldWebforms = fn ($searchTerm): Generator => $streamHandler( | |
\App\Models\Webform::query() | |
->where('webform_uid', 'LIKE', "%{$searchTerm}%") | |
->where('api_name', 'LIKE', "%{$searchTerm}%") | |
->orWhere('display_name', 'LIKE', "%{$searchTerm}%"), | |
fn ($webform) => new class($webform) extends \Illuminate\Http\Resources\Json\JsonResource | |
{ | |
public function toArray($request): array | |
{ | |
return [ | |
'webform_uid' => $this->webform_uid, | |
'api_name' => $this->api_name, | |
'display_name' => $this->display_name, | |
]; | |
} | |
} | |
); | |
$jsonStreamer = response()->streamJson([ | |
'users' => $yieldUsers($searchTerm), | |
'webforms' => $yieldWebforms($searchTerm), | |
]); | |
return $jsonStreamer; | |
}); | |
// OR use below core streamer instead of json streamer; | |
/* | |
$coreStreamer = response()->stream(function () use ($yieldUsers, $yieldWebforms, $searchTerm) { | |
if (ob_get_level()) { | |
ob_end_clean(); | |
} | |
echo '['; | |
$first = true; | |
foreach ($yieldUsers($searchTerm) as $user) { | |
if (! $first) { | |
echo ','; | |
} | |
echo json_encode(['type' => 'user', 'data' => $user]); | |
flush(); | |
$first = false; | |
} | |
foreach ($yieldWebforms($searchTerm) as $webform) { | |
if (! $first) { | |
echo ','; | |
} | |
echo json_encode(['type' => 'webform', 'data' => $webform]); | |
flush(); | |
$first = false; | |
} | |
echo ']'; | |
flush(); | |
}, 200, ['Content-Type' => 'application/json']); | |
*/ |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment