Skip to content

Commit 298d7c8

Browse files
committed
add bootstrap 5 support
- select bootstrap version via KEG_BOOTSTRAP_MAJOR_VERSION config (default 5) - select whether bootstrap is used at all via KEG_TEMPLATE_FLAVOR config (default bootstrap) - use tomselect for bootstrap 5+, in place of select2 (to remove jQuery dependency) refs #188
1 parent 8c8b18c commit 298d7c8

11 files changed

Lines changed: 379 additions & 10 deletions

File tree

docs/source/getting-started.rst

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ from settings. The first of these defined is used:
4242
Form selects are rendered with select2 in templates extending ``keg-elements/form-view.html``.
4343
``keg-elements/select2-scripts.html`` and ``keg-elements/select2-styles.html`` can be included
4444
in templates to render select2s without extending form-view. Apps can opt out of select2
45-
rendering with ``KEG_USE_SELECT2`` config.
45+
rendering with ``KEG_USE_ENHANCED_SELECTS`` config.
4646

4747

4848
.. _gs-model:

keg_elements/templates/keg-elements/form-view.html

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,26 +1,26 @@
11
{% if is_read_only %}
22
{% import 'keg-elements/forms/horizontal-static.html' as horizontal %}
33
{% else %}
4-
{% import 'keg-elements/forms/horizontal-b4.html' as horizontal %}
4+
{% import 'keg-elements/forms/horizontal.html' as horizontal %}
55
{% endif %}
66

77
{% set base_template = config.get('BASE_TEMPLATE') or config.get('KEG_BASE_TEMPLATE') %}
88
{% if base_template %}
99
{% extends base_template %}
1010
{% endif %}
1111

12-
{% block scripts %}
12+
{% block keg_scripts %}
1313
{{ super() }}
14-
{% if config.get('KEG_USE_SELECT2', True) %}
15-
{% include 'keg-elements/forms/select2-scripts.html' %}
14+
{% if config.get('KEG_USE_ENHANCED_SELECTS', True) %}
15+
{% include 'keg-elements/forms/enhanced-select-scripts.html' %}
1616
{% endif %}
1717
{{ horizontal.custom_js() }}
1818
{% endblock %}
1919

