├── .github ├── ISSUE_TEMPLATE │ ├── config.yml │ └── issue.md └── workflows │ └── django.yml ├── .gitignore ├── .readthedocs.yaml ├── LICENSE ├── MANIFEST.in ├── Makefile ├── README.md ├── demosite ├── .env.example ├── .gitignore ├── Makefile ├── demo │ ├── __init__.py │ ├── admin.py │ ├── apps.py │ ├── components │ │ ├── __init__.py │ │ ├── default │ │ │ ├── __init__.py │ │ │ ├── counter │ │ │ │ ├── __init__.py │ │ │ │ └── counter.html │ │ │ ├── reactive_search │ │ │ │ ├── __init__.py │ │ │ │ └── reactive_search.html │ │ │ ├── todo_item │ │ │ │ ├── __init__.py │ │ │ │ ├── todo_item.css │ │ │ │ ├── todo_item.html │ │ │ │ └── todo_item.js │ │ │ └── todo_list │ │ │ │ ├── __init__.py │ │ │ │ ├── todo_list.css │ │ │ │ ├── todo_list.html │ │ │ │ └── todo_list.js │ │ └── examples │ │ │ ├── __init__.py │ │ │ ├── click_to_edit │ │ │ ├── __init__.py │ │ │ └── click_to_edit.html │ │ │ ├── counter │ │ │ ├── __init__.py │ │ │ └── counter.html │ │ │ ├── delete_row │ │ │ ├── __init__.py │ │ │ └── delete_row.html │ │ │ ├── delete_row_table │ │ │ ├── __init__.py │ │ │ └── delete_row_table.html │ │ │ ├── disable_button │ │ │ ├── __init__.py │ │ │ └── disable_button.html │ │ │ └── spinner │ │ │ ├── __init__.py │ │ │ ├── spinner.css │ │ │ └── spinner.html │ ├── migrations │ │ ├── 0001_initial.py │ │ ├── 0002_todo_sessionid_alter_todo_added.py │ │ ├── 0003_rename_sessionid_todo_session_key.py │ │ └── __init__.py │ ├── models.py │ ├── movies.py │ ├── static │ │ ├── favicon.png │ │ └── logo.svg │ ├── templates │ │ ├── base.html │ │ ├── base_examples.html │ │ ├── counter_components.txt │ │ ├── counter_index.txt │ │ ├── examples │ │ │ ├── README.md │ │ │ ├── click_to_edit │ │ │ │ ├── demo.html │ │ │ │ └── text.md │ │ │ ├── counter │ │ │ │ ├── demo.html │ │ │ │ └── text.md │ │ │ ├── delete_row │ │ │ │ ├── demo.html │ │ │ │ └── text.md │ │ │ ├── disable_button │ │ │ │ ├── demo.html │ │ │ │ └── text.md │ │ │ ├── introduction │ │ │ │ └── text.md │ │ │ └── spinner │ │ │ │ ├── demo.html │ │ │ │ └── text.md │ │ ├── index.html │ │ ├── reactive_components.txt │ │ ├── todo_components.txt │ │ ├── todo_index.txt │ │ └── todo_models.txt │ ├── templatetags │ │ ├── __init__.py │ │ └── demo_tags.py │ ├── tests.py │ ├── urls.py │ ├── utils.py │ └── views.py ├── demosite │ ├── __init__.py │ ├── asgi.py │ ├── settings.py │ ├── urls.py │ └── wsgi.py ├── docs │ ├── __init__.py │ ├── admin.py │ ├── apps.py │ ├── migrations │ │ └── __init__.py │ ├── models.py │ ├── templates │ │ └── base_docs.html │ ├── tests.py │ ├── urls.py │ └── views.py ├── manage.py ├── package-lock.json └── package.json ├── docs ├── attribute-tag.md ├── basic-components.md ├── changelog.md ├── component-inheritance.md ├── component-libraries.md ├── component-life-cycle.md ├── component-tag.md ├── components.md ├── contribute.md ├── events.md ├── files.md ├── flowcharts │ └── click_behaviour.md ├── form-components.md ├── helpers.md ├── if-else-filters.md ├── img │ ├── favicon-white.png │ ├── favicon.ico │ ├── favicon.png │ └── logo.svg ├── include-js-css.md ├── index.md ├── install.md ├── magic-static.md ├── messages.md ├── request.md ├── settings.md ├── state-security.md └── testing.md ├── mkdocs.yml ├── pyproject.toml ├── tests ├── __init__.py ├── another_app │ ├── __init__.py │ ├── apps.py │ └── migrations │ │ └── __init__.py ├── conftest.py ├── main │ ├── __init__.py │ ├── apps.py │ ├── components │ │ ├── __init__.py │ │ ├── default.py │ │ ├── faulty.py │ │ ├── forms.py │ │ └── other │ │ │ ├── __init__.py │ │ │ └── dir_component │ │ │ ├── __init__.py │ │ │ └── dir_component.html │ ├── helpers.py │ ├── migrations │ │ └── __init__.py │ ├── models.py │ ├── static │ │ └── .gitignore │ ├── templates │ │ ├── base.html │ │ └── basic_component.html │ ├── urls.py │ └── views.py ├── package.json ├── test_component_attributes.py ├── test_component_attrs.py ├── test_component_blocks.py ├── test_component_context.py ├── test_component_imports.py ├── test_component_registration.py ├── test_component_tags.py ├── test_form_component.py ├── test_library.py ├── test_middleware.py ├── test_public_decorator.py ├── test_utils.py ├── ui │ ├── __init__.py │ ├── test_component_return.py │ └── test_public_download.py ├── urls.py └── utils.py └── tetra ├── __init__.py ├── apps.py ├── build.py ├── build_js.sh ├── component_register.py ├── components ├── __init__.py ├── base.py └── callbacks.py ├── default_settings.py ├── exceptions.py ├── js ├── tetra.core.js └── tetra.js ├── library.py ├── loaders ├── __init__.py └── components_directories.py ├── management ├── __init__.py └── commands │ ├── __init__.py │ ├── makemessages.py │ ├── runserver.py │ └── tetrabuild.py ├── middleware.py ├── state.py ├── static └── tetra │ └── js │ ├── alpinejs.cdn.js │ ├── alpinejs.cdn.min.js │ ├── alpinejs.morph.cdn.js │ ├── alpinejs.morph.cdn.min.js │ ├── tetra.js │ ├── tetra.js.map │ ├── tetra.min.js │ └── tetra.min.js.map ├── templates.py ├── templates ├── lib_scripts.html ├── lib_styles.html └── script.js ├── templatetags ├── __init__.py └── tetra.py ├── types.py ├── urls.py ├── utils.py └── views.py /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: false 2 | contact_links: 3 | - name: Feature Request 4 | url: https://github.com/tetra-framework/tetra/discussions/new 5 | about: Request a feature to be added to the platform 6 | - name: Ask a Question 7 | url: https://github.com/tetra-framework/tetra/discussions/new 8 | about: Ask questions and discuss with other community members -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/issue.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug Report 3 | about: Submit a GitHub issue 4 | --- 5 | -------------------------------------------------------------------------------- /.github/workflows/django.yml: -------------------------------------------------------------------------------- 1 | name: pytest-django CI 2 | 3 | on: 4 | push: 5 | # TODO: change that to "main" when merging 6 | branches: [ "tetra-package" ] 7 | pull_request: 8 | branches: [ "tetra-package" ] 9 | 10 | jobs: 11 | build: 12 | 13 | runs-on: ubuntu-latest 14 | strategy: 15 | max-parallel: 4 16 | matrix: 17 | python-version: [3.11, 3.12] 18 | 19 | steps: 20 | - uses: actions/checkout@v4 21 | - name: Set up Python ${{ matrix.python-version }} 22 | uses: actions/setup-python@v5 23 | with: 24 | python-version: ${{ matrix.python-version }} 25 | - name: Install Dependencies 26 | run: | 27 | python -m pip install --upgrade pip 28 | python -m pip install .[dev] 29 | cd tests 30 | npm install 31 | - name: Run Tests 32 | run: | 33 | cd tests 34 | pytest -------------------------------------------------------------------------------- /.readthedocs.yaml: -------------------------------------------------------------------------------- 1 | # Read the Docs configuration file for MkDocs projects 2 | # See https://docs.readthedocs.io/en/stable/config-file/v2.html for details 3 | 4 | version: 2 5 | 6 | build: 7 | os: ubuntu-22.04 8 | tools: 9 | python: "3.12" 10 | 11 | mkdocs: 12 | configuration: mkdocs.yml 13 | 14 | python: 15 | install: 16 | - method: pip 17 | path: . 18 | extra_requirements: 19 | - doc -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022-2025 Sam Willis, Christian González 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | recursive-include tetra/static * 2 | recursive-include tetra/js * 3 | recursive-include tetra/templates * 4 | prune demosite 5 | prune .github 6 | exclude mkdocs.yml 7 | exclude .readthedocs.yaml 8 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | 2 | venv: 3 | # Create venv if it doesn't exist 4 | test -d .venv || /usr/bin/env python3 -m venv .venv 5 | 6 | _activate: 7 | . .venv/bin/activate 8 | 9 | npm: 10 | cd tests && test -d node_modules || npm install 11 | 12 | test: venv _activate npm 13 | cd tests && python -m pytest 14 | 15 | #coverage: 16 | # coverage run -m pytest 17 | 18 | check: venv _activate 19 | ruff check . 20 | 21 | doc: venv _activate 22 | mkdocs build -d docs/build/doc/ 23 | 24 | doc-dev: venv _activate 25 | mkdocs serve -a localhost:8002 26 | 27 | build: venv _activate npm 28 | # remove dist/ if it exists 29 | rm -rf dist/ 30 | python -m build 31 | 32 | # https://packaging.python.org/en/latest/tutorials/packaging-projects/#uploading-your-project-to-pypi 33 | publish-test: 34 | python -m twine upload --repository testpypi dist/* 35 | 36 | publish-prod: 37 | python -m twine upload --repository pypi dist/* -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Tetra 2 | 3 | Full stack component framework for [Django](http://djangoproject.com) using [Alpine.js](https://alpinejs.dev) 4 | 5 | Tetra is a new full stack component framework for Django, bridging the gap between your server logic and front end presentation. It uses a public shared state and a resumable server state to enable inplace updates. It also encapsulates your Python, HTML, JavaScript and CSS into one file for close proximity of related concerns. 6 | 7 | See examples at [tetraframework.com](https://www.tetraframework.com) 8 | 9 | Read the [Documentation](https://tetra.readthedocs.org) 10 | 11 | ``` 12 | pip install tetra 13 | ``` 14 | 15 | ## What does Tetra do? 16 | 17 | - Django on the backend, Alpine.js in the browser 18 | 19 | Tetra combines the power of Django with Alpine.js to make development easier and quicker. 20 | 21 | - Component encapsulation 22 | 23 | Each component combines its Python, HTML, CSS and JavaScript in one place for close proximity of related code. 24 | 25 | - Resumable server state 26 | 27 | The components' full server state is saved between public method calls. This state is encrypted for security. 28 | 29 | - Public server methods 30 | 31 | Methods can be made public, allowing you to easily call them from JS on the front end, resuming the component's state. 32 | 33 | - Shared public state 34 | 35 | Attributes can be decorated to indicate they should be available in the browser as Alpine.js data objects. 36 | 37 | - Server "watcher" methods 38 | 39 | Public methods can be instructed to watch a public attribute, enabling reactive re-rendering on the server. 40 | 41 | - Inplace updating from the server 42 | 43 | Server methods can update the rendered component in place. Powered by the Alpine.js morph plugin. 44 | 45 | - Component library packaging 46 | 47 | Every component belongs to a "library"; their JS & CSS is packed together for quicker browser downloads. 48 | 49 | - Components with overridable blocks 50 | 51 | Components can have multiple {% block(s) %} which can be overridden when used. 52 | 53 | - JS/CSS builds using [esbuild](https://esbuild.github.io) 54 | 55 | Both for development (built into runserver) and production your JS & CSS is built with esbuild. 56 | 57 | - Source Maps 58 | 59 | Source maps are generated during development so that you can track down errors to the original Python files. 60 | 61 | - Syntax highlighting with type annotations 62 | 63 | Tetra uses type annotations to syntax highlight your JS, CSS & HTML in your Python files with a [VS Code plugin](https://github.com/samwillis/python-inline-source/tree/main/vscode-python-inline-source) 64 | 65 | - Forms 66 | 67 | `FormComponent`s can act as simple replacements for Django's FormView, but due to Tetra's dynamic nature, a field can e.g. change its value or disappear depending on other fields' values. -------------------------------------------------------------------------------- /demosite/.env.example: -------------------------------------------------------------------------------- 1 | DEBUG=True 2 | SECRET_KEY= 3 | # ALLOWED_HOSTS="www.tetraframework.com,tetraframework.com" 4 | # CSRF_TRUSTED_ORIGINS="https://www.tetraframework.com,https://tetraframework.com" 5 | # STATIC_ROOT="..." -------------------------------------------------------------------------------- /demosite/.gitignore: -------------------------------------------------------------------------------- 1 | static/ -------------------------------------------------------------------------------- /demosite/Makefile: -------------------------------------------------------------------------------- 1 | update: 2 | git pull 3 | ./manage.py tetrabuild 4 | ./manage.py collectstatic --noinput 5 | supervisorctl restart tetra -------------------------------------------------------------------------------- /demosite/demo/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tetra-framework/tetra/3f9faeaf5af277b27fd420f02d972856e39287e0/demosite/demo/__init__.py -------------------------------------------------------------------------------- /demosite/demo/admin.py: -------------------------------------------------------------------------------- 1 | 2 | # Register your models here. 3 | -------------------------------------------------------------------------------- /demosite/demo/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class DemoConfig(AppConfig): 5 | default_auto_field = "django.db.models.BigAutoField" 6 | name = "demo" 7 | -------------------------------------------------------------------------------- /demosite/demo/components/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tetra-framework/tetra/3f9faeaf5af277b27fd420f02d972856e39287e0/demosite/demo/components/__init__.py -------------------------------------------------------------------------------- /demosite/demo/components/default/__init__.py: -------------------------------------------------------------------------------- 1 | from sourcetypes import django_html 2 | 3 | from tetra import BasicComponent 4 | 5 | 6 | class Col(BasicComponent): 7 | def load(self, title="", *args, **kwargs): 8 | self.title = title 9 | 10 | # language=html 11 | template: django_html = """ 12 |
13 |
14 | {% block title %}{% if title %}{{ title }}{% endif %}{% endblock %} 15 |
16 |

{% block default %}{% endblock %}

17 |
18 | """ 19 | -------------------------------------------------------------------------------- /demosite/demo/components/default/counter/__init__.py: -------------------------------------------------------------------------------- 1 | from tetra import Component, public 2 | 3 | 4 | class Counter(Component): 5 | count = 0 6 | current_sum = 0 7 | 8 | def load(self, current_sum=None, *args, **kwargs): 9 | if current_sum is not None: 10 | self.current_sum = current_sum 11 | 12 | @public 13 | def increment(self): 14 | self.count += 1 15 | 16 | def sum(self): 17 | return self.count + self.current_sum 18 | -------------------------------------------------------------------------------- /demosite/demo/components/default/counter/counter.html: -------------------------------------------------------------------------------- 1 |
2 |

3 | Count: {{ count }}, 4 | Sum: {{ sum }} 5 | 6 |

7 |
8 | {% block default %}{% endblock %} 9 |
10 |
-------------------------------------------------------------------------------- /demosite/demo/components/default/reactive_search/__init__.py: -------------------------------------------------------------------------------- 1 | import itertools 2 | from tetra import Component, public 3 | from demo.movies import movies 4 | 5 | 6 | class ReactiveSearch(Component): 7 | query = public("") 8 | results = [] 9 | 10 | @public.watch("query") 11 | @public.throttle(200, leading=False, trailing=True) 12 | def watch_query(self, value, old_value, attr): 13 | if self.query: 14 | self.results = itertools.islice( 15 | (movie for movie in movies if self.query.lower() in movie.lower()), 20 16 | ) 17 | else: 18 | self.results = [] 19 | -------------------------------------------------------------------------------- /demosite/demo/components/default/reactive_search/reactive_search.html: -------------------------------------------------------------------------------- 1 |
2 |

3 | 5 |

6 | 11 |
-------------------------------------------------------------------------------- /demosite/demo/components/default/todo_item/__init__.py: -------------------------------------------------------------------------------- 1 | from tetra import Component, public 2 | 3 | 4 | class ToDoItem(Component): 5 | title = public("") 6 | done = public(False) 7 | 8 | def load(self, todo, *args, **kwargs): 9 | self.todo = todo 10 | self.title = todo.title 11 | self.done = todo.done 12 | 13 | @public.watch("title", "done") 14 | @public.debounce(200) 15 | def save(self, value, old_value, attr): 16 | self.todo.title = self.title 17 | self.todo.done = self.done 18 | self.todo.save() 19 | 20 | @public(update=False) 21 | def delete_item(self): 22 | self.todo.delete() 23 | self.client._removeComponent() 24 | -------------------------------------------------------------------------------- /demosite/demo/components/default/todo_item/todo_item.css: -------------------------------------------------------------------------------- 1 | .todo-strike { 2 | text-decoration: line-through; 3 | } -------------------------------------------------------------------------------- /demosite/demo/components/default/todo_item/todo_item.html: -------------------------------------------------------------------------------- 1 |
2 | 6 | 15 | 18 |
-------------------------------------------------------------------------------- /demosite/demo/components/default/todo_item/todo_item.js: -------------------------------------------------------------------------------- 1 | export default { 2 | lastTitleValue: "", 3 | inputDeleteDown() { 4 | this.lastTitleValue = this.title; 5 | }, 6 | inputDeleteUp() { 7 | if (this.title === "" && this.lastTitleValue === "") { 8 | this.delete_item() 9 | } 10 | } 11 | } -------------------------------------------------------------------------------- /demosite/demo/components/default/todo_list/__init__.py: -------------------------------------------------------------------------------- 1 | from demo.models import ToDo 2 | from tetra import Component, public 3 | 4 | 5 | class ToDoList(Component): 6 | title = public("") 7 | 8 | def load(self, *args, **kwargs): 9 | self.todos = ToDo.objects.filter( 10 | session_key=self.request.session.session_key, 11 | ) 12 | 13 | @public 14 | def add_todo(self, title: str): 15 | if self.title: 16 | todo = ToDo( 17 | title=title, 18 | session_key=self.request.session.session_key, 19 | ) 20 | todo.save() 21 | self.title = "" 22 | -------------------------------------------------------------------------------- /demosite/demo/components/default/todo_list/todo_list.css: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tetra-framework/tetra/3f9faeaf5af277b27fd420f02d972856e39287e0/demosite/demo/components/default/todo_list/todo_list.css -------------------------------------------------------------------------------- /demosite/demo/components/default/todo_list/todo_list.html: -------------------------------------------------------------------------------- 1 |
2 |
3 | 5 | 7 |
8 |
9 | {% for todo in todos %} 10 | {% @ ToDoItem todo=todo key=todo.id / %} 11 | {% endfor %} 12 |
13 |
-------------------------------------------------------------------------------- /demosite/demo/components/default/todo_list/todo_list.js: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tetra-framework/tetra/3f9faeaf5af277b27fd420f02d972856e39287e0/demosite/demo/components/default/todo_list/todo_list.js -------------------------------------------------------------------------------- /demosite/demo/components/examples/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tetra-framework/tetra/3f9faeaf5af277b27fd420f02d972856e39287e0/demosite/demo/components/examples/__init__.py -------------------------------------------------------------------------------- /demosite/demo/components/examples/click_to_edit/__init__.py: -------------------------------------------------------------------------------- 1 | from tetra import Component, public 2 | 3 | 4 | class ClickToEdit(Component): 5 | name = public("John Doe") 6 | old_name = "" 7 | edit_mode: bool = public(False) 8 | 9 | @public 10 | def edit(self): 11 | self.old_name = self.name 12 | self.edit_mode = True 13 | 14 | @public 15 | def save(self): 16 | """save `self.name` into a model""" 17 | self.edit_mode = False 18 | 19 | @public 20 | def cancel(self): 21 | self.name = self.old_name 22 | self.edit_mode = False 23 | 24 | @public 25 | def reset(self): 26 | self.name = self.old_name 27 | -------------------------------------------------------------------------------- /demosite/demo/components/examples/click_to_edit/click_to_edit.html: -------------------------------------------------------------------------------- 1 |
2 | {% if edit_mode %} 3 |
4 | 5 | 7 | 9 | 11 |
12 | {% else %} 13 |
Name: {{name}}
14 | {% endif %} 15 |
-------------------------------------------------------------------------------- /demosite/demo/components/examples/counter/__init__.py: -------------------------------------------------------------------------------- 1 | from tetra import Component, public 2 | 3 | 4 | class Counter(Component): 5 | count: int = 0 6 | 7 | @public 8 | def increment(self): 9 | self.count += 1 10 | -------------------------------------------------------------------------------- /demosite/demo/components/examples/counter/counter.html: -------------------------------------------------------------------------------- 1 |
2 |

3 | Count: {{ count }}, 4 | 5 |

6 |
-------------------------------------------------------------------------------- /demosite/demo/components/examples/delete_row/__init__.py: -------------------------------------------------------------------------------- 1 | from tetra import Component, public 2 | 3 | 4 | class DeleteRow(Component): 5 | title: str = public("") 6 | 7 | def load(self, row, *args, **kwargs): 8 | self.row = row 9 | self.title = row.title 10 | 11 | @public(update=False) 12 | def delete_item(self): 13 | # self.row.delete() # delete the item in the DB here! 14 | self.client._removeComponent() 15 | -------------------------------------------------------------------------------- /demosite/demo/components/examples/delete_row/delete_row.html: -------------------------------------------------------------------------------- 1 | 2 | {{ title }} 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /demosite/demo/components/examples/delete_row_table/__init__.py: -------------------------------------------------------------------------------- 1 | from demo.models import ToDo 2 | from tetra import Component 3 | 4 | 5 | class DeleteRowTable(Component): 6 | def load(self, *args, **kwargs): 7 | self.rows = ToDo.objects.filter(session_key=self.request.session.session_key) 8 | -------------------------------------------------------------------------------- /demosite/demo/components/examples/delete_row_table/delete_row_table.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | {% for row in rows %} 10 | {% @ demo.examples.DeleteRow row=row key=row.id / %} 11 | {% endfor %} 12 | 13 |
TitleActions
-------------------------------------------------------------------------------- /demosite/demo/components/examples/disable_button/__init__.py: -------------------------------------------------------------------------------- 1 | from tetra import Component, public 2 | 3 | 4 | class DisableButton(Component): 5 | 6 | # update=False, because in the demo, we don't want to refresh the component, 7 | # as the button would be re-enabled then. 8 | @public(update=False) 9 | def submit(self): 10 | pass 11 | -------------------------------------------------------------------------------- /demosite/demo/components/examples/disable_button/disable_button.html: -------------------------------------------------------------------------------- 1 |
2 | Lorem ipsum dolor 3 | 4 |
5 | -------------------------------------------------------------------------------- /demosite/demo/components/examples/spinner/__init__.py: -------------------------------------------------------------------------------- 1 | from time import sleep 2 | from tetra import Component, public 3 | 4 | 5 | class LoadingIndicatorDemo(Component): 6 | 7 | @public 8 | def long_lasting_process(self): 9 | sleep(2) 10 | -------------------------------------------------------------------------------- /demosite/demo/components/examples/spinner/spinner.css: -------------------------------------------------------------------------------- 1 | .spinner-border { 2 | display: none; 3 | /*opacity: 0;*/ 4 | /*transition: opacity 500ms ease-in;*/ 5 | } 6 | .tetra-request .spinner-border, 7 | .tetra-request.spinner-border { 8 | display: inline-block; 9 | /*opacity: 1;*/ 10 | } -------------------------------------------------------------------------------- /demosite/demo/components/examples/spinner/spinner.html: -------------------------------------------------------------------------------- 1 |
2 | 3 |
4 | 11 |
12 | 13 | 14 |
15 | 21 | 22 |
23 |
-------------------------------------------------------------------------------- /demosite/demo/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.0.3 on 2022-05-08 11:12 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | initial = True 8 | 9 | dependencies = [] 10 | 11 | operations = [ 12 | migrations.CreateModel( 13 | name="ToDo", 14 | fields=[ 15 | ( 16 | "id", 17 | models.BigAutoField( 18 | auto_created=True, 19 | primary_key=True, 20 | serialize=False, 21 | verbose_name="ID", 22 | ), 23 | ), 24 | ("title", models.CharField(max_length=80)), 25 | ("done", models.BooleanField(default=False)), 26 | ("added", models.DateTimeField(auto_now_add=True)), 27 | ("modified", models.DateTimeField(auto_now=True)), 28 | ], 29 | ), 30 | ] 31 | -------------------------------------------------------------------------------- /demosite/demo/migrations/0002_todo_sessionid_alter_todo_added.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.0.3 on 2022-05-09 20:23 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [ 8 | ("demo", "0001_initial"), 9 | ] 10 | 11 | operations = [ 12 | migrations.AddField( 13 | model_name="todo", 14 | name="sessionid", 15 | field=models.CharField(db_index=True, default="", max_length=40), 16 | preserve_default=False, 17 | ), 18 | migrations.AlterField( 19 | model_name="todo", 20 | name="added", 21 | field=models.DateTimeField(auto_now_add=True, db_index=True), 22 | ), 23 | ] 24 | -------------------------------------------------------------------------------- /demosite/demo/migrations/0003_rename_sessionid_todo_session_key.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.0.3 on 2022-05-09 20:25 2 | 3 | from django.db import migrations 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [ 8 | ("demo", "0002_todo_sessionid_alter_todo_added"), 9 | ] 10 | 11 | operations = [ 12 | migrations.RenameField( 13 | model_name="todo", 14 | old_name="sessionid", 15 | new_name="session_key", 16 | ), 17 | ] 18 | -------------------------------------------------------------------------------- /demosite/demo/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tetra-framework/tetra/3f9faeaf5af277b27fd420f02d972856e39287e0/demosite/demo/migrations/__init__.py -------------------------------------------------------------------------------- /demosite/demo/models.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | 3 | 4 | class ToDo(models.Model): 5 | session_key = models.CharField(max_length=40, db_index=True) 6 | title = models.CharField(max_length=80) 7 | done = models.BooleanField(default=False) 8 | added = models.DateTimeField(auto_now_add=True, db_index=True) 9 | modified = models.DateTimeField(auto_now=True) 10 | -------------------------------------------------------------------------------- /demosite/demo/movies.py: -------------------------------------------------------------------------------- 1 | movies = [ 2 | "A Christmas Story", 3 | "A Fish Called Wanda", 4 | "A Nightmare on Elm Street", 5 | "Adventures in Babysitting", 6 | "After Hours", 7 | "Airplane!", 8 | "Akira", 9 | "Amadeus", 10 | "Au Revoir, les enfants", 11 | "Baby Boom", 12 | "Back to the Future", 13 | "Batman", 14 | "Beetlejuice", 15 | "Better Off Dead", 16 | "Beverly Hills Cop", 17 | "Big Trouble in Little China", 18 | "Big", 19 | "Bill & Ted's Excellent Adventure", 20 | "Blade Runner", 21 | "Blue Velvet", 22 | "Body Heat", 23 | "Broadcast News", 24 | "Caddyshack", 25 | "Coming to America", 26 | "Commando", 27 | "Dead Poets Society", 28 | "Desperately Seeking Susan", 29 | "Die Hard", 30 | "Dirty Dancing", 31 | "Do the Right Thing", 32 | "Dragonslayer", 33 | "Drugstore Cowboy", 34 | "E.T. the Extra-Terrestrial", 35 | "Eddie Murphy: Delirious", 36 | "Escape From New York", 37 | "Evil Dead 2", 38 | "Fast Times at Ridgemont High", 39 | "Ferris Bueller's Day Off", 40 | "Field of Dreams", 41 | "Flashdance", 42 | "Flight of the Navigator", 43 | "Footloose", 44 | "Friday the 13th", 45 | "Full Metal Jacket", 46 | "Ghostbusters", 47 | "Grave of the Fireflies", 48 | "Gremlins", 49 | "Hannah and Her Sisters", 50 | "Heathers", 51 | "Highlander", 52 | "Indiana Jones and the Last Crusade", 53 | "Indiana Jones and the Temple of Doom", 54 | "Krush Groove", 55 | "Labyrinth", 56 | "Ladyhawke", 57 | "Lethal Weapon", 58 | "Little Shop of Horrors", 59 | "Lost in America", 60 | "Mad Max 2", 61 | "Moonstruck", 62 | "My Left Foot", 63 | "My Life as a Dog", 64 | "My Neighbor Totoro", 65 | "Mystery Train", 66 | "Mystic Pizza", 67 | "National Lampoon's Vacation", 68 | "Near Dark", 69 | "Paris, Texas", 70 | "Planes, Trains and Automobiles", 71 | "Platoon", 72 | "Poltergeist", 73 | "Predator", 74 | "Pretty in Pink", 75 | "Purple Rain", 76 | "Raging Bull", 77 | "Raiders of the Lost Ark", 78 | "Raising Arizona", 79 | "Ran", 80 | "Re-Animator", 81 | "Real Genius", 82 | "Repo Man", 83 | "Return to Oz", 84 | "Revenge of the Nerds", 85 | "Risky Business", 86 | "RoboCop", 87 | "Say Anything...", 88 | "Scanners", 89 | "Scarface", 90 | "Short Circuit", 91 | "Sid & Nancy", 92 | "Sixteen Candles", 93 | "Sleepaway Camp", 94 | "Spaceballs", 95 | "St. Elmo's Fire", 96 | "Stand by Me", 97 | "Star Wars: Episode V -- The Empire Strikes Back", 98 | "Star Wars: Episode VI -- Return of the Jedi", 99 | "Steel Magnolias", 100 | "Stop Making Sense", 101 | "Stripes", 102 | "Terms of Endearment", 103 | "The Adventures of Buckaroo Banzai Across the Eighth Dimension", 104 | "The Blues Brothers", 105 | "The Boat", 106 | "The Breakfast Club", 107 | "The Color Purple", 108 | "The Goonies", 109 | "The Karate Kid", 110 | "The Killer", 111 | "The Land Before Time", 112 | "The Last Starfighter", 113 | "The Little Mermaid", 114 | "The Lost Boys", 115 | "The Naked Gun", 116 | "The Neverending Story", 117 | "The Princess Bride", 118 | "The Return of the Living Dead", 119 | "The Shining", 120 | "The Terminator", 121 | "The Thing", 122 | "The Toxic Avenger", 123 | "The Transformers: The Movie", 124 | "The Untouchables", 125 | "They Live", 126 | "This Is Spinal Tap", 127 | "Time Bandits", 128 | "To Live and Die in L.A.", 129 | "Tootsie", 130 | "Top Gun", 131 | "Trading Places", 132 | "Tron", 133 | "UHF", 134 | "Valley Girl", 135 | "Wall Street", 136 | "WarGames", 137 | "Weird Science", 138 | "When Harry Met Sally...", 139 | "Who Framed Roger Rabbit", 140 | "Willow", 141 | "Working Girl", 142 | ] 143 | -------------------------------------------------------------------------------- /demosite/demo/static/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tetra-framework/tetra/3f9faeaf5af277b27fd420f02d972856e39287e0/demosite/demo/static/favicon.png -------------------------------------------------------------------------------- /demosite/demo/static/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /demosite/demo/templates/base.html: -------------------------------------------------------------------------------- 1 | {% load demo_tags tetra static %} 2 | 3 | 4 | 5 | 6 | 7 | 8 | {% block title %}Tetra{% endblock %} 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 51 | 52 | 72 | 73 | {% block extra_head %}{% endblock %} 74 | {% tetra_styles %} 75 | {% tetra_scripts include_alpine=True %} 76 | 77 | 78 | 79 | {% block body %} 80 |
81 |
82 | {% block content %}{% endblock %} 83 |
84 |
85 | {% endblock %} 86 | {% block footer %} 87 | 90 | {% endblock %} 91 | 92 | 93 | -------------------------------------------------------------------------------- /demosite/demo/templates/counter_components.txt: -------------------------------------------------------------------------------- 1 | class Counter(Component): 2 | count = 0 3 | current_sum = 0 4 | 5 | def load(self, current_sum=None): 6 | if current_sum is not None: 7 | self.current_sum = current_sum 8 | 9 | @public 10 | def increment(self): 11 | self.count += 1 12 | 13 | def sum(self): 14 | return self.count + self.current_sum 15 | 16 | template: django_html = """ 17 |
18 |

