A textarea with a character counter widget for Flask, WTForms and Bootstrap
Adding a WTForms textarea widget looks easy but differences between Linux and Windows cause unexpected problems.
I hoped to tell you today that you could comment on the blog posts of this website now. That would have meant that I completed the first implementation of the comments system. Unfortunately I stumbled upon some problems, yes of course, I am a programmer, and one of them involved the TextAreaField.
I just wanted a simple extended version of the WTForm TextAreaField, just add a character counter field below the textarea and that's it. I thought this would take few hours but was totally wrong but I somehow solved this and thought to share this with you. Let me know your thoughts ... hmmm ... when the comments get online ... :-)
I am using Bootstrap and jQuery. For jQuery the textarea with remaining characters has been documented many times. Many solutions suggest following the Twitter example. This means that you keep showing the full text even if the number of characters exceeds the allowed number. If the number of characters exceeds the allowed number we show the number of characters in the color red. No problem, I will implement.
Specifing the maximum number of characters at one place
I want the widget to be universal, so no hard-coded values in it. jQuery is used to count the actual number of characters but how does jQuery know what is the maximum allowed? We can define constants and use these everywhere but it is more flexible to implement extra HTML5 data-attributes for the textarea:
- data-char-count-min
- data-char-count-max
We add an extra element, showing the remaining number of characters below the textarea. The data-attrbutes can be referenced by jQuery code and used to calculate the number of remaining characters.
Implementation
Again we first look at the WTForms code for the TextAreaField. See also a previous post. I copied this code and modified it into this:
class TextAreaWithCounterWidget(object):
"""
Renders a multi-line text area.
`rows` and `cols` ought to be passed as keyword args when rendering.
"""
def __init__(self):
pass
def __call__(self, field, **kwargs):
fname = 'TextAreaWithCounterWidget - __call__'
kwargs.setdefault('id', field.id)
if 'required' not in kwargs and 'required' in getattr(field, 'flags', []):
kwargs['required'] = True
return HTMLString('<textarea %s>%s</textarea>' % (
html_params(name=field.name, **kwargs),
escape(text_type(field._value()), quote=False)
) + '<span class="" id="' + field.id + '-char-count-num' + '"></span>' )
class TextAreaWithCounterField(StringField):
"""
This field represents an HTML ``<textarea>`` and can be used to take
multi-line input.
"""
widget = TextAreaWithCounterWidget()
Then I use it as follows:
def strip_whitespace(s):
if isinstance(s, str):
s = s.strip()
return s
class ContentItemCommentForm(FlaskForm):
message = TextAreaWithCounterField(_l('Your message'),
render_kw={'data-char-count-min': 0, 'data-char-count-max': 1000, 'rows': 4},
validators=[ InputRequired(), Length(min=6, max=1000) ],
filters=[ strip_whitespace ] )
submit = SubmitField(_l('Add'))
Sometimes things are more easy than expected. We can simply add our new parameters to the form using render_kw. Then in the widget they are passed as attributes to the textarea. If we want we can also access these parameters in the widget using kwargs. We can reference them as:
data_char_count_max = kwargs['data-char-count-max']
We can also pop them from kwargs, meaning read and remove. Then they will not appear as attributes in the textarea:
char_count_max = kwargs.pop('char_count_max', None)
But there is no need to use this here. Inside the widget we also can access the Length validator values but again, no need for this here.
I also specified the number of rows in render_kw. This is passed to the textarea field. The generated HTML is appended with the extra element showing the remaining number of characters. The id of this element is constructed from the textarea id:
field.id + '-char-count-num'
The filter strip_whitespace is called to trim leading and trailing white space.
The jQuery code is not that difficult. I am using Bootstrap, the classes change the color and set padding:
function update_character_count(textarea_id){
var char_count_num_id = textarea_id + '-char-count-num';
if( ($("#" + textarea_id).length == 0) || ($("#" + char_count_num_id).length == 0) ){
// must exist
return;
}
var char_count_min = parseInt( $('#' + textarea_id).data('char-count-min'), 10 );
var char_count_max = parseInt( $('#' + textarea_id).data('char-count-max'), 10 );
var remaining = char_count_max - $('#' + textarea_id).val().length;
$('#' + char_count_num_id).html( '' + remaining );
if(remaining >= 0){
$('#' + char_count_num_id).removeClass('text-danger');
$('#' + char_count_num_id).addClass('pl-2 text-secondary');
}else{
$('#' + char_count_num_id).removeClass('text-secondary');
$('#' + char_count_num_id).addClass('pl-2 text-danger');
}
}
and:
$(document).ready(function(){
...
// comment form: character count
update_character_count('comment_form-message');
$('#comment_form-message').on('change input paste', function(event){
update_character_count('comment_form-message');
});
}
Testing and mismatch in character count
Now we can start testing the new TextAreaWithCounterField. Everything looked fine until I started entering newlines. The length was reported correct by jQuery which means that they were counted as a single character. But the WTForms validator said that the maximum length was exceeded. Debugging time again, in the widget I printed the characters received by WTForms:
message = field.data
if message is None:
current_app.logger.debug(fname + ': message is None')
else:
current_app.logger.debug(fname + ': message = {}, len(message) = {}, list(message) = {}'.format(message, len(message), list(message)))
This gave me the following result:
len(message) = 4, list(message) = ['a', '\r', '\n', 'b']
jQuery counts \r\n as a single character but WTForms counts it as two characters. Digging into WTForms code, validators.py, we see that it uses the Python len function to determine the length:
l = field.data and len(field.data) or 0
Understandable but in this case it is wrong! What to do? I did not want to override the default WTForms validation function. Fortunately we have the filters that are called before (!) validation. I was already using strip_whitespace and added a new filter:
def compress_newline(s):
if isinstance(s, str):
s = s.replace("\r\n", "\n")
s = re.sub(r'\n+', '\n', s)
return s
This filter replaces \r\n with a single \n. In addition, it replaces multiple \n characters by a single \n. The latter is just a very primitive protection against unavoidable wrong (and crazy) submits. The filters line then becomes:
filters=[ strip_whitespace, compress_newline ] )
Now it worked as intended.
Summary
Using render_kw is an easy way to pass parameters to the widget. I searched the internet for the \r\n problem with the WTForms TextAreaField but could not find any references. Please do not tell me I am the only one. The problem is caused by a mismatch between Windows and Linux systems. Also, Javascript/jQuery coding takes time if you do not do this all the time.
Links / credits
How to specify rows and columns of a <textarea > tag using wtforms
https://stackoverflow.com/questions/4930747/how-to-specify-rows-and-columns-of-a-textarea-tag-using-wtforms
New line in text area
https://stackoverflow.com/questions/8627902/new-line-in-text-area
Using data attributes
https://developer.mozilla.org/en-US/docs/Learn/HTML/Howto/Use_data_attributes
WTForms - Fields
https://wtforms.readthedocs.io/en/stable/fields.html
WTForms - Widgets
https://wtforms.readthedocs.io/en/stable/widgets.html
Leave a comment
Comment anonymously or log in to comment.
Comments (1)
Leave a reply
Reply anonymously or log in to reply.
Thanks for sharing! I wrote long comment but token has expired since I was reading the other tabs and I lost my comment :/. In short - great article and real demo, which I can see here, while typing this one again.
Recent
- Hiding database UUID primary keys of your web application
- Don't Repeat Yourself (DRY) with Jinja2
- SQLAlchemy, PostgreSQL, maximum number of rows per user
- Show the values in SQLAlchemy dynamic filters
- Secure data transfer with Public Key encryption and pyNaCl
- rqlite: a high-availability and distributed SQLite alternative
Most viewed
- Using Python's pyOpenSSL to verify SSL certificates downloaded from a host
- Using UUIDs instead of Integer Autoincrement Primary Keys with SQLAlchemy and MariaDb
- Connect to a service on a Docker host from a Docker container
- Using PyInstaller and Cython to create a Python executable
- SQLAlchemy: Using Cascade Deletes to delete related objects
- Flask RESTful API request parameter validation with Marshmallow schemas