Alpha v0.1.1

Full stack reactive component framework for Django using Alpine.js

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 reactive updates. It also encapsulates your Python, HTML, JavaScript and CSS into one file for close proximity of related concerns.

See how easy it is to build a todo list:

components.py
from sourcetypes import javascript, css, django_html
from tetra import Component, public, Library
from .models import ToDo

default = Library()

@default.register
class ToDoList(Component):
    title = public("")

    def load(self):
        self.todos = ToDo.objects.filter(session_key=self.request.session.session_key)

    @public
    def add_todo(self, title):
        todo = ToDo(
            title=title,
            session_key=self.request.session.session_key,
        )
        todo.save()
        self.title = ""

    template: django_html = """
    <div>
        <div class="input-group mb-2">
            <input type="text" x-model="title" class="form-control" 
                placeholder="New task..." @keyup.enter="add_todo(title)">
            <button class="btn btn-primary" :class="{'disabled': title == ''}"
                @click="add_todo(title)">Add</button>
        </div>
        <div class="list-group">
            {% for todo in todos %}
                {% @ to_do_item todo=todo key=todo.id / %}
            {% endfor %}
        </div>
    </div>
    """

@default.register
class ToDoItem(Component):
    title = public("")
    done = public(False)

    def load(self, todo):
        self.todo = todo
        self.title = todo.title
        self.done = todo.done

    @public.watch("title", "done").debounce(200)
    def save(self, value, old_value, attr):
        self.todo.title = self.title
        self.todo.done = self.done
        self.todo.save()

    @public(update=False)
    def delete_item(self):
        self.todo.delete()
        self.client._removeComponent()

    template: django_html = """
    <div class="list-group-item d-flex gap-1 p-1" {% ... attrs %}>
        <label class="align-middle px-2 d-flex">
            <input class="form-check-input m-0 align-self-center" type="checkbox"
                x-model="done">
        </label>
        <input 
            type="text" 
            class="form-control border-0 p-0 m-0"
            :class="{'text-muted': done, 'todo-strike': done}"
            x-model="title"
            maxlength="80"
            @keydown.backspace="inputDeleteDown()"
            @keyup.backspace="inputDeleteUp()"
        >
        <button @click="delete_item()" class="btn btn-sm">
            <i class="fa-solid fa-trash"></i>
        </button>
    </div>
    """

    script: javascript = """
    export default {
        lastTitleValue: "",
        inputDeleteDown() {
            this.lastTitleValue = this.title;
        },
        inputDeleteUp() {
            if (this.title === "" && this.lastTitleValue === "") {
                this.delete_item()
            }
        }
    }
    """

    style: css = """
    .todo-strike {
        text-decoration: line-through;
    }
    """
models.py
from django.db import models

class ToDo(models.Model):
    session_key = models.CharField(max_length=40, db_index=True)
    title = models.CharField(max_length=80)
    done = models.BooleanField(default=False)
index.html
{% load tetra %}
<h4>Your todo list:</h4>
{% @ to_do_list / %}

Your todo list:

Or a reactive server rendered search component:

components.py
import itertools
from sourcetypes import django_html
from tetra import Component, public, Library
from .movies import movies

@default.register
class reactive_search(Component):
    query = public("")
    results = []

    @public.watch("query").throttle(200, leading=False, trailing=True)
    def watch_query(self, value, old_value, attr):
        if self.query:
            self.results = itertools.islice(
                (movie for movie in movies if self.query.lower() in movie.lower()), 20
            )
        else:
            self.results = []

    template: django_html = """
    <div>
        <p>
            <input class="form-control" placeholder="Search for an 80s movie..."
            type="text" x-model="query">
        </p>
        <ul>
        {% for result in results %}
            <li>{{ result }}</li>
        {% endfor %}
        </ul>
    </div>
    """

Or a counter, but with multiple nested instances.

components.py
@default.register
class Counter(Component):
    count = 0
    current_sum = 0

    def load(self, current_sum=None):
        if current_sum is not None:
            self.current_sum = current_sum

    @public
    def increment(self):
        self.count += 1

    def sum(self):
        return self.count + self.current_sum

    template: django_html = """
    <div class="border rounded p-3">
        <p>
            Count: <b>{{ count }}</b>,
            Sum: <b>{{ sum }}</b>
            <button class="btn btn-sm btn-primary" @click="increment()">Increment</button>
        </p>
        <div>
            {% block default %}{% endblock %}
        </div>
    </div>
    """
index.html
{% load tetra %}
{% @ demo.counter key="counter-1" %}
  {% @ demo.counter key="counter-2" current_sum=sum %}
    {% @ demo.counter key="counter-3" current_sum=sum / %}
  {% /@ demo.counter %}
{% /@ demo.counter %}

Count: 0, Sum: 0

Count: 0, Sum: 0

Count: 0, Sum: 0

What does Tetra do?

Django on the backend, Alpine.js in the browser

Tetra combines the power of Django with Alpine.js to make development easier and quicker.

Component encapsulation

Each component combines its Python, HTML, CSS and JavaScript in one place for close proximity of related code.

Resumable server state

The components' full server state is saved between public method calls. This state is encrypted for security.

Public server methods

Methods can be made public, allowing you to easily call them from JS on the front end, resuming the component's state.

Shared public state

Attributes can be decorated to indicate they should be available in the browser as Alpine.js data objects.

Server "watcher" methods

Public methods can be instructed to watch a public attribute, enabling reactive re-rendering on the server.

Inplace updating from the server

Server methods can update the rendered component in place. Powered by the Alpine.js morph plugin.

Component library packaging

Every component belongs to a "library"; their JS & CSS is packed together for quicker browser downloads.

Components with overridable blocks

Component can have multiple {% block(s) %} which can be overridden when used.

JS/CSS builds using esbuild

Both for development (built into runserver) and production your JS & CSS is built with esbuild.

Source Maps

Source maps are generated during development so that you can track down errors to the original Python files.

Syntax highlighting with type annotations

Tetra uses type annotations to syntax highlight your JS, CSS & HTML in your Python files with a VS Code plugin

This is only the beginning, with much more planned, including:
• ModelComponent for bootstrapping standard CRUD interactions.   • FormComponent bringing the power of Django forms to Tetra.   • Python type annotations   • Intergration with Django Validators   • Alpine.js directives for component state (loading, offline etc.)   • Routing and query strings for "whole page" components.   • Page title and metadata in header.   • Pagination and Infinity Scroll components.   • PostCSS/SASS/etc. support.   • CSS scoped to a component.   • Websocket/SSE components enabling realtime updates.   • File upload/downloads.   • Redirect responses.   • Integration with Django Messages.   • Additional authentication tools.   • Integration with UI & component toolkits.   • Bundling of esbuild to be npm free.