├── tests ├── __init__.py ├── testapp │ ├── __init__.py │ ├── apps.py │ ├── models.py │ ├── urls.py │ └── forms.py ├── templates │ └── test.html ├── conftest.py ├── utils.py ├── test_layout.py ├── test_tags.py └── test_form_helper.py ├── src └── django_formify │ ├── __init__.py │ ├── tailwind │ ├── __init__.py │ ├── layout.py │ └── formify_helper.py │ ├── templates │ └── formify │ │ └── tailwind │ │ ├── form_tag.html │ │ ├── attrs.html │ │ ├── select_option.html │ │ ├── components │ │ ├── div.html │ │ ├── button.html │ │ └── fieldset.html │ │ ├── errors.html │ │ ├── errors_formset.html │ │ ├── common_field.html │ │ ├── file_input.html │ │ ├── checkbox_input.html │ │ ├── select.html │ │ ├── radio_select.html │ │ └── checkbox_select_multiple.html │ ├── apps.py │ ├── components │ └── formify │ │ └── tw │ │ ├── field_help_text.html │ │ ├── field_error.html │ │ ├── field_label.html │ │ ├── field_error_help_text.html │ │ ├── field_error_help_text.py │ │ ├── field_wrapper.py │ │ ├── field_help_text.py │ │ ├── field_error.py │ │ ├── field_label.py │ │ └── field_wrapper.html │ ├── app_settings.py │ ├── utils.py │ └── templatetags │ └── formify.py ├── docs ├── requirements.txt ├── source │ ├── images │ │ ├── form_grid.jpg │ │ ├── simple_form.jpg │ │ ├── horizontal_form.jpg │ │ └── simple_form_errors.jpg │ ├── index.rst │ ├── install.md │ ├── tags.md │ ├── layout.md │ ├── conf.py │ ├── get_started.md │ ├── formify_helper.md │ └── component_design.md ├── Makefile └── make.bat ├── .github ├── assets │ ├── code.png │ └── formify-demo.jpg └── workflows │ ├── python-publish.yml │ └── runtests.yml ├── Makefile ├── requirements-dev.txt ├── .readthedocs.yaml ├── CHANGELOG.md ├── setup.cfg ├── .gitignore ├── tox.ini ├── .pre-commit-config.yaml ├── pyproject.toml └── README.md /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/testapp/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/django_formify/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/templates/test.html: -------------------------------------------------------------------------------- 1 | Hello -------------------------------------------------------------------------------- /src/django_formify/tailwind/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /docs/requirements.txt: -------------------------------------------------------------------------------- 1 | furo==2023.9.10 2 | myst-parser 3 | -------------------------------------------------------------------------------- /.github/assets/code.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rails-inspire-django/django-formify/HEAD/.github/assets/code.png -------------------------------------------------------------------------------- /.github/assets/formify-demo.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rails-inspire-django/django-formify/HEAD/.github/assets/formify-demo.jpg -------------------------------------------------------------------------------- /docs/source/images/form_grid.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rails-inspire-django/django-formify/HEAD/docs/source/images/form_grid.jpg -------------------------------------------------------------------------------- /docs/source/images/simple_form.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rails-inspire-django/django-formify/HEAD/docs/source/images/simple_form.jpg -------------------------------------------------------------------------------- /docs/source/images/horizontal_form.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rails-inspire-django/django-formify/HEAD/docs/source/images/horizontal_form.jpg -------------------------------------------------------------------------------- /docs/source/images/simple_form_errors.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rails-inspire-django/django-formify/HEAD/docs/source/images/simple_form_errors.jpg -------------------------------------------------------------------------------- /src/django_formify/templates/formify/tailwind/form_tag.html: -------------------------------------------------------------------------------- 1 | {% load formify %} 2 | 3 |
4 | {{ form_content|safe }} 5 |
6 | -------------------------------------------------------------------------------- /tests/testapp/apps.py: -------------------------------------------------------------------------------- 1 | # Django 2 | from django.apps import AppConfig 3 | 4 | 5 | class TestAppConfig(AppConfig): 6 | name = "tests.testapp" 7 | verbose_name = "TestApp" 8 | -------------------------------------------------------------------------------- /src/django_formify/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class DjangoFormifyConfig(AppConfig): 5 | name = "django_formify" 6 | verbose_name = "Django Formify" 7 | -------------------------------------------------------------------------------- /tests/testapp/models.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | 3 | 4 | class CrispyTestModel(models.Model): 5 | email = models.CharField(max_length=20) 6 | password = models.CharField(max_length=20) 7 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | build: 2 | poetry build 3 | 4 | publish: 5 | poetry publish 6 | 7 | # poetry config repositories.testpypi https://test.pypi.org/legacy/ 8 | publish-test: 9 | poetry publish -r testpypi 10 | -------------------------------------------------------------------------------- /requirements-dev.txt: -------------------------------------------------------------------------------- 1 | pre-commit==2.9.2 2 | tox==4.11.3 3 | tox-gh-actions==3.1.3 4 | 5 | django==4.2 # for local tests 6 | typing_extensions 7 | pytest 8 | pytest-django 9 | pytest-xdist 10 | pytest-mock 11 | jinja2 12 | -------------------------------------------------------------------------------- /src/django_formify/templates/formify/tailwind/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 | -------------------------------------------------------------------------------- /src/django_formify/templates/formify/tailwind/select_option.html: -------------------------------------------------------------------------------- 1 | {% load l10n %} 2 | 3 | 4 | -------------------------------------------------------------------------------- /tests/testapp/urls.py: -------------------------------------------------------------------------------- 1 | # Django 2 | from django.http import HttpResponse 3 | from django.urls import path 4 | 5 | 6 | def index(request): 7 | return HttpResponse("OK") 8 | 9 | 10 | urlpatterns = [path("", index, name="index")] 11 | -------------------------------------------------------------------------------- /src/django_formify/templates/formify/tailwind/components/div.html: -------------------------------------------------------------------------------- 1 |
2 | {{ self.fields_html|safe }} 3 |
4 | -------------------------------------------------------------------------------- /src/django_formify/templates/formify/tailwind/components/button.html: -------------------------------------------------------------------------------- 1 | 9 | -------------------------------------------------------------------------------- /src/django_formify/components/formify/tw/field_help_text.html: -------------------------------------------------------------------------------- 1 | {% if self.should_render %} 2 |
3 | {{ self.field.help_text|safe }} 4 |
5 | {% endif %} 6 | -------------------------------------------------------------------------------- /.readthedocs.yaml: -------------------------------------------------------------------------------- 1 | version: 2 2 | 3 | build: 4 | os: ubuntu-22.04 5 | tools: 6 | python: "3.12" 7 | 8 | # Build documentation in the "docs/" directory with Sphinx 9 | sphinx: 10 | configuration: docs/source/conf.py 11 | 12 | python: 13 | install: 14 | - requirements: docs/requirements.txt 15 | -------------------------------------------------------------------------------- /src/django_formify/components/formify/tw/field_error.html: -------------------------------------------------------------------------------- 1 | {% if self.should_render %} 2 |
3 | {% for error in self.field.errors %} 4 |

{{ error }}

