Enhance exam search widget: refactor query handling to support wildcard searches and improve filter options rendering

This commit is contained in:
Ross
2026-02-16 13:46:09 +00:00
parent 36da045d71
commit 6e30fcc19f
2 changed files with 147 additions and 96 deletions
+82 -33
View File
@@ -382,6 +382,32 @@ class ExamSearchSelectMultipleWidget:
# default filters for this widget instance; fall back to module defaults
self.default_filters = default_filters if default_filters is not None else DEFAULT_EXAM_SEARCH_FILTERS
def _archive_select_options(self):
# Render a three-state select for archive filtering: Any / Not archived / Archived only
df = self.default_filters or {}
sel = None
if isinstance(df, dict) and 'archive' in df:
if df['archive'] is True:
sel = '1'
elif df['archive'] is False:
sel = '0'
parts = []
parts.append(f"<option value=\"\"{' selected' if sel is None else ''}>Any</option>")
parts.append(f"<option value=\"0\"{' selected' if sel == '0' else ''}>Not archived</option>")
parts.append(f"<option value=\"1\"{' selected' if sel == '1' else ''}>Archived only</option>")
return ''.join(parts)
def _binary_select_options(self, key, true_label, false_label):
df = self.default_filters or {}
sel = None
if isinstance(df, dict) and key in df:
sel = '1' if df[key] else '0'
parts = []
parts.append(f"<option value=\"\"{' selected' if sel is None else ''}>Any</option>")
parts.append(f"<option value=\"0\"{' selected' if sel == '0' else ''}>{false_label}</option>")
parts.append(f"<option value=\"1\"{' selected' if sel == '1' else ''}>{true_label}</option>")
return ''.join(parts)
def render(self):
field_id = f"id_{self.name}"
search_id = f"exam_search_{self.name}"
@@ -454,22 +480,30 @@ class ExamSearchSelectMultipleWidget:
<strong>Advanced search tips</strong>
<div class="mb-2 mt-2">
<strong class="small">Filters</strong>
<div class="d-flex gap-2 mt-1">
<div class="form-check form-check-inline">
<input class="form-check-input" type="checkbox" id="filter_archive_{self.name}" />
<label class="form-check-label small" for="filter_archive_{self.name}">Archived only</label>
<div class="d-flex gap-2 mt-1 align-items-center">
<div>
<label class="form-label small mb-0 me-2" for="filter_archive_{self.name}">Archive</label>
<select id="filter_archive_{self.name}" class="form-select form-select-sm" style="max-width:160px">
{self._archive_select_options() if hasattr(self, '_archive_select_options') else ''}
</select>
</div>
<div class="form-check form-check-inline">
<input class="form-check-input" type="checkbox" id="filter_open_access_{self.name}" />
<label class="form-check-label small" for="filter_open_access_{self.name}">Open access only</label>
<div>
<label class="form-label small mb-0 me-2" for="filter_open_access_{self.name}">Open access</label>
<select id="filter_open_access_{self.name}" class="form-select form-select-sm" style="max-width:160px">
{self._binary_select_options('open_access', 'Open access only', 'Not open access') if hasattr(self, '_binary_select_options') else ''}
</select>
</div>
<div class="form-check form-check-inline">
<input class="form-check-input" type="checkbox" id="filter_exam_mode_{self.name}" />
<label class="form-check-label small" for="filter_exam_mode_{self.name}">Exam mode only</label>
<div>
<label class="form-label small mb-0 me-2" for="filter_exam_mode_{self.name}">Exam mode</label>
<select id="filter_exam_mode_{self.name}" class="form-select form-select-sm" style="max-width:160px">
{self._binary_select_options('exam_mode', 'Exam mode only', 'Not exam mode') if hasattr(self, '_binary_select_options') else ''}
</select>
</div>
<div class="form-check form-check-inline">
<input class="form-check-input" type="checkbox" id="filter_candidates_only_{self.name}" />
<label class="form-check-label small" for="filter_candidates_only_{self.name}">Candidates only</label>
<div>
<label class="form-label small mb-0 me-2" for="filter_candidates_only_{self.name}">Candidates only</label>
<select id="filter_candidates_only_{self.name}" class="form-select form-select-sm" style="max-width:160px">
{self._binary_select_options('candidates_only', 'Candidates only', 'Not candidates only') if hasattr(self, '_binary_select_options') else ''}
</select>
</div>
</div>
</div>
@@ -478,10 +512,8 @@ class ExamSearchSelectMultipleWidget:
<li>Field-specific searches: prefix with <code>field:term</code> to restrict the search. Supported fields:
<ul class="mb-0 mt-1">
<li><code>name:</code> — matches the exam name.</li>
<li><code>code:</code> — matches an exam code or short identifier.</li>
<li><code>id:</code> or <code>pk:</code> — match by numeric id.</li>
<li><code>date:</code> — match by date (YYYY-MM-DD) where available.</li>
<li><code>type:</code> — narrow to an exam type (e.g. <code>type:anatomy</code>).</li>
</ul>
</li>
<li>To add exams, click the <em>Add</em> button beside a result. Use <em>Add all results</em> to import visible matches.</li>
@@ -546,12 +578,6 @@ class ExamSearchSelectMultipleWidget:
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 f_archive = document.getElementById('filter_archive_{self.name}');
@@ -560,22 +586,45 @@ class ExamSearchSelectMultipleWidget:
const f_candidates = document.getElementById('filter_candidates_only_{self.name}');
let extra = '';
// checkboxes are authoritative; do not mirror tokens into the inputs
// 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
if (!q || q.trim().length === 0) {{
let anyFilter = Object.keys(parsed.filters).length > 0;
if (!anyFilter) {{
try {{
if (f_archive) {{
const tag = f_archive.tagName ? f_archive.tagName.toLowerCase() : '';
if (tag === 'select') {{
if (f_archive.value === '1' || f_archive.value === '0') anyFilter = true;
}} else if (f_archive.checked) anyFilter = true;
}}
if (f_open && f_open.checked) anyFilter = true;
if (f_exam_mode && f_exam_mode.checked) anyFilter = true;
if (f_candidates && f_candidates.checked) anyFilter = true;
}} catch(e) {{ /* ignore */ }}
}}
if (!anyFilter) {{ results.innerHTML = ''; return; }}
}}
// 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';
// Only send filter params when their controls are present.
// - `archive`: explicit when present (1 = archived only, 0 = not archived)
// - other filters: only active when checked (send name=1)
function appendFilterParam(name, el) {{
if (!el) return;
const tag = el.tagName ? el.tagName.toLowerCase() : '';
if (tag === 'select') {{
const v = el.value;
if (v === '1' || v === '0') extra += '&' + encodeURIComponent(name) + '=' + v;
}} else {{
// checkbox fallback: send explicit 1/0 when present
extra += '&' + encodeURIComponent(name) + '=' + (el.checked ? '1' : '0');
}}
}}
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);
appendFilterParam('open_access', f_open);
appendFilterParam('exam_mode', f_exam_mode);
appendFilterParam('candidates_only', f_candidates);
const sendQ = (q && q.length) ? q : '*';
const url = '{url}?field=' + encodeURIComponent(widgetFieldName) + '&q=' + encodeURIComponent(sendQ) + modelParam + extra;