20-
{% block styles %}
20+
{% block keg_styles %}
2121
{{ super() }}
22-
{% if config.get('KEG_USE_SELECT2', True) %}
23-
{% include 'keg-elements/forms/select2-styles.html' %}
22+
{% if config.get('KEG_USE_ENHANCED_SELECTS', True) %}
23+
{% include 'keg-elements/forms/enhanced-select-styles.html' %}
2424
{% endif %}
2525
{{ horizontal.custom_css() }}
2626
{% endblock %}
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
{% if config.get('KEG_TEMPLATE_FLAVOR', 'bootstrap') == 'bootstrap' %}
2+
{% if config.get('KEG_BOOTSTRAP_MAJOR_VERSION', 5) >= 5 %}
3+
{% include 'keg-elements/forms/tomselect-scripts.html' %}
4+
{% else %}
5+
{% include 'keg-elements/forms/select2-scripts.html' %}
6+
{% endif %}
7+
{% endif %}
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
{% if config.get('KEG_TEMPLATE_FLAVOR', 'bootstrap') == 'bootstrap' %}
2+
{% if config.get('KEG_BOOTSTRAP_MAJOR_VERSION', 5) >= 5 %}
3+
{% include 'keg-elements/forms/tomselect-styles.html' %}
4+
{% else %}
5+
{% include 'keg-elements/forms/select2-styles.html' %}
6+
{% endif %}
7+
{% endif %}
Lines changed: 328 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,328 @@
1+
{%- if _ is not defined -%}
2+
{% macro _(message) -%}
3+
{{ message }}
4+
{%- endmacro %}
5+
{%- endif -%}
6+
7+
{# Renders field for bootstrap 4 standards.
8+
9+
Params:
10+
field - WTForm field
11+
kwargs - pass any arguments you want in order to put them into the html attributes.
12+
There are few exceptions: for - for_, class - class_, class__ - class_
13+
14+
Example usage:
15+
{{ horz_form.field(form.email, placeholder='Input email', type='email') }}
16+
#}
17+
18+
{% macro div_form_group(field) -%}
19+
<div class="row mb-3 {{ 'required' if field.flags.required }} {{ kwargs.get('class_', '') }}">
20+
{{ caller(**kwargs) }}
21+
</div>
22+
{%- endmacro %}
23+
24+
{% macro description(field) -%}
25+
<small class="form-text text-muted description">
26+
{% if field.description is callable %}
27+
{{ field.description()|safe }}
28+
{% else %}
29+
{{ field.description|safe }}
30+
{% endif %}
31+
</small>
32+
{%- endmacro %}
33+
34+
{% macro field_errors(field) -%}
35+
{# You will need javascript similar to below for validations to work properly.
36+
Alternatively, remove .was-validated from the form element to disable native client side validations by default.
37+
38+
var forms = document.getElementsByClassName('needs-validation');
39+
var validation = Array.prototype.filter.call(forms, function(form) {
40+
form.addEventListener('submit', function(event) {
41+
if (form.checkValidity() === false) {
42+
event.preventDefault();
43+
event.stopPropagation();
44+
}
45+
form.classList.add('was-validated');
46+
}, false);
47+
});
48+
49+
var fields = document.getElementsByClassName('is-invalid');
50+
Array.prototype.filter.call(fields, function(field) {
51+
field.setCustomValidity(false);
52+
});
53+
#}
54+
<div class="invalid-feedback">
55+
{% if field.errors %}
56+
{% for e in field.errors %}
57+
<p>{{ e }}</p>
58+
{% endfor %}
59+
{% elif field.flags.required %}
60+
<p>This field is required.</p>
61+
{% endif %}
62+
</div>
63+
{% endmacro %}
64+
65+
{% macro field(field, label_visible=true) -%}
66+
{% call div_form_group(field, **kwargs) %}
67+
{% if (field.type != 'HiddenField' and field.type != 'CSRFTokenField') and label_visible %}
68+
{{ field.label(class_='col-form-label col-3') }}
69+
{% endif %}
70+
<div class="col-9">
71+
{{ field_widget(field, **kwargs) }}
72+
{% if field.description %}
73+
{{ description(field) }}
74+
{% endif %}
75+
</div>
76+
{% endcall %}
77+
{%- endmacro %}
78+
79+
80+
{% macro custom_css() %}
81+
<!-- included from keg-elements custom-css macro -->
82+
<style>
83+
.multi-checkbox > * {
84+
flex: 1 1 48%;
85+
margin: 0 1%;
86+
}
87+
88+
.multi-checkbox-controls {
89+
padding: 0.5em 1em;
90+
}
91+
</style>
92+
{% endmacro %}
93+
94+
{% macro custom_js() %}
95+
<!-- included from keg-elements custom-js macro -->
96+
<script>
97+
document.querySelectorAll('[multi-checkbox-data^="select-"]').forEach(item => {
98+
const target_value = item.attributes['multi-checkbox-data'].value.indexOf('select-all') !== -1;
99+
item.addEventListener('click', event => {
100+
event.target.parentElement.parentElement.querySelectorAll('.custom-checkbox input').forEach(checkbox => {
101+
checkbox.checked = target_value;
102+
});
103+
event.preventDefault();
104+
})
105+
})
106+
</script>
107+
{{ datetime_helper() }}
108+
{% endmacro %}
109+
110+
{% macro multi_checkbox(field) %}
111+
<div class="multi-checkbox-controls">
112+
<button multi-checkbox-data="select-all">Select All</button>
113+
<button multi-checkbox-data="select-none">Select None</button>
114+
</div>
115+
<div id="{{ field.id }}" class="d-flex flex-row flex-wrap multi-checkbox">
116+
{% for choice_id, choice in field.choices %}
117+
<div class="custom-control custom-checkbox">
118+
<input type="checkbox" name="{{ field.name }}"
119+
{{ 'checked="checked"' if field.data and choice_id in field.data else "" }}
120+
class="custom-control-input" value="{{ choice_id }}" id="{{ field.id }}{{choice_id}}">
121+
<label class="col-form-label custom-control-label" for="{{ field.id }}{{ choice_id }}">{{choice}}</label>
122+
</div>
123+
{% endfor %}
124+
</div>
125+
{% endmacro %}
126+
127+
{% macro field_widget(field) %}
128+
{% if field.type == "MultiCheckboxField" %}
129+
{{ multi_checkbox(field) }}
130+
{% else %}
131+
{% if field.flags.disabled %}{% set _dummy = kwargs.update({'disabled': field.flags.disabled}) %}{% endif %}
132+
{% if field.flags.readonly %}{% set _dummy = kwargs.update({'readonly': field.flags.readonly}) %}{% endif %}
133+
{{ field(class_='form-control is-invalid' if field.errors else 'form-control', **kwargs) }}
134+
{% endif %}
135+
{{ field_errors(field) }}
136+
{% endmacro %}
137+
138+
{# Renders checkbox fields since they are represented differently in bootstrap
139+
Params:
140+
field - WTForm field (there are no check, but you should put here only BooleanField.
141+
kwargs - pass any arguments you want in order to put them into the html attributes.
142+
There are few exceptions: for - for_, class - class_, class__ - class_
143+
144+
Example usage:
145+
{{ horiz_form.checkbox_field(form.remember_me) }}
146+
#}
147+
{% macro checkbox_field(field) -%}
148+
{% call div_form_group(field, **kwargs) %}
149+
<div class="col-sm-9 offset-sm-3">
150+
<div class="pt-2 checkbox form-check custom-control custom-checkbox">
151+
{% if field.flags.disabled %}{% set _dummy = kwargs.update({'disabled': field.flags.disabled}) %}{% endif %}
152+
{{ field(type='checkbox', class_='form-check-input custom-control-input' + (' is-invalid' if field.errors else ''), **kwargs) }}
153+
{# pt-0 is to align the label with the checkbox by removing the padding #}
154+
<label class="pt-0 col-form-label form-check-label custom-control-label" for="{{ field.id }}">
155+
{{ field.label }}
156+
</label>
157+
{{ field_errors(field) }}
158+
</div>
159+
{% if field.description %}
160+
{{ description(field) }}
161+
{% endif %}
162+
</div>
163+
{% endcall %}
164+
{%- endmacro %}
165+
166+
{# Renders radio field
167+
Params:
168+
field - WTForm field (must have an `iter_choices` method)
169+
kwargs - pass any arguments you want in order to put them into the html attributes.
170+
There are few exceptions: for - for_, class - class_, class__ - class_
171+
172+
Example usage:
173+
{{ horiz_form.radio_field(form.answers) }}
174+
#}
175+
{% macro radio_field(field, label_visible=true) -%}
176+
<fieldset class="row mb-3 {{ 'required' if field.flags.required }} {{ kwargs.get('class_', '') }}">
177+
{% if label_visible %}
178+
{{ field.label(class_='col-form-label col-3') }}
179+
{% endif %}
180+
<div class="col-9">
181+
{% for value, label, checked in field.iter_choices() %}
182+
<div class="radio form-check">
183+
<input type="radio"
184+
class="form-check-input{{' is-invalid' if field.errors }}"
185+
name="{{ field.id }}"
186+
id="{{ field.id }}-{{ value | lower }}"
187+
value="{{ value }}"
188+
{{ 'checked' if checked }}
189+
{{ 'disabled' if field.flags.disabled or (field.flags.readonly and not checked) }}
190+
>
191+
<label class="form-check-label">
192+
{{ label }}
193+
</label>
194+
{% if loop.last %}{{ field_errors(field) }}{% endif %}
195+
</div>
196+
{% endfor %}
197+
</div>
198+
{% if field.description %}
199+
{{ description(field) }}
200+
{% endif %}
201+
</fieldset>
202+
{%- endmacro %}
203+
204+
{% macro submit_group(action_text='Submit', btn_class='btn btn-primary', cancel_url='') -%}
205+
<div class="row mb-3 col-9 offset-sm-3">
206+
<div class="p-0">
207+
<button type="submit" class="{{ btn_class }}">{{ action_text }} </button>
208+
{% if cancel_url %}
209+
<a href="{{cancel_url}}" class="cancel">Cancel</a>
210+
{% endif %}
211+
</div>
212+
</div>
213+
{%- endmacro %}
214+
215+
{% macro render_field(f) -%}
216+
{% if f is none %}
217+
{# Do nothing b/c the field is None #}
218+
{% elif f.type == 'BooleanField' or f.widget.input_type == 'checkbox' %}
219+
{{ checkbox_field(f, **kwargs) }}
220+
{% elif f.type == 'RadioField' %}
221+
{{ radio_field(f, **kwargs) }}
222+
{% elif f.type == 'FormField' %}
223+
{{ render_form_fields(f, render_hidden=true) }}
224+
{% else %}
225+
{{ field(f, **kwargs) }}
226+
{% endif %}
227+
{%- endmacro %}
228+
229+
230+
{% macro form_errors(form) -%}
231+
{% if form.form_errors %}
232+
<div class="text-danger">
233+
{% for e in form.form_errors %}
234+
<p>{{ e }}</p>
235+
{% endfor %}
236+
</div>
237+
{% endif %}
238+
{% endmacro %}
239+
240+
241+
{# Renders WTForm in bootstrap way. There are two ways to call function:
242+
- as macros: it will render all field forms using cycle to iterate over them
243+
- as call: it will insert form fields as you specify:
244+
e.g. {% call macros.render_form(form, action_url=url_for('login_view'), action_text='Login',
245+
class_='login-form') %}
246+
{{ macros.render_field(form.email, placeholder='Input email', type='email') }}
247+
{{ macros.render_field(form.password, placeholder='Input password', type='password') }}
248+
{{ macros.render_checkbox_field(form.remember_me, type='checkbox') }}
249+
{% endcall %}
250+
251+
Params:
252+
form - WTForm class
253+
action_url - url where to submit this form
254+
action_text - text of submit button
255+
class_ - sets a class for form
256+
#}
257+
{% macro form(
258+
form,
259+
field_names=None,
260+
action_url='',
261+
action_text='Submit',
262+
class_='',
263+
btn_class='btn btn-primary',
264+
cancel_url='',
265+
form_upload=false,
266+
dirty_check=false,
267+
form_id=None,
268+
form_name=None,
269+
form_attrs=None
270+
) -%}
271+
272+
<form method="POST"
273+
action="{{ action_url }}"
274+
role="form"
275+
class="{{ class_ }} {{ 'was-validated needs-validation' if form.errors else 'needs-validation' }}"
276+
{% if form_id %}id="{{ form_id }}"{% endif %}
277+
{% if form_name %}name="{{ form_name }}"{% endif %}
278+
{% if form_upload %}enctype="multipart/form-data"{% endif %}
279+
{% if dirty_check %}data-dirty-check="on"{% endif %}
280+
novalidate
281+
{% for attr_key, attr_value in (form_attrs or {}).items() %}
282+
{{attr_key}}{% if attr_value %}="{{attr_value}}"{% endif %}
283+
{% endfor %}
284+
>
285+
{{ form.hidden_tag() if form.hidden_tag }}
286+
{{ form_errors(form) }}
287+
288+
{% if caller %}
289+
{{ caller() }}
290+
{% elif field_names %}
291+
{{ fields(form, field_names) }}
292+
{% else %}
293+
{{ render_form_fields(form) }}
294+
{% endif %}
295+
{{ submit_group(action_text=action_text, btn_class=btn_class, cancel_url=cancel_url) }}
296+
</form>
297+
{%- endmacro %}
298+
299+
{% macro render_form_fields(form, render_hidden=false) -%}
300+
{# Render hidden tags if flag is passed (for subforms only) #}
301+
{{ form.hidden_tag() if render_hidden and form.hidden_tag }}
302+
{% for f in form %}
303+
{% if not f.widget.input_type == 'hidden' %}
304+
{{ render_field(f) }}
305+
{% endif %}
306+
{% endfor %}
307+
{%- endmacro %}
308+
309+
{% macro fields(form, field_names) -%}
310+
{% for field_name in field_names %}
311+
{{ render_field(form[field_name]) }}
312+
{% endfor %}
313+
{%- endmacro %}
314+
315+
{% macro section(heading, form, field_names) -%}
316+
<h2>{{heading}}</h2>
317+
{% if caller %}
318+
{{ caller() }}
319+
{% else %}
320+
{{ fields(form, field_names) }}
321+
{% endif %}
322+
{%- endmacro %}
323+
324+
{% macro datetime_helper() -%}
325+
<script type="text/javascript">
326+
{% include "keg-elements/forms/datetime-helper.js" %}
327+
</script>
328+
{%- endmacro %}
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
{% if config.get('KEG_TEMPLATE_FLAVOR', 'bootstrap') == 'bootstrap' %}
2+
{% extends 'keg-elements/forms/horizontal-b' + config.get('KEG_BOOTSTRAP_MAJOR_VERSION', 5)|string + '.html' %}
3+
{% endif %}

0 commit comments

Comments
 (0)