Enhance exam search widget: improve filter handling and parsing in JavaScript

This commit is contained in:
Ross
2026-02-16 13:17:08 +00:00
parent 990f4534bd
commit 64103cac1e
2 changed files with 76 additions and 32 deletions
+9 -1
View File
@@ -6761,10 +6761,18 @@ def exam_search_widget(request):
# Apply optional filters from GET params to the per-model queryset # Apply optional filters from GET params to the per-model queryset
# Only include filters that were supplied in GET _and_ where the model
# actually exposes the field. This prevents models without a given
# attribute from being accidentally excluded when a default filter
# value is always sent by the widget JS.
extra_filters = {} extra_filters = {}
for pname in ("archive", "open_access", "exam_mode", "candidates_only"): for pname in ("archive", "open_access", "exam_mode", "candidates_only"):
val = _get_bool_param(pname) val = _get_bool_param(pname)
if val is not None: if val is None:
continue
# Only include the filter if the model defines the field
if not _field_exists(model, pname):
continue
extra_filters[pname] = val extra_filters[pname] = val
results = [] results = []
+49 -13
View File
@@ -516,11 +516,43 @@ class ExamSearchSelectMultipleWidget:
const widgetFieldName = '{self.name}'; const widgetFieldName = '{self.name}';
let timeout = null; let timeout = null;
function fetchResults(q) {{
if (!q || q.trim().length === 0) {{ // Parse filter tokens embedded in the search box, e.g. "archive:1 open_access:0"
results.innerHTML = ''; const FILTER_NAMES = ['archive','open_access','exam_mode','candidates_only'];
return; const tokenRe = new RegExp('(?:^|\\s)(' + FILTER_NAMES.join('|') + '):\\s*(1|0|true|false|yes|no)\\b','gi');
function parseFilterTokens(q) {{
const filters = {{}};
if (!q) return {{ cleaned: '', filters: filters }};
let cleaned = q.replace(tokenRe, function(m, name, val) {{
filters[name] = /^(1|true|yes)$/i.test(val);
return ' ';
}});
cleaned = cleaned.replace(/\s+/g,' ').trim();
return {{ cleaned: cleaned, filters: filters }};
}} }}
function parseFilterTokens(q) {{
const filters = {{}};
if (!q) return {{ cleaned: '', filters: filters }};
let cleaned = q.replace(tokenRe, function(m, name, val) {{
filters[name] = /^(1|true|yes)$/i.test(val);
return ' ';
}});
cleaned = cleaned.replace(/\s+/g,' ').trim();
return {{ cleaned: cleaned, filters: filters }};
}}
function fetchResults(rawQ) {{
const parsed = parseFilterTokens(rawQ || '');
const q = parsed.cleaned;
if (!q || q.trim().length === 0) {{
// still respect filters even when query is empty: show results for wildcard if filters present
// but don't send empty q param; use '*' to trigger wildcard behaviour if any filter present
const anyFilter = Object.keys(parsed.filters).length > 0;
if (!anyFilter) {{ results.innerHTML = ''; return; }}
}}
const modelParam = defaultModel ? '&model=' + encodeURIComponent(defaultModel) : ''; const modelParam = defaultModel ? '&model=' + encodeURIComponent(defaultModel) : '';
const f_archive = document.getElementById('filter_archive_{self.name}'); const f_archive = document.getElementById('filter_archive_{self.name}');
const f_open = document.getElementById('filter_open_access_{self.name}'); const f_open = document.getElementById('filter_open_access_{self.name}');
@@ -528,13 +560,7 @@ class ExamSearchSelectMultipleWidget:
const f_candidates = document.getElementById('filter_candidates_only_{self.name}'); const f_candidates = document.getElementById('filter_candidates_only_{self.name}');
let extra = ''; let extra = '';
// Initialize checkbox states from defaults (if provided) // checkboxes are authoritative; do not mirror tokens into the inputs
try {{
if (f_archive && defaultFilters.hasOwnProperty('archive')) f_archive.checked = !!defaultFilters.archive;
if (f_open && defaultFilters.hasOwnProperty('open_access')) f_open.checked = !!defaultFilters.open_access;
if (f_exam_mode && defaultFilters.hasOwnProperty('exam_mode')) f_exam_mode.checked = !!defaultFilters.exam_mode;
if (f_candidates && defaultFilters.hasOwnProperty('candidates_only')) f_candidates.checked = !!defaultFilters.candidates_only;
}} catch (e) {{ /* ignore */ }}
// For each supported filter, send the param if there's a default or the checkbox exists. // For each supported filter, send the param if there's a default or the checkbox exists.
function appendFilterParam(name, checkboxEl, defaultVal) {{ function appendFilterParam(name, checkboxEl, defaultVal) {{
@@ -551,15 +577,19 @@ class ExamSearchSelectMultipleWidget:
appendFilterParam('exam_mode', f_exam_mode, defaultFilters.exam_mode); appendFilterParam('exam_mode', f_exam_mode, defaultFilters.exam_mode);
appendFilterParam('candidates_only', f_candidates, defaultFilters.candidates_only); appendFilterParam('candidates_only', f_candidates, defaultFilters.candidates_only);
const url = '{url}?field=' + encodeURIComponent(widgetFieldName) + '&q=' + encodeURIComponent(q) + modelParam + extra; const sendQ = (q && q.length) ? q : '*';
const url = '{url}?field=' + encodeURIComponent(widgetFieldName) + '&q=' + encodeURIComponent(sendQ) + modelParam + extra;
fetch(url) fetch(url)
.then(r => r.text()) .then(r => r.text())
.then(html => {{ results.innerHTML = html; }}); .then(html => {{ results.innerHTML = html; }});
}} }}
// wire up input events
search.addEventListener('keyup', function(e) {{ search.addEventListener('keyup', function(e) {{
clearTimeout(timeout); clearTimeout(timeout);
timeout = setTimeout(() => fetchResults(search.value), 300); timeout = setTimeout(() => {{
fetchResults(search.value);
}}, 300);
}}); }});
// Prevent Enter from submitting the surrounding form; run the search instead // Prevent Enter from submitting the surrounding form; run the search instead
@@ -572,6 +602,12 @@ class ExamSearchSelectMultipleWidget:
}} }}
}}); }});
// update search input when filters change
['filter_archive_{self.name}','filter_open_access_{self.name}','filter_exam_mode_{self.name}','filter_candidates_only_{self.name}'].forEach(function(id) {{
const el = document.getElementById(id);
if (el) el.addEventListener('change', function() {{ clearTimeout(timeout); timeout = setTimeout(() => fetchResults(search.value), 50); }});
}});
// Toggle help panel visibility // Toggle help panel visibility
if (helpBtn && helpPanel) {{ if (helpBtn && helpPanel) {{
helpBtn.addEventListener('click', function(e) {{ helpBtn.addEventListener('click', function(e) {{