├── .editorconfig
├── .github
└── workflows
│ ├── python-publish.yml
│ └── runtests.yml
├── .gitignore
├── .pre-commit-config.yaml
├── .readthedocs.yaml
├── CHANGELOG.md
├── LICENSE
├── Makefile
├── README.md
├── docs
├── Makefile
├── make.bat
├── requirements.txt
└── source
│ ├── conf.py
│ ├── dom_helper.md
│ ├── extend-turbo-stream.md
│ ├── form-submission.md
│ ├── index.rst
│ ├── install.md
│ ├── multi-format.md
│ ├── real-time-updates.md
│ ├── signal-decorator.md
│ ├── test.md
│ ├── turbo_frame.md
│ └── turbo_stream.md
├── pyproject.toml
├── requirements-dev.txt
├── setup.cfg
├── src
└── turbo_helper
│ ├── __init__.py
│ ├── apps.py
│ ├── channels
│ ├── __init__.py
│ ├── broadcasts.py
│ ├── stream_name.py
│ └── streams_channel.py
│ ├── constants.py
│ ├── middleware.py
│ ├── renderers.py
│ ├── response.py
│ ├── shortcuts.py
│ ├── signals.py
│ ├── stream.py
│ ├── templatetags
│ ├── __init__.py
│ └── turbo_helper.py
│ └── turbo_power.py
├── tests
├── __init__.py
├── conftest.py
├── templates
│ ├── csrf.html
│ ├── simple.html
│ └── todoitem.turbo_stream.html
├── test_broadcasts.py
├── test_channels.py
├── test_middleware.py
├── test_shortcuts.py
├── test_signal_handler.py
├── test_stream.py
├── test_tags.py
├── test_turbo_power.py
├── testapp
│ ├── __init__.py
│ ├── apps.py
│ ├── forms.py
│ ├── models.py
│ └── urls.py
└── utils.py
└── tox.ini
/.editorconfig:
--------------------------------------------------------------------------------
1 | # Copyright (c) 2020 by Dan Jacob
2 | # SPDX-License-Identifier: AGPL-3.0-or-later
3 |
4 | root = true
5 |
6 | # top-most EditorConfig file
7 | root = true
8 |
9 | [*]
10 | trim_trailing_whitespace = true
11 | insert_final_newline = true
12 | indent_style = space
13 | charset = utf-8
14 | indent_size = 2
15 |
16 | [*.py]
17 | indent_size = 4
--------------------------------------------------------------------------------
/.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@v3
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 |
--------------------------------------------------------------------------------
/.github/workflows/runtests.yml:
--------------------------------------------------------------------------------
1 | name: Runs tests
2 |
3 | on:
4 | pull_request:
5 | push:
6 | branches:
7 | - main
8 |
9 | jobs:
10 | runtests:
11 | runs-on: ubuntu-latest
12 | env:
13 | CHANNELS_REDIS: redis://localhost:6379/0
14 | strategy:
15 | matrix:
16 | python-version: ['3.8', '3.9', '3.10' ]
17 | services:
18 | redis:
19 | image: redis
20 | ports:
21 | - 6379:6379
22 | steps:
23 | - uses: actions/checkout@v3
24 | - name: Set up Python ${{ matrix.python-version }}
25 | uses: actions/setup-python@v4
26 | with:
27 | python-version: ${{ matrix.python-version }}
28 | - name: Install dependencies
29 | run: |
30 | pip install -r requirements-dev.txt
31 | - name: Run tests
32 | run: |
33 | tox
34 |
--------------------------------------------------------------------------------
/.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 |
--------------------------------------------------------------------------------
/.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 |
--------------------------------------------------------------------------------
/.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 |
--------------------------------------------------------------------------------
/CHANGELOG.md:
--------------------------------------------------------------------------------
1 | # Changelog
2 |
3 | ## [2.1.4]
4 |
5 | 1. Automatically change the status code in the middleware to 422 for failed form submissions requested by Turbo.
6 | 2. Add doc about `django-lifecycle` package.
7 | 3. Add doc about `morph` method in Turbo Stream.
8 |
9 | ## [2.1.3]
10 |
11 | 1. Work with `Turbo 8 morph-refreshes`
12 | 2. Add decorator `after_create_commit, after_update_commit, after_destroy_commit`.
13 | 3. Add `broadcast_action_to`, `broadcast_refresh_to` to broadcast action to the channels.
14 |
15 | ## [2.1.2]
16 |
17 | 1. Update to work with django-actioncable>=1.0.4
18 | 2. Support part of [turbo_power](https://github.com/marcoroth/turbo_power) turbo stream actions.
19 | 3. Update multi-format response solution.
20 |
21 | ## [2.1.0]
22 |
23 | **Breaking Change**
24 |
25 | 1. Refactored the whole project and the way to render Turbo Frame and Turbo Stream.
26 | 2. Please check the documentation to see how to use the new API.
27 |
28 | ## [2.0.1]
29 |
30 | 1. Update doc config
31 | 2. Update `pyproject.toml`
32 |
33 | ## [2.0.0]
34 |
35 | 1. Rename from `turbo_response` to `turbo_helper`
36 | 2. Make `dom_id` respect `to_key` method just like Rails
37 | 3. Add `response_format` so developer can return different response format in clean way.
38 |
39 | ## [1.0.3]
40 |
41 | 1. Add `turbo_stream_from` tag and make it work with [django-actioncable](https://github.com/AccordBox/django-actioncable)
42 |
43 | ## [1.0.2]
44 |
45 | 1. Import `dom_id`, `turbo_frame`, `turbo_stream` template tags.
46 |
47 | ## [0.0.52] - 2021-9-6
48 |
49 | Support multiple targets
50 |
51 | ## [0.0.51] - 2021-8-20
52 |
53 | TurboCreateView/TurboUpdateView inheritance fix
54 |
55 | ## [0.0.50] - 2021-7-5
56 |
57 | Add BEFORE and AFTER actions
58 |
59 | ## [0.0.49] - 2021-5-12
60 |
61 | Use format_html for rendering
62 |
63 | ## [0.0.48] - 2021-4-9
64 |
65 | Add support for Django 3.2
66 |
67 | Add rendering options to mixin classes
68 |
69 | ## [0.0.47] - 2021-4-4
70 |
71 | Removing support for Python 3.7
72 |
73 | ## [0.0.46] - 2021-4-2
74 |
75 | Remove tests dir from distribution
76 |
77 | ## [0.0.45] - 2021-4-2
78 |
79 | Refactor renderers and remove dependency on form widget renderer classes
80 |
81 | ## [0.0.44] - 2021-4-2
82 |
83 | Allow renderer as arg for all stream and frame mixins/classes
84 |
85 | ## [0.0.40] - 2021-4-2
86 |
87 | Use file-based templates for rendering streams/frames
88 |
89 | ## [0.0.39] - 2021-4-2
90 |
91 | Fix for https://github.com/rails-inspire-django/django-turbo-helper/issues/5
92 |
93 | ## [0.0.36] - 2021-3-30
94 |
95 | **render_form_response** adds turbo_stream_template name to context
96 |
97 | **turbo_stream_response** decorator
98 |
99 | ## [0.0.35] - 2021-2-17
100 |
101 | **render_form_response** can optionally re-render form as turbo stream
102 |
103 | ## [0.0.34] - 2021-2-13
104 |
105 | Tidy up argument parsing in API
106 |
107 | ## [0.0.33] - 2021-2-4
108 |
109 | Mixin **TurboFormAdapterMixin** added
110 |
111 | ## [0.0.32] - 2021-2-2
112 |
113 | Refactoring modules
114 |
115 | ## [0.0.31] - 2021-2-2
116 |
117 | Refactoring mixin classes
118 |
119 | ## [0.0.30] - 2021-2-1
120 |
121 | **TurboStreamIterableResponse** removed
122 |
123 | **TurboStreamFormMixin** and turbo-stream form views added
124 |
125 | ## [0.0.29] - 2021-1-27
126 |
127 | **TurboStreamIterableResponse** deprecated
128 |
129 | **TurboStreamMiddleware** removed
130 |
131 | ## [0.0.28] - 2021-1-22
132 |
133 | Support for Python 3.7
134 |
135 | ## [0.0.27] - 2021-1-21
136 |
137 | Bugfix for Turbo-Frame header in middleware
138 |
139 | ## [0.0.26] - 2021-1-17
140 |
141 | Added **TurboMiddleware** (replacing **TurboStreamMiddleware**).
142 |
143 | Removed **TemplateFormResponse**.
144 |
145 | ## [0.0.24] - 2021-1-17
146 |
147 | Added **render_form_response** shortcut.
148 |
149 | ## [0.0.23] - 2021-1-14
150 |
151 | Added **TurboStreamIterableResponse** class.
152 |
153 | ## [0.0.22] - 2021-1-14
154 |
155 | Added **TemplateFormResponse** class which automatically sets correct status based on form error state.
156 |
157 | ## [0.0.21] - 2021-1-13
158 |
159 | Middleware now accepts content type **text/vnd.turbo-stream.html*.
160 |
161 | ## [0.0.20] - 2021-1-13
162 |
163 | Deprecated mixin methods removed.
164 |
165 | Update response content type from *text/html; turbo-stream* to *text/vnd.turbo-stream.html*:
166 |
167 | https://github.com/hotwired/turbo/releases/tag/v7.0.0-beta.3
168 |
169 | ## [0.0.19] - 2021-1-13
170 |
171 | Changes to mixin APIs.
172 |
173 | ## [0.0.18] - 2021-1-12
174 |
175 | **TurboStreamDeleteView** automatically resolves target based on model name+PK.
176 |
177 | ## [0.0.17] - 2021-1-7
178 |
179 | Added **HttpResponseSeeOther** class and **redirect_303** shortcut function.
180 |
181 | ## [0.0.16] - 2021-1-7
182 |
183 | Ensure all form mixins/views return a 303 on redirect as per Turbo docs.
184 |
185 | ## [0.0.15] - 2021-1-7
186 |
187 | Removed protocol classes and mypy pre-commit requirement
188 |
189 | ## [0.0.14] - 2021-1-6
190 |
191 | Dependency bugfix
192 |
193 | ## [0.0.13] - 2021-1-6
194 |
195 | Added type hinting, tidy up of mixin class inheritance.
196 |
197 | ## [0.0.12] - 2021-1-5
198 |
199 | Update form handling for changes in @hotwired/turbo 7.0.0-beta.2:
200 |
201 | - **TurboStreamFormMixin** class and supporting classes removed
202 | - **TurboFormMixin** class added that just returns a 422 response on invalid
203 | - **TurboStreamFormView**, **TurboStreamCreateView** and **TurboStreamUpdateView** classes removed
204 | - **TurboFormView**, **TurboCreateView** and **TurboUpdateView** classes added, using new **TurboFormMixin**
205 |
206 | ## [0.0.10] - 2020-12-30
207 |
208 | Remove __str__ methods from TurboStream and TurboFrame classes
209 |
210 | ## [0.0.9] - 2020-12-30
211 |
212 | Add render() method to template proxies
213 |
214 | ## [0.0.8] - 2020-12-30
215 |
216 | ### Added
217 |
218 | TurboStream and TurboFrame classes
219 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | Copyright 2020 Dan Jacob danjac2018@gmail.com
2 |
3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
4 |
5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
6 |
7 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
8 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Hotwire/Turbo helpers for Django, inspired by Rails.
2 |
3 | [](https://badge.fury.io/py/django-turbo-helper)
4 | [](https://django-turbo-helper.readthedocs.io/)
5 |
6 | ## Documentation
7 |
8 | **`django-turbo-response` has been renamed to `django-turbo-helper` since version 2.x.x**
9 |
10 | 1. For legacy `django-turbo-response` user, please check https://django-turbo-helper.readthedocs.io/en/1.0.3/
11 | 2. For `django-turbo-helper` user, please check https://django-turbo-helper.readthedocs.io/
12 |
13 | ## Free Hotwire Django eBook
14 |
15 | If you are new to Hotwire, you may be interested in this free eBook [Hotwire Django Tutorial](https://tutorial.saashammer.com/).
16 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 | if "%1" == "" goto help
14 |
15 | %SPHINXBUILD% >NUL 2>NUL
16 | if errorlevel 9009 (
17 | echo.
18 | echo.The 'sphinx-build' command was not found. Make sure you have Sphinx
19 | echo.installed, then set the SPHINXBUILD environment variable to point
20 | echo.to the full path of the 'sphinx-build' executable. Alternatively you
21 | echo.may add the Sphinx directory to PATH.
22 | echo.
23 | echo.If you don't have Sphinx installed, grab it from
24 | echo.http://sphinx-doc.org/
25 | exit /b 1
26 | )
27 |
28 | %SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O%
29 | goto end
30 |
31 | :help
32 | %SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O%
33 |
34 | :end
35 | popd
36 |
--------------------------------------------------------------------------------
/docs/requirements.txt:
--------------------------------------------------------------------------------
1 | sphinx-autoapi==3.0.0
2 | furo==2023.9.10
3 | myst-parser
4 |
--------------------------------------------------------------------------------
/docs/source/conf.py:
--------------------------------------------------------------------------------
1 | # -- Path setup --------------------------------------------------------------
2 | # https://github.com/evansd/whitenoise/blob/main/docs/conf.py
3 |
4 | import datetime
5 | import sys
6 | import tomllib
7 | from pathlib import Path
8 |
9 | here = Path(__file__).parent.resolve()
10 | sys.path.insert(0, str(here / ".." / ".." / "src"))
11 |
12 |
13 | # -- Project information -----------------------------------------------------
14 |
15 | project = "django-turbo-helper"
16 | copyright = f"{datetime.datetime.now().year}, Michael Yin"
17 | author = "Michael Yin"
18 |
19 | # The version info for the project you're documenting, acts as replacement for
20 | # |version| and |release|, also used in various other places throughout the
21 | # built documents.
22 | #
23 | # The short X.Y version.
24 |
25 |
26 | def _get_version() -> str:
27 | with (here / ".." / ".." / "pyproject.toml").open("rb") as fp:
28 | data = tomllib.load(fp)
29 | version: str = data["tool"]["poetry"]["version"]
30 | return version
31 |
32 |
33 | version = _get_version()
34 | # The full version, including alpha/beta/rc tags.
35 | release = version
36 |
37 |
38 | # -- General configuration ---------------------------------------------------
39 |
40 | # Add any Sphinx extension module names here, as strings. They can be
41 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom
42 | # ones.
43 |
44 | extensions = ["sphinx.ext.autodoc", "autoapi.extension", "myst_parser"]
45 |
46 | source_suffix = {
47 | ".rst": "restructuredtext",
48 | ".txt": "markdown",
49 | ".md": "markdown",
50 | }
51 |
52 | # Document Python Code
53 | autoapi_type = "python"
54 | autoapi_dirs = ["../../src"]
55 | autoapi_ignore = ["*/tests/*.py"]
56 | autodoc_typehints = "description"
57 |
58 | # Add any paths that contain templates here, relative to this directory.
59 | templates_path = ["_templates"]
60 |
61 | # List of patterns, relative to source directory, that match files and
62 | # directories to ignore when looking for source files.
63 | # This pattern also affects html_static_path and html_extra_path.
64 | exclude_patterns = [] # type: ignore
65 |
66 | # The name of the Pygments (syntax highlighting) style to use.
67 | pygments_style = "sphinx"
68 |
69 | # -- Options for HTML output -------------------------------------------------
70 |
71 | # The theme to use for HTML and HTML Help pages. See the documentation for
72 | # a list of builtin themes.
73 | #
74 | html_theme = "furo"
75 |
76 | # Add any paths that contain custom static files (such as style sheets) here,
77 | # relative to this directory. They are copied after the builtin static files,
78 | # so a file named "default.css" will overwrite the builtin "default.css".
79 |
80 | html_theme_options = {
81 | "announcement": 'If you are new to Hotwire, you may be interested in this free eBook Hotwire Django Tutorial',
82 | }
83 |
--------------------------------------------------------------------------------
/docs/source/dom_helper.md:
--------------------------------------------------------------------------------
1 | # DOM Helper
2 |
3 | ## dom_id
4 |
5 | `dom_id` is a helper method that returns a unique DOM ID based on the object's class name and ID
6 |
7 | ```html
8 | {% load turbo_helper %}
9 |
10 | {% dom_id instance %} -> task_1
11 | {% dom_id instance 'detail' %} -> detail_task_1
12 | {% dom_id Task %} -> new_task
13 | ```
14 |
15 | 1. `dom_id` first argument can be string, instance or Model class
16 | 2. `dom_id` second argument is optional string that will be used as `prefix`.
17 | 3. The `dom_id` can help make the id generation behavior consistent across the project, and save our time to update it in `turbo-stream` or `turbo-frame` element.
18 | 4. You can also use it in your Django view code.
19 |
20 | Use in Django view
21 |
22 | ```python
23 | from turbo_helper import dom_id
24 |
25 | target = dom_id(instance, "detail_container")
26 | ```
27 |
28 | ## class_names
29 |
30 | Inspired by JS [classnames](https://www.npmjs.com/package/classnames) and Rails `class_names`
31 |
32 | `class_names` can help conditionally render css classes
33 |
34 | ```javascript
35 |
36 |
37 | ''
38 | ```
39 |
40 | It can also work well with TailwindCSS's some special css char such as `/` and `:`
41 |
--------------------------------------------------------------------------------
/docs/source/extend-turbo-stream.md:
--------------------------------------------------------------------------------
1 | # Extend Turbo Stream
2 |
3 | ## Simple Example
4 |
5 | You can extend Turbo Stream Action by `register_turbo_stream_action` decorator.
6 |
7 | ```python
8 | from turbo_helper import (
9 | register_turbo_stream_action,
10 | turbo_stream,
11 | )
12 |
13 |
14 | # register toast action
15 | @register_turbo_stream_action("toast")
16 | def toast(target, content=None, **kwargs):
17 | position = kwargs.get('position', 'left')
18 | return turbo_stream.action(
19 | "toast", target=target, message=kwargs['message'], position=position
20 | )
21 |
22 |
23 | turbo_stream.toast("dom_id", message="hello world", position="right")
24 | #
25 | ```
26 |
27 | Or you can render it in template:
28 |
29 | ```django
30 | {% load turbo_helper %}
31 |
32 | {% turbo_stream "toast" "target" message="Hello Word" position="right" %}{% endturbo_stream %}
33 | ```
34 |
35 | Next, you can update your frontend code to make it work with new `action`
36 |
37 | [https://turbo.hotwired.dev/handbook/streams#custom-actions](https://turbo.hotwired.dev/handbook/streams#custom-actions)
38 |
39 | ## Ecosystem
40 |
41 | There are some good projects on GitHub that can save our time:
42 |
43 | 1. [https://github.com/marcoroth/turbo_power](https://github.com/marcoroth/turbo_power)
44 | 2. [https://github.com/hopsoft/turbo_boost-streams](https://github.com/hopsoft/turbo_boost-streams)
45 |
46 | For example, to add css class to a DOM element, we can use
47 |
48 | ```python
49 | turbo_stream.add_css_class(
50 | targets="#element", classes="container text-center"
51 | )
52 | ```
53 |
54 | and it can generate HTML snippet like this
55 |
56 | ```html
57 |
58 | ```
59 |
60 | And registering the action handler on the frontend side, we can add css class on server side, without writing Javascript code.
61 |
62 | Now "django-turbo-helper" already contains some `turbo_power` actions, please check the source code and test cases for more details.
63 |
64 | | Turbo Power Actions |
65 | |---------------------|
66 | | graft |
67 | | add_css_class |
68 | | dispatch_event |
69 | | notification |
70 | | redirect_to |
71 | | turbo_frame_reload |
72 | | turbo_frame_set_src |
73 |
--------------------------------------------------------------------------------
/docs/source/form-submission.md:
--------------------------------------------------------------------------------
1 | # Form Submission
2 |
3 | By default, Turbo will intercept all clicks on links and form submission, as for form submission, if the form validation fail on the server side, Turbo expects the server return `422 Unprocessable Entity`.
4 |
5 | In Django, however, failed form submission would still return HTTP `200`, which would cause some issues when working with Turbo Drive.
6 |
7 | [https://turbo.hotwired.dev/handbook/drive#redirecting-after-a-form-submission](https://turbo.hotwired.dev/handbook/drive#redirecting-after-a-form-submission)
8 |
9 | How to solve this issue?
10 |
11 | `turbo_helper.middleware.TurboMiddleware` can detect POST request from Turbo and change the response status code to `422` if the form validation failed.
12 |
13 | It should work out of the box for `Turbo 8+` on the frontend.
14 |
15 | So developers do not need to manually set the status code to `422` in the view.
16 |
--------------------------------------------------------------------------------
/docs/source/index.rst:
--------------------------------------------------------------------------------
1 | django-turbo-helper
2 | =====================
3 |
4 | Hotwire/Turbo helpers for Django, inspired by Rails.
5 |
6 | .. warning::
7 | ``django-turbo-response`` has been renamed to ``django-turbo-helper`` since version 2.x.x.
8 |
9 | For legacy ``django-turbo-response`` user, please check `django-turbo-response documentation `_.
10 |
11 | .. _topics:
12 |
13 | Topics
14 | ------
15 |
16 | .. toctree::
17 | :maxdepth: 2
18 |
19 | install.md
20 | form-submission.md
21 | dom_helper.md
22 | turbo_frame.md
23 | turbo_stream.md
24 | real-time-updates.md
25 | extend-turbo-stream.md
26 | multi-format.md
27 | signal-decorator.md
28 | test.md
29 |
--------------------------------------------------------------------------------
/docs/source/install.md:
--------------------------------------------------------------------------------
1 | # Installation
2 |
3 | ```{note}
4 | This package does not include any Javascript library, you may wish to add these yourself using your preferred Javascript build tool, or use a CDN.
5 |
6 | Please check Hotwire Doc for more details
7 | ```
8 |
9 | ## Requirements
10 |
11 | This library requires Python 3.8+ and Django 3.2+.
12 |
13 | ## Getting Started
14 |
15 | ```shell
16 | $ pip install django-turbo-helper
17 | ```
18 |
19 | Next update **INSTALLED_APPS**:
20 |
21 | ```python
22 | INSTALLED_APPS = [
23 | "turbo_helper",
24 | ...
25 | ]
26 | ```
27 |
28 | ## Middleware
29 |
30 | Add `turbo_helper.middleware.TurboMiddleware` to the `MIDDLEWARE` in Django settings file.
31 |
32 | ```python
33 | MIDDLEWARE = [
34 | ...
35 | "turbo_helper.middleware.TurboMiddleware", # new
36 | ...
37 | ]
38 | ```
39 |
40 | With the `TurboMiddleware` we have `request.turbo` object which we can access in Django view or template. It will also help us to change the response status code to `422` if the POST request come from Turbo and the form validation failed.
41 |
42 | If the request originates from a turbo-frame, we can get the value from the `request.turbo.frame`
43 |
44 | ```django
45 | {% load turbo_helper %}
46 |
47 | {% if request.turbo.frame %}
48 |
49 | {% turbo_frame request.turbo.frame %}
50 | {% include 'template.html' %}
51 | {% endturbo_frame %}
52 |
53 | {% endif %}
54 | ```
55 |
56 | Or we can use `request.turbo.accept_turbo_stream` to check if the request accepts turbo stream response.
57 |
58 | ```python
59 | if request.turbo.accept_turbo_stream:
60 | # return turbo stream response
61 | else:
62 | # return normal HTTP response
63 | ```
64 |
--------------------------------------------------------------------------------
/docs/source/multi-format.md:
--------------------------------------------------------------------------------
1 | # Multi-format Response
2 |
3 | In Ruby on Rails, `respond_to` is a method that allows you to define how your controller should respond to different types of requests.
4 |
5 | ```ruby
6 | class PostsController < ApplicationController
7 | def create
8 | @post = Post.new(post_params)
9 |
10 | respond_to do |format|
11 | if @post.save
12 | format.html { redirect_to @post, notice: 'Post was successfully created.' }
13 | format.json { render json: @post, status: :created }
14 | format.turbo_stream { render turbo_stream: turbo_stream.append(@post) }
15 | else
16 | format.html { render :new }
17 | format.json { render json: @post.errors, status: :unprocessable_entity }
18 | format.turbo_stream { render turbo_stream: turbo_stream.replace('new_post', partial: 'posts/form', locals: { post: @post }) }
19 | end
20 | end
21 | end
22 | end
23 | ```
24 |
25 | In the above code, developer can return different response format based on request `Accept` header.
26 |
27 | We can do similar approach with `turbo_helper`
28 |
29 | ```python
30 | from turbo_helper import (
31 | TurboStreamResponse,
32 | respond_to,
33 | )
34 |
35 | class TaskCreateView(LoginRequiredMixin, CreateView):
36 | def form_valid(self, form):
37 | response = super().form_valid(form)
38 | request = self.request
39 | messages.success(request, "Created successfully")
40 |
41 | with respond_to(request) as resp_format:
42 | if resp_format.turbo_stream:
43 | return TurboStreamResponse(
44 | render_to_string(
45 | "task_create_success.turbo_stream.html",
46 | context={
47 | "form": TaskForm(),
48 | },
49 | request=self.request,
50 | ),
51 | )
52 | if resp_format.html:
53 | return response
54 | ```
55 |
56 | Notes:
57 |
58 | 1. If the browser accepts turbo stream (`Accept` header should **explicitly contain** `text/vnd.turbo-stream.html`), we return turbo stream response.
59 | 2. If the browser accepts HTML (`*/*` in `Accept` also work), we return HTML response.
60 | 3. This is useful when we want to **migrate our Django app from normal web page to turbo stream gradually**.
61 |
62 | ```{note}
63 | Please **put the non html response before html response**, and use html response as the fallback response.
64 | ```
65 |
--------------------------------------------------------------------------------
/docs/source/real-time-updates.md:
--------------------------------------------------------------------------------
1 | # Real Time Updates via Websocket
2 |
3 | Use Websocket and Turbo Stream to update the web page in real time, without writing Javascript.
4 |
5 | This can help render `turbo-cable-stream-source` in Django template
6 |
7 | `` is a custom element provided by [turbo-rails](https://github.com/hotwired/turbo-rails/blob/097d8f90cf0c5ed24ac6b1a49cead73d49fa8ab5/app/javascript/turbo/cable_stream_source_element.js), with it, we can send Turbo Stream over the websocket connection and update the page in real time.
8 |
9 | To import `turbo-cable-stream-source` element to the frontend, there are two ways:
10 |
11 | ```html
12 |
15 | ```
16 |
17 | Or you can [Jump start frontend project bundled by Webpack](https://github.com/AccordBox/python-webpack-boilerplate#jump-start-frontend-project-bundled-by-webpack) and install it via `npm install`
18 |
19 | After frontend is setup, to support Actioncable protocol on the server side, please install [django-actioncable](https://github.com/AccordBox/django-actioncable).
20 |
21 | In `routing.py`, register `TurboStreamCableChannel`
22 |
23 | ```python
24 | from actioncable import cable_channel_register
25 | from turbo_helper.channels.streams_channel import TurboStreamCableChannel
26 |
27 | cable_channel_register(TurboStreamCableChannel)
28 | ```
29 |
30 | In Django template, we can subscribe to stream source like this, it has nearly the same syntax as Rails `turbo_stream_from`:
31 |
32 | ```html
33 | {% load turbo_helper %}
34 |
35 | {% turbo_stream_from 'chat' view.kwargs.chat_pk %}
36 | ```
37 |
38 | `turbo_stream_from` can accept multiple positional arguments
39 |
40 | Then in Python code, we can send Turbo Stream to the stream source like this
41 |
42 | ```python
43 |
44 | from turbo_helper.channels.broadcasts import broadcast_render_to
45 |
46 | broadcast_render_to(
47 | "chat",
48 | instance.chat_id,
49 | template="message_append.turbo_stream.html",
50 | context={
51 | "instance": instance,
52 | },
53 | )
54 | ```
55 |
56 | 1. `arguments` **should** match the arguments passed in the `turbo_stream_from` tag.
57 | 2. `keyword arguments` `template` and `context` are used to render the template.
58 |
59 | The web page can be updated in real time, through Turbo Stream over Websocket.
60 |
61 | ## Broadcasts
62 |
63 | ### broadcast_action_to
64 |
65 | Under `turbo_helper.channels.broadcasts`, there are some other helper functions to broadcast Turbo Stream to the stream source, just like Rails:
66 |
67 | ```python
68 | def broadcast_action_to(*streamables, action, target=None, targets=None, **kwargs):
69 | ```
70 |
71 | The `broadcast_action_to` function is inspired by Rails and is designed to facilitate broadcasting actions to multiple streamable objects. It accepts a variable number of streamables as arguments, which represent the objects that will receive the broadcasted actions.
72 |
73 | The function requires an `action` parameter, which specifies the type of action to be performed.
74 |
75 | Example:
76 |
77 | ```python
78 | broadcast_action_to(
79 | "chat",
80 | instance.chat_id,
81 | action="append",
82 | template="message_content.html",
83 | context={
84 | "instance": instance,
85 | },
86 | target=dom_id(instance.chat_id, "message_list"),
87 | )
88 | ```
89 |
90 | ### broadcast_refresh_to
91 |
92 | This is for Rails 8 refresh action, and it would broadcast something like this via the websocket to trigger the page refresh:
93 |
94 | ```html
95 |
98 |
99 | ```
100 |
--------------------------------------------------------------------------------
/docs/source/signal-decorator.md:
--------------------------------------------------------------------------------
1 | # Signal Decorator
2 |
3 | In Django, developer usually use `post_save` signal to perform certain actions after a model instance is saved.
4 |
5 | Even `created` parameter indicates whether the instance is newly created or an existing one, this is not that straightforward.
6 |
7 | With `turbo_helper`, we provide **syntax suger** to make it more clear, just like Rails.
8 |
9 | ```python
10 | from turbo_helper import after_create_commit, after_update_commit, after_delete_commit
11 |
12 |
13 | @after_create_commit(sender=Message)
14 | def create_message_content(sender, instance, created, **kwargs):
15 | broadcast_action_to(
16 | "chat",
17 | instance.chat_id,
18 | action="append",
19 | template="demo_openai/message_content.html",
20 | context={
21 | "instance": instance,
22 | },
23 | target=dom_id(instance.chat_id, "message_list"),
24 | )
25 | ```
26 |
27 | Notes:
28 |
29 | 1. `after_create_commit`, `after_update_commit`, `after_delete_commit`, are decorators, they are used to decorate a function, which will be called after the model instance is created, updated or deleted.
30 | 2. The function decorated by `after_create_commit`, `after_update_commit`, receive the same arguments as `post_save` signal handler.
31 | 3. The function decorated by `after_delete_commit` receive the same arguments as `post_delete` signal handler.
32 | 4. This can make our code more clear, especially when we need to some broadcasts.
33 |
34 | ## django-lifecycle
35 |
36 | Another approach is to use `django-lifecycle` package, which is inspired by Rails' `ActiveRecord` callbacks.
37 |
38 | So we can write the code in Django model like this:
39 |
40 | ```python
41 | @hook(AFTER_UPDATE, on_commit=True)
42 | def broadcast_updated(self):
43 | pass
44 |
45 | @hook(BEFORE_DELETE)
46 | def broadcast_deleted(self):
47 | pass
48 | ```
49 |
--------------------------------------------------------------------------------
/docs/source/test.md:
--------------------------------------------------------------------------------
1 | # Hints on testing
2 |
3 | When testing, it's useful to be able to simulate Turbo headers.
4 |
5 | If you wish to test the result of a response within a Turbo frame, use the header **HTTP_TURBO_FRAME**:
6 |
7 | ```python
8 | from django.test import TestCase
9 |
10 | class TestViews(TestCase):
11 |
12 | def test_my_frame_view(self):
13 | response = self.client.get("/", HTTP_TURBO_FRAME="some-dom-id")
14 | self.assertEqual(response.status_code, 200)
15 | ```
16 |
17 | To simulate the Turbo-Stream header, you should set **HTTP_ACCEPT**.
18 |
19 | ```python
20 | from django.test import TestCase
21 | from turbo_helper.constants import TURBO_STREAM_MIME_TYPE
22 |
23 |
24 | class TestViews(TestCase):
25 |
26 | def test_my_stream_view(self):
27 | response = self.client.post("/", HTTP_ACCEPT=TURBO_STREAM_MIME_TYPE)
28 | self.assertEqual(response.status_code, 200)
29 | ```
30 |
--------------------------------------------------------------------------------
/docs/source/turbo_frame.md:
--------------------------------------------------------------------------------
1 | # Turbo Frame
2 |
3 | This tag can help us generate `turbo-frame` element in Django template.
4 |
5 | ```html
6 | {% load turbo_helper %}
7 |
8 | {% url 'message-create' as src %}
9 | {% turbo_frame "message_create" src=src %}
10 | Loading...
11 | {% endturbo_frame %}
12 | ```
13 |
14 | or you can use it with the powerful `dom_id` tag.
15 |
16 | ```html
17 | {% load turbo_helper %}
18 |
19 | {% dom_id instance 'detail' as dom_id %}
20 | {% turbo_frame dom_id class="flex-1" %}
21 | {% include 'components/detail.html' %}
22 | {% endturbo_frame %}
23 | ```
24 |
25 | Notes:
26 |
27 | 1. First argument is `turbo frame id`
28 | 2. Other arguments can be passed as `key=value` pairs
29 |
--------------------------------------------------------------------------------
/docs/source/turbo_stream.md:
--------------------------------------------------------------------------------
1 | # Turbo Stream
2 |
3 | ## Render from Django View
4 |
5 | ```python
6 | from turbo_helper import (
7 | turbo_stream,
8 | )
9 |
10 | # simple example
11 | turbo_stream.append("dom_id", "OK")
12 | turbo_stream.append("dom_id", content="OK")
13 |
14 | # template example
15 | turbo_stream.append(
16 | "dom_id",
17 | template="simple.html",
18 | context={"msg": "my content"},
19 | request=request
20 | )
21 | ```
22 |
23 | Notes:
24 |
25 | 1. `request`, `context` are optional
26 | 2. If `content` is not set, then `template` is required to render the `content`.
27 |
28 | Turbo Stream built-in actions are all supported in syntax `turbo_stream.xxx`:
29 |
30 | - append
31 | - prepend
32 | - replace
33 | - update
34 | - remove
35 | - before
36 | - after
37 |
38 | ### TurboStreamResponse
39 |
40 | In Django view, we should use `TurboStreamResponse` to wrap Turbo Stream elements so the client can recognize it.
41 |
42 | ```python
43 | from turbo_helper import TurboStreamResponse
44 |
45 | # render multiple turbo stream elements in one response
46 | return TurboStreamResponse([
47 | turbo_stream.append(
48 | "message",
49 | template="message.html",
50 | context={"msg": "my content"},
51 | request=request
52 | ),
53 | turbo_stream.update(
54 | "form",
55 | template="form.html",
56 | context={"form": form},
57 | request=request
58 | ),
59 | ])
60 | ```
61 |
62 | Or cleaner way:
63 |
64 | ```python
65 | from turbo_helper import turbo_stream
66 |
67 | # render multiple turbo stream elements in one response
68 | return turbo_stream.response([
69 | turbo_stream.append(
70 | "message",
71 | template="message.html",
72 | context={"msg": "my content"},
73 | request=request
74 | ),
75 | turbo_stream.update(
76 | "form",
77 | template="form.html",
78 | context={"form": form},
79 | request=request
80 | ),
81 | ])
82 | ```
83 |
84 | ## Morph Method
85 |
86 | As for `update` and `replace` actions, we can set `[method="morph"]` to make it work.
87 |
88 | ```python
89 | turbo_stream.update("target", content="some html", method="morph")
90 | ```
91 |
92 | In Django template:
93 |
94 | ```html
95 | {% load turbo_helper %}
96 |
97 | {% turbo_stream "update" "target" method="morph" %}some html{% endturbo_stream %}
98 | ```
99 |
100 | ## Render from Django Template
101 |
102 | `turbo_stream` can help us generate `turbo-stream` element in Django template.
103 |
104 | ```html
105 | {% load turbo_helper %}
106 |
107 | {% turbo_stream 'append' 'messages' %}
108 | {% include 'core/components/message.html' %}
109 | {% endturbo_stream %}
110 |
111 | {% turbo_stream 'update' 'new_task' %}
112 | {% include 'components/create.html' %}
113 | {% endturbo_stream %}
114 | ```
115 |
116 | Notes:
117 |
118 | 1. First argument is `turbo stream action`
119 | 2. Second argument is `turbo stream target`
120 | 3. Other arguments can be passed as `key=value` pairs
121 | 4. We can generate **multiple** turbo stream elements in one template and render it in one response, and update multiple part of the page in one response.
122 |
123 | ## Targeting Multiple Elements
124 |
125 | To target multiple elements with a single action, use the `targets` attribute with a CSS query selector instead of the `target` attribute
126 |
127 | ```python
128 | turbo_stream.append_all(
129 | ".old_records", template="simple.html", context={"msg": "my content"}
130 | )
131 |
132 | #
133 | ```
134 |
135 | Or template tag
136 |
137 | ```django
138 | {% turbo_stream_all "remove" ".old_records" %}{% endturbo_stream_all %}
139 | ```
140 |
--------------------------------------------------------------------------------
/pyproject.toml:
--------------------------------------------------------------------------------
1 | [tool.poetry]
2 | name = "django-turbo-helper"
3 | version = "2.1.4"
4 | description = "Hotwire/Turbo helpers for Django, inspired by Rails."
5 | authors = ["Michael Yin "]
6 | license = "MIT"
7 | homepage = "https://github.com/rails-inspire-django/django-turbo-helper"
8 | readme = "README.md"
9 | packages = [{ include = "turbo_helper", from = "src" }]
10 |
11 | [tool.poetry.urls]
12 | Changelog = "https://github.com/rails-inspire-django/django-turbo-helper/releases"
13 |
14 | [tool.poetry.dependencies]
15 | python = ">=3.8"
16 | django = ">=3.0"
17 | django-actioncable = ">=1.0.4"
18 | django-template-simplify = ">=1.0.2"
19 |
20 | [tool.poetry.dev-dependencies]
21 |
22 | [build-system]
23 | requires = ["setuptools", "poetry_core>=1.0"]
24 | build-backend = "poetry.core.masonry.api"
25 |
--------------------------------------------------------------------------------
/requirements-dev.txt:
--------------------------------------------------------------------------------
1 | pre-commit==2.9.2
2 | tox==4.11.3
3 | tox-gh-actions==3.1.3
4 |
--------------------------------------------------------------------------------
/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/turbo_helper/__init__.py:
--------------------------------------------------------------------------------
1 | from template_simplify import dom_id
2 |
3 | from .middleware import get_current_request
4 | from .response import HttpResponseSeeOther, TurboStreamResponse
5 | from .shortcuts import redirect_303, respond_to
6 | from .signals import after_create_commit, after_delete_commit, after_update_commit
7 | from .stream import register_turbo_stream_action, turbo_stream
8 |
9 | # extend turbo_stream actions, inspired by https://github.com/marcoroth/turbo_power
10 | from .turbo_power import * # noqa
11 |
12 | __all__ = [
13 | "turbo_stream",
14 | "register_turbo_stream_action",
15 | "TurboStreamResponse",
16 | "HttpResponseSeeOther",
17 | "redirect_303",
18 | "dom_id",
19 | "respond_to",
20 | "get_current_request",
21 | "after_create_commit",
22 | "after_update_commit",
23 | "after_delete_commit",
24 | ]
25 |
--------------------------------------------------------------------------------
/src/turbo_helper/apps.py:
--------------------------------------------------------------------------------
1 | from django.apps import AppConfig
2 |
3 |
4 | class TurboHelperConfig(AppConfig):
5 | name = "turbo_helper"
6 | verbose_name = "Turbo Helper"
7 |
--------------------------------------------------------------------------------
/src/turbo_helper/channels/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/rails-inspire-django/django-turbo-helper/777851c153602218503b1aa7432c8313147f52d1/src/turbo_helper/channels/__init__.py
--------------------------------------------------------------------------------
/src/turbo_helper/channels/broadcasts.py:
--------------------------------------------------------------------------------
1 | from actioncable import cable_broadcast
2 | from django.template.loader import render_to_string
3 |
4 | from turbo_helper.renderers import render_turbo_stream_refresh
5 | from turbo_helper.stream import action_proxy
6 |
7 | from .stream_name import stream_name_from
8 |
9 |
10 | def broadcast_render_to(*streamables, **kwargs):
11 | """
12 | Rails: Turbo::Streams::Broadcasts#broadcast_render_to
13 |
14 | Help render Django template to Turbo Stream Channel
15 |
16 | for example, in Django template, we subscribe to a Turbo stream Channel
17 |
18 | {% turbo_stream_from 'chat' view.kwargs.chat_pk %}
19 |
20 | Then in Python code
21 |
22 | broadcast_render_to(
23 | "chat",
24 | instance.chat_id,
25 | template="message_append.turbo_stream.html",
26 | context={
27 | "instance": instance,
28 | },
29 | )
30 | """
31 | template = kwargs.pop("template", None)
32 | broadcast_stream_to(
33 | *streamables, content=render_to_string(template_name=template, **kwargs)
34 | )
35 |
36 |
37 | def broadcast_action_to(*streamables, action, target=None, targets=None, **kwargs):
38 | """
39 | For now, we do not support:
40 |
41 | broadcast_remove_to
42 | broadcast_replace_to
43 | broadcast_update_to
44 | ...
45 |
46 | But we can use to do the same work
47 |
48 | For example:
49 |
50 | # remove DOM which has id="new_task"
51 | broadcast_action_to("tasks", action="remove", target="new_task")
52 | """
53 | content = action_proxy(
54 | action,
55 | target=target,
56 | targets=targets,
57 | **kwargs,
58 | )
59 | broadcast_stream_to(*streamables, content=content)
60 |
61 |
62 | def broadcast_refresh_to(*streamables, request, **kwargs):
63 | content = render_turbo_stream_refresh(request_id=request.turbo.request_id, **kwargs)
64 | broadcast_stream_to(*streamables, content=content)
65 |
66 |
67 | def broadcast_stream_to(*streamables, content):
68 | stream_name = stream_name_from(*streamables)
69 | cable_broadcast(
70 | group_name=stream_name,
71 | message=content,
72 | )
73 |
--------------------------------------------------------------------------------
/src/turbo_helper/channels/stream_name.py:
--------------------------------------------------------------------------------
1 | from typing import Tuple
2 |
3 | from django.core import signing
4 | from django.core.signing import Signer
5 |
6 | from turbo_helper.templatetags.turbo_helper import dom_id
7 |
8 | signer = Signer()
9 |
10 |
11 | def stream_name_from(*streamables) -> str:
12 | """
13 | Generate stream_name from a list of objects or a single object.
14 | """
15 | if len(streamables) == 1:
16 | return dom_id(streamables[0])
17 | else:
18 | return "_".join(stream_name_from(streamable) for streamable in streamables)
19 |
20 |
21 | def generate_signed_stream_key(stream_name: str) -> str:
22 | """
23 | Generate signed stream key from stream_name
24 | """
25 | return signer.sign(stream_name)
26 |
27 |
28 | def verify_signed_stream_key(signed_stream_key: str) -> Tuple[bool, str]:
29 | """
30 | Verify signed stream key
31 | """
32 | try:
33 | unsigned_data = signer.unsign(signed_stream_key)
34 | return True, unsigned_data
35 |
36 | except signing.BadSignature:
37 | pass
38 |
39 | return False, ""
40 |
--------------------------------------------------------------------------------
/src/turbo_helper/channels/streams_channel.py:
--------------------------------------------------------------------------------
1 | from actioncable import ActionCableConsumer, CableChannel
2 | from django.core.signing import Signer
3 |
4 | from .stream_name import verify_signed_stream_key
5 |
6 | signer = Signer()
7 |
8 |
9 | class TurboStreamCableChannel(CableChannel):
10 | def __init__(self, consumer: ActionCableConsumer, identifier_key, params=None):
11 | self.params = params if params else {}
12 | self.identifier_key = identifier_key
13 | self.consumer = consumer
14 | self.group_name = None
15 |
16 | async def subscribe(self):
17 | flag, stream_name = verify_signed_stream_key(self.params["signed_stream_name"])
18 | self.group_name = stream_name
19 | if flag:
20 | await self.consumer.subscribe_group(self.group_name, self)
21 |
22 | async def unsubscribe(self):
23 | await self.consumer.unsubscribe_group(self.group_name, self)
24 |
--------------------------------------------------------------------------------
/src/turbo_helper/constants.py:
--------------------------------------------------------------------------------
1 | TURBO_STREAM_MIME_TYPE = "text/vnd.turbo-stream.html"
2 |
3 |
4 | class ResponseFormat:
5 | html = False
6 | json = False
7 | turbo_stream = False
8 |
--------------------------------------------------------------------------------
/src/turbo_helper/middleware.py:
--------------------------------------------------------------------------------
1 | import http
2 | import threading
3 | from typing import Callable
4 |
5 | from django.http import HttpRequest, HttpResponse
6 | from django.utils.functional import SimpleLazyObject
7 |
8 | from .constants import TURBO_STREAM_MIME_TYPE
9 |
10 | _thread_locals = threading.local()
11 |
12 |
13 | def get_current_request():
14 | return getattr(_thread_locals, "request", None)
15 |
16 |
17 | def set_current_request(request):
18 | setattr(_thread_locals, "request", request) # noqa: B010
19 |
20 |
21 | class SetCurrentRequest:
22 | """
23 | Can let developer access Django request from anywhere
24 |
25 | https://github.com/zsoldosp/django-currentuser
26 | https://stackoverflow.com/questions/4716330/accessing-the-users-request-in-a-post-save-signal
27 | """
28 |
29 | def __init__(self, request):
30 | self.request = request
31 |
32 | def __enter__(self):
33 | set_current_request(self.request)
34 |
35 | def __exit__(self, exc_type, exc_value, traceback):
36 | # cleanup
37 | set_current_request(None)
38 |
39 |
40 | class TurboData:
41 | def __init__(self, request: HttpRequest):
42 | # be careful about the */* from browser
43 | self.accept_turbo_stream = TURBO_STREAM_MIME_TYPE in request.headers.get(
44 | "Accept", ""
45 | )
46 | self.frame = request.headers.get("Turbo-Frame", None)
47 | self.request_id = request.headers.get("X-Turbo-Request-Id", None)
48 |
49 | def __bool__(self):
50 | """
51 | TODO: Deprecate
52 | """
53 | return self.accept_turbo_stream
54 |
55 |
56 | class TurboMiddleware:
57 | """
58 | Task 1: Adds `turbo` attribute to request:
59 | 1. `request.turbo` : True if request contains turbo header
60 | 2. `request.turbo.frame`: DOM ID of requested Turbo-Frame (or None)
61 |
62 | Task 2: Auto change status code for Turbo Drive
63 | https://turbo.hotwired.dev/handbook/drive#redirecting-after-a-form-submission
64 | """
65 |
66 | def __init__(self, get_response: Callable[[HttpRequest], HttpResponse]):
67 | self.get_response = get_response
68 |
69 | def __call__(self, request: HttpRequest) -> HttpResponse:
70 | with SetCurrentRequest(request):
71 | request.turbo = SimpleLazyObject(lambda: TurboData(request))
72 |
73 | response = self.get_response(request)
74 |
75 | if (
76 | request.method == "POST"
77 | and request.headers.get("X-Turbo-Request-Id")
78 | and response.get("Content-Type") != "text/vnd.turbo-stream.html"
79 | ):
80 | if response.status_code == http.HTTPStatus.OK:
81 | response.status_code = http.HTTPStatus.UNPROCESSABLE_ENTITY
82 |
83 | if response.status_code in (
84 | http.HTTPStatus.MOVED_PERMANENTLY,
85 | http.HTTPStatus.FOUND,
86 | ):
87 | response.status_code = http.HTTPStatus.SEE_OTHER
88 |
89 | return response
90 |
--------------------------------------------------------------------------------
/src/turbo_helper/renderers.py:
--------------------------------------------------------------------------------
1 | from typing import Any, Dict, List, Optional
2 |
3 | from django.template import engines
4 | from django.utils.html import escape
5 | from django.utils.safestring import mark_safe
6 |
7 |
8 | def render_turbo_stream(
9 | action: str,
10 | content: Optional[str],
11 | attributes: Dict[str, Any],
12 | target: Optional[str] = None,
13 | targets: Optional[str] = None,
14 | ) -> str:
15 | element_attributes = {}
16 | for key, value in attributes.items():
17 | # convert data_xxx to data-xxx
18 | if key.startswith("data"):
19 | element_attributes[key.replace("_", "-")] = value
20 | else:
21 | element_attributes[key] = value
22 |
23 | element_attributes_array = []
24 | for key, value in element_attributes.items():
25 | if value is None:
26 | continue
27 | # TODO: bool type django/forms/widgets/attrs.html
28 | element_attributes_array.append(f'{key}="{escape(value)}"')
29 |
30 | attribute_string = mark_safe(" ".join(element_attributes_array))
31 |
32 | django_engine = engines["django"]
33 | template_string = """{{ content|default:'' }}"""
34 | context = {
35 | "content": content,
36 | "action": action,
37 | "target": target,
38 | "targets": targets,
39 | "attribute_string": attribute_string,
40 | }
41 | return django_engine.from_string(template_string).render(context)
42 |
43 |
44 | def render_turbo_frame(frame_id: str, content: str, attributes: Dict[str, Any]) -> str:
45 | # convert data_xxx to data-xxx
46 | element_attributes = {}
47 | for key, value in attributes.items():
48 | # convert data_xxx to data-xxx
49 | if key.startswith("data"):
50 | element_attributes[key.replace("_", "-")] = value
51 | else:
52 | element_attributes[key] = value
53 |
54 | element_attributes_array = []
55 | for key, value in element_attributes.items():
56 | if value is None:
57 | continue
58 | # TODO: bool type django/forms/widgets/attrs.html
59 | element_attributes_array.append(f'{key}="{escape(value)}"')
60 |
61 | attribute_string = mark_safe(" ".join(element_attributes_array))
62 |
63 | django_engine = engines["django"]
64 | template_string = """{{ content }}""" # noqa
65 | context = {
66 | "frame_id": frame_id,
67 | "content": content,
68 | "attribute_string": attribute_string,
69 | }
70 | return django_engine.from_string(template_string).render(context)
71 |
72 |
73 | def render_turbo_stream_from(stream_name_array: List[Any]):
74 | from turbo_helper.channels.stream_name import (
75 | generate_signed_stream_key,
76 | stream_name_from,
77 | )
78 | from turbo_helper.channels.streams_channel import TurboStreamCableChannel
79 |
80 | stream_name_string = stream_name_from(*stream_name_array)
81 |
82 | django_engine = engines["django"]
83 | template_string = """""" # noqa
84 | context = {
85 | "signed_stream_name": generate_signed_stream_key(stream_name_string),
86 | "channel": TurboStreamCableChannel.__name__,
87 | }
88 | return django_engine.from_string(template_string).render(context)
89 |
90 |
91 | def render_turbo_stream_refresh(request_id, **attributes):
92 | attributes["request-id"] = request_id
93 | return render_turbo_stream(
94 | action="refresh",
95 | content=None,
96 | target=None,
97 | targets=None,
98 | attributes=attributes,
99 | )
100 |
--------------------------------------------------------------------------------
/src/turbo_helper/response.py:
--------------------------------------------------------------------------------
1 | import http
2 |
3 | from django.http import HttpResponse, HttpResponseRedirect
4 |
5 | from .constants import TURBO_STREAM_MIME_TYPE
6 |
7 |
8 | class HttpResponseSeeOther(HttpResponseRedirect):
9 | status_code = http.HTTPStatus.SEE_OTHER
10 |
11 |
12 | class TurboStreamResponse(HttpResponse):
13 | def __init__(self, *args, **kwargs):
14 | super().__init__(content_type=TURBO_STREAM_MIME_TYPE, *args, **kwargs)
15 |
--------------------------------------------------------------------------------
/src/turbo_helper/shortcuts.py:
--------------------------------------------------------------------------------
1 | from contextlib import contextmanager
2 | from typing import Union
3 |
4 | from django.db.models import Model
5 | from django.shortcuts import resolve_url
6 |
7 | from .constants import TURBO_STREAM_MIME_TYPE, ResponseFormat
8 | from .response import HttpResponseSeeOther
9 |
10 |
11 | def redirect_303(to: Union[str, Model], *args, **kwargs) -> HttpResponseSeeOther:
12 | """Sends an HTTP 303 redirect.
13 |
14 | All arguments are forwarded to django.shortcuts.resolve_url to generate the redirect URL.
15 |
16 | https://github.com/django/django/blob/9c436a09b3a641874881706495ae07293aa97c2f/django/shortcuts.py#L151
17 | """
18 | return HttpResponseSeeOther(resolve_url(to, *args, **kwargs))
19 |
20 |
21 | def get_respond_to(request):
22 | resp_format = ResponseFormat()
23 |
24 | accept_header = request.headers.get("Accept", "*/*")
25 |
26 | # Most browsers send Accept: */* by default, so this would return True for all content types
27 | # we do explicitly check here
28 | if TURBO_STREAM_MIME_TYPE in accept_header:
29 | resp_format.turbo_stream = True
30 |
31 | # Most browsers send Accept: */* by default, so this would return True for all content types
32 | # we do explicitly check here
33 | if "application/json" in accept_header:
34 | resp_format.json = True
35 |
36 | if request.accepts("text/html"):
37 | # fallback
38 | resp_format.html = True
39 |
40 | return resp_format
41 |
42 |
43 | @contextmanager
44 | def respond_to(request):
45 | """
46 | Inspired by Rails
47 |
48 | https://www.writesoftwarewell.com/how-respond_to-method-works-rails/
49 |
50 | respond_to do |format|
51 | format.turbo_stream { render turbo_stream: turbo_stream_template }
52 | end
53 | """
54 | resp_format: ResponseFormat = get_respond_to(request)
55 | try:
56 | yield resp_format
57 | finally:
58 | # Clean-up code, if needed
59 | pass
60 |
--------------------------------------------------------------------------------
/src/turbo_helper/signals.py:
--------------------------------------------------------------------------------
1 | from django.db.models.signals import post_delete, post_save
2 |
3 |
4 | def after_create_commit(sender):
5 | def decorator(handler_func):
6 | def wrapper(sender, instance, created, **kwargs):
7 | if created:
8 | handler_func(
9 | sender=sender, instance=instance, created=created, **kwargs
10 | )
11 |
12 | # Connect the wrapper function to the post_save signal
13 | post_save.connect(wrapper, sender=sender)
14 |
15 | # Return the wrapper function to be used as the signal handler
16 | return wrapper
17 |
18 | # Return the decorator function
19 | return decorator
20 |
21 |
22 | def after_update_commit(sender):
23 | def decorator(handler_func):
24 | def wrapper(sender, instance, created, **kwargs):
25 | if not created:
26 | handler_func(
27 | sender=sender, instance=instance, created=created, **kwargs
28 | )
29 |
30 | # Connect the wrapper function to the post_save signal
31 | post_save.connect(wrapper, sender=sender)
32 |
33 | # Return the wrapper function to be used as the signal handler
34 | return wrapper
35 |
36 | # Return the decorator function
37 | return decorator
38 |
39 |
40 | def after_delete_commit(sender):
41 | def decorator(handler_func):
42 | def wrapper(sender, instance, **kwargs):
43 | handler_func(sender=sender, instance=instance, **kwargs)
44 |
45 | # Connect the wrapper function to the post_delete signal
46 | post_delete.connect(wrapper, sender=sender)
47 |
48 | # Return the wrapper function to be used as the signal handler
49 | return wrapper
50 |
51 | # Return the decorator function
52 | return decorator
53 |
--------------------------------------------------------------------------------
/src/turbo_helper/stream.py:
--------------------------------------------------------------------------------
1 | from django.template.loader import render_to_string
2 |
3 | from turbo_helper.renderers import render_turbo_stream
4 | from turbo_helper.response import TurboStreamResponse
5 |
6 |
7 | class TurboStream:
8 | """
9 | https://github.com/hotwired/turbo-rails/blob/066396c67d4cee740c0348089955d7e8cdaa2cb0/app/models/turbo/streams/tag_builder.rb
10 | """
11 |
12 | def __init__(self):
13 | self.registered_actions = []
14 |
15 | def is_registered(self, name):
16 | return name in self.registered_actions
17 |
18 | def action(self, action, target, content=None, **kwargs):
19 | if not content and kwargs.get("template", None):
20 | # render template content
21 | template = kwargs.pop("template")
22 | context = kwargs.pop("context", {})
23 | request = kwargs.pop("request", None)
24 |
25 | content = render_to_string(template, context=context, request=request)
26 |
27 | return render_turbo_stream(
28 | action=action, content=content, target=target, attributes=kwargs
29 | )
30 |
31 | def action_all(self, action, targets, content=None, **kwargs):
32 | if not content and kwargs.get("template", None):
33 | # render template content
34 | template = kwargs.pop("template")
35 | context = kwargs.pop("context", {})
36 | request = kwargs.pop("request", None)
37 |
38 | content = render_to_string(template, context=context, request=request)
39 |
40 | return render_turbo_stream(
41 | action=action, content=content, targets=targets, attributes=kwargs
42 | )
43 |
44 | def response(self, *args, **kwargs):
45 | """
46 | Shortcut for TurboStreamResponse
47 | """
48 | return TurboStreamResponse(*args, **kwargs)
49 |
50 |
51 | turbo_stream = TurboStream()
52 |
53 |
54 | def register_turbo_stream_action(name):
55 | def decorator(func):
56 | if hasattr(turbo_stream, name):
57 | raise AttributeError(
58 | f"TurboStream action '{name}' already exists in turbo_stream"
59 | )
60 | setattr(turbo_stream, name, func)
61 | turbo_stream.registered_actions.append(name)
62 | return func
63 |
64 | return decorator
65 |
66 |
67 | def action_proxy(action, target=None, targets=None, **kwargs):
68 | """
69 | https://github.com/marcoroth/turbo_power-rails/issues/35
70 | """
71 | if target:
72 | func = getattr(turbo_stream, f"{action}")
73 | return func(
74 | target=target,
75 | **kwargs,
76 | )
77 | elif targets:
78 | func = getattr(turbo_stream, f"{action}_all", None)
79 |
80 | if func:
81 | return func(
82 | targets=targets,
83 | **kwargs,
84 | )
85 | else:
86 | # fallback to pass targets to the single target handler
87 | # we do this because of turbo_power
88 | func = getattr(turbo_stream, f"{action}")
89 | return func(
90 | targets=targets,
91 | **kwargs,
92 | )
93 |
94 |
95 | ################################################################################
96 |
97 |
98 | @register_turbo_stream_action("append")
99 | def append(target, content=None, **kwargs):
100 | return turbo_stream.action("append", target, content, **kwargs)
101 |
102 |
103 | @register_turbo_stream_action("after")
104 | def after(target, content=None, **kwargs):
105 | return turbo_stream.action("after", target, content, **kwargs)
106 |
107 |
108 | @register_turbo_stream_action("before")
109 | def before(target, content=None, **kwargs):
110 | return turbo_stream.action("before", target, content, **kwargs)
111 |
112 |
113 | @register_turbo_stream_action("prepend")
114 | def prepend(target, content=None, **kwargs):
115 | return turbo_stream.action("prepend", target, content, **kwargs)
116 |
117 |
118 | @register_turbo_stream_action("remove")
119 | def remove(target, **kwargs):
120 | return turbo_stream.action("remove", target, **kwargs)
121 |
122 |
123 | @register_turbo_stream_action("replace")
124 | def replace(target, content=None, **kwargs):
125 | return turbo_stream.action("replace", target, content, **kwargs)
126 |
127 |
128 | @register_turbo_stream_action("update")
129 | def update(target, content=None, **kwargs):
130 | return turbo_stream.action("update", target, content, **kwargs)
131 |
132 |
133 | ################################################################################
134 |
135 |
136 | @register_turbo_stream_action("append_all")
137 | def append_all(targets, content=None, **kwargs):
138 | return turbo_stream.action_all("append", targets, content, **kwargs)
139 |
140 |
141 | @register_turbo_stream_action("after_all")
142 | def after_all(targets, content=None, **kwargs):
143 | return turbo_stream.action_all("after", targets, content, **kwargs)
144 |
145 |
146 | @register_turbo_stream_action("before_all")
147 | def before_all(targets, content=None, **kwargs):
148 | return turbo_stream.action_all("before", targets, content, **kwargs)
149 |
150 |
151 | @register_turbo_stream_action("prepend_all")
152 | def prepend_all(targets, content=None, **kwargs):
153 | return turbo_stream.action_all("prepend", targets, content, **kwargs)
154 |
155 |
156 | @register_turbo_stream_action("remove_all")
157 | def remove_all(targets, **kwargs):
158 | return turbo_stream.action_all("remove", targets, **kwargs)
159 |
160 |
161 | @register_turbo_stream_action("replace_all")
162 | def replace_all(targets, content=None, **kwargs):
163 | return turbo_stream.action_all("replace", targets, content, **kwargs)
164 |
165 |
166 | @register_turbo_stream_action("update_all")
167 | def update_all(targets, content=None, **kwargs):
168 | return turbo_stream.action_all("update", targets, content, **kwargs)
169 |
--------------------------------------------------------------------------------
/src/turbo_helper/templatetags/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/rails-inspire-django/django-turbo-helper/777851c153602218503b1aa7432c8313147f52d1/src/turbo_helper/templatetags/__init__.py
--------------------------------------------------------------------------------
/src/turbo_helper/templatetags/turbo_helper.py:
--------------------------------------------------------------------------------
1 | from django import template
2 | from django.template import Node, TemplateSyntaxError
3 | from django.template.base import token_kwargs
4 | from template_simplify.templatetags.template_simplify import class_names, dom_id
5 |
6 | from turbo_helper.renderers import render_turbo_frame, render_turbo_stream_from
7 | from turbo_helper.stream import action_proxy
8 |
9 | register = template.Library()
10 |
11 | register.simple_tag(dom_id, name="dom_id")
12 | register.tag(class_names)
13 |
14 |
15 | class TurboFrameTagNode(Node):
16 | def __init__(self, frame_id, nodelist, extra_context=None):
17 | self.frame_id = frame_id
18 | self.nodelist = nodelist
19 | self.extra_context = extra_context or {}
20 |
21 | def __repr__(self):
22 | return "<%s>" % self.__class__.__name__
23 |
24 | def render(self, context):
25 | children = self.nodelist.render(context)
26 |
27 | attributes = {
28 | key: str(value.resolve(context))
29 | for key, value in self.extra_context.items()
30 | }
31 |
32 | return render_turbo_frame(
33 | frame_id=self.frame_id.resolve(context),
34 | attributes=attributes,
35 | content=children,
36 | )
37 |
38 |
39 | class TurboStreamTagNode(Node):
40 | def __init__(self, action, target, targets, nodelist, extra_context=None):
41 | self.action = action
42 | self.target = target
43 | self.targets = targets
44 | self.nodelist = nodelist
45 | self.extra_context = extra_context or {}
46 |
47 | def __repr__(self):
48 | return "<%s>" % self.__class__.__name__
49 |
50 | def render(self, context):
51 | action = self.action.resolve(context)
52 | children = self.nodelist.render(context)
53 |
54 | attributes = {
55 | key: str(value.resolve(context))
56 | for key, value in self.extra_context.items()
57 | }
58 |
59 | target = self.target.resolve(context) if self.target else None
60 | targets = self.targets.resolve(context) if self.targets else None
61 |
62 | return action_proxy(
63 | action=action,
64 | target=target,
65 | targets=targets,
66 | content=children,
67 | **attributes,
68 | )
69 |
70 |
71 | class TurboStreamFromTagNode(Node):
72 | def __init__(self, stream_name_array):
73 | """
74 | TODO: Support override channel
75 | """
76 | self.stream_name_array = stream_name_array
77 |
78 | def __repr__(self):
79 | return "<%s>" % self.__class__.__name__
80 |
81 | def render(self, context):
82 | stream_name_array = [
83 | stream_name.resolve(context) for stream_name in self.stream_name_array
84 | ]
85 |
86 | return render_turbo_stream_from(stream_name_array)
87 |
88 |
89 | @register.tag("turbo_frame")
90 | def turbo_frame_tag(parser, token):
91 | args = token.split_contents()
92 |
93 | if len(args) < 2:
94 | raise TemplateSyntaxError(
95 | "'turbo_frame' tag requires at least one argument to set the id"
96 | )
97 |
98 | frame_id = parser.compile_filter(args[1])
99 |
100 | # Get all elements of the list except the first one
101 | remaining_bits = args[2:]
102 |
103 | # Parse the remaining bits as keyword arguments
104 | extra_context = token_kwargs(remaining_bits, parser, support_legacy=True)
105 |
106 | # If there are still remaining bits after parsing the keyword arguments,
107 | # raise an exception indicating that an invalid token was received
108 | if remaining_bits:
109 | raise TemplateSyntaxError(
110 | "%r received an invalid token: %r" % (args[0], remaining_bits[0])
111 | )
112 |
113 | # Parse the content between the start and end tags
114 | nodelist = parser.parse(("endturbo_frame",))
115 |
116 | # Delete the token that triggered this function from the parser's token stream
117 | parser.delete_first_token()
118 |
119 | return TurboFrameTagNode(frame_id, nodelist, extra_context=extra_context)
120 |
121 |
122 | @register.tag("turbo_stream")
123 | def turbo_stream_tag(parser, token):
124 | args = token.split_contents()
125 |
126 | if len(args) < 3:
127 | raise TemplateSyntaxError(
128 | "'turbo_stream' tag requires two arguments, first is action, second is the target_id"
129 | )
130 |
131 | action = parser.compile_filter(args[1])
132 | target = parser.compile_filter(args[2])
133 |
134 | # Get all elements of the list except the first one
135 | remaining_bits = args[3:]
136 |
137 | # Parse the remaining bits as keyword arguments
138 | extra_context = token_kwargs(remaining_bits, parser, support_legacy=True)
139 |
140 | # If there are still remaining bits after parsing the keyword arguments,
141 | # raise an exception indicating that an invalid token was received
142 | if remaining_bits:
143 | raise TemplateSyntaxError(
144 | "%r received an invalid token: %r" % (args[0], remaining_bits[0])
145 | )
146 |
147 | # Parse the content between the start and end tags
148 | nodelist = parser.parse(("endturbo_stream",))
149 |
150 | # Delete the token that triggered this function from the parser's token stream
151 | parser.delete_first_token()
152 |
153 | return TurboStreamTagNode(
154 | action,
155 | target=target,
156 | targets=None,
157 | nodelist=nodelist,
158 | extra_context=extra_context,
159 | )
160 |
161 |
162 | @register.tag("turbo_stream_all")
163 | def turbo_stream_all_tag(parser, token):
164 | args = token.split_contents()
165 |
166 | if len(args) < 3:
167 | raise TemplateSyntaxError(
168 | "'turbo_stream_all' tag requires two arguments, first is action, second is the target_id"
169 | )
170 |
171 | action = parser.compile_filter(args[1])
172 | targets = parser.compile_filter(args[2])
173 |
174 | # Get all elements of the list except the first one
175 | remaining_bits = args[3:]
176 |
177 | # Parse the remaining bits as keyword arguments
178 | extra_context = token_kwargs(remaining_bits, parser, support_legacy=True)
179 |
180 | # If there are still remaining bits after parsing the keyword arguments,
181 | # raise an exception indicating that an invalid token was received
182 | if remaining_bits:
183 | raise TemplateSyntaxError(
184 | "%r received an invalid token: %r" % (args[0], remaining_bits[0])
185 | )
186 |
187 | # Parse the content between the start and end tags
188 | nodelist = parser.parse(("endturbo_stream_all",))
189 |
190 | # Delete the token that triggered this function from the parser's token stream
191 | parser.delete_first_token()
192 |
193 | return TurboStreamTagNode(
194 | action,
195 | target=None,
196 | targets=targets,
197 | nodelist=nodelist,
198 | extra_context=extra_context,
199 | )
200 |
201 |
202 | @register.tag("turbo_stream_from")
203 | def turbo_stream_from_tag(parser, token):
204 | args = token.split_contents()
205 |
206 | if len(args) < 1:
207 | raise TemplateSyntaxError(
208 | "'turbo_stream_from' tag requires at least one arguments"
209 | )
210 |
211 | remaining_bits = args[1:]
212 | stream_name_array = [parser.compile_filter(bit) for bit in remaining_bits]
213 |
214 | return TurboStreamFromTagNode(stream_name_array)
215 |
--------------------------------------------------------------------------------
/src/turbo_helper/turbo_power.py:
--------------------------------------------------------------------------------
1 | """
2 | https://github.com/marcoroth/turbo_power-rails
3 |
4 | Bring turbo_power to Django
5 | """
6 | import json
7 |
8 | from django.utils.safestring import mark_safe
9 |
10 | from turbo_helper import register_turbo_stream_action, turbo_stream
11 |
12 |
13 | def transform_attributes(attributes):
14 | transformed_attributes = {}
15 | for key, value in attributes.items():
16 | transformed_key = transform_key(key)
17 | transformed_value = transform_value(value)
18 | transformed_attributes[transformed_key] = transformed_value
19 | return transformed_attributes
20 |
21 |
22 | def transform_key(key):
23 | return str(key).replace("_", "-")
24 |
25 |
26 | def transform_value(value):
27 | if isinstance(value, str):
28 | return value
29 | elif isinstance(value, (int, float, bool)):
30 | return str(value).lower()
31 | elif value is None:
32 | return None
33 | else:
34 | return json.dumps(value)
35 |
36 |
37 | ################################################################################
38 |
39 |
40 | def custom_action(action, target=None, content=None, **kwargs):
41 | return turbo_stream.action(
42 | action, target=target, content=content, **transform_attributes(kwargs)
43 | )
44 |
45 |
46 | def custom_action_all(action, targets=None, content=None, **kwargs):
47 | return turbo_stream.action_all(
48 | action, targets=targets, content=content, **transform_attributes(kwargs)
49 | )
50 |
51 |
52 | ################################################################################
53 |
54 | """
55 | When defining custom action, `target` or `targets` are both supported
56 |
57 | def example_action(targets=None, **attributes):
58 | pass
59 |
60 | This action by default will use `targets` as the target selector
61 |
62 | turbo_stream.example_action("A")
63 | Generate: {{ msg }}
3 |
--------------------------------------------------------------------------------
/tests/templates/simple.html:
--------------------------------------------------------------------------------
1 | {{ msg }}
2 |
--------------------------------------------------------------------------------
/tests/templates/todoitem.turbo_stream.html:
--------------------------------------------------------------------------------
1 | {% load turbo_helper %}
2 |
3 | {% turbo_stream 'append' 'todo_list' %}
4 | {{ instance.description }}
5 | {% endturbo_stream %}
6 |
--------------------------------------------------------------------------------
/tests/test_broadcasts.py:
--------------------------------------------------------------------------------
1 | import unittest
2 | from unittest import mock
3 |
4 | import pytest
5 |
6 | import turbo_helper.channels.broadcasts
7 | from tests.testapp.models import TodoItem
8 | from tests.utils import assert_dom_equal
9 | from turbo_helper import dom_id
10 | from turbo_helper.channels.broadcasts import (
11 | broadcast_action_to,
12 | broadcast_render_to,
13 | broadcast_stream_to,
14 | )
15 |
16 | pytestmark = pytest.mark.django_db
17 |
18 |
19 | class TestBroadcastStreamTo:
20 | def test_broadcast_stream_to(self, monkeypatch):
21 | mock_cable_broadcast = mock.MagicMock(name="cable_broadcast")
22 | monkeypatch.setattr(
23 | turbo_helper.channels.broadcasts, "cable_broadcast", mock_cable_broadcast
24 | )
25 |
26 | ################################################################################
27 |
28 | broadcast_stream_to("test", content="hello world")
29 |
30 | mock_cable_broadcast.assert_called_with(
31 | group_name="test", message="hello world"
32 | )
33 |
34 | ################################################################################
35 | todo_item = TodoItem.objects.create(description="Test Model")
36 |
37 | broadcast_stream_to(todo_item, content="hello world")
38 |
39 | mock_cable_broadcast.assert_called_with(
40 | group_name=dom_id(todo_item), message="hello world"
41 | )
42 |
43 | ################################################################################
44 | todo_item = TodoItem.objects.create(description="Test Model")
45 |
46 | broadcast_stream_to(todo_item, "test", content="hello world")
47 |
48 | mock_cable_broadcast.assert_called_with(
49 | group_name=f"{dom_id(todo_item)}_test", message="hello world"
50 | )
51 |
52 |
53 | class TestBroadcastActionTo:
54 | def test_broadcast_action_to(self, monkeypatch):
55 | mock_cable_broadcast = mock.MagicMock(name="cable_broadcast")
56 | monkeypatch.setattr(
57 | turbo_helper.channels.broadcasts, "cable_broadcast", mock_cable_broadcast
58 | )
59 |
60 | ################################################################################
61 |
62 | broadcast_action_to("tasks", action="remove", target="new_task")
63 |
64 | assert mock_cable_broadcast.call_args.kwargs["group_name"] == "tasks"
65 | assert_dom_equal(
66 | mock_cable_broadcast.call_args.kwargs["message"],
67 | '',
68 | )
69 |
70 | ################################################################################
71 | todo_item = TodoItem.objects.create(description="Test Model")
72 |
73 | broadcast_action_to(todo_item, action="remove", target="new_task")
74 |
75 | mock_cable_broadcast.assert_called_with(
76 | group_name=dom_id(todo_item), message=unittest.mock.ANY
77 | )
78 |
79 | ################################################################################
80 | todo_item = TodoItem.objects.create(description="Test Model")
81 |
82 | broadcast_action_to(todo_item, "test", action="remove", target="new_task")
83 |
84 | mock_cable_broadcast.assert_called_with(
85 | group_name=f"{dom_id(todo_item)}_test", message=unittest.mock.ANY
86 | )
87 |
88 |
89 | class TestBroadcastRenderTo:
90 | def test_broadcast_render_to(self, monkeypatch):
91 | mock_cable_broadcast = mock.MagicMock(name="cable_broadcast")
92 | monkeypatch.setattr(
93 | turbo_helper.channels.broadcasts, "cable_broadcast", mock_cable_broadcast
94 | )
95 |
96 | ################################################################################
97 | todo_item = TodoItem.objects.create(description="test")
98 |
99 | broadcast_render_to(
100 | todo_item,
101 | template="todoitem.turbo_stream.html",
102 | context={
103 | "instance": todo_item,
104 | },
105 | )
106 |
107 | mock_cable_broadcast.assert_called_with(
108 | group_name=dom_id(todo_item), message=unittest.mock.ANY
109 | )
110 |
111 | assert_dom_equal(
112 | mock_cable_broadcast.call_args.kwargs["message"],
113 | 'test
',
114 | )
115 |
--------------------------------------------------------------------------------
/tests/test_channels.py:
--------------------------------------------------------------------------------
1 | import pytest
2 | from actioncable import ActionCableConsumer, cable_channel_register, compact_encode_json
3 | from actioncable.utils import async_cable_broadcast
4 | from channels.testing import WebsocketCommunicator
5 |
6 | from turbo_helper.channels.stream_name import generate_signed_stream_key
7 | from turbo_helper.channels.streams_channel import TurboStreamCableChannel
8 |
9 | # register the TurboStreamCableChannel
10 | cable_channel_register(TurboStreamCableChannel)
11 |
12 |
13 | @pytest.mark.asyncio
14 | async def test_subscribe():
15 | communicator = WebsocketCommunicator(
16 | ActionCableConsumer.as_asgi(), "/cable", subprotocols=["actioncable-v1-json"]
17 | )
18 | connected, subprotocol = await communicator.connect(timeout=10)
19 | assert connected
20 | response = await communicator.receive_json_from()
21 | assert response == {"type": "welcome"}
22 |
23 | # Subscribe
24 | group_name = "test"
25 | subscribe_command = {
26 | "command": "subscribe",
27 | "identifier": compact_encode_json(
28 | {
29 | "channel": TurboStreamCableChannel.__name__,
30 | "signed_stream_name": generate_signed_stream_key(group_name),
31 | }
32 | ),
33 | }
34 |
35 | await communicator.send_to(text_data=compact_encode_json(subscribe_command))
36 | response = await communicator.receive_json_from(timeout=10)
37 | assert response["type"] == "confirm_subscription"
38 |
39 | # Message
40 | await async_cable_broadcast(group_name, "html_snippet")
41 |
42 | response = await communicator.receive_json_from(timeout=5)
43 | assert response["message"] == "html_snippet"
44 |
45 | # Unsubscribe
46 | group_name = "test"
47 | subscribe_command = {
48 | "command": "unsubscribe",
49 | "identifier": compact_encode_json(
50 | {
51 | "channel": TurboStreamCableChannel.__name__,
52 | "signed_stream_name": generate_signed_stream_key(group_name),
53 | }
54 | ),
55 | }
56 |
57 | await communicator.send_to(text_data=compact_encode_json(subscribe_command))
58 |
59 | # Message
60 | await async_cable_broadcast(group_name, "html_snippet")
61 |
62 | assert await communicator.receive_nothing() is True
63 |
64 | # Close
65 | await communicator.disconnect()
66 |
--------------------------------------------------------------------------------
/tests/test_middleware.py:
--------------------------------------------------------------------------------
1 | import http
2 |
3 | import pytest
4 | from django.http import (
5 | HttpResponse,
6 | HttpResponsePermanentRedirect,
7 | HttpResponseRedirect,
8 | )
9 |
10 | from turbo_helper.middleware import TurboMiddleware
11 | from turbo_helper.response import TurboStreamResponse
12 |
13 |
14 | @pytest.fixture
15 | def get_response():
16 | return lambda req: HttpResponse()
17 |
18 |
19 | class TestTurboMiddleware:
20 | def test_accept_header_not_found(self, rf, get_response):
21 | headers = {
22 | "ACCEPT": "text/html",
23 | }
24 | headers = {f"HTTP_{key.upper()}": value for key, value in headers.items()}
25 | req = rf.get("/", **headers)
26 | TurboMiddleware(get_response)(req)
27 | assert not req.turbo
28 | assert req.turbo.frame is None
29 |
30 | def test_accept_header_found(self, rf, get_response):
31 | headers = {
32 | "ACCEPT": "text/vnd.turbo-stream.html",
33 | }
34 | headers = {f"HTTP_{key.upper()}": value for key, value in headers.items()}
35 | req = rf.get("/", **headers)
36 | TurboMiddleware(get_response)(req)
37 | assert req.turbo
38 | assert req.turbo.frame is None
39 |
40 | def test_turbo_frame(self, rf, get_response):
41 | headers = {
42 | "ACCEPT": "text/vnd.turbo-stream.html",
43 | "TURBO_FRAME": "my-playlist",
44 | }
45 | headers = {
46 | f"HTTP_{key.upper()}": value for key, value in headers.items()
47 | } # Add "HTTP_" prefix
48 | req = rf.get("/", **headers)
49 | TurboMiddleware(get_response)(req)
50 | assert req.turbo
51 | assert req.turbo.frame == "my-playlist"
52 |
53 |
54 | class TestTurboMiddlewareAutoChangeStatusCode:
55 | def test_post_failed_form_submission(self, rf):
56 | headers = {
57 | "ACCEPT": "text/vnd.turbo-stream.html",
58 | "X-Turbo-Request-Id": "d4165765-488b-41a0-82b6-39126c40e3e0",
59 | }
60 | headers = {
61 | f"HTTP_{key.upper()}": value for key, value in headers.items()
62 | } # Add "HTTP_" prefix
63 | req = rf.post("/", **headers)
64 |
65 | def form_submission(request):
66 | # in Django, failed form submission will return 200
67 | return HttpResponse()
68 |
69 | resp = TurboMiddleware(form_submission)(req)
70 |
71 | assert resp.status_code == http.HTTPStatus.UNPROCESSABLE_ENTITY
72 |
73 | @pytest.mark.parametrize(
74 | "response_class", [HttpResponseRedirect, HttpResponsePermanentRedirect]
75 | )
76 | def test_post_succeed_form_submission(self, rf, response_class):
77 | headers = {
78 | "ACCEPT": "text/vnd.turbo-stream.html",
79 | "X-Turbo-Request-Id": "d4165765-488b-41a0-82b6-39126c40e3e0",
80 | }
81 | headers = {
82 | f"HTTP_{key.upper()}": value for key, value in headers.items()
83 | } # Add "HTTP_" prefix
84 | req = rf.post("/", **headers)
85 |
86 | def form_submission(request):
87 | # in Django, failed form submission will return 301, 302
88 | return response_class("/success/")
89 |
90 | resp = TurboMiddleware(form_submission)(req)
91 |
92 | assert resp.status_code == http.HTTPStatus.SEE_OTHER
93 |
94 | def test_post_turbo_stream(self, rf, get_response):
95 | """
96 | Do not change if response is TurboStreamResponse
97 | """
98 | headers = {
99 | "ACCEPT": "text/vnd.turbo-stream.html",
100 | "X-Turbo-Request-Id": "d4165765-488b-41a0-82b6-39126c40e3e0",
101 | }
102 | headers = {
103 | f"HTTP_{key.upper()}": value for key, value in headers.items()
104 | } # Add "HTTP_" prefix
105 | req = rf.post("/", **headers)
106 |
107 | def form_submission(request):
108 | return TurboStreamResponse()
109 |
110 | resp = TurboMiddleware(form_submission)(req)
111 | assert resp.status_code == http.HTTPStatus.OK
112 |
--------------------------------------------------------------------------------
/tests/test_shortcuts.py:
--------------------------------------------------------------------------------
1 | import http
2 |
3 | import pytest
4 |
5 | from turbo_helper.shortcuts import redirect_303, respond_to
6 |
7 | pytestmark = pytest.mark.django_db
8 |
9 |
10 | class TestRedirect303:
11 | def test_plain_url(self):
12 | resp = redirect_303("/")
13 | assert resp.status_code == http.HTTPStatus.SEE_OTHER
14 | assert resp.url == "/"
15 |
16 | def test_view_name(self):
17 | resp = redirect_303("index")
18 | assert resp.status_code == http.HTTPStatus.SEE_OTHER
19 | assert resp.url == "/"
20 |
21 | def test_model(self, todo):
22 | resp = redirect_303(todo)
23 | assert resp.status_code == http.HTTPStatus.SEE_OTHER
24 | assert resp.url == f"/todos/{todo.id}/"
25 |
26 |
27 | class TestResponseTo:
28 | def test_response_to(self, rf):
29 | req = rf.get("/", HTTP_ACCEPT="*/*")
30 | with respond_to(req) as resp:
31 | """
32 | wildcard only work for HTML
33 | """
34 | assert resp.html
35 | assert not resp.turbo_stream
36 | assert not resp.json
37 |
38 | req = rf.get("/", HTTP_ACCEPT="text/vnd.turbo-stream.html")
39 | with respond_to(req) as resp:
40 | assert resp.turbo_stream
41 | assert not resp.html
42 | assert not resp.json
43 |
44 | req = rf.get(
45 | "/", HTTP_ACCEPT="text/html; charset=utf-8, application/json; q=0.9"
46 | )
47 | with respond_to(req) as resp:
48 | assert not resp.turbo_stream
49 | assert resp.html
50 | assert resp.json
51 |
52 | req = rf.get(
53 | "/",
54 | HTTP_ACCEPT="text/vnd.turbo-stream.html, text/html, application/xhtml+xml",
55 | )
56 | with respond_to(req) as resp:
57 | assert resp.turbo_stream
58 | assert resp.html
59 | assert not resp.json
60 |
--------------------------------------------------------------------------------
/tests/test_signal_handler.py:
--------------------------------------------------------------------------------
1 | import pytest
2 |
3 | from tests.testapp.models import TodoItem
4 | from turbo_helper.signals import (
5 | after_create_commit,
6 | after_delete_commit,
7 | after_update_commit,
8 | )
9 |
10 | pytestmark = pytest.mark.django_db
11 |
12 |
13 | class TestSignalHandler:
14 | def test_after_create_commit_signal_handler(self):
15 | handler_called_1 = False
16 |
17 | def handler_func_1(sender, instance, created, **kwargs):
18 | nonlocal handler_called_1
19 | handler_called_1 = True
20 |
21 | handler_called_2 = False
22 |
23 | def handler_func_2(sender, instance, created, **kwargs):
24 | nonlocal handler_called_2
25 | handler_called_2 = True
26 |
27 | decorated_handler = after_create_commit(sender=TodoItem)( # noqa: F841
28 | handler_func_1
29 | )
30 | decorated_handler_2 = after_create_commit(sender=TodoItem)( # noqa: F841
31 | handler_func_2
32 | )
33 |
34 | TodoItem.objects.create(description="Test Model")
35 |
36 | assert handler_called_1
37 | assert handler_called_2
38 |
39 | def test_after_update_commit_signal_handler(self):
40 | handler_called_1 = False
41 |
42 | def handler_func_1(sender, instance, created, **kwargs):
43 | nonlocal handler_called_1
44 | handler_called_1 = True
45 |
46 | handler_called_2 = False
47 |
48 | def handler_func_2(sender, instance, created, **kwargs):
49 | nonlocal handler_called_2
50 | handler_called_2 = True
51 |
52 | decorated_handler = after_update_commit(sender=TodoItem)( # noqa: F841
53 | handler_func_1
54 | )
55 | decorated_handler_2 = after_update_commit(sender=TodoItem)( # noqa: F841
56 | handler_func_2
57 | )
58 |
59 | todo_item = TodoItem.objects.create(description="Test Model")
60 | todo_item.description = "test"
61 | todo_item.save()
62 |
63 | assert handler_called_1
64 | assert handler_called_2
65 |
66 | def test_after_delete_commit_signal_handler(self):
67 | handler_called_1 = False
68 |
69 | def handler_func_1(sender, instance, **kwargs):
70 | nonlocal handler_called_1
71 | handler_called_1 = True
72 |
73 | handler_called_2 = False
74 |
75 | def handler_func_2(sender, instance, **kwargs):
76 | nonlocal handler_called_2
77 | handler_called_2 = True
78 |
79 | decorated_handler = after_delete_commit(sender=TodoItem)( # noqa: F841
80 | handler_func_1
81 | )
82 | decorated_handler_2 = after_delete_commit(sender=TodoItem)( # noqa: F841
83 | handler_func_2
84 | )
85 |
86 | todo_item = TodoItem.objects.create(description="Test Model")
87 | todo_item.delete()
88 |
89 | assert handler_called_1
90 | assert handler_called_2
91 |
--------------------------------------------------------------------------------
/tests/test_stream.py:
--------------------------------------------------------------------------------
1 | from django.http import HttpRequest
2 | from django.utils.safestring import mark_safe
3 |
4 | from tests.test_tags import render
5 | from tests.utils import assert_dom_equal
6 | from turbo_helper import turbo_stream
7 | from turbo_helper.constants import TURBO_STREAM_MIME_TYPE
8 |
9 |
10 | class TestTurboStream:
11 | def test_render(self):
12 | s = turbo_stream.append("dom_id", "OK")
13 | assert (
14 | s
15 | == 'OK'
16 | )
17 |
18 | def test_render_escape_behavior(self):
19 | s = turbo_stream.append("dom_id", "")
20 | assert (
21 | s
22 | == '<script></script>'
23 | )
24 |
25 | s = turbo_stream.append("dom_id", mark_safe(""))
26 | assert (
27 | s
28 | == ''
29 | )
30 |
31 | def test_template(self):
32 | s = turbo_stream.append(
33 | "dom_id", template="simple.html", context={"msg": "my content"}
34 | )
35 | assert "my content" in s
36 | assert '' in s
37 |
38 | def test_template_csrf(self):
39 | s = turbo_stream.append(
40 | "dom_id",
41 | template="csrf.html",
42 | context={"msg": "my content"},
43 | request=HttpRequest(),
44 | )
45 |
46 | assert "my content" in s
47 | assert '' in s
49 |
50 | def test_template_with_req_arg(self, rf):
51 | s = turbo_stream.append(
52 | "dom_id",
53 | template="simple.html",
54 | context={"msg": "my content"},
55 | request=rf.get("/"),
56 | )
57 | assert "my content" in s
58 | assert '' in s
59 |
60 | def test_template_multiple_targets(self):
61 | s = turbo_stream.append_all(
62 | ".old_records", template="simple.html", context={"msg": "my content"}
63 | )
64 | assert "my content" in s
65 | assert '' in s
66 |
67 | def test_custom_register(self, register_toast_action):
68 | s = turbo_stream.toast("dom_id", message="hello world", position="right")
69 | assert (
70 | ''
71 | in s
72 | )
73 |
74 | # test attributes escape
75 | s = turbo_stream.toast("dom_id", message='hello "world"', position="right")
76 | assert "hello "world"" in s
77 |
78 | def test_response(self, rf):
79 | response = turbo_stream.response(
80 | [
81 | turbo_stream.append("dom_id", "OK"),
82 | turbo_stream.append(
83 | "dom_id_2",
84 | template="simple.html",
85 | context={"msg": "my content"},
86 | request=rf.get("/"),
87 | ),
88 | ]
89 | )
90 |
91 | assert response.headers["content-type"] == TURBO_STREAM_MIME_TYPE
92 |
93 | assert (
94 | 'OK'
95 | in response.content.decode("utf-8")
96 | )
97 |
98 | assert "my content" in response.content.decode("utf-8")
99 | assert (
100 | ''
101 | in response.content.decode("utf-8")
102 | )
103 |
104 |
105 | class TestMorphMethod:
106 | def test_update_morph_method(self):
107 | stream = 'Morph
'
108 | assert_dom_equal(
109 | stream,
110 | turbo_stream.update("#input", mark_safe("Morph
"), method="morph"),
111 | )
112 |
113 | def test_replace_morph_method(self):
114 | stream = 'Morph
'
115 | assert_dom_equal(
116 | stream,
117 | turbo_stream.replace("#input", mark_safe("Morph
"), method="morph"),
118 | )
119 |
120 | def test_tag(self, register_toast_action):
121 | template = """
122 | {% load turbo_helper %}
123 |
124 | {% turbo_stream "update" dom_id method="morph" %}{% endturbo_stream %}
125 | """
126 | output = render(template, {"dom_id": "test"}).strip()
127 | assert '' in output
128 |
--------------------------------------------------------------------------------
/tests/test_tags.py:
--------------------------------------------------------------------------------
1 | import pytest
2 | from django.template import Context, Template
3 |
4 | from tests.testapp.models import TodoItem
5 | from tests.utils import assert_dom_equal
6 | from turbo_helper.templatetags.turbo_helper import dom_id
7 |
8 | pytestmark = pytest.mark.django_db
9 |
10 |
11 | def render(template, context):
12 | return Template(template).render(Context(context))
13 |
14 |
15 | class TestDomId:
16 | def test_instance(self, todo):
17 | result = dom_id(todo)
18 | assert "todoitem_1" == result
19 |
20 | setattr(todo, "to_key", "test_1") # noqa: B010
21 | result = dom_id(todo)
22 | assert "todoitem_test_1" == result
23 |
24 | def test_model(self):
25 | result = dom_id(TodoItem)
26 | assert "new_todoitem" == result
27 |
28 | def test_string(self):
29 | result = dom_id("test")
30 | assert "test" == result
31 |
32 | def test_prefix(self, todo):
33 | result = dom_id(todo, "test")
34 | assert "test_todoitem_1" == result
35 |
36 | def test_value_override(self):
37 | template = """
38 | {% load turbo_helper %}
39 |
40 | {% dom_id first as dom_id %}
41 |
42 |
43 | {% dom_id second as dom_id %}
44 |
45 |
46 |
47 | """
48 | output = render(
49 | template,
50 | {
51 | "first": "first",
52 | "second": "second",
53 | },
54 | ).strip()
55 | assert_dom_equal(
56 | output,
57 | ' ',
58 | )
59 |
60 |
61 | class TestClassNames:
62 | def test_logic(self):
63 | template = """
64 | {% load turbo_helper %}
65 |
66 |
67 | """
68 | output = render(template, {}).strip()
69 | assert_dom_equal(
70 | output,
71 | '',
72 | )
73 |
74 |
75 | class TestFrame:
76 | def test_string(self):
77 | template = """
78 | {% load turbo_helper %}
79 |
80 | {% turbo_frame "test" %}Loading...{% endturbo_frame %}
81 | """
82 | output = render(template, {}).strip()
83 | assert output == 'Loading...'
84 |
85 | def test_dom_id_variable(self):
86 | template = """
87 | {% load turbo_helper %}
88 |
89 | {% turbo_frame dom_id %}Loading...{% endturbo_frame %}
90 | """
91 | output = render(template, {"dom_id": "test"}).strip()
92 | assert output == 'Loading...'
93 |
94 | def test_src(self):
95 | template = """
96 | {% load turbo_helper %}
97 |
98 | {% turbo_frame dom_id src=src %}Loading...{% endturbo_frame %}
99 | """
100 | output = render(
101 | template, {"dom_id": "test", "src": "http://localhost:8000"}
102 | ).strip()
103 | assert (
104 | output
105 | == 'Loading...'
106 | )
107 |
108 | def test_other_attributes(self):
109 | template = """
110 | {% load turbo_helper %}
111 |
112 | {% turbo_frame dom_id src=src lazy="loading" %}Loading...{% endturbo_frame %}
113 | """
114 | output = render(
115 | template, {"dom_id": "test", "src": "http://localhost:8000"}
116 | ).strip()
117 | assert (
118 | output
119 | == 'Loading...'
120 | )
121 |
122 |
123 | class TestStream:
124 | def test_string(self):
125 | template = """
126 | {% load turbo_helper %}
127 |
128 | {% turbo_stream "append" 'test' %}Test{% endturbo_stream %}
129 | """
130 | output = render(template, {}).strip()
131 | assert (
132 | output
133 | == 'Test'
134 | )
135 |
136 | def test_dom_id_variable(self):
137 | template = """
138 | {% load turbo_helper %}
139 |
140 | {% turbo_stream "append" dom_id %}Test{% endturbo_stream %}
141 | """
142 | output = render(template, {"dom_id": "test"}).strip()
143 | assert (
144 | output
145 | == 'Test'
146 | )
147 |
148 | def test_custom_register(self, register_toast_action):
149 | template = """
150 | {% load turbo_helper %}
151 |
152 | {% turbo_stream "toast" dom_id message="Hello Word" position="right" %}{% endturbo_stream %}
153 | """
154 | output = render(template, {"dom_id": "test"}).strip()
155 | assert (
156 | ''
157 | in output
158 | )
159 |
160 |
161 | class TestStreamAll:
162 | def test_string(self):
163 | template = """
164 | {% load turbo_helper %}
165 |
166 | {% turbo_stream_all "remove" ".old_records" %}{% endturbo_stream_all %}
167 | """
168 | output = render(template, {}).strip()
169 | assert (
170 | output
171 | == ''
172 | )
173 |
174 | def test_dom_id_variable(self):
175 | template = """
176 | {% load turbo_helper %}
177 |
178 | {% turbo_stream_all "remove" dom_id %}{% endturbo_stream_all %}
179 | """
180 | output = render(template, {"dom_id": ".test"}).strip()
181 | assert (
182 | output
183 | == ''
184 | )
185 |
186 |
187 | class TestStreamFrom:
188 | def test_string(self):
189 | template = """
190 | {% load turbo_helper %}
191 |
192 | {% turbo_stream_from "test" %}
193 | """
194 | output = render(template, {}).strip()
195 | assert (
196 | output
197 | == ''
198 | )
199 |
200 | def test_dom_id_variable(self):
201 | template = """
202 | {% load turbo_helper %}
203 |
204 | {% turbo_stream_from "test" dom_id %}
205 | """
206 | output = render(template, {"dom_id": "todo_3"}).strip()
207 | assert (
208 | output
209 | == ''
210 | )
211 |
--------------------------------------------------------------------------------
/tests/test_turbo_power.py:
--------------------------------------------------------------------------------
1 | import pytest
2 |
3 | from tests.utils import assert_dom_equal
4 | from turbo_helper import turbo_stream
5 |
6 | pytestmark = pytest.mark.django_db
7 |
8 |
9 | class TestGraft:
10 | def test_graft(self):
11 | stream = ''
12 | assert_dom_equal(stream, turbo_stream.graft("#input", "#parent"))
13 |
14 | def test_graft_with_targets_and_html_as_kwargs(self):
15 | stream = ''
16 | assert_dom_equal(stream, turbo_stream.graft(targets="#input", parent="#parent"))
17 |
18 | stream = ''
19 | assert_dom_equal(stream, turbo_stream.graft(parent="#parent", targets="#input"))
20 |
21 | def test_graft_with_targets_as_positional_arg_and_html_as_kwarg(self):
22 | stream = ''
23 | assert_dom_equal(stream, turbo_stream.graft("#input", parent="#parent"))
24 |
25 | def test_graft_with_additional_arguments(self):
26 | stream = ''
27 | assert_dom_equal(
28 | stream, turbo_stream.graft("#input", parent="#parent", something="else")
29 | )
30 |
31 |
32 | class TestAddCssClass:
33 | def test_add_css_class(self):
34 | stream = ''
35 | assert_dom_equal(
36 | stream, turbo_stream.add_css_class("#element", "container text-center")
37 | )
38 |
39 | def test_add_css_class_with_targets_and_classes_as_kwargs(self):
40 | stream = ''
41 | assert_dom_equal(
42 | stream,
43 | turbo_stream.add_css_class(
44 | targets="#element", classes="container text-center"
45 | ),
46 | )
47 |
48 | def test_add_css_class_with_classes_and_targets_as_kwargs(self):
49 | stream = ''
50 | assert_dom_equal(
51 | stream,
52 | turbo_stream.add_css_class(
53 | classes="container text-center", targets="#element"
54 | ),
55 | )
56 |
57 | def test_add_css_class_with_targets_as_positional_arg_and_classes_as_kwarg(self):
58 | stream = ''
59 | assert_dom_equal(
60 | stream,
61 | turbo_stream.add_css_class("#element", classes="container text-center"),
62 | )
63 |
64 | def test_add_css_class_with_additional_arguments(self):
65 | stream = ''
66 | assert_dom_equal(
67 | stream,
68 | turbo_stream.add_css_class(
69 | "#element", classes="container text-center", something="else"
70 | ),
71 | )
72 |
73 | def test_add_css_class_with_classes_as_array(self):
74 | stream = ''
75 | assert_dom_equal(
76 | stream, turbo_stream.add_css_class("#element", ["container", "text-center"])
77 | )
78 |
79 | def test_add_css_class_with_classes_as_array_and_kwarg(self):
80 | stream = ''
81 | assert_dom_equal(
82 | stream,
83 | turbo_stream.add_css_class(
84 | "#element", classes=["container", "text-center"]
85 | ),
86 | )
87 |
88 |
89 | class TestDispatchEvent:
90 | def test_dispatch_event(self):
91 | stream = '{}'
92 | assert_dom_equal(
93 | stream, turbo_stream.dispatch_event("#element", "custom-event")
94 | )
95 |
96 | def test_dispatch_event_with_detail(self):
97 | stream = '{"count":1,"type":"custom","enabled":true,"ids":[1,2,3]}'
98 | assert_dom_equal(
99 | stream,
100 | turbo_stream.dispatch_event(
101 | "#element",
102 | "custom-event",
103 | detail={
104 | "count": 1,
105 | "type": "custom",
106 | "enabled": True,
107 | "ids": [1, 2, 3],
108 | },
109 | ),
110 | )
111 |
112 | def test_dispatch_event_with_name_as_kwarg(self):
113 | stream = '{}'
114 | assert_dom_equal(
115 | stream, turbo_stream.dispatch_event("#element", name="custom-event")
116 | )
117 |
118 | def test_dispatch_event_with_targets_and_name_as_kwarg(self):
119 | stream = '{}'
120 | assert_dom_equal(
121 | stream, turbo_stream.dispatch_event(targets="#element", name="custom-event")
122 | )
123 |
124 | def test_dispatch_event_with_target_and_name_as_kwarg(self):
125 | stream = '{}'
126 | assert_dom_equal(
127 | stream, turbo_stream.dispatch_event(target="element", name="custom-event")
128 | )
129 |
130 | def test_dispatch_event_with_targets_name_and_detail_as_kwargs(self):
131 | stream = '{"count":1,"type":"custom","enabled":true,"ids":[1,2,3]}'
132 | assert_dom_equal(
133 | stream,
134 | turbo_stream.dispatch_event(
135 | targets="#element",
136 | name="custom-event",
137 | detail={
138 | "count": 1,
139 | "type": "custom",
140 | "enabled": True,
141 | "ids": [1, 2, 3],
142 | },
143 | ),
144 | )
145 |
146 | def test_dispatch_event_with_additional_attributes(self):
147 | stream = '{}'
148 | assert_dom_equal(
149 | stream,
150 | turbo_stream.dispatch_event(
151 | "#element", name="custom-event", something="else"
152 | ),
153 | )
154 |
155 |
156 | class TestNotification:
157 | def test_notification_with_just_title(self):
158 | stream = ''
159 | assert_dom_equal(stream, turbo_stream.notification("A title"))
160 |
161 | def test_notification_with_title_and_option(self):
162 | stream = ''
163 | assert_dom_equal(stream, turbo_stream.notification("A title", body="A body"))
164 |
165 | def test_notification_with_title_and_all_options(self):
166 | stream = """
167 |
184 | """.strip()
185 |
186 | options = {
187 | "dir": "ltr",
188 | "lang": "EN",
189 | "badge": "https://example.com/badge.png",
190 | "body": "This is displayed below the title.",
191 | "tag": "Demo",
192 | "icon": "https://example.com/icon.png",
193 | "image": "https://example.com/image.png",
194 | "data": '{"arbitrary":"data"}',
195 | "vibrate": "[200,100,200]",
196 | "renotify": "true",
197 | "require-interaction": "true",
198 | "actions": '[{"action":"respond","title":"Please respond","icon":"https://example.com/icon.png"}]',
199 | "silent": "true",
200 | }
201 |
202 | assert_dom_equal(stream, turbo_stream.notification("A title", **options))
203 |
204 | def test_notification_with_title_kwarg(self):
205 | stream = ''
206 | assert_dom_equal(stream, turbo_stream.notification(title="A title"))
207 |
208 |
209 | class TestRedirectTo:
210 | def test_redirect_to_default(self):
211 | stream = ''
212 | assert_dom_equal(stream, turbo_stream.redirect_to("http://localhost:8080"))
213 |
214 | def test_redirect_to_with_turbo_false(self):
215 | stream = ''
216 | assert_dom_equal(
217 | stream, turbo_stream.redirect_to("http://localhost:8080", turbo=False)
218 | )
219 |
220 | def test_redirect_to_with_turbo_action_replace(self):
221 | stream = ''
222 | assert_dom_equal(
223 | stream, turbo_stream.redirect_to("http://localhost:8080", "replace")
224 | )
225 |
226 | def test_redirect_to_with_turbo_action_replace_kwarg(self):
227 | stream = ''
228 | assert_dom_equal(
229 | stream,
230 | turbo_stream.redirect_to("http://localhost:8080", turbo_action="replace"),
231 | )
232 |
233 | def test_redirect_to_with_turbo_action_replace_and_turbo_frame_modals_as_positional_arguments(
234 | self,
235 | ):
236 | stream = ''
237 | assert_dom_equal(
238 | stream,
239 | turbo_stream.redirect_to("http://localhost:8080", "replace", "modals"),
240 | )
241 |
242 | def test_redirect_to_with_turbo_action_replace_as_positional_argument_and_turbo_frame_modals_as_kwarg(
243 | self,
244 | ):
245 | stream = ''
246 | assert_dom_equal(
247 | stream,
248 | turbo_stream.redirect_to(
249 | "http://localhost:8080", "replace", turbo_frame="modals"
250 | ),
251 | )
252 |
253 | def test_redirect_to_with_turbo_action_replace_and_turbo_frame_modals_as_kwargs(
254 | self,
255 | ):
256 | stream = ''
257 | assert_dom_equal(
258 | stream,
259 | turbo_stream.redirect_to(
260 | "http://localhost:8080", turbo_action="replace", turbo_frame="modals"
261 | ),
262 | )
263 |
264 | def test_redirect_to_all_kwargs(self):
265 | stream = ''
266 | assert_dom_equal(
267 | stream,
268 | turbo_stream.redirect_to(
269 | url="http://localhost:8080",
270 | turbo_action="replace",
271 | turbo_frame="modals",
272 | turbo=True,
273 | ),
274 | )
275 |
276 |
277 | class TestTurboFrameReload:
278 | def test_turbo_frame_reload(self):
279 | stream = ''
280 | assert_dom_equal(stream, turbo_stream.turbo_frame_reload("user_1"))
281 |
282 | def test_turbo_frame_reload_with_target_kwarg(self):
283 | stream = ''
284 | assert_dom_equal(stream, turbo_stream.turbo_frame_reload(target="user_1"))
285 |
286 | def test_turbo_frame_reload_with_targets_kwarg(self):
287 | stream = ''
288 | assert_dom_equal(stream, turbo_stream.turbo_frame_reload(targets="#user_1"))
289 |
290 | def test_turbo_frame_reload_additional_attribute(self):
291 | stream = ''
292 | assert_dom_equal(
293 | stream, turbo_stream.turbo_frame_reload("user_1", something="else")
294 | )
295 |
296 |
297 | class TestTurboFrameSetSrc:
298 | def test_turbo_frame_set_src(self):
299 | stream = ''
300 | assert_dom_equal(stream, turbo_stream.turbo_frame_set_src("user_1", "/users"))
301 |
302 | def test_turbo_frame_set_src_with_src_kwarg(self):
303 | stream = ''
304 | assert_dom_equal(
305 | stream, turbo_stream.turbo_frame_set_src("user_1", src="/users")
306 | )
307 |
308 | def test_turbo_frame_set_src_with_target_and_src_kwarg(self):
309 | stream = ''
310 | assert_dom_equal(
311 | stream, turbo_stream.turbo_frame_set_src(target="user_1", src="/users")
312 | )
313 |
314 | def test_turbo_frame_set_src_with_src_and_target_kwarg(self):
315 | stream = ''
316 | assert_dom_equal(
317 | stream, turbo_stream.turbo_frame_set_src(src="/users", target="user_1")
318 | )
319 |
320 | def test_turbo_frame_set_src_with_targets_and_src_kwarg(self):
321 | stream = ''
322 | assert_dom_equal(
323 | stream, turbo_stream.turbo_frame_set_src(targets="#user_1", src="/users")
324 | )
325 |
326 | def test_turbo_frame_set_src_additional_attribute(self):
327 | stream = ''
328 | assert_dom_equal(
329 | stream,
330 | turbo_stream.turbo_frame_set_src("user_1", src="/users", something="else"),
331 | )
332 |
--------------------------------------------------------------------------------
/tests/testapp/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/rails-inspire-django/django-turbo-helper/777851c153602218503b1aa7432c8313147f52d1/tests/testapp/__init__.py
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/tests/testapp/forms.py:
--------------------------------------------------------------------------------
1 | from django import forms
2 |
3 | from .models import TodoItem
4 |
5 |
6 | class TodoForm(forms.ModelForm):
7 | class Meta:
8 | model = TodoItem
9 | fields = ("description",)
10 |
--------------------------------------------------------------------------------
/tests/testapp/models.py:
--------------------------------------------------------------------------------
1 | # Django
2 | from django.db import models
3 |
4 |
5 | class TodoItem(models.Model):
6 | description = models.TextField()
7 |
8 | def get_absolute_url(self):
9 | return f"/todos/{self.id}/"
10 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/tests/utils.py:
--------------------------------------------------------------------------------
1 | from bs4 import BeautifulSoup
2 |
3 |
4 | def normalize_classes(soup):
5 | """Normalize the order of CSS classes in the BeautifulSoup object."""
6 | for tag in soup.find_all(class_=True):
7 | classes = tag.get("class", [])
8 | sorted_classes = sorted(classes)
9 | tag["class"] = " ".join(sorted_classes)
10 | return soup
11 |
12 |
13 | def assert_dom_equal(expected_html, actual_html):
14 | """Assert that two HTML strings are equal, ignoring differences in class order."""
15 | expected_soup = BeautifulSoup(expected_html, "html.parser")
16 | actual_soup = BeautifulSoup(actual_html, "html.parser")
17 |
18 | # Normalize the class attribute order
19 | expected_soup = normalize_classes(expected_soup)
20 | actual_soup = normalize_classes(actual_soup)
21 |
22 | expected_str = expected_soup.prettify()
23 | actual_str = actual_soup.prettify()
24 |
25 | assert expected_str == actual_str
26 |
--------------------------------------------------------------------------------
/tox.ini:
--------------------------------------------------------------------------------
1 | [tox]
2 | envlist =
3 | py{38,39,310}-django32
4 | py{39,310}-django42
5 |
6 | [testenv]
7 | changedir=tests
8 | deps =
9 | django32: django>=3.2,<3.3
10 | django42: django>=3.3,<4.3
11 | channels
12 | daphne
13 | pytest-asyncio
14 | channels_redis
15 | django-actioncable
16 | typing_extensions
17 | pytest
18 | pytest-django
19 | pytest-xdist
20 | pytest-mock
21 | jinja2
22 | BeautifulSoup4
23 | usedevelop = True
24 | commands =
25 | pytest {posargs}
26 | setenv =
27 | PYTHONDONTWRITEBYTECODE=1
28 |
29 | [gh-actions]
30 | python =
31 | 3.8: py38
32 | 3.9: py39
33 | 3.10: py310
34 |
--------------------------------------------------------------------------------