13 |
14 | 
15 |
16 | 
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 | 
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 | 
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 |
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 | 
44 |
45 | It will also help display form non-field errors and form field errors as well.
46 |
47 | 
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 |
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 |
--------------------------------------------------------------------------------