├── .coveragerc ├── .github ├── dependabot.yml └── workflows │ ├── release.yml │ └── test.yml ├── .gitignore ├── .pre-commit-config.yaml ├── CHANGES.rst ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── LICENSE ├── MANIFEST.in ├── README.rst ├── setup.py ├── tests ├── __init__.py ├── forms.py ├── settings.py └── tests.py ├── tox.ini └── widget_tweaks ├── __init__.py ├── models.py └── templatetags ├── __init__.py └── widget_tweaks.py /.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | branch = True 3 | source = widget_tweaks 4 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # Configure Dependabot scanning. 2 | version: 2 3 | 4 | updates: 5 | # Check for updates to GitHub Actions. 6 | - package-ecosystem: "github-actions" 7 | directory: "/" 8 | schedule: 9 | interval: "monthly" 10 | open-pull-requests-limit: 10 11 | groups: 12 | github-actions: 13 | patterns: 14 | - "*" 15 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | push: 5 | tags: 6 | - '*' 7 | 8 | jobs: 9 | build: 10 | if: github.repository == 'jazzband/django-widget-tweaks' 11 | runs-on: ubuntu-latest 12 | 13 | steps: 14 | - uses: actions/checkout@v4 15 | with: 16 | fetch-depth: 0 17 | 18 | - name: Set up Python 19 | uses: actions/setup-python@v5 20 | with: 21 | python-version: '3.13' 22 | 23 | - name: Install dependencies 24 | run: | 25 | python -m pip install -U pip 26 | python -m pip install -U setuptools twine wheel 27 | 28 | - name: Build package 29 | run: | 30 | python setup.py --version 31 | python setup.py sdist --format=gztar bdist_wheel 32 | twine check dist/* 33 | 34 | - name: Upload packages to Jazzband 35 | if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags') 36 | uses: pypa/gh-action-pypi-publish@master 37 | with: 38 | user: jazzband 39 | password: ${{ secrets.JAZZBAND_RELEASE_KEY }} 40 | repository_url: https://jazzband.co/projects/django-widget-tweaks/upload 41 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | build: 7 | name: build (Python ${{ matrix.python-version }}) 8 | runs-on: ubuntu-latest 9 | strategy: 10 | fail-fast: false 11 | max-parallel: 5 12 | matrix: 13 | python-version: 14 | - '3.9' 15 | - '3.10' 16 | - '3.11' 17 | - '3.12' 18 | - '3.13' 19 | - 'pypy-3.10' 20 | - 'pypy-3.11' 21 | steps: 22 | - uses: actions/checkout@v4 23 | 24 | - name: Set up Python ${{ matrix.python-version }} 25 | uses: actions/setup-python@v5 26 | with: 27 | python-version: ${{ matrix.python-version }} 28 | 29 | - name: Get pip cache dir 30 | id: pip-cache 31 | shell: bash 32 | run: | 33 | echo "dir=$(pip cache dir)" >> $GITHUB_OUTPUT 34 | 35 | - name: Cache 36 | uses: actions/cache@v4 37 | with: 38 | path: ${{ steps.pip-cache.outputs.dir }} 39 | key: 40 | ${{ matrix.python-version }}-v1-${{ hashFiles('**/setup.py') }}-${{ hashFiles('**/tox.ini') }} 41 | restore-keys: | 42 | ${{ matrix.python-version }}-v1- 43 | 44 | - name: Install dependencies 45 | run: | 46 | python -m pip install --upgrade pip 47 | python -m pip install --upgrade tox tox-gh-actions 48 | 49 | - name: Tox tests 50 | run: | 51 | tox -v 52 | 53 | - name: Upload coverage 54 | uses: codecov/codecov-action@v5 55 | with: 56 | name: Python ${{ matrix.python-version }} 57 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | 5 | # C extensions 6 | *.so 7 | 8 | # Distribution / packaging 9 | .Python 10 | env/ 11 | bin/ 12 | build/ 13 | develop-eggs/ 14 | dist/ 15 | eggs/ 16 | lib/ 17 | lib64/ 18 | parts/ 19 | sdist/ 20 | var/ 21 | *.egg-info/ 22 | .installed.cfg 23 | *.egg 24 | MANIFEST 25 | 26 | # Installer logs 27 | pip-log.txt 28 | pip-delete-this-directory.txt 29 | 30 | # Unit test / coverage reports 31 | htmlcov/ 32 | .tox/ 33 | .coverage 34 | .cache 35 | nosetests.xml 36 | coverage.xml 37 | 38 | # Translations 39 | *.mo 40 | 41 | # Mr Developer 42 | .mr.developer.cfg 43 | .project 44 | .pydevproject 45 | 46 | # Rope 47 | .ropeproject 48 | 49 | # Django stuff: 50 | *.log 51 | *.pot 52 | 53 | # Sphinx documentation 54 | docs/_build/ 55 | 56 | .idea 57 | 58 | # VSCode settings 59 | .vscode/ 60 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/adamchainz/django-upgrade 3 | rev: 1.24.0 4 | hooks: 5 | - id: django-upgrade 6 | args: [--target-version, "4.2"] 7 | 8 | - repo: https://github.com/asottile/pyupgrade 9 | rev: v3.19.1 10 | hooks: 11 | - id: pyupgrade 12 | args: [--py39-plus] 13 | 14 | - repo: https://github.com/pre-commit/pre-commit-hooks 15 | rev: v5.0.0 16 | hooks: 17 | - id: check-executables-have-shebangs 18 | - id: check-illegal-windows-names 19 | - id: check-merge-conflict 20 | - id: end-of-file-fixer 21 | - id: fix-byte-order-marker 22 | - id: mixed-line-ending 23 | - id: trailing-whitespace 24 | -------------------------------------------------------------------------------- /CHANGES.rst: -------------------------------------------------------------------------------- 1 | Changes 2 | ======= 3 | 4 | 1.5.1 (2025-04-25) 5 | ------------------ 6 | 7 | * Drop support for Django 4.1 8 | * Drop support for Python 3.8 9 | * Add Django 5.0 support 10 | * Add Django 5.1 support 11 | * Add Python 3.12 support 12 | * Add Python 3.13 support 13 | * Add PyPy 3.11 support 14 | * See GitHub releases for further release notes: https://github.com/jazzband/django-widget-tweaks/releases 15 | 16 | 1.5.0 (2023-08-25) 17 | ------------------ 18 | 19 | * Add Django 4.2 support. 20 | * Add Django 4.1 support. 21 | * Drop Django 4.0 support. 22 | * Drop Django 2.2 support. 23 | * Add Python 3.11 support. 24 | * Drop Python 3.7 support. 25 | 26 | 27 | 1.4.12 (2022-01-13) 28 | ------------------- 29 | 30 | * Set minimum required Python version to 3.7. 31 | * Add better documentation syntax highlighting. 32 | * Adjust build settings and stop building deprecated universal Python 2 wheels. 33 | 34 | 35 | 1.4.11 (2022-01-08) 36 | ------------------- 37 | 38 | * Add support for Django 4.0 39 | * Drop support for Django 3.0 and 3.1 40 | * Add support for Python 3.10 41 | * Drop support for Python 3.6 42 | 43 | 44 | 1.4.9 (2021-09-02) 45 | ------------------ 46 | 47 | * Add support for Django 3.2 48 | * Move to GitHub Actions. 49 | * Drop support for Django 1.11. 50 | * Add support for Python 3.9. 51 | 52 | 53 | 1.4.8 (2020-03-12) 54 | ------------------ 55 | 56 | * Fix Release version 57 | 58 | 59 | 1.4.7 (2020-03-10) 60 | ------------------ 61 | 62 | * Fix Travis deployment to Jazzband site 63 | 64 | 65 | 1.4.6 (2020-03-09) 66 | ------------------ 67 | 68 | * Feature remove attribute from field 69 | * Added documentation for remove_attr feature 70 | * Reformat code with black for PEP8 compatibility 71 | * More consistent tox configuration 72 | * adding a new templatetag, unittest and documentation 73 | * Deprecate Python 2.7 support 74 | * Use automatic formatting for all files 75 | 76 | 77 | 1.4.5 (2019-06-08) 78 | ------------------ 79 | 80 | * Fix rST formatting errors. 81 | 82 | 83 | 1.4.4 (2019-06-05) 84 | ------------------ 85 | 86 | * Add support for type attr. 87 | * Add Python 3.7 and drop Python 3.3 support. 88 | * Add support for double colon syntax. 89 | 90 | 91 | 1.4.3 (2018-09-6) 92 | ------------------ 93 | 94 | * Added add_label_class filter for CSS on form labels 95 | * Removed compatibility code for unsupported Django versions 96 | * Fixed support for non-value attributes in Django < 1.8 97 | * Support non-value attributes in HTML5 by setting their value to True 98 | 99 | 100 | 1.4.2 (2018-03-19) 101 | ------------------ 102 | 103 | * update readme to make installation more clear 104 | * shallow copy field before updating attributes 105 | * drop Python 2.6 and Python 3.2 support 106 | * always cast the result of render to a string 107 | * fix import for django >= 2.0 108 | * moved to jazzband 109 | 110 | 111 | 1.4.1 (2015-06-29) 112 | ------------------ 113 | 114 | * fixed a regression in django-widget-tweaks v1.4 115 | (the field is no longer deep copied). 116 | 117 | 1.4 (2015-06-27) 118 | ---------------- 119 | 120 | * Django 1.7, 1.8 and 1.9 support; 121 | * setup.py is switched to setuptools; 122 | * testing improvements; 123 | * Python 3.4 support is added; 124 | * Python 2.5 is not longer supported; 125 | * bitbucket repository is no longer supported (development is moved to github). 126 | 127 | 1.3 (2013-04-05) 128 | ---------------- 129 | 130 | * added support for ``WIDGET_ERROR_CLASS`` and ``WIDGET_REQUIRED_CLASS`` 131 | template variables that affect ``{% render_field %}``. 132 | 133 | 1.2 (2013-03-23) 134 | ---------------- 135 | 136 | * new ``add_error_attr`` template filter; 137 | * testing improvements. 138 | 139 | 1.1.2 (2012-06-06) 140 | ------------------ 141 | 142 | * support for template variables is added to ``render_field`` tag; 143 | * new ``field_type`` and ``widget_type`` filters. 144 | 145 | 1.1.1 (2012-03-22) 146 | ------------------ 147 | 148 | * some issues with ``render_field`` tag are fixed. 149 | 150 | 1.1 (2012-03-22) 151 | ---------------- 152 | 153 | * ``render_field`` template tag. 154 | 155 | 1.0 (2012-02-06) 156 | ---------------- 157 | 158 | * filters return empty strings instead of raising exceptions if field is missing; 159 | * test running improvements; 160 | * python 3 support; 161 | * undocumented 'behave' filter is removed. 162 | 163 | 0.3 (2011-03-04) 164 | ---------------- 165 | 166 | * ``add_error_class`` filter. 167 | 168 | 0.2.1 (2011-02-03) 169 | ------------------ 170 | 171 | * Attributes customized in widgets are preserved; 172 | * no more extra whitespaces; 173 | * tests; 174 | 175 | 0.1 (2011-01-12) 176 | ---------------- 177 | 178 | Initial release. 179 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Code of Conduct 2 | 3 | As contributors and maintainers of the Jazzband projects, and in the interest of 4 | fostering an open and welcoming community, we pledge to respect all people who 5 | contribute through reporting issues, posting feature requests, updating documentation, 6 | submitting pull requests or patches, and other activities. 7 | 8 | We are committed to making participation in the Jazzband a harassment-free experience 9 | for everyone, regardless of the level of experience, gender, gender identity and 10 | expression, sexual orientation, disability, personal appearance, body size, race, 11 | ethnicity, age, religion, or nationality. 12 | 13 | Examples of unacceptable behavior by participants include: 14 | 15 | - The use of sexualized language or imagery 16 | - Personal attacks 17 | - Trolling or insulting/derogatory comments 18 | - Public or private harassment 19 | - Publishing other's private information, such as physical or electronic addresses, 20 | without explicit permission 21 | - Other unethical or unprofessional conduct 22 | 23 | The Jazzband roadies have the right and responsibility to remove, edit, or reject 24 | comments, commits, code, wiki edits, issues, and other contributions that are not 25 | aligned to this Code of Conduct, or to ban temporarily or permanently any contributor 26 | for other behaviors that they deem inappropriate, threatening, offensive, or harmful. 27 | 28 | By adopting this Code of Conduct, the roadies commit themselves to fairly and 29 | consistently applying these principles to every aspect of managing the jazzband 30 | projects. Roadies who do not follow or enforce the Code of Conduct may be permanently 31 | removed from the Jazzband roadies. 32 | 33 | This code of conduct applies both within project spaces and in public spaces when an 34 | individual is representing the project or its community. 35 | 36 | Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by 37 | contacting the roadies at `roadies@jazzband.co`. All complaints will be reviewed and 38 | investigated and will result in a response that is deemed necessary and appropriate to 39 | the circumstances. Roadies are obligated to maintain confidentiality with regard to the 40 | reporter of an incident. 41 | 42 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 43 | 1.3.0, available at [https://contributor-covenant.org/version/1/3/0/][version] 44 | 45 | [homepage]: https://contributor-covenant.org 46 | [version]: https://contributor-covenant.org/version/1/3/0/ 47 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | [![Jazzband](https://jazzband.co/static/img/jazzband.svg)](https://jazzband.co/) 2 | 3 | This is a [Jazzband](https://jazzband.co/) project. By contributing you agree to abide by the [Contributor Code of Conduct](https://jazzband.co/about/conduct) and follow the [guidelines](https://jazzband.co/about/guidelines). 4 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2011-2015 Mikhail Korobov 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in 11 | all copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 | THE SOFTWARE. 20 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include *.txt 2 | include *.rst 3 | include LICENSE 4 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | ==================== 2 | django-widget-tweaks 3 | ==================== 4 | 5 | .. image:: https://jazzband.co/static/img/badge.svg 6 | :target: https://jazzband.co/ 7 | :alt: Jazzband 8 | 9 | .. image:: https://img.shields.io/pypi/v/django-widget-tweaks.svg 10 | :target: https://pypi.python.org/pypi/django-widget-tweaks 11 | :alt: PyPI Version 12 | 13 | .. image:: https://github.com/jazzband/django-widget-tweaks/workflows/Test/badge.svg 14 | :target: https://github.com/jazzband/django-widget-tweaks/actions 15 | :alt: GitHub Actions 16 | 17 | .. image:: https://codecov.io/gh/jazzband/django-widget-tweaks/branch/master/graph/badge.svg 18 | :target: https://app.codecov.io/gh/jazzband/django-widget-tweaks 19 | :alt: Coverage 20 | 21 | Tweak the form field rendering in templates, not in python-level 22 | form definitions. Altering CSS classes and HTML attributes is supported. 23 | 24 | That should be enough for designers to customize field presentation (using 25 | CSS and unobtrusive javascript) without touching python code. 26 | 27 | License is MIT. 28 | 29 | Installation 30 | ============ 31 | 32 | You can get Django Widget Tweaks by using pip:: 33 | 34 | $ pip install django-widget-tweaks 35 | 36 | To enable `widget_tweaks` in your project you need to add it to `INSTALLED_APPS` in your projects 37 | `settings.py` file: 38 | 39 | .. code-block:: python 40 | 41 | INSTALLED_APPS += [ 42 | 'widget_tweaks', 43 | ] 44 | 45 | Usage 46 | ===== 47 | 48 | This app provides two sets of tools that may be used together or standalone: 49 | 50 | 1. a ``render_field`` template tag for customizing form fields by using an 51 | HTML-like syntax. 52 | 2. several template filters for customizing form field HTML attributes and CSS 53 | classes 54 | 55 | The ``render_field`` tag should be easier to use and should make form field 56 | customizations much easier for designers and front-end developers. 57 | 58 | The template filters are more powerful than the ``render_field`` tag, but they 59 | use a more complex and less HTML-like syntax. 60 | 61 | render_field 62 | ------------ 63 | 64 | This is a template tag that can be used as an alternative to aforementioned 65 | filters. This template tag renders a field using a syntax similar to plain 66 | HTML attributes. 67 | 68 | Example: 69 | 70 | .. code-block:: html+django 71 | 72 | {% load widget_tweaks %} 73 | 74 | 75 | {% render_field form.search_query type="search" %} 76 | 77 | 78 | {% render_field form.text rows="20" cols="20" title="Hello, world!" %} 79 | 80 | 81 | {% render_field form.title class+="css_class_1 css_class_2" %} 82 | 83 | 84 | {% render_field form.text placeholder=form.text.label %} 85 | 86 | 87 | {% render_field form.search_query v-bind::class="{active:isActive}" %} 88 | 89 | 90 | For fields rendered with ``{% render_field %}`` tag it is possible 91 | to set error class and required fields class by using 92 | ``WIDGET_ERROR_CLASS`` and ``WIDGET_REQUIRED_CLASS`` template variables: 93 | 94 | .. code-block:: html+django 95 | 96 | {% with WIDGET_ERROR_CLASS='my_error' WIDGET_REQUIRED_CLASS='my_required' %} 97 | {% render_field form.field1 %} 98 | {% render_field form.field2 %} 99 | {% render_field form.field3 %} 100 | {% endwith %} 101 | 102 | You can be creative with these variables: e.g. a context processor could 103 | set a default CSS error class on all fields rendered by 104 | ``{% render_field %}``. 105 | 106 | attr 107 | ---- 108 | Adds or replaces any single html attribute for the form field. 109 | 110 | Examples: 111 | 112 | .. code-block:: html+django 113 | 114 | {% load widget_tweaks %} 115 | 116 | 117 | {{ form.search_query|attr:"type:search" }} 118 | 119 | 120 | {{ form.text|attr:"rows:20"|attr:"cols:20"|attr:"title:Hello, world!" }} 121 | 122 | 123 | {{ form.search_query|attr:"autofocus" }} 124 | 125 | 126 | 127 | {{ form.search_query|attr:"v-bind::class:{active:ValueEnabled}" }} 128 | 129 | add_class 130 | --------- 131 | 132 | Adds CSS class to field element. Split classes by whitespace in order to add 133 | several classes at once. 134 | 135 | Example: 136 | 137 | .. code-block:: html+django 138 | 139 | {% load widget_tweaks %} 140 | 141 | 142 | {{ form.title|add_class:"css_class_1 css_class_2" }} 143 | 144 | set_data 145 | -------- 146 | 147 | Sets HTML5 data attribute ( https://johnresig.com/blog/html-5-data-attributes/ ). 148 | Useful for unobtrusive javascript. It is just a shortcut for 'attr' filter 149 | that prepends attribute names with 'data-' string. 150 | 151 | Example: 152 | 153 | .. code-block:: html+django 154 | 155 | {% load widget_tweaks %} 156 | 157 | 158 | {{ form.title|set_data:"filters:OverText" }} 159 | 160 | append_attr 161 | ----------- 162 | 163 | Appends attribute value with extra data. 164 | 165 | Example: 166 | 167 | .. code-block:: html+django 168 | 169 | {% load widget_tweaks %} 170 | 171 | 172 | {{ form.title|append_attr:"class:css_class_1 css_class_2" }} 173 | 174 | 'add_class' filter is just a shortcut for 'append_attr' filter that 175 | adds values to the 'class' attribute. 176 | 177 | remove_attr 178 | ----------- 179 | Removes any single html attribute for the form field. 180 | 181 | Example: 182 | 183 | .. code-block:: html+django 184 | 185 | {% load widget_tweaks %} 186 | 187 | 188 | {{ form.title|remove_attr:"autofocus" }} 189 | 190 | add_label_class 191 | --------------- 192 | 193 | The same as `add_class` but adds css class to form labels. 194 | 195 | Example: 196 | 197 | .. code-block:: html+django 198 | 199 | {% load widget_tweaks %} 200 | 201 | 202 | {{ form.title|add_label_class:"label_class_1 label_class_2" }} 203 | 204 | add_error_class 205 | --------------- 206 | 207 | The same as 'add_class' but adds css class only if validation failed for 208 | the field (field.errors is not empty). 209 | 210 | Example: 211 | 212 | .. code-block:: html+django 213 | 214 | {% load widget_tweaks %} 215 | 216 | 217 | {{ form.title|add_error_class:"error-border" }} 218 | 219 | add_error_attr 220 | -------------- 221 | 222 | The same as 'attr' but sets an attribute only if validation failed for 223 | the field (field.errors is not empty). This can be useful when dealing 224 | with accessibility: 225 | 226 | .. code-block:: html+django 227 | 228 | {% load widget_tweaks %} 229 | 230 | 231 | {{ form.title|add_error_attr:"aria-invalid:true" }} 232 | 233 | add_required_class 234 | ------------------ 235 | 236 | The same as 'add_error_class' adds css class only for required field. 237 | 238 | Example: 239 | 240 | .. code-block:: html+django 241 | 242 | {% load widget_tweaks %} 243 | 244 | 245 | {{ form.title|add_required_class:"is-required" }} 246 | 247 | field_type and widget_type 248 | -------------------------- 249 | 250 | ``'field_type'`` and ``'widget_type'`` are template filters that return 251 | field class name and field widget class name (in lower case). 252 | 253 | Example: 254 | 255 | .. code-block:: html+django 256 | 257 | {% load widget_tweaks %} 258 | 259 |
260 | {{ field }} 261 |
262 | 263 | Output: 264 | 265 | .. code-block:: html+django 266 | 267 |
268 | 269 |
270 | 271 | Fields with multiple widgets 272 | ============================ 273 | 274 | Some fields may render as a `MultiWidget`, composed of multiple subwidgets 275 | (for example, a `ChoiceField` using `RadioSelect`). You can use the same tags 276 | and filters, but your template code will need to include a for loop for fields 277 | like this: 278 | 279 | .. code-block:: html+django 280 | 281 | {% load widget_tweaks %} 282 | 283 | {% for widget in form.choice %} 284 | {{ widget|add_class:"css_class_1 css_class_2" }} 285 | {% endfor %} 286 | 287 | Mixing render_field and filters 288 | =============================== 289 | 290 | The render_field tag and filters mentioned above can be mixed. 291 | 292 | Example: 293 | 294 | .. code-block:: html+django 295 | 296 | {% render_field form.category|append_attr:"readonly:readonly" type="text" placeholder="Category" %} 297 | 298 | 299 | returns: 300 | 301 | .. code-block:: html+django 302 | 303 | 304 | 305 | Filter chaining 306 | =============== 307 | 308 | The order django-widget-tweaks filters apply may seem counter-intuitive 309 | (leftmost filter wins): 310 | 311 | .. code-block:: html+django 312 | 313 | {{ form.simple|attr:"foo:bar"|attr:"foo:baz" }} 314 | 315 | returns: 316 | 317 | .. code-block:: html+django 318 | 319 | 320 | 321 | It is not a bug, it is a feature that enables creating reusable templates 322 | with overridable defaults. 323 | 324 | Reusable field template example: 325 | 326 | .. code-block:: html+django 327 | 328 | {# inc/field.html #} 329 | {% load widget_tweaks %} 330 |
{{ field|attr:"foo:default_foo" }}
331 | 332 | Example usage: 333 | 334 | .. code-block:: html+django 335 | 336 | {# my_template.html #} 337 | {% load widget_tweaks %} 338 |
{% csrf_token %} 339 | {% include "inc/field.html" with field=form.title %} 340 | {% include "inc/field.html" with field=form.description|attr:"foo:non_default_foo" %} 341 |
342 | 343 | With 'rightmost filter wins' rule it wouldn't be possible to override 344 | ``|attr:"foo:default_foo"`` in main template. 345 | 346 | Rendering form error messages 347 | ============================= 348 | 349 | This app can render the following form error messages: 350 | 351 | 1. Field related errors 352 | 2. Non-field related errors 353 | 3. All form errors - Displays all field and Non-field related errors. If related to a specific field the name is displayed above the error, if the error is a general form error, displays __all__ 354 | 355 | Field related errors 356 | -------------------- 357 | 358 | To render field related errors in your form: 359 | 360 | Example: 361 | 362 | .. code-block:: html+django 363 | 364 | {% load widget_tweaks %} 365 | {% for error in field.errors %} 366 | {{ error }} 367 | {% endfor %} 368 | 369 | Example usage: 370 | 371 | .. code-block:: html+django 372 | 373 | {% for field in form.visible_fields %} 374 | {{ field }} 375 | 376 | {% for error in field.errors %} 377 | {{ error }} 378 | {% endfor %} 379 | {% endfor %} 380 | 381 | Non-field related errors 382 | ------------------------ 383 | 384 | Render general form errors: 385 | 386 | Example: 387 | 388 | .. code-block:: html+django 389 | 390 | {% load widget_tweaks %} 391 | {% if form.non_field_errors %} 392 | {{ form.non_field_errors }} 393 | {% endif %} 394 | 395 | Example usage: 396 | 397 | .. code-block:: html+django 398 | 399 | {% for field in form.visible_fields %} 400 | {{ field }} 401 | 402 | {% for error in field.errors %} 403 | {{ error }} 404 | {% endfor %} 405 | {% endfor %} 406 | 407 | All form errors 408 | --------------- 409 | 410 | Render all form errors: 411 | 412 | Example: 413 | 414 | .. code-block:: html+django 415 | 416 | {% load widget_tweaks %} 417 | {{ form.errors }} 418 | 419 | Contributing 420 | ============ 421 | 422 | If you've found a bug, implemented a feature or have a suggestion, 423 | do not hesitate to contact me, fire an issue or send a pull request. 424 | 425 | * Source code: https://github.com/jazzband/django-widget-tweaks/ 426 | * Bug tracker: https://github.com/jazzband/django-widget-tweaks/issues 427 | 428 | Testing 429 | ------- 430 | 431 | Make sure you have `tox `_ installed, then type 432 | 433 | :: 434 | 435 | tox 436 | 437 | from the source checkout. 438 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | from setuptools import setup 3 | 4 | setup( 5 | name="django-widget-tweaks", 6 | use_scm_version={"version_scheme": "post-release"}, 7 | setup_requires=["setuptools_scm"], 8 | author="Mikhail Korobov", 9 | author_email="kmike84@gmail.com", 10 | url="https://github.com/jazzband/django-widget-tweaks", 11 | description="Tweak the form field rendering in templates, not in python-level form definitions.", 12 | long_description=open("README.rst").read() + "\n\n" + open("CHANGES.rst").read(), 13 | long_description_content_type="text/x-rst", 14 | license="MIT license", 15 | python_requires=">=3.9", 16 | install_requires=["django (>=4.2)"], 17 | packages=["widget_tweaks", "widget_tweaks.templatetags"], 18 | classifiers=[ 19 | "Development Status :: 5 - Production/Stable", 20 | "Framework :: Django", 21 | "Framework :: Django :: 4.2", 22 | "Framework :: Django :: 5.0", 23 | "Framework :: Django :: 5.1", 24 | "Framework :: Django :: 5.2", 25 | "Intended Audience :: Developers", 26 | "License :: OSI Approved :: MIT License", 27 | "Programming Language :: Python", 28 | "Programming Language :: Python :: 3", 29 | "Programming Language :: Python :: 3.9", 30 | "Programming Language :: Python :: 3.10", 31 | "Programming Language :: Python :: 3.11", 32 | "Programming Language :: Python :: 3.12", 33 | "Programming Language :: Python :: 3.13", 34 | "Programming Language :: Python :: Implementation :: CPython", 35 | "Programming Language :: Python :: Implementation :: PyPy", 36 | "Topic :: Software Development :: Libraries :: Python Modules", 37 | ], 38 | ) 39 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jazzband/django-widget-tweaks/93b601137eaba07924b7258c7d5496fb8a6d48b8/tests/__init__.py -------------------------------------------------------------------------------- /tests/forms.py: -------------------------------------------------------------------------------- 1 | import string 2 | from django import forms 3 | from django.forms import Form, CharField, SelectDateWidget, TextInput 4 | from django.template import Template, Context 5 | 6 | 7 | class MyForm(Form): 8 | """ 9 | Test form. If you want to test rendering of a field, 10 | add it to this form and use one of 'render_...' functions 11 | from this module. 12 | """ 13 | 14 | simple = CharField() 15 | with_attrs = CharField(widget=TextInput(attrs={"foo": "baz", "egg": "spam"})) 16 | with_cls = CharField(widget=TextInput(attrs={"class": "class0"})) 17 | date = forms.DateField(widget=SelectDateWidget(attrs={"egg": "spam"})) 18 | choice = forms.ChoiceField(choices=[(1, "one"), (2, "two")]) 19 | radio = forms.ChoiceField( 20 | label="Radio Input", 21 | choices=[("option1", "Option 1"), ("option2", "Option 2")], 22 | widget=forms.RadioSelect, 23 | ) 24 | 25 | 26 | def render_form(text, form=None, **context_args): 27 | """ 28 | Renders template ``text`` with widget_tweaks library loaded 29 | and MyForm instance available in context as ``form``. 30 | """ 31 | tpl = Template("{% load widget_tweaks %}" + text) 32 | context_args.update({"form": MyForm() if form is None else form}) 33 | context = Context(context_args) 34 | return tpl.render(context) 35 | 36 | 37 | def render_field(field, template_filter, params, *args, **kwargs): 38 | """ 39 | Renders ``field`` of MyForm with filter ``template_filter`` applied. 40 | ``params`` are filter arguments. 41 | 42 | If you want to apply several filters (in a chain), 43 | pass extra ``template_filter`` and ``params`` as positional arguments. 44 | 45 | In order to use custom form, pass form instance as ``form`` 46 | keyword argument. 47 | """ 48 | filters = [(template_filter, params)] 49 | filters.extend(zip(args[::2], args[1::2])) 50 | filter_strings = [f'|{f[0]}:"{f[1]}"' for f in filters] 51 | render_field_str = "{{{{ form.{}{} }}}}".format(field, "".join(filter_strings)) 52 | return render_form(render_field_str, **kwargs) 53 | 54 | 55 | def render_choice_field(field, choice_no, template_filter, params, *args, **kwargs): 56 | """ 57 | Renders ``field`` of MyForm with choice_no and filter ``template_filter`` 58 | applied. 59 | ``params`` are filter arguments. 60 | 61 | If you want to apply several filters (in a chain), 62 | pass extra ``template_filter`` and ``params`` as positional arguments. 63 | 64 | In order to use custom form, pass form instance as ``form`` 65 | keyword argument. 66 | """ 67 | filters = [(template_filter, params)] 68 | filters.extend(zip(args[::2], args[1::2])) 69 | filter_strings = [f'|{f[0]}:"{f[1]}"' for f in filters] 70 | render_field_str = "{{{{ form.{}.{}{} }}}}".format( 71 | field, 72 | choice_no, 73 | "".join(filter_strings), 74 | ) 75 | return render_form(render_field_str, **kwargs) 76 | 77 | 78 | def render_field_from_tag(field, *attributes): 79 | """ 80 | Renders MyForm's field ``field`` with attributes passed 81 | as positional arguments. 82 | """ 83 | attr_strings = [" %s" % f for f in attributes] 84 | tpl = string.Template("{% render_field form.$field$attrs %}") 85 | render_field_str = tpl.substitute(field=field, attrs="".join(attr_strings)) 86 | return render_form(render_field_str) 87 | -------------------------------------------------------------------------------- /tests/settings.py: -------------------------------------------------------------------------------- 1 | INSTALLED_APPS = ["widget_tweaks"] 2 | 3 | TEMPLATES = [ 4 | { 5 | "BACKEND": "django.template.backends.django.DjangoTemplates", 6 | }, 7 | ] 8 | 9 | SECRET_KEY = "spam-eggs" 10 | 11 | USE_TZ = True 12 | -------------------------------------------------------------------------------- /tests/tests.py: -------------------------------------------------------------------------------- 1 | from unittest import TestCase 2 | 3 | from .forms import ( 4 | render_field, 5 | render_choice_field, 6 | render_field_from_tag, 7 | render_form, 8 | MyForm, 9 | ) 10 | 11 | 12 | def assertIn(value, obj): 13 | assert value in obj, f"{value} not in {obj}" 14 | 15 | 16 | def assertNotIn(value, obj): 17 | assert value not in obj, f"{value} in {obj}" 18 | 19 | 20 | # =============================== 21 | # Test cases 22 | # =============================== 23 | 24 | 25 | class SimpleAttrTest(TestCase): 26 | def test_attr(self): 27 | res = render_field("simple", "attr", "foo:bar") 28 | assertIn('type="text"', res) 29 | assertIn('name="simple"', res) 30 | assertIn('id="id_simple"', res) 31 | assertIn('foo="bar"', res) 32 | 33 | def test_attr_chaining(self): 34 | res = render_field("simple", "attr", "foo:bar", "attr", "bar:baz") 35 | assertIn('type="text"', res) 36 | assertIn('name="simple"', res) 37 | assertIn('id="id_simple"', res) 38 | assertIn('foo="bar"', res) 39 | assertIn('bar="baz"', res) 40 | 41 | def test_add_class(self): 42 | res = render_field("simple", "add_class", "foo") 43 | assertIn('class="foo"', res) 44 | 45 | def test_add_multiple_classes(self): 46 | res = render_field("simple", "add_class", "foo bar") 47 | assertIn('class="foo bar"', res) 48 | 49 | def test_add_class_chaining(self): 50 | res = render_field("simple", "add_class", "foo", "add_class", "bar") 51 | assertIn('class="bar foo"', res) 52 | 53 | def test_set_data(self): 54 | res = render_field("simple", "set_data", "key:value") 55 | assertIn('data-key="value"', res) 56 | 57 | def test_replace_type(self): 58 | res = render_field("simple", "attr", "type:date") 59 | self.assertTrue(res.count("type=") == 1, (res, res.count("type="))) 60 | assertIn('type="date"', res) 61 | 62 | def test_replace_hidden(self): 63 | res = render_field("simple", "attr", "type:hidden") 64 | self.assertTrue(res.count("type=") == 1, (res, res.count("type="))) 65 | assertIn('type="hidden"', res) 66 | 67 | 68 | class ErrorsTest(TestCase): 69 | def _err_form(self): 70 | form = MyForm({"foo": "bar"}) # some random data 71 | form.is_valid() # trigger form validation 72 | return form 73 | 74 | def test_error_class_no_error(self): 75 | res = render_field("simple", "add_error_class", "err") 76 | assertNotIn('class="err"', res) 77 | 78 | def test_error_class_error(self): 79 | form = self._err_form() 80 | res = render_field("simple", "add_error_class", "err", form=form) 81 | assertIn('class="err"', res) 82 | 83 | def test_required_class(self): 84 | res = render_field("simple", "add_required_class", "is-required") 85 | assertIn('class="is-required"', res) 86 | 87 | def test_required_class_requiredfield(self): 88 | form = self._err_form() 89 | res = render_field("simple", "add_required_class", "is-required", form=form) 90 | assertIn('class="is-required"', res) 91 | assertIn("required", res) 92 | 93 | def test_error_attr_no_error(self): 94 | res = render_field("simple", "add_error_attr", "aria-invalid:true") 95 | assertNotIn('aria-invalid="true"', res) 96 | 97 | def test_error_attr_error(self): 98 | form = self._err_form() 99 | res = render_field("simple", "add_error_attr", "aria-invalid:true", form=form) 100 | assertIn('aria-invalid="true"', res) 101 | 102 | 103 | class SilenceTest(TestCase): 104 | def test_silence_without_field(self): 105 | res = render_field("nothing", "attr", "foo:bar") 106 | self.assertEqual(res, "") 107 | res = render_field("nothing", "add_class", "some") 108 | self.assertEqual(res, "") 109 | res = render_field("nothing", "remove_attr", "some") 110 | self.assertEqual(res, "") 111 | 112 | 113 | class CustomizedWidgetTest(TestCase): 114 | def test_attr(self): 115 | res = render_field("with_attrs", "attr", "foo:bar") 116 | assertIn('foo="bar"', res) 117 | assertNotIn('foo="baz"', res) 118 | assertIn('egg="spam"', res) 119 | 120 | def test_attr_chaining(self): 121 | res = render_field("with_attrs", "attr", "foo:bar", "attr", "bar:baz") 122 | assertIn('foo="bar"', res) 123 | assertNotIn('foo="baz"', res) 124 | assertIn('egg="spam"', res) 125 | assertIn('bar="baz"', res) 126 | 127 | def test_attr_class(self): 128 | res = render_field("with_cls", "attr", "foo:bar") 129 | assertIn('foo="bar"', res) 130 | assertIn('class="class0"', res) 131 | 132 | def test_default_attr(self): 133 | res = render_field("with_cls", "attr", "type:search") 134 | assertIn('class="class0"', res) 135 | assertIn('type="search"', res) 136 | 137 | def test_add_class(self): 138 | res = render_field("with_cls", "add_class", "class1") 139 | assertIn("class0", res) 140 | assertIn("class1", res) 141 | 142 | def test_add_class_chaining(self): 143 | res = render_field("with_cls", "add_class", "class1", "add_class", "class2") 144 | assertIn("class0", res) 145 | assertIn("class1", res) 146 | assertIn("class2", res) 147 | 148 | def test_remove_attr(self): 149 | res = render_field("with_attrs", "remove_attr", "foo") 150 | assertNotIn("foo", res) 151 | 152 | 153 | class FieldReuseTest(TestCase): 154 | def test_field_double_rendering_simple(self): 155 | res = render_form( 156 | '{{ form.simple }}{{ form.simple|attr:"foo:bar" }}{{ form.simple }}' 157 | ) 158 | self.assertEqual(res.count("bar"), 1) 159 | 160 | def test_field_double_rendering_simple_css(self): 161 | res = render_form( 162 | '{{ form.simple }}{{ form.simple|add_class:"bar" }}{{ form.simple|add_class:"baz" }}' 163 | ) 164 | self.assertEqual(res.count("baz"), 1) 165 | self.assertEqual(res.count("bar"), 1) 166 | 167 | def test_field_double_rendering_attrs(self): 168 | res = render_form( 169 | '{{ form.with_cls }}{{ form.with_cls|add_class:"bar" }}{{ form.with_cls }}' 170 | ) 171 | self.assertEqual(res.count("class0"), 3) 172 | self.assertEqual(res.count("bar"), 1) 173 | 174 | 175 | class SimpleRenderFieldTagTest(TestCase): 176 | def test_attr(self): 177 | res = render_field_from_tag("simple", 'foo="bar"') 178 | assertIn('type="text"', res) 179 | assertIn('name="simple"', res) 180 | assertIn('id="id_simple"', res) 181 | assertIn('foo="bar"', res) 182 | 183 | def test_multiple_attrs(self): 184 | res = render_field_from_tag("simple", 'foo="bar"', 'bar="baz"') 185 | assertIn('type="text"', res) 186 | assertIn('name="simple"', res) 187 | assertIn('id="id_simple"', res) 188 | assertIn('foo="bar"', res) 189 | assertIn('bar="baz"', res) 190 | 191 | 192 | class RenderFieldTagSilenceTest(TestCase): 193 | def test_silence_without_field(self): 194 | res = render_field_from_tag("nothing", 'foo="bar"') 195 | self.assertEqual(res, "") 196 | res = render_field_from_tag("nothing", 'class+="some"') 197 | self.assertEqual(res, "") 198 | 199 | 200 | class RenderFieldTagCustomizedWidgetTest(TestCase): 201 | def test_attr(self): 202 | res = render_field_from_tag("with_attrs", 'foo="bar"') 203 | assertIn('foo="bar"', res) 204 | assertNotIn('foo="baz"', res) 205 | assertIn('egg="spam"', res) 206 | 207 | def test_multiple_attrs(self): 208 | res = render_field_from_tag("with_attrs", 'foo="bar"', 'bar="baz"') 209 | assertIn('foo="bar"', res) 210 | assertNotIn('foo="baz"', res) 211 | assertIn('egg="spam"', res) 212 | assertIn('bar="baz"', res) 213 | 214 | def test_attr_class(self): 215 | res = render_field_from_tag("with_cls", 'foo="bar"') 216 | assertIn('foo="bar"', res) 217 | assertIn('class="class0"', res) 218 | 219 | def test_default_attr(self): 220 | res = render_field_from_tag("with_cls", 'type="search"') 221 | assertIn('class="class0"', res) 222 | assertIn('type="search"', res) 223 | 224 | def test_append_attr(self): 225 | res = render_field_from_tag("with_cls", 'class+="class1"') 226 | assertIn("class0", res) 227 | assertIn("class1", res) 228 | 229 | def test_duplicate_append_attr(self): 230 | res = render_field_from_tag("with_cls", 'class+="class1"', 'class+="class2"') 231 | assertIn("class0", res) 232 | assertIn("class1", res) 233 | assertIn("class2", res) 234 | 235 | def test_hyphenated_attributes(self): 236 | res = render_field_from_tag("with_cls", 'data-foo="bar"') 237 | assertIn('data-foo="bar"', res) 238 | assertIn('class="class0"', res) 239 | 240 | def test_alpinejs_event_modifier(self): 241 | res = render_field_from_tag( 242 | "simple", '@click.away="open=false"', 'x-on::click.away="open=false"' 243 | ) 244 | assertIn('@click.away="open=false"', res) 245 | assertIn('x-on:click.away="open=false"', res) 246 | 247 | 248 | class RenderFieldWidgetClassesTest(TestCase): 249 | def test_use_widget_required_class(self): 250 | res = render_form( 251 | "{% render_field form.simple %}", WIDGET_REQUIRED_CLASS="required_class" 252 | ) 253 | assertIn('class="required_class"', res) 254 | 255 | def test_use_widget_error_class(self): 256 | res = render_form( 257 | "{% render_field form.simple %}", 258 | form=MyForm({}), 259 | WIDGET_ERROR_CLASS="error_class", 260 | ) 261 | assertIn('class="error_class"', res) 262 | 263 | def test_use_widget_error_class_with_other_classes(self): 264 | res = render_form( 265 | '{% render_field form.simple class="blue" %}', 266 | form=MyForm({}), 267 | WIDGET_ERROR_CLASS="error_class", 268 | ) 269 | assertIn('class="blue error_class"', res) 270 | 271 | def test_use_widget_required_class_with_other_classes(self): 272 | res = render_form( 273 | '{% render_field form.simple class="blue" %}', 274 | form=MyForm({}), 275 | WIDGET_REQUIRED_CLASS="required_class", 276 | ) 277 | assertIn('class="blue required_class"', res) 278 | 279 | 280 | class RenderFieldTagFieldReuseTest(TestCase): 281 | def test_field_double_rendering_simple(self): 282 | res = render_form( 283 | '{{ form.simple }}{% render_field form.simple foo="bar" %}{% render_field form.simple %}' 284 | ) 285 | self.assertEqual(res.count("bar"), 1) 286 | 287 | def test_field_double_rendering_simple_css(self): 288 | res = render_form( 289 | '{% render_field form.simple %}{% render_field form.simple class+="bar" %}{% render_field form.simple class+="baz" %}' 290 | ) 291 | self.assertEqual(res.count("baz"), 1) 292 | self.assertEqual(res.count("bar"), 1) 293 | 294 | def test_field_double_rendering_attrs(self): 295 | res = render_form( 296 | '{% render_field form.with_cls %}{% render_field form.with_cls class+="bar" %}{% render_field form.with_cls %}' 297 | ) 298 | self.assertEqual(res.count("class0"), 3) 299 | self.assertEqual(res.count("bar"), 1) 300 | 301 | def test_field_double_rendering_id(self): 302 | res = render_form( 303 | "{{ form.simple }}" 304 | '{% render_field form.simple id="id_1" %}' 305 | '{% render_field form.simple id="id_2" %}' 306 | ) 307 | self.assertEqual(res.count("id_1"), 1) 308 | self.assertEqual(res.count("id_2"), 1) 309 | 310 | def test_field_double_rendering_id_name(self): 311 | res = render_form( 312 | "{{ form.simple }}" 313 | '{% render_field form.simple id="id_1" name="n_1" %}' 314 | '{% render_field form.simple id="id_2" name="n_2" %}' 315 | ) 316 | self.assertEqual(res.count("id_1"), 1) 317 | self.assertEqual(res.count("id_2"), 1) 318 | self.assertEqual(res.count("n_1"), 1) 319 | self.assertEqual(res.count("n_2"), 1) 320 | 321 | def test_field_double_rendering_id_class(self): 322 | res = render_form( 323 | "{{ form.simple }}" 324 | '{% render_field form.simple id="id_1" class="c_1" %}' 325 | '{% render_field form.simple id="id_2" class="c_2" %}' 326 | ) 327 | self.assertEqual(res.count("id_1"), 1) 328 | self.assertEqual(res.count("id_2"), 1) 329 | self.assertEqual(res.count("c_1"), 1) 330 | self.assertEqual(res.count("c_2"), 1) 331 | 332 | def test_field_double_rendering_name_class(self): 333 | res = render_form( 334 | "{{ form.simple }}" 335 | '{% render_field form.simple name="n_1" class="c_1" %}' 336 | '{% render_field form.simple name="n_2" class="c_2" %}' 337 | ) 338 | self.assertEqual(res.count("n_1"), 1) 339 | self.assertEqual(res.count("n_2"), 1) 340 | self.assertEqual(res.count("c_1"), 1) 341 | self.assertEqual(res.count("c_2"), 1) 342 | 343 | def test_field_double_rendering_simple_again(self): 344 | res = render_form('{% render_field form.simple foo="bar" v-model="username" %}') 345 | self.assertEqual(res.count('v-model="username"'), 1) 346 | 347 | 348 | class RenderFieldTagUseTemplateVariableTest(TestCase): 349 | def test_use_template_variable_in_parameters(self): 350 | res = render_form( 351 | '{% render_field form.with_attrs egg+="pahaz" placeholder=form.with_attrs.label %}' 352 | ) 353 | assertIn('egg="spam pahaz"', res) 354 | assertIn('placeholder="With attrs"', res) 355 | 356 | 357 | class RenderFieldFilter_field_type_widget_type_Test(TestCase): 358 | def test_field_type_widget_type_rendering_simple(self): 359 | res = render_form( 360 | '
{{ form.simple }}
' 361 | ) 362 | assertIn('class="charfield textinput simple"', res) 363 | 364 | 365 | class RenderFieldTagNonValueAttribute(TestCase): 366 | def test_field_non_value(self): 367 | res = render_form('{{ form.simple|attr:"foo" }}') 368 | assertIn("foo", res) 369 | assertNotIn("foo=", res) 370 | 371 | def test_field_empty_value(self): 372 | res = render_form('{{ form.simple|attr:"foo:" }}') 373 | assertIn('foo=""', res) 374 | 375 | def test_field_other_value(self): 376 | res = render_form('{{ form.simple|attr:"foo:bar" }}') 377 | assertIn('foo="bar"', res) 378 | 379 | def test_field_double_colon(self): 380 | res = render_form('{{ form.simple|attr:"v-bind::class:value" }}') 381 | assertIn('v-bind:class="value"', res) 382 | 383 | def test_field_double_colon_morethanone(self): 384 | res = render_form('{{ form.simple|attr:"v-bind::class:{active:True}" }}') 385 | assertIn('v-bind:class="{active:True}"', res) 386 | 387 | def test_field_arroba(self): 388 | res = render_form('{{ form.simple|attr:"@click:onClick" }}') 389 | assertIn('@click="onClick"', res) 390 | 391 | def test_field_arroba_dot(self): 392 | res = render_form('{{ form.simple|attr:"@click.prevent:onClick" }}') 393 | assertIn('@click.prevent="onClick"', res) 394 | 395 | def test_field_double_colon_missing(self): 396 | res = render_form('{{ form.simple|attr:"::class:{active:True}" }}') 397 | assertIn(':class="{active:True}"', res) 398 | 399 | 400 | class SelectFieldTest(TestCase): 401 | def test_parent_field(self): 402 | res = render_field("choice", "attr", "foo:bar") 403 | assertIn("select", res) 404 | assertIn('name="choice"', res) 405 | assertIn('id="id_choice"', res) 406 | assertIn('foo="bar"', res) 407 | assertIn('', res) 408 | assertIn('', res) 409 | 410 | def test_rendering_id_class(self): 411 | res = render_form( 412 | '{% render_field form.choice id="id_1" class="c_1" %}' 413 | '{% render_field form.choice id="id_2" class="c_2" %}' 414 | ) 415 | self.assertEqual(res.count("id_1"), 1) 416 | self.assertEqual(res.count("id_2"), 1) 417 | self.assertEqual(res.count("c_1"), 1) 418 | self.assertEqual(res.count("c_2"), 1) 419 | 420 | 421 | class RadioFieldTest(TestCase): 422 | def test_first_choice(self): 423 | res = render_choice_field("radio", 0, "attr", "foo:bar") 424 | assertIn('type="radio"', res) 425 | assertIn('name="radio"', res) 426 | assertIn('value="option1"', res) 427 | assertIn('id="id_radio_0"', res) 428 | assertIn('foo="bar"', res) 429 | 430 | def test_second_choice(self): 431 | res = render_choice_field("radio", 1, "attr", "foo:bar") 432 | assertIn('type="radio"', res) 433 | assertIn('name="radio"', res) 434 | assertIn('value="option2"', res) 435 | assertIn('id="id_radio_1"', res) 436 | assertIn('foo="bar"', res) 437 | 438 | def test_rendering_id_class(self): 439 | res = render_form( 440 | '{% render_field form.radio.0 id="id_1" class="c_1" %}' 441 | '{% render_field form.radio.1 id="id_2" class="c_2" %}' 442 | ) 443 | self.assertEqual(res.count("id_1"), 1) 444 | self.assertEqual(res.count("id_2"), 1) 445 | self.assertEqual(res.count("c_1"), 1) 446 | self.assertEqual(res.count("c_2"), 1) 447 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = 3 | py{39,310,311,py310,py311}-dj42 4 | py{310,311,312,py310,py311}-dj50 5 | py{310,311,312,313,py310,py311}-dj{51,52} 6 | py{312,313}-djmain 7 | py313-djqa 8 | 9 | [gh-actions] 10 | python = 11 | 3.9: py39 12 | 3.10: py310 13 | 3.11: py311 14 | 3.12: py312 15 | 3.13: py313 16 | pypy-3.10: pypy310 17 | pypy-3.11: pypy311 18 | 19 | [testenv] 20 | deps = 21 | coverage 22 | dj42: django>=4.2,<4.3 23 | dj50: django>=5.0,<5.1 24 | dj51: django>=5.1,<5.2 25 | dj52: django>=5.2b1,<5.3 26 | djmain: https://github.com/django/django/archive/main.tar.gz 27 | usedevelop = True 28 | setenv = 29 | PYTHONDONTWRITEBYTECODE=1 30 | # Django development version is allowed to fail the test matrix 31 | ignore_outcome = 32 | djmain: True 33 | ignore_errors = 34 | djmain: True 35 | commands = 36 | coverage run -m django test -v2 --settings=tests.settings 37 | coverage report -m 38 | coverage xml 39 | 40 | [testenv:py313-djqa] 41 | ignore_errors = true 42 | basepython = python3.13 43 | setenv = 44 | DJANGO_SETTINGS_MODULE=tests.settings 45 | deps = 46 | black 47 | django 48 | prospector 49 | skip_install = true 50 | commands = 51 | prospector -X 52 | black -t py39 --check --diff . 53 | -------------------------------------------------------------------------------- /widget_tweaks/__init__.py: -------------------------------------------------------------------------------- 1 | from importlib.metadata import version, PackageNotFoundError 2 | 3 | try: 4 | __version__ = version("django-widget-tweaks") 5 | except PackageNotFoundError: 6 | # package is not installed 7 | __version__ = None 8 | -------------------------------------------------------------------------------- /widget_tweaks/models.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jazzband/django-widget-tweaks/93b601137eaba07924b7258c7d5496fb8a6d48b8/widget_tweaks/models.py -------------------------------------------------------------------------------- /widget_tweaks/templatetags/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jazzband/django-widget-tweaks/93b601137eaba07924b7258c7d5496fb8a6d48b8/widget_tweaks/templatetags/__init__.py -------------------------------------------------------------------------------- /widget_tweaks/templatetags/widget_tweaks.py: -------------------------------------------------------------------------------- 1 | import re 2 | import types 3 | from copy import copy 4 | from django.template import Library, Node, TemplateSyntaxError 5 | 6 | register = Library() 7 | 8 | 9 | def silence_without_field(fn): 10 | def wrapped(field, attr): 11 | if not field: 12 | return "" 13 | return fn(field, attr) 14 | 15 | return wrapped 16 | 17 | 18 | def _process_field_attributes(field, attr, process): 19 | # split attribute name and value from 'attr:value' string 20 | # params = attr.split(':', 1) 21 | # attribute = params[0] 22 | params = re.split(r"(? 156 | [@\w:_\.-]+ 157 | ) 158 | (?P 159 | \+?= 160 | ) 161 | (?P 162 | ['"]? # start quote 163 | [^"']* 164 | ['"]? # end quote 165 | ) 166 | """, 167 | re.VERBOSE | re.UNICODE, 168 | ) 169 | 170 | 171 | @register.tag 172 | def render_field(parser, token): # pylint: disable=too-many-locals 173 | """ 174 | Render a form field using given attribute-value pairs 175 | 176 | Takes form field as first argument and list of attribute-value pairs for 177 | all other arguments. Attribute-value pairs should be in the form of 178 | attribute=value or attribute="a value" for assignment and attribute+=value 179 | or attribute+="value" for appending. 180 | """ 181 | error_msg = ( 182 | f"{token.split_contents()[0]!r} tag requires a form field followed by " 183 | 'a list of attributes and values in the form attr="value"' 184 | ) 185 | try: 186 | bits = token.split_contents() 187 | tag_name = bits[0] # noqa 188 | form_field = bits[1] 189 | attr_list = bits[2:] 190 | except ValueError as exc: 191 | raise TemplateSyntaxError(error_msg) from exc 192 | 193 | form_field = parser.compile_filter(form_field) 194 | 195 | set_attrs = [] 196 | append_attrs = [] 197 | for pair in attr_list: 198 | match = ATTRIBUTE_RE.match(pair) 199 | if not match: 200 | raise TemplateSyntaxError(error_msg + f": {pair}") 201 | dct = match.groupdict() 202 | attr, sign, value = ( 203 | dct["attr"], 204 | dct["sign"], 205 | parser.compile_filter(dct["value"]), 206 | ) 207 | if sign == "=": 208 | set_attrs.append((attr, value)) 209 | else: 210 | append_attrs.append((attr, value)) 211 | 212 | return FieldAttributeNode(form_field, set_attrs, append_attrs) 213 | 214 | 215 | class FieldAttributeNode(Node): 216 | def __init__(self, field, set_attrs, append_attrs): 217 | self.field = field 218 | self.set_attrs = set_attrs 219 | self.append_attrs = append_attrs 220 | 221 | def render(self, context): 222 | bounded_field = self.field.resolve(context) 223 | field = getattr(bounded_field, "field", None) 224 | if getattr(bounded_field, "errors", None) and "WIDGET_ERROR_CLASS" in context: 225 | bounded_field = append_attr( 226 | bounded_field, f'class:{context["WIDGET_ERROR_CLASS"]}' 227 | ) 228 | if field and field.required and "WIDGET_REQUIRED_CLASS" in context: 229 | bounded_field = append_attr( 230 | bounded_field, f"class:{context['WIDGET_REQUIRED_CLASS']}" 231 | ) 232 | for k, v in self.set_attrs: 233 | if k == "type": 234 | bounded_field.field.widget.input_type = v.resolve(context) 235 | else: 236 | bounded_field = set_attr(bounded_field, f"{k}:{v.resolve(context)}") 237 | for k, v in self.append_attrs: 238 | bounded_field = append_attr(bounded_field, f"{k}:{v.resolve(context)}") 239 | return str(bounded_field) 240 | 241 | 242 | # ======================== remove_attr tag ============================== 243 | 244 | 245 | @register.filter("remove_attr") 246 | @silence_without_field 247 | def remove_attr(field, attr): 248 | if attr in field.field.widget.attrs: 249 | del field.field.widget.attrs[attr] 250 | return field 251 | --------------------------------------------------------------------------------