19 | Count: {{ count }}, 20 | Sum: {{ sum }} 21 | 23 |

24 |
25 | {% block default %}{% endblock %} 26 |
27 |
28 | """ -------------------------------------------------------------------------------- /demosite/demo/templates/counter_index.txt: -------------------------------------------------------------------------------- 1 | {% @ demo.Counter key="counter-1" %} 2 | {% @ demo.Counter key="counter-2" current_sum=sum %} 3 | {% @ demo.Counter key="counter-3" current_sum=sum / %} 4 | {% /@ demo.Counter %} 5 | {% /@ demo.Counter %} -------------------------------------------------------------------------------- /demosite/demo/templates/examples/README.md: -------------------------------------------------------------------------------- 1 | # Examples 2 | 3 | This directory contains Tetra examples. It follows a certain structure: 4 | 5 | ``` 6 | name_of_example/ 7 | demo.html 8 | text.md 9 | component.py 10 | other_example/ 11 | demo.html 12 | text.md 13 | component.py 14 | ``` 15 | 16 | * The `text.md` file contains the description of the example, with code sections. This is rendered as HTML. It must contain a `title` as front matter. You can include source files using `{% md_include_source 'path/to/file' 'optional_first_line_comment' %}` 17 | * The `demo.html` part, which is a django template, using the Tetra component, is rendered. 18 | -------------------------------------------------------------------------------- /demosite/demo/templates/examples/click_to_edit/demo.html: -------------------------------------------------------------------------------- 1 | {% load tetra %} 2 | 3 |

Click the line below to start editing.

4 | 5 | {% @ demo.examples.ClickToEdit / %} 6 | -------------------------------------------------------------------------------- /demosite/demo/templates/examples/click_to_edit/text.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Click to Edit 3 | --- 4 | 5 | # Click to Edit 6 | 7 | The *click-to-edit* pattern enables inline editing of a record without refreshing the page. 8 | 9 | This is a simple way to implement this as Tetra component, including save/cancel buttons: 10 | {% md_include_source "demo/components/examples/click_to_edit/__init__.py" %} 11 | {% md_include_source "demo/components/examples/click_to_edit/click_to_edit.html" %} 12 | 13 | 14 | If you click the text, it is replaced with an input form field. 15 | 16 | You could also imagine to do that in other ways: 17 | 18 | * by hiding the borders of the input field in display mode, and showing them again using Alpine when in edit_mode. 19 | * without buttons, just by using the `@blur` event for saving. -------------------------------------------------------------------------------- /demosite/demo/templates/examples/counter/demo.html: -------------------------------------------------------------------------------- 1 | {% load tetra %} 2 | 3 | {% @ demo.examples.Counter / %} 4 | -------------------------------------------------------------------------------- /demosite/demo/templates/examples/counter/text.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Counter 3 | --- 4 | 5 | # Counter demo 6 | 7 | The "counter" is basically the "Hello World" demo of components. It is a simple demo of how to use Tetra components. 8 | 9 | The component itself only provides a `count` attribute, and a public `increment()` method. 10 | 11 | 'nuff said, show me the code. 12 | 13 | {% md_include_component_source "demo.examples.Counter" %} 14 | 15 | Rendering is straightforward. 16 | 17 | {% md_include_component_template "demo.examples.Counter" %} 18 | 19 | Note below in the demo how fast Tetra rendering is. Component updates almost feel as fast as native Javascript. 20 | -------------------------------------------------------------------------------- /demosite/demo/templates/examples/delete_row/demo.html: -------------------------------------------------------------------------------- 1 | {% load tetra %} 2 | 3 | {% @ demo.examples.DeleteRowTable / %} 4 | -------------------------------------------------------------------------------- /demosite/demo/templates/examples/delete_row/text.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Delete Row 3 | --- 4 | 5 | # Delete Row 6 | 7 | Here's an example component demonstrating how to create a delete button that removes a table row when clicked. 8 | 9 | {% md_include_source "demo/components/examples/delete_row_table/__init__.py" %} 10 | {% md_include_source "demo/components/examples/delete_row_table/delete_row_table.html" %} 11 | 12 | So far for the table component. The rows are components themselves. Each row is responsible for its own deletion. So there is no `delete_item(some_id)` necessary, as the component already knows its id since it internally saves its state. `delete_item()` is sufficient within the component's template code. 13 | 14 | {% md_include_source "demo/components/examples/delete_row/__init__.py" %} 15 | 16 | {% md_include_source "demo/components/examples/delete_row/delete_row.html" %} 17 | -------------------------------------------------------------------------------- /demosite/demo/templates/examples/disable_button/demo.html: -------------------------------------------------------------------------------- 1 | {% load tetra %} 2 | 3 | {% @ demo.examples.DisableButton / %} 4 | -------------------------------------------------------------------------------- /demosite/demo/templates/examples/disable_button/text.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Disable submit button 3 | --- 4 | 5 | # Disable submit button 6 | 7 | When submitting a form, many users tend to double-click the `submit` button, leading to double entries in the databases, if the timing was right ;-) 8 | 9 | It is an easy pattern to just disabling the button right after clicking it. You can do two things in the `@click` listener: disable the button *and* call `submit()`. 10 | 11 | {% md_include_source "demo/components/examples/disable_button/disable_button.html" %} 12 | 13 | 14 | If you click the button, it is disabled, without altering the state. When the component is reloaded, the buttin is enabled again (for a create form), but mostly, you will redirect to another page using `self.client._redirect(...)` 15 | 16 | -------------------------------------------------------------------------------- /demosite/demo/templates/examples/introduction/text.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Examples 3 | --- 4 | 5 | # Tetra examples 6 | 7 | Here you can see a few examples of common coding patterns, solved by the Tetra approach. 8 | 9 | Keep in mind that these are not the only way how to solve these problems, even not in Tetra. 10 | 11 | Choose an example on the left sidebar. 12 | -------------------------------------------------------------------------------- /demosite/demo/templates/examples/spinner/demo.html: -------------------------------------------------------------------------------- 1 | {% load tetra %} 2 | 3 | {% @ demo.examples.LoadingIndicatorDemo / %} 4 | -------------------------------------------------------------------------------- /demosite/demo/templates/examples/spinner/text.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Click to Edit 3 | --- 4 | 5 | # Loading indicator / spinner 6 | 7 | A common pattern is showing loading indicator (also called "spinner"), whenever a request duration is longer than the usual user is inclined to wait, without getting nervous... 8 | 9 | 10 | {% md_include_source "demo/components/examples/spinner/__init__.py" %} 11 | {% md_include_source "demo/components/examples/spinner/spinner.html" %} 12 | 13 | You'll need a bit of CSS to get this to work, as you have to hide the spinner per default: 14 | 15 | {% md_include_source "demo/components/examples/spinner/spinner.css" %} 16 | 17 | You can also accomplish the hiding with `opacity: 0` and `opacity:1` with a `transition` to make it smoother. 18 | 19 | You can click the button below, the spinner is shown for the period of the tetra request and hidden again afterwords. -------------------------------------------------------------------------------- /demosite/demo/templates/reactive_components.txt: -------------------------------------------------------------------------------- 1 | import itertools 2 | from sourcetypes import django_html 3 | from tetra import Component, public, Library 4 | from .movies import movies 5 | 6 | class ReactiveSearch(Component): 7 | query = public("") 8 | results = [] 9 | 10 | @public.watch("query").throttle(200, leading=False, trailing=True) 11 | def watch_query(self, value, old_value, attr): 12 | if self.query: 13 | self.results = itertools.islice( 14 | (movie for movie in movies if self.query.lower() in movie.lower()), 20 15 | ) 16 | else: 17 | self.results = [] 18 | 19 | template: django_html = """ 20 |
21 |

22 | 24 |

