Skip to content

Commit 25d4bbd

Browse files
authored
Merge pull request #89 from Doist/add-searchable-event-dropdowns
feat(cohort): add searchable event dropdowns
2 parents d9b6ead + 543d92c commit 25d4bbd

5 files changed

Lines changed: 560 additions & 2 deletions

File tree

bitmapist/cohort/__init__.py

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,25 @@
8181

8282
# --- HTML rendering
8383

84+
# Tom Select is vendored (not loaded from a CDN) so the cohort form makes no
85+
# third-party network calls at runtime and works offline. The assets are inlined
86+
# into the fragment because the library has no static-serving mechanism of its
87+
# own -- consumers just embed the string returned by render_html_form(). Note
88+
# that inline tags still require the host's CSP to permit inline script/style
89+
# (or that callers pass include_assets=False and load Tom Select themselves).
90+
_TOM_SELECT_CSS = "tmpl/vendor/tom-select-2.3.1.default.min.css"
91+
_TOM_SELECT_JS = "tmpl/vendor/tom-select-2.3.1.complete.min.js"
92+
93+
_VENDOR_CACHE: dict[str, str] = {}
94+
95+
96+
def _read_vendor_asset(relative_path: str) -> str:
97+
if relative_path not in _VENDOR_CACHE:
98+
file_path = path.join(path.dirname(path.abspath(__file__)), relative_path)
99+
with open(file_path, encoding="utf-8") as f:
100+
_VENDOR_CACHE[relative_path] = f.read()
101+
return _VENDOR_CACHE[relative_path]
102+
84103

85104
def render_html_form(
86105
action_url,
@@ -97,6 +116,7 @@ def render_html_form(
97116
num_results: int = 31,
98117
num_of_rows: int = 12,
99118
start_date: Optional[str] = None,
119+
include_assets: bool = True,
100120
):
101121
"""
102122
Render a HTML form that can be used to query the data in bitmapist.
@@ -111,6 +131,11 @@ def render_html_form(
111131
:param :select1b What is the current selected filter (extra, optional)
112132
:param :select2 What is the current selected filter (second)
113133
:param :select2b What is the current selected filter (extra, optional)
134+
:param :include_assets Whether to inline the vendored Tom Select CSS/JS into
135+
the fragment. Defaults to `True`. Set to `False` when rendering more than
136+
one form on a page, or when the host page already loads Tom Select, to
137+
avoid shipping the library more than once. The searchable dropdowns still
138+
initialize as long as Tom Select is available on the page.
114139
115140
"""
116141
# mandatory
@@ -141,6 +166,11 @@ def render_html_form(
141166
num_results=int(num_results),
142167
num_of_rows=int(num_of_rows),
143168
start_date=start_date,
169+
include_assets=include_assets,
170+
tom_select_css=_read_vendor_asset(_TOM_SELECT_CSS)
171+
if include_assets
172+
else "",
173+
tom_select_js=_read_vendor_asset(_TOM_SELECT_JS) if include_assets else "",
144174
)
145175
)
146176

bitmapist/cohort/tmpl/form_data.mako

Lines changed: 56 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,18 @@
1+
<%doc>
2+
Tom Select 2.3.1 is vendored and inlined (see bitmapist/cohort/tmpl/vendor/)
3+
rather than loaded from a CDN, so the cohort form makes no third-party
4+
network calls at runtime and works offline.
5+
6+
Emitting the library is gated on `include_assets` so a caller that renders
7+
more than one cohort form on a page (or already loads Tom Select itself)
8+
can suppress the duplicated CSS/JS by passing include_assets=False on the
9+
later forms. The small initializer below is always emitted and no-ops if
10+
the library isn't present.
11+
</%doc>
12+
%if include_assets:
13+
<style>${ tom_select_css | n }</style>
14+
%endif
15+
116
<style>
217
.cohort_form dd {
318
display: inline-block;
@@ -7,6 +22,14 @@
722
.cohort_form select {
823
max-width: 150px;
924
}
25+
26+
/* The event selects are enhanced with Tom Select, which renders its own
27+
searchable control. Give that control a usable width. */
28+
.cohort_form .ts-wrapper {
29+
display: inline-block;
30+
min-width: 200px;
31+
vertical-align: middle;
32+
}
1033
</style>
1134

1235
<form action="${ action_url }" method="GET" class="cohort_form">
@@ -83,8 +106,40 @@
83106
</dl>
84107
</form>
85108

109+
<%doc>
110+
The library is inlined here, after the form markup, so the browser can
111+
render the form before parsing/executing ~50KB of JS.
112+
</%doc>
113+
%if include_assets:
114+
<script>${ tom_select_js | n }</script>
115+
%endif
116+
117+
<script>
118+
// Turn the event dropdowns into searchable selects. With hundreds of
119+
// bitmapist events, scrolling a native <select> is painful; Tom Select
120+
// adds a type-to-filter search box while still submitting the same value.
121+
if (typeof TomSelect === 'undefined') {
122+
// Library failed to load for some reason; leave the native <select>s
123+
// in place rather than throwing.
124+
console.warn('Tom Select unavailable; cohort event dropdowns not enhanced.');
125+
} else {
126+
document.querySelectorAll('.cohort_form .cohort-event-select').forEach(function (el) {
127+
// The form fragment may be rendered more than once on a page, so
128+
// this initializer can run again over selects that are already
129+
// enhanced. Tom Select throws if re-initialized, so skip those.
130+
if (el.tomselect) {
131+
return;
132+
}
133+
new TomSelect(el, {
134+
maxOptions: null, // never truncate the filtered list
135+
searchField: ['text', 'value'],
136+
});
137+
});
138+
}
139+
</script>
140+
86141
<%def name="render_options(select_name, selections, current_selection)">
87-
<select name="${ select_name }">
142+
<select name="${ select_name }" class="cohort-event-select">
88143
%for option in selections:
89144
%if option == '---':
90145
<option value="" disabled="disabled">----</option>

0 commit comments

Comments
 (0)