Enhance exam search widget: improve filter handling and parsing in JavaScript
This commit is contained in:
+10
-2
@@ -6761,11 +6761,19 @@ def exam_search_widget(request):
|
||||
|
||||
|
||||
# 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 = {}
|
||||
for pname in ("archive", "open_access", "exam_mode", "candidates_only"):
|
||||
val = _get_bool_param(pname)
|
||||
if val is not None:
|
||||
extra_filters[pname] = val
|
||||
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
|
||||
|
||||
results = []
|
||||
logger.error(f"Exam search widget: model={model}, q='{q}', extra_filters={extra_filters}, initial_count={qs.count() if qs is not None else 'N/A'}")
|
||||
|
||||
+66
-30
@@ -516,50 +516,80 @@ class ExamSearchSelectMultipleWidget:
|
||||
const widgetFieldName = '{self.name}';
|
||||
|
||||
let timeout = null;
|
||||
function fetchResults(q) {{
|
||||
|
||||
// Parse filter tokens embedded in the search box, e.g. "archive:1 open_access:0"
|
||||
const FILTER_NAMES = ['archive','open_access','exam_mode','candidates_only'];
|
||||
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) {{
|
||||
results.innerHTML = '';
|
||||
return;
|
||||
// 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 f_archive = document.getElementById('filter_archive_{self.name}');
|
||||
const f_open = document.getElementById('filter_open_access_{self.name}');
|
||||
const f_exam_mode = document.getElementById('filter_exam_mode_{self.name}');
|
||||
const f_candidates = document.getElementById('filter_candidates_only_{self.name}');
|
||||
let extra = '';
|
||||
|
||||
// Initialize checkbox states from defaults (if provided)
|
||||
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 */ }}
|
||||
const modelParam = defaultModel ? '&model=' + encodeURIComponent(defaultModel) : '';
|
||||
const f_archive = document.getElementById('filter_archive_{self.name}');
|
||||
const f_open = document.getElementById('filter_open_access_{self.name}');
|
||||
const f_exam_mode = document.getElementById('filter_exam_mode_{self.name}');
|
||||
const f_candidates = document.getElementById('filter_candidates_only_{self.name}');
|
||||
let extra = '';
|
||||
|
||||
// For each supported filter, send the param if there's a default or the checkbox exists.
|
||||
function appendFilterParam(name, checkboxEl, defaultVal) {{
|
||||
if (typeof defaultVal !== 'undefined') {{
|
||||
const val = checkboxEl ? checkboxEl.checked : defaultVal;
|
||||
extra += '&' + encodeURIComponent(name) + '=' + (val ? '1' : '0');
|
||||
}} else if (checkboxEl && checkboxEl.checked) {{
|
||||
extra += '&' + encodeURIComponent(name) + '=1';
|
||||
}}
|
||||
// checkboxes are authoritative; do not mirror tokens into the inputs
|
||||
|
||||
// For each supported filter, send the param if there's a default or the checkbox exists.
|
||||
function appendFilterParam(name, checkboxEl, defaultVal) {{
|
||||
if (typeof defaultVal !== 'undefined') {{
|
||||
const val = checkboxEl ? checkboxEl.checked : defaultVal;
|
||||
extra += '&' + encodeURIComponent(name) + '=' + (val ? '1' : '0');
|
||||
}} else if (checkboxEl && checkboxEl.checked) {{
|
||||
extra += '&' + encodeURIComponent(name) + '=1';
|
||||
}}
|
||||
}}
|
||||
|
||||
appendFilterParam('archive', f_archive, defaultFilters.archive);
|
||||
appendFilterParam('open_access', f_open, defaultFilters.open_access);
|
||||
appendFilterParam('exam_mode', f_exam_mode, defaultFilters.exam_mode);
|
||||
appendFilterParam('candidates_only', f_candidates, defaultFilters.candidates_only);
|
||||
appendFilterParam('archive', f_archive, defaultFilters.archive);
|
||||
appendFilterParam('open_access', f_open, defaultFilters.open_access);
|
||||
appendFilterParam('exam_mode', f_exam_mode, defaultFilters.exam_mode);
|
||||
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)
|
||||
.then(r => r.text())
|
||||
.then(html => {{ results.innerHTML = html; }});
|
||||
}}
|
||||
|
||||
// wire up input events
|
||||
search.addEventListener('keyup', function(e) {{
|
||||
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
|
||||
@@ -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
|
||||
if (helpBtn && helpPanel) {{
|
||||
helpBtn.addEventListener('click', function(e) {{
|
||||
|
||||
Reference in New Issue
Block a user