25 | 30 |
31 | """ 32 | -------------------------------------------------------------------------------- /demosite/demo/templates/todo_components.txt: -------------------------------------------------------------------------------- 1 | from sourcetypes import javascript, css, django_html 2 | from tetra import Component, public 3 | from .models import ToDo 4 | 5 | class ToDoList(Component): 6 | title = public("") 7 | 8 | def load(self): 9 | self.todos = ToDo.objects.filter( 10 | session_key=self.request.session.session_key 11 | ) 12 | 13 | @public 14 | def add_todo(self, title): 15 | todo = ToDo( 16 | title=title, 17 | session_key=self.request.session.session_key, 18 | ) 19 | todo.save() 20 | self.title = "" 21 | 22 | template: django_html = """ 23 |
24 |
25 | 27 | 29 |
30 |
31 | {% for todo in todos %} 32 | {% @ ToDoItem todo=todo key=todo.id / %} 33 | {% endfor %} 34 |
35 |
36 | """ 37 | 38 | class ToDoItem(Component): 39 | title = public("") 40 | done = public(False) 41 | todo: ToDo = None 42 | 43 | def load(self, todo, *args, **kwargs): 44 | self.todo = todo 45 | self.title = todo.title 46 | self.done = todo.done 47 | 48 | @public.watch("title", "done").debounce(200) 49 | def save(self, value, old_value, attr): 50 | self.todo.title = self.title 51 | self.todo.done = self.done 52 | self.todo.save() 53 | 54 | @public(update=False) 55 | def delete_item(self): 56 | self.todo.delete() 57 | self.client._removeComponent() 58 | 59 | template: django_html = """ 60 |
61 | 65 | 74 | 77 |
78 | """ 79 | 80 | script: javascript = """ 81 | export default { 82 | lastTitleValue: "", 83 | inputDeleteDown() { 84 | this.lastTitleValue = this.title; 85 | }, 86 | inputDeleteUp() { 87 | if (this.title === "" && this.lastTitleValue === "") { 88 | this.delete_item() 89 | } 90 | } 91 | } 92 | """ 93 | 94 | style: css = """ 95 | .todo-strike { 96 | text-decoration: line-through; 97 | } 98 | """ -------------------------------------------------------------------------------- /demosite/demo/templates/todo_index.txt: -------------------------------------------------------------------------------- 1 |

Your todo list:

2 | {% @ ToDoList / %} -------------------------------------------------------------------------------- /demosite/demo/templates/todo_models.txt: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | 3 | class ToDo(models.Model): 4 | session_key = models.CharField(max_length=40, db_index=True) 5 | title = models.CharField(max_length=80) 6 | done = models.BooleanField(default=False) -------------------------------------------------------------------------------- /demosite/demo/templatetags/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tetra-framework/tetra/3f9faeaf5af277b27fd420f02d972856e39287e0/demosite/demo/templatetags/__init__.py -------------------------------------------------------------------------------- /demosite/demo/templatetags/demo_tags.py: -------------------------------------------------------------------------------- 1 | import os.path 2 | 3 | from django import template 4 | from django.conf import settings 5 | from django.utils.html import escape 6 | from django.utils.safestring import mark_safe, SafeString 7 | from django.template.loaders.app_directories import Loader 8 | import tetra 9 | from tetra.component_register import resolve_component 10 | 11 | register = template.Library() 12 | 13 | 14 | @register.simple_tag 15 | def tetra_version() -> str: 16 | return tetra.__version__ 17 | 18 | 19 | @register.simple_tag 20 | def include_source(file_name, start=None, end=None) -> SafeString: 21 | error = None 22 | for origin in Loader(None).get_template_sources(file_name): 23 | try: 24 | with open(origin.name) as f: 25 | text = f.read() 26 | if (start is not None) or (end is not None): 27 | text = "\n".join(text.split("\n")[start:end]) 28 | return mark_safe(escape(text)) 29 | except FileNotFoundError as e: 30 | error = e 31 | pass 32 | if error: 33 | raise error 34 | 35 | 36 | @register.simple_tag 37 | def md_include_source(filename: str, first_line_comment: str = "") -> SafeString: 38 | """Includes the source code of a file, rlative to the demo root directory. 39 | 40 | It returns a SafeString version of the file content, surrounded with 41 | MarkDown triple-quote syntax including language hint, 42 | so you can include the result directly in a markdown file: 43 | 44 | {% md_include_source "path/to/file.py" "# some title" %} 45 | 46 | You can provide a title for the code block. If no title is provided, the filename 47 | itself is used. 48 | """ 49 | ext = os.path.splitext(filename)[1] 50 | basename = os.path.basename(filename) 51 | if basename == "__init__.py": 52 | # "__init__.py" isn't very explanative. 53 | # So use the containing directory name + basename 54 | basename = ( 55 | f"{os.path.basename(os.path.dirname(filename))}/" 56 | f"{os.path.basename(filename)}" 57 | ) 58 | try: 59 | with open(settings.BASE_DIR / filename) as f: 60 | content = f.read() 61 | except FileNotFoundError as e: 62 | return mark_safe(f"File not found: {filename}") 63 | 64 | language = "" 65 | if ext == ".html": 66 | language = "django" 67 | first_line_comment = f"\n" 68 | elif ext == ".py": 69 | language = "python" 70 | first_line_comment = f"# {first_line_comment or basename}\n" 71 | elif ext == ".css": 72 | language = "css" 73 | first_line_comment = f"// {first_line_comment or basename}\n" 74 | elif ext == ".js": 75 | language = "javascript" 76 | first_line_comment = f"// {first_line_comment or basename}\n" 77 | return mark_safe(f"```{language}\n{first_line_comment}{content}\n```") 78 | 79 | 80 | @register.simple_tag 81 | def md_include_component_source( 82 | component_name: str, first_line_comment: str = "" 83 | ) -> SafeString: 84 | component = resolve_component(None, component_name) 85 | if not component: 86 | raise template.TemplateSyntaxError( 87 | f"Unable to resolve dynamic component: '{component_name}'" 88 | ) 89 | return md_include_source(component.get_source_location()[0], first_line_comment) 90 | 91 | 92 | @register.simple_tag 93 | def md_include_component_template( 94 | component_name: str, first_line_comment: str = "" 95 | ) -> SafeString: 96 | component = resolve_component(None, component_name) 97 | if not component: 98 | raise template.TemplateSyntaxError( 99 | f"Unable to resolve dynamic component: '{component_name}'" 100 | ) 101 | return md_include_source( 102 | component.get_template_source_location()[0], first_line_comment 103 | ) 104 | 105 | 106 | @register.simple_tag 107 | def include_source_part(file_name, part=0): 108 | error = None 109 | for origin in Loader(None).get_template_sources(file_name): 110 | try: 111 | with open(origin.name) as f: 112 | text = f.read().split("# SPLIT") 113 | text = text[part].rstrip().lstrip("\n") 114 | return mark_safe(escape(text)) 115 | except FileNotFoundError as e: 116 | error = e 117 | pass 118 | if error: 119 | raise error 120 | -------------------------------------------------------------------------------- /demosite/demo/tests.py: -------------------------------------------------------------------------------- 1 | 2 | # Create your tests here. 3 | -------------------------------------------------------------------------------- /demosite/demo/urls.py: -------------------------------------------------------------------------------- 1 | from django.urls import path 2 | from .views import home, examples 3 | 4 | 5 | urlpatterns = [ 6 | path("", home, name="home"), 7 | path("examples/", examples, name="examples-home"), 8 | path("examples//", examples, name="examples"), 9 | ] 10 | -------------------------------------------------------------------------------- /demosite/demo/utils.py: -------------------------------------------------------------------------------- 1 | from .models import ToDo 2 | 3 | 4 | to_do_defaults = [ 5 | (True, "Discover Tetra"), 6 | (False, "Install and explore Tetra"), 7 | (False, "Decide to build your next startup using Tetra"), 8 | (False, "Become a billionaire"), 9 | (False, "Start a rocket company"), 10 | (False, "Populate Pluto, it's a much cooler planet than Mars"), 11 | ] 12 | 13 | 14 | def prepopulate_session_to_do(request) -> None: 15 | if not request.session.session_key: 16 | request.session.create() 17 | session_key = request.session.session_key 18 | if ToDo.objects.filter(session_key=session_key).count() == 0: 19 | todos = [ 20 | ToDo(done=d, title=t, session_key=session_key) for d, t in to_do_defaults 21 | ] 22 | ToDo.objects.bulk_create(todos) 23 | -------------------------------------------------------------------------------- /demosite/demo/views.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import os 3 | import re 4 | 5 | import markdown 6 | from django.conf import settings 7 | from django.http import HttpResponse, Http404 8 | from django.shortcuts import render 9 | from django.template import TemplateDoesNotExist, Template, RequestContext 10 | from django.template.loader import render_to_string 11 | from markdown.extensions.toc import TocExtension 12 | 13 | from .utils import prepopulate_session_to_do 14 | 15 | logger = logging.getLogger(__name__) 16 | 17 | FIRST_SLUG = "introduction" 18 | 19 | 20 | def home(request) -> HttpResponse: 21 | prepopulate_session_to_do(request) 22 | return render(request, "index.html") 23 | 24 | 25 | def titlify(slug: str) -> str: 26 | return slug.replace("_", " ").title() 27 | 28 | 29 | def markdown_title(title) -> str: 30 | return markdown.markdown(title).replace("

", "").replace("

", "") 31 | 32 | 33 | def examples(request, slug: str = FIRST_SLUG) -> HttpResponse: 34 | slug = slug.lower() 35 | if not re.match(r"^[a-zA-Z0-9_]+$", slug): 36 | raise Http404() 37 | 38 | examples_dir = settings.BASE_DIR / "demo" / "templates" / "examples" 39 | # keep Sam's "structure", as we may need it later, when more examples need to be 40 | # structured into sections 41 | structure = {} 42 | # TODO cache this! 43 | for entry in os.scandir(examples_dir): 44 | if entry.name == FIRST_SLUG: 45 | continue 46 | if entry.is_dir(follow_symlinks=False): 47 | structure[entry.name] = {"slug": entry.name, "title": titlify(entry.name)} 48 | 49 | if slug not in structure and slug != FIRST_SLUG: 50 | raise Http404() 51 | 52 | md = markdown.Markdown( 53 | extensions=[ 54 | "extra", 55 | "meta", 56 | TocExtension(permalink="#", toc_depth=3), 57 | ] 58 | ) 59 | # first, render the markdown from text.md 60 | with open(examples_dir / slug / "text.md") as f: 61 | # assume content has Django template directives, render them first 62 | content = Template("{% load demo_tags %}" + f.read()).render( 63 | context=RequestContext(request) 64 | ) 65 | content = md.convert(content) 66 | 67 | # # then render component code, if available 68 | # try: 69 | # component = resolve_component(None, f"demo.examples.{slug}") 70 | # if component: 71 | # filename, start, length = component.get_source_location() 72 | # with open(filename) as f: 73 | # # this only works if each component is located in one file 74 | # content += md.convert( 75 | # "```python\n" + "# component source code\n" + f.read() + "\n```" 76 | # ) 77 | # # content += """
models.py
""" 78 | # content += md.convert( 79 | # "```django\n# file template\n" 80 | # + component._read_component_file_with_extension("html") 81 | # + "\n```" 82 | # ) 83 | # except ComponentNotFound: 84 | # pass 85 | 86 | # if there exists a demo, add it 87 | demo_html = "" 88 | try: 89 | demo_html = render_to_string(examples_dir / slug / "demo.html", request=request) 90 | if demo_html: 91 | content += "

Demo

" 92 | content += demo_html 93 | except TemplateDoesNotExist: 94 | pass 95 | 96 | logger.debug(md.Meta) 97 | return render( 98 | request, 99 | "base_examples.html", 100 | { 101 | "structure": structure, 102 | "FIRST_SLUG": {"slug": FIRST_SLUG, "title": titlify(FIRST_SLUG)}, 103 | "content": content, 104 | "toc": md.toc, 105 | "active_slug": slug, 106 | "title": " ".join(md.Meta["title"]) if "title" in md.Meta else "", 107 | "has_demo": bool(demo_html), 108 | }, 109 | ) 110 | -------------------------------------------------------------------------------- /demosite/demosite/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tetra-framework/tetra/3f9faeaf5af277b27fd420f02d972856e39287e0/demosite/demosite/__init__.py -------------------------------------------------------------------------------- /demosite/demosite/asgi.py: -------------------------------------------------------------------------------- 1 | """ 2 | ASGI config for demosite project. 3 | 4 | It exposes the ASGI callable as a module-level variable named ``application``. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/4.0/howto/deployment/asgi/ 8 | """ 9 | 10 | import os 11 | 12 | from django.core.asgi import get_asgi_application 13 | 14 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "demosite.settings") 15 | 16 | application = get_asgi_application() 17 | -------------------------------------------------------------------------------- /demosite/demosite/settings.py: -------------------------------------------------------------------------------- 1 | """ 2 | Django settings for demosite project. 3 | 4 | Generated by 'django-admin startproject' using Django 4.0.3. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/4.0/topics/settings/ 8 | 9 | For the full list of settings and their values, see 10 | https://docs.djangoproject.com/en/4.0/ref/settings/ 11 | """ 12 | 13 | import os 14 | from pathlib import Path 15 | import environ 16 | 17 | env = environ.Env( 18 | # set casting, default value 19 | DEBUG=(bool, False) 20 | ) 21 | 22 | # Build paths inside the project like this: BASE_DIR / 'subdir'. 23 | BASE_DIR = Path(__file__).resolve().parent.parent 24 | 25 | # Take environment variables from .env file 26 | environ.Env.read_env(os.path.join(BASE_DIR, ".env")) 27 | 28 | # Quick-start development settings - unsuitable for production 29 | # See https://docs.djangoproject.com/en/4.0/howto/deployment/checklist/ 30 | 31 | # SECURITY WARNING: keep the secret key used in production secret! 32 | # Raises Django's ImproperlyConfigured exception if SECRET_KEY not in os.environ 33 | SECRET_KEY = env("SECRET_KEY") 34 | 35 | # SECURITY WARNING: don't run with debug turned on in production! 36 | DEBUG = env("DEBUG") 37 | ALLOWED_HOSTS = env.list("ALLOWED_HOSTS", default=["127.0.0.1", "localhost"]) 38 | 39 | CSRF_TRUSTED_ORIGINS = env.list("CSRF_TRUSTED_ORIGINS", default=[]) 40 | 41 | 42 | # Application definition 43 | 44 | INSTALLED_APPS = [ 45 | "django.contrib.admin", 46 | "django.contrib.auth", 47 | "django.contrib.contenttypes", 48 | "django.contrib.sessions", 49 | "django.contrib.messages", 50 | "tetra", 51 | "django.contrib.staticfiles", 52 | "demo", 53 | ] 54 | 55 | MIDDLEWARE = [ 56 | "django.middleware.security.SecurityMiddleware", 57 | "whitenoise.middleware.WhiteNoiseMiddleware", 58 | "django.contrib.sessions.middleware.SessionMiddleware", 59 | "django.middleware.common.CommonMiddleware", 60 | "django.middleware.csrf.CsrfViewMiddleware", 61 | "django.contrib.auth.middleware.AuthenticationMiddleware", 62 | "django.contrib.messages.middleware.MessageMiddleware", 63 | "django.middleware.clickjacking.XFrameOptionsMiddleware", 64 | "tetra.middleware.TetraMiddleware", 65 | ] 66 | 67 | ROOT_URLCONF = "demosite.urls" 68 | 69 | TEMPLATES = [ 70 | { 71 | "BACKEND": "django.template.backends.django.DjangoTemplates", 72 | "DIRS": [], 73 | "APP_DIRS": True, 74 | "OPTIONS": { 75 | "context_processors": [ 76 | "django.template.context_processors.debug", 77 | "django.template.context_processors.request", 78 | "django.contrib.auth.context_processors.auth", 79 | "django.contrib.messages.context_processors.messages", 80 | ], 81 | # "loaders": [ 82 | # ( 83 | # "django.template.loaders.cached.Loader", 84 | # [ 85 | # "django.template.loaders.filesystem.Loader", 86 | # "django.template.loaders.app_directories.Loader", 87 | # "tetra.loaders.components_directories.Loader", 88 | # ], 89 | # ) 90 | # ], 91 | }, 92 | }, 93 | ] 94 | 95 | WSGI_APPLICATION = "demosite.wsgi.application" 96 | 97 | 98 | # Database 99 | # https://docs.djangoproject.com/en/4.0/ref/settings/#databases 100 | 101 | DATABASES = { 102 | "default": { 103 | "ENGINE": "django.db.backends.sqlite3", 104 | "NAME": BASE_DIR / "db.sqlite3", 105 | } 106 | } 107 | 108 | 109 | # Password validation 110 | # https://docs.djangoproject.com/en/4.0/ref/settings/#auth-password-validators 111 | 112 | AUTH_PASSWORD_VALIDATORS = [ 113 | { 114 | "NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator", 115 | }, 116 | { 117 | "NAME": "django.contrib.auth.password_validation.MinimumLengthValidator", 118 | }, 119 | { 120 | "NAME": "django.contrib.auth.password_validation.CommonPasswordValidator", 121 | }, 122 | { 123 | "NAME": "django.contrib.auth.password_validation.NumericPasswordValidator", 124 | }, 125 | ] 126 | 127 | 128 | # Internationalization 129 | # https://docs.djangoproject.com/en/4.0/topics/i18n/ 130 | 131 | LANGUAGE_CODE = "en-us" 132 | 133 | TIME_ZONE = "UTC" 134 | 135 | USE_I18N = True 136 | 137 | USE_TZ = True 138 | 139 | 140 | # Static files (CSS, JavaScript, Images) 141 | # https://docs.djangoproject.com/en/4.0/howto/static-files/ 142 | 143 | STATIC_URL = "static/" 144 | STATIC_ROOT = env("STATIC_ROOT", default=BASE_DIR / "static") 145 | 146 | # STATICFILES_STORAGE = "django.contrib.staticfiles.storage.ManifestStaticFilesStorage" 147 | STORAGES = { 148 | "staticfiles": { 149 | "BACKEND": "whitenoise.storage.CompressedManifestStaticFilesStorage", 150 | }, 151 | } 152 | 153 | 154 | # Default primary key field type 155 | # https://docs.djangoproject.com/en/4.0/ref/settings/#default-auto-field 156 | 157 | DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" 158 | 159 | LOGGING = { 160 | "version": 1, 161 | "disable_existing_loggers": False, 162 | "handlers": {"console": {"class": "logging.StreamHandler"}}, 163 | "loggers": { 164 | "django": {"handlers": ["console"], "level": "INFO", "propagate": True}, 165 | "tetra": {"handlers": ["console"], "level": "DEBUG" if DEBUG else "INFO"}, 166 | "demo": {"handlers": ["console"], "level": "DEBUG" if DEBUG else "INFO"}, 167 | }, 168 | } 169 | -------------------------------------------------------------------------------- /demosite/demosite/urls.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | from django.urls import path, include 3 | from django.conf import settings 4 | from django.conf.urls.static import static 5 | 6 | urlpatterns = [ 7 | path("", include("demo.urls")), 8 | path("admin/", admin.site.urls), 9 | path("tetra/", include("tetra.urls")), 10 | ] + static(settings.STATIC_URL, document_root=settings.STATIC_ROOT) 11 | -------------------------------------------------------------------------------- /demosite/demosite/wsgi.py: -------------------------------------------------------------------------------- 1 | """ 2 | WSGI config for demosite project. 3 | 4 | It exposes the WSGI callable as a module-level variable named ``application``. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/4.0/howto/deployment/wsgi/ 8 | """ 9 | 10 | import os 11 | import sys 12 | from pathlib import Path 13 | 14 | from django.core.wsgi import get_wsgi_application 15 | 16 | # Add parent dir to PYTHONPATH so that 'tetra' is available 17 | sys.path.append(str(Path(__file__).resolve().parent.parent.parent)) 18 | 19 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "demosite.settings") 20 | 21 | application = get_wsgi_application() 22 | -------------------------------------------------------------------------------- /demosite/docs/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tetra-framework/tetra/3f9faeaf5af277b27fd420f02d972856e39287e0/demosite/docs/__init__.py -------------------------------------------------------------------------------- /demosite/docs/admin.py: -------------------------------------------------------------------------------- 1 | 2 | # Register your models here. 3 | -------------------------------------------------------------------------------- /demosite/docs/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class DocsConfig(AppConfig): 5 | default_auto_field = "django.db.models.BigAutoField" 6 | name = "docs" 7 | -------------------------------------------------------------------------------- /demosite/docs/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tetra-framework/tetra/3f9faeaf5af277b27fd420f02d972856e39287e0/demosite/docs/migrations/__init__.py -------------------------------------------------------------------------------- /demosite/docs/models.py: -------------------------------------------------------------------------------- 1 | 2 | # Create your models here. 3 | -------------------------------------------------------------------------------- /demosite/docs/tests.py: -------------------------------------------------------------------------------- 1 | 2 | # Create your tests here. 3 | -------------------------------------------------------------------------------- /demosite/docs/urls.py: -------------------------------------------------------------------------------- 1 | from django.urls import path 2 | from docs import views 3 | 4 | urlpatterns = [ 5 | path("", views.doc, name="docs-home"), 6 | path("", views.doc, name="doc"), 7 | ] 8 | -------------------------------------------------------------------------------- /demosite/docs/views.py: -------------------------------------------------------------------------------- 1 | from django.shortcuts import render 2 | from django.http import Http404 3 | from django.conf import settings 4 | import yaml 5 | import markdown 6 | from markdown.extensions.toc import TocExtension 7 | 8 | 9 | def markdown_title(title) -> str: 10 | return markdown.markdown(title).replace("

", "").replace("

