} ignore A list of characters that should be removed
220 | * ignored when calculating the counters.
221 | * (default: )
222 | */
223 |
224 | const Countable = {
225 |
226 | /**
227 | * The `on` method binds the counting handler to all given elements. The
228 | * event is either `oninput` or `onkeydown`, based on the capabilities of
229 | * the browser.
230 | *
231 | * @param {Nodes} elements All elements that should receive the
232 | * Countable functionality.
233 | *
234 | * @param {Function} callback The callback to fire whenever the
235 | * element's value changes. The callback is
236 | * called with the relevant element bound
237 | * to `this` and the counted values as the
238 | * single parameter.
239 | *
240 | * @param {Object} [options] An object to modify Countable's
241 | * behaviour.
242 | *
243 | * @return {Object} Returns the Countable object to allow for chaining.
244 | */
245 |
246 | on: function (elements, callback, options) {
247 | if (!validateArguments(elements, callback)) return
248 |
249 | if (!Array.isArray(elements)) {
250 | elements = [ elements ]
251 | }
252 |
253 | each.call(elements, function (e) {
254 | const handler = function () {
255 | callback.call(e, count(e, options))
256 | }
257 |
258 | liveElements.push({ element: e, handler: handler })
259 |
260 | handler()
261 |
262 | e.addEventListener('input', handler)
263 | })
264 |
265 | return this
266 | },
267 |
268 | /**
269 | * The `off` method removes the Countable functionality from all given
270 | * elements.
271 | *
272 | * @param {Nodes} elements All elements whose Countable functionality
273 | * should be unbound.
274 | *
275 | * @return {Object} Returns the Countable object to allow for chaining.
276 | */
277 |
278 | off: function (elements) {
279 | if (!validateArguments(elements, function () {})) return
280 |
281 | if (!Array.isArray(elements)) {
282 | elements = [ elements ]
283 | }
284 |
285 | liveElements.filter(function (e) {
286 | return elements.indexOf(e.element) !== -1
287 | }).forEach(function (e) {
288 | e.element.removeEventListener('input', e.handler)
289 | })
290 |
291 | liveElements = liveElements.filter(function (e) {
292 | return elements.indexOf(e.element) === -1
293 | })
294 |
295 | return this
296 | },
297 |
298 | /**
299 | * The `count` method works mostly like the `live` method, but no events are
300 | * bound, the functionality is only executed once.
301 | *
302 | * @param {Nodes|String} targets All elements that should be counted.
303 | *
304 | * @param {Function} callback The callback to fire whenever the
305 | * element's value changes. The callback
306 | * is called with the relevant element
307 | * bound to `this` and the counted values
308 | * as the single parameter.
309 | *
310 | * @param {Object} [options] An object to modify Countable's
311 | * behaviour.
312 | *
313 | * @return {Object} Returns the Countable object to allow for chaining.
314 | */
315 |
316 | count: function (targets, callback, options) {
317 | if (!validateArguments(targets, callback)) return
318 |
319 | if (!Array.isArray(targets)) {
320 | targets = [ targets ]
321 | }
322 |
323 | each.call(targets, function (e) {
324 | callback.call(e, count(e, options))
325 | })
326 |
327 | return this
328 | },
329 |
330 | /**
331 | * The `enabled` method checks if the live-counting functionality is bound
332 | * to an element.
333 | *
334 | * @param {Node} element All elements that should be checked for the
335 | * Countable functionality.
336 | *
337 | * @return {Boolean} A boolean value representing whether Countable
338 | * functionality is bound to all given elements.
339 | */
340 |
341 | enabled: function (elements) {
342 | if (elements.length === undefined) {
343 | elements = [ elements ]
344 | }
345 |
346 | return liveElements.filter(function (e) {
347 | return elements.indexOf(e.element) !== -1
348 | }).length === elements.length
349 | }
350 |
351 | }
352 |
353 | /**
354 | * Expose Countable depending on the module system used across the
355 | * application. (Node / CommonJS, AMD, global)
356 | */
357 |
358 | if (typeof exports === 'object') {
359 | module.exports = Countable
360 | } else if (typeof define === 'function' && define.amd) {
361 | define(function () { return Countable })
362 | } else {
363 | global.Countable = Countable
364 | }
365 | }(this));
--------------------------------------------------------------------------------
/countable_field/widgets.py:
--------------------------------------------------------------------------------
1 | from django import VERSION
2 | from django.forms import widgets
3 | from django.utils.safestring import mark_safe
4 |
5 |
6 | class CountableWidget(widgets.Textarea):
7 | class Media:
8 | js = ('countable_field/js/scripts.js',)
9 | css = {
10 | 'all':
11 | ('countable_field/css/styles.css',)
12 | }
13 |
14 | def render(self, name, value, attrs=None, **kwargs):
15 | # the build_attrs signature changed in django version 1.11
16 | if VERSION[:2] >= (1, 11):
17 | final_attrs = self.build_attrs(self.attrs, attrs)
18 | else:
19 | final_attrs = self.build_attrs(attrs)
20 | # to avoid xss, if the min or max attributes are not integers, remove them
21 | if final_attrs.get('data-min-count') and not isinstance(final_attrs.get('data-min-count'), int):
22 | final_attrs.pop('data-min-count')
23 | if final_attrs.get('data-max-count') and not isinstance(final_attrs.get('data-max-count'), int):
24 | final_attrs.pop('data-max-count')
25 | if not final_attrs.get('data-count') in ['words', 'characters', 'paragraphs', 'sentences']:
26 | final_attrs['data-count'] = 'words'
27 |
28 | if VERSION[:2] >= (1, 11):
29 | output = super(CountableWidget, self).render(name, value, final_attrs, **kwargs)
30 | else:
31 | output = super(CountableWidget, self).render(name, value, final_attrs)
32 | output += self.get_word_count_template(final_attrs)
33 | return mark_safe(output)
34 |
35 | @staticmethod
36 | def get_word_count_template(attrs):
37 | count_type = attrs.get('data-count', 'words')
38 | count_direction = attrs.get('data-count-direction', 'up')
39 | max_count = attrs.get('data-max-count', '0')
40 |
41 | if count_direction == 'down':
42 | count_label = "Words remaining: "
43 | if count_type == "characters":
44 | count_label = "Characters remaining: "
45 | elif count_type == "paragraphs":
46 | count_label = "Paragraphs remaining: "
47 | elif count_type == "sentences":
48 | count_label = "Sentences remaining: "
49 | else:
50 | count_label = "Word count: "
51 | if count_type == "characters":
52 | count_label = "Character count: "
53 | elif count_type == "paragraphs":
54 | count_label = "Paragraph count: "
55 | elif count_type == "sentences":
56 | count_label = "Sentence count: "
57 | return (
58 | '%(label)s'
59 | '%(number)s\r\n'
60 | ) % {'label': count_label,
61 | 'id': attrs.get('id'),
62 | 'number': max_count if count_direction == 'down' else '0'}
63 |
64 |
65 |
--------------------------------------------------------------------------------
/example.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/RoboAndie/django-countable-field/ec9a49d86d3fa56c3cb006bba3b55c7516ed3b14/example.gif
--------------------------------------------------------------------------------
/example_project/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/RoboAndie/django-countable-field/ec9a49d86d3fa56c3cb006bba3b55c7516ed3b14/example_project/__init__.py
--------------------------------------------------------------------------------
/example_project/forms.py:
--------------------------------------------------------------------------------
1 | from django import forms
2 |
3 | from crispy_forms.helper import FormHelper
4 |
5 | from countable_field.widgets import CountableWidget
6 |
7 |
8 | class CountableTestForm(forms.Form):
9 | char_count_field = forms.CharField(label="Character count")
10 | word_count_field = forms.CharField(label="Word count")
11 | paragraph_count_field = forms.CharField(label="Paragraph count")
12 | sentence_count_field = forms.CharField(label="Sentence count")
13 |
14 | def __init__(self, *args, **kwargs):
15 | super(CountableTestForm, self).__init__(*args, **kwargs)
16 | self.fields['char_count_field'].widget = CountableWidget(attrs={'data-max-count': 160,
17 | 'data-count': 'characters',
18 | 'data-count-direction': 'down'})
19 | self.fields['char_count_field'].help_text = "Type up to 160 characters"
20 | self.fields['word_count_field'].widget = CountableWidget(attrs={'data-min-count': 100,
21 | 'data-max-count': 200})
22 | self.fields['word_count_field'].help_text = "Must be between 100 and 200 words"
23 | self.fields['paragraph_count_field'].widget = CountableWidget(attrs={'data-min-count': 2,
24 | 'data-count': 'paragraphs'})
25 | self.fields['paragraph_count_field'].help_text = "Must be at least 2 paragraphs"
26 | self.fields['sentence_count_field'].widget = CountableWidget(attrs={'data-count': 'sentences'})
27 |
28 | self.helper = FormHelper()
29 | self.helper.wrapper_class = 'row'
30 | self.helper.label_class = 'col-md-3'
31 | self.helper.field_class = 'col-md-9'
32 | self.helper.help_text_inline = False
33 |
--------------------------------------------------------------------------------
/example_project/requirements.txt:
--------------------------------------------------------------------------------
1 | django-crispy-forms==1.7.2
--------------------------------------------------------------------------------
/example_project/settings.py:
--------------------------------------------------------------------------------
1 | from django.conf.urls import url
2 | from os.path import join, dirname, abspath
3 |
4 | from example_project import views
5 |
6 | DEBUG = True
7 |
8 | SECRET_KEY = '4l0ngs3cr3tstr1ngw3lln0ts0l0ngw41tn0w1tsl0ng3n0ugh'
9 | ROOT_URLCONF = __name__
10 |
11 | urlpatterns = [
12 | url(r'^$', views.test_form_view),
13 | ]
14 |
15 | INSTALLED_APPS = [
16 | 'django.contrib.staticfiles',
17 |
18 | 'crispy_forms',
19 |
20 | 'example_project',
21 | 'countable_field'
22 | ]
23 |
24 | CRISPY_TEMPLATE_PACK = 'bootstrap4'
25 |
26 | BASE_DIR = dirname(dirname(abspath(__file__)))
27 | TEMPLATES = [
28 | {
29 | 'BACKEND': 'django.template.backends.django.DjangoTemplates',
30 | 'DIRS': [join(BASE_DIR, "templates")],
31 | 'APP_DIRS': True,
32 | },
33 | ]
34 |
35 | PROJECT_ROOT = dirname(abspath(__file__))
36 | STATIC_ROOT = join(PROJECT_ROOT, 'staticfiles')
37 |
38 | STATIC_URL = '/static/'
39 | STATICFILES_STORAGE = 'django.contrib.staticfiles.storage.StaticFilesStorage'
40 |
--------------------------------------------------------------------------------
/example_project/templates/example_project/home.html:
--------------------------------------------------------------------------------
1 | {% load crispy_forms_tags %}
2 |
3 |
4 |
5 |
6 |
7 |
8 | Test Countable Field
9 |
10 |
11 |
12 |
13 |
14 | {% crispy form %}
15 |
16 |
17 |
--------------------------------------------------------------------------------
/example_project/urls.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/RoboAndie/django-countable-field/ec9a49d86d3fa56c3cb006bba3b55c7516ed3b14/example_project/urls.py
--------------------------------------------------------------------------------
/example_project/views.py:
--------------------------------------------------------------------------------
1 | from django.shortcuts import render
2 |
3 | from example_project.forms import CountableTestForm
4 |
5 |
6 | def test_form_view(request):
7 | form = CountableTestForm()
8 | return render(request, "example_project/home.html", {'form': form})
9 |
10 |
--------------------------------------------------------------------------------
/example_project/wsgi.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/RoboAndie/django-countable-field/ec9a49d86d3fa56c3cb006bba3b55c7516ed3b14/example_project/wsgi.py
--------------------------------------------------------------------------------
/manage.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python
2 | import os
3 | import sys
4 |
5 | if __name__ == "__main__":
6 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "tests.test_settings")
7 |
8 | from django.core.management import execute_from_command_line
9 |
10 | execute_from_command_line(sys.argv)
11 |
--------------------------------------------------------------------------------
/runtests.py:
--------------------------------------------------------------------------------
1 | import sys
2 |
3 | import django
4 | from django.conf import settings
5 | from django.test.runner import DiscoverRunner
6 |
7 | settings.configure(
8 | DEBUG=True,
9 | SECRET_KEY='fake-key',
10 | INSTALLED_APPS=(
11 | 'tests',
12 | 'countable_field'
13 | ),
14 | )
15 |
16 | if hasattr(django, 'setup'):
17 | django.setup()
18 |
19 | test_runner = DiscoverRunner(verbosity=1)
20 |
21 | failures = test_runner.run_tests(['tests', ])
22 |
23 | if failures:
24 | sys.exit(failures)
25 |
--------------------------------------------------------------------------------
/setup.py:
--------------------------------------------------------------------------------
1 | import os
2 | from setuptools import find_packages, setup
3 |
4 | with open(os.path.join(os.path.dirname(__file__), 'README.rst')) as readme:
5 | README = readme.read()
6 |
7 | # allow setup.py to be run from any path
8 | os.chdir(os.path.normpath(os.path.join(os.path.abspath(__file__), os.pardir)))
9 |
10 | setup(
11 | name='django-countable-field',
12 | version='1.3',
13 | packages=find_packages(),
14 | include_package_data=True,
15 | license='MIT License',
16 | description='A simple Django form field widget to display a text field with the current word count.',
17 | long_description=README,
18 | url='https://github.com/RoboAndie/django-countable-field',
19 | author='Andrea Robertson',
20 | author_email='roboandie@gmail.com',
21 | classifiers=[
22 | 'Environment :: Web Environment',
23 | 'Framework :: Django',
24 | 'Framework :: Django :: 1.11',
25 | 'Intended Audience :: Developers',
26 | 'License :: OSI Approved :: MIT License',
27 | 'Operating System :: OS Independent',
28 | 'Programming Language :: Python',
29 | 'Programming Language :: Python :: 3',
30 | 'Programming Language :: Python :: 3.4',
31 | 'Programming Language :: Python :: 3.5',
32 | 'Topic :: Internet :: WWW/HTTP',
33 | 'Topic :: Internet :: WWW/HTTP :: Dynamic Content',
34 | ],
35 | )
--------------------------------------------------------------------------------
/tests/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/RoboAndie/django-countable-field/ec9a49d86d3fa56c3cb006bba3b55c7516ed3b14/tests/__init__.py
--------------------------------------------------------------------------------
/tests/test_settings.py:
--------------------------------------------------------------------------------
1 | SECRET_KEY = 'fake-key'
2 | INSTALLED_APPS = [
3 | "tests",
4 | "countable_field"
5 | ]
6 |
7 | DATABASES = {
8 | 'default': {
9 | 'ENGINE': 'django.db.backends.sqlite3',
10 | }
11 | }
--------------------------------------------------------------------------------
/tests/tests.py:
--------------------------------------------------------------------------------
1 | from django.test import TestCase
2 |
3 | from countable_field import widgets
4 |
5 | class WidgetTestCase(TestCase):
6 |
7 | def test_no_limits(self):
8 | widget = widgets.CountableWidget()
9 | result = widget.render('countable', None)
10 | self.assertTrue(str(result).__contains__('data-min-count="false"'))
11 | self.assertTrue(str(result).__contains__('data-max-count="false"'))
12 |
13 | def test_lower_limit(self):
14 | widget = widgets.CountableWidget()
15 | result = widget.render('countable', None, attrs={'data-min-count': 50})
16 | self.assertTrue(str(result).__contains__('data-min-count="50"'))
17 | self.assertTrue(str(result).__contains__('data-max-count="false"'))
18 |
19 | def test_upper_limit(self):
20 | widget = widgets.CountableWidget()
21 | result = widget.render('countable', None, attrs={'data-max-count': 70})
22 | self.assertTrue(str(result).__contains__('data-min-count="false"'))
23 | self.assertTrue(str(result).__contains__('data-max-count="70"'))
24 |
25 | def test_both_limits(self):
26 | widget = widgets.CountableWidget()
27 | result = widget.render('countable', None, attrs={'data-min-count': 30, 'data-max-count': 80})
28 | self.assertTrue(str(result).__contains__('data-min-count="30"'))
29 | self.assertTrue(str(result).__contains__('data-max-count="80"'))
30 |
31 | def test_invalid_limits(self):
32 | widget = widgets.CountableWidget()
33 | result = widget.render('countable', None, attrs={'data-min-count': 'blue', 'data-max-count': 50.9})
34 | self.assertTrue(str(result).__contains__('data-min-count="false"'))
35 | self.assertTrue(str(result).__contains__('data-max-count="false"'))
36 |
--------------------------------------------------------------------------------