├── .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 | [](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 |
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 |
--------------------------------------------------------------------------------