", "") 11 | 12 | 13 | def doc(request, slug="introduction"): 14 | with open(settings.BASE_DIR.parent / "docs" / "structure.yaml") as f: 15 | raw_structure = yaml.load(f, Loader=yaml.CLoader) 16 | structure = [] 17 | slugs = [] 18 | for top_level in raw_structure: 19 | key, value = next(iter(top_level.items())) 20 | if isinstance(value, str): 21 | # Header with link 22 | slugs.append(key) 23 | structure.append( 24 | { 25 | "slug": key, 26 | "title": markdown_title(value), 27 | "items": [], 28 | } 29 | ) 30 | else: 31 | # Header with sub items 32 | items = [] 33 | for item in value: 34 | item_key, item_value = next(iter(item.items())) 35 | slugs.append(item_key) 36 | items.append( 37 | { 38 | "slug": item_key, 39 | "title": markdown_title(item_value), 40 | } 41 | ) 42 | structure.append( 43 | { 44 | "slug": None, 45 | "title": markdown_title(key), 46 | "items": items, 47 | } 48 | ) 49 | 50 | if slug not in slugs: 51 | raise Http404() 52 | 53 | with open(settings.BASE_DIR.parent / "docs" / f"{slug}.md") as f: 54 | md = markdown.Markdown( 55 | extensions=[ 56 | "extra", 57 | "meta", 58 | TocExtension(permalink="#", toc_depth=3), 59 | ] 60 | ) 61 | content = md.convert(f.read()) 62 | print(md.Meta) 63 | return render( 64 | request, 65 | "base_docs.html", 66 | { 67 | "structure": structure, 68 | "content": content, 69 | "toc": md.toc, 70 | "active_slug": slug, 71 | "title": " ".join(md.Meta["title"]), 72 | }, 73 | ) 74 | -------------------------------------------------------------------------------- /demosite/manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | """Django's command-line utility for administrative tasks.""" 3 | import os 4 | import sys 5 | from pathlib import Path 6 | 7 | # Add parent dir to PYTHONPATH so that 'tetra' is available 8 | sys.path.append(str(Path(__file__).resolve().parent.parent)) 9 | 10 | 11 | def main() -> None: 12 | """Run administrative tasks.""" 13 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "demosite.settings") 14 | try: 15 | from django.core.management import execute_from_command_line 16 | except ImportError as exc: 17 | raise ImportError( 18 | "Couldn't import Django. Are you sure it's installed and " 19 | "available on your PYTHONPATH environment variable? Did you " 20 | "forget to activate a virtual environment?" 21 | ) from exc 22 | execute_from_command_line(sys.argv) 23 | 24 | 25 | if __name__ == "__main__": 26 | main() 27 | -------------------------------------------------------------------------------- /demosite/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "demosite", 3 | "version": "1.0.0", 4 | "main": "index.js", 5 | "scripts": { 6 | "test": "echo \"Error: no test specified\" && exit 1" 7 | }, 8 | "private": true, 9 | "author": "", 10 | "license": "ISC", 11 | "dependencies": { 12 | "esbuild": "~=0.25" 13 | }, 14 | "directories": { 15 | "doc": "docs" 16 | }, 17 | "description": "" 18 | } 19 | -------------------------------------------------------------------------------- /docs/attribute-tag.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: "The `...` Attribute" 3 | --- 4 | 5 | # `...` Attribute Tag 6 | 7 | HTML attributes regularly need to be set programmatically in a template. To aid in this, Tetra has the "attribute tag", available as `...` (three periods) as it "unpacks" the arguments provided to it as HTML attributes. 8 | 9 | The attributes tag is automaticity available in your **component templates**. In other templates be sure to `{% load tetra %}`. 10 | 11 | ``` django 12 | {% load tetra %} 13 |
15 | ``` 16 | 17 | All Tetra components have an `attrs` context available, which is a `dict` of attributes that have been passed to the component when it is included in a template with the [`@` tag](component-tag.md). It can be unpacked as HTML attributes on your root node: 18 | 19 | ``` django 20 |
21 | ``` 22 | 23 | The attribute tag can take the following arguments: 24 | 25 | - A (not keyword) variable resolving to a `dict` of attribute names mapped to values. This is what the `attrs` context variable is. 26 | 27 | - An attribute name and literal value such as `class="test"`. 28 | 29 | - An attribute name and context variable such as `class=list_of_classes`. 30 | 31 | The attributes are processed left to right, and if there is a duplicate attribute name the last occurrence is used (there is a special case for [`class`](#class-attribute) and [`style`](#style-attribute)). This allows you to provide both default values and overrides. In the example below the `id` has a default value of `"test"` but can be overridden when the component is used via the `attrs` variable. It also forces the `title` attribute to a specific value overriding any set in `attrs`. 32 | 33 | ``` django 34 |
35 | ``` 36 | 37 | ## Boolean attributes 38 | 39 | Boolean values have a special case. If an attribute is set to `False` it is not included in the final HTML. If an attribute is set to `True` it is included in the HTML as just the attribute name, such as ``. 40 | 41 | ## Class attribute 42 | 43 | The `class` attribute treats each class name as an individual option concatenating all passed classes. In the example below all classes will appear on the final element: 44 | 45 | ``` django 46 | {# where the component is used #} 47 | {% @ Component attrs: class="class1" %} 48 | 49 | {# component template with a_list_of_classes=["classA", "classB"] #} 50 |
51 | ``` 52 | 53 | Resulting html: 54 | 55 | ``` html 56 |
57 | ``` 58 | 59 | ## Style attribute 60 | 61 | There is a special case for the `style` attribute, similar to the `class` attribute. All passed styes are split into individual property names with the final value for property name used in the final attribute. 62 | 63 | ``` django 64 |
65 | ``` 66 | 67 | Would result in: 68 | 69 | ``` html 70 |
71 | ``` 72 | 73 | !!! note 74 | Tetra currently does not understand that a style property can be applied in multiple ways. Therefore, if you pass both `margin-top: 1em` and `margin: 2em 0 0 0`, both will appear in the final HTML style tag, with the final property taking precedence in the browser. 75 | 76 | ## Conditional values 77 | 78 | The [`if` and `else` template filters](if-else-filters.md) are provided to enable conditional attribute values: 79 | 80 | ``` html 81 |
82 | ``` 83 | 84 | See the documentation for the [`if` and `else` template filters](if-else-filters.md). 85 | -------------------------------------------------------------------------------- /docs/basic-components.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Basic Components 3 | --- 4 | 5 | # Basic Components 6 | 7 | `BasicComponent` supports CSS, but not JS, Alpine.js, or any public attributes or methods. Basic Components should be used for encapsulating reusable components that have no direct client side interaction and are useful for composing within other components. 8 | 9 | As they don't save their state to be resumable, or initiate an Alpine.js component in the browser, they have lower overhead. 10 | 11 | They are registered exactly the same way as normal components and their CSS styles will be bundled with the rest of the library's styles. 12 | 13 | Supported features: 14 | 15 | - `load` method 16 | - `template` 17 | - `styles` 18 | - Private methods and attributes 19 | 20 | ``` python 21 | from tetra import BasicComponent 22 | 23 | class MyBasicComponent(BasicComponent): 24 | ... 25 | ``` -------------------------------------------------------------------------------- /docs/changelog.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Changelog 3 | --- 4 | 5 | # Changelog 6 | 7 | !!! note 8 | Tetra is still early in its development, and we can make no promises about 9 | API stability at this stage. 10 | 11 | The intention is to stabilise the API prior to a v1.0 release, as well as 12 | implementing some additional functionality. 13 | After v1.0 we will move to using [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 14 | 15 | ## [0.3.2] - unreleased 16 | ### Changed 17 | - rename `request.tetra.current_url_abs_path` to `current_url_full_path` to better adhere to naming standards. `current_url_abs_path` is deprecated. 18 | 19 | ### Added 20 | - `request.tetra.current_url_path` that holds the path without query params 21 | - a `tetra` template variable that holds the TetraDetails of a request, or possible equivalents of the current main request. 22 | - Client URL pushes are now anticipated and reflected on the server before rendering the component on updates 23 | 24 | ## [0.3.1] - 2025-04-19 25 | - **BREAKING CHANGE** rename all `tetra:*` events to kebab-case: `before-request`, `after-request`, `component-updated`, `component-before-remove` etc. This was necessary because camelCase Events cannot be used properly in `x-on:` attributes - HTMX attributes are forced to lowercase, which breaks the event capture. 26 | 27 | ## [0.3.0] - 2025-04-18 28 | ### Added 29 | - beforeRequest, afterRequest events 30 | - add support for loading indicators (=spinners) 31 | - add support for file downloads in component methods 32 | 33 | ### Changed 34 | - **BREAKING CHANGE** rename all `tetra:*` events to camelCase: `componentUpdated`, `componentBeforeRemove` etc. 35 | 36 | ### Fixed 37 | - fix file uploads for FormComponent (using multipart/form-data) 38 | 39 | ## [0.2.1] - 2025-03-29 40 | - fix a small bug that could early-delete temporary uploaded files 41 | 42 | ## [0.2.0] - 2025-03-27 43 | ### Added 44 | - DynamicFormMixin for dynamically updatable FormComponents 45 | - Improved demo site 46 | - Added debug logging handler 47 | - Improved component import error handling 48 | - Allow component names to be dynamic 49 | - `@v` shortcut templatetag for "live" rendering of frontend variables 50 | - Better support for Django models 51 | - Experimental FormComponent and ModelFormComponent support with form validation 52 | - Definable extra context per component class 53 | - `reset()` method for FormComponent 54 | - `push_url()` and `replace_url()` component methods for manipulating the URL in the address bar 55 | - `recalculate_attrs()` method for calculated updates to attributes before and after component methods 56 | - `request.tetra` helper for *current_url*, *current_abs_path* and *url_query_params*, like in HTMX 57 | - add life cycle Js events when updating/removing etc. components 58 | - add a T-Response header that only is available in Tetra responses. 59 | - Integration of Django messages into Tetra, using T-Messages response header 60 | - `` at begin of components are possible now 61 | 62 | ### Removed 63 | - **BREAKING CHANGE** `ready()` method is removed and functionally replaced with `recalculate_attrs()` 64 | 65 | ### Changed 66 | - **BREAKING CHANGE**: registering libraries is completely different. Libraries are directories and automatically found. The library automatically is the containing module name now. Explicit "default = Library()" ist disregarded. 67 | - Components should be referenced using PascalCase in templates now 68 | - Component tags: replace `**context` with `__all__` when passing all context in template tags 69 | - More verbose error when template is not enclosed in HTML tags 70 | - Improved component import error handling 71 | - Improved demo site 72 | 73 | ## [0.1.1] - 2024-04-10 74 | ### Changed 75 | - **New package name: tetra** 76 | - Add conditional block check within components 77 | - Update Alpine.js to v3.13.8 78 | - Switch to pyproject.toml based python package 79 | - Improve demo project: TodoList component,- add django-environ for keeping secrets, use whitenoise for staticfiles 80 | - Give users more hints when no components are found 81 | - MkDocs based documentation 82 | - Format codebase with Black 83 | 84 | ### Added 85 | - Basic testing using pytest 86 | 87 | ### Fixed 88 | - Correctly find components 89 | 90 | ## [0.0.5] - 2022-06-13 91 | ### Changed 92 | - **This is the last package with the name "tetraframework", transition to "tetra"** 93 | - Provisional Python 3.8 support 94 | 95 | ### Fixed 96 | - Windows support 97 | 98 | 99 | ## [0.0.4] - 2022-06-22 100 | - Cleanup 101 | 102 | 103 | ## [0.0.3] - 2022-05-29 104 | ### Added 105 | - `_parent` client attribute added, this can be used to access the parent component mounted in the client. 106 | - `_redirect` client method added, this can be used to redirect to another url from the public server methods. `self.client._redirect("/test")` would redirect to the `/test` url. 107 | - `_dispatch` client method added, this is a wrapper around the Alpine.js [`dispatch` magic](https://alpinejs.dev/magics/dispatch) allowing you to dispatch events from public server methods. These bubble up the DOM and be captured by listeners on (grand)parent components. Example: `self.client._dispatch("MyEvent", {'some_data': 123})`. 108 | - `_refresh` public method added, this simply renders the component on the server updating the dom in the browser. This can be used in combination with `_parent` to instruct a parent component to re-render from a child components public method such as: `self.client._parent._refresh()` 109 | 110 | ### Changed 111 | - Built in Tetra client methods renamed to be prefixed with an underscore so that they are separated from user implemented methods: 112 | - `updateHtml` is now `_updateHtml` 113 | - `updateData` is now `_updateData` 114 | - `removeComponent` is now `_removeComponent` 115 | - `replaceComponentAndState` is now `_replaceComponent` 116 | -------------------------------------------------------------------------------- /docs/component-inheritance.md: -------------------------------------------------------------------------------- 1 | Title: Component Inheritance 2 | 3 | # Component inheritance - abstract components 4 | 5 | Components basically are inheritable, to create components that bundle common features, which can be reused and extended by more specialized ones. But: **You cannot inherit from already registered components.** 6 | 7 | As components are registered automatically by putting them into a library module, you can create *abstract components* to exclude them from registering. 8 | 9 | This works with both `BasicComponent` and `Component`. 10 | 11 | ```python 12 | # no registering here! 13 | class BaseCard(BasicComponent): 14 | __abstract__ = True 15 | template = "
" 16 | 17 | 18 | # no registering here! 19 | class Card(BaseCard): 20 | __abstract__ = True 21 | template: django_html = """ 22 |
23 | {% block default %}{% endblock %]} 24 |
25 | """ 26 | 27 | 28 | # This component is registered: 29 | class GreenCard(Card): 30 | style: css = """ 31 | .mycard { 32 | background-color: green 33 | } 34 | """ 35 | ``` 36 | 37 | You can even define more than one directory style components in one file, as long as only *one* of them is actually registered, the others must be abstract. -------------------------------------------------------------------------------- /docs/component-libraries.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Libraries 3 | --- 4 | 5 | # Libraries 6 | 7 | Every Tetra component belongs to a component library. Basically, libraries are the modules within `.components` (or, alternatively, `.tetra_components`) where components are found automatically: 8 | 9 | ``` 10 | myapp 11 | ├── components/ 12 | │ ├──anotherlib 13 | │ ├──default * 14 | │ ├──ui 15 | ``` 16 | 17 | With other words: The first module layer within `myapp.components` are libraries. It doesn't matter if you create libraries as file modules, or packages, both are equally used, with one difference: package modules allow [Directory style components](#directory-style-components), see below. 18 | 19 | When resolving a component, and you don't specify a library in your *component tag*, Tetra assumes you put the component in the `default` library. However, you can have infinite libraries. This is a good way to organise components into related sets. Each library's Javascript and CSS is packaged together. As long as components are registered to a library and that library instance is available in `.components` or `.tetra_components` they will be available to use from templates, and within other components. 20 | 21 | While it is not necessary, it is also possible to create libraries manually (e.g. in your testing files). You have to provide a `name` and an `app`, and if the same library was already registered, it is not recreated - the library with that name is reused, so name and app are unique together within libraries. 22 | 23 | ```python 24 | from tetra import Library, Component 25 | from django.apps import apps 26 | 27 | class FooComponent(Component): 28 | template = "
foo!
" 29 | 30 | # create a new library named "default" for the "main" app 31 | default = Library(name="default", app=apps.get_app_config("main")) 32 | 33 | # register the FooComponent to the default library 34 | default.register(FooComponent) 35 | 36 | # if you create a library twice, or you use a library that was already created automatically by 37 | # creating a "default" folder in your `.components` directory, that library is reused. 38 | default_double = Library("default", "main") 39 | assert default_double is default 40 | ``` 41 | 42 | #### Directory style components 43 | A component is created as a subclass of `BasicComponent` or `Component` and registered to a library by placing it into the library package. Let's see how the directory structure would look like for a `MyCalendar` component: 44 | 45 | ``` 46 | myapp 47 | ├── components/ 48 | │ │ └──default 49 | │ │ └── my_calendar/ 50 | │ │ ├──__init__.py* <-- here is the component class defined 51 | │ │ ├──script.js 52 | │ │ ├──style.css 53 | │ │ └──my_calendar.html* 54 | ``` 55 | 56 | The `__init__.py` and `my_calendar.html` template are mandatory, css/js and other files are optional. 57 | 58 | #### Inline components 59 | 60 | There is another (shortcut) way of creating components, especially for simple building bricks (like `BasicComponents` without Js, CSS, and with small HTML templates). 61 | Create a component class and place it directly into a library module. You can create multiple components directly in the module. The simplest form is directly in the `default` library: 62 | ``` python 63 | #myapp/components/default.py 64 | 65 | class Link(BasicComponent): 66 | href: str = "" 67 | title: str = "" 68 | template: django_html = "{{title}} 69 | ``` 70 | 71 | However, You can mix directory libraries and file libraries as you want: Put a few components into `default/__init__.py`, and another into `default/my_component/__init__.py`. Both are found: 72 | 73 | ``` 74 | myapp 75 | ├── components/ 76 | │ │ ├──default 77 | │ │ │ └──__init__.py <-- put all "default" component classes in here 78 | │ │ ├──otherlib.py <-- put all "otherlib" component classes in here 79 | │ │ ├──widgets 80 | │ │ │ ├──__init__.py <-- put all "widgets" component classes in here 81 | │ │ │ ├──link 82 | │ │ │ │ ├──__init__.py <-- Link component definition 83 | │ │ │ │ └──link.html 84 | ... 85 | ``` 86 | 87 | 88 | 89 | !!! note 90 | If you use a **directory style component**, make sure you define only ONE component class per module (e.g. in `components/default/my_calendar.py`). If you use the library module directly to create components (`components/default.py`), you can certainly put multiple components in there. 91 | 92 | ## Manually declared libraries 93 | 94 | It is not necessary to follow the directory structure. You can also declare a Library anywhere in your code, and register components to it. The `Library` class takes the *name of the library* and the *AppConfig (or app label)* as parameters. You can declare the libraries more then once, everything with the same name will be merged together. 95 | 96 | !!! note 97 | Library names must be globally unique. Declaring Libraries with the same name in different apps is forbidden. 98 | 99 | ```python 100 | from tetra import Library 101 | 102 | widgets = Library("widgets", "ui") 103 | # this is the same library! 104 | widgets_too = Library("widgets", "ui") 105 | ``` 106 | 107 | When a Library is declared, you can register Components to it by using the `@.register` decorator: 108 | 109 | ```python 110 | @widgets.register 111 | class Button(BasicComponent): 112 | ... 113 | ``` 114 | 115 | As a decorator can be used as a function, you can even register components in code: 116 | 117 | ```python 118 | lib = Library("mylib", "myapp") 119 | lib.register(MyComponentClass) 120 | ``` -------------------------------------------------------------------------------- /docs/component-life-cycle.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Component life cycle 3 | --- 4 | 5 | # Attribute data life cycle 6 | 7 | The data attributes of a component exist within a specific lifecycle. The component, when constructed or resumed sets its atributes in a certain order (see below). In each step, the already existing attribute data are overridden. 8 | 9 | ## 1. Attribute assignment 10 | 11 | ```python 12 | class Person(Component): 13 | name:str = "John Doe" 14 | age:int = None 15 | 16 | ... 17 | ``` 18 | 19 | When Attributes are set directly in the class, they are used as default values, as in any other python class too. Even if no `load()` method is present, the component can use this values. 20 | 21 | ## 2. Resumed data from encrypted state 22 | 23 | If the component is resumed from a previous state, the encrypted state data is decrypted, and all component attributes are set from the previous state. 24 | This is omitted if the component is initialized the first time, as there is no encrypted previous state yet. 25 | 26 | ## 3. The `load()` method 27 | 28 | Next, the component's `load()` method is called. Any data assignment to the attributes overrides the previous values. 29 | 30 | ```python 31 | class Person(Component): 32 | name:str = "John Doe" 33 | age:int = None 34 | 35 | def load(self, pk:int, *args, **kwargs): 36 | person = Person.objects.get(pk=pk) 37 | self.name = person.name 38 | self.age = person.age 39 | ``` 40 | 41 | Attributes that set in the `load()` method are **not** saved with the state, as the values are overwritten in the subsequent step. This seems to be extraneous, but in fact makes sure that the component attributes gets a consistent initialization. 42 | 43 | 44 | ## 4. The client data 45 | 46 | The final step involves updating attributes using *data* passed from the client-side to the server via component methods. Note that this is not the same as the *state*: 47 | 48 | * The **state** represents the "frozen data" sent to the client during the last render, essentially what the client received initially. 49 | * The **data** refers to dynamic values, such as a component input tag's value, which may have changed during the last interaction cycle. 50 | 51 | 52 | # Events on the client side 53 | 54 | Have a look at the [events][events.md]. 55 | 56 | # Sequence diagram 57 | 58 | What happens when a public method is called? This sequence diagram shows everything. 59 | ```mermaid 60 | sequenceDiagram 61 | box Client 62 | participant Client 63 | end 64 | 65 | box Server 66 | participant Server 67 | participant component_method 68 | participant Component 69 | participant TetraJSONEncoder 70 | participant TetraJSONDecoder 71 | participant decode_component 72 | participant StateUnpickler 73 | end 74 | 75 | Client->> Server: POST request to /component_method/ 76 | Server ->> component_method: Call component_method() view 77 | component_method ->> component_method: Set PersistentTemporaryFileUploadHandler 78 | component_method ->> component_method: Validate request method (==POST?) 79 | component_method ->> component_method: Retrieve Component class from Library list 80 | component_method ->> component_method: Validate method name is public 81 | component_method ->> TetraJSONDecoder: Decode request.POST data (from_json) 82 | TetraJSONDecoder -->> component_method: Return decoded data 83 | component_method ->> component_method: Add Component class to set of used components in this request 84 | component_method ->> Component: Request new component instance (using Component.from_state) 85 | Component ->> Component: Validate ComponentState data structure 86 | 87 | Component ->> decode_component: decode_component(ComponentState["state"], request) 88 | decode_component ->> decode_component: get fernet for request 89 | decode_component ->> decode_component: decrypt encoded state_token using fernet 90 | decode_component ->> decode_component: decompress decrypted data with gzip 91 | decode_component ->> StateUnpickler: unpickle component data state 92 | StateUnpickler -->> decode_component: component 93 | decode_component -->> Component: component 94 | 95 | Component ->> Component: Set component request, key, attrs, context, blocks 96 | Component ->> Component: recall load() with initial params 97 | Component ->> Component: set component attributes from client data 98 | Component ->> Component: client data contains a Model PK? -> replace it with Model instance from DB 99 | Component ->> Component: hook: recalculate_attrs(component_method_finished=False) 100 | Component -->> component_method: Return initialized component instance 101 | 102 | 103 | component_method ->> component_method: Attach uploaded files (from request.FILES) to component 104 | component_method ->> Component: Call Component's _call_public_method 105 | Component ->> Component: Execute public method 106 | 107 | Component ->> TetraJSONEncoder: Encode result data to JSON 108 | TetraJSONEncoder -->> Component: Return JSON-encoded data 109 | 110 | Note over Component: JSON response 111 | Component -->> component_method: Return encoded result 112 | component_method -->> Server: Return JsonResponse 113 | Server -->>Client: Send response 114 | 115 | 116 | ``` 117 | -------------------------------------------------------------------------------- /docs/contribute.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Contributing 3 | --- 4 | 5 | # Contributing to the project 6 | 7 | You can help/contribute in many ways: 8 | 9 | * Bring in new ideas and [discussions](https://github.com/tetra-framework/tetra/discussions) 10 | * Report bugs in our [issue tracker](https://github.com/tetra-framework/tetra/issues) 11 | * Add documentation 12 | * Write code 13 | 14 | 15 | ## Writing code 16 | 17 | Fork the repository locally and install it as editable package: 18 | 19 | ```bash 20 | git clone git@github.com:tetra-framework/tetra.git 21 | cd tetra 22 | python -m pip install -e . 23 | ``` 24 | 25 | 26 | ### Code style 27 | 28 | * Please only write [Black](https://github.com/psf/black) styled code. You can automate that by using your IDE's save 29 | trigger feature. 30 | * Document your code well, using [Napoleon style docstrings](https://sphinxcontrib-napoleon.readthedocs.io/en/latest/example_google.html#example-google). 31 | * Write appropriate tests for your code. -------------------------------------------------------------------------------- /docs/events.md: -------------------------------------------------------------------------------- 1 | # Tetra events 2 | 3 | There are some events that occur during the tetra component life cycle. You can use them to hook into. 4 | 5 | 6 | On the client, there are certain javascript events fired when certain things happen. You can react on that using Alpine's `x-on` or by using custom Javascript code. 7 | 8 | If not stated otherwise, all events have the actual component as `component` payload attached in `event.detail`. 9 | 10 | ## Event list 11 | 12 | ### `tetra:before-request` 13 | 14 | This event fires after a component method has completed — whether the request was successful (even if the response includes an HTTP error like 404) or if a network error occurred. It can be used alongside `tetra:before-request` to implement custom behavior around the full request lifecycle, such as showing or hiding a loading indicator. 15 | 16 | 17 | ### `tetra:after-request` 18 | 19 | This event is triggered before a component method is called. 20 | 21 | ### `tetra:child-component-init` 22 | 23 | Whenever a child component is initialized, this event is fired. This is mainly used internally within Tetra. 24 | 25 | ### `tetra:child-component-destroy` 26 | 27 | Called before a child component is going to be destroyed. This is mainly used internally within Tetra. 28 | 29 | ### `tetra:component-updated` 30 | This event is fired after a component has called a public method and the new HTML is completely morphed into the DOM. 31 | It is also fired after a component has been replaced. 32 | 33 | ```html 34 |
35 | Original text 36 |
37 | ``` 38 | 39 | ### `tetra:component-data-updated` 40 | 41 | The same goes for data updates: the event is fired after a data update without HTML changes was finished. 42 | 43 | ### `tetra:component-before-remove` 44 | 45 | Right before a component is removed using `self.client._removeComponent()` this event is triggered. 46 | 47 | ### `tetra:new-message` 48 | 49 | After a request returns a response, Tetra fires this event if there are new messages from the Django messaging system. You can react to these messages, e.g. display them in a component. 50 | 51 | #### Details 52 | * `messages`: a list of message objects, see [messages](messages.md) 53 | -------------------------------------------------------------------------------- /docs/files.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: File handling in Tetra 3 | --- 4 | 5 | # File handling in Tetra 6 | 7 | ## File uploads 8 | 9 | When using HTML file input tags, Tetra's [FormComponent](form-components.md) takes care of the uploading process. While in normal HTML `
` elements a file upload can only happen with special precautions (form enctype=multipart/form-data; page is never reloaded using validation with GET because the browser deletes the file then), `FormComponent` takes care of the uploading process within a component automatically: 10 | 11 | * Whenever the first `POST` request is fired, the file is sent to the server. You don't have to create a `form enctype=multipart/form-data` etc., Tetra does that automatically. 12 | * The file is then saved temporarily on the server, until the `submit()` method is called finally 13 | * Now the file is copied to its final destination and attached to the form's field. 14 | 15 | So there's not anything to mention. Just use a FileField in your `FormComponent` 16 | 17 | ```python 18 | class PersonForm(Form): 19 | name = forms.CharField() 20 | attachment = forms.FileField(upload_to="attachments/") 21 | 22 | class PersonComponent(FormComponent): 23 | form_class = PersonForm 24 | ``` 25 | 26 | ## File downloads 27 | 28 | You can place any link to a staticfile as ` FileResponse|None: 49 | if self.request.user.is_authenticated: 50 | pdf_file = generate_pdf_from_some_template( 51 | "/path/to/template.pdf", { 52 | "first_name": self.first_name, 53 | "last_name": self.last_name, 54 | "password": some_random_generated_password(), 55 | } 56 | ) 57 | return FileResponse(content_type="application/pdf", filename="credentials.pdf") 58 | 59 | # if user is not authenticated, the normal Tetra response is executed, 60 | # so the component just updates itself. 61 | ``` 62 | 63 | You can also return a `FileResponse(open(/path/to/file.dat), ...)` to offer a downloadable file that has no publicly available URL. 64 | Just make sure that content_type and filename is provided -------------------------------------------------------------------------------- /docs/flowcharts/click_behaviour.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | ```mermaid 4 | flowchart TD 5 | subgraph initial_page_load["Initial Page load"] 6 | as_tag["{% @ ... %} tag creates Component"] 7 | create_component["Initialize new component"] 8 | component_render["Render component"] 9 | render_data["render data + state as JSON"] 10 | end 11 | 12 | 13 | subgraph s1["Component method called"] 14 | get_client_state["Get component state from client"] --> decode_component 15 | subgraph resume_component["Resume component from state"] 16 | recall_load["recall component.load() with initial params"] 17 | decode_component["decode component from state"] 18 | populate_data["Populate component attributes from client data"] 19 | return_component["Return component"] 20 | end 21 | subgraph call_component_method["Call component method"] 22 | call_public_method["Execute @public method"] 23 | update["Update component html"] 24 | json_response["Return (encoded) JSON response"] 25 | end 26 | end 27 | as_tag --> create_component 28 | decode_component --> recall_load 29 | recall_load --> populate_data 30 | populate_data --> return_component 31 | json_response --> client_updates_component["Client updates component"] 32 | call_public_method --> update 33 | return_component --> recalculate_attrs["Recalculate attributes 1.x"] 34 | recalculate_attrs --> call_public_method & component_render 35 | create_component --> recalculate_attrs 36 | recalculate_attrs2["Recalculate attrs 2.x"] --> render_data 37 | component_render --> recalculate_attrs2 38 | start(["Start"]) --> page_load_or_method_called{"Initial page load, or method called?"} 39 | page_load_or_method_called -- component method called --> get_client_state 40 | page_load_or_method_called -- Initial page load --> as_tag 41 | render_data --> _end(["End"]) 42 | client_updates_component --> _end["End"] 43 | update --> json_response 44 | ``` -------------------------------------------------------------------------------- /docs/helpers.md: -------------------------------------------------------------------------------- 1 | # Tetra helpers 2 | 3 | 4 | Tetra offers some helpers that solve common problems. 5 | 6 | 7 | ## Loading indicators / spinners 8 | 9 | When clicks are answered with loading times from below 100ms, users perceive this as "instantly". With every +100ms added, the system feels more "laggy". But the most frustrating experience we all know is: clicking on a button that starts a longer loading procedure, and there is no indicator of "busyness" at all, so you don't know if the system is doing something, crashed, or you just did not "click hard enough". So users tend to click again, eventually causing to start the procedure again. 10 | 11 | This can be massively improved by "loading indicators", mostly known as "spinners". Tetra offers simple, yet versatile support for loading indicators, with a bit of custom CSS. 12 | 13 | Spinners can be placed 14 | 15 | * globally on a page, 16 | * used per component, 17 | * or even per button that calls a backend method of that component. 18 | 19 | ### The `tetra-request` class 20 | 21 | While a request is in flight, the element that initiated the request (e.g. a button) receives the `tetra_request` class automatically. You can use that to show busy indicators within the button, just by adding some css. 22 | 23 | Here is an example that works with Bootstrap 5 CSS (`.spinner-border`): 24 | 25 | ```css 26 | .spinner-border { 27 | display: none; 28 | } 29 | .tetra-request.spinner-border, 30 | .tetra-request + .spinner-border { 31 | display: inline-block; 32 | } 33 | ``` 34 | Now place a "spinner" into your button: 35 | 36 | ```html 37 | 41 | ``` 42 | 43 | This is all you need to get a simple loading indicator working, for within an element. 44 | 45 | !!! note 46 | Have a look at the adjacent sibling combinator (+) for the activated spinner. This CSS ruls make sure you can put your spinner **inside** the element that does the AJAX call, or place it next to it. 47 | 48 | Beware that if you just use `.tetra-request .spinner-border` you select all child elements below too - so if the `tetra-request` event fires in any parent component, **all** spinners of all its childs will be visible, which is mostly not what you'll want. So write your CSS wisely. 49 | 50 | ### `tx-indicator` attribute 51 | 52 | If you don't want to place the indicator **into** the calling element, you have to tell Tetra somehow where this indicator is: 53 | 54 | The `tx-indicator` attribute contains a CSS selector that directs Tetra to the element that is used as loading indicator. During a Tetra request, **that element** will get the class `tetra-request` now. 55 | 56 | `tx-indicator` elements will take precedence over any inline spinners defined directly in the element. This means if both an inline spinner and a tx-indicator target are specified, the tx-indicator target will be used and the inline spinner will be ignored. 57 | 58 | ```html 59 | 60 | 61 | ``` 62 | 63 | There is nothing that holds you from doing "fancier" transitions than `display: inline`: 64 | 65 | ```css 66 | .spinner-border { 67 | opacity: 0; 68 | transition: opacity 500ms ease-in; 69 | } 70 | .tetra-request .spinner-border, 71 | .tetra-request.spinner-border { 72 | opacity: 1; 73 | } 74 | ``` 75 | 76 | It does not matter where you put the spinner, nor how many elements point to one spinner using `tx-indicator`: 77 | 78 | ```html 79 | 80 | 81 | 82 | ... 83 | 84 | ``` 85 | 86 | You can also add full syntactic ARIA sugar: 87 | 88 | ```html 89 | 93 | ``` 94 | 95 | This is all you need. Of course, you can implement this pattern in any other framework than Bootstrap, be it Bulma, Tailwind or others. 96 | 97 | 98 | Credits: The indicator functionality is closely modeled after the [hx-indicator](https://htmx.org/attributes/hx-indicator/) feature of HTMX. 99 | -------------------------------------------------------------------------------- /docs/if-else-filters.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: helper filters & tags 3 | --- 4 | 5 | # `if` and `else` Template Filters 6 | 7 | The `if` and `else` template filters are provided to enable conditional attribute values with the [`...` attribute template tag](attribute-tag.md): 8 | 9 | ``` django 10 |
11 | ``` 12 | 13 | The `if` and `else` template filters are automatically available in your components' templates, in other templates be sure to `{% load tetra %}`. 14 | 15 | ## `if` Filter 16 | 17 | When the value of the right hand argument evaluates to `True` it returns the value of its left hand argument. When the value of the right hand argument evaluates to `False` it returns an empty string `""` (a falsy value). 18 | 19 | So when setting a class: 20 | 21 | ``` django 22 |
23 | ``` 24 | 25 | if `my_var=True` you will generate this HTML: 26 | 27 | ``` html 28 |
29 | ``` 30 | 31 | if it's `False` then you will receive this: 32 | 33 | ``` html 34 |
35 | ``` 36 | 37 | ## `else` Filter 38 | 39 | When the value of the left hand argument evaluates to `True` it returns the value of its left hand argument. When the value of the left hand argument evaluates to `False` (such as an empty string `""`) it returns the value of its right hand argument. 40 | 41 | So with this: 42 | 43 | ``` django 44 |
45 | ``` 46 | 47 | If `context_var="Some 'Truthy' String"` then you will generate this HTML: 48 | 49 | ``` html 50 |
51 | ``` 52 | 53 | if it's `False` then you will receive this: 54 | 55 | ``` html 56 |
57 | ``` 58 | 59 | ## Chaining 60 | 61 | `if` and `else` can be chained together, so with this: 62 | 63 | ``` django 64 |
65 | ``` 66 | 67 | If `variable_name="A 'Truthy' Value"` then you will generate this HTML: 68 | 69 | ``` html 70 |
71 | ``` 72 | 73 | if it's `False` then you will receive this: 74 | 75 | ``` html 76 |
77 | ``` 78 | 79 | It is possible to further chain the filters such as: 80 | 81 | ``` django 82 |
83 | ``` 84 | 85 | ## The `@v` tag 86 | 87 | When variables are displayed in components, a common pattern is that a string should reflect a variable name "live", as you type. While a normal Django variable `{{ title }}` will get only updated after the next rendering of the component (e.g. after you call a backend method), Tetra provides a convenience way to render a variable instantly in the frontend using Alpine.js: `{% @v title %}`. 88 | 89 | ```django 90 |
91 |
Current title: {{ title }} - New title: {% @v title %}
92 |
93 | 94 |
95 | {# with quotes #} 96 |
97 | ``` 98 | It does not matter if you put the variable into quotes or not. 99 | Technically, it simply renders a `` and let Alpine.js do the rest. -------------------------------------------------------------------------------- /docs/img/favicon-white.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tetra-framework/tetra/3f9faeaf5af277b27fd420f02d972856e39287e0/docs/img/favicon-white.png -------------------------------------------------------------------------------- /docs/img/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tetra-framework/tetra/3f9faeaf5af277b27fd420f02d972856e39287e0/docs/img/favicon.ico -------------------------------------------------------------------------------- /docs/img/favicon.png: -------------------------------------------------------------------------------- 1 | ../../demosite/demo/static/favicon.png -------------------------------------------------------------------------------- /docs/img/logo.svg: -------------------------------------------------------------------------------- 1 | ../../demosite/demo/static/logo.svg -------------------------------------------------------------------------------- /docs/include-js-css.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Including Tetra CSS and JS 3 | --- 4 | 5 | # Including Tetra CSS and JS 6 | 7 | When processing a `request`, Tetra keeps track of which components have been used on a page. It then needs to inject the component's CSS and JavaScript into the page. You mark where this is to happen with the `{% tetra_styles %}` and `{% tetra_scripts %}` tags. They should be included in your HTML ``as below. 8 | 9 | By default `{% tetra_scripts %}` does not include Alpine.js. You can instruct it to do so by setting `include_alpine=True`. If you would prefer to include Alpine.js yourself you must do so *before* `{% tetra_scripts %}` and also include its [morph plugin](https://alpinejs.dev/plugins/morph). 10 | 11 | The `tetra_styles` and `tetra_scripts` need to be loaded via `{% load tetra %}`. 12 | 13 | ``` django 14 | {% load tetra %} 15 | 16 | 17 | ... 18 | {% tetra_styles %} 19 | {% tetra_scripts include_alpine=True %} 20 | 21 | ... 22 | ``` -------------------------------------------------------------------------------- /docs/install.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Installation 3 | --- 4 | 5 | # Installation 6 | 7 | As a component framework for Django, Tetra requires that you have a Django project setup before installing. [Follow the Django introduction tutorial](https://docs.djangoproject.com/en/4.2/intro/tutorial01/). 8 | 9 | Once ready, install Tetra from PyPi: 10 | 11 | ``` 12 | $ pip install tetra 13 | ``` 14 | 15 | !!! note 16 | As Tetra is still being developed it has only been tested with Python 3.9-3.12, we intend to support all officially supported Python versions at the time of v1.0.0. 17 | 18 | ## Initial configuration 19 | 20 | Modify your Django `settings.py`: 21 | 22 | * Add `tetra` to your INSTALLED_APPS (if you use `daphne` or `django.contrib.staticfiles`, tetra must occur before it) 23 | * add `tetra.middleware.TetraMiddleware` to your middlewares 24 | 25 | ``` python 26 | INSTALLED_APPS = [ 27 | ... 28 | # Add the tetra app! 29 | # Tetra must be before the Django staticfiles app (and daphne, if you use it) 30 | # in INSTALLED_APPS so that the Tetra's 'runserver' command takes precedence as it will 31 | # automatically recompile your JS & CSS during development. 32 | "tetra", 33 | # "daphne", 34 | ... 35 | "django.contrib.staticfiles", 36 | ... 37 | ] 38 | 39 | MIDDLEWARE = [ 40 | ... 41 | # Add the Tetra middleware at the end of the list. 42 | # This adds the JS and CSS for your components to HTML responses 43 | "tetra.middleware.TetraMiddleware" 44 | ] 45 | ``` 46 | 47 | Modify your `urls.py`: 48 | 49 | ``` python 50 | from django.urls import path, include 51 | from django.conf import settings 52 | from django.conf.urls.static import static 53 | 54 | urlpatterns = [ 55 | ... 56 | # Add the Tetra app urls: 57 | # These include the endpoints that your components will connect to when 58 | # calling public methods. 59 | path('tetra/', include('tetra.urls')), 60 | # Also ensure you have setup static files for development: 61 | ] + static(settings.STATIC_URL, document_root=settings.STATIC_ROOT) 62 | ``` 63 | 64 | ## Installing esbuild 65 | 66 | Tetra requires [esbuild](https://esbuild.github.io), this is used to build your components' JavaScript/CSS into packages, and create sourcemaps so that you can trace errors back to your source Python files. The easiest way to install esbuild is via [Node.js](https://nodejs.org) [npm](https://www.npmjs.com), in the root of your Django project (the directory where `./manage.py` is located): 67 | 68 | ``` 69 | $ npm init # If you don't already have a npm package.json and ./node_modules directory 70 | $ npm install esbuild 71 | ``` 72 | 73 | By default, Tetra will expect the esbuild binary to be available as `[projectroot]/node_modules/.bin/esbuild`. If you have it installed in a different location you can set `TETRA_ESBUILD_PATH` in your Django `settings.py` file to the correct path. 74 | 75 | ## Modify base template 76 | 77 | Next, ensure that you have included the `tetra_styles` and `tetra_scripts` tags in your base HTML template. These instruct the `TetraMiddleware` where to insert the CSS and JavaScript for the components used on the page: 78 | 79 | ``` django 80 | {% load tetra %} 81 | 82 | 83 | ... 84 | {% tetra_styles %} 85 | {% tetra_scripts include_alpine=True %} 86 | 87 | ... 88 | ``` 89 | 90 | ## Inline syntax highlighting 91 | 92 | If you are using [VS Code](https://code.visualstudio.com) you can use the [Python Inline Source](https://marketplace.visualstudio.com/items?itemName=samwillis.python-inline-source) extension to syntax highlight the inline HTML, CSS and JavaScript in your component files. It looks for Python type annotations labeling the language used in strings. 93 | 94 | ## Running the dev server 95 | 96 | Finally, run the Django development server command as usual. When your files are modified it will rebuild your JS and CSS: 97 | 98 | ``` 99 | $ python manage.py runserver 100 | ``` 101 | 102 | You can also manually run the component build process with: 103 | 104 | ``` 105 | $ python manage.py tetrabuild 106 | ``` 107 | 108 | ## .gitignore 109 | 110 | While you might want to use a common [Django .gitignore file like from gitignore.io](https://www.toptal.com/developers/gitignore/api/django), you should add this to not accidentally check in cache files into your VCS: 111 | 112 | ``` 113 | # Tetra 114 | __tetracache__/ 115 | /**/static/*/tetra/** 116 | ``` -------------------------------------------------------------------------------- /docs/magic-static.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Alpine.js Magic 3 | --- 4 | 5 | # Alpine.js Magic: `$static` 6 | 7 | The `$static(path)` Alpine.js magic is the client side equivalent of the [Django `static` template tag](https://docs.djangoproject.com/en/4.2/ref/templates/builtins/#static). It takes a path in string form relative to your static root and returns the correct path to the file, whether it is on the same host, or on a completely different domain. 8 | 9 | -------------------------------------------------------------------------------- /docs/messages.md: -------------------------------------------------------------------------------- 1 | Title: Messaging integration 2 | 3 | # Message framework integration 4 | 5 | ## Django Messages 6 | Tetra integrates deeply Django's messaging framework. A traditional Django approach is to fetch all messages into the template and display them. 7 | 8 | In a component based layout as Tetra describes, where individual components independently make AJAX calls and update themselves, it is not as easy. You may have one component that gets the messages, but it is not notified about that when another component calls a method. Then, all messages would be stuck in the wrong response/component, so this doesn't help. Even when notifying the "messages" component so that it could get the new messages, would help, it would even make things worse, as there would be race conditions when 2 components update, and the messages component updates itself too twice, overwriting the first message with the proceeding one. 9 | 10 | ## Tetra Messages — brought by events 11 | So, the messaging must be kept independently on the client. Tetra tries to solve this by providing new messages, whenever they occur, with any call of any component, through the middleware. `TetraMiddleware` and Tetra's client JavaScript part process all messages and convert them into JavaScript objects that are then sent individually via a fired event: `tetra:new-message`. 12 | 13 | You can react on it in any component, using client side/Alpine.js or server side code: 14 | 15 | ```django 16 |
17 | 18 |
19 | ``` 20 | This shows a Bootstrap bell icon whenever a new message has arrived, instantly. You can also call a function, the message itself is added as the "details" attribute of the event. 21 | 22 | ```django 23 |
24 | ``` 25 | 26 | Since Tetra provides a [@public.subscribe modifier](components.md#subscribe), you can even react on the server on that: 27 | ```python 28 | from django.contrib.messages import Message 29 | 30 | class MyComponent(Component): 31 | 32 | @public.subscribe("tetra:new-message") 33 | def message_added(self, message:Message): 34 | ... 35 | ``` 36 | !!! note 37 | This makes only sense in certain scenarios, as the roundtrips Message generation -> Middleware -> Client event emitting -> AJAX call of backend method is a bit much for just reacting on a message. 38 | 39 | Tetra takes care of the serialization/transition from a Django `Message` to a full Javascript object with all necessary data available, including a boolean "dismissible" attribute. 40 | 41 | ### Message Attributes 42 | 43 | Django knows only three attributes: `message:str`, `level:int` and `extra_tags:str`, and dynamically builds the tags and level_tags as properties from them. 44 | 45 | ```javascript 46 | // Message anatomy 47 | { 48 | message: "Successfully saved File", 49 | uid: "5d68f405-8427-4e04-80b7-0996bf5e3629", 50 | level: 25, 51 | level_tag: "bg-success-lt", 52 | tags: "success dismissible", 53 | extra_tags: "dismissible", 54 | dismissible: true 55 | } 56 | ``` 57 | 58 | #### The `.uid` attribute 59 | 60 | In Django, there is no UID in messages. You could change your Message/Storage type by using `settings.MESSAGE_STORAGE`, but this is not necessary. We need the UID of each message only on the client. 61 | 62 | So each message gets an UID during the middleware transition, so it can be displayed and deleted individually. Just make sure to include the MessageMiddleware, and TetraMiddleware. The messages are transported via a special `T-Messages` response header. 63 | 64 | #### The `.dismissible` attribute: a special case 65 | 66 | There are cases where messages should be "sticky". In this case, you can add the extra_tag "dismissible" to the message, and Tetra will treat it specially: 67 | 68 | ```python 69 | messages.warning("You did something nasty.", extra_tags="dismissible") 70 | ``` 71 | 72 | In this case, the message will get a boolean attribute `.dismissible` on the client, so you could filter them out easier. 73 | 74 | ```javascript 75 | export default { 76 | message_arrived(message) { 77 | if(message.dismissible) { 78 | console.log("To whom it may concern: A sticky message waits to be clicked aẃay by the user.") 79 | } 80 | } 81 | } 82 | ``` 83 | -------------------------------------------------------------------------------- /docs/request.md: -------------------------------------------------------------------------------- 1 | from tetra import Component--- 2 | title: Tetra requests 3 | --- 4 | # Tetra requests 5 | 6 | Tetra requests are modified by `TetraMiddleware`, and spiked with a few helpful attributes. 7 | 8 | !!! note 9 | Make sure you have `tetra.middleware.TetraMiddleware` in your `settings.MIDDLEWARE` 10 | 11 | Tetra keeps a track of which components have been used on a page. It then injects the component's CSS and JavaScript into the page. You mark where this is to happen with the `{% tetra_styles %}` and `{% tetra_scripts %}` tags. See [Javascript and JS](include-js-css.md) for details. 12 | 13 | ## request.tetra 14 | 15 | When a component method is called, the request is modified by `TetraMiddleware`. Within the method, a `request.tetra` property is available on the component, providing a set of useful attributes for your application. 16 | 17 | #### __bool__() 18 | 19 | First, `self.request.tetra` itself evaluates to a bool, indicating whether the current request originated from a Tetra (AJAX) call or not. You can use that for e.g. changing the behavior in your `load()` method: 20 | 21 | ```python 22 | class MyComponent(Component): 23 | def load(self): 24 | if not self.request.tetra: 25 | # only executes when component is loading the first time, e.g. via browser page reload 26 | else: 27 | # this branch is executed only when called from a component method. 28 | ``` 29 | 30 | #### current_url 31 | 32 | Within a Tetra component's method call, Django's `request.build_absolute_uri()` holds the internal *URL of the component method* 33 | (the AJAX URL), which is often not what you want: sometimes you want the URL of the main page. The `request.tetra.current_url` provides the real url of the browser window:. 34 | 35 | ```python 36 | >>> self.request.build_absolute_uri() 37 | 'https://example.com/__tetra__/mycomponent/default/foo_method' 38 | >>> self.request.tetra.current_url 39 | 'https://example.com/foo/bar?q=qux' 40 | ``` 41 | 42 | #### current_url_path 43 | 44 | Returns the main request's URL's path (without parameters) 45 | 46 | ```python 47 | >>> self.request.tetra.current_url_path 48 | '/foo/bar' 49 | ``` 50 | 51 | #### current_url_full_path 52 | 53 | Similarly, you sometimes need the path of the current page. `request.tetra.current_url_full_path` holds the cleaned path. 54 | 55 | ```python 56 | >>> self.request.tetra.current_url_full_path 57 | '/foo/bar?q=qux' 58 | ``` 59 | 60 | #### url_query_params 61 | 62 | The same goes for GET parameters. `self.request.GET` means the get params of the Tetra call, which is mostly not what you want. 63 | To receive the GET params of your main URL in the browser, your tetra components can be accessed via `url_query_params`. 64 | 65 | E.g. when your page URL is `https://example.com/foo/bar/?tab=main`, within any called component method you can do this: 66 | 67 | ```python 68 | >>> self.request.tetra.url_query_params.get("tab") 69 | 'main' 70 | ``` 71 | 72 | So you can display different things depending on which GET params are given, or keep a certain state (e.g. open tab) when the "tab" param is set. -------------------------------------------------------------------------------- /docs/settings.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Settings 3 | --- 4 | 5 | # Settings 6 | 7 | Tetra exposes some settings variables, you can change them to your needs. 8 | 9 | 10 | ## TETRA_TEMP_UPLOAD_PATH 11 | 12 | You can manually set the directory where FormComponent saves its temporary file upload (relative to MEDIA_ROOT). The standard is `tetra_temp_upload`. 13 | 14 | ```python 15 | TETRA_TEMP_UPLOAD_PATH = "please_pass_by_nothing_to_see_here" 16 | ``` 17 | 18 | 19 | ## TETRA_FILE_CACHE_DIR_NAME 20 | 21 | How Tetra names its cache directories. Normally you shouldn't change this. Defaults to `__tetracache__`. 22 | 23 | ## others 24 | 25 | `TETRA_ESBUILD_CSS_ARGS` and `TETRA_ESBUILD_JS_ARGS` are used internally. Please look at the code if you really want to change these. -------------------------------------------------------------------------------- /docs/state-security.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Saved Server State and Security 3 | --- 4 | 5 | # Saved Server State and Security 6 | 7 | When a component is rendered, as well as making its public state available as JSON to the client, it saves its server state so that it can be resumed later. This is done using the builtin Python Pickle toolkit. The "Pickled" state is then encrypted using 128-bit AES and authenticated with HMAC via [Fernet](https://cryptography.io/en/latest/fernet/) using a key derived from your Django settings `SECRET_KEY` and the user's session id using [HKDF](https://cryptography.io/en/latest/hazmat/primitives/key-derivation-functions/#hkdf). 8 | 9 | This state is then sent to the client and resubmitted back to the server for unpickling on further requests via public methods. Each time the state changes on the server a new pickled state is created and sent to the client. 10 | 11 | By using Pickle for the serialisation of the server state we are able to support a very broad range of object types, effectively almost anything. 12 | 13 | It is essential that the Django `SECRET_KEY` is kept secure. It should never be checked into source controls, and ideally be stored securely by a secrets management system. 14 | 15 | As this encrypted server state was generated after the component had been passed its arguments, and after any view based authentication, it holds onto that authentication when resumed later. It is, in effect, an authentication token allowing the user to continue from that state at a later point in time. It is also possible to do further authentication within the `load()` method, or any other public method. 16 | 17 | ## State optimizations 18 | 19 | A number of optimizations have been made to ensure that the Pickled state is efficient and doesn't become stale. These include: 20 | 21 | - Models are saved as just a reference to the model type and the primary key. They are then retrieved from the database when unpickling. This ensures that they always present the latest data to your public methods. 22 | 23 | - QuerySets are saved in raw query form, not including any of the results. After unpickling, they are then lazily run to retrieve results from the database when required. 24 | 25 | - Template Blocks passed to a component are saved as just a reference to where they originated. This is almost always possible. It includes blocks defined within a component's template, or blocks in templates loaded using a Django built-in template loader. 26 | 27 | - When a component runs its `load` method, it tracks what properties are set. These are then excluded from the data when pickling. The `load` method is re-run after unpickling using the same arguments it was originally passed, or updated arguments if it is being resumed as a child component. -------------------------------------------------------------------------------- /docs/testing.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Testing 3 | --- 4 | 5 | # Testing 6 | 7 | Testing Tetra is done using pytest. Make sure you have npm (or yarn etc.) installed, Tetra needs `esbuild` and `chromium webdriver` for building the frontend components before testing. 8 | 9 | ```bash 10 | python -m pip install .[dev] 11 | cd tests 12 | npm install 13 | ``` 14 | 15 | And for e.g. Debian/Ubuntu Linux, 16 | ```bash 17 | sudo apt install chromium-chromedriver 18 | # start the chromedriver: 19 | chromium.chromedriver 20 | ``` 21 | 22 | In Tetra's root dir, use the Makefile. 23 | 24 | ```bash 25 | make test 26 | ``` -------------------------------------------------------------------------------- /mkdocs.yml: -------------------------------------------------------------------------------- 1 | site_name: Tetra framework 2 | site_url: https://tetraframework.readthedocs.org 3 | 4 | plugins: 5 | - mermaid2 6 | - panzoom 7 | 8 | theme: 9 | name: material 10 | logo: img/favicon-white.png 11 | favicon: img/favicon.png 12 | palette: 13 | primary: grey 14 | accent: blue 15 | features: 16 | - navigation.footer 17 | - navigation.top 18 | - navigation.instant 19 | - navigation.expand 20 | # - navigation.tabs 21 | # - navigation.tabs.sticky 22 | markdown_extensions: 23 | - admonition 24 | - pymdownx.highlight: 25 | anchor_linenums: true 26 | - pymdownx.superfences: 27 | # make exceptions to highlighting of code: 28 | custom_fences: 29 | - name: mermaid 30 | class: mermaid 31 | format: !!python/name:mermaid2.fence_mermaid_custom 32 | nav: 33 | - index.md 34 | - install.md 35 | - Components: 36 | - component-libraries.md 37 | - components.md 38 | - basic-components.md 39 | - form-components.md 40 | - component-inheritance.md 41 | - request.md 42 | - messages.md 43 | - component-life-cycle.md 44 | - events.md 45 | - files.md 46 | - helpers.md 47 | - Template: 48 | - component-tag.md 49 | - attribute-tag.md 50 | - include-js-css.md 51 | - if-else-filters.md 52 | - state-security.md 53 | - magic-static.md 54 | - Development: 55 | - contribute.md 56 | - changelog.md 57 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["setuptools>=77.0.0", "wheel"] 3 | build-backend = "setuptools.build_meta" 4 | 5 | [project] 6 | name = "tetra" 7 | dynamic = ["version"] 8 | description = "Full stack component framework for Django using Alpine.js" 9 | authors = [ 10 | { name = "Sam Willis", email="sam.willis@gmail.com"}, 11 | { name = "Christian González", email = "christian.gonzalez@nerdocs.at" } 12 | ] 13 | license = "MIT" 14 | license-files = [ 15 | "LICENSE" 16 | ] 17 | readme = "README.md" 18 | keywords = ["python", "django", "framework", "components"] 19 | classifiers = [ 20 | "Development Status :: 3 - Alpha", 21 | "Programming Language :: Python :: 3", 22 | "Operating System :: OS Independent", 23 | ] 24 | dependencies = [ 25 | "cryptography>=37.0.1", 26 | "Django>=3.2.0", 27 | "python-dateutil>=2.8.2", 28 | "sourcetypes>=0.0.4", 29 | ] 30 | requires-python = ">=3.8" 31 | 32 | [project.urls] 33 | Homepage = "https://tetraframework.com" 34 | Documentation = "https://tetra.readthedocs.io" 35 | Repository = "https://github.com/tetra-framework/tetra" 36 | 37 | [project.optional-dependencies] 38 | dev = [ 39 | "build", 40 | "twine>=6.1", 41 | "packaging>=24.2", 42 | "pkginfo>=1.12.1.2", 43 | "pytest", 44 | "pytest-django", 45 | "pre-commit", 46 | "black", 47 | "python-dateutil>=2.8.2", 48 | "beautifulsoup4", 49 | "tetra[demo]", # include all the demo packages too 50 | "tetra[doc]", 51 | "selenium" 52 | ] 53 | demo = [ 54 | "PyYAML>=6.0", 55 | "markdown>=3.3.7", 56 | "gunicorn", 57 | "django-environ", 58 | "whitenoise>=6.6.0", 59 | "PyYAML>=6.0", 60 | "markdown>=3.3.7", 61 | ] 62 | doc = [ 63 | "mkdocs", 64 | "mkdocs-material", 65 | "pymdown-extensions", 66 | "pygments", 67 | "mkdocstrings[python]", 68 | "mkdocs-mermaid2-plugin", 69 | "mkdocs-panzoom-plugin" 70 | ] 71 | 72 | 73 | [tool.setuptools.dynamic] 74 | version = {attr = "tetra.__version__"} 75 | 76 | [tool.setuptools.packages.find] 77 | exclude = ["docs", "tests", "demosite"] 78 | 79 | [tool.pytest.ini_options] 80 | minversion = "6.0" 81 | addopts = "--nomigrations" 82 | testpaths =[ 83 | "tests" 84 | ] -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tetra-framework/tetra/3f9faeaf5af277b27fd420f02d972856e39287e0/tests/__init__.py -------------------------------------------------------------------------------- /tests/another_app/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tetra-framework/tetra/3f9faeaf5af277b27fd420f02d972856e39287e0/tests/another_app/__init__.py -------------------------------------------------------------------------------- /tests/another_app/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class AnotherAppConfig(AppConfig): 5 | default_auto_field = "django.db.models.BigAutoField" 6 | name = "tests.another_app" 7 | -------------------------------------------------------------------------------- /tests/another_app/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tetra-framework/tetra/3f9faeaf5af277b27fd420f02d972856e39287e0/tests/another_app/migrations/__init__.py -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from pathlib import Path 3 | 4 | from django.apps import apps 5 | from django.conf import settings 6 | from django.contrib.sessions.backends.cache import SessionStore 7 | from django.core.management import call_command 8 | from django.test import RequestFactory 9 | from selenium import webdriver 10 | from selenium.webdriver.chrome.options import Options 11 | 12 | from tetra.middleware import TetraDetails 13 | 14 | BASE_DIR = Path(__file__).resolve().parent 15 | 16 | 17 | @pytest.fixture(scope="session", autouse=True) 18 | def setup_django_environment(): 19 | # Call your `tetrabuild` command before running tests - to make sure the Js 20 | # scripts and CSS files are built. 21 | call_command("tetrabuild") 22 | 23 | 24 | @pytest.fixture 25 | def tetra_request(): 26 | factory = RequestFactory() 27 | req = factory.get("/") 28 | req.tetra = TetraDetails(req) 29 | return req 30 | 31 | 32 | @pytest.fixture 33 | def request_with_session(): 34 | """Fixture to provide an Http GET Request with a session.""" 35 | from django.contrib.auth.models import AnonymousUser 36 | 37 | factory = RequestFactory() 38 | req = factory.get("/") # Create a request object 39 | 40 | req.session = SessionStore() 41 | req.session.create() 42 | req.user = AnonymousUser() 43 | req.tetra = TetraDetails(req) 44 | 45 | return req 46 | 47 | 48 | @pytest.fixture 49 | def post_request_with_session(): 50 | """Fixture to provide a Http POST Request with a session.""" 51 | from django.contrib.auth.models import AnonymousUser 52 | 53 | factory = RequestFactory() 54 | req = factory.post("/") # Create a request object 55 | 56 | req.session = SessionStore() 57 | req.session.create() 58 | req.user = AnonymousUser() 59 | 60 | return req 61 | 62 | 63 | @pytest.fixture 64 | def current_app(): 65 | return apps.get_app_config("main") 66 | 67 | 68 | @pytest.fixture(scope="module") 69 | def driver(): 70 | options = Options() 71 | options.add_argument("--headless") 72 | # options.add_argument("--no-sandbox") 73 | # options.add_argument("--disable-dev-shm-usage") 74 | driver = webdriver.Chrome( 75 | options=options, 76 | ) 77 | yield driver 78 | driver.quit() 79 | 80 | 81 | def pytest_configure(): 82 | settings.configure( 83 | BASE_DIR=BASE_DIR, 84 | SECRET_KEY="django-insecure1234567890", 85 | ROOT_URLCONF="tests.urls", 86 | INSTALLED_APPS=[ 87 | "tetra", 88 | "django.contrib.auth", 89 | "django.contrib.contenttypes", 90 | "django.contrib.staticfiles", 91 | "django.contrib.sessions", 92 | "tests.main", 93 | "tests.another_app", 94 | ], 95 | MIDDLEWARE=[ 96 | "django.middleware.security.SecurityMiddleware", 97 | "whitenoise.middleware.WhiteNoiseMiddleware", 98 | "django.contrib.sessions.middleware.SessionMiddleware", 99 | "django.middleware.common.CommonMiddleware", 100 | "django.middleware.csrf.CsrfViewMiddleware", 101 | "django.contrib.auth.middleware.AuthenticationMiddleware", 102 | "django.contrib.messages.middleware.MessageMiddleware", 103 | "django.middleware.clickjacking.XFrameOptionsMiddleware", 104 | "tetra.middleware.TetraMiddleware", 105 | ], 106 | DATABASES={ 107 | "default": {"ENGINE": "django.db.backends.sqlite3", "NAME": ":memory:"} 108 | }, 109 | TEMPLATES=[ 110 | { 111 | "BACKEND": "django.template.backends.django.DjangoTemplates", 112 | # "DIRS": [BASE_DIR / "templates"], 113 | "APP_DIRS": True, 114 | }, 115 | ], 116 | STATIC_URL="/static/", 117 | STATIC_ROOT=BASE_DIR / "staticfiles", 118 | DEBUG=True, 119 | STORAGES={ 120 | "staticfiles": { 121 | "BACKEND": "whitenoise.storage.CompressedStaticFilesStorage", 122 | }, 123 | }, 124 | ) 125 | -------------------------------------------------------------------------------- /tests/main/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tetra-framework/tetra/3f9faeaf5af277b27fd420f02d972856e39287e0/tests/main/__init__.py -------------------------------------------------------------------------------- /tests/main/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class MainConfig(AppConfig): 5 | default_auto_field = "django.db.models.BigAutoField" 6 | name = "tests.main" 7 | -------------------------------------------------------------------------------- /tests/main/components/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tetra-framework/tetra/3f9faeaf5af277b27fd420f02d972856e39287e0/tests/main/components/__init__.py -------------------------------------------------------------------------------- /tests/main/components/default.py: -------------------------------------------------------------------------------- 1 | from tetra import BasicComponent, public, Component 2 | from sourcetypes import django_html, css 3 | 4 | 5 | class SimpleBasicComponent(BasicComponent): 6 | template: django_html = "
foo
" 7 | 8 | 9 | class SimpleBasicComponentWithCSS(BasicComponent): 10 | template: django_html = "
bar
" 11 | style: css = ".text-red { color: red; }" 12 | 13 | 14 | class SimpleComponentWithDefaultBlock(BasicComponent): 15 | template: django_html = ( 16 | "
{% block default %}{% endblock %}
" 17 | ) 18 | 19 | 20 | class SimpleComponentWithNamedBlock(BasicComponent): 21 | template: django_html = "
{% block foo %}{% endblock %}
" 22 | 23 | 24 | class SimpleComponentWithNamedBlockWithContent(BasicComponent): 25 | template: django_html = "
{% block foo %}foo{% endblock %}
" 26 | 27 | 28 | class SimpleComponentWithConditionalBlock(BasicComponent): 29 | template: django_html = ( 30 | """
{% if blocks.foo %}BEFORE{% block foo %}content{% endblock %}AFTER{% endif %}always
""" 31 | ) 32 | 33 | 34 | class SimpleComponentWithConditionalBlockAndAdditionalContent(BasicComponent): 35 | template: django_html = ( 36 | """
BE{% if blocks.foo %}FORE{% block foo %}{% endblock %}AF{% endif %}TER
""" 37 | ) 38 | 39 | 40 | class SimpleComponentWithConditionalBlockAndAdditionalHtmlContent(BasicComponent): 41 | template: django_html = """ 42 |
43 | {% if blocks.foo %} 44 | {% block foo %}{% endblock %} 45 | {% endif %} 46 |
47 | """ 48 | 49 | 50 | class SimpleComponentWith2Blocks(BasicComponent): 51 | template: django_html = """ 52 |
{% block default %}default{% endblock %}{% block foo %}foo{% endblock %}
53 | """ 54 | 55 | 56 | class SimpleComponentWithAttrs(BasicComponent): 57 | template: django_html = """ 58 |
content
59 | """ 60 | 61 | 62 | class SimpleComponentWithFooContext(BasicComponent): 63 | """Simple component that adds "foo" context""" 64 | 65 | _extra_context = ["foo"] 66 | template: django_html = """ 67 |
{% block default %}{% endblock %}
68 | """ 69 | 70 | 71 | class SimpleComponentWithExtraContextAll(BasicComponent): 72 | """Simple component that adds __all__ global context""" 73 | 74 | _extra_context = ["__all__"] 75 | template: django_html = """ 76 |
{% block default %}{% endblock %}
77 | """ 78 | 79 | 80 | # -------------------------------------------------- 81 | 82 | 83 | class SimpleComponentWithAttributeInt(BasicComponent): 84 | my_int: int = 23 85 | template: django_html = "
int: {{ my_int }}
" 86 | 87 | 88 | class SimpleComponentWithAttributeFloat(BasicComponent): 89 | my_float: float = 2.32 90 | template: django_html = "
float: {{ my_float }}
" 91 | 92 | 93 | class SimpleComponentWithAttributeList(BasicComponent): 94 | my_list: list = [1, 2, 3] 95 | template: django_html = "
list: {{ my_list }}
" 96 | 97 | 98 | class SimpleComponentWithAttributeDict(BasicComponent): 99 | my_dict: dict = {"key": "value"} 100 | template: django_html = "
dict: {{ my_dict }}
" 101 | 102 | 103 | class SimpleComponentWithAttributeSet(BasicComponent): 104 | my_set: set = {1, 2, 3} 105 | template: django_html = "
set: {{ my_set }}
" 106 | 107 | 108 | class SimpleComponentWithAttributeFrozenSet(BasicComponent): 109 | my_set: frozenset = frozenset({1, 2, 3}) 110 | template: django_html = "
frozenset: {{ my_set }}
" 111 | 112 | 113 | class SimpleComponentWithAttributeBool(BasicComponent): 114 | my_bool: bool = False 115 | template: django_html = "
bool: {{ my_bool }}
" 116 | 117 | 118 | class ComponentWithPublic(Component): 119 | msg = public("Message") 120 | 121 | @public 122 | def do_something(self) -> str: 123 | pass 124 | 125 | template: django_html = "
{{ message }}
" 126 | -------------------------------------------------------------------------------- /tests/main/components/faulty.py: -------------------------------------------------------------------------------- 1 | from tetra import Component 2 | 3 | # This library contains some Components that are faulty. 4 | # We cannot add faulty Python code at module level, as this would affect all other 5 | # components' tests. So we place some syntax/import/etc. errors into the components' 6 | # __init__ method so the errors occur when the components itself are imported. 7 | 8 | 9 | class FaultyComponent1(Component): 10 | template = "
" 11 | 12 | def __init__(self, _request, *args, **kwargs): # noqa 13 | import foo_bar_not_existing_module 14 | 15 | def __init__(self, *args, **kwargs): # noqa 16 | import foo_bar_not_existing_module # noqa 17 | 18 | 19 | class FaultyComponent2(Component): 20 | template = "
" 21 | 22 | def __init__(self, _request, *args, **kwargs): # noqa 23 | # This must raise a NameError 24 | foo # noqa 25 | 26 | 27 | class FaultyComponent3(Component): 28 | # this component has no html tag as root element in the template 29 | template = "foo" 30 | -------------------------------------------------------------------------------- /tests/main/components/forms.py: -------------------------------------------------------------------------------- 1 | from django.forms import Form 2 | from django import forms 3 | 4 | from tetra.components import FormComponent 5 | 6 | 7 | class PersonForm(Form): 8 | first_name = forms.CharField(max_length=100, initial="John") 9 | 10 | 11 | class SimpleFormComponent(FormComponent): 12 | form_class = PersonForm 13 | template = """
{{ form.first_name }}
""" 14 | -------------------------------------------------------------------------------- /tests/main/components/other/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tetra-framework/tetra/3f9faeaf5af277b27fd420f02d972856e39287e0/tests/main/components/other/__init__.py -------------------------------------------------------------------------------- /tests/main/components/other/dir_component/__init__.py: -------------------------------------------------------------------------------- 1 | from tetra import Component 2 | 3 | 4 | class DirComponent(Component): 5 | pass 6 | -------------------------------------------------------------------------------- /tests/main/components/other/dir_component/dir_component.html: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tetra-framework/tetra/3f9faeaf5af277b27fd420f02d972856e39287e0/tests/main/components/other/dir_component/dir_component.html -------------------------------------------------------------------------------- /tests/main/helpers.py: -------------------------------------------------------------------------------- 1 | from django.http import HttpRequest 2 | from django.template import Template, RequestContext 3 | 4 | 5 | def render_component_tag( 6 | request_with_session: HttpRequest, component_string, context=None 7 | ): 8 | """Helper function to return a full html document with loaded Tetra stuff, 9 | and the component_string as body content. 10 | 11 | Attributes: 12 | request: The request object. 13 | component_string: The string to be rendered - usually something like 14 | '{% @ my_component / %}'. 15 | context: The context the template is rendered with. This is the outer context 16 | of the component 17 | """ 18 | ctx = RequestContext(request_with_session) 19 | if context: 20 | ctx.update(context) 21 | ctx.request = request_with_session 22 | return Template( 23 | "{% load tetra %}" 24 | "" 25 | "{% tetra_styles %}" 26 | "{% tetra_scripts include_alpine=True %}" 27 | "" 28 | f"{component_string}" 29 | "" 30 | ).render(ctx) 31 | -------------------------------------------------------------------------------- /tests/main/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tetra-framework/tetra/3f9faeaf5af277b27fd420f02d972856e39287e0/tests/main/migrations/__init__.py -------------------------------------------------------------------------------- /tests/main/models.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | 3 | 4 | class SimpleModel(models.Model): 5 | """Simple model for testing purposes.""" 6 | 7 | name = models.CharField(max_length=100) 8 | created_at = models.DateTimeField(auto_now_add=True) 9 | 10 | def __repr__(self): 11 | return f"" 12 | 13 | 14 | class AwareDateTimeModel(models.Model): 15 | name = models.CharField(max_length=100) 16 | created_at = models.DateTimeField(auto_now_add=True) 17 | 18 | def __repr__(self): 19 | return f"" 20 | -------------------------------------------------------------------------------- /tests/main/static/.gitignore: -------------------------------------------------------------------------------- 1 | # dynamically generated files of Tetra tests 2 | main/tetra/default -------------------------------------------------------------------------------- /tests/main/templates/base.html: -------------------------------------------------------------------------------- 1 | {% load tetra %} 2 | 3 | 4 | 5 | {# Even in a test environment, we need the full tetra stuff for components to work properly.#} 6 | {% tetra_styles %} 7 | {% tetra_scripts include_alpine=True %} 8 | Tetra test project 9 | 10 | {% block content %}{% endblock %} 11 | -------------------------------------------------------------------------------- /tests/main/templates/basic_component.html: -------------------------------------------------------------------------------- 1 | {% extends 'base.html' %} 2 | {% load tetra %} 3 | {% block content %} 4 | {% @ main.default.SimpleBasicComponent / %} 5 | {% endblock %} 6 | -------------------------------------------------------------------------------- /tests/main/urls.py: -------------------------------------------------------------------------------- 1 | from django.urls import path 2 | 3 | from main import views 4 | 5 | urlpatterns = [ 6 | path( 7 | "component_with_return_value/", 8 | views.component_with_return_value, 9 | name="component_with_return_value", 10 | ), 11 | path( 12 | "download_component/", 13 | views.download_component, 14 | name="download_component", 15 | ), 16 | ] 17 | -------------------------------------------------------------------------------- /tests/main/views.py: -------------------------------------------------------------------------------- 1 | from django.http import HttpResponse 2 | 3 | from tests.main.helpers import render_component_tag 4 | 5 | 6 | def simple_basic_component_with_css(request): 7 | return HttpResponse( 8 | render_component_tag( 9 | request, "{% @ main.default.SimpleBasicComponentWithCss / %}" 10 | ) 11 | ) 12 | 13 | 14 | def component_with_return_value(request): 15 | return HttpResponse( 16 | render_component_tag( 17 | request, "{% @ main.default.ComponentWithMethodReturnValue / %}" 18 | ) 19 | ) 20 | 21 | 22 | def download_component(request): 23 | return HttpResponse( 24 | render_component_tag(request, "{% @ main.ui.DownloadComponent / %}") 25 | ) 26 | -------------------------------------------------------------------------------- /tests/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "tetra-tests", 3 | "private": true, 4 | "version": "0.0.7", 5 | "description": "", 6 | "main": "index.js", 7 | "scripts": { 8 | "test": "echo \"Error: no test specified\" && exit 1" 9 | }, 10 | "dependencies": { 11 | "esbuild": "~=0.25" 12 | }, 13 | "author": "Christian González ", 14 | "license": "MIT" 15 | } 16 | -------------------------------------------------------------------------------- /tests/test_component_attributes.py: -------------------------------------------------------------------------------- 1 | from sourcetypes import django_html 2 | 3 | from tetra import Library, BasicComponent 4 | from tests.main.helpers import render_component_tag 5 | from utils import extract_component_tag 6 | 7 | attrs = Library("attrs", "main") 8 | 9 | 10 | @attrs.register 11 | class SimpleComponentWithAttributeStr(BasicComponent): 12 | my_str: str = "foo" 13 | template: django_html = "
str: {{ my_str }}
" 14 | 15 | 16 | def test_simple_component_attribute_str(request_with_session): 17 | """Tests a simple component with a str attribute""" 18 | content = render_component_tag( 19 | request_with_session, 20 | "{% @ main.attrs.SimpleComponentWithAttributeStr / %}", 21 | ) 22 | soup = extract_component_tag(content) 23 | assert soup.text == "str: foo" 24 | component = Library.registry["main"]["attrs"].components.get( 25 | "simple_component_with_attribute_str" 26 | ) 27 | assert component.my_str == "foo" 28 | 29 | 30 | def test_simple_component_attribute_int(request_with_session): 31 | """Tests a simple component with an int attribute""" 32 | 33 | content = render_component_tag( 34 | request_with_session, "{% @ main.default.SimpleComponentWithAttributeInt / %}" 35 | ) 36 | soup = extract_component_tag(content) 37 | 38 | assert soup.text == "int: 23" 39 | # get handler for the component class 40 | component = Library.registry["main"]["default"].components.get( 41 | "simple_component_with_attribute_int" 42 | ) 43 | assert component.my_int == 23 44 | 45 | 46 | def test_simple_component_attribute_float(request_with_session): 47 | """Tests a simple component with a float attribute""" 48 | 49 | content = render_component_tag( 50 | request_with_session, "{% @ main.default.SimpleComponentWithAttributeFloat / %}" 51 | ) 52 | assert extract_component_tag(content).text == "float: 2.32" 53 | 54 | 55 | def test_simple_component_attribute_list(request_with_session): 56 | """Tests a simple component with a list attribute""" 57 | 58 | content = render_component_tag( 59 | request_with_session, "{% @ main.default.SimpleComponentWithAttributeList / %}" 60 | ) 61 | soup = extract_component_tag(content) 62 | assert soup.text == "list: [1, 2, 3]" 63 | component = Library.registry["main"]["default"].components.get( 64 | "simple_component_with_attribute_list" 65 | ) 66 | assert component.my_list == [1, 2, 3] 67 | 68 | 69 | def test_simple_component_attribute_dict(request_with_session): 70 | """Tests a simple component with a dict attribute""" 71 | 72 | content = render_component_tag( 73 | request_with_session, "{% @ main.default.SimpleComponentWithAttributeDict / %}" 74 | ) 75 | soup = extract_component_tag(content) 76 | assert soup.text == "dict: {'key': 'value'}" 77 | component = Library.registry["main"]["default"].components.get( 78 | "simple_component_with_attribute_dict" 79 | ) 80 | assert component.my_dict == {"key": "value"} 81 | 82 | 83 | def test_simple_component_attribute_set(request_with_session): 84 | """Tests a simple component with a set attribute""" 85 | 86 | content = render_component_tag( 87 | request_with_session, "{% @ main.default.SimpleComponentWithAttributeSet / %}" 88 | ) 89 | soup = extract_component_tag(content) 90 | assert soup.text == "set: {1, 2, 3}" 91 | component = Library.registry["main"]["default"].components.get( 92 | "simple_component_with_attribute_set" 93 | ) 94 | assert component.my_set == {1, 2, 3} 95 | 96 | 97 | def test_simple_component_attribute_frozenset(request_with_session): 98 | """Tests a simple component with a frozenset attribute""" 99 | 100 | content = render_component_tag( 101 | request_with_session, 102 | "{% @ main.default.SimpleComponentWithAttributeFrozenSet / %}", 103 | ) 104 | soup = extract_component_tag(content) 105 | assert soup.text == "frozenset: frozenset({1, 2, 3})" 106 | 107 | 108 | def test_simple_component_attribute_bool(request_with_session): 109 | """Tests a simple component with a bool attribute""" 110 | 111 | content = render_component_tag( 112 | request_with_session, 113 | "{% @ main.default.SimpleComponentWithAttributeBool / %}", 114 | ) 115 | soup = extract_component_tag(content) 116 | assert soup.text == "bool: False" 117 | component = Library.registry["main"]["default"].components.get( 118 | "simple_component_with_attribute_bool" 119 | ) 120 | assert component.my_bool is False 121 | -------------------------------------------------------------------------------- /tests/test_component_attrs.py: -------------------------------------------------------------------------------- 1 | from utils import extract_component_tag 2 | from tests.main.helpers import render_component_tag 3 | 4 | 5 | def test_attrs(tetra_request): 6 | """Tests a simple component with / end""" 7 | content = render_component_tag( 8 | tetra_request, "{% @ main.default.SimpleComponentWithAttrs / %}" 9 | ) 10 | soup = extract_component_tag(content) 11 | assert soup.text == "content" 12 | assert "class1" in soup.attrs["class"] 13 | 14 | 15 | def test_attrs_merge(tetra_request): 16 | """Tests a simple component with / end""" 17 | content = render_component_tag( 18 | tetra_request, 19 | "{% @ main.default.SimpleComponentWithAttrs attrs: class='class2' / %}", 20 | ) 21 | soup = extract_component_tag(content) 22 | assert set(soup.attrs["class"]) == {"class1", "class2"} 23 | -------------------------------------------------------------------------------- /tests/test_component_context.py: -------------------------------------------------------------------------------- 1 | from tests.utils import extract_component_tag 2 | from tests.main.helpers import render_component_tag 3 | 4 | 5 | def test_use_extra_context_not_scoped(tetra_request): 6 | """Component may not display outer context vars, if not explicitly included.""" 7 | content = render_component_tag( 8 | tetra_request, 9 | component_string="{% @ main.default.SimpleComponentWithDefaultBlock %}" 10 | "{{foo}}" 11 | "{% /@ %}", 12 | context={"foo": "bar"}, # global, outer context 13 | ) 14 | assert extract_component_tag(content).text == "" 15 | 16 | 17 | def test_use_extra_context(tetra_request): 18 | """Component must display outer context vars, if explicitly included in 19 | _extra_context.""" 20 | content = render_component_tag( 21 | tetra_request, 22 | component_string="{% @ main.default.SimpleComponentWithFooContext %}" 23 | "{{foo}}" 24 | "{% /@ %}", 25 | context={"foo": "bar"}, # global, outer context 26 | ) 27 | assert extract_component_tag(content).text == "bar" 28 | 29 | 30 | def test_use_extra_context_empty(tetra_request): 31 | """Component must not display outer context vars, if explicitly included in 32 | _extra_context, but var==empty.""" 33 | content = render_component_tag( 34 | tetra_request, 35 | component_string="{% @ main.default.SimpleComponentWithFooContext %}" 36 | "{{foo}}" 37 | "{% /@ %}", # FIXME:KeyError(key) 38 | # context={"foo": "bar"}, # global, outer context 39 | ) 40 | assert extract_component_tag(content).text == "" 41 | 42 | 43 | def test_use_extra_context_all_empty(tetra_request): 44 | """Component must not display outer context vars, if _extra_context == __all__, 45 | but var==empty.""" 46 | content = render_component_tag( 47 | tetra_request, 48 | component_string="{% @ main.default.SimpleComponentWithExtraContextAll %}" 49 | "{{foo}}" 50 | "{% /@ %}", 51 | # context={"foo": "bar"}, # global, outer context 52 | ) 53 | assert extract_component_tag(content).text == "" 54 | 55 | 56 | def test_use_extra_context_all(tetra_request): 57 | """Component must display outer context vars, if __all__ in _extra_context.""" 58 | content = render_component_tag( 59 | tetra_request, 60 | component_string="{% @ main.default.SimpleComponentWithExtraContextAll %}" 61 | "{{foo}}" 62 | "{% /@ %}", 63 | context={"foo": "bar"}, # global, outer context 64 | ) 65 | assert extract_component_tag(content).text == "bar" 66 | 67 | 68 | # -------- using template attrs -------- 69 | 70 | 71 | def test_use_context_attr(tetra_request): 72 | """context must be available when ctx var explicitly given on template calling""" 73 | content = render_component_tag( 74 | tetra_request, 75 | component_string="{% @ main.default.SimpleComponentWithDefaultBlock " 76 | "context: foo='bar' %}" 77 | "{{foo}}" 78 | "{% /@ %}", 79 | ) 80 | assert extract_component_tag(content).text == "bar" 81 | 82 | 83 | def test_use_context_attr_all(tetra_request): 84 | """context must be available when ctx == all on template calling""" 85 | content = render_component_tag( 86 | tetra_request, 87 | component_string="{% @ main.default.SimpleComponentWithDefaultBlock " 88 | "context: __all__ %}" 89 | "{{foo}}" 90 | "{% /@ %}", 91 | context={"foo": "bar"}, # global, outer context 92 | ) 93 | assert extract_component_tag(content).text == "bar" 94 | 95 | 96 | def test_extra_context(tetra_request): 97 | """_extra_context must be available automatically.""" 98 | content = render_component_tag( 99 | tetra_request, 100 | component_string="{% @ main.default.SimpleComponentWithExtraContextAll %}" 101 | "{{foo}}" 102 | "{% /@ %}", 103 | context={"foo": "bar"}, # global, outer context, included in __all__ 104 | ) 105 | assert extract_component_tag(content).text == "bar" 106 | 107 | 108 | def test_context_attr_overrides_extra_context(tetra_request): 109 | """context given at the template tag must override the outer context.""" 110 | content = render_component_tag( 111 | tetra_request, 112 | component_string="{% @ main.default.SimpleComponentWithExtraContextAll " 113 | "context: foo='nobaz' %}" 114 | "{{foo}}" 115 | "{% /@ %}", 116 | ) 117 | assert extract_component_tag(content).text == "nobaz" 118 | -------------------------------------------------------------------------------- /tests/test_component_imports.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from tests.main.helpers import render_component_tag 4 | from tetra.exceptions import ComponentError, ComponentNotFound 5 | 6 | 7 | def test_error_when_using_missing_component(tetra_request): 8 | """If a component itself is not found, a ComponentNotFound exception must be 9 | raised.""" 10 | with pytest.raises(ComponentNotFound): 11 | render_component_tag( 12 | tetra_request, "{% @ main.faulty.NotExistingComponent / %}" 13 | ) 14 | 15 | 16 | def test_component_importing_missing_module(tetra_request): 17 | """if the imported component itself imports a non-existing (e.g. not installed) 18 | python module, a ModuleNotFoundError must be raised.""" 19 | with pytest.raises(ModuleNotFoundError) as exc_info: 20 | render_component_tag(tetra_request, "{% @ main.faulty.FaultyComponent1 / %}") 21 | 22 | assert exc_info.value.msg == "No module named 'foo_bar_not_existing_module'" 23 | 24 | 25 | def test_component_with_name_error(tetra_request): 26 | """If a component calls not-existing code, this must be raised transparently.""" 27 | with pytest.raises(NameError): 28 | render_component_tag(tetra_request, "{% @ main.faulty.FaultyComponent2 / %}") 29 | 30 | 31 | def test_component_with_no_root_tag(tetra_request): 32 | """If a component calls not-existing code, this must be raised transparently.""" 33 | with pytest.raises(ComponentError): 34 | render_component_tag(tetra_request, "{% @ main.faulty.faulty_component3 / %}") 35 | -------------------------------------------------------------------------------- /tests/test_component_registration.py: -------------------------------------------------------------------------------- 1 | from tetra import Library, BasicComponent 2 | 3 | 4 | # TestComponent will be registered manually 5 | class Component1(BasicComponent): 6 | template = """
""" 7 | 8 | 9 | lib2 = Library("lib2", app="main") 10 | 11 | 12 | @lib2.register 13 | class Component2(BasicComponent): 14 | template = """
""" 15 | 16 | 17 | def test_register_component_manually(current_app): 18 | """create a lib and register a component manually and make sure it exists in the library""" 19 | lib1 = Library("lib1", current_app) 20 | lib1.register(Component1) 21 | assert lib1.components["component1"] is Component1 22 | 23 | 24 | def test_register_decorator(current_app): 25 | """get a registered library with a component registered there using the decorator 26 | syntax and make sure it exists in the library""" 27 | lib2 = Library("lib2", current_app) 28 | assert lib2.components["component2"] is Component2 29 | -------------------------------------------------------------------------------- /tests/test_component_tags.py: -------------------------------------------------------------------------------- 1 | import re 2 | 3 | from bs4 import BeautifulSoup 4 | from django.urls import reverse 5 | from django.template.exceptions import TemplateSyntaxError 6 | 7 | from main.components.default import SimpleBasicComponent 8 | from tests.utils import extract_component_tag 9 | from tests.main.helpers import render_component_tag 10 | import pytest 11 | 12 | from tetra.exceptions import ComponentNotFound 13 | 14 | 15 | def test_basic_component(tetra_request): 16 | """Tests a simple component with / end""" 17 | content = render_component_tag( 18 | tetra_request, "{% @ main.default.SimpleBasicComponent / %}" 19 | ) 20 | assert extract_component_tag(content).text == "foo" 21 | 22 | 23 | def test_basic_component_as_default(tetra_request): 24 | """Tests a simple component that implicitly is found in the default library""" 25 | content = render_component_tag(tetra_request, "{% @ main.SimpleBasicComponent / %}") 26 | assert extract_component_tag(content).text == "foo" 27 | 28 | 29 | def test_basic_component_with_library(tetra_request): 30 | with pytest.raises(ComponentNotFound) as exc_info: 31 | """Tests a simple component that is can't be found in the current_app.default 32 | library.""" 33 | content = render_component_tag( 34 | tetra_request, "{% @ default.SimpleBasicComponent / %}" 35 | ) 36 | assert ( 37 | "but there is no component 'SimpleBasicComponent' in the 'default' library of " 38 | "the 'default' app" 39 | ) in str(exc_info.value) 40 | 41 | 42 | def test_basic_component_with_app_and_library(tetra_request): 43 | with pytest.raises(ComponentNotFound) as exc_info: 44 | content = render_component_tag(tetra_request, "{% @ SimpleBasicComponent / %}") 45 | assert ( 46 | "Unable to ascertain current app. Component name 'SimpleBasicComponent' must be in " 47 | ) in str(exc_info.value) 48 | 49 | 50 | def test_basic_component_with_end_tag(tetra_request): 51 | """Tests a simple component with /@ end tag""" 52 | content = render_component_tag( 53 | tetra_request, "{% @ main.default.SimpleBasicComponent %}{% /@ %}" 54 | ) 55 | assert extract_component_tag(content).text == "foo" 56 | 57 | 58 | def test_basic_component_with_end_tag_and_name(tetra_request): 59 | """Tests a simple component with `/@ ` end tag""" 60 | content = render_component_tag( 61 | tetra_request, 62 | "{% @ main.default.SimpleBasicComponent %}{% /@ SimpleBasicComponent %}", 63 | ) 64 | assert extract_component_tag(content).text == "foo" 65 | 66 | 67 | def test_basic_component_with_missing_end_tag(tetra_request): 68 | """Tests a simple component without end tag - must produce TemplateSyntaxError""" 69 | with pytest.raises(TemplateSyntaxError): 70 | render_component_tag( 71 | tetra_request, 72 | "{% @ main.default.SimpleBasicComponent %}", 73 | ) 74 | 75 | 76 | def test_component_css_link_generation(client): 77 | """Tests a component with CSS file""" 78 | response = client.get(reverse("simple_basic_component_with_css")) 79 | assert response.status_code == 200 80 | soup = BeautifulSoup(response.content, "html.parser") 81 | # it should be the only link in the header... TODO: make that more fool-proof 82 | link = soup.head.link["href"] 83 | assert link is not None 84 | assert re.match(r"/static/main/tetra/default/main_default-[A-Z0-9]+.css", link) 85 | # TODO we can't test the actual file content of the CSS file here, as static files 86 | # seem not to be available in testing - have to figure out how 87 | # response = client.get(static(link)) 88 | # assert response.status_code == 200 89 | # assert b".text-red { color: red; }" in response.content 90 | 91 | 92 | # ---------- Dynamic components ------------ 93 | 94 | 95 | def test_basic_dynamic_component(tetra_request): 96 | """Tests a simple dynamic component""" 97 | content = render_component_tag( 98 | tetra_request, 99 | "{% @ =dynamic_component /%}", 100 | {"dynamic_component": SimpleBasicComponent}, 101 | ) 102 | assert extract_component_tag(content).text == "foo" 103 | 104 | 105 | def test_basic_dynamic_non_existing_component(tetra_request): 106 | """Tests a simple non-existing component - must produce ComponentNotFound""" 107 | with pytest.raises(ComponentNotFound): 108 | render_component_tag( 109 | tetra_request, 110 | "{% @ =foo.bar.NotExistingComponent /%}", 111 | ) 112 | -------------------------------------------------------------------------------- /tests/test_form_component.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from django import forms 3 | from django.forms import Form 4 | 5 | from tetra import Library 6 | from tetra.components import FormComponent 7 | 8 | lib = Library("forms", "main") 9 | 10 | 11 | class SimpleTestForm1(Form): 12 | # a form with many different field types 13 | name = forms.CharField(max_length=100) 14 | email = forms.EmailField() 15 | address = forms.CharField() 16 | accept_terms = forms.BooleanField(required=True) 17 | count = forms.IntegerField() 18 | size = forms.FloatField() 19 | 20 | 21 | def test_form_component_registration(): 22 | """test FormComponent initialization and attribute assignment""" 23 | 24 | @lib.register 25 | class Foo(FormComponent): 26 | form_class = SimpleTestForm1 27 | template = """
""" 28 | 29 | 30 | # # TODO 31 | # def test_recalculate_attrs_clears_errors(): 32 | # @lib.register 33 | # class Foo(FormComponent): 34 | # form_class = SimpleTestForm1 35 | # template = """
""" 36 | # 37 | # c = lib.components["foo"] 38 | # c.form_submitted = False 39 | # 40 | # # Call the method 41 | # c.recalculate_attrs(component_method_finished=True) 42 | # 43 | # # Assert that errors were cleared 44 | -------------------------------------------------------------------------------- /tests/test_library.py: -------------------------------------------------------------------------------- 1 | from django.apps import apps 2 | 3 | from tetra import Library 4 | 5 | 6 | def test_create_library(current_app): 7 | """simply create a library and make sure it exists""" 8 | lib = Library("lib1", app=current_app) 9 | assert lib is not None 10 | 11 | 12 | def test_create_library_with_str_app(current_app): 13 | """create a lib and register a component manually and make sure it exists in the library""" 14 | lib2 = Library("lib2", current_app.label) 15 | assert lib2 is not None 16 | 17 | 18 | def test_create_library_twice(current_app): 19 | """creates a library twice and makes sure they are the same instance""" 20 | lib = Library("default", app=current_app) 21 | samelib = Library("default", app=current_app) 22 | assert lib is samelib 23 | 24 | # create library with the same name but different app - must fail 25 | otherlibname = Library("other", app=current_app) 26 | assert lib is not otherlibname 27 | 28 | otherlibapp = Library("other", app=apps.get_app_config("another_app")) 29 | assert lib is not otherlibapp 30 | -------------------------------------------------------------------------------- /tests/test_middleware.py: -------------------------------------------------------------------------------- 1 | from django.http import HttpRequest, QueryDict 2 | from django.test import RequestFactory 3 | from tetra.middleware import TetraDetails 4 | 5 | import pytest 6 | 7 | 8 | @pytest.fixture 9 | def request_factory(): 10 | return RequestFactory() 11 | 12 | 13 | def test_tetra_details_bool_false(request_factory): 14 | """Should return False when 'T-Request' header is not present""" 15 | request = request_factory.get("/") 16 | tetra_details = TetraDetails(request) 17 | assert bool(tetra_details) is False 18 | 19 | 20 | def test_tetra_details_bool_true(request_factory): 21 | """Should return True when 'T-Request' header is present and set to 'true'""" 22 | request = request_factory.get("/", HTTP_T_REQUEST="true") 23 | tetra_details = TetraDetails(request) 24 | assert bool(tetra_details) is True 25 | 26 | 27 | def test_tetra_details_current_url(request_factory): 28 | """Should return the correct current_url when 'T-Current-URL' header is present""" 29 | request = request_factory.get("/", HTTP_T_CURRENT_URL="https://testserver/test") 30 | tetra_details = TetraDetails(request) 31 | assert tetra_details.current_url == "https://testserver/test" 32 | 33 | 34 | def test_tetra_details_current_url_full_path_none_when_schemes_dont_match( 35 | request_factory, 36 | ): 37 | """Should return None for current_url_full_path when schemes don't match""" 38 | request = request_factory.get( 39 | "/foo/bar/", HTTP_T_CURRENT_URL="https://testserver/test" 40 | ) 41 | tetra_details = TetraDetails(request) 42 | assert tetra_details.current_url_full_path is None 43 | 44 | 45 | def test_tetra_details_current_url_full_path_none_when_hosts_dont_match( 46 | request_factory, 47 | ): 48 | """Should return None for current_url_full_path when hosts don't match""" 49 | request = request_factory.get( 50 | "/foor/bar/", HTTP_T_CURRENT_URL="http://different-host.com/test" 51 | ) 52 | tetra_details = TetraDetails(request) 53 | assert tetra_details.current_url_full_path is None 54 | 55 | 56 | def test_tetra_details_current_url_full_path_when_schemes_and_hosts_match( 57 | request_factory, 58 | ): 59 | """Should return the correct absolute path when schemes and hosts match""" 60 | request = request_factory.get("/", HTTP_T_CURRENT_URL="http://testserver/test/path") 61 | tetra_details = TetraDetails(request) 62 | assert tetra_details.current_url_full_path == "/test/path" 63 | 64 | 65 | def test_tetra_details_current_url_query_empty(request_factory): 66 | """Should return an empty QueryDict when current_url has no query parameters""" 67 | request = request_factory.get( 68 | "/foo/bar/", HTTP_T_CURRENT_URL="http://testserver/test" 69 | ) 70 | tetra_details = TetraDetails(request) 71 | assert tetra_details.url_query_params == QueryDict() 72 | 73 | 74 | def test_tetra_details_component_call_url_query_with_parameters(request_factory): 75 | """Should return a correct QueryDict when current_url has query parameters""" 76 | request = request_factory.get( 77 | "/foo/bar/?foo=bar&baz=qux", HTTP_T_CURRENT_URL="http://testserver/test/" 78 | ) 79 | tetra_details = TetraDetails(request) 80 | assert tetra_details.url_query_params == QueryDict() 81 | 82 | 83 | def test_tetra_details_current_url_query_with_parameters(request_factory): 84 | """Should return a correct QueryDict when current_url has query parameters""" 85 | request = request_factory.get( 86 | "/foo/bar/", 87 | HTTP_T_CURRENT_URL="http://testserver/test/?foo=bar&baz=qux", 88 | ) 89 | tetra_details = TetraDetails(request) 90 | assert tetra_details.url_query_params == QueryDict("foo=bar&baz=qux") 91 | 92 | 93 | # changing the url 94 | 95 | 96 | def test_set_url(request_factory): 97 | request = request_factory.get( 98 | "/foo/bar/", 99 | HTTP_T_CURRENT_URL="http://testserver/test/?foo=bar&baz=qux", 100 | ) 101 | tetra_details = TetraDetails(request) 102 | tetra_details.set_url("http://example.com/foo/baz/") 103 | assert tetra_details.current_url == "http://example.com/foo/baz/" 104 | 105 | 106 | def test_change_path(request_factory): 107 | request = request_factory.get( 108 | "/foo/bar/", 109 | HTTP_T_CURRENT_URL="http://testserver/test/?foo=bar&baz=qux", 110 | ) 111 | tetra_details = TetraDetails(request) 112 | tetra_details.set_url_path("/foo/baz/") 113 | assert tetra_details.current_url == "http://testserver/foo/baz/?foo=bar&baz=qux" 114 | 115 | 116 | def test_change_query(request_factory): 117 | request = request_factory.get( 118 | "/foo/bar/", 119 | HTTP_T_CURRENT_URL="http://testserver/test/?foo=bar&baz=qux", 120 | ) 121 | tetra_details = TetraDetails(request) 122 | tetra_details.set_url_query_param("foo", "new") 123 | assert tetra_details.current_url == "http://testserver/test/?foo=new&baz=qux" 124 | 125 | 126 | def test_add_query(request_factory): 127 | request = request_factory.get( 128 | "/foo/bar/", 129 | HTTP_T_CURRENT_URL="http://testserver/test/?foo=bar&baz=qux", 130 | ) 131 | tetra_details = TetraDetails(request) 132 | tetra_details.set_url_query_param("another", "qui") 133 | assert ( 134 | tetra_details.current_url 135 | == "http://testserver/test/?foo=bar&baz=qux&another=qui" 136 | ) 137 | -------------------------------------------------------------------------------- /tests/test_public_decorator.py: -------------------------------------------------------------------------------- 1 | from types import FunctionType, MethodType 2 | 3 | import pytest 4 | 5 | from tetra import public, Library, Component 6 | from utils import extract_component_tag 7 | from main.components.default import ComponentWithPublic 8 | from tests.main.helpers import render_component_tag 9 | 10 | 11 | def test_public_decorator_is_replaced_with_actual_method_or_attribute(request): 12 | # assert extract_component(content) == "mMessage" 13 | c = ComponentWithPublic(request) 14 | # make sure that @public decorated methods and attributes are replaced with their 15 | # actual method/attribute - they must not be "PublicMeta" 16 | assert type(c.msg) is str 17 | assert isinstance(c.do_something, MethodType) 18 | 19 | 20 | default = Library("default", "main") 21 | 22 | 23 | def test_subscribe_with_wrong_arguments(): 24 | with pytest.raises(ValueError): 25 | 26 | @default.register 27 | class ComponentWithPublicSubscribe(Component): 28 | @public.subscribe("keyup.enter") 29 | def do_something(self) -> str: # should have event_detail as param 30 | pass 31 | 32 | template = """
""" 33 | 34 | 35 | @default.register 36 | class ComponentWithPublicSubscribe(Component): 37 | 38 | @public.subscribe("keyup.enter") 39 | def do_something(self, event_detail) -> str: 40 | pass 41 | 42 | template = """
""" 43 | 44 | 45 | def test_public_subscribe_renders_attrs(request_with_session): 46 | """Checks if a @public.subscribe decorator renders the attr correctly.""" 47 | content = render_component_tag( 48 | request_with_session, "{% @ main.default.ComponentWithPublicSubscribe / %}" 49 | ) 50 | component = extract_component_tag(content) 51 | assert component.has_attr("@keyup.enter") 52 | assert component.attrs["@keyup.enter"] == "do_something($event.detail)" 53 | 54 | 55 | # ------------- watch ------------- 56 | 57 | 58 | class ComponentWatchBase(Component): 59 | template = """
""" 60 | 61 | 62 | def test_watch_parameter_count0(): 63 | 64 | with pytest.raises(ValueError) as exc_info: 65 | 66 | @default.register 67 | class ComponentWatch1(ComponentWatchBase): 68 | @public.watch() 69 | def watchmethod(self, value, old_value, attr): 70 | pass 71 | 72 | assert str(exc_info.value) == ".watch decorator requires at least one argument." 73 | 74 | 75 | def test_watch_parameter_count1(): 76 | 77 | with pytest.raises(ValueError) as exc_info: 78 | 79 | @default.register 80 | class ComponentWatch1(ComponentWatchBase): 81 | @public.watch("foo") 82 | def watchmethod(self): 83 | pass 84 | 85 | assert ( 86 | str(exc_info.value) 87 | == "The .watch method `watchmethod` must have 'value', 'old_value' and 'attr' as arguments." 88 | ) 89 | 90 | 91 | def test_watch_parameter_count2(): 92 | 93 | with pytest.raises(ValueError) as exc_info: 94 | 95 | @default.register 96 | class ComponentWatch(ComponentWatchBase): 97 | @public.watch("foo") 98 | def watchmethod(self, value): # "old_value", "attr" missing 99 | pass 100 | 101 | assert ( 102 | str(exc_info.value) 103 | == "The .watch method `watchmethod` must have 'value', 'old_value' and 'attr' as arguments." 104 | ) 105 | 106 | 107 | def test_watch_parameter_count3(): 108 | 109 | with pytest.raises(ValueError) as exc_info: 110 | 111 | @default.register 112 | class ComponentWatch(ComponentWatchBase): 113 | @public.watch("foo") 114 | def watchmethod(self, value, old_value): # "attr" missing 115 | pass 116 | 117 | assert ( 118 | str(exc_info.value) 119 | == "The .watch method `watchmethod` must have 'value', 'old_value' and 'attr' as arguments." 120 | ) 121 | 122 | 123 | def test_watch_parameter_names(): 124 | 125 | with pytest.raises(ValueError) as exc_info: 126 | 127 | @default.register 128 | class ComponentWatch(ComponentWatchBase): 129 | @public.watch("foo") 130 | def watchmethod(self, foo, bar, baz): # wrong names 131 | pass 132 | 133 | assert ( 134 | str(exc_info.value) 135 | == "The .watch method `watchmethod` must have 'value', 'old_value' and 'attr' as arguments." 136 | ) 137 | -------------------------------------------------------------------------------- /tests/ui/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tetra-framework/tetra/3f9faeaf5af277b27fd420f02d972856e39287e0/tests/ui/__init__.py -------------------------------------------------------------------------------- /tests/ui/test_component_return.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from django.urls import reverse 3 | 4 | from tetra import Component, public, Library 5 | from selenium.webdriver.common.by import By 6 | 7 | lib = Library("default", "main") 8 | 9 | 10 | @lib.register 11 | class ComponentWithMethodReturnValue(Component): 12 | msg = public("") 13 | 14 | @public 15 | def get_hello(self) -> str: 16 | # don't set the value directly, just return it 17 | return "Hello, World!" 18 | 19 | template = """
20 | 21 |
22 |
""" 23 | 24 | 25 | @pytest.mark.django_db 26 | def test_basic_component_return(post_request_with_session, driver, live_server): 27 | """Tests a simple component with / end""" 28 | driver.get(live_server.url + reverse("component_with_return_value")) 29 | button = driver.find_element(By.ID, "clickme") 30 | assert driver.find_element(By.ID, "result").text == "" 31 | button.click() 32 | assert driver.find_element(By.ID, "result").text == "Hello, World!" 33 | -------------------------------------------------------------------------------- /tests/ui/test_public_download.py: -------------------------------------------------------------------------------- 1 | import os 2 | import time 3 | import pytest 4 | from django.http import FileResponse 5 | from django.urls import reverse 6 | from selenium.webdriver.common.by import By 7 | 8 | from tetra import Library, Component, public 9 | 10 | foo = Library("ui", "main") 11 | 12 | 13 | @foo.register 14 | class DownloadComponent(Component): 15 | @public 16 | def download_default(self): 17 | return FileResponse( 18 | "Hello, World!", content_type="text/plain", filename="foo.txt" 19 | ) 20 | 21 | # language=html 22 | template = """ 23 |
24 | 26 |
27 | """ 28 | 29 | 30 | @pytest.mark.django_db 31 | # FIXME: downloaded file is not found! 32 | def test_component_download(post_request_with_session, driver, live_server, tmp_path): 33 | """Tests a simple component with download functionality""" 34 | # Configure the browser to download files to our temporary test directory 35 | download_dir = str(tmp_path) 36 | driver.command_executor._commands["send_command"] = ( 37 | "POST", 38 | "/session/$sessionId/chromium/send_command", 39 | ) 40 | params = { 41 | "cmd": "Page.setDownloadBehavior", 42 | "params": {"behavior": "allow", "downloadPath": download_dir}, 43 | } 44 | driver.execute("send_command", params) 45 | 46 | # Navigate to and click the download button 47 | driver.get(live_server.url + reverse("download_component")) 48 | button = driver.find_element(By.ID, "download_default") 49 | button.click() 50 | 51 | # Wait for the file to be downloaded (max 10 seconds) 52 | expected_filename = "foo.txt" 53 | expected_file_path = os.path.join(download_dir, expected_filename) 54 | 55 | def file_downloaded(): 56 | return ( 57 | os.path.exists(expected_file_path) 58 | and os.path.getsize(expected_file_path) > 0 59 | ) 60 | 61 | timeout = time.time() + 2 62 | while not file_downloaded() and time.time() < timeout: 63 | time.sleep(0.5) 64 | 65 | # Verify file exists and content is correct 66 | assert os.path.exists( 67 | expected_file_path 68 | ), f"Download file '{expected_file_path}' not found." 69 | 70 | with open(expected_file_path, "r", encoding="utf-8") as f: 71 | content = f.read() 72 | assert ( 73 | content == "Hello, World!" 74 | ), "File content does not match expected content" 75 | -------------------------------------------------------------------------------- /tests/urls.py: -------------------------------------------------------------------------------- 1 | from django.conf.urls.static import static 2 | from django.urls import path, include 3 | from django.conf import settings 4 | from tests.main.views import simple_basic_component_with_css 5 | 6 | 7 | urlpatterns = [ 8 | path("__tetra__", include("tetra.urls")), 9 | path( 10 | "simple_basic_component_with_css", 11 | simple_basic_component_with_css, 12 | name="simple_basic_component_with_css", 13 | ), 14 | path("main/", include("tests.main.urls")), 15 | ] + static(settings.STATIC_URL, document_root=settings.STATIC_ROOT) 16 | -------------------------------------------------------------------------------- /tests/utils.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | from bs4 import BeautifulSoup, Tag 4 | from django.contrib.auth.models import AnonymousUser 5 | from django.contrib.sessions.backends.cache import SessionStore 6 | from django.test import RequestFactory 7 | 8 | from tetra.views import _component_method 9 | 10 | 11 | def extract_component(html: str | bytes, innerHTML=True) -> str: 12 | """Helper to extract the `div#component` content from the given HTML. 13 | Also cuts out ALL newlines from the output. 14 | if innerHTML is False, it will return the outerHTML, including the HTML tag and 15 | attributes. If False, it returns only the inner content. 16 | """ 17 | el = BeautifulSoup(html, features="html.parser").html.body.find(id="component") 18 | if innerHTML: 19 | return el.decode_contents().replace("\n", "") 20 | else: 21 | return str(el).replace("\n", "") 22 | 23 | 24 | def extract_component_tag(html: str | bytes) -> Tag: 25 | """Helper to extract the `div#component` content from the given HTML as 26 | BeautifulSoup parsed entity.""" 27 | return BeautifulSoup(html, features="html.parser").html.body.find(id="component") 28 | 29 | 30 | def call_component_method( 31 | app_name, 32 | library_name, 33 | component_name, 34 | method, 35 | *args, 36 | **kwargs, 37 | ): 38 | factory = RequestFactory(content_type="application/json") 39 | component_state = { 40 | "csrfmiddlewaretoken": "fake-token", 41 | "args": [], 42 | "encrypted": "", # FIXME: test does not work with invalid encrypted state 43 | "data": {"data": ""}, 44 | } 45 | req = factory.post( 46 | "/", json.dumps(component_state), content_type="application/json" 47 | ) 48 | 49 | req.session = SessionStore() 50 | req.session.create() 51 | req.user = AnonymousUser() 52 | req.csrf_processing_done = True 53 | return _component_method(req, app_name, library_name, component_name, method) 54 | -------------------------------------------------------------------------------- /tetra/__init__.py: -------------------------------------------------------------------------------- 1 | """Full stack component framework for Django using Alpine.js""" 2 | 3 | from .components import BasicComponent, Component, public 4 | from .library import Library 5 | 6 | __all__ = [BasicComponent, Component, public, Library] 7 | __version__ = "0.3.2" 8 | __version_info__ = tuple([int(num) for num in __version__.split(".")]) 9 | -------------------------------------------------------------------------------- /tetra/apps.py: -------------------------------------------------------------------------------- 1 | from glob import glob 2 | from django.apps import AppConfig 3 | from pathlib import Path 4 | import os 5 | 6 | from . import Library 7 | from .templates import monkey_patch_template 8 | from django.utils.autoreload import autoreload_started 9 | 10 | monkey_patch_template() 11 | 12 | 13 | def watch_extra_files(sender, *args, **kwargs): 14 | watch = sender.extra_files.add 15 | for app_name, library in Library.registry.items(): 16 | for lib_name, library_info in library.items(): 17 | if library_info: 18 | # watch for html, js, and css files 19 | watch_list = glob(f"{library_info.path}/**/*.*", recursive=True) 20 | for file in watch_list: 21 | if os.path.exists(file) and file.endswith((".html", ".css", ".js")): 22 | watch(Path(file)) 23 | 24 | 25 | class TetraConfig(AppConfig): 26 | name = "tetra" 27 | 28 | def ready(self): 29 | from .component_register import find_component_libraries 30 | from . import default_settings 31 | from django.conf import settings 32 | 33 | for name in dir(default_settings): 34 | if name.isupper() and not hasattr(settings, name): 35 | setattr(settings, name, getattr(default_settings, name)) 36 | 37 | if not hasattr(settings, "TETRA_ESBUILD_PATH"): 38 | bin_name = "esbuild" 39 | if os.name == "nt": 40 | bin_name = "esbuild.cmd" 41 | if settings.BASE_DIR: 42 | setattr( 43 | settings, 44 | "TETRA_ESBUILD_PATH", 45 | Path(settings.BASE_DIR) / "node_modules" / ".bin" / bin_name, 46 | ) 47 | else: 48 | setattr(settings, "TETRA_ESBUILD_PATH", None) 49 | 50 | find_component_libraries() 51 | autoreload_started.connect(watch_extra_files) 52 | -------------------------------------------------------------------------------- /tetra/build.py: -------------------------------------------------------------------------------- 1 | import itertools 2 | 3 | from tetra import Library 4 | 5 | 6 | def build(libs_to_build): 7 | print("Tetra: Building Javascript and CSS") 8 | print(" - Libraries: %s" % ",".join(o.display_name for o in libs_to_build)) 9 | for lib in libs_to_build: 10 | lib.build() 11 | 12 | 13 | def runserver_build(): 14 | libs_to_build = list( 15 | itertools.chain.from_iterable(d.values() for d in Library.registry.values()) 16 | ) 17 | build(libs_to_build) 18 | -------------------------------------------------------------------------------- /tetra/build_js.sh: -------------------------------------------------------------------------------- 1 | ../demosite/node_modules/.bin/esbuild ./js/tetra.js --bundle --sourcemap --target=chrome80,firefox73,safari13,edge80 --outfile=./static/tetra/js/tetra.js 2 | ../demosite/node_modules/.bin/esbuild ./js/tetra.js --bundle --minify --sourcemap --target=chrome80,firefox73,safari13,edge80 --outfile=./static/tetra/js/tetra.min.js -------------------------------------------------------------------------------- /tetra/components/__init__.py: -------------------------------------------------------------------------------- 1 | from .base import ( 2 | BasicComponent, 3 | Component, 4 | FormComponent, 5 | public, 6 | ModelFormComponent, 7 | DynamicFormMixin, 8 | ) 9 | from ..exceptions import ComponentError, ComponentNotFound 10 | 11 | __all__ = [ 12 | ComponentError, 13 | ComponentNotFound, 14 | BasicComponent, 15 | Component, 16 | ModelFormComponent, 17 | public, 18 | DynamicFormMixin, 19 | FormComponent, 20 | ] 21 | -------------------------------------------------------------------------------- /tetra/components/callbacks.py: -------------------------------------------------------------------------------- 1 | class CallbackPath: 2 | def __init__(self, root, path=("",)): 3 | self.root = root 4 | self.path = path 5 | 6 | def __getattr__(self, name): 7 | return CallbackPath(self.root, self.path + (name,)) 8 | 9 | def __getitem__(self, name): 10 | return self.__getattr__(name) 11 | 12 | def __call__(self, *args): 13 | self.root.callbacks.append( 14 | { 15 | "callback": self.path, 16 | "args": args, 17 | } 18 | ) 19 | 20 | 21 | class CallbackList: 22 | def __init__(self): 23 | self.callbacks = [] 24 | 25 | def __getattr__(self, name): 26 | return CallbackPath(self, (name,)) 27 | 28 | def __getitem__(self, name): 29 | return self.__getattr__(name) 30 | 31 | def serialize(self): 32 | return self.callbacks 33 | -------------------------------------------------------------------------------- /tetra/default_settings.py: -------------------------------------------------------------------------------- 1 | # Default settings for Tetra 2 | 3 | TETRA_FILE_CACHE_DIR_NAME = "__tetracache__" 4 | TETRA_ESBUILD_JS_ARGS = [ 5 | "--bundle", 6 | "--minify", 7 | "--sourcemap", 8 | "--entry-names=[name]-[hash]", 9 | "--target=chrome80,firefox73,safari13,edge80", 10 | ] 11 | TETRA_ESBUILD_CSS_ARGS = [ 12 | "--bundle", 13 | "--minify", 14 | "--sourcemap", 15 | "--entry-names=[name]-[hash]", 16 | "--loader:.png=file", 17 | "--loader:.svg=file", 18 | "--loader:.gif=file", 19 | "--loader:.jpg=file", 20 | "--loader:.jpeg=file", 21 | "--loader:.webm=file", 22 | "--loader:.woff=file", 23 | "--loader:.woff2=file", 24 | "--loader:.ttf=file", 25 | ] 26 | 27 | TETRA_TEMP_UPLOAD_PATH = "tetra_temp_upload" 28 | -------------------------------------------------------------------------------- /tetra/exceptions.py: -------------------------------------------------------------------------------- 1 | class ComponentError(Exception): 2 | pass 3 | 4 | 5 | class ComponentNotFound(ComponentError): 6 | pass 7 | 8 | 9 | class LibraryError(Exception): 10 | pass 11 | -------------------------------------------------------------------------------- /tetra/js/tetra.js: -------------------------------------------------------------------------------- 1 | import Tetra from './tetra.core' 2 | 3 | window.Tetra = Tetra; 4 | window.document.addEventListener('alpine:init', () => { 5 | Tetra.init(); 6 | }) 7 | -------------------------------------------------------------------------------- /tetra/loaders/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tetra-framework/tetra/3f9faeaf5af277b27fd420f02d972856e39287e0/tetra/loaders/__init__.py -------------------------------------------------------------------------------- /tetra/loaders/components_directories.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | from django.apps import apps 3 | from django.template.loaders.filesystem import Loader as FileSystemLoader 4 | 5 | 6 | class Loader(FileSystemLoader): 7 | """Loader that loads templates from "components" directories in INSTALLED_APPS packages.""" 8 | 9 | # @functools.lru_cache 10 | def get_dirs(self): 11 | # Collect all `components` directories from each app 12 | component_dirs = [] 13 | for app_config in apps.get_app_configs(): 14 | if app_config.label != "tetra": 15 | # TODO use dynamic components_module_names 16 | components_dir = Path(app_config.path) / "components" 17 | if components_dir.is_dir(): 18 | component_dirs.append(components_dir) 19 | return component_dirs 20 | -------------------------------------------------------------------------------- /tetra/management/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tetra-framework/tetra/3f9faeaf5af277b27fd420f02d972856e39287e0/tetra/management/__init__.py -------------------------------------------------------------------------------- /tetra/management/commands/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tetra-framework/tetra/3f9faeaf5af277b27fd420f02d972856e39287e0/tetra/management/commands/__init__.py -------------------------------------------------------------------------------- /tetra/management/commands/makemessages.py: -------------------------------------------------------------------------------- 1 | import ast 2 | import os 3 | 4 | from django.core.management.commands.makemessages import ( 5 | Command as MakeMessagesCommand, 6 | BuildFile, 7 | ) 8 | from django.utils.functional import cached_property 9 | 10 | 11 | def mark_for_translation(s): 12 | """Mark a string for translation without translating it immediately.""" 13 | from django.utils.translation import gettext_noop 14 | 15 | return gettext_noop(s) 16 | 17 | 18 | class TetraBuildFile(BuildFile): 19 | 20 | def __init__(self, *args, **kwargs): 21 | super().__init__(*args, **kwargs) 22 | self.inline_templates: dict[int, str] = {} 23 | 24 | @cached_property 25 | def is_templatized(self): 26 | if self.domain == "django": 27 | file_ext = os.path.splitext(self.translatable.file)[1] 28 | if file_ext == ".py" and ( 29 | self.translatable.file == "components.py" 30 | or "components" in self.translatable.dirpath 31 | ): 32 | # FIXME: find a proper way to quickly find out if this file contains 33 | # Tetra components. 34 | with open(self.translatable.path, "r") as file: 35 | content = file.read() 36 | try: 37 | tree = ast.parse(content) 38 | for node in ast.walk(tree): 39 | if not isinstance(node, ast.ClassDef): 40 | continue 41 | has_component_base = False 42 | # FIXME: use better way to find out if this is a 43 | # Tetra component 44 | # it could be "tetra.components.Component", or 45 | # "tetra.Component", or a custom "FooComponent" named 46 | # in another way. We just check for the 47 | # name ending in "Component" which is a bit dull... 48 | for base in node.bases: 49 | if isinstance(base, ast.Name) and base.id.endswith( 50 | "Component" 51 | ): 52 | has_component_base = True 53 | if has_component_base: 54 | for stmt in node.body: 55 | targets = [] 56 | if isinstance(stmt, ast.Assign): 57 | targets = stmt.targets 58 | elif isinstance(stmt, ast.AnnAssign): 59 | targets = [stmt.target] 60 | for target in targets: 61 | if ( 62 | isinstance(target, ast.Name) 63 | and target.id == "template" 64 | ): 65 | # stmt.value.s now is the 66 | # template html string which may 67 | # contain translatable strings 68 | self.inline_templates[target.lineno] = ( 69 | stmt.value.s 70 | ) 71 | # if there is at least one 72 | # template in the component, 73 | # consider the component file 74 | # templatized. 75 | return True 76 | 77 | except SyntaxError | SyntaxWarning: 78 | # in case of syntax errors/warnings, just ignore this file 79 | return False 80 | return super().is_templatized 81 | 82 | def __repr_(self): 83 | return "<%s: %s>" % (self.__class__.__name__, self.translatable.path) 84 | 85 | 86 | class Command(MakeMessagesCommand): 87 | """Django's makemessages command which includes .py files that contain inline 88 | templates within Tetra components.""" 89 | 90 | build_file_class = TetraBuildFile 91 | -------------------------------------------------------------------------------- /tetra/management/commands/runserver.py: -------------------------------------------------------------------------------- 1 | import sys 2 | 3 | from django.conf import settings 4 | from django.core.management import CommandError 5 | from django.core.management.commands.runserver import Command as BaseRunserverCommand 6 | from tetra.build import runserver_build 7 | 8 | 9 | class Command(BaseRunserverCommand): 10 | # def inner_run(self, *args, **options): 11 | # runserver_build() 12 | # super().inner_run(*args, **options) 13 | 14 | def handle(self, *args, **options): 15 | runserver_build() 16 | # instead of super().handle(*args, **options) 17 | self.call_next_runserver(*args, **options) 18 | 19 | def call_next_runserver(self, *args, **options): 20 | from django.core.management import load_command_class 21 | 22 | # Get list of all apps excluding 'tetra*' 23 | apps = [app for app in settings.INSTALLED_APPS if not app.startswith("tetra")] 24 | # add 'django.core' as fallback, if even staticfiles is not there 25 | apps.append("django.core") 26 | 27 | for app_name in apps: 28 | try: 29 | # Attempt to load runserver from next app 30 | cmd = load_command_class(app_name, "runserver") 31 | cmd.run_from_argv(sys.argv) 32 | return 33 | except ModuleNotFoundError: 34 | continue 35 | except AttributeError: 36 | continue 37 | 38 | raise CommandError("No 'runserver' command found in any of the INSTALLED_APPS.") 39 | -------------------------------------------------------------------------------- /tetra/management/commands/tetrabuild.py: -------------------------------------------------------------------------------- 1 | from django.core.management.base import BaseCommand, CommandError 2 | 3 | from ... import Library 4 | from ...build import build 5 | 6 | 7 | class Command(BaseCommand): 8 | help = "Build the javascript and css for a/all apps." 9 | 10 | def add_arguments(self, parser): 11 | parser.add_argument( 12 | "app_libraries", 13 | nargs="*", 14 | help="'app_name' or 'app_name.library_name' of an application/library_name to build css/js for.", 15 | ) 16 | 17 | def handle(self, *args, **options): 18 | libs_to_build = [] 19 | app_libraries = options["app_libraries"] 20 | libraries = Library.registry 21 | if app_libraries: 22 | for app_library in app_libraries: 23 | if "." in app_library: 24 | app_label, library_name = app_library.split(".", 1) 25 | try: 26 | libs_to_build.append(libraries[app_label][library_name]) 27 | except KeyError: 28 | raise CommandError(f'Library "{app_library}" not found.') 29 | else: 30 | if app_library in libraries: 31 | libs_to_build.extend(libraries[app_library].values()) 32 | else: 33 | raise CommandError(f'App "{app_library}" not found.') 34 | else: 35 | for app_libs in libraries.values(): 36 | for lib in app_libs.values(): 37 | libs_to_build.append(lib) 38 | 39 | build(libs_to_build) 40 | -------------------------------------------------------------------------------- /tetra/static/tetra/js/alpinejs.morph.cdn.min.js: -------------------------------------------------------------------------------- 1 | (()=>{function k(u,l,o){Y();let g,h,y,B,O,E,v,T,_,A;function V(e={}){let n=a=>a.getAttribute("key"),d=()=>{};O=e.updating||d,E=e.updated||d,v=e.removing||d,T=e.removed||d,_=e.adding||d,A=e.added||d,y=e.key||n,B=e.lookahead||!1}function D(e,n){if(W(e,n))return q(e,n);let d=!1;if(!b(O,e,n,()=>d=!0)){if(e.nodeType===1&&window.Alpine&&window.Alpine.cloneNode(e,n),X(n)){$(e,n),E(e,n);return}d||G(e,n),E(e,n),L(e,n)}}function W(e,n){return e.nodeType!=n.nodeType||e.nodeName!=n.nodeName||x(e)!=x(n)}function q(e,n){if(b(v,e))return;let d=n.cloneNode(!0);b(_,d)||(e.replaceWith(d),T(e),A(d))}function $(e,n){let d=n.nodeValue;e.nodeValue!==d&&(e.nodeValue=d)}function G(e,n){if(e._x_transitioning||e._x_isShown&&!n._x_isShown||!e._x_isShown&&n._x_isShown)return;let d=Array.from(e.attributes),a=Array.from(n.attributes);for(let i=d.length-1;i>=0;i--){let t=d[i].name;n.hasAttribute(t)||e.removeAttribute(t)}for(let i=a.length-1;i>=0;i--){let t=a[i].name,m=a[i].value;e.getAttribute(t)!==m&&e.setAttribute(t,m)}}function L(e,n){e._x_teleport&&(e=e._x_teleport),n._x_teleport&&(n=n._x_teleport);let d=H(e.children),a={},i=I(n),t=I(e);for(;i;){Z(i,t);let s=x(i),p=x(t);if(!t)if(s&&a[s]){let r=a[s];e.appendChild(r),t=r}else{if(!b(_,i)){let r=i.cloneNode(!0);e.appendChild(r),A(r)}i=c(n,i);continue}let C=r=>r&&r.nodeType===8&&r.textContent==="[if BLOCK]>r&&r.nodeType===8&&r.textContent==="[if ENDBLOCK]>0)r--;else if(S(f)&&r===0){t=f;break}t=f}let R=t;r=0;let j=i;for(;i;){let f=c(n,i);if(C(f))r++;else if(S(f)&&r>0)r--;else if(S(f)&&r===0){i=f;break}i=f}let z=i,J=new w(N,R),Q=new w(j,z);L(J,Q);continue}if(t.nodeType===1&&B&&!t.isEqualNode(i)){let r=c(n,i),N=!1;for(;!N&&r;)r.nodeType===1&&t.isEqualNode(r)&&(N=!0,t=K(e,i,t),p=x(t)),r=c(n,r)}if(s!==p){if(!s&&p){a[p]=t,t=K(e,i,t),a[p].remove(),t=c(e,t),i=c(n,i);continue}if(s&&!p&&d[s]&&(t.replaceWith(d[s]),t=d[s]),s&&p){let r=d[s];if(r)a[p]=t,t.replaceWith(r),t=r;else{a[p]=t,t=K(e,i,t),a[p].remove(),t=c(e,t),i=c(n,i);continue}}}let P=t&&c(e,t);D(t,i),i=i&&c(n,i),t=P}let m=[];for(;t;)b(v,t)||m.push(t),t=c(e,t);for(;m.length;){let s=m.shift();s.remove(),T(s)}}function x(e){return e&&e.nodeType===1&&y(e)}function H(e){let n={};for(let d of e){let a=x(d);a&&(n[a]=d)}return n}function K(e,n,d){if(!b(_,n)){let a=n.cloneNode(!0);return e.insertBefore(a,d),A(a),a}return n}return V(o),g=u,h=typeof l=="string"?U(l):l,window.Alpine&&window.Alpine.closestDataStack&&!u._x_dataStack&&(h._x_dataStack=window.Alpine.closestDataStack(u),h._x_dataStack&&window.Alpine.cloneNode(u,h)),D(u,h),g=void 0,h=void 0,u}k.step=()=>{};k.log=()=>{};function b(u,...l){let o=!1;return u(...l,()=>o=!0),o}var F=!1;function U(u){let l=document.createElement("template");return l.innerHTML=u,l.content.firstElementChild}function X(u){return u.nodeType===3||u.nodeType===8}var w=class{constructor(l,o){this.startComment=l,this.endComment=o}get children(){let l=[],o=this.startComment.nextSibling;for(;o&&o!==this.endComment;)l.push(o),o=o.nextSibling;return l}appendChild(l){this.endComment.before(l)}get firstChild(){let l=this.startComment.nextSibling;if(l!==this.endComment)return l}nextNode(l){let o=l.nextSibling;if(o!==this.endComment)return o}insertBefore(l,o){return o.before(l),l}};function I(u){return u.firstChild}function c(u,l){let o;return u instanceof w?o=u.nextNode(l):o=l.nextSibling,o}function Y(){if(F)return;F=!0;let u=Element.prototype.setAttribute,l=document.createElement("div");Element.prototype.setAttribute=function(g,h){if(!g.includes("@"))return u.call(this,g,h);l.innerHTML=``;let y=l.firstElementChild.getAttributeNode(g);l.firstElementChild.removeAttributeNode(y),this.setAttributeNode(y)}}function Z(u,l){let o=l&&l._x_bindings&&l._x_bindings.id;o&&(u.setAttribute("id",o),u.id=o)}function M(u){u.morph=k}document.addEventListener("alpine:init",()=>{window.Alpine.plugin(M)});})(); 2 | -------------------------------------------------------------------------------- /tetra/templates.py: -------------------------------------------------------------------------------- 1 | from django.template.base import Template, Origin 2 | from django.template.loader_tags import BlockNode 3 | from collections import defaultdict 4 | import re 5 | 6 | 7 | original_template_compile_nodelist = Template.compile_nodelist 8 | 9 | 10 | class InlineTemplate(Template): 11 | """Represents an "inline" template string within a component.""" 12 | 13 | def get_exception_info(self, *args, **kwargs): 14 | ret = super().get_exception_info(*args, **kwargs) 15 | line_offset = self.origin.start_line - 1 16 | ret["top"] += line_offset 17 | ret["bottom"] += line_offset 18 | ret["line"] += line_offset 19 | ret["top"] += line_offset 20 | ret["message"] = re.sub( 21 | r"(line )(\d+)(:)", 22 | lambda m: f"{m.group(1)}{int(m.group(2))+line_offset}{m.group(3)}", 23 | ret["message"], 24 | ) 25 | ret["source_lines"] = [ 26 | (line + line_offset, s) for (line, s) in ret["source_lines"] 27 | ] 28 | return ret 29 | 30 | 31 | class InlineOrigin(Origin): 32 | def __init__(self, start_line=0, component=None, *args, **kwargs): 33 | super().__init__(*args, **kwargs) 34 | self.start_line = start_line 35 | self.component = component 36 | 37 | 38 | def new_template_compile_nodelist(self): 39 | nodelist = original_template_compile_nodelist(self) 40 | self.blocks_by_key = {} 41 | annotate_nodelist(self, nodelist, []) 42 | return nodelist 43 | 44 | 45 | def monkey_patch_template(): 46 | Template.compile_nodelist = new_template_compile_nodelist 47 | 48 | 49 | def annotate_nodelist(template, nodelist, path): 50 | from .templatetags.tetra import ComponentNode 51 | 52 | if nodelist: 53 | node_type_counter = defaultdict(int) 54 | for node in nodelist: 55 | if isinstance(node, BlockNode): 56 | node_key = f"block:{node.name}:{node_type_counter['block:'+node.name]}" 57 | node._path_key = "/".join([*path, node_key]) 58 | template.blocks_by_key[node._path_key] = node 59 | annotate_nodelist(template, node.nodelist, [*path, node_key]) 60 | node_type_counter["block:" + node.name] += 1 61 | elif isinstance(node, ComponentNode): 62 | node_key = f"comp:{node.component_name}:{node_type_counter['block:'+node.component_name]}" 63 | annotate_nodelist(template, node.nodelist, [*path, node_key]) 64 | node_type_counter["comp:" + node.component_name] += 1 65 | elif hasattr(node, "nodelist"): 66 | annotate_nodelist(template, node.nodelist, path) 67 | -------------------------------------------------------------------------------- /tetra/templates/lib_scripts.html: -------------------------------------------------------------------------------- 1 | {% load static %} 2 | {% if csrf_token %} 3 | 7 | {% endif %} 8 | {% for lib in libs %} 9 | 10 | {% endfor %} 11 | 12 | {% if include_alpine %} 13 | {% if debug %} 14 | 15 | 16 | {% else %} 17 | 18 | 19 | {% endif %} 20 | {% endif %} -------------------------------------------------------------------------------- /tetra/templates/lib_styles.html: -------------------------------------------------------------------------------- 1 | {% for lib in libs %} 2 | 3 | {% endfor %} -------------------------------------------------------------------------------- /tetra/templates/script.js: -------------------------------------------------------------------------------- 1 | (() => { 2 | let __script = {{ component_script|safe }} 3 | let __serverMethods = {{ component_server_methods|safe }} 4 | let __serverProperties = {{ component_server_properties|safe }} 5 | let __componentName = '{{ component_name|safe }}' 6 | window.document.addEventListener('alpine:init', () => { 7 | Tetra.makeAlpineComponent( 8 | __componentName, 9 | __script, 10 | __serverMethods, 11 | __serverProperties, 12 | ) 13 | }) 14 | })(); -------------------------------------------------------------------------------- /tetra/templatetags/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tetra-framework/tetra/3f9faeaf5af277b27fd420f02d972856e39287e0/tetra/templatetags/__init__.py -------------------------------------------------------------------------------- /tetra/types.py: -------------------------------------------------------------------------------- 1 | from typing import TypedDict, Any 2 | 3 | 4 | class ComponentData(TypedDict): 5 | encrypted: str 6 | data: dict[str, Any] 7 | children: list["ComponentData"] 8 | args: [str] 9 | -------------------------------------------------------------------------------- /tetra/urls.py: -------------------------------------------------------------------------------- 1 | from django.urls import path 2 | from . import views 3 | 4 | urlpatterns = [ 5 | path( 6 | "///", 7 | views.component_method, 8 | name="tetra_public_component_method", 9 | ), 10 | ] 11 | -------------------------------------------------------------------------------- /tetra/views.py: -------------------------------------------------------------------------------- 1 | import json 2 | import logging 3 | 4 | from django.http import HttpResponseNotFound, HttpResponseBadRequest, HttpResponse 5 | from django.views.decorators.csrf import csrf_exempt, csrf_protect 6 | 7 | from tetra.types import ComponentData 8 | from . import Library 9 | from .utils import from_json, PersistentTemporaryFileUploadHandler 10 | 11 | 12 | logger = logging.getLogger(__name__) 13 | 14 | 15 | @csrf_exempt 16 | def component_method(request, *args, **kwargs): 17 | """Override default upload handlers, to create a "persistent" temporary file for 18 | file uploads that are done using Tetra methods.""" 19 | request.upload_handlers = [PersistentTemporaryFileUploadHandler(request)] 20 | return _component_method(request, *args, **kwargs) 21 | 22 | 23 | @csrf_protect 24 | def _component_method( 25 | request, app_name, library_name, component_name, method_name 26 | ) -> HttpResponse: 27 | if not request.method == "POST": 28 | return HttpResponseBadRequest() 29 | try: 30 | Component = Library.registry[app_name][library_name].components[component_name] 31 | except KeyError: 32 | return HttpResponseNotFound() 33 | 34 | if method_name not in (m["name"] for m in Component._public_methods): 35 | logger.warning( 36 | f"Tetra method was requested, but not found: {component_name}.{method_name}()" 37 | ) 38 | return HttpResponseNotFound() 39 | 40 | try: 41 | # check if request includes multipart/form-data files 42 | if request.content_type == "multipart/form-data": 43 | component_state = from_json(request.POST["component_state"]) 44 | # data["args"].extend(request.FILES.values()) 45 | elif request.content_type == "application/json" and request.body: 46 | # if request is application-data/json, we need to decode it ourselves 47 | component_state = from_json(request.body.decode()) 48 | else: 49 | logger.error("Unsupported content type: %s", request.content_type) 50 | return HttpResponseBadRequest() 51 | 52 | except json.decoder.JSONDecodeError as e: 53 | logger.error(e) 54 | return HttpResponseBadRequest() 55 | 56 | if not ( 57 | isinstance(component_state, dict) 58 | and "args" in component_state 59 | and isinstance(component_state["args"], list) 60 | ): 61 | raise TypeError("Invalid component state args.") 62 | 63 | if not hasattr(request, "tetra_components_used"): 64 | request.tetra_components_used = set() 65 | request.tetra_components_used.add(Component) 66 | 67 | component = Component.from_state(component_state, request) 68 | 69 | logger.debug( 70 | f"Calling component method {component.__class__.__name__}.{method_name}()" 71 | ) 72 | return component._call_public_method( 73 | request, method_name, component_state["children"], *component_state["args"] 74 | ) 75 | --------------------------------------------------------------------------------