├── .DS_Store ├── .gitignore ├── LICENSE ├── MANIFEST.in ├── README.md ├── README.rst ├── countable_field ├── __init__.py ├── static │ └── countable_field │ │ ├── css │ │ └── styles.css │ │ └── js │ │ └── scripts.js └── widgets.py ├── example.gif ├── example_project ├── __init__.py ├── forms.py ├── requirements.txt ├── settings.py ├── templates │ └── example_project │ │ └── home.html ├── urls.py ├── views.py └── wsgi.py ├── manage.py ├── runtests.py ├── setup.py └── tests ├── __init__.py ├── test_settings.py └── tests.py /.DS_Store: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/RoboAndie/django-countable-field/ec9a49d86d3fa56c3cb006bba3b55c7516ed3b14/.DS_Store -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | *.egg-info/ 24 | .installed.cfg 25 | *.egg 26 | 27 | # PyInstaller 28 | # Usually these files are written by a python script from a template 29 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 30 | *.manifest 31 | *.spec 32 | 33 | # Installer logs 34 | pip-log.txt 35 | pip-delete-this-directory.txt 36 | 37 | # Unit test / coverage reports 38 | htmlcov/ 39 | .tox/ 40 | .coverage 41 | .coverage.* 42 | .cache 43 | nosetests.xml 44 | coverage.xml 45 | *.cover 46 | .hypothesis/ 47 | 48 | # Translations 49 | *.mo 50 | *.pot 51 | 52 | # Django stuff: 53 | *.log 54 | local_settings.py 55 | 56 | # Flask stuff: 57 | instance/ 58 | .webassets-cache 59 | 60 | # Scrapy stuff: 61 | .scrapy 62 | 63 | # Sphinx documentation 64 | docs/_build/ 65 | 66 | # PyBuilder 67 | target/ 68 | 69 | # Jupyter Notebook 70 | .ipynb_checkpoints 71 | 72 | # pyenv 73 | .python-version 74 | 75 | # celery beat schedule file 76 | celerybeat-schedule 77 | 78 | # SageMath parsed files 79 | *.sage.py 80 | 81 | # Environments 82 | .env 83 | .venv 84 | env/ 85 | venv/ 86 | ENV/ 87 | .idea/ 88 | 89 | # Spyder project settings 90 | .spyderproject 91 | .spyproject 92 | 93 | # Rope project settings 94 | .ropeproject 95 | 96 | # mkdocs documentation 97 | /site 98 | 99 | # mypy 100 | .mypy_cache/ 101 | 102 | 103 | .DS_Store 104 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 Andrea Robertson 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include LICENSE 2 | include README.rst 3 | recursive-include countable_field/static/* -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # django-countable-field 2 | A simple django form field widget for a text field with a current word count. It can alternatively be configured 3 | to display the character, paragraph, or sentence count. 4 | 5 | 6 | ## Installation and usage 7 | 1. Install with pip 8 | ``` 9 | pip install django-countable-field 10 | ``` 11 | 2. Add "countable_field" to your INSTALLED_APPS setting like this: 12 | ``` 13 | INSTALLED_APPS = [ 14 | ... 15 | 'countable_field', 16 | ] 17 | ``` 18 | 3. In the form, set the field's widget to be "CountableWidget", passing 19 | the count type, minimum and maximum word count as additional parameters, such as: 20 | ``` 21 | self.fields['essay_response'].widget = \ 22 | CountableWidget(attrs={'data-count': 'words', 23 | 'data-min-count': this.essay_min_length, 24 | 'data-max-count': this.essay_max_length}) 25 | ``` 26 | 4. Include `{{ form.media }}` in your template to render the JavaScript where `form` is the name of your form context variable. If you use the [Crispy Forms](https://github.com/django-crispy-forms/django-crispy-forms) app you can skip this step - it will take care of the media element for you. 27 | 28 | The following additional parameters are optional. `data-min-count` and `data-max-count` must be integers. 29 | `data-count` indicates what kind of text to count, and can be one of the following: `'words'` (default), 30 | `'characters'`, `'paragraphs'`, or `'sentences'`. 31 | 32 | Additional parameters: 33 | 34 | Attribute | Options 35 | ---------------------- | ------- 36 | `data-count` | The type of text to be counted. Options are `'words'` (default), `'characters'`, `'paragraphs'`, or `'sentences'`. 37 | `data-min-count` | The minimum of the text count type that is required for this field. Must be an integer. 38 | `data-max-count` | The maximum of the text count type that is allowed for this field. Must be an integer that is larger than the `data-min-count` (if set). 39 | `data-count-direction` | Whether the counter displays the current count or the allowed remaining count. Options are `'up'` (default) or `'down'`. Set to to `'down'` to display the allowed text remaining. 40 | 41 | To run the example project, run the server using the settings file in the example_project module. 42 | ``` 43 | python manage.py runserver --settings=example_project.settings 44 | ``` 45 | 46 | I wrote a blog post to explain how I made this app. Check it out here: https://andrearobertson.com/2017/06/17/django-example-creating-a-custom-form-field-widget/ 47 | 48 | ## Credit 49 | This project makes use of the Countable.js library courtesy of Sacha Schmid. https://sacha.me/Countable/ 50 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | =============== 2 | Countable Field 3 | =============== 4 | 5 | Countable Field is a simple Django custom for widget for displaying a 6 | text area with the current word count displayed underneath. It can be 7 | set up to display the count in red when the current word count is out 8 | of required minimum or maximum word count for the form field. 9 | 10 | Quick start 11 | ----------- 12 | 13 | 1. Add "countable_field" to your INSTALLED_APPS setting like this:: 14 | 15 | INSTALLED_APPS = [ 16 | ... 17 | 'countable_field', 18 | ] 19 | 20 | 2. In the form, set the field's widget to be "CountableWidget", passing 21 | the minimum and maximum word count as additional parameters, such as:: 22 | 23 | self.fields['essay_response'].widget = \ 24 | CountableWidget(attrs={'data-min-count': this.essay_min_length, 25 | 'data-max-count': this.essay_max_length}) 26 | 27 | -------------------------------------------------------------------------------- /countable_field/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/RoboAndie/django-countable-field/ec9a49d86d3fa56c3cb006bba3b55c7516ed3b14/countable_field/__init__.py -------------------------------------------------------------------------------- /countable_field/static/countable_field/css/styles.css: -------------------------------------------------------------------------------- 1 | .text-count { 2 | float: right; 3 | font-size: 80%; 4 | color: #818a91; 5 | } 6 | .text-count.text-is-under-min, 7 | .text-count.text-is-over-max { 8 | color: #d9534f; 9 | } -------------------------------------------------------------------------------- /countable_field/static/countable_field/js/scripts.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Control-specific JavaScript for countable-field 3 | * @author Andrea Robertson () 4 | * @version 1.3 5 | * @license MIT 6 | * @see 7 | */ 8 | (function() { 9 | function CountableField(textarea) { 10 | var countDisplay = document.getElementById(textarea.id + "_counter"); 11 | var allowedTypes = ["words", "paragraphs", "characters", "sentences", "all"]; 12 | var countDown = false; 13 | var minCount, maxCount, countType; 14 | if (textarea != null && countDisplay != null) { 15 | minCount = textarea.getAttribute("data-min-count"); 16 | maxCount = textarea.getAttribute("data-max-count"); 17 | countType = textarea.getAttribute("data-count"); 18 | if (textarea.getAttribute("data-count-direction") === "down"){ 19 | countDown = true; 20 | if (!maxCount > 0) 21 | maxCount = 0; 22 | } 23 | 24 | if (!countType || allowedTypes.indexOf(countType) < 0) 25 | countType = "words"; 26 | else if (countType === "characters") 27 | countType = "all"; // all includes spaces, but characters does not 28 | 29 | Countable.on(textarea, updateFieldWordCount); 30 | } 31 | 32 | function updateFieldWordCount(counter) { 33 | var count; 34 | if (countDown) 35 | count = maxCount - counter[countType]; 36 | else 37 | count = counter[countType]; 38 | countDisplay.getElementsByClassName("text-count-current")[0].innerHTML = count; 39 | if (minCount && counter[countType] < minCount) 40 | countDisplay.className = "text-count text-is-under-min"; 41 | else if (maxCount && counter[countType] > maxCount) 42 | countDisplay.className = "text-count text-is-over-max"; 43 | else 44 | countDisplay.className = "text-count"; 45 | } 46 | } 47 | 48 | document.addEventListener('DOMContentLoaded', function(e) { 49 | ;[].forEach.call(document.querySelectorAll('[data-count]'), CountableField) 50 | }) 51 | })() 52 | 53 | /** 54 | * Countable is a script to allow for live paragraph-, word- and character- 55 | * counting on an HTML element. 56 | * 57 | * @author Sacha Schmid () 58 | * @version 3.0.1 59 | * @license MIT 60 | * @see 61 | */ 62 | 63 | /** 64 | * Note: For the purpose of this internal documentation, arguments of the type 65 | * {Nodes} are to be interpreted as either {NodeList} or {Element}. 66 | */ 67 | 68 | ;(function (global) { 69 | 70 | /** 71 | * @private 72 | * 73 | * `liveElements` holds all elements that have the live-counting 74 | * functionality bound to them. 75 | */ 76 | 77 | let liveElements = [] 78 | const each = Array.prototype.forEach 79 | 80 | /** 81 | * `ucs2decode` function from the punycode.js library. 82 | * 83 | * Creates an array containing the decimal code points of each Unicode 84 | * character in the string. While JavaScript uses UCS-2 internally, this 85 | * function will convert a pair of surrogate halves (each of which UCS-2 86 | * exposes as separate characters) into a single code point, matching 87 | * UTF-16. 88 | * 89 | * @see 90 | * @see 91 | * 92 | * @param {String} string The Unicode input string (UCS-2). 93 | * 94 | * @return {Array} The new array of code points. 95 | */ 96 | 97 | function decode (string) { 98 | const output = [] 99 | let counter = 0 100 | const length = string.length 101 | 102 | while (counter < length) { 103 | const value = string.charCodeAt(counter++) 104 | 105 | if (value >= 0xD800 && value <= 0xDBFF && counter < length) { 106 | 107 | // It's a high surrogate, and there is a next character. 108 | 109 | const extra = string.charCodeAt(counter++) 110 | 111 | if ((extra & 0xFC00) == 0xDC00) { // Low surrogate. 112 | output.push(((value & 0x3FF) << 10) + (extra & 0x3FF) + 0x10000) 113 | } else { 114 | 115 | // It's an unmatched surrogate; only append this code unit, in case the 116 | // next code unit is the high surrogate of a surrogate pair. 117 | 118 | output.push(value) 119 | counter-- 120 | } 121 | } else { 122 | output.push(value) 123 | } 124 | } 125 | 126 | return output 127 | } 128 | 129 | /** 130 | * `validateArguments` validates the arguments given to each function call. 131 | * Errors are logged to the console as warnings, but Countable fails 132 | * silently. 133 | * 134 | * @private 135 | * 136 | * @param {Nodes|String} targets A (collection of) element(s) or a single 137 | * string to validate. 138 | * 139 | * @param {Function} callback The callback function to validate. 140 | * 141 | * @return {Boolean} Returns whether all arguments are vaild. 142 | */ 143 | 144 | function validateArguments (targets, callback) { 145 | const nodes = Object.prototype.toString.call(targets) 146 | const targetsValid = typeof targets === 'string' || ((nodes === '[object NodeList]' || nodes === '[object HTMLCollection]') || targets.nodeType === 1) 147 | const callbackValid = typeof callback === 'function' 148 | 149 | if (!targetsValid) console.error('Countable: Not a valid target') 150 | if (!callbackValid) console.error('Countable: Not a valid callback function') 151 | 152 | return targetsValid && callbackValid 153 | } 154 | 155 | /** 156 | * `count` trims an element's value, optionally strips HTML tags and counts 157 | * paragraphs, sentences, words, characters and characters plus spaces. 158 | * 159 | * @private 160 | * 161 | * @param {Node|String} target The target for the count. 162 | * 163 | * @param {Object} options The options to use for the counting. 164 | * 165 | * @return {Object} The object containing the number of paragraphs, 166 | * sentences, words, characters and characters 167 | * plus spaces. 168 | */ 169 | 170 | function count (target, options) { 171 | let original = '' + (typeof target === 'string' ? target : ('value' in target ? target.value : target.textContent)) 172 | options = options || {} 173 | 174 | /** 175 | * The initial implementation to allow for HTML tags stripping was created 176 | * @craniumslows while the current one was created by @Rob--W. 177 | * 178 | * @see 179 | * @see 180 | */ 181 | 182 | if (options.stripTags) original = original.replace(/<\/?[a-z][^>]*>/gi, '') 183 | 184 | if (options.ignore) { 185 | each.call(options.ignore, function (i) { 186 | original = original.replace(i, '') 187 | }) 188 | } 189 | 190 | const trimmed = original.trim() 191 | 192 | /** 193 | * Most of the performance improvements are based on the works of @epmatsw. 194 | * 195 | * @see 196 | */ 197 | 198 | return { 199 | paragraphs: trimmed ? (trimmed.match(options.hardReturns ? /\n{2,}/g : /\n+/g) || []).length + 1 : 0, 200 | sentences: trimmed ? (trimmed.match(/[.?!…]+./g) || []).length + 1 : 0, 201 | words: trimmed ? (trimmed.replace(/['";:,.?¿\-!¡]+/g, '').match(/\S+/g) || []).length : 0, 202 | characters: trimmed ? decode(trimmed.replace(/\s/g, '')).length : 0, 203 | all: decode(original).length 204 | } 205 | } 206 | 207 | /** 208 | * This is the main object that will later be exposed to other scripts. It 209 | * holds all the public methods that can be used to enable the Countable 210 | * functionality. 211 | * 212 | * Some methods accept an optional options parameter. This includes the 213 | * following options. 214 | * 215 | * {Boolean} hardReturns Use two returns to seperate a paragraph 216 | * instead of one. (default: false) 217 | * {Boolean} stripTags Strip HTML tags before counting the values. 218 | * (default: false) 219 | * {Array} 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 | --------------------------------------------------------------------------------