├── .github └── workflows │ ├── CI.yml │ ├── build_docs.yml │ ├── docs.yml │ └── publish.yml ├── .gitignore ├── .pre-commit-config.yaml ├── CHANGELOG.md ├── LICENSE.txt ├── MANIFEST.in ├── README.rst ├── crispy_tailwind ├── __init__.py ├── layout.py ├── tailwind.py ├── templates │ └── tailwind │ │ ├── display_form.html │ │ ├── errors.html │ │ ├── errors_formset.html │ │ ├── field.html │ │ ├── inputs.html │ │ ├── layout │ │ ├── alert.html │ │ ├── attrs.html │ │ ├── baseinput.html │ │ ├── button.html │ │ ├── buttonholder.html │ │ ├── checkboxselectmultiple.html │ │ ├── checkboxselectmultiple_inline.html │ │ ├── column.html │ │ ├── div.html │ │ ├── field_errors.html │ │ ├── field_errors_block.html │ │ ├── field_with_buttons.html │ │ ├── fieldset.html │ │ ├── formactions.html │ │ ├── help_text.html │ │ ├── help_text_and_errors.html │ │ ├── inline_field.html │ │ ├── prepended_appended_text.html │ │ ├── radioselect.html │ │ ├── radioselect_inline.html │ │ ├── row.html │ │ ├── select.html │ │ └── select_option.html │ │ ├── table_inline_formset.html │ │ ├── uni_form.html │ │ ├── uni_formset.html │ │ ├── whole_uni_form.html │ │ └── whole_uni_formset.html └── templatetags │ ├── __init__.py │ ├── tailwind_field.py │ └── tailwind_filters.py ├── docs ├── Makefile ├── conf.py ├── contributing.txt ├── custom.txt ├── examples.txt ├── getting_started.txt ├── img │ ├── Buttons.png │ ├── alert.png │ ├── crispy_failing.png │ ├── crispy_form.png │ ├── custom_button.png │ ├── field_with_buttons.png │ ├── fieldset.png │ ├── formset_failing.png │ ├── inline_field.png │ ├── prepended.png │ └── row_col.png ├── index.txt ├── layout_objects.txt ├── make.bat └── requirements.txt ├── pyproject.toml ├── requirements.txt ├── setup.cfg ├── tests ├── __init__.py ├── conftest.py ├── forms.py ├── results │ ├── alert │ │ ├── alert.html │ │ ├── alert_custom.html │ │ └── alert_dismiss_false.html │ ├── crispy_addon │ │ └── crispy_addon.html │ ├── filter │ │ ├── crispy_filter.html │ │ ├── crispy_filter_lt50.html │ │ └── formset.html │ ├── helper │ │ ├── buttons.html │ │ ├── buttons_with_css.html │ │ ├── charfield.html │ │ ├── charfield_failing.html │ │ ├── charfield_failing_lt50.html │ │ ├── col_row.html │ │ ├── col_row_lt50.html │ │ ├── crispy_layout.html │ │ ├── div.html │ │ ├── div_lt50.html │ │ ├── field_with_buttons.html │ │ ├── fieldset.html │ │ ├── fieldset_lt50.html │ │ ├── formset.html │ │ ├── formset_errors.html │ │ ├── formset_errors_lt50.html │ │ ├── formset_form_tag.html │ │ ├── formset_form_tag_lt50.html │ │ ├── formset_lt50.html │ │ ├── inline_checkbox.html │ │ ├── inline_field.html │ │ ├── inline_radio.html │ │ ├── multiple_checkbox.html │ │ ├── non_form_errors.html │ │ ├── non_form_errors_lt50.html │ │ ├── password.html │ │ ├── radio.html │ │ └── select.html │ ├── prepended │ │ ├── appended_errors.html │ │ ├── appended_errors_lt50.html │ │ ├── appended_text.html │ │ ├── prepended_appended_errors.html │ │ ├── prepended_appended_errors_lt50.html │ │ ├── prepended_appended_text.html │ │ ├── prepended_errors.html │ │ ├── prepended_errors_lt50.html │ │ ├── prepended_help.html │ │ ├── prepended_help_lt50.html │ │ ├── prepended_long_text.html │ │ ├── prepended_no_label.html │ │ └── prepended_text.html │ └── table_inline_formset │ │ ├── table_inline_formset.html │ │ ├── table_inline_formset_failing.html │ │ ├── table_inline_formset_failing_lt50.html │ │ └── table_inline_formset_lt50.html ├── test_alert.py ├── test_crispy_addon.py ├── test_filter.py ├── test_helper.py ├── test_prepended_appended.py ├── test_settings.py ├── test_table_inline_formset.py ├── test_tailwind.py ├── urls.py └── utils.py └── tox.ini /.github/workflows/CI.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | 9 | permissions: 10 | contents: read 11 | 12 | jobs: 13 | linting: 14 | name: Linting 15 | runs-on: ubuntu-latest 16 | steps: 17 | - uses: actions/checkout@v4 18 | 19 | - name: Set up Python 3.12 20 | uses: actions/setup-python@v4 21 | with: 22 | python-version: "3.12" 23 | 24 | - name: Install required packages 25 | run: python -m pip install --upgrade tox 26 | 27 | - name: Run linters 28 | run: tox -e lint 29 | 30 | 31 | tests: 32 | name: Python ${{ matrix.python-version }} 33 | runs-on: ubuntu-latest 34 | 35 | strategy: 36 | matrix: 37 | python-version: 38 | - '3.8' 39 | - '3.9' 40 | - '3.10' 41 | - '3.11' 42 | - '3.12' 43 | 44 | steps: 45 | - uses: actions/checkout@v4 46 | 47 | - uses: actions/setup-python@v5 48 | with: 49 | python-version: ${{ matrix.python-version }} 50 | cache: pip 51 | cache-dependency-path: requirements/*.txt 52 | 53 | - name: Upgrade packaging tools 54 | run: python -m pip install --upgrade pip setuptools virtualenv wheel 55 | 56 | - name: Install dependencies 57 | run: python -m pip install --upgrade codecov tox 58 | 59 | - name: Run tox targets for Python ${{ matrix.python-version }} 60 | run: | 61 | ENV_PREFIX=$(tr -C -d "0-9" <<< "${{ matrix.python-version }}") 62 | TOXENV=$(tox --listenvs | grep "^py$ENV_PREFIX" | tr '\n' ',') tox 63 | 64 | - name: Upload coverage to Codecov 65 | uses: codecov/codecov-action@v3 66 | with: 67 | flags: unittests 68 | name: codecov-umbrella 69 | fail_ci_if_error: false 70 | 71 | 72 | deploy: 73 | name: Deploy 74 | needs: 75 | - linting 76 | - tests 77 | runs-on: ubuntu-latest 78 | if: github.ref=='refs/heads/main' && github.event_name!='pull_request' 79 | 80 | permissions: 81 | contents: write 82 | id-token: write 83 | 84 | steps: 85 | - uses: actions/checkout@v4 86 | 87 | - name: Set up Python 88 | uses: actions/setup-python@v5 89 | with: 90 | python-version: "3.10" 91 | 92 | - name: Check release 93 | id: check_release 94 | run: | 95 | python -m pip install autopub httpx==0.18.2 96 | python -m pip install https://github.com/scikit-build/github-release/archive/master.zip 97 | autopub check 98 | 99 | - name: Publish 100 | if: ${{ steps.check_release.outputs.autopub_release=='true' }} 101 | env: 102 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 103 | run: | 104 | autopub prepare 105 | autopub commit 106 | autopub build 107 | autopub githubrelease 108 | 109 | - name: Upload package to PyPI 110 | if: ${{ steps.check_release.outputs.autopub_release=='true' }} 111 | uses: pypa/gh-action-pypi-publish@release/v1 112 | -------------------------------------------------------------------------------- /.github/workflows/build_docs.yml: -------------------------------------------------------------------------------- 1 | name: github pages 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | - test-docs 8 | 9 | jobs: 10 | deploy: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v4 14 | 15 | - name: Setup Python 16 | uses: actions/setup-python@v4 17 | with: 18 | python-version: '3.12' 19 | cache: pip 20 | cache-dependency-path: requirements.txt 21 | 22 | - name: Upgrade pip 23 | run: | 24 | # install pip=>20.1 to use "pip cache dir" 25 | python -m pip install --upgrade pip 26 | 27 | - name: Install dependencies 28 | run: python -m pip install -r ./requirements.txt 29 | 30 | - run: | 31 | cd docs 32 | make html 33 | 34 | - name: Deploy 35 | uses: peaceiris/actions-gh-pages@v3 36 | with: 37 | github_token: ${{ secrets.GITHUB_TOKEN }} 38 | publish_dir: docs/_build/html 39 | -------------------------------------------------------------------------------- /.github/workflows/docs.yml: -------------------------------------------------------------------------------- 1 | name: Docs Check 2 | on: 3 | pull_request: 4 | push: 5 | branches: 6 | - main 7 | 8 | jobs: 9 | docs: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v4 13 | - uses: ammaraskar/sphinx-action@master 14 | with: 15 | docs-folder: "docs/" 16 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish 2 | 3 | on: 4 | push: 5 | tags: 6 | - '*' 7 | 8 | jobs: 9 | test: 10 | name: Python ${{ matrix.python-version }} 11 | runs-on: ubuntu-latest 12 | 13 | strategy: 14 | matrix: 15 | python-version: 16 | - '3.8' 17 | - '3.9' 18 | - '3.10' 19 | - '3.11' 20 | - '3.12' 21 | 22 | steps: 23 | - uses: actions/checkout@v4 24 | 25 | - uses: actions/setup-python@v4 26 | with: 27 | python-version: ${{ matrix.python-version }} 28 | cache: pip 29 | cache-dependency-path: requirements/*.txt 30 | 31 | - name: Upgrade packaging tools 32 | run: python -m pip install --upgrade pip setuptools virtualenv wheel 33 | 34 | - name: Install dependencies 35 | run: python -m pip install --upgrade codecov tox 36 | 37 | - name: Run tox targets for ${{ matrix.python-version }} 38 | run: | 39 | ENV_PREFIX=$(tr -C -d "0-9" <<< "${{ matrix.python-version }}") 40 | TOXENV=$(tox --listenvs | grep "^py$ENV_PREFIX" | tr '\n' ',') tox 41 | - name: Run lint 42 | if: ${{ matrix.python-version == '3.12' }} 43 | run: | 44 | tox -e lint 45 | 46 | deploy: 47 | runs-on: ubuntu-latest 48 | needs: [test] 49 | steps: 50 | - uses: actions/checkout@v3 51 | - name: Set up Python 52 | uses: actions/setup-python@v3 53 | with: 54 | python-version: '3.12' 55 | - name: Install dependencies 56 | run: | 57 | pip install build twine 58 | - name: Publish 59 | env: 60 | TWINE_USERNAME: __token__ 61 | TWINE_PASSWORD: ${{ secrets.PYPI_TOKEN }} 62 | run: | 63 | python -m build 64 | twine upload dist/* 65 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Created by .ignore support plugin (hsz.mobi) 2 | ### Python template 3 | # Byte-compiled / optimized / DLL files 4 | __pycache__/ 5 | *.py[cod] 6 | *$py.class 7 | 8 | # C extensions 9 | *.so 10 | 11 | # Distribution / packaging 12 | .Python 13 | build/ 14 | develop-eggs/ 15 | dist/ 16 | downloads/ 17 | eggs/ 18 | .eggs/ 19 | lib/ 20 | lib64/ 21 | parts/ 22 | sdist/ 23 | var/ 24 | wheels/ 25 | pip-wheel-metadata/ 26 | share/python-wheels/ 27 | *.egg-info/ 28 | .installed.cfg 29 | *.egg 30 | MANIFEST 31 | 32 | # PyInstaller 33 | # Usually these files are written by a python script from a template 34 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 35 | *.manifest 36 | *.spec 37 | 38 | # Installer logs 39 | pip-log.txt 40 | pip-delete-this-directory.txt 41 | 42 | # Unit test / coverage reports 43 | htmlcov/ 44 | .tox/ 45 | .nox/ 46 | .coverage 47 | .coverage.* 48 | .cache 49 | nosetests.xml 50 | coverage.xml 51 | *.cover 52 | *.py,cover 53 | .hypothesis/ 54 | .pytest_cache/ 55 | cover/ 56 | 57 | # Translations 58 | *.mo 59 | *.pot 60 | 61 | # Django stuff: 62 | *.log 63 | local_settings.py 64 | db.sqlite3 65 | db.sqlite3-journal 66 | 67 | # Flask stuff: 68 | instance/ 69 | .webassets-cache 70 | 71 | # Scrapy stuff: 72 | .scrapy 73 | 74 | # Sphinx documentation 75 | docs/_build/ 76 | 77 | # PyBuilder 78 | .pybuilder/ 79 | target/ 80 | 81 | # Jupyter Notebook 82 | .ipynb_checkpoints 83 | 84 | # IPython 85 | profile_default/ 86 | ipython_config.py 87 | 88 | # pyenv 89 | # For a library or package, you might want to ignore these files since the code is 90 | # intended to run in multiple environments; otherwise, check them in: 91 | # .python-version 92 | 93 | # pipenv 94 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 95 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 96 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 97 | # install all needed dependencies. 98 | #Pipfile.lock 99 | 100 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 101 | __pypackages__/ 102 | 103 | # Celery stuff 104 | celerybeat-schedule 105 | celerybeat.pid 106 | 107 | # SageMath parsed files 108 | *.sage.py 109 | 110 | # Environments 111 | .env 112 | .venv 113 | env/ 114 | venv/ 115 | ENV/ 116 | env.bak/ 117 | venv.bak/ 118 | 119 | # Spyder project settings 120 | .spyderproject 121 | .spyproject 122 | 123 | # Rope project settings 124 | .ropeproject 125 | 126 | # mkdocs documentation 127 | /site 128 | 129 | # mypy 130 | .mypy_cache/ 131 | .dmypy.json 132 | dmypy.json 133 | 134 | # Pyre type checker 135 | .pyre/ 136 | 137 | # pytype static type analyzer 138 | .pytype/ 139 | 140 | # Cython debug symbols 141 | cython_debug/ 142 | 143 | .idea/ 144 | venv/ 145 | 146 | # Visual studio code 147 | .vscode 148 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | # See https://pre-commit.com/hooks.html for info on hooks 3 | - repo: https://github.com/pre-commit/pre-commit-hooks 4 | rev: v5.0.0 5 | hooks: 6 | - id: check-added-large-files 7 | - id: check-ast 8 | - id: check-case-conflict 9 | - id: check-toml 10 | - id: check-yaml 11 | - id: debug-statements 12 | - id: detect-private-key 13 | - id: end-of-file-fixer 14 | - id: trailing-whitespace 15 | 16 | - repo: https://github.com/psf/black 17 | rev: 24.10.0 18 | hooks: 19 | - id: black 20 | - repo: https://github.com/PyCQA/isort 21 | rev: 5.13.2 22 | hooks: 23 | - id: isort 24 | - repo: https://github.com/PyCQA/flake8 25 | rev: 7.1.1 26 | hooks: 27 | - id: flake8 28 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | CHANGELOG 2 | ========= 3 | 4 | Next Release - TBC 5 | ------------------ 6 | 7 | * Added support for Django 5.1. 8 | 9 | Contributed by [David S.](https://github.com/smithdc1) via [PR #167](https://github.com/django-crispy-forms/crispy-tailwind/pull/167/) 10 | 11 | 1.0.3 - 2024-02-13 12 | ------------------ 13 | 14 | * Fixed stray closing divs in template files (second try 😅) 15 | 16 | Contributed by [Ronny V.](https://github.com/GitRon) via [PR #162](https://github.com/django-crispy-forms/crispy-tailwind/pull/162/) 17 | 18 | 19 | 1.0.2 - 2024-02-13 20 | ------------------ 21 | 22 | * Fixed stray closing divs in template files 23 | 24 | Contributed by [Ronny V.](https://github.com/GitRon) via [PR #160](https://github.com/django-crispy-forms/crispy-tailwind/pull/160/) 25 | 26 | 27 | 1.0.1 - 2024-02-06 28 | ------------------ 29 | 30 | * Add `build_attrs` filter to `tailwind_filters.py` 31 | * Add `attrs.html` template for Tailwind layout 32 | * Refactor `select.html` and `select_option.html` templates 33 | * Add ID attribute to `select` elements 34 | 35 | 1.0.0 - 2024-01-09 36 | ------------------ 37 | 38 | * Added support for Django 5.0 (#142) 39 | * Added support for Python 3.11 and 3.12 (#142) 40 | * Added support for Python 3.10 (#116) 41 | * Added support for Django 4.2 (#135) 42 | * Dropped support for Django 2.2 (#116) 43 | * Dropped support for Django 3.2, 4.0 and 4.1 (#138) 44 | * Dropped support for Python 3.6 (#116) 45 | * Dropped support for Python 3.7 (#135) 46 | * Increased minimum supported django-crispy-forms version to 2.0 (#135) 47 | * Added docs about Tailwind CLI template discovery management command (#144) 48 | * Fixed bug with select template and disabled property (#118) 49 | 50 | 0.5.0 - 2021-04-21 51 | ------------------ 52 | 53 | * Added support for custom widgets (#92) 54 | * Confirmed support for Django 3.2 (#91) 55 | * Dropped support for Django 3.1 (#91) 56 | 57 | See [Release Notes](https://github.com/django-crispy-forms/crispy-tailwind/milestone/5?closed=1) 58 | for full change log. 59 | 60 | 0.4.0 - 2021-03-22 61 | ------------------ 62 | 63 | * Fixed compatibility with django-crispy-forms 1.11.2 (#86) 64 | * Fixed field names when using formsets (#84) 65 | 66 | See [Release Notes](https://github.com/django-crispy-forms/crispy-tailwind/milestone/4?closed=1) 67 | for full change log. 68 | 69 | 0.3.0 - 2021-02-14 70 | ------------------ 71 | 72 | * Fixed non form errors (#77) 73 | * Various documentation improvements 74 | * Python 3.9 (#60) and Django 3.1 (#56) support 75 | 76 | See [Release Notes](https://github.com/django-crispy-forms/crispy-tailwind/milestone/3?closed=1) 77 | for full change log. 78 | 79 | 0.2.0 - 2020-07-11 80 | ------------------ 81 | 82 | * Support for Formsets 83 | * Prepended and Appended inputs 84 | * Customisable buttons 85 | * Much improved test coverage 86 | * Improved documentation. Docs now include details on how to use the majority 87 | of the core layout objects, crispy filter and add-on 88 | 89 | See [Release Notes](https://github.com/django-crispy-forms/crispy-tailwind/milestone/2?closed=1) 90 | for full change log. 91 | 92 | 0.1.0 - 2020-06-09 93 | ------------------ 94 | 95 | * First Release, please do come and test! 96 | * Opinionated forms can be rendered with crispy filter 97 | * Limited set of layout objects are also available for crispy tags, also with 98 | opinionated rendering. 99 | 100 | See [Release Notes](https://github.com/django-crispy-forms/crispy-tailwind/milestone/1) 101 | for full change log 102 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Copyright (c) 2020 David Smith and contributors. 2 | 3 | Permission is hereby granted, free of charge, to any person 4 | obtaining a copy of this software and associated documentation 5 | files (the "Software"), to deal in the Software without 6 | restriction, including without limitation the rights to use, 7 | copy, modify, merge, publish, distribute, sublicense, and/or sell 8 | copies of the Software, and to permit persons to whom the 9 | Software is furnished to do so, subject to the following 10 | conditions: 11 | 12 | The above copyright notice and this permission notice shall be 13 | included in all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 16 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES 17 | OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 18 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT 19 | HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, 20 | WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 21 | FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR 22 | OTHER DEALINGS IN THE SOFTWARE. 23 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include LICENSE.txt 2 | include MANIFEST.in 3 | include README.rst 4 | recursive-include crispy_tailwind/templates * 5 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | =============== 2 | Crispy-Tailwind 3 | =============== 4 | 5 | .. image:: https://img.shields.io/badge/code%20style-black-000000.svg 6 | :target: https://github.com/psf/black 7 | 8 | A `Tailwind CSS`_ template pack for the wonderful django-crispy-forms_. 9 | 10 | **WARNING** 11 | 12 | This project is still in its early stages of development. Any contributions to 13 | the package would be very welcomed. 14 | 15 | Currently the template pack allows the use of the ``|crispy`` filter to style 16 | your form. Here is an example image. 17 | 18 | .. image:: https://django-crispy-forms.github.io/crispy-tailwind/_images/crispy_form.png 19 | 20 | How to install 21 | -------------- 22 | 23 | Install via pip. :: 24 | 25 | pip install crispy-tailwind 26 | 27 | You will need to update your project's settings file to add ``crispy_forms`` 28 | and ``crispy_tailwind`` to your project's ``INSTALLED_APPS`` setting. Also set 29 | ``tailwind`` as an allowed template pack and as the default template pack 30 | for your project:: 31 | 32 | INSTALLED_APPS = ( 33 | ... 34 | "crispy_forms", 35 | "crispy_tailwind", 36 | ... 37 | ) 38 | 39 | CRISPY_ALLOWED_TEMPLATE_PACKS = "tailwind" 40 | 41 | CRISPY_TEMPLATE_PACK = "tailwind" 42 | 43 | How to use 44 | ---------- 45 | 46 | This project is still in its early stages. 47 | 48 | Current functionality allows the ``|crispy`` filter to be used to style your 49 | form. In your template: 50 | 51 | 1. Load the filter: ``{% load tailwind_filters %}`` 52 | 2. Apply the crispy filter: ``{{ form|crispy }}`` 53 | 54 | We can also use the ``{% crispy %}`` tag to allow usage of crispy-forms' 55 | ``FormHelper`` and ``Layout``. In your template: 56 | 57 | 1. Load the crispy tag: ``{% load crispy_forms_tags %}`` 58 | 2. Add ``FormHelper`` to your form and use crispy-forms to set-up your form 59 | 3. Use the crispy tag ``{% crispy form %}`` in your template 60 | 61 | Documentation 62 | ------------- 63 | 64 | The documentation for this project is available here: 65 | https://django-crispy-forms.github.io/crispy-tailwind/index.html 66 | 67 | FAQs 68 | ---- 69 | 70 | What about custom widgets? 71 | ========================== 72 | 73 | The template pack includes default styles for widgets included in Django 74 | itself. `Styling of widget instances`_ can be done by using the ``widget.attrs`` 75 | argument when creating the widget. 76 | 77 | For example the following form will render 78 | ````:: 79 | 80 | class CustomTextWidget(forms.TextInput): 81 | pass 82 | 83 | class CustomTextWidgetForm(forms.Form): 84 | name = forms.CharField( 85 | widget=CustomTextWidget(attrs={"class": "custom-css"}) 86 | ) 87 | 88 | .. _`Styling of widget instances` : https://docs.djangoproject.com/en/dev/ref/forms/widgets/#styling-widget-instances 89 | .. _Tailwind CSS: https://tailwindcss.com/ 90 | .. _django-crispy-forms: https://github.com/django-crispy-forms/django-crispy-forms 91 | -------------------------------------------------------------------------------- /crispy_tailwind/__init__.py: -------------------------------------------------------------------------------- 1 | __version__ = "1.0.3" 2 | -------------------------------------------------------------------------------- /crispy_tailwind/layout.py: -------------------------------------------------------------------------------- 1 | from crispy_forms.bootstrap import Alert 2 | from crispy_forms.layout import BaseInput 3 | 4 | 5 | class Submit(BaseInput): 6 | """ 7 | Used to create a Submit button descriptor for the {% crispy %} template tag:: 8 | submit = Submit('Search the Site', 'search this site') 9 | .. note:: The first argument is also slugified and turned into the id for the submit button. 10 | 11 | This is a customised version for Tailwind to add Tailwind CSS style by default 12 | """ 13 | 14 | input_type = "submit" 15 | 16 | def __init__(self, *args, css_class=None, **kwargs): 17 | if css_class is None: 18 | self.field_classes = "bg-green-500 hover:bg-green-700 text-white font-bold py-2 px-4 rounded" 19 | else: 20 | self.field_classes = css_class 21 | super().__init__(*args, **kwargs) 22 | 23 | 24 | class Reset(BaseInput): 25 | """ 26 | Used to create a Reset button input descriptor for the {% crispy %} template tag:: 27 | reset = Reset('Reset This Form', 'Revert Me!') 28 | .. note:: The first argument is also slugified and turned into the id for the reset. 29 | 30 | This is a customised version for Tailwind to add Tailwind CSS style by default 31 | """ 32 | 33 | input_type = "reset" 34 | 35 | def __init__(self, *args, css_class=None, **kwargs): 36 | if css_class is None: 37 | self.field_classes = "bg-red-500 hover:bg-red-700 text-white font-bold py-2 px-4 rounded" 38 | else: 39 | self.field_classes = css_class 40 | super().__init__(*args, **kwargs) 41 | 42 | 43 | class Button(BaseInput): 44 | """ 45 | Used to create a button descriptor for the {% crispy %} template tag:: 46 | submit = Button('Search the Site', 'search this site') 47 | .. note:: The first argument is also slugified and turned into the id for the submit button. 48 | 49 | This is a customised version for Tailwind to add Tailwind CSS style by default 50 | """ 51 | 52 | input_type = "button" 53 | 54 | def __init__(self, *args, css_class=None, **kwargs): 55 | if css_class is None: 56 | self.field_classes = "bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded" 57 | else: 58 | self.field_classes = css_class 59 | super().__init__(*args, **kwargs) 60 | 61 | 62 | class Alert(Alert): 63 | css_class = "" 64 | -------------------------------------------------------------------------------- /crispy_tailwind/tailwind.py: -------------------------------------------------------------------------------- 1 | # todo 2 | # 1: this file needs a tidy up 3 | # 2: is this the right implementation. Maybe we can use class converters? 4 | 5 | import re 6 | 7 | 8 | class CSSContainer: 9 | def __init__(self, css_styles): 10 | default_items = [ 11 | # widgets 12 | "text", 13 | "number", 14 | "email", 15 | "url", 16 | "password", 17 | "hidden", 18 | "multiplehidden", 19 | "file", 20 | "clearablefile", 21 | "textarea", 22 | "date", 23 | "datetime", 24 | "time", 25 | "checkbox", 26 | "select", 27 | "nullbooleanselect", 28 | "selectmultiple", 29 | "radioselect", 30 | "checkboxselectmultiple", 31 | "multi", 32 | "splitdatetime", 33 | "splithiddendatetime", 34 | "selectdate", 35 | # other items 36 | "error_border", 37 | ] 38 | 39 | base = css_styles.get("base", "") 40 | for item in default_items: 41 | setattr(self, item, base) 42 | 43 | for key, value in css_styles.items(): 44 | if key != "base": 45 | # get current attribute and rejoin with a set, also to ensure a space between each attribute 46 | current_class = set(getattr(self, key).split()) 47 | current_class.update(set(value.split())) 48 | new_classes = " ".join(current_class) 49 | setattr(self, key, new_classes) 50 | 51 | def __repr__(self): 52 | return str(self.__dict__) 53 | 54 | def __add__(self, other): 55 | for field, css_class in other.items(): 56 | current_class = set(getattr(self, field).split()) 57 | current_class.update(set(css_class.split())) 58 | new_classes = " ".join(current_class) 59 | setattr(self, field, new_classes) 60 | return self 61 | 62 | def __sub__(self, other): 63 | for field, css_class in other.items(): 64 | current_class = set(getattr(self, field).split()) 65 | removed_classes = set(css_class.split()) 66 | new_classes = " ".join(current_class - removed_classes) 67 | setattr(self, field, new_classes) 68 | return self 69 | 70 | def get_input_class(self, field): 71 | widget_name = re.sub(r"widget$|input$", "", field.field.widget.__class__.__name__.lower()) 72 | return getattr(self, widget_name, "") 73 | -------------------------------------------------------------------------------- /crispy_tailwind/templates/tailwind/display_form.html: -------------------------------------------------------------------------------- 1 | {% if form.form_html %} 2 | {% if include_media %}{{ form.media }}{% endif %} 3 | {% if form_show_errors %} 4 | {% include "tailwind/errors.html" %} 5 | {% endif %} 6 | {{ form.form_html }} 7 | {% else %} 8 | {% include "tailwind/uni_form.html" %} 9 | {% endif %} 10 | -------------------------------------------------------------------------------- /crispy_tailwind/templates/tailwind/errors.html: -------------------------------------------------------------------------------- 1 | {% if form.non_field_errors %} 2 |
3 | {% if form_error_title %} 4 |
5 | {{ form_error_title }} 6 |
7 | {% endif %} 8 |
9 | 12 |
13 |
14 | {% endif %} 15 | -------------------------------------------------------------------------------- /crispy_tailwind/templates/tailwind/errors_formset.html: -------------------------------------------------------------------------------- 1 | {% if formset.non_form_errors %} 2 |
3 | {% if formset_error_title %} 4 |
5 | {{ formset_error_title }} 6 |
7 | {% endif %} 8 |
9 | 12 |
13 |
14 | {% endif %} 15 | -------------------------------------------------------------------------------- /crispy_tailwind/templates/tailwind/field.html: -------------------------------------------------------------------------------- 1 | {% load tailwind_field %} 2 | 3 | {% if field.is_hidden %} 4 | {{ field }} 5 | {% else %} 6 | {# Opening Div and Label first #} 7 | 8 | <{% if tag %}{{ tag }}{% else %}div{% endif %} id="div_{{ field.auto_id }}" class="{% if wrapper_class %}{{ wrapper_class }} {% endif %}{% if field_class %}{{ field_class }}{% else %}mb-3{% endif %}"> 9 | {% if field.label and form_show_labels %} 10 | 13 | {% endif %} 14 | 15 | {# if field has a special template then use this #} 16 | {% if field|is_select %} 17 |
18 | {% include 'tailwind/layout/select.html' %} 19 |
20 | {% elif field|is_checkboxselectmultiple %} 21 |
22 | {% include 'tailwind/layout/checkboxselectmultiple.html' %} 23 |
24 | {% elif field|is_radioselect %} 25 |
26 | {% include 'tailwind/layout/radioselect.html' %} 27 |
28 | {% else %} 29 | {# otherwise use django rendering with additional classes added #} 30 | {% tailwind_field field %} 31 | {% endif %} 32 | 33 | {% include 'tailwind/layout/help_text_and_errors.html' %} 34 | 35 | 36 | 37 | {% endif %} 38 | -------------------------------------------------------------------------------- /crispy_tailwind/templates/tailwind/inputs.html: -------------------------------------------------------------------------------- 1 | {% if inputs %} 2 |
3 | {% if label_class %} 4 |
5 | {% endif %} 6 | 7 |
8 | {% for input in inputs %} 9 | {% include "tailwind/layout/baseinput.html" %} 10 | {% endfor %} 11 |
12 |
13 | {% endif %} 14 | -------------------------------------------------------------------------------- /crispy_tailwind/templates/tailwind/layout/alert.html: -------------------------------------------------------------------------------- 1 | 2 |
{{ content|safe }}
3 | {% if dismiss %} 4 | 5 | Close 6 | 7 | {% endif %} 8 | 9 | -------------------------------------------------------------------------------- /crispy_tailwind/templates/tailwind/layout/attrs.html: -------------------------------------------------------------------------------- 1 | {% for name, value in widget.attrs.items %}{% if value is not False %} {{ name }}{% if value is not True %}="{{ value|stringformat:'s' }}"{% endif %}{% endif %}{% endfor %} 2 | -------------------------------------------------------------------------------- /crispy_tailwind/templates/tailwind/layout/baseinput.html: -------------------------------------------------------------------------------- 1 | 10 | -------------------------------------------------------------------------------- /crispy_tailwind/templates/tailwind/layout/button.html: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /crispy_tailwind/templates/tailwind/layout/buttonholder.html: -------------------------------------------------------------------------------- 1 |
3 | {{ fields_output|safe }} 4 |
5 | -------------------------------------------------------------------------------- /crispy_tailwind/templates/tailwind/layout/checkboxselectmultiple.html: -------------------------------------------------------------------------------- 1 | {% load crispy_forms_filters %} 2 | {% load l10n %} 3 | 4 | 5 | {% for choice in field.field.choices %} 6 |
7 | 11 | {% if field.errors and forloop.last and not inline_class %} 12 | {# include 'tailwind/layout/field_errors_block.html' <-- bs4 template adds this here. Currently this pack adds it in field.html #} 13 | {% endif %} 14 |
15 | {% endfor %} 16 | -------------------------------------------------------------------------------- /crispy_tailwind/templates/tailwind/layout/checkboxselectmultiple_inline.html: -------------------------------------------------------------------------------- 1 | {% if field.is_hidden %} 2 | {{ field }} 3 | {% else %} 4 | <{% if tag %}{{ tag }}{% else %}div{% endif %} id="div_{{ field.auto_id }}" class="{% if wrapper_class %}{{ wrapper_class }} {% endif %}{% if field_class %}{{ field_class }}{% else %}mb-3{% endif %}"> 5 | 6 | {% if field.label %} 7 | 10 | {% endif %} 11 | 12 |
13 | {% include 'tailwind/layout/checkboxselectmultiple.html' %} 14 |
15 | {% endif %} 16 | -------------------------------------------------------------------------------- /crispy_tailwind/templates/tailwind/layout/column.html: -------------------------------------------------------------------------------- 1 |
3 | {{ fields|safe }} 4 |
5 | -------------------------------------------------------------------------------- /crispy_tailwind/templates/tailwind/layout/div.html: -------------------------------------------------------------------------------- 1 |
3 | {{ fields|safe }} 4 |
5 | -------------------------------------------------------------------------------- /crispy_tailwind/templates/tailwind/layout/field_errors.html: -------------------------------------------------------------------------------- 1 | {% if form_show_errors and field.errors %} 2 | {% for error in field.errors %} 3 |

{{ error }}

4 | {% endfor %} 5 | {% endif %} 6 | -------------------------------------------------------------------------------- /crispy_tailwind/templates/tailwind/layout/field_errors_block.html: -------------------------------------------------------------------------------- 1 | {% if form_show_errors and field.errors %} 2 | {% for error in field.errors %} 3 |

{{ error }}

4 | {% endfor %} 5 | {% endif %} 6 | -------------------------------------------------------------------------------- /crispy_tailwind/templates/tailwind/layout/field_with_buttons.html: -------------------------------------------------------------------------------- 1 | {% load tailwind_field %} 2 | 3 | 4 | {% if field.label and form_show_labels %} 5 | 8 | {% endif %} 9 | 10 |
11 | {% tailwind_field field "class" "border border-gray-300 rounded-lg rounded-r-none px-4 py-2 w-full focus:outline-none text-gray-700 border-r-0 leading-normal" %} 12 | {{ buttons|safe }} 13 | {% include 'tailwind/layout/help_text_and_errors.html' %} 14 |
15 | 16 | -------------------------------------------------------------------------------- /crispy_tailwind/templates/tailwind/layout/fieldset.html: -------------------------------------------------------------------------------- 1 |
4 | {% if legend %}{{ legend|safe }}{% endif %} 5 | {{ fields|safe }} 6 |
7 | -------------------------------------------------------------------------------- /crispy_tailwind/templates/tailwind/layout/formactions.html: -------------------------------------------------------------------------------- 1 | 2 | {% if label_class %} 3 |
4 | {% endif %} 5 | 6 |
7 | {{ fields_output|safe }} 8 |
9 | 10 | -------------------------------------------------------------------------------- /crispy_tailwind/templates/tailwind/layout/help_text.html: -------------------------------------------------------------------------------- 1 | {% if field.help_text %} 2 | {% if help_text_inline %} 3 |

{{ field.help_text|safe }}

4 | {% else %} 5 | {{ field.help_text|safe }} 6 | {% endif %} 7 | {% endif %} 8 | -------------------------------------------------------------------------------- /crispy_tailwind/templates/tailwind/layout/help_text_and_errors.html: -------------------------------------------------------------------------------- 1 | {% if help_text_inline and not error_text_inline %} 2 | {% include 'tailwind/layout/help_text.html' %} 3 | {% endif %} 4 | 5 | {% if error_text_inline %} 6 | {% include 'tailwind/layout/field_errors.html' %} 7 | {% else %} 8 | {% include 'tailwind/layout/field_errors_block.html' %} 9 | {% endif %} 10 | 11 | {% if not help_text_inline %} 12 | {% include 'tailwind/layout/help_text.html' %} 13 | {% endif %} 14 | -------------------------------------------------------------------------------- /crispy_tailwind/templates/tailwind/layout/inline_field.html: -------------------------------------------------------------------------------- 1 | {% load tailwind_field %} 2 | 3 | {% if field.is_hidden %} 4 | {{ field }} 5 | {% else %} 6 | {% if field|is_checkbox %} 7 |
8 | 12 |
13 | {% else %} 14 |
15 | 18 | {% tailwind_field field 'placeholder' field.label %} 19 |
20 | {% endif %} 21 | {% endif %} 22 | -------------------------------------------------------------------------------- /crispy_tailwind/templates/tailwind/layout/prepended_appended_text.html: -------------------------------------------------------------------------------- 1 | {% load tailwind_field %} 2 | 3 | {% if field.is_hidden %} 4 | {{ field }} 5 | {% else %} 6 |
7 | 8 | {% if field.label and form_show_labels %} 9 | 12 | {% endif %} 13 | 14 |
15 |
16 | 17 | {% if crispy_prepended_text and not crispy_appended_text %} 18 | {{ crispy_prepended_text|safe }} 19 | {% if field.errors %} 20 | {% tailwind_field field "class" "border border-red-500 rounded-lg rounded-l-none px-4 py-2 w-full focus:outline-none text-gray-700 border-l-0 leading-normal" %} 21 | {% else %} 22 | {% tailwind_field field "class" "border border-gray-300 rounded-lg rounded-l-none px-4 py-2 w-full focus:outline-none text-gray-700 border-l-0 leading-normal" %} 23 | {% endif %} 24 | 25 | {% elif crispy_appended_text and not crispy_prepended_text %} 26 | {% if field.errors %} 27 | {% tailwind_field field "class" "border border-red-500 rounded-lg rounded-r-none px-4 py-2 w-full focus:outline-none text-gray-700 border-r-0 leading-normal" %} 28 | {% else %} 29 | {% tailwind_field field "class" "border border-gray-300 rounded-lg rounded-r-none px-4 py-2 w-full focus:outline-none text-gray-700 border-r-0 leading-normal" %} 30 | {% endif %} 31 | {{ crispy_appended_text|safe }} 32 | 33 | {% else %} 34 | {{ crispy_prepended_text|safe }} 35 | {% if field.errors %} 36 | {% tailwind_field field "class" "border border-r-0 border-l-0 border-red-500 px-4 py-2 w-full focus:outline-none text-gray-700 border-r-0 leading-normal" %} 37 | {% else %} 38 | {% tailwind_field field "class" "border border-gray-300 px-4 py-2 w-full focus:outline-none text-gray-700 border-r-0 leading-normal" %} 39 | {% endif %} 40 | {{ crispy_appended_text|safe }} 41 | 42 | {% endif %} 43 |
44 |
45 | 46 | {% include 'tailwind/layout/field_errors.html' %} 47 | {% include 'tailwind/layout/help_text.html' %} 48 |
49 | {% endif %} 50 | -------------------------------------------------------------------------------- /crispy_tailwind/templates/tailwind/layout/radioselect.html: -------------------------------------------------------------------------------- 1 | {% load crispy_forms_filters %} 2 | {% load l10n %} 3 | 4 | {% for choice in field.field.choices %} 5 | 9 | {% if field.errors and forloop.last %} 10 | {# include 'tailwind/layout/field_errors_block.html' <-- bs4 template adds this here. Currently this pack adds it in field.html #} 11 | {% endif %} 12 | {% endfor %} 13 | -------------------------------------------------------------------------------- /crispy_tailwind/templates/tailwind/layout/radioselect_inline.html: -------------------------------------------------------------------------------- 1 | {% if field.is_hidden %} 2 | {{ field }} 3 | {% else %} 4 | <{% if tag %}{{ tag }}{% else %}div{% endif %} id="div_{{ field.auto_id }}" class="{% if wrapper_class %}{{ wrapper_class }} {% endif %}{% if field_class %}{{ field_class }}{% else %}mb-3{% endif %}"> 5 | 6 | {% if field.label %} 7 | 10 | {% endif %} 11 | 12 |
13 | {% include 'tailwind/layout/radioselect.html' %} 14 |
15 | {% endif %} 16 | -------------------------------------------------------------------------------- /crispy_tailwind/templates/tailwind/layout/row.html: -------------------------------------------------------------------------------- 1 |
2 | {{ fields|safe }} 3 |
4 | -------------------------------------------------------------------------------- /crispy_tailwind/templates/tailwind/layout/select.html: -------------------------------------------------------------------------------- 1 | {% load crispy_forms_filters %} 2 | {% load tailwind_filters %} 3 | {% load l10n %} 4 | 5 |
6 | 15 | {% if not field.field.widget.allow_multiple_selected %} 16 |
17 | 18 |
19 | {% endif %} 20 |
21 | -------------------------------------------------------------------------------- /crispy_tailwind/templates/tailwind/layout/select_option.html: -------------------------------------------------------------------------------- 1 | {% load crispy_forms_filters %} 2 | {% load l10n %} 3 | 4 | 5 | -------------------------------------------------------------------------------- /crispy_tailwind/templates/tailwind/table_inline_formset.html: -------------------------------------------------------------------------------- 1 | {% load crispy_forms_tags %} 2 | {% load crispy_forms_utils %} 3 | {% load tailwind_field %} 4 | 5 | {% specialspaceless %} 6 | {% if formset_tag %} 7 |
8 | {% endif %} 9 | {% if formset_method|lower == 'post' and not disable_csrf %} 10 | {% csrf_token %} 11 | {% endif %} 12 | 13 |
14 | {{ formset.management_form|crispy }} 15 |
16 | 17 | {% include "tailwind/errors_formset.html" %} 18 | 19 | 20 | 21 | {% if formset.readonly and not formset.queryset.exists %} 22 | {% else %} 23 | 24 | {% for field in formset.forms.0 %} 25 | {% if field.label and not field.is_hidden %} 26 | 27 | {{ field.label|safe }}{% if field.field.required and not field|is_checkbox %}*{% endif %} 28 | 29 | {% endif %} 30 | {% endfor %} 31 | 32 | {% endif %} 33 | 34 | 35 | 36 | 37 | {% for form in formset %} 38 | {% if form_show_errors and not form.is_extra %} 39 | {% include "tailwind/errors.html" %} 40 | {% endif %} 41 | 42 | 43 | {% for field in form %} 44 | {% include 'tailwind/field.html' with tag="td" form_show_labels=False field_class="border px-4 py-2" %} 45 | {% endfor %} 46 | 47 | {% endfor %} 48 | 49 | 50 | 51 |
52 | {% include "tailwind/inputs.html" %} 53 |
54 | {% if formset_tag %}{% endif %} 55 | {% endspecialspaceless %} 56 | -------------------------------------------------------------------------------- /crispy_tailwind/templates/tailwind/uni_form.html: -------------------------------------------------------------------------------- 1 | {% load crispy_forms_utils %} 2 | 3 | {% specialspaceless %} 4 | {% if include_media %}{{ form.media }}{% endif %} 5 | {% if form_show_errors %} 6 | {% include "tailwind/errors.html" %} 7 | {% endif %} 8 | {% for field in form %} 9 | {% include field_template %} 10 | {% endfor %} 11 | {% endspecialspaceless %} 12 | -------------------------------------------------------------------------------- /crispy_tailwind/templates/tailwind/uni_formset.html: -------------------------------------------------------------------------------- 1 | {% with formset.management_form as form %} 2 | {% include 'tailwind/uni_form.html' %} 3 | {% endwith %} 4 | {% for form in formset %} 5 |
6 | {% include 'tailwind/uni_form.html' %} 7 |
8 | {% endfor %} 9 | -------------------------------------------------------------------------------- /crispy_tailwind/templates/tailwind/whole_uni_form.html: -------------------------------------------------------------------------------- 1 | {% load crispy_forms_utils %} 2 | 3 | {% specialspaceless %} 4 | {% if form_tag %}
{% endif %} 5 | {% if form_method|lower == 'post' and not disable_csrf %} 6 | {% csrf_token %} 7 | {% endif %} 8 | 9 | {% include "tailwind/display_form.html" %} 10 | 11 | {% include "tailwind/inputs.html" %} 12 | 13 | {% if form_tag %}
{% endif %} 14 | {% endspecialspaceless %} 15 | -------------------------------------------------------------------------------- /crispy_tailwind/templates/tailwind/whole_uni_formset.html: -------------------------------------------------------------------------------- 1 | {% load crispy_forms_tags %} 2 | {% load crispy_forms_utils %} 3 | 4 | {% specialspaceless %} 5 | {% if formset_tag %} 6 |
7 | {% endif %} 8 | {% if formset_method|lower == 'post' and not disable_csrf %} 9 | {% csrf_token %} 10 | {% endif %} 11 | 12 |
13 | {{ formset.management_form|crispy }} 14 |
15 | 16 | {% include "tailwind/errors_formset.html" %} 17 | 18 | {% for form in formset %} 19 | {% include "tailwind/display_form.html" %} 20 | {% endfor %} 21 | 22 | {% if inputs %} 23 |
24 | {% for input in inputs %} 25 | {% include "tailwind/layout/baseinput.html" %} 26 | {% endfor %} 27 |
28 | {% endif %} 29 | {% if formset_tag %}
{% endif %} 30 | {% endspecialspaceless %} 31 | -------------------------------------------------------------------------------- /crispy_tailwind/templatetags/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/django-crispy-forms/crispy-tailwind/47075468f09c30272e5f9d59abd7154e5682c3d1/crispy_tailwind/templatetags/__init__.py -------------------------------------------------------------------------------- /crispy_tailwind/templatetags/tailwind_field.py: -------------------------------------------------------------------------------- 1 | # This file is currently copied directly from django-crispy-forms 2 | # Changes to core form are highlighted. These are to add additional input classes 3 | # to meet requirements of Tailwind 4 | 5 | import re 6 | 7 | from django import forms, template 8 | from django.conf import settings 9 | from django.template import Context, loader 10 | 11 | from crispy_forms.utils import TEMPLATE_PACK, get_template_pack 12 | from crispy_tailwind.tailwind import CSSContainer 13 | 14 | register = template.Library() 15 | 16 | 17 | @register.filter 18 | def is_checkbox(field): 19 | return isinstance(field.field.widget, forms.CheckboxInput) 20 | 21 | 22 | @register.filter 23 | def is_password(field): 24 | return isinstance(field.field.widget, forms.PasswordInput) 25 | 26 | 27 | @register.filter 28 | def is_radioselect(field): 29 | return isinstance(field.field.widget, forms.RadioSelect) 30 | 31 | 32 | @register.filter 33 | def is_select(field): 34 | return isinstance(field.field.widget, forms.Select) 35 | 36 | 37 | @register.filter 38 | def is_checkboxselectmultiple(field): 39 | return isinstance(field.field.widget, forms.CheckboxSelectMultiple) 40 | 41 | 42 | @register.filter 43 | def is_file(field): 44 | return isinstance(field.field.widget, forms.FileInput) 45 | 46 | 47 | @register.filter 48 | def is_clearable_file(field): 49 | return isinstance(field.field.widget, forms.ClearableFileInput) 50 | 51 | 52 | @register.filter 53 | def is_multivalue(field): 54 | return isinstance(field.field.widget, forms.MultiWidget) 55 | 56 | 57 | @register.filter 58 | def classes(field): 59 | """ 60 | Returns CSS classes of a field 61 | """ 62 | return field.widget.attrs.get("class", None) 63 | 64 | 65 | @register.filter 66 | def css_class(field): 67 | """ 68 | Returns widgets class name in lowercase 69 | """ 70 | return field.field.widget.__class__.__name__.lower() 71 | 72 | 73 | def pairwise(iterable): 74 | """s -> (s0,s1), (s2,s3), (s4, s5), ...""" 75 | a = iter(iterable) 76 | return zip(a, a) 77 | 78 | 79 | class CrispyTailwindFieldNode(template.Node): 80 | base_input = ( 81 | "bg-white focus:outline-none border border-gray-300 rounded-lg py-2 px-4 block w-full " 82 | "appearance-none leading-normal text-gray-700" 83 | ) 84 | 85 | default_styles = { 86 | "text": base_input, 87 | "number": base_input, 88 | "radioselect": "", 89 | "email": base_input, 90 | "url": base_input, 91 | "password": base_input, 92 | "hidden": "", 93 | "multiplehidden": "", 94 | "file": "", 95 | "clearablefile": "", 96 | "textarea": base_input, 97 | "date": base_input, 98 | "datetime": base_input, 99 | "time": base_input, 100 | "checkbox": "", 101 | "select": "", 102 | "nullbooleanselect": "", 103 | "selectmultiple": "", 104 | "checkboxselectmultiple": "", 105 | "multi": "", 106 | "splitdatetime": "text-gray-700 bg-white focus:outline border border-gray-300 leading-normal px-4 " 107 | "appearance-none rounded-lg py-2 focus:outline-none mr-2", 108 | "splithiddendatetime": "", 109 | "selectdate": "", 110 | "error_border": "border-red-500", 111 | } 112 | 113 | default_container = CSSContainer(default_styles) 114 | 115 | def __init__(self, field, attrs): 116 | self.field = field 117 | self.attrs = attrs 118 | self.html5_required = "html5_required" 119 | 120 | def render(self, context): # noqa: C901 121 | # Nodes are not threadsafe so we must store and look up our instance 122 | # variables in the current rendering context first 123 | if self not in context.render_context: 124 | context.render_context[self] = ( 125 | template.Variable(self.field), 126 | self.attrs, 127 | template.Variable(self.html5_required), 128 | ) 129 | 130 | field, attrs, html5_required = context.render_context[self] 131 | field = field.resolve(context) 132 | try: 133 | html5_required = html5_required.resolve(context) 134 | except template.VariableDoesNotExist: 135 | html5_required = False 136 | 137 | # If template pack has been overridden in FormHelper we can pick it from context 138 | template_pack = context.get("template_pack", TEMPLATE_PACK) 139 | 140 | # There are special django widgets that wrap actual widgets, 141 | # such as forms.widgets.MultiWidget, admin.widgets.RelatedFieldWidgetWrapper 142 | widgets = getattr(field.field.widget, "widgets", [getattr(field.field.widget, "widget", field.field.widget)]) 143 | 144 | if isinstance(attrs, dict): 145 | attrs = [attrs] * len(widgets) 146 | 147 | converters = {} 148 | converters.update(getattr(settings, "CRISPY_CLASS_CONVERTERS", {})) 149 | 150 | for widget, attr in zip(widgets, attrs): 151 | class_name = widget.__class__.__name__.lower() 152 | class_name = converters.get(class_name, class_name) 153 | css_class = widget.attrs.get("class", "") 154 | if css_class: 155 | if css_class.find(class_name) == -1: 156 | css_class += " %s" % class_name 157 | else: 158 | css_class = class_name 159 | 160 | # Added additional code for Tailwind if class has not been passed in via the tag in the template 161 | if template_pack == "tailwind" and '"class"' not in attr.keys(): 162 | css_container = context.get("css_container", self.default_container) 163 | if css_container: 164 | css = " " + css_container.get_input_class(field) 165 | css_class += css 166 | if field.errors: 167 | error_border_class = css_container.error_border 168 | css_class = re.sub(r"border-\S+", error_border_class, css_class) 169 | 170 | widget.attrs["class"] = css_class 171 | 172 | # HTML5 required attribute 173 | if html5_required and field.field.required and "required" not in widget.attrs: 174 | if field.field.widget.__class__.__name__ != "RadioSelect": 175 | widget.attrs["required"] = "required" 176 | 177 | # classes passed in via the template are added here 178 | for attribute_name, attribute in attr.items(): 179 | attribute_name = template.Variable(attribute_name).resolve(context) 180 | 181 | if attribute_name in widget.attrs: 182 | widget.attrs[attribute_name] += " " + template.Variable(attribute).resolve(context) 183 | else: 184 | widget.attrs[attribute_name] = template.Variable(attribute).resolve(context) 185 | 186 | return str(field) 187 | 188 | 189 | @register.tag(name="tailwind_field") 190 | def tailwind_field(parser, token): 191 | """ 192 | {% tailwind_field field attrs %} 193 | """ 194 | token = token.split_contents() 195 | field = token.pop(1) 196 | attrs = {} 197 | 198 | # We need to pop tag name, or pairwise would fail 199 | token.pop(0) 200 | for attribute_name, value in pairwise(token): 201 | attrs[attribute_name] = value 202 | 203 | return CrispyTailwindFieldNode(field, attrs) 204 | 205 | 206 | @register.simple_tag() 207 | def crispy_addon(field, append="", prepend="", form_show_labels=True): 208 | """ 209 | Renders a form field using bootstrap's prepended or appended text:: 210 | 211 | {% crispy_addon form.my_field prepend="$" append=".00" %} 212 | 213 | You can also just prepend or append like so 214 | 215 | {% crispy_addon form.my_field prepend="$" %} 216 | {% crispy_addon form.my_field append=".00" %} 217 | """ 218 | if field: 219 | context = Context({"field": field, "form_show_errors": True, "form_show_labels": form_show_labels}) 220 | template = loader.get_template("%s/layout/prepended_appended_text.html" % get_template_pack()) 221 | context["crispy_prepended_text"] = prepend 222 | context["crispy_appended_text"] = append 223 | 224 | if not prepend and not append: 225 | raise TypeError("Expected a prepend and/or append argument") 226 | 227 | context = context.flatten() 228 | 229 | return template.render(context) 230 | -------------------------------------------------------------------------------- /crispy_tailwind/templatetags/tailwind_filters.py: -------------------------------------------------------------------------------- 1 | from functools import lru_cache 2 | 3 | from django import template 4 | from django.conf import settings 5 | from django.forms import boundfield 6 | from django.forms.formsets import BaseFormSet 7 | from django.template import Context 8 | from django.template.loader import get_template 9 | from django.utils.safestring import mark_safe 10 | 11 | from crispy_forms.exceptions import CrispyError 12 | from crispy_forms.utils import TEMPLATE_PACK, flatatt 13 | 14 | 15 | @lru_cache() 16 | def uni_formset_template(template_pack=TEMPLATE_PACK): 17 | return get_template("%s/uni_formset.html" % template_pack) 18 | 19 | 20 | @lru_cache() 21 | def uni_form_template(template_pack=TEMPLATE_PACK): 22 | return get_template("%s/uni_form.html" % template_pack) 23 | 24 | 25 | register = template.Library() 26 | 27 | 28 | @register.filter(name="crispy") 29 | def as_crispy_form( 30 | form, template_pack=TEMPLATE_PACK, label_class="block text-gray-700 text-sm font-bold mb-2", field_class="mb-3" 31 | ): 32 | """ 33 | The original and still very useful way to generate a div elegant form/formset:: 34 | 35 | {% load tailwind_filters %} 36 | 37 |
38 | {% csrf_token %} 39 | {{ myform|crispy }} 40 |
41 | 42 | or, if you want to explicitly set the template pack:: 43 | 44 | {{ myform|crispy:"bootstrap" }} 45 | 46 | In ``bootstrap3`` or ``bootstrap4`` for horizontal forms you can do:: 47 | 48 | {{ myform|label_class:"col-lg-2",field_class:"col-lg-8" }} 49 | """ 50 | c = Context( 51 | { 52 | "field_class": field_class, 53 | "field_template": "%s/field.html" % template_pack, 54 | "form_show_errors": True, 55 | "form_show_labels": True, 56 | "label_class": label_class, 57 | } 58 | ).flatten() 59 | if isinstance(form, BaseFormSet): 60 | template = uni_formset_template(template_pack) 61 | c["formset"] = form 62 | else: 63 | template = uni_form_template(template_pack) 64 | c["form"] = form 65 | 66 | return template.render(c) 67 | 68 | 69 | @register.filter(name="as_crispy_errors") 70 | def as_crispy_errors(form, template_pack=TEMPLATE_PACK): 71 | """ 72 | Renders only form errors the same way as django-crispy-forms:: 73 | 74 | {% load crispy_forms_tags %} 75 | {{ form|as_crispy_errors }} 76 | 77 | or:: 78 | 79 | {{ form|as_crispy_errors:"bootstrap" }} 80 | """ 81 | if isinstance(form, BaseFormSet): 82 | template = get_template("%s/errors_formset.html" % template_pack) 83 | c = Context({"formset": form}).flatten() 84 | else: 85 | template = get_template("%s/errors.html" % template_pack) 86 | c = Context({"form": form}).flatten() 87 | 88 | return template.render(c) 89 | 90 | 91 | @register.filter(name="as_crispy_field") 92 | def as_crispy_field(field, template_pack=TEMPLATE_PACK, label_class="", field_class=""): 93 | """ 94 | Renders a form field like a django-crispy-forms field:: 95 | 96 | {% load crispy_forms_tags %} 97 | {{ form.field|as_crispy_field }} 98 | 99 | or:: 100 | 101 | {{ form.field|as_crispy_field:"tailwind" }} 102 | """ 103 | if not isinstance(field, boundfield.BoundField) and settings.DEBUG: 104 | raise CrispyError("|as_crispy_field got passed an invalid or inexistent field") 105 | 106 | attributes = { 107 | "field": field, 108 | "form_show_errors": True, 109 | "form_show_labels": True, 110 | "label_class": label_class, 111 | "field_class": field_class, 112 | } 113 | helper = getattr(field.form, "helper", None) 114 | 115 | template_path = None 116 | if helper is not None: 117 | attributes.update(helper.get_attributes(template_pack)) 118 | template_path = helper.field_template 119 | if not template_path: 120 | template_path = "%s/field.html" % template_pack 121 | template = get_template(template_path) 122 | 123 | c = Context(attributes).flatten() 124 | return template.render(c) 125 | 126 | 127 | @register.filter(name="flatatt") 128 | def flatatt_filter(attrs): 129 | return mark_safe(flatatt(attrs)) 130 | 131 | 132 | @register.filter 133 | def build_attrs(field): 134 | """ 135 | Build HTML attributes for a form field, also checking for a 136 | ``widget.allow_multiple_selected`` attribute and adding ``multiple`` to the 137 | attributes if it is set to ``True``. 138 | """ 139 | attrs = field.field.widget.attrs 140 | attrs.setdefault("id", field.auto_id) 141 | 142 | field_built_widget_attrs = field.build_widget_attrs(attrs) 143 | attrs.update(field_built_widget_attrs) 144 | 145 | # Some custom widgets (e.g. Select2) may add additional attributes to the 146 | # widget attrs dict. We need to add those to the attrs dict as well calling 147 | # the widget's build_attrs method. 148 | 149 | built_widget_attrs = field.field.widget.build_attrs(attrs) 150 | attrs.update(built_widget_attrs) 151 | 152 | if hasattr(field.field.widget, "allow_multiple_selected"): 153 | attrs["multiple"] = attrs.get("multiple", field.field.widget.allow_multiple_selected) 154 | return mark_safe(flatatt(attrs)) 155 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Minimal makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line, and also 5 | # from the environment for the first two. 6 | SPHINXOPTS ?= 7 | SPHINXBUILD ?= sphinx-build 8 | SOURCEDIR = . 9 | BUILDDIR = _build 10 | 11 | # Put it first so that "make" without argument is like "make help". 12 | help: 13 | @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 14 | 15 | .PHONY: help Makefile 16 | 17 | # Catch-all target: route all unknown targets to Sphinx using the new 18 | # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). 19 | %: Makefile 20 | @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 21 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | # Configuration file for the Sphinx documentation builder. 2 | # 3 | # This file only contains a selection of the most common options. For a full 4 | # list see the documentation: 5 | # https://www.sphinx-doc.org/en/master/usage/configuration.html 6 | 7 | # -- Path setup -------------------------------------------------------------- 8 | 9 | # If extensions (or modules to document with autodoc) are in another directory, 10 | # add these directories to sys.path here. If the directory is relative to the 11 | # documentation root, use os.path.abspath to make it absolute, like shown here. 12 | # 13 | # import os 14 | # import sys 15 | # sys.path.insert(0, os.path.abspath('.')) 16 | 17 | 18 | # -- Project information ----------------------------------------------------- 19 | 20 | project = "crispy-tailwind" 21 | copyright = "2020, David Smith" 22 | author = "David Smith" 23 | 24 | # The full version, including alpha/beta/rc tags 25 | release = "0.5" 26 | 27 | 28 | # -- General configuration --------------------------------------------------- 29 | 30 | # Add any Sphinx extension module names here, as strings. They can be 31 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom 32 | # ones. 33 | extensions = [] 34 | 35 | # Add any paths that contain templates here, relative to this directory. 36 | templates_path = ["_templates"] 37 | 38 | # List of patterns, relative to source directory, that match files and 39 | # directories to ignore when looking for source files. 40 | # This pattern also affects html_static_path and html_extra_path. 41 | exclude_patterns = ["_build", "Thumbs.db", ".DS_Store", "requirements.txt"] 42 | 43 | 44 | # -- Options for HTML output ------------------------------------------------- 45 | 46 | # The theme to use for HTML and HTML Help pages. See the documentation for 47 | # a list of builtin themes. 48 | # 49 | html_theme = "sphinx_rtd_theme" 50 | 51 | # Add any paths that contain custom static files (such as style sheets) here, 52 | # relative to this directory. They are copied after the builtin static files, 53 | # so a file named "default.css" will overwrite the builtin "default.css". 54 | # html_static_path = ["_static"] 55 | 56 | # The suffix of source filenames. 57 | source_suffix = ".txt" 58 | -------------------------------------------------------------------------------- /docs/contributing.txt: -------------------------------------------------------------------------------- 1 | ============ 2 | Contributing 3 | ============ 4 | 5 | Contributions to ``crispy-tailwind`` are very welcome! 6 | 7 | Here are some notes on how to get setup with a development environment. 8 | 9 | 1. Clone the ``crispy-tailwind`` repo from GitHub. 10 | 2. Create a new virtual environment and install the development requirements 11 | from ``requirements.txt``. 12 | 3. Install ``crispy-tailwind`` as well. :: 13 | 14 | pip install -e . 15 | 16 | 17 | 4. Make sure you can run the test suite locally. :: 18 | 19 | pytest --ds=tests.test_settings 20 | 21 | 5. After making documentation changes, build the HTML and review the rendered 22 | output via http://localhost:8000 in your browser. :: 23 | 24 | cd docs && make html 25 | cd _build/html && python -m http.server 26 | 27 | 6. You may find the ``crispy-test-project`` helpful to see the rendered output 28 | of the package. 29 | 30 | - Clone the ``tailwind`` branch of that project. 31 | - Install the dependencies. 32 | - Install ``crispy-tailwind``. :: 33 | 34 | pip install -e path/to/crispy-tailwind 35 | -------------------------------------------------------------------------------- /docs/custom.txt: -------------------------------------------------------------------------------- 1 | ============= 2 | Custom Styles 3 | ============= 4 | 5 | With Tailwind being a utility framework there is much more ability to customise 6 | your site than with many of the other CSS frameworks such as Bootstrap. 7 | 8 | While our opinionated Tailwind styles may get you so far you may wish to change these. 9 | 10 | The Tailwind template pack aims to help you customise your form in a DRY way. This 11 | template pack comes with a utility class called `CSSContainer` which can be attached to your 12 | form helper. 13 | 14 | ------------ 15 | CSSContainer 16 | ------------ 17 | 18 | `CSSContainer` is imported from `crispy_tailwind.tailwind`. This is a class which holds the 19 | CSS style for your form. Currently it holds the classes for the `` tags within your form. 20 | The class expects a dict of Django widgets and class styles:: 21 | 22 | >>> from crispy_tailwind.tailwind import CSSContainer 23 | 24 | >>> css = CSSContainer({ 25 | "text": "border border-gray-300", 26 | "number": "border border-gray-300", 27 | ... 28 | }) 29 | 30 | As there are many Django widgets, there is the option to pass in classes to a "base" key 31 | which will then be applied to all of the widgets:: 32 | 33 | >>> css = CSSContainer({ 34 | "base": "bg-white" 35 | }) 36 | >>> css 37 | { 38 | 'text': 'bg-white', 39 | 'number': 'bg-white', 40 | 'email': 'bg-white', 41 | ... 42 | } 43 | 44 | You can also update add and remove styles:: 45 | 46 | >>> css += { 47 | "text": "text more styles", 48 | "number": "number style" 49 | } 50 | >>> css 51 | { 52 | 'text': 'bg-white text more styles', 53 | 'number': 'bg-white number style', 54 | 'email': 'bg-white', 55 | ... 56 | } 57 | >>> css -= { 58 | "text": "bg-white", 59 | "number": "bg-white" 60 | } 61 | >>> css 62 | { 63 | 'text': 'text more styles', 64 | 'number': 'number style', 65 | 'email': 'bg-white', 66 | ... 67 | } 68 | -------------------------------------------------------------------------------- /docs/examples.txt: -------------------------------------------------------------------------------- 1 | ============== 2 | Example Images 3 | ============== 4 | 5 | Here are some example images of how forms are rendered using the Tailwind 6 | template Pack 7 | 8 | Formsets 9 | ======== 10 | 11 | A formset with ``non_form_errors`` and a field error. See the 12 | "test_formset_with_errors" test for configuration of the ``FormHelper`` and 13 | ``Layout`` the rendered this form. 14 | 15 | .. image:: img/formset_failing.png 16 | :width: 50% 17 | -------------------------------------------------------------------------------- /docs/getting_started.txt: -------------------------------------------------------------------------------- 1 | =============== 2 | Getting Started 3 | =============== 4 | 5 | Installation 6 | ============ 7 | 8 | Install via pip. :: 9 | 10 | pip install crispy-tailwind 11 | 12 | You will need to update your project's settings file to add ``crispy_forms`` 13 | and ``crispy_tailwind`` to your project's ``INSTALLED_APPS`` setting. Also set 14 | ``tailwind`` as an allowed template pack and as the default template pack for 15 | your project:: 16 | 17 | INSTALLED_APPS = ( 18 | ... 19 | "crispy_forms", 20 | "crispy_tailwind", 21 | ... 22 | ) 23 | 24 | CRISPY_ALLOWED_TEMPLATE_PACKS = "tailwind" 25 | 26 | CRISPY_TEMPLATE_PACK = "tailwind" 27 | 28 | If you use `Tailwind CLI `_, you have to provide the path to your 29 | templates. Since this package contains templates which are within your Pythons `site-packages`, it's hard to tell 30 | Tailwind where these templates live. 31 | 32 | Thanks to Carlton Gibson's blog, you can implement (aka copy & paste) a Django management command and the corresponding 33 | Tailwind configuration: 34 | 35 | https://noumenal.es/notes/tailwind/django-integration/ 36 | 37 | Usage 38 | ===== 39 | 40 | There are a number of ways you can use ``crispy-tailwind``. The method 41 | requiring the least amount of effort involves using the ``|crispy`` filter. 42 | This will add an opinionated `Tailwind` style to your form. If you require more 43 | control, you can use the ``crispy`` tags and utilise the full power of the 44 | ``crispy-forms`` through its ``FormHelper`` and ``Layout`` capabilities. 45 | 46 | ``|crispy`` filter 47 | ------------------ 48 | 49 | The ``|crispy`` filter is the method requiring the least amount of effort to 50 | add `Tailwind` style to your form. It avoids the complexity of ``FormHelper``, 51 | and it comes with an opinionated form style. 52 | 53 | 1. Load the ``tailwind_filters`` in your template:: 54 | 55 | {% load tailwind_filters %} 56 | 57 | 2. Apply the ``|crispy`` filter to your form:: 58 | 59 | {{ form|crispy }} 60 | 61 | Here are two examples. The first shows the default rendering. The second shows 62 | rendering of server-side errors: 63 | 64 | .. image:: img/crispy_form.png 65 | :width: 49% 66 | .. image:: img/crispy_failing.png 67 | :width: 49% 68 | 69 | ``{% crispy %}`` tags 70 | --------------------- 71 | 72 | Using the ``{% crispy %}`` tag allows you to use the core crispy-form_ 73 | functionality. 74 | 75 | 1. Load the crispy tag: ``{% load crispy_forms_tags %}`` 76 | 2. Add ``FormHelper`` to your form and use crispy-forms to configure your form. 77 | 3. Use the crispy tag ``{% crispy form %}`` in your template. 78 | 79 | This will render a form with the same opinionated style as the ``|crispy`` 80 | filter. 81 | 82 | The classes are largely held in a ``CSSContainer`` class. This can be changed 83 | to meet your needs. Docs and functionality for this area continue to be 84 | developed. 85 | 86 | .. _crispy-form: https://django-crispy-forms.readthedocs.io/en/latest/index.html 87 | 88 | Crispy-addon 89 | ============ 90 | 91 | There is a template tag called ``crispy-addon``. It can be used to render a 92 | single field (including label) with prepended and appended text:: 93 | 94 | {% load tailwind_field %} 95 | {% crispy_addon form.my_field prepend="$" append=".00" %} 96 | 97 | You can also prepend or append like so:: 98 | 99 | {% crispy_addon form.my_field prepend="$" %} 100 | {% crispy_addon form.my_field append=".00" %} 101 | -------------------------------------------------------------------------------- /docs/img/Buttons.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/django-crispy-forms/crispy-tailwind/47075468f09c30272e5f9d59abd7154e5682c3d1/docs/img/Buttons.png -------------------------------------------------------------------------------- /docs/img/alert.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/django-crispy-forms/crispy-tailwind/47075468f09c30272e5f9d59abd7154e5682c3d1/docs/img/alert.png -------------------------------------------------------------------------------- /docs/img/crispy_failing.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/django-crispy-forms/crispy-tailwind/47075468f09c30272e5f9d59abd7154e5682c3d1/docs/img/crispy_failing.png -------------------------------------------------------------------------------- /docs/img/crispy_form.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/django-crispy-forms/crispy-tailwind/47075468f09c30272e5f9d59abd7154e5682c3d1/docs/img/crispy_form.png -------------------------------------------------------------------------------- /docs/img/custom_button.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/django-crispy-forms/crispy-tailwind/47075468f09c30272e5f9d59abd7154e5682c3d1/docs/img/custom_button.png -------------------------------------------------------------------------------- /docs/img/field_with_buttons.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/django-crispy-forms/crispy-tailwind/47075468f09c30272e5f9d59abd7154e5682c3d1/docs/img/field_with_buttons.png -------------------------------------------------------------------------------- /docs/img/fieldset.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/django-crispy-forms/crispy-tailwind/47075468f09c30272e5f9d59abd7154e5682c3d1/docs/img/fieldset.png -------------------------------------------------------------------------------- /docs/img/formset_failing.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/django-crispy-forms/crispy-tailwind/47075468f09c30272e5f9d59abd7154e5682c3d1/docs/img/formset_failing.png -------------------------------------------------------------------------------- /docs/img/inline_field.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/django-crispy-forms/crispy-tailwind/47075468f09c30272e5f9d59abd7154e5682c3d1/docs/img/inline_field.png -------------------------------------------------------------------------------- /docs/img/prepended.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/django-crispy-forms/crispy-tailwind/47075468f09c30272e5f9d59abd7154e5682c3d1/docs/img/prepended.png -------------------------------------------------------------------------------- /docs/img/row_col.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/django-crispy-forms/crispy-tailwind/47075468f09c30272e5f9d59abd7154e5682c3d1/docs/img/row_col.png -------------------------------------------------------------------------------- /docs/index.txt: -------------------------------------------------------------------------------- 1 | .. crispy-tailwind documentation master file, created by 2 | sphinx-quickstart on Sat May 2 20:32:30 2020. 3 | You can adapt this file completely to your liking, but it should at least 4 | contain the root `toctree` directive. 5 | 6 | Welcome to crispy-tailwind's documentation! 7 | =========================================== 8 | 9 | This is a Tailwind template pack for django-crispy-forms_. 10 | 11 | .. _django-crispy-forms: https://django-crispy-forms.readthedocs.io/en/latest/index.html 12 | 13 | 14 | .. toctree:: 15 | :maxdepth: 2 16 | :caption: Contents: 17 | 18 | getting_started 19 | layout_objects 20 | custom 21 | examples 22 | contributing 23 | 24 | 25 | Indices and tables 26 | ================== 27 | 28 | * :ref:`genindex` 29 | * :ref:`modindex` 30 | * :ref:`search` 31 | -------------------------------------------------------------------------------- /docs/layout_objects.txt: -------------------------------------------------------------------------------- 1 | ============== 2 | Layout Objects 3 | ============== 4 | 5 | This template pack renders Django widgets with the Tailwind CSS framework. This 6 | page shows examples of how these widgets look when rendered with the default 7 | styling. For these objects we have given an opinion on how they should look. 8 | 9 | If you are using the ``|crispy`` filter the form will be styled using the 10 | default layout. If you wish to customise the form you can use ``{% crispy %}`` 11 | tags and ``FormHelper`` to control the layout of your form. 12 | 13 | Wherever possible layout objects are used from ``crispy-forms`` itself (either 14 | core or bootstrap). Where these can not be used Tailwind versions are included 15 | within this template pack. This means that the layout objects may be found 16 | within one of three files. 17 | 18 | - ``crispy_forms.layout`` (``Layout`` and other standard objects) 19 | - ``crispy_forms.bootstrap`` (Some bootstrap objects work out of the box, so we 20 | will use these) 21 | - ``crispy_tailwind.layout`` (Customised versions for Tailwind are found here) 22 | 23 | Knowledge for crispy-forms ``Layout`` class is assumed. Please see 24 | https://django-crispy-forms.readthedocs.io/en/latest/layouts.html for further 25 | information. 26 | 27 | crispy_forms.layout 28 | =================== 29 | 30 | Layout 31 | ------ 32 | 33 | The core Layout_ class which provides the ability to control the rendering of your form. This is the parent which will 34 | wrap individual layout objects. 35 | 36 | .. _Layout: https://django-crispy-forms.readthedocs.io/en/latest/layouts.html?#fundamentals 37 | 38 | Column 39 | ------ 40 | 41 | The Column_ layout object wraps fields in a ``div`` so it can be used as a 42 | column. The Tailwind template adds no css class to this wrapper by default. If 43 | required classes can be added:: 44 | 45 | Column('form_field_1', 'form_field_2', css_class='additional classes',) 46 | 47 | .. _column: https://django-crispy-forms.readthedocs.io/en/latest/api_layout.html?#layout.Column 48 | 49 | Row 50 | --- 51 | 52 | The Row_ layout object wraps fields in a ``div`` so it can be used as a 53 | row. The Tailwind template adds ``flex flex-row`` to this wrapper by default. 54 | If required this default can be replaced by custom css classes:: 55 | 56 | Row('form_field_1', 'form_field_2', css_class='additional classes',) 57 | 58 | Row and Column layouts are typically used together. Here is an example layout 59 | with an image showing how the form is rendered:: 60 | 61 | Row( 62 | Column(Field("first_name"), Field("last_name"), css_class="px-2"), 63 | Column("email", css_class="px-2"), 64 | ) 65 | 66 | .. image:: img/row_col.png 67 | :width: 50% 68 | 69 | .. _Row : https://django-crispy-forms.readthedocs.io/en/latest/api_layout.html?#layout.Row 70 | 71 | Field 72 | ----- 73 | 74 | The Field_ layout object allows attributes to be easily added to a single 75 | field. 76 | 77 | .. _Field: https://django-crispy-forms.readthedocs.io/en/latest/api_layout.html?#layout.Field 78 | 79 | Div 80 | --- 81 | 82 | The Div_ layout object wraps fields in a `Div`. No classes are added by default. 83 | 84 | .. _Div: https://django-crispy-forms.readthedocs.io/en/latest/api_layout.html?#layout.Div 85 | 86 | HTML 87 | ---- 88 | 89 | Used to render pure HTML code and behaves like a Django template. See the django-crispy-forms HTML_ docs for more 90 | information. 91 | 92 | The HTML_: https://django-crispy-forms.readthedocs.io/en/latest/layouts.html?#universal-layout-objects 93 | 94 | .. _HTML: 95 | 96 | Hidden 97 | ------ 98 | 99 | Used to create a hidden_ input. 100 | 101 | .. _Hidden: https://django-crispy-forms.readthedocs.io/en/latest/api_layout.html?#layout.Hidden 102 | 103 | Fieldset 104 | -------- 105 | 106 | Wraps fields in a ``
``. The first parameter is the legend for the 107 | fieldset. By default the class for the legend is 108 | ``block text-gray-700 font-bold mb-2``. Below is an example layout and the 109 | default rendering:: 110 | 111 | Fieldset("Text for the legend", "is_company", "email",) 112 | 113 | .. image:: img/fieldset.png 114 | :width: 50% 115 | 116 | If you wish to customise the legend please over ride the template. See the 117 | `over riding layout objects templates`_ docs for guidance on how to do this. 118 | 119 | .. _`over riding layout objects templates`: https://django-crispy-forms.readthedocs.io/en/latest/layouts.html?#overriding-layout-objects-templates 120 | .. _Fieldset: https://django-crispy-forms.readthedocs.io/en/latest/api_layout.html?#layout.Fieldset 121 | 122 | TBC 123 | --- 124 | Multifield, MultiWidgetField 125 | 126 | crispy_forms.bootstrap 127 | ====================== 128 | 129 | The following objects can be imported from ``crispy_forms.boostrap``. 130 | 131 | Field With Buttons 132 | ------------------ 133 | 134 | An oppinionated field with buttons template is provided with the template 135 | pack. If you wish to customise this layout object you can 136 | `over ride templates`_. 137 | 138 | .. image:: img/field_with_buttons.png 139 | :width: 35% 140 | 141 | Inline Field 142 | ------------ 143 | 144 | An oppinionated inline field. If you wish to customise this layout object you 145 | can `over ride templates`_. 146 | 147 | .. image:: img/inline_field.png 148 | :width: 35% 149 | 150 | Prepended and Appended 151 | ---------------------- 152 | 153 | The three layout objects which allow for prepended and appended inputs are: 154 | 155 | - ``PrependedText``. First argument is the field name, the second is the 156 | prepended text. 157 | - ``AppendedText``. First argument is the field name, the second is the 158 | appended text. 159 | - ``PrependedAppendedText``. A combined prepended and appended text. The first 160 | parameter is the name of the field, then second is the prepended text and 161 | thirdly the appended text. 162 | 163 | The image below shows examples of how these are rendered with the template 164 | provided with ``crispy-tailwind``. If you wish to customise these then you 165 | will need to `over ride templates`_. 166 | 167 | .. image:: img/prepended.png 168 | :width: 50% 169 | 170 | See the ``crispy-forms`` Prepended_ docs for more information on how to use 171 | these fields. 172 | 173 | Note: the active argument does not have any impact on the ``crispy-tailwind`` 174 | template pack. 175 | 176 | .. _Prepended: https://django-crispy-forms.readthedocs.io/en/latest/layouts.html?#bootstrap-layout-objects 177 | 178 | crispy_tailwind.layout 179 | ====================== 180 | 181 | The following layout objects can be imported from ``crispy_tailwind.layout``. 182 | 183 | Buttons 184 | ------- 185 | 186 | The three Buttons (``Button``, ``Reset`` and ``Submit``) from ``crispy-forms`` 187 | have been customised for the Tailwind template pack. Below is an example form 188 | layout with an image showing how it is rendered:: 189 | 190 | form.helper.layout = Layout( 191 | ButtonHolder(Button("button", "Button"), Submit("submit", "Submit",), Reset("cancel", "Cancel")) 192 | ) 193 | 194 | .. image:: img/Buttons.png 195 | :height: 35px 196 | 197 | The three buttons have default classes applied to them. The buttons can all be 198 | customised by adding css_classes to these layout objects. The following layout 199 | renders a single customised ``Submit`` button:: 200 | 201 | form.helper.layout = Layout(Submit("submit", "Submit", css_class="bg-transparent hover:bg-blue-500 text-blue-700 font-semibold hover:text-white py-2 px-4 border border-blue-500 hover:border-transparent rounded")) 202 | 203 | .. image:: img/custom_button.png 204 | :height: 35px 205 | 206 | Alert 207 | ----- 208 | 209 | Renders an alert message box:: 210 | 211 | form.helper.layout = Layout( 212 | Alert(content="Warning! Here's a test message.") 213 | ) 214 | 215 | .. image:: img/alert.png 216 | :height: 50px 217 | 218 | The ``css_class`` argument can be used to change the default classes on the 219 | wrapping ``
``, this allows simple changes to the alert style (e.g. colour) 220 | . 221 | 222 | By default alerts have a close button. To disable, set ``dismiss`` to 223 | ``false``. 224 | 225 | In addition the variety of alerts possible with Tailwind is wide and but may 226 | require a different HTML layout. In this case you may find is useful to over 227 | ride the ``alert.html`` template. See the crispy-forms docs on how to 228 | `over ride templates`_ for more information. 229 | 230 | .. _over ride templates: https://django-crispy-forms.readthedocs.io/en/latest/layouts.html?#overriding-layout-objects-templates 231 | -------------------------------------------------------------------------------- /docs/make.bat: -------------------------------------------------------------------------------- 1 | @ECHO OFF 2 | 3 | pushd %~dp0 4 | 5 | REM Command file for Sphinx documentation 6 | 7 | if "%SPHINXBUILD%" == "" ( 8 | set SPHINXBUILD=sphinx-build 9 | ) 10 | set SOURCEDIR=. 11 | set BUILDDIR=_build 12 | 13 | if "%1" == "" goto help 14 | 15 | %SPHINXBUILD% >NUL 2>NUL 16 | if errorlevel 9009 ( 17 | echo. 18 | echo.The 'sphinx-build' command was not found. Make sure you have Sphinx 19 | echo.installed, then set the SPHINXBUILD environment variable to point 20 | echo.to the full path of the 'sphinx-build' executable. Alternatively you 21 | echo.may add the Sphinx directory to PATH. 22 | echo. 23 | echo.If you don't have Sphinx installed, grab it from 24 | echo.http://sphinx-doc.org/ 25 | exit /b 1 26 | ) 27 | 28 | %SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% 29 | goto end 30 | 31 | :help 32 | %SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% 33 | 34 | :end 35 | popd 36 | -------------------------------------------------------------------------------- /docs/requirements.txt: -------------------------------------------------------------------------------- 1 | sphinx-rtd-theme 2 | docutils<0.21,>=0.18.1 3 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["setuptools>=61.0"] 3 | build-backend = "setuptools.build_meta" 4 | 5 | [project] 6 | name = "crispy-tailwind" 7 | version = "1.0.3" 8 | description = "Tailwind CSS for Django Crispy Forms" 9 | readme = "README.rst" 10 | license = {text = "MIT"} 11 | authors = [{name = "David Smith", email = "smithdc@gmail.com"}] 12 | requires-python = ">=3.8" 13 | classifiers=[ 14 | "Development Status :: 5 - Production/Stable", 15 | "Environment :: Web Environment", 16 | "Framework :: Django", 17 | "Framework :: Django :: 4.2", 18 | "Framework :: Django :: 5.0", 19 | "Framework :: Django :: 5.1", 20 | "License :: OSI Approved :: MIT License", 21 | "Operating System :: OS Independent", 22 | "Programming Language :: JavaScript", 23 | "Programming Language :: Python :: 3", 24 | "Programming Language :: Python :: 3.8", 25 | "Programming Language :: Python :: 3.9", 26 | "Programming Language :: Python :: 3.10", 27 | "Programming Language :: Python :: 3.11", 28 | "Programming Language :: Python :: 3.12", 29 | "Topic :: Internet :: WWW/HTTP", 30 | "Topic :: Internet :: WWW/HTTP :: Dynamic Content", 31 | "Topic :: Software Development :: Libraries :: Python Modules", 32 | ] 33 | dependencies = [ 34 | "django-crispy-forms>=2.0", 35 | "django>=4.2" 36 | ] 37 | 38 | [project.optional-dependencies] 39 | test = [ 40 | "pytest", 41 | "pytest-django", 42 | ] 43 | 44 | [project.urls] 45 | "Homepage" = "https://github.com/django-crispy-forms/crispy-tailwind" 46 | "Issues" = "https://github.com/django-crispy-forms/crispy-tailwind/issues" 47 | "Changelog" = "https://github.com/django-crispy-forms/crispy-tailwind/releases" 48 | 49 | [tool.autopub] 50 | project-name = "Crispy-Tailwind" 51 | git-username = "botpub" 52 | git-email = "52496925+botpub@users.noreply.github.com" 53 | version-strings = ["crispy_tailwind/__init__.py"] 54 | append-github-contributor = true 55 | 56 | [tool.black] 57 | line-length = 119 58 | target-version = ['py38'] 59 | include = '\.pyi?$' 60 | exclude = ''' 61 | /( 62 | \.eggs 63 | | \.git 64 | | \.hg 65 | | \.mypy_cache 66 | | \.tox 67 | | \.venv 68 | | _build 69 | | buck-out 70 | | build 71 | | dist 72 | 73 | )/ 74 | ''' 75 | 76 | [tool.pytest.ini_options] 77 | DJANGO_SETTINGS_MODULE= "tests.test_settings" 78 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | # dependencies 2 | django>=3.2 3 | django-crispy-forms>=2.0 4 | 5 | # dev 6 | pytest 7 | pytest-django 8 | coverage 9 | twine 10 | wheel 11 | black 12 | isort 13 | flake8 14 | django-coverage-plugin 15 | codecov 16 | pre-commit 17 | 18 | 19 | # docs 20 | sphinx 21 | sphinx-rtd-theme 22 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [metadata] 2 | license-file = LICENSE.txt 3 | 4 | [isort] 5 | multi_line_output=3 6 | include_trailing_comma=True 7 | force_grid_wrap=0 8 | use_parentheses=True 9 | line_length=119 10 | default_section=THIRDPARTY 11 | known_django=django 12 | known_first_party=crispy_forms, crispy_tailwind 13 | sections=FUTURE,STDLIB,THIRDPARTY,DJANGO,FIRSTPARTY,LOCALFOLDER 14 | 15 | [flake8] 16 | exclude = 17 | # mirrors max-line-length of Django. This is shorter than GitHub editor (127) 18 | max-line-length = 119 19 | statistics = True 20 | max-complexity = 10 21 | count = True 22 | select = C,E,F,W,B 23 | ignore = E203, W503, E501 24 | 25 | [coverage:run] 26 | plugins = django_coverage_plugin 27 | branch = True 28 | source = crispy_tailwind 29 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/django-crispy-forms/crispy-tailwind/47075468f09c30272e5f9d59abd7154e5682c3d1/tests/__init__.py -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/django-crispy-forms/crispy-tailwind/47075468f09c30272e5f9d59abd7154e5682c3d1/tests/conftest.py -------------------------------------------------------------------------------- /tests/forms.py: -------------------------------------------------------------------------------- 1 | from django import forms 2 | 3 | 4 | class SampleForm(forms.Form): 5 | is_company = forms.CharField(label="company", required=False, widget=forms.CheckboxInput()) 6 | email = forms.EmailField( 7 | label="email", max_length=30, required=True, widget=forms.TextInput(), help_text="Insert your email" 8 | ) 9 | password1 = forms.CharField(label="password", max_length=30, required=True, widget=forms.PasswordInput()) 10 | password2 = forms.CharField(label="re-enter password", max_length=30, required=True, widget=forms.PasswordInput()) 11 | first_name = forms.CharField(label="first name", max_length=5, required=True, widget=forms.TextInput()) 12 | last_name = forms.CharField(label="last name", max_length=5, required=True, widget=forms.TextInput()) 13 | datetime_field = forms.SplitDateTimeField(label="date time", widget=forms.SplitDateTimeWidget()) 14 | tos_accepted = forms.ChoiceField( 15 | label="terms of service", 16 | widget=forms.Select(), 17 | choices=(("accepted", "Accepted"), ("not_accepted", "Not accepted")), 18 | ) 19 | select_multiple = forms.MultipleChoiceField(choices=[("1", "one"), ("2", "two"), ("3", "three")]) 20 | select_required = forms.ChoiceField( 21 | choices=[("", "Select and option"), ("1", "one"), ("2", "two"), ("3", "three")], 22 | initial="2", 23 | ) 24 | grouped_select = forms.TypedChoiceField( 25 | choices=[ 26 | ( 27 | "Group 1", 28 | [ 29 | ("1", "one"), 30 | ("2", "two"), 31 | ], 32 | ), 33 | ( 34 | "Group 2", 35 | [ 36 | ("3", "three"), 37 | ("4", "four"), 38 | ], 39 | ), 40 | ], 41 | coerce=str, 42 | widget=forms.Select(attrs={"class": "custom-class"}), 43 | ) 44 | 45 | def clean(self): 46 | super().clean() 47 | password1 = self.cleaned_data.get("password1", None) 48 | password2 = self.cleaned_data.get("password2", None) 49 | if not password1 and not password2 or password1 != password2: 50 | raise forms.ValidationError("Passwords dont match") 51 | 52 | return self.cleaned_data 53 | 54 | 55 | class CharFieldForm(forms.Form): 56 | name = forms.CharField(required=True) 57 | 58 | 59 | class ShortCharFieldForm(forms.Form): 60 | name = forms.CharField(required=True, max_length=3) 61 | 62 | 63 | class PasswordFieldForm(forms.Form): 64 | password = forms.CharField(widget=forms.PasswordInput) 65 | 66 | 67 | class RadioForm(forms.Form): 68 | choices = [ 69 | ("blue", "Blue"), 70 | ("green", "Green"), 71 | ] 72 | radio = forms.ChoiceField(widget=forms.RadioSelect, choices=choices) 73 | 74 | 75 | class CheckboxMultiple(forms.Form): 76 | choices = [ 77 | ("blue", "Blue"), 78 | ("green", "Green"), 79 | ] 80 | checkbox = forms.ChoiceField(widget=forms.CheckboxSelectMultiple, choices=choices) 81 | 82 | 83 | class SelectForm(forms.Form): 84 | tos_accepted = forms.ChoiceField( 85 | label="terms of service", 86 | widget=forms.Select(), 87 | choices=(("accepted", "Accepted"), ("not_accepted", "Not accepted")), 88 | ) 89 | 90 | 91 | class CustomTextWidget(forms.TextInput): 92 | pass 93 | 94 | 95 | class CustomTextWidgetForm(forms.Form): 96 | first_name = forms.CharField(widget=CustomTextWidget()) 97 | last_name = forms.CharField(widget=CustomTextWidget(attrs={"class": "custom-css"})) 98 | -------------------------------------------------------------------------------- /tests/results/alert/alert.html: -------------------------------------------------------------------------------- 1 | 12 | -------------------------------------------------------------------------------- /tests/results/alert/alert_custom.html: -------------------------------------------------------------------------------- 1 | 12 | -------------------------------------------------------------------------------- /tests/results/alert/alert_dismiss_false.html: -------------------------------------------------------------------------------- 1 | 4 | -------------------------------------------------------------------------------- /tests/results/crispy_addon/crispy_addon.html: -------------------------------------------------------------------------------- 1 |
2 | 3 |
4 |
5 | $ 6 | 7 | .00 8 |
9 |
10 |
11 | -------------------------------------------------------------------------------- /tests/results/filter/crispy_filter.html: -------------------------------------------------------------------------------- 1 |
2 | 3 |
4 | 5 |
6 | 8 | 12 | Insert your email
13 | 14 |
15 | 18 | 22 |
23 | 24 |
25 | 27 |
32 | 33 |
34 | 36 | 40 |
41 | 42 |
43 | 45 | 48 |
49 | 50 |
51 | 53 | 56 |
59 | 60 |
61 | 63 |
64 |
65 | 70 |
71 | 72 | 73 | 74 |
75 |
76 |
77 |
78 | 79 |
80 | 85 |
86 |
87 | 100 |
101 |
102 |
103 | 104 |
105 | 110 |
111 |
112 | 128 |
129 | 130 | 131 | 132 |
133 |
134 |
135 |
136 | 137 |
138 | 143 |
144 |
145 | 165 |
166 | 167 | 168 | 169 |
170 |
171 |
172 |
173 | -------------------------------------------------------------------------------- /tests/results/filter/crispy_filter_lt50.html: -------------------------------------------------------------------------------- 1 |
2 | 3 |
4 | 5 |
6 | 8 | 12 | Insert your email
13 | 14 |
15 | 18 | 22 |
23 | 24 |
25 | 27 |
32 | 33 |
34 | 36 | 40 |
41 | 42 |
43 | 45 | 48 |
49 | 50 |
51 | 53 | 56 |
59 | 60 |
61 | 63 |
64 |
65 | 70 |
71 | 72 | 73 | 74 |
75 |
76 |
77 |
78 | 79 |
80 | 85 |
86 |
87 | 100 |
101 |
102 |
103 | 104 |
105 | 110 |
111 |
112 | 128 |
129 | 130 | 131 | 132 |
133 |
134 |
135 |
136 | 137 |
138 | 143 |
144 |
145 | 165 |
166 | 167 | 168 | 169 |
170 |
171 |
172 |
173 | -------------------------------------------------------------------------------- /tests/results/filter/formset.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 |
5 |
6 | 7 | 14 |
15 |
16 | 17 |
18 |
19 | 20 | 27 |
28 |
29 | -------------------------------------------------------------------------------- /tests/results/helper/buttons.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /tests/results/helper/buttons_with_css.html: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /tests/results/helper/charfield.html: -------------------------------------------------------------------------------- 1 |
2 |
3 | 4 | 5 |
6 |
7 | -------------------------------------------------------------------------------- /tests/results/helper/charfield_failing.html: -------------------------------------------------------------------------------- 1 |
2 |
3 | 4 | 5 |

This field is required.

6 |
7 |
8 | -------------------------------------------------------------------------------- /tests/results/helper/charfield_failing_lt50.html: -------------------------------------------------------------------------------- 1 |
2 |
3 | 4 | 5 |

This field is required.

6 |
7 |
8 | -------------------------------------------------------------------------------- /tests/results/helper/col_row.html: -------------------------------------------------------------------------------- 1 |
2 |
3 |
4 | 5 | 13 |
14 |
15 | 16 | 24 |
25 |
26 |
27 |
28 | 29 | 38 | Insert your email 39 |
40 |
41 |
42 | -------------------------------------------------------------------------------- /tests/results/helper/col_row_lt50.html: -------------------------------------------------------------------------------- 1 |
2 |
3 |
4 | 5 | 13 |
14 |
15 | 16 | 24 |
25 |
26 |
27 |
28 | 29 | 37 | Insert your email 38 |
39 |
40 |
41 | -------------------------------------------------------------------------------- /tests/results/helper/crispy_layout.html: -------------------------------------------------------------------------------- 1 |
2 | 5 | 6 |
7 |
8 | 9 | 17 |
18 | -------------------------------------------------------------------------------- /tests/results/helper/div.html: -------------------------------------------------------------------------------- 1 |
2 |
3 | 4 | 13 | Insert your email 14 |
15 |
16 | -------------------------------------------------------------------------------- /tests/results/helper/div_lt50.html: -------------------------------------------------------------------------------- 1 |
2 |
3 | 4 | 12 | Insert your email 13 |
14 |
15 | -------------------------------------------------------------------------------- /tests/results/helper/field_with_buttons.html: -------------------------------------------------------------------------------- 1 |
2 | 5 |
6 | 9 | 10 | 12 | 13 |
14 |
15 | -------------------------------------------------------------------------------- /tests/results/helper/fieldset.html: -------------------------------------------------------------------------------- 1 |
2 | Text for the legend 3 |
4 | 7 | 8 |
9 |
10 | 11 | 20 | Insert your email 21 |
22 |
23 | -------------------------------------------------------------------------------- /tests/results/helper/fieldset_lt50.html: -------------------------------------------------------------------------------- 1 |
2 | Text for the legend 3 |
4 | 7 | 8 |
9 |
10 | 11 | 19 | Insert your email 20 |
21 |
22 | -------------------------------------------------------------------------------- /tests/results/helper/formset.html: -------------------------------------------------------------------------------- 1 |
2 | 3 | 4 |
5 |
6 | 7 | 15 | Insert your email 16 |
17 |
18 | 19 | 27 | Insert your email 28 |
29 | -------------------------------------------------------------------------------- /tests/results/helper/formset_errors.html: -------------------------------------------------------------------------------- 1 |
2 | 3 | 4 |
5 |
6 |
7 | Non Form Errors 8 |
9 |
10 |
    11 |
  • Please submit at most 2 forms.
  • 12 |
13 |
14 |
15 |
16 | 17 | 26 |

Ensure this value has at most 3 characters (it has 4).

27 |
28 |
29 | 30 | 37 |
38 |
39 | 40 | 47 |
48 | -------------------------------------------------------------------------------- /tests/results/helper/formset_errors_lt50.html: -------------------------------------------------------------------------------- 1 |
2 | 3 | 4 |
5 |
6 |
7 | Non Form Errors 8 |
9 |
10 |
    11 |
  • Please submit at most 2 forms.
  • 12 |
13 |
14 |
15 |
16 | 17 | 25 |

Ensure this value has at most 3 characters (it has 4).

26 |
27 |
28 | 29 | 36 |
37 |
38 | 39 | 46 |
47 | -------------------------------------------------------------------------------- /tests/results/helper/formset_form_tag.html: -------------------------------------------------------------------------------- 1 |
2 |
3 | 4 | 5 |
6 |
7 | 8 | 16 | Insert your email 17 |
18 |
19 | 20 | 28 | Insert your email 29 |
30 |
31 | -------------------------------------------------------------------------------- /tests/results/helper/formset_form_tag_lt50.html: -------------------------------------------------------------------------------- 1 |
2 |
3 | 4 | 5 |
6 |
7 | 8 | 15 | Insert your email 16 |
17 |
18 | 19 | 26 | Insert your email 27 |
28 |
29 | -------------------------------------------------------------------------------- /tests/results/helper/formset_lt50.html: -------------------------------------------------------------------------------- 1 |
2 | 3 | 4 |
5 |
6 | 7 | 14 | Insert your email 15 |
16 |
17 | 18 | 25 | Insert your email 26 |
27 | -------------------------------------------------------------------------------- /tests/results/helper/inline_checkbox.html: -------------------------------------------------------------------------------- 1 |
2 | 3 |
4 |
5 | 9 |
10 |
11 | 15 |
16 |
17 |
18 | -------------------------------------------------------------------------------- /tests/results/helper/inline_field.html: -------------------------------------------------------------------------------- 1 |
2 | 3 | 6 |
7 |
8 | 13 |
14 | -------------------------------------------------------------------------------- /tests/results/helper/inline_radio.html: -------------------------------------------------------------------------------- 1 |
2 | 3 |
4 | 8 | 12 |
13 |
14 | -------------------------------------------------------------------------------- /tests/results/helper/multiple_checkbox.html: -------------------------------------------------------------------------------- 1 |
2 | 3 |
4 |
5 | 9 |
10 |
11 | 15 |
16 |
17 |
18 | -------------------------------------------------------------------------------- /tests/results/helper/non_form_errors.html: -------------------------------------------------------------------------------- 1 |
2 |
3 | Form Error Title 4 |
5 |
6 |
    7 |
  • Passwords dont match
  • 8 |
9 |
10 |
11 |
12 | 13 | 22 |

This field is required.

23 |
24 |
25 | 26 | 35 |

This field is required.

36 |
37 | -------------------------------------------------------------------------------- /tests/results/helper/non_form_errors_lt50.html: -------------------------------------------------------------------------------- 1 |
2 |
3 | Form Error Title 4 |
5 |
6 |
    7 |
  • Passwords dont match
  • 8 |
9 |
10 |
11 |
12 | 13 | 21 |

This field is required.

22 |
23 |
24 | 25 | 33 |

This field is required.

34 |
35 | -------------------------------------------------------------------------------- /tests/results/helper/password.html: -------------------------------------------------------------------------------- 1 |
2 |
3 | 4 | 5 |
6 |
7 | -------------------------------------------------------------------------------- /tests/results/helper/radio.html: -------------------------------------------------------------------------------- 1 |
2 | 3 |
4 | 8 | 12 |
13 |
14 | -------------------------------------------------------------------------------- /tests/results/helper/select.html: -------------------------------------------------------------------------------- 1 |
2 | 3 |
4 |
5 | 9 |
10 | 11 |
12 |
13 |
14 |
15 | -------------------------------------------------------------------------------- /tests/results/prepended/appended_errors.html: -------------------------------------------------------------------------------- 1 |
2 |
3 |
4 | 12 | @ 13 |
14 |
15 |

This field is required.

16 |
17 | -------------------------------------------------------------------------------- /tests/results/prepended/appended_errors_lt50.html: -------------------------------------------------------------------------------- 1 |
2 |
3 |
4 | 5 | @ 6 |
7 |
8 |

This field is required.

9 |
10 | -------------------------------------------------------------------------------- /tests/results/prepended/appended_text.html: -------------------------------------------------------------------------------- 1 |
2 | 3 |
4 |
5 | 13 | .com 14 |
15 |
16 |
17 | -------------------------------------------------------------------------------- /tests/results/prepended/prepended_appended_errors.html: -------------------------------------------------------------------------------- 1 |
2 |
3 |
4 | @ 5 | 13 | .com 14 |
15 |
16 |

This field is required.

17 |
18 | -------------------------------------------------------------------------------- /tests/results/prepended/prepended_appended_errors_lt50.html: -------------------------------------------------------------------------------- 1 |
2 |
3 |
4 | @ 5 | 6 | .com 7 |
8 |
9 |

This field is required.

10 |
11 | -------------------------------------------------------------------------------- /tests/results/prepended/prepended_appended_text.html: -------------------------------------------------------------------------------- 1 |
2 | 3 |
4 |
5 | www. 6 | 7 | .com 8 |
9 |
10 |
11 | -------------------------------------------------------------------------------- /tests/results/prepended/prepended_errors.html: -------------------------------------------------------------------------------- 1 |
2 |
3 |
4 | @ 5 | 13 |
14 |
15 |

This field is required.

16 |
17 | -------------------------------------------------------------------------------- /tests/results/prepended/prepended_errors_lt50.html: -------------------------------------------------------------------------------- 1 |
2 |
3 |
4 | @ 5 | 6 |
7 |
8 |

This field is required.

9 |
10 | -------------------------------------------------------------------------------- /tests/results/prepended/prepended_help.html: -------------------------------------------------------------------------------- 1 |
2 |
3 |
4 | @ 5 | 14 |
15 |
16 | Insert your email 17 |
18 | -------------------------------------------------------------------------------- /tests/results/prepended/prepended_help_lt50.html: -------------------------------------------------------------------------------- 1 |
2 |
3 |
4 | @ 5 | 13 |
14 |
15 | Insert your email 16 |
17 | -------------------------------------------------------------------------------- /tests/results/prepended/prepended_long_text.html: -------------------------------------------------------------------------------- 1 |
2 | 3 |
4 |
5 | http://www. 6 | 14 |
15 |
16 |
17 | -------------------------------------------------------------------------------- /tests/results/prepended/prepended_no_label.html: -------------------------------------------------------------------------------- 1 |
2 |
3 |
4 | @ 5 | 13 |
14 |
15 |
16 | -------------------------------------------------------------------------------- /tests/results/prepended/prepended_text.html: -------------------------------------------------------------------------------- 1 |
2 | 3 |
4 |
5 | @ 6 | 14 |
15 |
16 |
17 | -------------------------------------------------------------------------------- /tests/results/table_inline_formset/table_inline_formset_failing.html: -------------------------------------------------------------------------------- 1 |
2 |
3 |
4 |
5 |
    6 |
  • Please submit at most 2 forms.
  • 7 |
8 |
9 |
10 | 11 | 12 | 13 | 15 | 16 | 17 | 18 | 19 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 |
14 | Name*
20 |

Ensure this value has at most 3 characters (it has 4).

21 |
31 |
32 |
33 |
34 |
35 |
36 |
37 | -------------------------------------------------------------------------------- /tests/results/table_inline_formset/table_inline_formset_failing_lt50.html: -------------------------------------------------------------------------------- 1 |
2 |
3 |
4 |
5 |
    6 |
  • Please submit at most 2 forms.
  • 7 |
8 |
9 |
10 | 11 | 12 | 13 | 15 | 16 | 17 | 18 | 19 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 |
14 | Name*
20 |

Ensure this value has at most 3 characters (it has 4).

21 |
31 |
32 |
33 |
34 |
35 |
36 |
37 | -------------------------------------------------------------------------------- /tests/results/table_inline_formset/table_inline_formset_lt50.html: -------------------------------------------------------------------------------- 1 |
2 | 3 | 4 |
5 | 6 | 7 | 8 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 36 | 45 | 54 | 63 | 72 | 86 | 99 | 118 | 146 | 178 | 179 | 180 | 181 | 191 | 200 | 209 | 218 | 227 | 241 | 254 | 273 | 301 | 333 | 334 | 335 |
9 | company 10 | email*password*re-enter password*first name*last name*date time*terms of service*Select multiple*Select required*Grouped select*
27 | 34 | Insert your email 35 | 37 | 44 | 46 | 53 | 55 | 62 | 64 | 71 | 73 | 79 | 85 | 87 |
88 |
89 | 93 |
94 | 95 |
96 |
97 |
98 |
100 |
101 |
102 | 115 |
116 |
117 |
119 |
120 |
121 | 137 |
139 | 140 | 141 | 142 |
143 |
144 |
145 |
147 |
148 |
149 | 169 |
171 | 172 | 173 | 174 |
175 |
176 |
177 |
182 | 189 | Insert your email 190 | 192 | 199 | 201 | 208 | 210 | 217 | 219 | 226 | 228 | 234 | 240 | 242 |
243 |
244 | 248 |
249 | 250 |
251 |
252 |
253 |
255 |
256 |
257 | 270 |
271 |
272 |
274 |
275 |
276 | 292 |
294 | 295 | 296 | 297 |
298 |
299 |
300 |
302 |
303 |
304 | 324 |
326 | 327 | 328 | 329 |
330 |
331 |
332 |
336 |
337 |
338 |
339 |
340 |
341 | -------------------------------------------------------------------------------- /tests/test_alert.py: -------------------------------------------------------------------------------- 1 | from django.template import Template 2 | from django.test import SimpleTestCase 3 | 4 | from crispy_forms.helper import FormHelper 5 | from crispy_forms.layout import Layout 6 | from crispy_tailwind.layout import Alert 7 | 8 | from .forms import SampleForm 9 | from .utils import parse_expected, parse_form 10 | 11 | template = Template( 12 | """ 13 | {% load crispy_forms_tags %} 14 | {% crispy form %} 15 | """ 16 | ) 17 | 18 | 19 | class CrispyHelperTests(SimpleTestCase): 20 | maxDiff = None 21 | 22 | def test_alert(self): 23 | form = SampleForm 24 | form.helper = FormHelper() 25 | form.helper.form_tag = False 26 | form.helper.layout = Layout(Alert(content="Warning! Here's a test message.")) 27 | assert parse_form(form) == parse_expected("alert/alert.html") 28 | 29 | def test_dismiss_false(self): 30 | form = SampleForm 31 | form.helper = FormHelper() 32 | form.helper.form_tag = False 33 | form.helper.layout = Layout(Alert(dismiss=False, content="Warning! Here's a test message.")) 34 | assert parse_form(form) == parse_expected("alert/alert_dismiss_false.html") 35 | 36 | def test_custom_alert(self): 37 | form = SampleForm 38 | form.helper = FormHelper() 39 | form.helper.form_tag = False 40 | form.helper.layout = Layout( 41 | Alert(content="Warning! Here's a test message.", css_class="custom css") 42 | ) 43 | assert parse_form(form) == parse_expected("alert/alert_custom.html") 44 | -------------------------------------------------------------------------------- /tests/test_crispy_addon.py: -------------------------------------------------------------------------------- 1 | from django.template import Context, Template 2 | from django.test import SimpleTestCase 3 | from django.test.html import parse_html 4 | 5 | from .forms import SampleForm 6 | from .utils import parse_expected 7 | 8 | 9 | class CrispyHelperTests(SimpleTestCase): 10 | maxDiff = None 11 | 12 | def test_crispy_addon(self): 13 | template = Template( 14 | """ 15 | {% load tailwind_field %} 16 | {% crispy_addon form.last_name prepend="$" append=".00" %} 17 | """ 18 | ) 19 | html = template.render(Context({"form": SampleForm()})) 20 | assert parse_html(html) == parse_expected("crispy_addon/crispy_addon.html") 21 | -------------------------------------------------------------------------------- /tests/test_filter.py: -------------------------------------------------------------------------------- 1 | import django 2 | from django.forms.models import formset_factory 3 | from django.template import Context, Template 4 | from django.test import SimpleTestCase 5 | from django.test.html import parse_html 6 | 7 | from .forms import CharFieldForm, SampleForm, ShortCharFieldForm 8 | from .utils import parse_expected 9 | 10 | 11 | class CrispyFilterTests(SimpleTestCase): 12 | maxDiff = None 13 | 14 | def test_crispy_filter(self): 15 | template = Template( 16 | """ 17 | {% load tailwind_filters %} 18 | {{ form|crispy }} 19 | """ 20 | ) 21 | form = SampleForm() 22 | c = Context({"form": form}) 23 | html = template.render(c) 24 | if django.VERSION < (5, 0): 25 | expected = "filter/crispy_filter_lt50.html" 26 | else: 27 | expected = "filter/crispy_filter.html" 28 | assert parse_html(html) == parse_expected(expected) 29 | 30 | def test_error_borders(self): 31 | template = Template( 32 | """ 33 | {% load tailwind_filters %} 34 | {{ form|crispy }} 35 | """ 36 | ) 37 | form = CharFieldForm() 38 | c = Context({"form": form}) 39 | html = template.render(c) 40 | assert "border-red-500" not in html 41 | assert "border-gray-300" in html 42 | 43 | form = CharFieldForm({"name": ""}) 44 | c = Context({"form": form}) 45 | html = template.render(c) 46 | assert "border-red-500" in html 47 | assert "border-gray-300" not in html 48 | 49 | def test_formset(self): 50 | template = Template( 51 | """ 52 | {% load tailwind_filters %} 53 | {{ form|crispy }} 54 | """ 55 | ) 56 | formset = formset_factory(ShortCharFieldForm, extra=2) 57 | c = Context({"form": formset}) 58 | html = template.render(c) 59 | assert parse_html(html) == parse_expected("filter/formset.html") 60 | -------------------------------------------------------------------------------- /tests/test_helper.py: -------------------------------------------------------------------------------- 1 | import django 2 | from django.forms.models import formset_factory 3 | from django.template import Template 4 | from django.test import SimpleTestCase 5 | 6 | from crispy_forms.bootstrap import FieldWithButtons, InlineCheckboxes, InlineField, InlineRadios 7 | from crispy_forms.helper import FormHelper 8 | from crispy_forms.layout import HTML, Column, Div, Field, Fieldset, Hidden, Layout, Row 9 | from crispy_forms.utils import render_crispy_form 10 | from crispy_tailwind.layout import Button, Reset, Submit 11 | 12 | from .forms import ( 13 | CharFieldForm, 14 | CheckboxMultiple, 15 | PasswordFieldForm, 16 | RadioForm, 17 | SampleForm, 18 | SelectForm, 19 | ShortCharFieldForm, 20 | ) 21 | from .utils import parse_expected, parse_form 22 | 23 | template = Template( 24 | """ 25 | {% load crispy_forms_tags %} 26 | {% crispy form %} 27 | """ 28 | ) 29 | 30 | 31 | class CrispyHelperTests(SimpleTestCase): 32 | maxDiff = None 33 | 34 | def test_CharField(self): 35 | form = CharFieldForm() 36 | form.helper = FormHelper() 37 | assert parse_form(form) == parse_expected("helper/charfield.html") 38 | 39 | def test_failing_CharField(self): 40 | form = CharFieldForm(data={"name": ""}) 41 | form.helper = FormHelper() 42 | if django.VERSION < (5, 0): 43 | expected = "helper/charfield_failing_lt50.html" 44 | else: 45 | expected = "helper/charfield_failing.html" 46 | assert parse_form(form) == parse_expected(expected) 47 | 48 | def test_password(self): 49 | form = PasswordFieldForm() 50 | form.helper = FormHelper() 51 | assert parse_form(form) == parse_expected("helper/password.html") 52 | 53 | def test_radio(self): 54 | form = RadioForm() 55 | form.helper = FormHelper() 56 | form.helper.form_tag = False 57 | assert parse_form(form) == parse_expected("helper/radio.html") 58 | 59 | def test_multiple_checkbox(self): 60 | form = CheckboxMultiple() 61 | form.helper = FormHelper() 62 | form.helper.form_tag = False 63 | assert parse_form(form) == parse_expected("helper/multiple_checkbox.html") 64 | 65 | def test_crispy_layout(self): 66 | form = SampleForm 67 | form.helper = FormHelper() 68 | form.helper.layout = Layout("is_company", "first_name") 69 | form.helper.form_tag = False 70 | assert parse_form(form) == parse_expected("helper/crispy_layout.html") 71 | 72 | def test_col_row(self): 73 | form = SampleForm() 74 | form.helper = FormHelper() 75 | form.helper.form_tag = False 76 | form.helper.layout = Layout( 77 | Row( 78 | Column(Field("first_name"), Field("last_name"), css_class="px-2"), 79 | Column("email", css_class="px-2"), 80 | ) 81 | ) 82 | if django.VERSION < (5, 0): 83 | expected = "helper/col_row_lt50.html" 84 | else: 85 | expected = "helper/col_row.html" 86 | assert parse_form(form) == parse_expected(expected) 87 | 88 | def test_inline_radio(self): 89 | form = RadioForm() 90 | form.helper = FormHelper() 91 | form.helper.form_tag = False 92 | form.helper.layout = Layout(InlineRadios("radio")) 93 | assert parse_form(form) == parse_expected("helper/inline_radio.html") 94 | 95 | def test_inline_checkbox(self): 96 | form = CheckboxMultiple() 97 | form.helper = FormHelper() 98 | form.helper.form_tag = False 99 | form.helper.layout = Layout(InlineCheckboxes("checkbox")) 100 | assert parse_form(form) == parse_expected("helper/inline_checkbox.html") 101 | 102 | def test_formset(self): 103 | SampleFormSet = formset_factory(SampleForm, extra=2) 104 | formset = SampleFormSet() 105 | formset.helper = FormHelper() 106 | formset.helper.form_tag = False 107 | formset.helper.layout = Layout("email") 108 | if django.VERSION < (5, 0): 109 | expected = "helper/formset_lt50.html" 110 | else: 111 | expected = "helper/formset.html" 112 | assert parse_form(formset) == parse_expected(expected) 113 | 114 | def test_formset_with_errors(self): 115 | SampleFormSet = formset_factory(ShortCharFieldForm, extra=1, max_num=2, validate_max=True) 116 | data = { 117 | "name-0-name": "test", 118 | "name-INITIAL_FORMS": "0", 119 | "name-MIN_NUM_FORMS": "0", 120 | "name-MAX_NUM_FORMS": "0", 121 | "name-TOTAL_FORMS": "3", 122 | } 123 | formset = SampleFormSet(data=data, prefix="name") 124 | formset.helper = FormHelper() 125 | formset.helper.formset_error_title = "Non Form Errors" 126 | formset.helper.form_tag = False 127 | formset.helper.layout = Layout("email") 128 | if django.VERSION < (5, 0): 129 | expected = "helper/formset_errors_lt50.html" 130 | else: 131 | expected = "helper/formset_errors.html" 132 | assert parse_form(formset) == parse_expected(expected) 133 | 134 | def test_formset_with_form_tag(self): 135 | SampleFormSet = formset_factory(SampleForm, extra=2) 136 | formset = SampleFormSet() 137 | formset.helper = FormHelper() 138 | formset.helper.form_tag = True 139 | formset.helper.layout = Layout("email") 140 | if django.VERSION < (5, 0): 141 | expected = "helper/formset_form_tag_lt50.html" 142 | else: 143 | expected = "helper/formset_form_tag.html" 144 | assert parse_form(formset) == parse_expected(expected) 145 | 146 | def test_buttons(self): 147 | form = CharFieldForm() 148 | form.helper = FormHelper() 149 | form.helper.form_tag = False 150 | form.helper.layout = Layout( 151 | Button("button", "Button"), 152 | Submit( 153 | "submit", 154 | "Submit", 155 | ), 156 | Reset("cancel", "Cancel"), 157 | ) 158 | assert parse_form(form) == parse_expected("helper/buttons.html") 159 | 160 | form.helper.layout = Layout( 161 | Submit( 162 | "submit", 163 | "Submit", 164 | css_class="bg-transparent hover:bg-blue-500 text-blue-700 font-semibold hover:text-white py-2 px-4 border border-blue-500 hover:border-transparent rounded", 165 | ) 166 | ) 167 | assert parse_form(form) == parse_expected("helper/buttons_with_css.html") 168 | 169 | def test_div(self): 170 | form = SampleForm() 171 | form.helper = FormHelper() 172 | form.helper.form_tag = False 173 | form.helper.layout = Layout(Div("email", "name", css_class="test class")) 174 | if django.VERSION < (5, 0): 175 | expected = "helper/div_lt50.html" 176 | else: 177 | expected = "helper/div.html" 178 | assert parse_form(form) == parse_expected(expected) 179 | 180 | def test_HTML(self): 181 | form = CharFieldForm() 182 | form.helper = FormHelper() 183 | form.helper.form_tag = False 184 | form.helper.layout = Layout(HTML('Test HTML Layout')) 185 | html = render_crispy_form(form) 186 | expected_html = 'Test HTML Layout' 187 | self.assertHTMLEqual(html, expected_html) 188 | 189 | def test_hidden(self): 190 | form = CharFieldForm() 191 | form.helper = FormHelper() 192 | form.helper.form_tag = False 193 | form.helper.layout = Layout(Hidden("name", "value")) 194 | html = render_crispy_form(form) 195 | expected_html = '' 196 | self.assertHTMLEqual(html, expected_html) 197 | 198 | def test_fieldset(self): 199 | form = SampleForm() 200 | form.helper = FormHelper() 201 | form.helper.form_tag = False 202 | form.helper.layout = Layout( 203 | Fieldset( 204 | "Text for the legend", 205 | "is_company", 206 | "email", 207 | ) 208 | ) 209 | if django.VERSION < (5, 0): 210 | expected = "helper/fieldset_lt50.html" 211 | else: 212 | expected = "helper/fieldset.html" 213 | assert parse_form(form) == parse_expected(expected) 214 | 215 | def test_non_form_errors(self): 216 | form = SampleForm(data={}) 217 | form.helper = FormHelper() 218 | form.helper.form_tag = False 219 | form.helper.form_error_title = "Form Error Title" 220 | form.helper.layout = Layout("password1", "password2") 221 | if django.VERSION < (5, 0): 222 | expected = "helper/non_form_errors_lt50.html" 223 | else: 224 | expected = "helper/non_form_errors.html" 225 | assert parse_form(form) == parse_expected(expected) 226 | 227 | def test_select(self): 228 | form = SelectForm(data={"tos_accepted": "accepted"}) 229 | form.helper = FormHelper() 230 | form.helper.form_tag = False 231 | parse_form(form) == parse_expected("helper/select.html") 232 | 233 | def test_field_with_buttons(self): 234 | form = SampleForm() 235 | form.helper = FormHelper() 236 | form.helper.form_tag = False 237 | form.helper.layout = Layout( 238 | FieldWithButtons("first_name", Button("Go!", "go")), 239 | ) 240 | assert parse_form(form) == parse_expected("helper/field_with_buttons.html") 241 | 242 | def test_inline_field(self): 243 | form = SampleForm() 244 | form.helper = FormHelper() 245 | form.helper.form_tag = False 246 | form.helper.layout = Layout( 247 | InlineField("first_name"), 248 | InlineField("is_company"), 249 | ) 250 | assert parse_form(form) == parse_expected("helper/inline_field.html") 251 | -------------------------------------------------------------------------------- /tests/test_prepended_appended.py: -------------------------------------------------------------------------------- 1 | import django 2 | from django.template import Template 3 | from django.test import SimpleTestCase 4 | 5 | from crispy_forms.bootstrap import AppendedText, PrependedAppendedText, PrependedText 6 | from crispy_forms.helper import FormHelper 7 | from crispy_forms.layout import Layout 8 | 9 | from .forms import CharFieldForm, SampleForm 10 | from .utils import parse_expected, parse_form 11 | 12 | template = Template( 13 | """ 14 | {% load crispy_forms_tags %} 15 | {% crispy form %} 16 | """ 17 | ) 18 | 19 | 20 | class CrispyHelperTests(SimpleTestCase): 21 | maxDiff = None 22 | 23 | def test_prepended_text(self): 24 | form = SampleForm() 25 | form.helper = FormHelper() 26 | form.helper.form_tag = False 27 | form.helper.layout = Layout(PrependedText("first_name", "@")) 28 | assert parse_form(form) == parse_expected("prepended/prepended_text.html") 29 | 30 | def test_prepended_long_text(self): 31 | form = SampleForm() 32 | form.helper = FormHelper() 33 | form.helper.form_tag = False 34 | form.helper.layout = Layout(PrependedText("first_name", "http://www.")) 35 | assert parse_form(form) == parse_expected("prepended/prepended_long_text.html") 36 | 37 | def test_appended_text(self): 38 | form = SampleForm() 39 | form.helper = FormHelper() 40 | form.helper.form_tag = False 41 | form.helper.layout = Layout(AppendedText("first_name", ".com")) 42 | assert parse_form(form) == parse_expected("prepended/appended_text.html") 43 | 44 | def test_prepended_and_appended_text(self): 45 | form = SampleForm() 46 | form.helper = FormHelper() 47 | form.helper.form_tag = False 48 | form.helper.layout = Layout(PrependedAppendedText("first_name", "www.", ".com")) 49 | assert parse_form(form) == parse_expected("prepended/prepended_appended_text.html") 50 | 51 | def test_prepended_text_no_label(self): 52 | form = SampleForm() 53 | form.helper = FormHelper() 54 | form.helper.form_tag = False 55 | form.helper.form_show_labels = False 56 | form.helper.layout = Layout(PrependedText("first_name", "@")) 57 | assert parse_form(form) == parse_expected("prepended/prepended_no_label.html") 58 | 59 | def test_prepended_with_help(self): 60 | form = SampleForm() 61 | form.helper = FormHelper() 62 | form.helper.form_tag = False 63 | form.helper.form_show_labels = False 64 | form.helper.layout = Layout(PrependedText("email", "@")) 65 | if django.VERSION < (5, 0): 66 | expected = "prepended/prepended_help_lt50.html" 67 | else: 68 | expected = "prepended/prepended_help.html" 69 | assert parse_form(form) == parse_expected(expected) 70 | 71 | def test_prepended_with_errors(self): 72 | form = CharFieldForm(data={"name": ""}) 73 | form.helper = FormHelper() 74 | form.helper.form_tag = False 75 | form.helper.form_show_labels = False 76 | form.helper.layout = Layout(PrependedText("name", "@")) 77 | if django.VERSION < (5, 0): 78 | expected = "prepended/prepended_errors_lt50.html" 79 | else: 80 | expected = "prepended/prepended_errors.html" 81 | assert parse_form(form) == parse_expected(expected) 82 | 83 | def test_appended_with_errors(self): 84 | form = CharFieldForm(data={"name": ""}) 85 | form.helper = FormHelper() 86 | form.helper.form_tag = False 87 | form.helper.form_show_labels = False 88 | form.helper.layout = Layout(AppendedText("name", "@")) 89 | if django.VERSION < (5, 0): 90 | expected = "prepended/appended_errors_lt50.html" 91 | else: 92 | expected = "prepended/appended_errors.html" 93 | assert parse_form(form) == parse_expected(expected) 94 | 95 | def test_prepended_and_appended_with_errors(self): 96 | form = CharFieldForm(data={"name": ""}) 97 | form.helper = FormHelper() 98 | form.helper.form_tag = False 99 | form.helper.form_show_labels = False 100 | form.helper.layout = Layout(PrependedAppendedText("name", "@", ".com")) 101 | if django.VERSION < (5, 0): 102 | expected = "prepended/prepended_appended_errors_lt50.html" 103 | else: 104 | expected = "prepended/prepended_appended_errors.html" 105 | assert parse_form(form) == parse_expected(expected) 106 | -------------------------------------------------------------------------------- /tests/test_settings.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | BASE_DIR = os.path.dirname(__file__) 4 | 5 | INSTALLED_APPS = ( 6 | "django.contrib.auth", 7 | "django.contrib.sessions", 8 | "django.contrib.contenttypes", 9 | "django.contrib.admin", 10 | "crispy_forms", 11 | "crispy_tailwind", 12 | ) 13 | 14 | DATABASES = {"default": {"ENGINE": "django.db.backends.sqlite3", "NAME": ":memory:"}} 15 | 16 | MIDDLEWARE_CLASSES = ( 17 | "django.middleware.common.CommonMiddleware", 18 | "django.middleware.csrf.CsrfViewMiddleware", 19 | ) 20 | 21 | ROOT_URLCONF = "tests.urls" 22 | CRISPY_CLASS_CONVERTERS = {"textinput": "textinput textInput inputtext"} 23 | SECRET_KEY = "secretkey" 24 | SITE_ROOT = os.path.dirname(os.path.abspath(__file__)) 25 | 26 | 27 | TEMPLATE_DEBUG = True 28 | TEMPLATE_DIRS = (os.path.join(BASE_DIR, "templates"),) 29 | 30 | TEMPLATES = [ 31 | { 32 | "BACKEND": "django.template.backends.django.DjangoTemplates", 33 | "DIRS": TEMPLATE_DIRS, 34 | "APP_DIRS": True, 35 | "OPTIONS": { 36 | "context_processors": [ 37 | # Insert your TEMPLATE_CONTEXT_PROCESSORS here or use this 38 | # list if you haven't customized them: 39 | "django.contrib.auth.context_processors.auth", 40 | "django.template.context_processors.debug", 41 | "django.template.context_processors.i18n", 42 | "django.template.context_processors.media", 43 | "django.template.context_processors.static", 44 | "django.template.context_processors.tz", 45 | "django.contrib.messages.context_processors.messages", 46 | ], 47 | "debug": TEMPLATE_DEBUG, 48 | }, 49 | }, 50 | ] 51 | 52 | CRISPY_ALLOWED_TEMPLATE_PACKS = "tailwind" 53 | 54 | USE_TZ = True 55 | 56 | CRISPY_TEMPLATE_PACK = "tailwind" 57 | -------------------------------------------------------------------------------- /tests/test_table_inline_formset.py: -------------------------------------------------------------------------------- 1 | import django 2 | from django.forms import formset_factory 3 | from django.test import SimpleTestCase 4 | 5 | from crispy_forms.helper import FormHelper 6 | from crispy_tailwind.layout import Submit 7 | 8 | from .forms import SampleForm, ShortCharFieldForm 9 | from .utils import parse_expected, parse_form 10 | 11 | 12 | class CrispyHelperTests(SimpleTestCase): 13 | maxDiff = None 14 | 15 | def test_table_inline_formset(self): 16 | SampleFormSet = formset_factory(SampleForm, extra=2) 17 | formset = SampleFormSet() 18 | formset.helper = FormHelper() 19 | formset.helper.form_tag = False 20 | formset.helper.add_input(Submit("submit", "submit")) 21 | formset.helper.template = "tailwind/table_inline_formset.html" 22 | if django.VERSION < (5, 0): 23 | expected = "table_inline_formset/table_inline_formset_lt50.html" 24 | else: 25 | expected = "table_inline_formset/table_inline_formset.html" 26 | assert parse_form(formset) == parse_expected(expected) 27 | 28 | def test_failing_table_inline_formset(self): 29 | SampleFormSet = formset_factory(ShortCharFieldForm, extra=1, max_num=2, validate_max=True) 30 | data = { 31 | "name-0-name": "test", 32 | "name-INITIAL_FORMS": "0", 33 | "name-MIN_NUM_FORMS": "0", 34 | "name-MAX_NUM_FORMS": "0", 35 | "name-TOTAL_FORMS": "3", 36 | } 37 | 38 | formset = SampleFormSet(data=data, prefix="name") 39 | formset.helper = FormHelper() 40 | formset.helper.add_input(Submit("submit", "submit")) 41 | formset.helper.template = "tailwind/table_inline_formset.html" 42 | if django.VERSION < (5, 0): 43 | expected = "table_inline_formset/table_inline_formset_failing_lt50.html" 44 | else: 45 | expected = "table_inline_formset/table_inline_formset_failing.html" 46 | assert parse_form(formset) == parse_expected(expected) 47 | -------------------------------------------------------------------------------- /tests/test_tailwind.py: -------------------------------------------------------------------------------- 1 | from django.template import Context, Template 2 | 3 | from crispy_forms.helper import FormHelper 4 | from crispy_forms.layout import Layout 5 | from crispy_forms.utils import render_crispy_form 6 | from crispy_tailwind.tailwind import CSSContainer 7 | 8 | from .forms import CustomTextWidgetForm, SampleForm 9 | 10 | individual_inputs = {"text": "text", "radioselect": "radio"} 11 | 12 | base_standalone = {"base": "base"} 13 | 14 | combined = {"base": "base", "text": "text", "radioselect": "radio"} 15 | 16 | 17 | def test_individual_input(): 18 | container = CSSContainer(individual_inputs) 19 | assert container.text == "text" 20 | assert container.radioselect == "radio" 21 | assert container.checkbox == "" 22 | 23 | 24 | def test_base_input(): 25 | container = CSSContainer(base_standalone) 26 | for item in container.__dict__.values(): 27 | assert item == "base" 28 | 29 | 30 | def test_base_and_individual(): 31 | container = CSSContainer(combined) 32 | assert "base" in container.text 33 | assert "text" in container.text 34 | assert "base" in container.radioselect 35 | assert "radio" in container.radioselect 36 | 37 | 38 | def test_add_remove_extra_class(): 39 | container = CSSContainer(base_standalone) 40 | container += individual_inputs 41 | assert "text" in container.text 42 | container -= individual_inputs 43 | assert "text" not in container.text 44 | 45 | 46 | def test_form(): 47 | form_helper = FormHelper() 48 | form_helper.css_container = CSSContainer(base_standalone) 49 | form_helper.layout = Layout("first_name") 50 | 51 | template = Template( 52 | """ 53 | {% load crispy_forms_tags %} 54 | {% crispy form form_helper %} 55 | """ 56 | ) 57 | 58 | context = Context({"form": SampleForm(), "form_helper": form_helper}) 59 | html = template.render(context) 60 | assert "base" in html 61 | 62 | 63 | def test_custom_widget(): 64 | form = CustomTextWidgetForm() 65 | form.helper = FormHelper() 66 | html = render_crispy_form(form) 67 | assert '' in html 68 | assert ( 69 | '' in html 70 | ) 71 | -------------------------------------------------------------------------------- /tests/urls.py: -------------------------------------------------------------------------------- 1 | from django.urls import path 2 | from django.views.generic import View 3 | 4 | urlpatterns = [ 5 | path("simple/action/", View.as_view(), name="simpleAction"), 6 | ] 7 | -------------------------------------------------------------------------------- /tests/utils.py: -------------------------------------------------------------------------------- 1 | import os 2 | from pathlib import Path 3 | 4 | from django.test.html import Element, parse_html 5 | 6 | from crispy_forms.utils import render_crispy_form 7 | 8 | TEST_DIR = os.path.dirname(os.path.abspath(__file__)) 9 | 10 | 11 | def contains_partial(haystack, needle): 12 | """Search for a html element with at least the corresponding elements 13 | (other elements may be present in the matched element from the haystack) 14 | """ 15 | if not isinstance(haystack, Element): 16 | haystack = parse_html(haystack) 17 | if not isinstance(needle, Element): 18 | needle = parse_html(needle) 19 | 20 | if needle.name == haystack.name and set(needle.attributes).issubset(haystack.attributes): 21 | return True 22 | return any(contains_partial(child, needle) for child in haystack.children if isinstance(child, Element)) 23 | 24 | 25 | def parse_expected(expected_file): 26 | test_file = Path(TEST_DIR) / "results" / expected_file 27 | with open(test_file) as f: 28 | return parse_html(f.read()) 29 | 30 | 31 | def parse_form(form): 32 | html = render_crispy_form(form) 33 | return parse_html(html) 34 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = 3 | {py38,py39,py310,py311}-django{42}-crispy{2,-latest}, 4 | {py310,py311,py312}-django{50,51}-crispy{2,-latest}, 5 | lint 6 | 7 | [testenv] 8 | package = wheel 9 | wheel_build_env = .pkg 10 | deps = 11 | django42: django>=4.2a1,<5.0 12 | django50: django>=5.0a1,<5.1 13 | django51: django>=5.1a1,<5.2 14 | crispy2: django-crispy-forms>=2.0 15 | crispy-latest: https://github.com/django-crispy-forms/django-crispy-forms/archive/main.tar.gz 16 | extras = test 17 | commands = python -W error::DeprecationWarning -W error::PendingDeprecationWarning -m pytest {posargs} 18 | 19 | [testenv:lint] 20 | skip_install = true 21 | commands = 22 | black . --check 23 | isort . --check --dif 24 | flake8 crispy_tailwind tests 25 | deps = 26 | black 27 | flake8 28 | isort 29 | 30 | [flake8] 31 | max-line-length = 88 32 | --------------------------------------------------------------------------------