├── .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 | [![PyPI version](https://badge.fury.io/py/django-turbo-helper.svg)](https://badge.fury.io/py/django-turbo-helper) 4 | [![Documentation](https://img.shields.io/badge/Documentation-link-green.svg)](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 = """""" 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 | '', 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 | == '' 16 | ) 17 | 18 | def test_render_escape_behavior(self): 19 | s = turbo_stream.append("dom_id", "") 20 | assert ( 21 | s 22 | == '' 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 | '' 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 = '' 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 = '' 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 | == '' 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 | == '' 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 = '' 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 = '' 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 | --------------------------------------------------------------------------------