5 | {% endfor %} 6 |
7 | {% endif %} 8 | -------------------------------------------------------------------------------- /src/django_formify/components/formify/tw/field_label.html: -------------------------------------------------------------------------------- 1 | {% if self.should_render %} 2 | 6 | {% endif %} -------------------------------------------------------------------------------- /src/django_formify/templates/formify/tailwind/components/fieldset.html: -------------------------------------------------------------------------------- 1 |
4 | {% if self.legend %}{{ self.legend|safe }}{% endif %} 5 | {{ self.fields_html|safe }} 6 |
7 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## 0.0.8 4 | 5 | 1. Exclude `attrs` from outer context. 6 | 7 | ## 0.0.7 8 | 9 | 1. Add `form_tag` to render form tag 10 | 11 | ## 0.0.6 12 | 13 | 1. Update `checkbox`, `radio select`, `checkbox select multiple` styles, make css classes can be managed via formify helper. 14 | 15 | ## 0.0.5 16 | 17 | 1. Initial release 18 | -------------------------------------------------------------------------------- /src/django_formify/components/formify/tw/field_error_help_text.html: -------------------------------------------------------------------------------- 1 | {% load viewcomponent_tags %} 2 | 3 | {% component self.formify_helper.field_error_component field=self.field formify_helper=self.formify_helper %}{% endcomponent %} 4 | 5 | {% component self.formify_helper.field_help_text_component field=self.field formify_helper=self.formify_helper %}{% endcomponent %} 6 | -------------------------------------------------------------------------------- /docs/source/index.rst: -------------------------------------------------------------------------------- 1 | django-formify 2 | ===================== 3 | 4 | Django-Formify seamlessly integrates Tailwind CSS styles into your Django forms for a modern look. 5 | 6 | Topics 7 | ------ 8 | 9 | .. toctree:: 10 | :maxdepth: 2 11 | 12 | install.md 13 | get_started.md 14 | tags.md 15 | formify_helper.md 16 | layout.md 17 | component_design.md 18 | -------------------------------------------------------------------------------- /src/django_formify/templates/formify/tailwind/errors.html: -------------------------------------------------------------------------------- 1 | {% if form.non_field_errors and formify_helper.form_show_errors %} 2 |
3 |
4 | 7 |
8 |
9 | {% endif %} 10 | -------------------------------------------------------------------------------- /src/django_formify/templates/formify/tailwind/errors_formset.html: -------------------------------------------------------------------------------- 1 | {% if formset.non_form_errors and formify_helper.form_show_errors %} 2 |
3 |
4 | 7 |
8 |
9 | {% endif %} 10 | -------------------------------------------------------------------------------- /src/django_formify/components/formify/tw/field_error_help_text.py: -------------------------------------------------------------------------------- 1 | from django_viewcomponent import component 2 | 3 | 4 | @component.register("formify.tw.field_error_help_text") 5 | class FieldErrorHelpTextComponent(component.Component): 6 | def __init__(self, field, formify_helper, **kwargs): 7 | self.field = field 8 | self.formify_helper = formify_helper 9 | 10 | template_name = "formify/tw/field_error_help_text.html" 11 | -------------------------------------------------------------------------------- /src/django_formify/app_settings.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | 3 | 4 | class AppSettings: 5 | def __init__(self): 6 | self.settings = getattr(settings, "FORMIFY", {}) 7 | 8 | @property 9 | def FORMIFY_HELPER(self): 10 | return self.settings.get( 11 | "formify_helper", 12 | "django_formify.tailwind.formify_helper.FormifyHelper", 13 | ) 14 | 15 | 16 | app_settings = AppSettings() 17 | -------------------------------------------------------------------------------- /src/django_formify/components/formify/tw/field_wrapper.py: -------------------------------------------------------------------------------- 1 | from django_viewcomponent import component 2 | from django_viewcomponent.fields import RendersOneField 3 | 4 | 5 | @component.register("formify.tw.field_wrapper") 6 | class FieldWrapperComponent(component.Component): 7 | label = RendersOneField() 8 | input = RendersOneField() 9 | field_helper_text_and_errors = RendersOneField() 10 | 11 | template_name = "formify/tw/field_wrapper.html" 12 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [flake8] 2 | ignore = E203, E266, E501, W503, E231, E701, B950, B907 3 | max-line-length = 88 4 | max-complexity = 18 5 | select = B,C,E,F,W,T4,B9 6 | 7 | [isort] 8 | profile = black 9 | 10 | [mypy] 11 | python_version = 3.10 12 | check_untyped_defs = False 13 | ignore_missing_imports = True 14 | warn_unused_ignores = False 15 | warn_redundant_casts = False 16 | warn_unused_configs = False 17 | 18 | [mypy-*.tests.*] 19 | ignore_errors = True 20 | 21 | [mypy-*.migrations.*] 22 | ignore_errors = True 23 | -------------------------------------------------------------------------------- /src/django_formify/components/formify/tw/field_help_text.py: -------------------------------------------------------------------------------- 1 | from django_viewcomponent import component 2 | 3 | 4 | @component.register("formify.tw.field_help_text") 5 | class FieldHelpTextComponent(component.Component): 6 | def __init__(self, field, formify_helper, **kwargs): 7 | self.field = field 8 | self.formify_helper = formify_helper 9 | 10 | @property 11 | def should_render(self): 12 | return self.field.help_text 13 | 14 | template_name = "formify/tw/field_help_text.html" 15 | -------------------------------------------------------------------------------- /src/django_formify/components/formify/tw/field_error.py: -------------------------------------------------------------------------------- 1 | from django_viewcomponent import component 2 | 3 | 4 | @component.register("formify.tw.field_error") 5 | class FieldErrorComponent(component.Component): 6 | def __init__(self, field, formify_helper, **kwargs): 7 | self.field = field 8 | self.formify_helper = formify_helper 9 | 10 | @property 11 | def should_render(self): 12 | return self.field.errors and self.formify_helper.form_show_errors 13 | 14 | template_name = "formify/tw/field_error.html" 15 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | *.egg 3 | *.egg-info 4 | 5 | docs/build 6 | dist 7 | build 8 | 9 | *.lock 10 | 11 | *.sqlite3 12 | *.db 13 | 14 | *.DS_Store 15 | 16 | .cache 17 | __pycache__ 18 | .mypy_cache/ 19 | .pytest_cache/ 20 | .vscode/ 21 | .coverage 22 | docs/build 23 | 24 | node_modules/ 25 | 26 | *.bak 27 | 28 | logs 29 | *log 30 | npm-debug.log* 31 | 32 | # Translations 33 | # *.mo 34 | *.pot 35 | 36 | # Django media/static dirs 37 | media/ 38 | static/dist/ 39 | static/dev/ 40 | 41 | .ipython/ 42 | .env 43 | 44 | celerybeat.pid 45 | celerybeat-schedule 46 | 47 | # Common typos 48 | :w 49 | ' 50 | .tox 51 | 52 | /venv/ 53 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = 3 | py{38,39,310}-django32 4 | py{39,310}-django42 5 | py{310, py311}-django5 6 | 7 | [testenv] 8 | changedir=tests 9 | deps = 10 | django32: django>=3.2,<3.3 11 | django42: django>=3.3,<4.3 12 | django5: django>=5.0,<6.0 13 | typing_extensions 14 | pytest 15 | pytest-django 16 | pytest-xdist 17 | pytest-mock 18 | bs4 19 | jinja2 20 | usedevelop = True 21 | commands = 22 | pytest {posargs} 23 | setenv = 24 | PYTHONDONTWRITEBYTECODE=1 25 | 26 | [gh-actions] 27 | python = 28 | 3.8: py38 29 | 3.9: py39 30 | 3.10: py310 31 | 3.11: py311 32 | -------------------------------------------------------------------------------- /src/django_formify/templates/formify/tailwind/common_field.html: -------------------------------------------------------------------------------- 1 | {% load viewcomponent_tags %} 2 | 3 | {% component formify_helper.field_wrapper_component as field_component %} 4 | {% call field_component.label %} 5 | {% component formify_helper.label_component field=field formify_helper=formify_helper %}{% endcomponent %} 6 | {% endcall %} 7 | 8 | {% call field_component.input %} 9 | {{ field_html }} 10 | {% endcall %} 11 | 12 | {% call field_component.field_helper_text_and_errors %} 13 | {% component formify_helper.field_error_help_text_component field=field formify_helper=formify_helper %}{% endcomponent %} 14 | {% endcall %} 15 | 16 | {% endcomponent %} 17 | -------------------------------------------------------------------------------- /src/django_formify/templates/formify/tailwind/file_input.html: -------------------------------------------------------------------------------- 1 | {% load viewcomponent_tags %} 2 | 3 | {% component formify_helper.field_wrapper_component as field_component %} 4 | {% call field_component.label %} 5 | {% component formify_helper.label_component field=field formify_helper=formify_helper %}{% endcomponent %} 6 | {% endcall %} 7 | 8 | {% call field_component.input %} 9 | {{ field_html }} 10 | {% endcall %} 11 | 12 | {% call field_component.field_helper_text_and_errors %} 13 | {% component formify_helper.field_error_help_text_component field=field formify_helper=formify_helper %}{% endcomponent %} 14 | {% endcall %} 15 | 16 | {% endcomponent %} 17 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/pycqa/isort 3 | rev: 5.12.0 4 | hooks: 5 | - id: isort 6 | - repo: https://github.com/psf/black 7 | rev: 23.10.1 8 | hooks: 9 | - id: black 10 | - repo: https://github.com/PyCQA/flake8 11 | rev: 6.0.0 12 | hooks: 13 | - id: flake8 14 | additional_dependencies: 15 | - flake8-bugbear 16 | - flake8-comprehensions 17 | - flake8-no-pep420 18 | - flake8-print 19 | - flake8-tidy-imports 20 | - flake8-typing-imports 21 | - repo: https://github.com/pre-commit/mirrors-mypy 22 | rev: v1.6.1 23 | hooks: 24 | - id: mypy 25 | -------------------------------------------------------------------------------- /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 = source 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 | -------------------------------------------------------------------------------- /src/django_formify/components/formify/tw/field_label.py: -------------------------------------------------------------------------------- 1 | from django_viewcomponent import component 2 | 3 | 4 | @component.register("formify.tw.field_label") 5 | class FieldLabelComponent(component.Component): 6 | def __init__(self, field, formify_helper, **kwargs): 7 | self.field = field 8 | self.formify_helper = formify_helper 9 | 10 | @property 11 | def should_render(self): 12 | return self.field.label and self.formify_helper.form_show_labels 13 | 14 | @property 15 | def asterisk_if_required(self): 16 | if self.field.field.required: 17 | return '*' 18 | else: 19 | return "" 20 | 21 | template_name = "formify/tw/field_label.html" 22 | -------------------------------------------------------------------------------- /src/django_formify/components/formify/tw/field_wrapper.html: -------------------------------------------------------------------------------- 1 |
2 | 3 | {% if self.label.filled %} 4 | {% if formify_helper.label_container_class %} 5 |
6 | {{ self.label.value }} 7 |
8 | {% else %} 9 | {{ self.label.value }} 10 | {% endif %} 11 | {% endif %} 12 | 13 | {% if formify_helper.field_container_class %} 14 |
15 | {{ self.input.value }} 16 | {{ self.field_helper_text_and_errors.value }} 17 |
18 | {% else %} 19 | {{ self.input.value }} 20 | {{ self.field_helper_text_and_errors.value }} 21 | {% endif %} 22 | 23 |
24 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "django-formify" 3 | version = "0.0.8" 4 | description = "Django-Formify seamlessly integrates Tailwind CSS styles into your Django forms for a modern look." 5 | authors = ["Michael Yin "] 6 | license = "MIT" 7 | homepage = "https://github.com/rails-inspire-django/django-formify" 8 | readme = "README.md" 9 | packages = [{ include = "django_formify", from = "src" }] 10 | 11 | [tool.poetry.urls] 12 | Changelog = "https://github.com/rails-inspire-django/django-formify/releases" 13 | 14 | [tool.poetry.dependencies] 15 | python = ">=3.8" 16 | django = ">=3.0" 17 | django-viewcomponent = ">=1.0.8" 18 | 19 | [tool.poetry.dev-dependencies] 20 | 21 | [build-system] 22 | requires = ["setuptools", "poetry_core>=1.0"] 23 | build-backend = "poetry.core.masonry.api" 24 | -------------------------------------------------------------------------------- /src/django_formify/templates/formify/tailwind/checkbox_input.html: -------------------------------------------------------------------------------- 1 | {% load viewcomponent_tags %} 2 | 3 | {% component formify_helper.field_wrapper_component as field_component %} 4 | {% call field_component.label %} 5 | {% endcall %} 6 | 7 | {% call field_component.input %} 8 | 13 | {% endcall %} 14 | 15 | {% call field_component.field_helper_text_and_errors %} 16 | {% component formify_helper.field_error_help_text_component field=field formify_helper=formify_helper %}{% endcomponent %} 17 | {% endcall %} 18 | 19 | {% endcomponent %} 20 | -------------------------------------------------------------------------------- /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=source 11 | set BUILDDIR=build 12 | 13 | %SPHINXBUILD% >NUL 2>NUL 14 | if errorlevel 9009 ( 15 | echo. 16 | echo.The 'sphinx-build' command was not found. Make sure you have Sphinx 17 | echo.installed, then set the SPHINXBUILD environment variable to point 18 | echo.to the full path of the 'sphinx-build' executable. Alternatively you 19 | echo.may add the Sphinx directory to PATH. 20 | echo. 21 | echo.If you don't have Sphinx installed, grab it from 22 | echo.https://www.sphinx-doc.org/ 23 | exit /b 1 24 | ) 25 | 26 | if "%1" == "" goto help 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 | -------------------------------------------------------------------------------- /.github/workflows/python-publish.yml: -------------------------------------------------------------------------------- 1 | # This workflow will upload a Python Package using Twine when a release is created 2 | # For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-python#publishing-to-package-registries 3 | 4 | # This workflow uses actions that are not certified by GitHub. 5 | # They are provided by a third-party and are governed by 6 | # separate terms of service, privacy policy, and support 7 | # documentation. 8 | 9 | name: Upload Python Package 10 | 11 | on: 12 | release: 13 | types: [published] 14 | 15 | permissions: 16 | contents: read 17 | 18 | jobs: 19 | deploy: 20 | 21 | runs-on: ubuntu-latest 22 | 23 | steps: 24 | - uses: actions/checkout@v4 25 | - name: Set up Python 26 | uses: actions/setup-python@v3 27 | with: 28 | python-version: '3.10' 29 | - name: Install dependencies 30 | run: | 31 | python -m pip install poetry 32 | - name: Build package 33 | run: poetry build 34 | - name: Publish package 35 | uses: pypa/gh-action-pypi-publish@release/v1 36 | with: 37 | password: ${{ secrets.PYPI_API_TOKEN }} 38 | -------------------------------------------------------------------------------- /docs/source/install.md: -------------------------------------------------------------------------------- 1 | # Installation 2 | 3 | ```shell 4 | $ pip install django-formify 5 | ``` 6 | 7 | Then add the app into `INSTALLED_APPS` in settings.py 8 | 9 | ```python 10 | INSTALLED_APPS = [ 11 | ..., 12 | 'django_viewcomponent', # new 13 | 'django_formify', # new 14 | ] 15 | ``` 16 | 17 | ```{note} 18 | `django_formify` contains some components build using `django_viewcomponent`, to make it work, please also update `TEMPLATES` by following the instruction below. 19 | ``` 20 | 21 | Modify `TEMPLATES` section of settings.py as follows: 22 | 23 | 1. Remove `'APP_DIRS': True,` 24 | 2. add `loaders` to `OPTIONS` list and set it to following value: 25 | 26 | ```python 27 | TEMPLATES = [ 28 | { 29 | ..., 30 | 'OPTIONS': { 31 | 'context_processors': [ 32 | ... 33 | ], 34 | 'loaders':[( 35 | 'django.template.loaders.cached.Loader', [ 36 | 'django.template.loaders.filesystem.Loader', 37 | 'django.template.loaders.app_directories.Loader', 38 | 'django_viewcomponent.loaders.ComponentLoader', 39 | ] 40 | )], 41 | }, 42 | }, 43 | ] 44 | ``` 45 | -------------------------------------------------------------------------------- /.github/workflows/runtests.yml: -------------------------------------------------------------------------------- 1 | name: Runs tests 2 | 3 | on: 4 | pull_request: 5 | push: 6 | branches: 7 | - main 8 | 9 | jobs: 10 | 11 | code-style-check: 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/checkout@v4 15 | - name: Set up Python 16 | uses: actions/setup-python@v5 17 | with: 18 | python-version: '3.10' 19 | cache: pip 20 | - name: Install dependencies 21 | run: | 22 | pip install -r requirements-dev.txt 23 | - name: Check code style 24 | run: | 25 | pre-commit run --all-files 26 | 27 | runtests: 28 | runs-on: ubuntu-latest 29 | env: 30 | CHANNELS_REDIS: redis://localhost:6379/0 31 | strategy: 32 | matrix: 33 | python-version: ['3.8', '3.9', '3.10' ] 34 | services: 35 | redis: 36 | image: redis 37 | ports: 38 | - 6379:6379 39 | steps: 40 | - uses: actions/checkout@v4 41 | - name: Set up Python ${{ matrix.python-version }} 42 | uses: actions/setup-python@v5 43 | with: 44 | python-version: ${{ matrix.python-version }} 45 | - name: Install dependencies 46 | run: | 47 | pip install -r requirements-dev.txt 48 | - name: Run tests 49 | run: | 50 | tox 51 | -------------------------------------------------------------------------------- /src/django_formify/templates/formify/tailwind/select.html: -------------------------------------------------------------------------------- 1 | {% load l10n formify viewcomponent_tags %} 2 | 3 | {% component formify_helper.field_wrapper_component as field_component %} 4 | {% call field_component.label %} 5 | {% component formify_helper.label_component field=field formify_helper=formify_helper %}{% endcomponent %} 6 | {% endcall %} 7 | 8 | {% call field_component.input %} 9 |
10 |
11 | 20 |
21 |
22 | {% endcall %} 23 | 24 | {% call field_component.field_helper_text_and_errors %} 25 | {% component formify_helper.field_error_help_text_component field=field formify_helper=formify_helper %}{% endcomponent %} 26 | {% endcall %} 27 | 28 | {% endcomponent %} 29 | -------------------------------------------------------------------------------- /src/django_formify/templates/formify/tailwind/radio_select.html: -------------------------------------------------------------------------------- 1 | {% load l10n formify %} 2 | {% load viewcomponent_tags %} 3 | 4 | {% component formify_helper.field_wrapper_component as field_component %} 5 | {% call field_component.label %} 6 | {% component formify_helper.label_component field=field formify_helper=formify_helper %}{% endcomponent %} 7 | {% endcall %} 8 | 9 | {% call field_component.input %} 10 |
11 | {% for choice in field.field.choices %} 12 |
13 | 17 |
18 | {% endfor %} 19 |
20 | {% endcall %} 21 | 22 | {% call field_component.field_helper_text_and_errors %} 23 | {% component formify_helper.field_error_help_text_component field=field formify_helper=formify_helper %}{% endcomponent %} 24 | {% endcall %} 25 | 26 | {% endcomponent %} 27 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 | 3 |

Django Formify

4 | 5 |

Django-Formify seamlessly integrates Tailwind CSS styles into your Django forms for a modern look.

6 | 7 |

Documentation  |  Demo site

8 | 9 |

Pypi version 10 | CI status

11 | 12 |
13 | 14 | ![Django Formify Code](.github/assets/code.png) 15 | 16 | ![Django Formify Demo](.github/assets/formify-demo.jpg) 17 | 18 | ## Documentation 19 | 20 | [Documentation](https://django-formify.readthedocs.io/) 21 | 22 | ## FAQ 23 | 24 | ### Django-Formify vs Crispy-Tailwind 25 | 26 | 1. Django-Formify is a fork of Crispy-Tailwind, the core logic is the same. 27 | 2. Django-Formify changed the way of rendering, to make it more flexible and easy to customize. 28 | 3. Django-Formify has components built using Django-ViewComponent, which makes the code more reusable and easy to maintain. Developers can also create their own components to fit their needs. 29 | 4. Django-Formify updated styles of some widgets such as file input to make them look better with Tailwind CSS. 30 | -------------------------------------------------------------------------------- /src/django_formify/templates/formify/tailwind/checkbox_select_multiple.html: -------------------------------------------------------------------------------- 1 | {% load formify l10n %} 2 | {% load viewcomponent_tags %} 3 | 4 | {% component formify_helper.field_wrapper_component as field_component %} 5 | {% call field_component.label %} 6 | {% component formify_helper.label_component field=field formify_helper=formify_helper %}{% endcomponent %} 7 | {% endcall %} 8 | 9 | {% call field_component.input %} 10 |
11 | {% for choice in field.field.choices %} 12 |
13 | 18 |
19 | {% endfor %} 20 |
21 | {% endcall %} 22 | 23 | {% call field_component.field_helper_text_and_errors %} 24 | {% component formify_helper.field_error_help_text_component field=field formify_helper=formify_helper %}{% endcomponent %} 25 | {% endcall %} 26 | 27 | {% endcomponent %} -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | import pathlib 2 | 3 | 4 | def pytest_configure(): 5 | from django.conf import settings 6 | 7 | settings.configure( 8 | SECRET_KEY="seekret", 9 | DATABASES={ 10 | "default": {"ENGINE": "django.db.backends.sqlite3", "NAME": "mem_db"}, 11 | }, 12 | TEMPLATES=[ 13 | { 14 | "BACKEND": "django.template.backends.django.DjangoTemplates", 15 | "DIRS": [pathlib.Path(__file__).parent.absolute() / "templates"], 16 | "OPTIONS": { 17 | "debug": False, 18 | "context_processors": [], 19 | "builtins": [], 20 | "libraries": {}, 21 | "loaders": [ 22 | ( 23 | "django.template.loaders.cached.Loader", 24 | [ 25 | "django.template.loaders.filesystem.Loader", 26 | "django.template.loaders.app_directories.Loader", 27 | "django_viewcomponent.loaders.ComponentLoader", 28 | ], 29 | ) 30 | ], 31 | }, 32 | } 33 | ], 34 | INSTALLED_APPS=[ 35 | "django.contrib.admin", 36 | "django.contrib.auth", 37 | "django.contrib.contenttypes", 38 | "django.contrib.sessions", 39 | "django.contrib.sites", 40 | "django_viewcomponent", 41 | "django_formify", 42 | "tests.testapp.apps.TestAppConfig", 43 | ], 44 | ROOT_URLCONF="tests.testapp.urls", 45 | ) 46 | -------------------------------------------------------------------------------- /docs/source/tags.md: -------------------------------------------------------------------------------- 1 | # Template Tags 2 | 3 | A typical case of using formify in a template is like this: 4 | 5 | ```html 6 | {% load formify %} 7 | 8 | {% form_tag form action=url %} 9 | 10 | {% csrf_token %} 11 | 12 | {% render_form form %} 13 | 14 | {% render_submit text='Submit' css_class="btn btn-primary" %} 15 | 16 | {% endform_tag %} 17 | ``` 18 | 19 | ## form_tag 20 | 21 | This tag is to render the form tag, it can help add some attributes to the form tag from the parameters from the template tag. 22 | 23 | ## render_form 24 | 25 | This tag can render `form` or `formset`. 26 | 27 | It will iterate and render all form fields automatically. 28 | 29 | ## render_submit 30 | 31 | This tag is to render the submit button. 32 | 33 | You can also add extra variables 34 | 35 | ```html 36 | {% render_submit text='Sign In' css_class="custom-css" name='action_primary' value='action_primary' %} 37 | ``` 38 | 39 | `render_submit` behavior can be customized by overriding `formify_helper.render_submit` method 40 | 41 | Please check [Formify Helper](./formify_helper.md) to learn more. 42 | 43 | To use formify_helper attached to the `form` instance, you can pass `form` to the `render_submit` like this: 44 | 45 | ```html 46 | {% render_submit form text='Hello' css_class="btn btn-primary" %} 47 | ``` 48 | 49 | If you have svg in submit button as indicator, you can use this approach to make your code DRY. 50 | 51 | ## render_field 52 | 53 | In some cases, if you want to render a specific field, you can use this tag. 54 | 55 | ```html 56 | {% render_field form.email %} 57 | ``` 58 | 59 | You can also override formify_helper variable like this: 60 | 61 | ```html 62 | {% render_field form.email form_show_labels=False %} 63 | ``` 64 | 65 | ## render_form_errors 66 | 67 | This tag can render form non-field errors. 68 | -------------------------------------------------------------------------------- /src/django_formify/utils.py: -------------------------------------------------------------------------------- 1 | import re 2 | 3 | from django.forms.utils import flatatt as _flatatt 4 | from django.utils.module_loading import import_string 5 | 6 | from django_formify.app_settings import app_settings 7 | 8 | 9 | def init_formify_helper_for_form(form): 10 | """ 11 | If form has formify_helper attribute, return it 12 | If not, return the global helper 13 | """ 14 | if hasattr(form, "formify_helper"): 15 | form.formify_helper.form = form 16 | return form.formify_helper 17 | else: 18 | helper_cls = import_string(app_settings.FORMIFY_HELPER) 19 | helper = helper_cls() 20 | if form: 21 | helper.form = form 22 | form.formify_helper = helper 23 | return helper 24 | 25 | 26 | def init_formify_helper_for_formset(formset): 27 | helper_cls = import_string(app_settings.FORMIFY_HELPER) 28 | helper = helper_cls() 29 | helper.formset = formset 30 | return helper 31 | 32 | 33 | def camel_to_snake(column_name): 34 | """ 35 | converts a string that is camelCase into snake_case 36 | Example: 37 | print camel_to_snake("javaLovesCamelCase") 38 | > java_loves_camel_case 39 | See Also: 40 | http://stackoverflow.com/questions/1175208/elegant-python-function-to-convert-camelcase-to-camel-case 41 | """ 42 | s1 = re.sub("(.)([A-Z][a-z]+)", r"\1_\2", column_name) 43 | return re.sub("([a-z0-9])([A-Z])", r"\1_\2", s1).lower() 44 | 45 | 46 | def flatatt(attrs): 47 | """ 48 | Convert a dictionary of attributes to a single string. 49 | 50 | Passed attributes are redirected to `django.forms.utils.flatatt()` 51 | with replaced "_" (underscores) by "-" (dashes) in their names. 52 | """ 53 | return _flatatt({k.replace("_", "-"): v for k, v in attrs.items()}) 54 | -------------------------------------------------------------------------------- /docs/source/layout.md: -------------------------------------------------------------------------------- 1 | # Layout 2 | 3 | ## Usage 4 | 5 | The layout is to help control the form layout in Python code. 6 | 7 | This feature is inspired from `django-crispy-forms` 8 | 9 | ```python 10 | from django_formify.tailwind.formify_helper import FormifyHelper 11 | from django_formify.tailwind.layout import Div, Field, Layout, Submit 12 | 13 | 14 | class ExampleForm(forms.Form): 15 | 16 | def __init__(self, *args, **kwargs): 17 | super().__init__(*args, **kwargs) 18 | self.formify_helper = FormifyHelper() 19 | self.formify_helper.layout = Layout( 20 | Div( 21 | Div(Field("email"), css_class="col-span-12 md:col-span-6"), 22 | Div(Field("password"), css_class="col-span-12 md:col-span-6"), 23 | Div(Field("address"), css_class="col-span-12"), 24 | Div(Field("address2"), css_class="col-span-12"), 25 | Div(Field("city"), css_class="col-span-12 md:col-span-6"), 26 | Div(Field("state"), css_class="col-span-12 md:col-span-4"), 27 | Div(Field("zip_code"), css_class="col-span-12 md:col-span-2"), 28 | Div(Field("check_box"), css_class="col-span-12"), 29 | Div(Submit(text="Sign in"), css_class="col-span-12"), 30 | css_class="grid grid-cols-12 gap-3", 31 | ), 32 | ) 33 | ``` 34 | 35 | ![](./images/form_grid.jpg) 36 | 37 | The `django_formify.tailwind.layout` current contains below classes for developers to use: 38 | 39 | - Layout 40 | - Div 41 | - HTML 42 | - Button 43 | - Submit 44 | - Reset 45 | - Field 46 | - Fieldset 47 | 48 | ## Horizontal Form 49 | 50 | Some people might have heard of a horizontal form, where the field labels and fields are arranged side by side 51 | 52 | To make it work, please use below code 53 | 54 | ```python 55 | form.formify_helper = FormifyHelper() 56 | form.formify_helper.field_wrapper_class = "md:flex md:items-center mb-6" 57 | form.formify_helper.label_container_class = "md:w-1/3" 58 | form.formify_helper.field_container_class = "md:w-2/3" 59 | ``` 60 | 61 | ![](./images/horizontal_form.jpg) 62 | -------------------------------------------------------------------------------- /docs/source/conf.py: -------------------------------------------------------------------------------- 1 | # Configuration file for the Sphinx documentation builder. 2 | # 3 | # For the full list of built-in configuration values, see the documentation: 4 | # https://www.sphinx-doc.org/en/master/usage/configuration.html 5 | 6 | # -- Project information ----------------------------------------------------- 7 | # https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information 8 | 9 | import datetime 10 | import sys 11 | import tomllib 12 | from pathlib import Path 13 | 14 | here = Path(__file__).parent.resolve() 15 | sys.path.insert(0, str(here / ".." / ".." / "src")) 16 | 17 | 18 | # -- Project information ----------------------------------------------------- 19 | project = "django-formify" 20 | copyright = f"{datetime.datetime.now().year}, Michael Yin" 21 | author = "Michael Yin" 22 | 23 | 24 | # The version info for the project you're documenting, acts as replacement for 25 | # |version| and |release|, also used in various other places throughout the 26 | # built documents. 27 | # 28 | # The short X.Y version. 29 | 30 | 31 | def _get_version() -> str: 32 | with (here / ".." / ".." / "pyproject.toml").open("rb") as fp: 33 | data = tomllib.load(fp) 34 | version: str = data["tool"]["poetry"]["version"] 35 | return version 36 | 37 | 38 | version = _get_version() 39 | # The full version, including alpha/beta/rc tags. 40 | release = version 41 | 42 | # -- General configuration --------------------------------------------------- 43 | # https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration 44 | 45 | extensions = ["sphinx.ext.autodoc", "myst_parser"] 46 | 47 | source_suffix = { 48 | ".rst": "restructuredtext", 49 | ".txt": "markdown", 50 | ".md": "markdown", 51 | } 52 | 53 | templates_path = ["_templates"] 54 | exclude_patterns = [] # type: ignore 55 | 56 | 57 | # -- Options for HTML output ------------------------------------------------- 58 | # https://www.sphinx-doc.org/en/master/usage/configuration.html#options-for-html-output 59 | 60 | # html_theme = 'alabaster' 61 | # html_static_path = ['_static'] 62 | html_theme = "furo" 63 | pygments_style = "sphinx" 64 | 65 | announcement_html = """ 66 |
67 | Have questions, feedback, or just want to chat? Reach out to me on 68 | 69 | Twitter / X 70 | 71 |
72 | """ 73 | 74 | html_theme_options = { 75 | "announcement": announcement_html, 76 | } 77 | -------------------------------------------------------------------------------- /docs/source/get_started.md: -------------------------------------------------------------------------------- 1 | # Get Started 2 | 3 | ## Simple Form Rendering 4 | 5 | Let's assume you already have Django forms. 6 | 7 | To render the form with good style in the template 8 | 9 | ```html 10 | {% load formify %} 11 | 12 | 13 | 14 | 15 | My Form 16 | 17 | 18 | 19 | 20 |
21 |
22 | 23 | {% csrf_token %} 24 | 25 | {% render_form form %} 26 | 27 | {% render_submit text='Submit' css_class="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded focus:outline-none focus:shadow-outline" %} 28 | 29 |
30 |
31 | 32 | 33 | 34 | ``` 35 | 36 | Notes: 37 | 38 | 1. We `{% load formify %}` at the top to use relevant tags. 39 | 2. We use `` to import Tailwind CSS and the `form` plugin, this is for testing purpose only. 40 | 3. `{% render_form form %}` is to iterate form fields and display all the fields with their labels and errors. 41 | 4. `{% render_submit %}` is to help render submit button with custom text and CSS class. 42 | 43 | ![](./images/simple_form.jpg) 44 | 45 | It will also help display form non-field errors and form field errors as well. 46 | 47 | ![](./images/simple_form_errors.jpg) 48 | 49 | ## Render Fields Manually 50 | 51 | If you want more control of the form layout, you can render fields manually. 52 | 53 | ```html 54 | {% load formify %} 55 | 56 | 57 | 58 | 59 | My Form 60 | 61 | 62 | 63 | 64 |
65 |
66 | 67 | {% csrf_token %} 68 | 69 |
70 |
71 | {% render_field form.name %} 72 |
73 |
74 | {% render_field form.email %} 75 |
76 |
77 | 78 | {% render_submit text='Submit' css_class="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded focus:outline-none focus:shadow-outline" %} 79 | 80 |
81 |
82 | 83 | 84 | 85 | ``` 86 | 87 | Notes: 88 | 89 | 1. You can use `{% render_field form.email %}` to render specific form field. 90 | -------------------------------------------------------------------------------- /docs/source/formify_helper.md: -------------------------------------------------------------------------------- 1 | # Formify Helper 2 | 3 | ## Workflow 4 | 5 | Unlike other form rendering packages, below template tags 6 | 7 | ```bash 8 | {% form_tag %} 9 | {% render_form %} 10 | {% render_submit %} 11 | {% render_field %} 12 | {% render_form_errors %} 13 | ``` 14 | 15 | Just accept arguments from the templates, and pass the arguments to the `formify_helper` to render them. 16 | 17 | So core logic is in the `formify_helper` 18 | 19 | The benefit of this approach is it can be easy to customize the logic. 20 | 21 | ## Global Formify Helper 22 | 23 | The default formify helper is `django_formify.tailwind.formify_helper.FormifyHelper` 24 | 25 | We can create a class to inherit and override some methods to change the rendering logic. 26 | 27 | To change the default global formify helper, just add below code to your Django settings 28 | 29 | ```python 30 | FORMIFY = { 31 | "formify_helper": "xxxx", 32 | } 33 | ``` 34 | 35 | Leveraging Python OOP, you can override some methods of the formify helper to customize the rendering behavior. 36 | 37 | ```bash 38 | {% form_tag %} -> formify_helper.render_form_tag 39 | {% render_form %} -> formify_helper.render_form 40 | {% render_submit %} -> formify_helper.render_submit 41 | {% render_field %} -> formify_helper.render_field 42 | {% render_form_errors %} -> formify_helper.render_form_errors 43 | ``` 44 | 45 | ## Field Dispatcher 46 | 47 | To make logic of rendering field more clean, there is a field dispatcher in the `render_field` method. 48 | 49 | For example, if a field is using `TextInput` widget, it will try to use below methods to render 50 | 51 | ``` 52 | text_input 53 | fallback 54 | ``` 55 | 56 | Notes: 57 | 58 | 1. If `text_input` method is not found in the `formify_helper` instance, `fallback` method will be used to render the field. 59 | 2. This can help developers to control rendering behavior of the specific widgets. 60 | 61 | If you have built some custom widgets, just add a method to the `formify_helper` and make the final result look well, this is much cleaner. 62 | 63 | ## Formify Helper Variables 64 | 65 | Formify Helper have some variables such as: 66 | 67 | ```python 68 | form_show_errors = True 69 | form_show_labels = True 70 | field_wrapper_class = "field-wrapper mb-3" 71 | ``` 72 | 73 | Developers can override or add more variables to change the behavior. 74 | 75 | In the final Django html, just access the variable using ``{{ formify_helper.form_show_errors }}`` 76 | 77 | For example, to control if rendering field label or not 78 | 79 | ```html 80 | {% if field.label and formify_helper.form_show_labels %} 81 | 82 | {% endif %} 83 | ``` 84 | 85 | ## Formify Helper in Form 86 | 87 | You can also create formify helper for the form to override the global formify helper. 88 | 89 | ```python 90 | from django_formify.tailwind.formify_helper import FormifyHelper 91 | 92 | class ExampleForm(forms.Form): 93 | 94 | def __init__(self, *args, **kwargs): 95 | super().__init__(*args, **kwargs) 96 | self.formify_helper = FormifyHelper() 97 | self.formify_helper.field_wrapper_class = "another-field-wrapper" 98 | ``` -------------------------------------------------------------------------------- /src/django_formify/tailwind/layout.py: -------------------------------------------------------------------------------- 1 | from django.template import Template 2 | from django_viewcomponent import component 3 | 4 | from django_formify.utils import flatatt 5 | 6 | 7 | class Layout(component.Component): 8 | def __init__(self, *fields): 9 | self.fields = list(fields) 10 | 11 | def render(self, context_data): 12 | context = self.prepare_context(context_data) 13 | fields_html = " ".join( 14 | [ 15 | child_component.render_from_parent_context(context) 16 | for child_component in self.fields 17 | ] 18 | ) 19 | return fields_html 20 | 21 | 22 | class Div(component.Component): 23 | template_name = "formify/tailwind/components/div.html" 24 | css_class = None 25 | 26 | def __init__(self, *fields, dom_id=None, css_class=None, template=None, **kwargs): 27 | self.fields = list(fields) 28 | if self.css_class and css_class: 29 | self.css_class += f" {css_class}" 30 | elif css_class: 31 | self.css_class = css_class 32 | self.dom_id = dom_id 33 | self.template_name = template or self.template_name 34 | self.flat_attrs = flatatt(kwargs) 35 | 36 | def get_context_data(self): 37 | context = super().get_context_data() 38 | self.fields_html = " ".join( 39 | [ 40 | child_component.render_from_parent_context(context) 41 | for child_component in self.fields 42 | ] 43 | ) 44 | return context 45 | 46 | 47 | class HTML(component.Component): 48 | """ 49 | It can contain pure HTML and it has access to the whole 50 | context of the page where the form is being rendered. 51 | 52 | Examples:: 53 | 54 | HTML("{% if saved %}Data saved{% endif %}") 55 | HTML('') 56 | """ 57 | 58 | def __init__(self, html, **kwargs): 59 | self.html = html 60 | 61 | def get_template(self) -> Template: 62 | return Template(self.html) 63 | 64 | 65 | class Button(component.Component): 66 | template_name = "formify/tailwind/components/button.html" 67 | default_css_class = "btn btn-primary" 68 | button_type = "button" 69 | 70 | def __init__(self, text=None, dom_id=None, css_class=None, template=None, **kwargs): 71 | self.text = text if text else "Button" 72 | self.dom_id = dom_id 73 | self.css_class = css_class or self.default_css_class 74 | self.template_name = template or self.template_name 75 | self.flat_attrs = flatatt(kwargs) 76 | 77 | def get_context_data(self): 78 | context = super().get_context_data() 79 | self.text_html = Template(str(self.text)).render(context) 80 | return context 81 | 82 | 83 | class Submit(Button): 84 | button_type = "submit" 85 | 86 | 87 | class Reset(Button): 88 | button_type = "reset" 89 | 90 | 91 | class Field(component.Component): 92 | def __init__(self, field_name): 93 | self.field_name = field_name 94 | 95 | def render(self, context_data): 96 | context = self.prepare_context(context_data) 97 | formify_helper = context.get("formify_helper") 98 | field = formify_helper.form[self.field_name] 99 | return formify_helper.render_field( 100 | field=field, context=context, create_new_context=True 101 | ) 102 | 103 | 104 | class Fieldset(component.Component): 105 | template_name = "formify/tailwind/components/fieldset.html" 106 | 107 | def __init__(self, legend, *fields, css_class=None, dom_id=None, **kwargs): 108 | self.fields = list(fields) 109 | self.legend = legend 110 | self.css_class = css_class 111 | self.dom_id = dom_id 112 | self.flat_attrs = flatatt(kwargs) 113 | 114 | def get_context_data(self): 115 | context = super().get_context_data() 116 | self.fields_html = " ".join( 117 | [ 118 | child_component.render_from_parent_context(context) 119 | for child_component in self.fields 120 | ] 121 | ) 122 | return context 123 | -------------------------------------------------------------------------------- /docs/source/component_design.md: -------------------------------------------------------------------------------- 1 | # Component Design 2 | 3 | ## Field Rendering 4 | 5 | In field rendering, Django-Formify use components to render the HTML: 6 | 7 | ``` 8 | Field Wrapper formify.tw.field_wrapper 9 | Label formify.tw.field_label 10 | Field 11 | Error And Help Text formify.tw.field_error_help_text 12 | Error formify.tw.field_error 13 | Help Text formify.tw.field_help_text 14 | ``` 15 | 16 | Notes: 17 | 18 | 1. Except `Field`, other parts are rendered using components, and each has its own logic. 19 | 2. During the rendering, components can read variable from the `formify_helper` to control the rendering behavior. 20 | 21 | For example: 22 | 23 | ```python 24 | form.formify_helper = FormifyHelper() 25 | form.formify_helper.field_wrapper_class = "md:flex md:items-center mb-6" 26 | form.formify_helper.label_container_class = "md:w-1/3" 27 | form.formify_helper.field_container_class = "md:w-2/3" 28 | ``` 29 | 30 | After we set the above class, `formify.tw.field_wrapper` will use the above class to render something like this: 31 | 32 | ```html 33 |
34 |
35 | 36 |
37 |
38 | 39 |
40 |
41 | ``` 42 | 43 | We just easily changed the form layout to horizontal form, in clean way. 44 | 45 | ## Customize 46 | 47 | All the field rending components are build using [django-viewcomponent](https://github.com/rails-inspire-django/django-viewcomponent), you can easily build your own components to fit your needs. 48 | 49 | For example, after you build your own `field wrapper` component, just override `field_wrapper_component` of the `formify_helper`, then it should work. 50 | 51 | ## Layout 52 | 53 | The components in `django_formify.tailwind.layout` are all also built using `django-viewcomponent`. 54 | 55 | You can also build custom components to fit your needs, for example, if you want to use `Accordion` to generate complex form, you can build `Accordion` and `AccordionSection` components using `django-viewcomponent`. 56 | 57 | And then you can use them like this: 58 | 59 | ```python 60 | class ExampleForm(forms.Form): 61 | 62 | def __init__(self, *args, **kwargs): 63 | super().__init__(*args, **kwargs) 64 | self.formify_helper = FormifyHelper() 65 | self.formify_helper.layout = Layout( 66 | Accordion( 67 | AccordionSection( 68 | ... 69 | ), 70 | AccordionSection( 71 | ... 72 | ), 73 | AccordionSection( 74 | ... 75 | ), 76 | dom_id="accordion-1" 77 | ), 78 | ) 79 | ``` 80 | 81 | What is more, after creating `Accordion` component, you can also use them in normal web pages as well (not just in form rendering), which is convenient. 82 | 83 | ## Django-ViewComponent 84 | 85 | `django-viewcomponen` provides solution for developer to build reusable components in Django, the biggest advantage in this case is that: **developers can use it to create components, which are used in both Django Templates and Python Code** 86 | 87 | In field rendering, we can use component in the django template: 88 | 89 | ```html 90 | {% load viewcomponent_tags %} 91 | 92 | {% component formify_helper.field_wrapper_component as field_component %} 93 | 94 | {% call field_component.label %} 95 | {% component formify_helper.label_component field=field formify_helper=formify_helper %}{% endcomponent %} 96 | {% endcall %} 97 | 98 | {% call field_component.input %} 99 | {{ field_html }} 100 | {% endcall %} 101 | 102 | {% call field_component.field_helper_text_and_errors %} 103 | {% component formify_helper.field_error_help_text_component field=field formify_helper=formify_helper %}{% endcomponent %} 104 | {% endcall %} 105 | 106 | {% endcomponent %} 107 | ``` 108 | 109 | In form layout, we can use component in the python code: 110 | 111 | ```python 112 | self.formify_helper.layout = Layout( 113 | Div( 114 | Div(Field("email"), css_class="col-span-12 md:col-span-6"), 115 | Div(Field("password"), css_class="col-span-12 md:col-span-6"), 116 | Div(Field("address"), css_class="col-span-12"), 117 | Div(Field("address2"), css_class="col-span-12"), 118 | Div(Field("city"), css_class="col-span-12 md:col-span-6"), 119 | Div(Field("state"), css_class="col-span-12 md:col-span-4"), 120 | Div(Field("zip_code"), css_class="col-span-12 md:col-span-2"), 121 | Div(Field("check_box"), css_class="col-span-12"), 122 | Div(Submit(text="Sign in"), css_class="col-span-12"), 123 | css_class="grid grid-cols-12 gap-3", 124 | ), 125 | ) 126 | ``` 127 | 128 | Since `django-viewcomponent` solve problem very well, `django-formify` use it instead of creating another component solution. 129 | -------------------------------------------------------------------------------- /tests/utils.py: -------------------------------------------------------------------------------- 1 | import re 2 | 3 | from bs4 import BeautifulSoup 4 | 5 | re_type = type(re.compile("")) 6 | 7 | 8 | def normalize_attributes(soup): 9 | """Normalize the order of attributes in the BeautifulSoup object.""" 10 | for tag in soup.find_all(True): # True matches all tags 11 | for attribute, value in tag.attrs.items(): 12 | if isinstance(value, list): 13 | # Sort the list of attribute values 14 | sorted_values = sorted(value) 15 | # Update the tag's attribute with the sorted values 16 | tag[attribute] = " ".join(sorted_values) 17 | else: 18 | # Ensure the attribute is not changed if it's not a list 19 | tag[attribute] = value 20 | return soup 21 | 22 | 23 | def assert_dom_equal(expected_html, actual_html): 24 | """Assert that two HTML strings are equal, ignoring differences in attribute order.""" 25 | expected_soup = BeautifulSoup(expected_html, "html.parser") 26 | actual_soup = BeautifulSoup(actual_html, "html.parser") 27 | 28 | # Normalize the attributes 29 | expected_soup = normalize_attributes(expected_soup) 30 | actual_soup = normalize_attributes(actual_soup) 31 | 32 | # Convert to prettified strings for comparison 33 | expected_str = expected_soup.prettify() 34 | actual_str = actual_soup.prettify() 35 | 36 | # Assert that the prettified strings are equal 37 | assert ( 38 | expected_str == actual_str 39 | ), f"Expected HTML:\n{expected_str}\n\nActual HTML:\n{actual_str}" 40 | 41 | 42 | def assert_select(content, selector, equality=True, message=None, **tests): 43 | doc = Page(content) 44 | return doc.assert_select(selector, equality=equality, message=message, **tests) 45 | 46 | 47 | class Page(object): 48 | """ 49 | https://github.com/aroberts/assert-select 50 | 51 | Represents an HTML page, probably rendered from a view, but could be 52 | sourced from anywhere 53 | """ 54 | 55 | def __init__(self, content=None, filename=None): 56 | if filename: 57 | content = open(filename) 58 | self.doc = BeautifulSoup(content, "html.parser") 59 | 60 | def __repr__(self): 61 | return self.doc.prettify(formatter="html") 62 | 63 | def css_select(self, selector): 64 | """ 65 | Takes a string as a CSS selector, and returns all the elements 66 | found by the selector. 67 | """ 68 | return self.doc.select(selector) 69 | 70 | def assert_select(self, selector, equality=True, message=None, **tests): 71 | """ 72 | Asserts that a css selector captures data from this Page, and 73 | that that data passes the test presented by the equality specifier. 74 | 75 | (from rails:) 76 | The test may be one of the following: 77 | * true - Assertion is true if at least one element selected. 78 | * false - Assertion is true if no element selected. 79 | * String/Regexp - Assertion is true if the text value of 80 | at least one element matches the string or regular expression. 81 | * Int - Assertion is true if exactly that number of 82 | elements are selected. 83 | * List of Int- Assertion is true if the number of selected 84 | elements is between the max and min of the list 85 | 86 | If no equality test specified, the assertion is true if at 87 | least one element selected. 88 | 89 | To perform more than one equality test, use the following keyword 90 | arguments: 91 | text - Narrow the selection to elements that have this text value (string or regexp). 92 | count - Assertion is true if the number of selected elements is equal to this value. 93 | minimum - Assertion is true if the number of selected elements is at least this value. 94 | maximum - Assertion is true if the number of selected elements is at most this value. 95 | """ 96 | 97 | # set up tests 98 | equality_type = type(equality) 99 | if equality_type == bool: 100 | if equality: 101 | tests["minimum"] = 1 102 | else: 103 | tests["count"] = 0 104 | elif equality_type == int: 105 | tests["count"] = equality 106 | elif equality_type in (str, re_type): 107 | tests["text"] = equality 108 | elif equality_type == list: 109 | tests["maximim"] = max(equality) 110 | tests["minimum"] = min(equality) 111 | else: 112 | raise TypeError("Couldn't understand equality: %s" % repr(equality)) 113 | 114 | if "count" in tests: 115 | tests["minimum"] = tests["maximum"] = tests["count"] 116 | else: 117 | tests["minimum"] = tests.get("minimum", 1) 118 | 119 | elements = self.css_select(selector) 120 | if "text" in tests: 121 | match_with = tests["text"] 122 | if type(match_with) == str: 123 | filtered_elements = [e for e in elements if match_with in e.string] 124 | else: 125 | filtered_elements = [e for e in elements if match_with.match(e.string)] 126 | 127 | else: 128 | filtered_elements = elements 129 | 130 | if not filtered_elements and elements: 131 | message = message or "%s expected, but was %s" % ( 132 | tests["text"], 133 | "".join([e.string for e in elements]), 134 | ) 135 | 136 | count_message = "%s elements expected, found %s" 137 | length = len(filtered_elements) 138 | count = tests.get("count", None) 139 | minimum = tests.get("minimum", None) 140 | maximum = tests.get("maximum", None) 141 | 142 | if count is not None: 143 | message = message or count_message % (count, length) 144 | assert count == length, message 145 | else: 146 | if minimum is not None: 147 | message = message or count_message % ("at least %s" % minimum, length) 148 | assert length >= minimum, message 149 | if maximum is not None: 150 | message = message or count_message % ("at most %s" % maximum, length) 151 | assert length <= maximum, message 152 | 153 | return filtered_elements 154 | -------------------------------------------------------------------------------- /tests/test_layout.py: -------------------------------------------------------------------------------- 1 | from django.template import Context, Template 2 | 3 | from django_formify.tailwind.formify_helper import FormifyHelper 4 | from django_formify.tailwind.layout import ( 5 | HTML, 6 | Button, 7 | Div, 8 | Field, 9 | Fieldset, 10 | Layout, 11 | Reset, 12 | Submit, 13 | ) 14 | from django_formify.utils import init_formify_helper_for_form 15 | from tests.testapp.forms import SampleForm 16 | 17 | from .utils import assert_select 18 | 19 | 20 | class TestBuiltinComponents: 21 | def test_html(self): 22 | html = HTML("{% if saved %}Data saved{% endif %}").render_from_parent_context( 23 | {"saved": True} 24 | ) 25 | assert "Data saved" in html 26 | 27 | # step_field and step0 not defined 28 | html = HTML( 29 | '' 30 | ).render_from_parent_context() 31 | assert_select(html, "input") 32 | 33 | def test_layout(self): 34 | html = Layout( 35 | Div( 36 | HTML("Hello {{ value_1 }}"), 37 | HTML("Hello {{ value_2 }}"), 38 | dom_id="main", 39 | ), 40 | ).render_from_parent_context({"value_1": "world"}) 41 | 42 | assert_select(html, "div#main") 43 | assert "Hello world" in html 44 | 45 | def test_div(self): 46 | html = Div( 47 | Div( 48 | HTML("Hello {{ value_1 }}"), 49 | HTML("Hello {{ value_2 }}"), 50 | css_class="wrapper", 51 | ), 52 | dom_id="main", 53 | ).render_from_parent_context({"value_1": "world"}) 54 | 55 | assert_select(html, "div#main") 56 | assert_select(html, "div.wrapper") 57 | assert "Hello world" in html 58 | 59 | def test_button(self): 60 | html = Div( 61 | Div( 62 | Button("{{ value_1 }}", css_class="btn btn-primary"), 63 | ), 64 | dom_id="main", 65 | ).render_from_parent_context({"value_1": "world"}) 66 | 67 | assert_select(html, "button.btn") 68 | assert_select(html, "button[type=button]") 69 | assert "world" in html 70 | 71 | # test custom attributes 72 | html = Button( 73 | "Hello", 74 | css_class="btn btn-primary", 75 | name="action_remove", 76 | value="action_remove", 77 | data_turbo_confirm="Are you sure?", 78 | ).render_from_parent_context() 79 | 80 | assert_select(html, "button.btn") 81 | assert_select(html, "button[type=button]") 82 | assert_select(html, "button[name=action_remove]") 83 | assert_select(html, "button[value=action_remove]") 84 | assert_select(html, "button[data-turbo-confirm]") 85 | 86 | def test_submit(self): 87 | html = Div( 88 | Div( 89 | Submit("{{ value_1 }}", css_class="btn btn-primary"), 90 | ), 91 | dom_id="main", 92 | ).render_from_parent_context({"value_1": "world"}) 93 | 94 | assert_select(html, "button.btn") 95 | assert_select(html, "button[type=submit]") 96 | assert "world" in html 97 | 98 | # test custom attributes 99 | html = Submit( 100 | "Hello", 101 | css_class="btn btn-primary", 102 | name="action_remove", 103 | value="action_remove", 104 | data_turbo_confirm="Are you sure?", 105 | ).render_from_parent_context() 106 | 107 | assert_select(html, "button.btn") 108 | assert_select(html, "button[type=submit]") 109 | assert_select(html, "button[name=action_remove]") 110 | assert_select(html, "button[value=action_remove]") 111 | assert_select(html, "button[data-turbo-confirm]") 112 | 113 | def test_reset(self): 114 | html = Div( 115 | Div( 116 | Reset("{{ value_1 }}", css_class="btn btn-primary"), 117 | ), 118 | dom_id="main", 119 | ).render_from_parent_context({"value_1": "world"}) 120 | 121 | assert_select(html, "button.btn") 122 | assert_select(html, "button[type=reset]") 123 | assert "world" in html 124 | 125 | # test custom attributes 126 | html = Reset( 127 | "Hello", 128 | css_class="btn btn-primary", 129 | name="action_remove", 130 | value="action_remove", 131 | data_turbo_confirm="Are you sure?", 132 | ).render_from_parent_context() 133 | 134 | assert_select(html, "button.btn") 135 | assert_select(html, "button[type=reset]") 136 | assert_select(html, "button[name=action_remove]") 137 | assert_select(html, "button[value=action_remove]") 138 | assert_select(html, "button[data-turbo-confirm]") 139 | 140 | def test_field(self): 141 | form = SampleForm() 142 | formify_helper = init_formify_helper_for_form(form) 143 | 144 | html = Div( 145 | Field("email"), 146 | Submit("Submit"), 147 | dom_id="main", 148 | ).render_from_parent_context({"formify_helper": formify_helper}) 149 | 150 | assert_select(html, "div#main") 151 | assert_select(html, "button[type=submit]") 152 | assert_select(html, "input[name=email]", 1) 153 | 154 | def test_fieldset(self): 155 | form = SampleForm() 156 | formify_helper = init_formify_helper_for_form(form) 157 | 158 | html = Div( 159 | Fieldset( 160 | "Basic Info", 161 | Field("first_name"), 162 | Field("last_name"), 163 | Field("email"), 164 | css_class="fieldset", 165 | ), 166 | Submit("Submit"), 167 | dom_id="main", 168 | ).render_from_parent_context({"formify_helper": formify_helper}) 169 | 170 | assert_select(html, "div#main") 171 | assert_select(html, "fieldset.fieldset") 172 | assert "Basic Info" in html 173 | 174 | assert_select(html, "input", 3) 175 | assert_select(html, "button[type=submit]") 176 | 177 | def test_form_helper(self): 178 | template = Template( 179 | """ 180 | {% load formify %} 181 | {% render_form test_form %} 182 | """ 183 | ) 184 | 185 | # now we render it, with errors 186 | form = SampleForm({"password1": "wargame", "password2": "god"}) 187 | form.formify_helper = FormifyHelper() 188 | form.formify_helper.layout = Layout( 189 | Fieldset( 190 | "Basic Info", 191 | Field("first_name"), 192 | Field("last_name"), 193 | Field("password1"), 194 | Field("password2"), 195 | css_class="fieldset", 196 | ), 197 | ) 198 | 199 | form.is_valid() 200 | c = Context({"test_form": form}) 201 | html = template.render(c) 202 | 203 | assert_select(html, "input", 4) 204 | assert "Basic Info" in html 205 | assert_select(html, ".form-non-field-errors") 206 | -------------------------------------------------------------------------------- /src/django_formify/templatetags/formify.py: -------------------------------------------------------------------------------- 1 | from django import template 2 | from django.forms.formsets import BaseFormSet 3 | from django.template.base import Node, NodeList 4 | from django.template.context import Context 5 | from django.template.exceptions import TemplateSyntaxError 6 | from django.template.library import parse_bits 7 | from django.utils.safestring import mark_safe 8 | 9 | from django_formify.utils import flatatt as utils_flatatt 10 | from django_formify.utils import ( 11 | init_formify_helper_for_form, 12 | init_formify_helper_for_formset, 13 | ) 14 | 15 | register = template.Library() 16 | 17 | 18 | @register.simple_tag(takes_context=True) 19 | def render_form(context, form_or_formset): 20 | if isinstance(form_or_formset, BaseFormSet): 21 | # formset 22 | formset = form_or_formset 23 | formify_helper = init_formify_helper_for_formset(formset) 24 | return formify_helper.render_formset(context) 25 | else: 26 | # form 27 | form = form_or_formset 28 | formify_helper = init_formify_helper_for_form(form) 29 | return formify_helper.render_form(context) 30 | 31 | 32 | @register.simple_tag(takes_context=True) 33 | def render_form_errors(context, form_or_formset): 34 | if isinstance(form_or_formset, BaseFormSet): 35 | # formset 36 | formset = form_or_formset 37 | formify_helper = init_formify_helper_for_formset(formset) 38 | return formify_helper.render_formset_errors(context) 39 | else: 40 | # form 41 | form = form_or_formset 42 | formify_helper = init_formify_helper_for_form(form) 43 | return formify_helper.render_form_errors(context) 44 | 45 | 46 | @register.simple_tag(takes_context=True) 47 | def render_field(context, field, **kwargs): 48 | form = field.form 49 | formify_helper = init_formify_helper_for_form(form) 50 | return formify_helper.render_field( 51 | context=context, 52 | field=field, 53 | **kwargs, 54 | ) 55 | 56 | 57 | @register.simple_tag(takes_context=True) 58 | def render_submit(context, form=None, **kwargs): 59 | formify_helper = init_formify_helper_for_form(form) 60 | return formify_helper.render_submit(context, **kwargs) 61 | 62 | 63 | @register.filter 64 | def flatatt(attrs): 65 | return mark_safe(utils_flatatt(attrs)) 66 | 67 | 68 | class FormTagNode(Node): 69 | def __init__( 70 | self, 71 | context_args, 72 | context_kwargs, 73 | nodelist: NodeList, 74 | ): 75 | self.context_args = context_args or [] 76 | self.context_kwargs = context_kwargs or {} 77 | self.nodelist = nodelist 78 | 79 | def __repr__(self): 80 | return "" % ( 81 | getattr( 82 | self, "nodelist", None 83 | ), # 'nodelist' attribute only assigned later. 84 | ) 85 | 86 | def render(self, context: Context): 87 | resolved_component_args = [ 88 | safe_resolve(arg, context) for arg in self.context_args 89 | ] 90 | resolved_component_kwargs = { 91 | key: safe_resolve(kwarg, context) 92 | for key, kwarg in self.context_kwargs.items() 93 | } 94 | form = resolved_component_args[0] if resolved_component_args else None 95 | formify_helper = init_formify_helper_for_form(form) 96 | content = self.nodelist.render(context) 97 | return formify_helper.render_form_tag( 98 | context=context, content=content, **resolved_component_kwargs 99 | ) 100 | 101 | 102 | @register.tag(name="form_tag") 103 | def do_form_tag(parser, token): 104 | bits = token.split_contents() 105 | tag_name = "form_tag" 106 | tag_args, tag_kwargs = parse_bits( 107 | parser=parser, 108 | bits=bits, 109 | params=[], 110 | takes_context=False, 111 | name=tag_name, 112 | varargs=True, 113 | varkw=[], 114 | defaults=None, 115 | kwonly=[], 116 | kwonly_defaults=None, 117 | ) 118 | 119 | if tag_name != tag_args[0].token: 120 | raise RuntimeError( 121 | f"Internal error: Expected tag_name to be {tag_name}, but it was {tag_args[0].token}" 122 | ) 123 | 124 | if len(tag_args) > 2: 125 | raise TemplateSyntaxError( 126 | f"'{tag_name}' tag only accepts form as the first argument, other arguments should be keyword arguments." 127 | ) 128 | 129 | context_args = tag_args[1:] 130 | context_kwargs = tag_kwargs 131 | 132 | nodelist: NodeList = parser.parse(parse_until=["endform_tag"]) 133 | parser.delete_first_token() 134 | 135 | component_node = FormTagNode( 136 | context_args=context_args, 137 | context_kwargs=context_kwargs, 138 | nodelist=nodelist, 139 | ) 140 | 141 | return component_node 142 | 143 | 144 | @register.filter 145 | def build_attrs(field): 146 | """ 147 | Copied from crispy form, maybe removed in the future. 148 | 149 | Build HTML attributes for a form field, also checking for a 150 | ``widget.allow_multiple_selected`` attribute and adding ``multiple`` to the 151 | attributes if it is set to ``True``. 152 | """ 153 | attrs = field.field.widget.attrs 154 | attrs.setdefault("id", field.auto_id) 155 | 156 | field_built_widget_attrs = field.build_widget_attrs(attrs) 157 | attrs.update(field_built_widget_attrs) 158 | 159 | # Some custom widgets (e.g. Select2) may add additional attributes to the 160 | # widget attrs dict. We need to add those to the attrs dict as well calling 161 | # the widget's build_attrs method. 162 | 163 | built_widget_attrs = field.field.widget.build_attrs(attrs) 164 | attrs.update(built_widget_attrs) 165 | 166 | if hasattr(field.field.widget, "allow_multiple_selected"): 167 | attrs["multiple"] = attrs.get( 168 | "multiple", field.field.widget.allow_multiple_selected 169 | ) 170 | return mark_safe(flatatt(attrs)) 171 | 172 | 173 | @register.filter 174 | def optgroups(field): 175 | """ 176 | Copied from crispy form, maybe removed in the future. 177 | 178 | A template filter to help rendering of fields with optgroups. 179 | 180 | Returns: 181 | A tuple of label, option, index 182 | 183 | label: Group label for grouped optgroups (`None` if inputs are not 184 | grouped). 185 | 186 | option: A dict containing information to render the option:: 187 | 188 | { 189 | "name": "checkbox_select_multiple", 190 | "value": 1, 191 | "label": 1, 192 | "selected": False, 193 | "index": "0", 194 | "attrs": {"id": "id_checkbox_select_multiple_0"}, 195 | "type": "checkbox", 196 | "template_name": "django/forms/widgets/checkbox_option.html", 197 | "wrap_label": True, 198 | } 199 | 200 | index: Group index 201 | 202 | """ 203 | id_ = field.field.widget.attrs.get("id") or field.auto_id 204 | attrs = {"id": id_} if id_ else {} 205 | attrs = field.build_widget_attrs(attrs) 206 | values = field.field.widget.format_value(field.value()) 207 | return field.field.widget.optgroups(field.html_name, values, attrs) 208 | 209 | 210 | def safe_resolve(context_item, context): 211 | """Resolve FilterExpressions and Variables in context if possible. Return other items unchanged.""" 212 | 213 | return ( 214 | context_item.resolve(context) 215 | if hasattr(context_item, "resolve") 216 | else context_item 217 | ) 218 | -------------------------------------------------------------------------------- /tests/test_tags.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from django.forms.formsets import formset_factory 3 | from django.template import Context, Template 4 | 5 | from tests.testapp.forms import SampleForm 6 | 7 | from .utils import assert_select 8 | 9 | pytestmark = pytest.mark.django_db 10 | 11 | 12 | class TestBasic: 13 | def test_form_tag(self): 14 | template = Template( 15 | """ 16 | {% load formify %} 17 | {% with url='/' %} 18 | {% form_tag form action=url %} 19 | {% render_form form %} 20 | {% endform_tag %} 21 | {% endwith %} 22 | """ 23 | ) 24 | c = Context({"form": SampleForm()}) 25 | html = template.render(c) 26 | 27 | assert_select(html, "form", 1) 28 | assert_select(html, "form[method='POST']", 1) 29 | assert_select(html, "form[action='/']", 1) 30 | 31 | # should still work if do not pass in form 32 | template = Template( 33 | """ 34 | {% load formify %} 35 | {% with url='/' %} 36 | {% form_tag action=url %} 37 | {% render_form form %} 38 | {% endform_tag %} 39 | {% endwith %} 40 | """ 41 | ) 42 | c = Context({"form": SampleForm()}) 43 | html = template.render(c) 44 | 45 | assert_select(html, "form", 1) 46 | assert_select(html, "form[method='POST']", 1) 47 | assert_select(html, "form[action='/']", 1) 48 | 49 | def test_form_tag_extra_kwargs(self): 50 | template = Template( 51 | """ 52 | {% load formify %} 53 | 54 | {% with url='/' %} 55 | {% form_tag form action=url data_test='test' novalidate=True %} 56 | {% render_form form %} 57 | {% endform_tag %} 58 | {% endwith %} 59 | """ 60 | ) 61 | c = Context({"form": SampleForm()}) 62 | html = template.render(c) 63 | 64 | assert_select(html, "form", 1) 65 | assert_select(html, "form[method='POST']", 1) 66 | assert_select(html, "form[action='/']", 1) 67 | assert_select(html, "form[data-test='test']", 1) 68 | assert_select(html, "form[novalidate]", 1) 69 | 70 | def test_form_tag_with_include(self): 71 | template = Template( 72 | """ 73 | {% load formify %} 74 | 75 | {% form_tag form %} 76 | {% include 'test.html' %} 77 | {% endform_tag %} 78 | """ 79 | ) 80 | c = Context({"form": SampleForm()}) 81 | html = template.render(c) 82 | assert "Hello" in html 83 | 84 | def test_render_field(self): 85 | template = Template( 86 | """ 87 | {% load formify %} 88 | {% for field in form %} 89 | {% render_field field %} 90 | {% endfor %} 91 | """ 92 | ) 93 | html = template.render(Context({"form": SampleForm()})) 94 | assert_select(html, "input", 8) 95 | 96 | def test_render_form(self): 97 | template = Template( 98 | """ 99 | {% load formify %} 100 | {% render_form form %} 101 | """ 102 | ) 103 | c = Context({"form": SampleForm()}) 104 | html = template.render(c) 105 | 106 | assert "id_is_company" in html 107 | assert_select(html, "label", 7) 108 | 109 | def test_render_formset(self): 110 | template = Template( 111 | """ 112 | {% load formify %} 113 | {% render_form testFormset %} 114 | """ 115 | ) 116 | 117 | SampleFormset = formset_factory(SampleForm, extra=4) 118 | testFormset = SampleFormset() 119 | 120 | c = Context({"testFormset": testFormset}) 121 | html = template.render(c) 122 | 123 | assert_select(html, "form", 0) 124 | 125 | # Check formset management form 126 | assert "form-TOTAL_FORMS" in html 127 | assert "form-INITIAL_FORMS" in html 128 | assert "form-MAX_NUM_FORMS" in html 129 | 130 | def test_form_without_non_field_errors(self): 131 | template = Template( 132 | """ 133 | {% load formify %} 134 | {% render_form form %} 135 | """ 136 | ) 137 | form = SampleForm({"password1": "god", "password2": "god"}) 138 | form.is_valid() 139 | 140 | c = Context({"form": form}) 141 | html = template.render(c) 142 | # no non-field errors 143 | assert_select(html, ".form-non-field-errors", 0) 144 | 145 | def test_form_with_non_field_errors(self): 146 | template = Template( 147 | """ 148 | {% load formify %} 149 | {% render_form form %} 150 | """ 151 | ) 152 | form = SampleForm({"password1": "god", "password2": "wargame"}) 153 | form.is_valid() 154 | 155 | c = Context({"form": form}) 156 | html = template.render(c) 157 | assert_select(html, ".form-non-field-errors") 158 | 159 | def test_form_errors_with_non_field_errors(self): 160 | template = Template( 161 | """ 162 | {% load formify %} 163 | {% render_form_errors form %} 164 | """ 165 | ) 166 | form = SampleForm({"password1": "god", "password2": "wargame"}) 167 | form.is_valid() 168 | 169 | c = Context({"form": form}) 170 | html = template.render(c) 171 | assert_select(html, ".form-non-field-errors") 172 | 173 | def test_formset_without_non_form_errors(self): 174 | template = Template( 175 | """ 176 | {% load formify %} 177 | {% render_form formset %} 178 | """ 179 | ) 180 | 181 | SampleFormset = formset_factory(SampleForm, max_num=1, validate_max=True) 182 | formset = SampleFormset() 183 | formset.is_valid() 184 | 185 | c = Context({"formset": formset}) 186 | html = template.render(c) 187 | assert_select(html, ".formset-non-form-errors", 0) 188 | 189 | def test_formset_with_non_form_errors(self): 190 | template = Template( 191 | """ 192 | {% load formify %} 193 | {% render_form formset %} 194 | """ 195 | ) 196 | 197 | SampleFormset = formset_factory(SampleForm, max_num=1, validate_max=True) 198 | formset = SampleFormset( 199 | { 200 | "form-TOTAL_FORMS": "2", 201 | "form-INITIAL_FORMS": "0", 202 | "form-MAX_NUM_FORMS": "", 203 | "form-0-password1": "god", 204 | "form-0-password2": "wargame", 205 | } 206 | ) 207 | formset.is_valid() 208 | 209 | c = Context({"formset": formset}) 210 | html = template.render(c) 211 | 212 | assert "Please submit at most 1 form" in html 213 | assert_select(html, ".formset-non-form-errors") 214 | 215 | def test_formset_errors_with_non_form_errors(self): 216 | template = Template( 217 | """ 218 | {% load formify %} 219 | {% render_form formset %} 220 | """ 221 | ) 222 | 223 | SampleFormset = formset_factory(SampleForm, max_num=1, validate_max=True) 224 | formset = SampleFormset( 225 | { 226 | "form-TOTAL_FORMS": "2", 227 | "form-INITIAL_FORMS": "0", 228 | "form-MAX_NUM_FORMS": "", 229 | "form-0-password1": "god", 230 | "form-0-password2": "wargame", 231 | } 232 | ) 233 | formset.is_valid() 234 | 235 | c = Context({"formset": formset}) 236 | html = template.render(c) 237 | 238 | assert "Please submit at most 1 form" in html 239 | assert_select(html, ".formset-non-form-errors") 240 | 241 | def test_bound_field(self): 242 | template = Template( 243 | """ 244 | {% load formify %} 245 | {% render_field field %} 246 | """ 247 | ) 248 | 249 | form = SampleForm({"password1": "god", "password2": "god"}) 250 | form.is_valid() 251 | 252 | c = Context({"field": form["password1"]}) 253 | 254 | html = template.render(c) 255 | assert "id_password1" in html 256 | assert "id_password2" not in html 257 | 258 | def test_render_submit(self): 259 | template = Template( 260 | """ 261 | {% load formify %} 262 | {% render_submit form text='Hello' css_class="btn btn-primary" name='action_primary' value='action_primary' %} 263 | """ 264 | ) 265 | form = SampleForm() 266 | c = Context({"form": form}) 267 | html = template.render(c) 268 | 269 | assert_select(html, "button.btn-primary") 270 | assert_select(html, "button.btn") 271 | assert_select(html, 'button[type="submit"][value="action_primary"]') 272 | assert_select(html, "button", text="Hello") 273 | 274 | def test_render_submit_with_none_form(self): 275 | # should work when form is not defined in context 276 | template = Template( 277 | """ 278 | {% load formify %} 279 | {% render_submit form text='Hello' css_class="btn btn-primary" name='action_primary' value='action_primary' %} 280 | """ 281 | ) 282 | c = Context({}) 283 | html = template.render(c) 284 | 285 | assert_select(html, "button.btn-primary") 286 | assert_select(html, "button.btn") 287 | assert_select(html, 'button[type="submit"][value="action_primary"]') 288 | assert_select(html, "button", text="Hello") 289 | 290 | # do not pass form 291 | template = Template( 292 | """ 293 | {% load formify %} 294 | {% render_submit text='Hello' css_class="btn btn-primary" name='action_primary' value='action_primary' %} 295 | """ 296 | ) 297 | c = Context({}) 298 | html = template.render(c) 299 | 300 | assert_select(html, "button.btn-primary") 301 | assert_select(html, "button.btn") 302 | assert_select(html, 'button[type="submit"][value="action_primary"]') 303 | assert_select(html, "button", text="Hello") 304 | -------------------------------------------------------------------------------- /tests/testapp/forms.py: -------------------------------------------------------------------------------- 1 | from django import forms 2 | from django.db import models 3 | 4 | from .models import CrispyTestModel 5 | 6 | 7 | class BaseForm(forms.Form): 8 | def __init__(self, *args, **kwargs): 9 | super().__init__(*args, **kwargs) 10 | 11 | 12 | class BaseModelForm(forms.ModelForm): 13 | def __init__(self, *args, **kwargs): 14 | super().__init__(*args, **kwargs) 15 | 16 | 17 | class SampleForm(BaseForm): 18 | is_company = forms.CharField( 19 | label="company", required=False, widget=forms.CheckboxInput() 20 | ) 21 | email = forms.EmailField( 22 | label="email", 23 | max_length=30, 24 | required=True, 25 | widget=forms.TextInput(), 26 | help_text="Insert your email", 27 | ) 28 | password1 = forms.CharField( 29 | label="password", max_length=30, required=True, widget=forms.PasswordInput() 30 | ) 31 | password2 = forms.CharField( 32 | label="re-enter password", 33 | max_length=30, 34 | required=True, 35 | widget=forms.PasswordInput(), 36 | ) 37 | first_name = forms.CharField( 38 | label="first name", max_length=5, required=True, widget=forms.TextInput() 39 | ) 40 | last_name = forms.CharField( 41 | label="last name", max_length=5, required=True, widget=forms.TextInput() 42 | ) 43 | datetime_field = forms.SplitDateTimeField( 44 | label="date time", widget=forms.SplitDateTimeWidget() 45 | ) 46 | 47 | def clean(self): 48 | super().clean() 49 | password1 = self.cleaned_data.get("password1", None) 50 | password2 = self.cleaned_data.get("password2", None) 51 | if not password1 and not password2 or password1 != password2: 52 | raise forms.ValidationError("Passwords dont match") 53 | 54 | return self.cleaned_data 55 | 56 | 57 | class AllFieldsForm(forms.Form): 58 | # CharField: Text input 59 | char_field = forms.CharField(label="CharField") 60 | 61 | # EmailField: Email input 62 | email_field = forms.EmailField(label="EmailField") 63 | 64 | # IntegerField: Number input 65 | integer_field = forms.IntegerField(label="IntegerField") 66 | 67 | # FloatField: Float number input 68 | float_field = forms.FloatField(label="FloatField") 69 | 70 | # BooleanField: Checkbox input 71 | boolean_field = forms.BooleanField(label="BooleanField", required=False) 72 | 73 | # DateField: Date input 74 | date_field = forms.DateField(label="DateField") 75 | 76 | # TimeField: Time input 77 | time_field = forms.TimeField(label="TimeField") 78 | 79 | # DateTimeField: DateTime input 80 | datetime_field = forms.DateTimeField(label="DateTimeField") 81 | 82 | # ChoiceField: Drop-down list 83 | choice_field = forms.ChoiceField( 84 | label="ChoiceField", 85 | choices=[("option1", "Option 1"), ("option2", "Option 2")], 86 | ) 87 | 88 | choice_radio_field = forms.ChoiceField( 89 | label="ChoiceRadioField", 90 | widget=forms.RadioSelect, 91 | choices=[("option1", "Option 1"), ("option2", "Option 2")], 92 | ) 93 | 94 | # MultipleChoiceField: Multiple select 95 | multiple_choice_field = forms.MultipleChoiceField( 96 | label="MultipleChoiceField", 97 | choices=[("option1", "Option 1"), ("option2", "Option 2")], 98 | ) 99 | 100 | favorite_colors = forms.MultipleChoiceField( 101 | required=False, 102 | widget=forms.CheckboxSelectMultiple, 103 | choices=[("option1", "Option 1"), ("option2", "Option 2")], 104 | ) 105 | 106 | # FileField: File upload 107 | file_field = forms.FileField(label="FileField", required=False) 108 | 109 | # ImageField: Image upload 110 | image_field = forms.ImageField(label="ImageField", required=False) 111 | 112 | # URLField: URL input 113 | url_field = forms.URLField(label="URLField") 114 | 115 | # RegexField: Input with regex validation 116 | regex_field = forms.RegexField( 117 | label="RegexField", 118 | regex=r"^\d{4}-\d{2}-\d{2}$", 119 | help_text="Enter a date in YYYY-MM-DD format", 120 | ) 121 | 122 | # DecimalField: Decimal number input 123 | decimal_field = forms.DecimalField( 124 | label="DecimalField", max_digits=10, decimal_places=2 125 | ) 126 | 127 | # DurationField: Duration input 128 | duration_field = forms.DurationField(label="DurationField") 129 | 130 | # Hidden input 131 | hidden_field = forms.CharField( 132 | widget=forms.HiddenInput(), 133 | ) 134 | 135 | # Textarea widget 136 | textarea_field = forms.CharField( 137 | widget=forms.Textarea(attrs={"rows": 4, "cols": 40}), 138 | ) 139 | 140 | # Number input with custom widget 141 | number_input_field = forms.FloatField( 142 | widget=forms.NumberInput(attrs={"step": "any"}), 143 | ) 144 | 145 | # Password input 146 | password_input_field = forms.CharField( 147 | widget=forms.PasswordInput(), 148 | ) 149 | 150 | 151 | class CheckboxesSampleForm(BaseForm): 152 | checkboxes = forms.MultipleChoiceField( 153 | choices=((1, "Option one"), (2, "Option two"), (3, "Option three")), 154 | initial=(1,), 155 | widget=forms.CheckboxSelectMultiple, 156 | ) 157 | 158 | alphacheckboxes = forms.MultipleChoiceField( 159 | choices=( 160 | ("option_one", "Option one"), 161 | ("option_two", "Option two"), 162 | ("option_three", "Option three"), 163 | ), 164 | initial=("option_two", "option_three"), 165 | widget=forms.CheckboxSelectMultiple, 166 | ) 167 | 168 | numeric_multiple_checkboxes = forms.MultipleChoiceField( 169 | choices=((1, "Option one"), (2, "Option two"), (3, "Option three")), 170 | initial=(1, 2), 171 | widget=forms.CheckboxSelectMultiple, 172 | ) 173 | 174 | inline_radios = forms.ChoiceField( 175 | choices=( 176 | ("option_one", "Option one"), 177 | ("option_two", "Option two"), 178 | ), 179 | widget=forms.RadioSelect, 180 | initial="option_two", 181 | ) 182 | 183 | 184 | class SelectSampleForm(BaseForm): 185 | select = forms.ChoiceField( 186 | choices=((1, "Option one"), (2, "Option two"), (3, "Option three")), 187 | initial=(1,), 188 | widget=forms.Select, 189 | ) 190 | 191 | 192 | class SampleForm3(BaseModelForm): 193 | class Meta: 194 | model = CrispyTestModel 195 | fields = ["email", "password"] 196 | exclude = ["password"] 197 | 198 | 199 | class SampleForm4(BaseModelForm): 200 | class Meta: 201 | """ 202 | before Django1.6, one cannot use __all__ shortcut for fields 203 | without getting the following error: 204 | django.core.exceptions.FieldError: Unknown field(s) (a, l, _) specified for CrispyTestModel 205 | because obviously it casts the string to a set 206 | """ 207 | 208 | model = CrispyTestModel 209 | fields = "__all__" # eliminate RemovedInDjango18Warning 210 | 211 | 212 | class SampleForm5(BaseForm): 213 | choices = [ 214 | (1, 1), 215 | (2, 2), 216 | (1000, 1000), 217 | ] 218 | checkbox_select_multiple = forms.MultipleChoiceField( 219 | widget=forms.CheckboxSelectMultiple, choices=choices 220 | ) 221 | radio_select = forms.ChoiceField(widget=forms.RadioSelect, choices=choices) 222 | pk = forms.IntegerField() 223 | 224 | 225 | class SampleFormWithMedia(BaseForm): 226 | class Media: 227 | css = {"all": ("test.css",)} 228 | js = ("test.js",) 229 | 230 | 231 | class SampleFormWithMultiValueField(BaseForm): 232 | multi = forms.SplitDateTimeField() 233 | 234 | 235 | class CrispyEmptyChoiceTestModel(models.Model): 236 | fruit = models.CharField( 237 | choices=[("apple", "Apple"), ("pear", "Pear")], 238 | null=True, 239 | blank=True, 240 | max_length=20, 241 | ) 242 | 243 | 244 | class SampleForm6(BaseModelForm): 245 | class Meta: 246 | """ 247 | When allowing null=True in a model field, 248 | the corresponding field will have a choice 249 | for the empty value. 250 | 251 | When the form is initialized by an instance 252 | with initial value None, this choice should 253 | be selected. 254 | """ 255 | 256 | model = CrispyEmptyChoiceTestModel 257 | fields = ["fruit"] 258 | widgets = {"fruit": forms.RadioSelect()} 259 | 260 | 261 | class SampleForm7(BaseModelForm): 262 | is_company = forms.CharField( 263 | label="company", required=False, widget=forms.CheckboxInput() 264 | ) 265 | password2 = forms.CharField( 266 | label="re-enter password", 267 | max_length=30, 268 | required=True, 269 | widget=forms.PasswordInput(), 270 | ) 271 | 272 | class Meta: 273 | model = CrispyTestModel 274 | fields = ("email", "password", "password2") 275 | 276 | 277 | class SampleForm8(BaseModelForm): 278 | is_company = forms.CharField( 279 | label="company", required=False, widget=forms.CheckboxInput() 280 | ) 281 | password2 = forms.CharField( 282 | label="re-enter password", 283 | max_length=30, 284 | required=True, 285 | widget=forms.PasswordInput(), 286 | ) 287 | 288 | class Meta: 289 | model = CrispyTestModel 290 | fields = ("email", "password2", "password") 291 | 292 | 293 | class FakeFieldFile: 294 | """ 295 | Quacks like a FieldFile (has a .url and string representation), but 296 | doesn't require us to care about storages etc. 297 | """ 298 | 299 | url = "something" 300 | 301 | def __str__(self): 302 | return self.url 303 | 304 | 305 | class FileForm(BaseForm): 306 | file_field = forms.FileField(widget=forms.FileInput) 307 | clearable_file = forms.FileField( 308 | widget=forms.ClearableFileInput, required=False, initial=FakeFieldFile() 309 | ) 310 | 311 | 312 | class AdvancedFileForm(BaseForm): 313 | file_field = forms.FileField( 314 | widget=forms.FileInput(attrs={"class": "my-custom-class"}) 315 | ) 316 | clearable_file = forms.FileField( 317 | widget=forms.ClearableFileInput(attrs={"class": "my-custom-class"}), 318 | required=False, 319 | initial=FakeFieldFile(), 320 | ) 321 | 322 | 323 | class GroupedChoiceForm(BaseForm): 324 | choices = [ 325 | ( 326 | "Audio", 327 | [ 328 | ("vinyl", "Vinyl"), 329 | ("cd", "CD"), 330 | ], 331 | ), 332 | ( 333 | "Video", 334 | [ 335 | ("vhs", "VHS Tape"), 336 | ("dvd", "DVD"), 337 | ], 338 | ), 339 | ("unknown", "Unknown"), 340 | ] 341 | checkbox_select_multiple = forms.MultipleChoiceField( 342 | widget=forms.CheckboxSelectMultiple, choices=choices 343 | ) 344 | radio = forms.MultipleChoiceField(widget=forms.RadioSelect, choices=choices) 345 | 346 | 347 | class CustomRadioSelect(forms.RadioSelect): 348 | pass 349 | 350 | 351 | class CustomCheckboxSelectMultiple(forms.CheckboxSelectMultiple): 352 | pass 353 | 354 | 355 | class SampleFormCustomWidgets(BaseForm): 356 | inline_radios = forms.ChoiceField( 357 | choices=( 358 | ("option_one", "Option one"), 359 | ("option_two", "Option two"), 360 | ), 361 | widget=CustomRadioSelect, 362 | initial="option_two", 363 | ) 364 | 365 | checkboxes = forms.MultipleChoiceField( 366 | choices=((1, "Option one"), (2, "Option two"), (3, "Option three")), 367 | initial=(1,), 368 | widget=CustomCheckboxSelectMultiple, 369 | ) 370 | -------------------------------------------------------------------------------- /tests/test_form_helper.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from django.template import Context, Template 3 | 4 | from django_formify.tailwind.formify_helper import FormifyHelper 5 | from tests.testapp.forms import AllFieldsForm, SampleForm 6 | 7 | from .utils import assert_select 8 | 9 | pytestmark = pytest.mark.django_db 10 | 11 | 12 | def render(template, context): 13 | return Template(template).render(Context(context)) 14 | 15 | 16 | class TestDefaultFormifyHelper: 17 | def test_render_all_supported_fields(self, mocker): 18 | mock_text_input = mocker.spy(FormifyHelper, "text_input") 19 | 20 | template = Template( 21 | """ 22 | {% load formify %} 23 | {% render_field form.char_field %} 24 | """ 25 | ) 26 | html = template.render(Context({"form": AllFieldsForm()})) 27 | 28 | mock_text_input.assert_called_once() 29 | assert_select(html, ".field-wrapper", 1) 30 | assert_select(html, ".label-class", 1) 31 | 32 | def test_render_email_field(self, mocker): 33 | mock_email_input = mocker.spy(FormifyHelper, "email_input") 34 | 35 | template = Template( 36 | """ 37 | {% load formify %} 38 | {% render_field form.email_field %} 39 | """ 40 | ) 41 | html = template.render(Context({"form": AllFieldsForm()})) 42 | 43 | mock_email_input.assert_called_once() 44 | assert_select(html, ".field-wrapper", 1) 45 | assert_select(html, ".label-class", 1) 46 | 47 | def test_render_integer_field(self, mocker): 48 | mock_number_input = mocker.spy(FormifyHelper, "number_input") 49 | 50 | template = Template( 51 | """ 52 | {% load formify %} 53 | {% render_field form.integer_field %} 54 | """ 55 | ) 56 | html = template.render(Context({"form": AllFieldsForm()})) 57 | 58 | mock_number_input.assert_called_once() 59 | assert_select(html, ".field-wrapper", 1) 60 | assert_select(html, ".label-class", 1) 61 | 62 | def test_render_float_field(self, mocker): 63 | mock_number_input = mocker.spy(FormifyHelper, "number_input") 64 | 65 | template = Template( 66 | """ 67 | {% load formify %} 68 | {% render_field form.float_field %} 69 | """ 70 | ) 71 | html = template.render(Context({"form": AllFieldsForm()})) 72 | 73 | mock_number_input.assert_called_once() 74 | assert_select(html, ".field-wrapper", 1) 75 | assert_select(html, ".label-class", 1) 76 | 77 | def test_render_boolean_field(self, mocker): 78 | mock_checkbox_input = mocker.spy(FormifyHelper, "checkbox_input") 79 | 80 | template = Template( 81 | """ 82 | {% load formify %} 83 | {% render_field form.boolean_field %} 84 | """ 85 | ) 86 | html = template.render(Context({"form": AllFieldsForm()})) 87 | 88 | mock_checkbox_input.assert_called_once() 89 | assert_select(html, ".field-wrapper", 1) 90 | assert_select(html, ".label-class", 0) 91 | 92 | def test_render_date_field(self, mocker): 93 | mock_date_input = mocker.spy(FormifyHelper, "date_input") 94 | 95 | template = Template( 96 | """ 97 | {% load formify %} 98 | {% render_field form.date_field %} 99 | """ 100 | ) 101 | html = template.render(Context({"form": AllFieldsForm()})) 102 | 103 | mock_date_input.assert_called_once() 104 | assert_select(html, ".field-wrapper", 1) 105 | assert_select(html, ".label-class", 1) 106 | 107 | def test_render_time_field(self, mocker): 108 | mock_time_input = mocker.spy(FormifyHelper, "time_input") 109 | 110 | template = Template( 111 | """ 112 | {% load formify %} 113 | {% render_field form.time_field %} 114 | """ 115 | ) 116 | html = template.render(Context({"form": AllFieldsForm()})) 117 | 118 | mock_time_input.assert_called_once() 119 | assert_select(html, ".field-wrapper", 1) 120 | assert_select(html, ".label-class", 1) 121 | 122 | def test_render_datetime_field(self, mocker): 123 | mock_date_time_input = mocker.spy(FormifyHelper, "date_time_input") 124 | 125 | template = Template( 126 | """ 127 | {% load formify %} 128 | {% render_field form.datetime_field %} 129 | """ 130 | ) 131 | html = template.render(Context({"form": AllFieldsForm()})) 132 | 133 | mock_date_time_input.assert_called_once() 134 | assert_select(html, ".field-wrapper", 1) 135 | assert_select(html, ".label-class", 1) 136 | 137 | def test_render_choice_field(self, mocker): 138 | mock_select = mocker.spy(FormifyHelper, "select") 139 | 140 | template = Template( 141 | """ 142 | {% load formify %} 143 | {% render_field form.choice_field %} 144 | """ 145 | ) 146 | html = template.render(Context({"form": AllFieldsForm()})) 147 | 148 | mock_select.assert_called_once() 149 | assert_select(html, ".field-wrapper", 1) 150 | assert_select(html, ".label-class", 1) 151 | 152 | def test_render_choice_radio_field(self, mocker): 153 | mock_radio_select = mocker.spy(FormifyHelper, "radio_select") 154 | 155 | template = Template( 156 | """ 157 | {% load formify %} 158 | {% render_field form.choice_radio_field %} 159 | """ 160 | ) 161 | html = template.render(Context({"form": AllFieldsForm()})) 162 | 163 | mock_radio_select.assert_called_once() 164 | assert_select(html, ".field-wrapper", 1) 165 | assert_select(html, ".label-class", 1) 166 | 167 | def test_render_select_multiple(self, mocker): 168 | mock_select_multiple = mocker.spy(FormifyHelper, "select_multiple") 169 | 170 | template = Template( 171 | """ 172 | {% load formify %} 173 | {% render_field form.multiple_choice_field %} 174 | """ 175 | ) 176 | html = template.render(Context({"form": AllFieldsForm()})) 177 | 178 | mock_select_multiple.assert_called_once() 179 | assert_select(html, ".field-wrapper", 1) 180 | assert_select(html, ".label-class", 1) 181 | 182 | def test_render_checkbox_select_multiple(self, mocker): 183 | mock_checkbox_select_multiple = mocker.spy( 184 | FormifyHelper, "checkbox_select_multiple" 185 | ) 186 | 187 | template = Template( 188 | """ 189 | {% load formify %} 190 | {% render_field form.favorite_colors %} 191 | """ 192 | ) 193 | html = template.render(Context({"form": AllFieldsForm()})) 194 | 195 | mock_checkbox_select_multiple.assert_called_once() 196 | assert_select(html, ".field-wrapper", 1) 197 | assert_select(html, ".label-class", 1) 198 | 199 | def test_render_file_field(self, mocker): 200 | mock_clearable_file_input = mocker.spy(FormifyHelper, "clearable_file_input") 201 | 202 | template = Template( 203 | """ 204 | {% load formify %} 205 | {% render_field form.file_field %} 206 | """ 207 | ) 208 | html = template.render(Context({"form": AllFieldsForm()})) 209 | 210 | mock_clearable_file_input.assert_called_once() 211 | assert_select(html, ".field-wrapper", 1) 212 | assert_select(html, ".label-class", 1) 213 | 214 | def test_render_image_field(self, mocker): 215 | mock_clearable_file_input = mocker.spy(FormifyHelper, "clearable_file_input") 216 | 217 | template = Template( 218 | """ 219 | {% load formify %} 220 | {% render_field form.image_field %} 221 | """ 222 | ) 223 | html = template.render(Context({"form": AllFieldsForm()})) 224 | 225 | mock_clearable_file_input.assert_called_once() 226 | assert_select(html, ".field-wrapper", 1) 227 | assert_select(html, ".label-class", 1) 228 | 229 | def test_render_url_field(self, mocker): 230 | mock_url_input = mocker.spy(FormifyHelper, "url_input") 231 | 232 | template = Template( 233 | """ 234 | {% load formify %} 235 | {% render_field form.url_field %} 236 | """ 237 | ) 238 | html = template.render(Context({"form": AllFieldsForm()})) 239 | 240 | mock_url_input.assert_called_once() 241 | assert_select(html, ".field-wrapper", 1) 242 | assert_select(html, ".label-class", 1) 243 | 244 | def test_render_regex_field(self, mocker): 245 | mock_text_input = mocker.spy(FormifyHelper, "text_input") 246 | 247 | template = Template( 248 | """ 249 | {% load formify %} 250 | {% render_field form.regex_field %} 251 | """ 252 | ) 253 | html = template.render(Context({"form": AllFieldsForm()})) 254 | 255 | mock_text_input.assert_called_once() 256 | assert_select(html, ".field-wrapper", 1) 257 | assert_select(html, ".label-class", 1) 258 | 259 | def test_render_hidden_field(self): 260 | template = Template( 261 | """ 262 | {% load formify %} 263 | {% render_field form.hidden_field %} 264 | """ 265 | ) 266 | html = template.render(Context({"form": AllFieldsForm()})) 267 | assert_select(html, 'input[type="hidden"]', 1) 268 | assert_select(html, ".label-class", 0) 269 | 270 | def test_render_textarea_field(self, mocker): 271 | mock_text_input = mocker.spy(FormifyHelper, "textarea") 272 | 273 | template = Template( 274 | """ 275 | {% load formify %} 276 | {% render_field form.textarea_field %} 277 | """ 278 | ) 279 | html = template.render(Context({"form": AllFieldsForm()})) 280 | 281 | mock_text_input.assert_called_once() 282 | assert_select(html, ".field-wrapper", 1) 283 | assert_select(html, ".label-class", 1) 284 | 285 | def test_render_password_input_field(self, mocker): 286 | mock_text_input = mocker.spy(FormifyHelper, "password_input") 287 | 288 | template = Template( 289 | """ 290 | {% load formify %} 291 | {% render_field form.password_input_field %} 292 | """ 293 | ) 294 | html = template.render(Context({"form": AllFieldsForm()})) 295 | 296 | mock_text_input.assert_called_once() 297 | assert_select(html, ".field-wrapper", 1) 298 | assert_select(html, ".label-class", 1) 299 | 300 | 301 | class TestFormFormifyHelper: 302 | def test_custom_formify_helper(self): 303 | helper = FormifyHelper() 304 | helper.form_show_errors = False 305 | helper.field_wrapper_class = "another-field-wrapper" 306 | helper.field_container_class = "another-field-container" 307 | helper.label_container_class = "another-label-container" 308 | 309 | template = Template( 310 | """ 311 | {% load formify %} 312 | {% render_form testForm %} 313 | """ 314 | ) 315 | 316 | # now we render it, with errors 317 | form = SampleForm({"password1": "wargame", "password2": "god"}) 318 | form.formify_helper = helper 319 | form.is_valid() 320 | c = Context({"testForm": form}) 321 | html = template.render(c) 322 | 323 | assert_select(html, ".field-wrapper", 0) 324 | assert_select(html, ".another-field-wrapper") 325 | assert_select(html, ".another-field-container") 326 | assert_select(html, ".another-label-container") 327 | assert_select(html, ".form-non-field-errors", 0) 328 | 329 | def test_override_in_field(self): 330 | """ 331 | Override formify_helper value on field level 332 | 333 | """ 334 | helper = FormifyHelper() 335 | 336 | template = Template( 337 | """ 338 | {% load formify %} 339 | {% render_field testForm.password1 form_show_labels=False %} 340 | {% render_field testForm.password2 %} 341 | """ 342 | ) 343 | 344 | # now we render it, with errors 345 | form = SampleForm({"password1": "wargame", "password2": "god"}) 346 | form.formify_helper = helper 347 | form.is_valid() 348 | c = Context({"testForm": form}) 349 | html = template.render(c) 350 | 351 | assert_select(html, ".field-wrapper") 352 | assert_select(html, "label", 1) 353 | -------------------------------------------------------------------------------- /src/django_formify/tailwind/formify_helper.py: -------------------------------------------------------------------------------- 1 | import copy 2 | import re 3 | 4 | from django.template.base import Template 5 | from django.template.context import Context 6 | from django.template.loader import get_template 7 | from django.utils.safestring import SafeString 8 | 9 | from django_formify.tailwind.layout import Field, Layout, Submit 10 | from django_formify.utils import camel_to_snake, init_formify_helper_for_form 11 | 12 | 13 | class CSSContainer: 14 | def __init__(self, css_styles): 15 | for key, value in css_styles.items(): 16 | setattr(self, key, value) 17 | 18 | def get_field_class(self, field): 19 | widget_cls = field.field.widget.__class__.__name__ 20 | key = camel_to_snake(widget_cls) 21 | css_classes = getattr(self, key, "") 22 | return css_classes 23 | 24 | 25 | class FormifyHelper: 26 | """ 27 | Developers can override these settings in their own FormifyHelper class 28 | and access them in template via formify_helper.xxx 29 | """ 30 | 31 | ################################################################################ 32 | # field wrapper 33 | # field wrapper is a wrapper for label, field and error messages 34 | ################################################################################ 35 | field_wrapper_class = "field-wrapper mb-3" 36 | # this is the component used to render the label, field input and error messages 37 | field_wrapper_component = "formify.tw.field_wrapper" 38 | 39 | ################################################################################ 40 | # field 41 | ################################################################################ 42 | field_container_class = "" 43 | 44 | # if form validation fail, use this to replace border css class for some inputs 45 | error_border = "border-red-300" 46 | 47 | common_style = ( 48 | "bg-white focus:outline-none border border-gray-300 rounded-lg py-2 px-4 block w-full " 49 | "appearance-none leading-normal text-gray-700" 50 | ) 51 | 52 | default_styles = { 53 | "text_input": common_style, 54 | "number_input": common_style, 55 | "email_input": common_style, 56 | "url_input": common_style, 57 | "password_input": common_style, 58 | "textarea": common_style, 59 | "date_input": common_style, 60 | "time_input": common_style, 61 | "date_time_input": common_style, 62 | "clearable_file_input": "w-full overflow-clip rounded-lg border border-gray-300 bg-gray-50/50 text-gray-600 file:mr-4 file:cursor-pointer file:border-none file:bg-gray-50 file:px-4 file:py-2 file:font-medium file:text-gray-900 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-black disabled:cursor-not-allowed disabled:opacity-75 dark:border-gray-700 dark:bg-gray-900/50 dark:text-gray-300 dark:file:bg-gray-900 dark:file:text-white dark:focus-visible:outline-white", 63 | "radio_select_option_label": "inline-flex items-center gap-2 text-gray-700", 64 | "checkbox_label": "inline-flex items-center gap-2 text-gray-700", 65 | } 66 | 67 | default_error_styles = { 68 | # border-red-300 69 | "clearable_file_input": "w-full overflow-clip rounded-lg border border-red-300 bg-gray-50/50 text-gray-600 file:mr-4 file:cursor-pointer file:border-none file:bg-gray-50 file:px-4 file:py-2 file:font-medium file:text-gray-900 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-black disabled:cursor-not-allowed disabled:opacity-75 dark:border-gray-700 dark:bg-gray-900/50 dark:text-gray-300 dark:file:bg-gray-900 dark:file:text-white dark:focus-visible:outline-white", 70 | } 71 | 72 | css_container = None 73 | 74 | error_css_container = None 75 | 76 | ################################################################################ 77 | # label 78 | ################################################################################ 79 | form_show_labels = True 80 | label_component = "formify.tw.field_label" 81 | label_container_class = "" 82 | label_class = "label-class block text-gray-900 mb-2" 83 | 84 | ################################################################################ 85 | # other 86 | ################################################################################ 87 | form_show_errors = True 88 | field_error_help_text_component = "formify.tw.field_error_help_text" 89 | field_help_text_component = "formify.tw.field_help_text" 90 | field_error_component = "formify.tw.field_error" 91 | 92 | form = None 93 | 94 | formset = None 95 | 96 | layout = None 97 | 98 | def __init__(self): 99 | self.prepare_css_container() 100 | 101 | def prepare_css_container(self): 102 | self.css_container = CSSContainer(self.default_styles) 103 | self.error_css_container = CSSContainer(self.default_error_styles) 104 | 105 | def get_context_data(self, context_data) -> Context: 106 | if isinstance(context_data, Context): 107 | context = context_data 108 | else: 109 | context = Context(context_data) 110 | 111 | context["formify_helper"] = self 112 | context["form"] = self.form 113 | context["formset"] = self.formset 114 | 115 | return context 116 | 117 | def smart_render(self, template, context): 118 | # if template is django.template.base.Template, make sure context is a Context object 119 | # if not, make sure context is pure dict 120 | if isinstance(template, Template): 121 | # make sure the context is Context 122 | if isinstance(context, Context): 123 | context_for_render = context 124 | else: 125 | context_for_render = Context(context) 126 | return template.render(context_for_render) 127 | else: 128 | # make sure the context is dict 129 | if isinstance(context, Context): 130 | # convert to dict 131 | context_for_render = context.flatten() 132 | else: 133 | context_for_render = context 134 | 135 | return template.render(context_for_render) 136 | 137 | def build_default_layout(self): 138 | return Layout(*[Field(field_name) for field_name in self.form.fields.keys()]) 139 | 140 | ################################################################################ 141 | # Rendering Methods 142 | ################################################################################ 143 | 144 | def render_form_tag(self, context, content, **kwargs): 145 | with context.push(): 146 | update_context = self.get_context_data(context) 147 | update_context["form_content"] = content 148 | attrs = { 149 | "class": kwargs.pop("css_class", ""), 150 | "method": kwargs.pop("method", "POST").upper(), 151 | } 152 | action = kwargs.pop("action", "") 153 | if action: 154 | attrs["action"] = action 155 | # add extra attributes 156 | for key, value in kwargs.items(): 157 | attrs[key] = value 158 | update_context["attrs"] = attrs 159 | template = get_template("formify/tailwind/form_tag.html") 160 | return self.smart_render(template, update_context) 161 | 162 | def render_formset(self, context): 163 | """ 164 | uni_formset.html 165 | """ 166 | # render formset management form fields 167 | management_form = self.formset.management_form 168 | management_form_helper = init_formify_helper_for_form(management_form) 169 | with context.push(): 170 | update_context = management_form_helper.get_context_data(context) 171 | management_form_html = management_form_helper.render_form(update_context) 172 | 173 | # render formset errors 174 | formset_errors = self.render_formset_errors(context) 175 | 176 | forms_html = "" 177 | for form in self.formset: 178 | form_helper = init_formify_helper_for_form(form) 179 | with context.push(): 180 | update_context = form_helper.get_context_data(context) 181 | forms_html += form_helper.render_form(update_context) 182 | 183 | return SafeString(management_form_html + formset_errors + forms_html) 184 | 185 | def render_form(self, context): 186 | """ 187 | uni_form.html 188 | """ 189 | with context.push(): 190 | context["attrs"] = None 191 | 192 | return SafeString( 193 | self.render_form_errors(context) + self.render_form_fields(context) 194 | ) 195 | 196 | def render_field(self, context, field, **kwargs): 197 | """ 198 | This method is to render specific field 199 | """ 200 | field_formify_helper = copy.copy(self) 201 | 202 | # assign extra kwargs to formify_helper if needed 203 | for key, value in kwargs.items(): 204 | setattr(field_formify_helper, key, value) 205 | 206 | with context.push(): 207 | context["attrs"] = None 208 | context["field"] = field 209 | 210 | if field.is_hidden: 211 | return SafeString(field.as_widget()) 212 | else: 213 | dispatch_method_callable = field_formify_helper.field_dispatch(field) 214 | update_context = field_formify_helper.get_context_data(context) 215 | return SafeString(dispatch_method_callable(update_context)) 216 | 217 | def render_submit(self, context, **kwargs): 218 | """ 219 | It would be called from the render_submit tag 220 | 221 | Here we use Submit component to render the submit button, you can also override this method and 222 | use Django's get_template and render methods to render the submit button 223 | """ 224 | css_class = kwargs.pop("css_class", None) 225 | text = kwargs.pop("text", None) 226 | submit_component = Submit(text=text, css_class=css_class, **kwargs) 227 | with context.push(): 228 | update_context = self.get_context_data(context) 229 | return submit_component.render_from_parent_context(update_context) 230 | 231 | def render_formset_errors(self, context): 232 | template = get_template("formify/tailwind/errors_formset.html") 233 | with context.push(): 234 | update_context = self.get_context_data(context) 235 | return self.smart_render(template, update_context) 236 | 237 | def render_form_errors(self, context): 238 | template = get_template("formify/tailwind/errors.html") 239 | with context.push(): 240 | update_context = self.get_context_data(context) 241 | return self.smart_render(template, update_context) 242 | 243 | ################################################################################ 244 | 245 | def field_dispatch(self, field): 246 | """ 247 | It will check if there is a method to render the field, if not, it will fall back to the "fallback" method 248 | 249 | For TextInput widget, the method is text_input 250 | """ 251 | widget_cls = field.field.widget.__class__.__name__ 252 | method_name = camel_to_snake(widget_cls) 253 | 254 | # check if method exists for self instance and callable 255 | if hasattr(self, method_name) and callable(getattr(self, method_name)): 256 | return getattr(self, method_name) 257 | else: 258 | return self.fallback 259 | 260 | def render_form_fields(self, context): 261 | if not self.layout: 262 | self.layout = self.build_default_layout() 263 | with context.push(): 264 | update_context = self.get_context_data(context) 265 | # render_from_parent_context is a method from the Component class 266 | return self.layout.render_from_parent_context(update_context) 267 | 268 | def render_as_tailwind_field(self, context): 269 | """ 270 | Logic from CrispyTailwindFieldNode.render method 271 | """ 272 | field = context["field"] 273 | widget = field.field.widget 274 | 275 | attrs = context.get("attrs", None) or {} 276 | css_class = widget.attrs.get("class", "") 277 | if "class" not in attrs.keys(): 278 | # if class is not set, then add additional css classes 279 | 280 | # add default input class 281 | css = " " + self.css_container.get_field_class(field) 282 | css_class += css 283 | 284 | if field.errors: 285 | error_css = self.error_css_container.get_field_class(field) 286 | if error_css: 287 | css_class = error_css 288 | else: 289 | # change border css class of the widget 290 | css_class = re.sub(r"border-\S+", self.error_border, css_class) 291 | 292 | widget.attrs["class"] = css_class 293 | 294 | # TODO 295 | # auto add required attribute 296 | if field.field.required and "required" not in widget.attrs: 297 | if field.field.widget.__class__.__name__ != "RadioSelect": 298 | widget.attrs["required"] = "required" 299 | 300 | # TODO 301 | for attribute_name, attributes in attrs.items(): 302 | if attribute_name in widget.attrs: 303 | # multiple attributes are in a single string, e.g. 304 | # "form-control is-invalid" 305 | for attr in attributes.split(): 306 | if attr not in widget.attrs[attribute_name].split(): 307 | widget.attrs[attribute_name] += " " + attr 308 | else: 309 | widget.attrs[attribute_name] = attributes 310 | 311 | return str(field) 312 | 313 | def common_field(self, context): 314 | field_html = self.render_as_tailwind_field(context) 315 | context["field_html"] = field_html 316 | field_template = get_template("formify/tailwind/common_field.html") 317 | return self.smart_render(field_template, context) 318 | 319 | def fallback(self, context): 320 | return self.common_field(context) 321 | 322 | ################################################################################ 323 | # Widget Methods 324 | ################################################################################ 325 | 326 | def text_input(self, context): 327 | return self.common_field(context) 328 | 329 | def number_input(self, context): 330 | return self.common_field(context) 331 | 332 | def email_input(self, context): 333 | return self.common_field(context) 334 | 335 | def password_input(self, context): 336 | return self.common_field(context) 337 | 338 | def checkbox_input(self, context): 339 | """ 340 | Aligning Checkboxes Horizontally 341 | """ 342 | field_html = self.render_as_tailwind_field(context) 343 | context["field_html"] = field_html 344 | field_template = get_template("formify/tailwind/checkbox_input.html") 345 | return self.smart_render(field_template, context) 346 | 347 | def date_input(self, context): 348 | # TODO 349 | # type="date" 350 | return self.common_field(context) 351 | 352 | def time_input(self, context): 353 | # TODO 354 | # type="time" 355 | return self.common_field(context) 356 | 357 | def date_time_input(self, context): 358 | # TODO 359 | # type="datetime-local" 360 | return self.common_field(context) 361 | 362 | def select(self, context): 363 | field_template = get_template("formify/tailwind/select.html") 364 | return self.smart_render(field_template, context) 365 | 366 | def select_multiple(self, context): 367 | return self.select(context) 368 | 369 | def radio_select(self, context): 370 | field_template = get_template("formify/tailwind/radio_select.html") 371 | return self.smart_render(field_template, context) 372 | 373 | def checkbox_select_multiple(self, context): 374 | field_template = get_template("formify/tailwind/checkbox_select_multiple.html") 375 | return self.smart_render(field_template, context) 376 | 377 | def clearable_file_input(self, context): 378 | return self.common_field(context) 379 | 380 | def url_input(self, context): 381 | return self.common_field(context) 382 | 383 | def textarea(self, context): 384 | return self.common_field(context) 385 | --------------------------------------------------------------------------------