Enhance exam search widget: refactor query handling to support wildcard searches and improve filter options rendering
This commit is contained in:
+82
-33
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user