Skip to content

Updated idiomatic FastHTML app adv_app.py #689

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 7 commits into
base: main
Choose a base branch
from
Draft
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
179 changes: 179 additions & 0 deletions examples/adv_app2.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,179 @@
from fasthtml.common import *
from fasthtml.jupyter import *
from functools import partial
from dataclasses import dataclass
from hmac import compare_digest


db = database(':memory:')

class User: name:str; pwd:str

class Todo:
id:int; title:str; done:bool; name:str; details:str; priority:int

db.users = db.create(User, transform=True, pk='name')
db.todos = db.create(Todo, transform=True)

@patch
def __ft__(self:Todo):
# Some FastHTML tags have an 'X' suffix, which means they're "extended" in some way.
# For instance, here `AX` is an extended `A` tag, which takes 3 positional arguments:
# `(text, hx_get, target_id)`.
# All underscores in FT attrs are replaced with hyphens, so this will create an `hx-get` attr,
# which HTMX uses to trigger a GET request.
# Generally, most of your route handlers in practice (as in this demo app) are likely to be HTMX handlers.
# For instance, for this demo, we only have two full-page handlers: the '/login' and '/' GET handlers.
### show = AX(self.title, f'/todos/{self.id}', 'current-todo')
### edit = AX('edit', f'/edit/{self.id}' , 'current-todo')
### dt = '✅ ' if self.done else ''
# FastHTML provides some shortcuts. For instance, `Hidden` is defined as simply:
# `return Input(type="hidden", value=value, **kwargs)`
cts = ('✅ ' if self.done else '',
AX(self.title, todo_detail.to(id=self.id), 'current-todo'),
' | ',
AX('edit', todo_edit.to(id=self.id) , 'current-todo'),
Hidden(id="id", value=self.id),
Hidden(id="priority", value="0"))
# Any FT object can take a list of children as positional args, and a dict of attrs as keyword args.
return Li(*cts, id=f'todo-{self.id}')

def user_auth_before(req, sess):
auth = req.scope['auth'] = sess.get('auth', None)
if not auth: return login_redir
db.todos.xtra(name=auth)

beforeware = Beforeware(
user_auth_before,
skip=[r'/favicon\.ico', r'/static/.*', r'.*\.css', r'.*\.js', '/login']
)
app, rt = fast_app(hdrs=[SortableJS('.sortable'),],before=beforeware)

# Authentication
login_redir = Redirect('/login')

@rt('/login')
def get():
# This creates a form with two input fields, and a submit button.
# All of these components are `FT` objects. All HTML tags are provided in this form by FastHTML.
# If you want other custom tags (e.g. `MyTag`), they can be auto-generated by e.g
# `from fasthtml.components import MyTag`.
# Alternatively, manually call e.g `ft(tag_name, *children, **attrs)`.
frm = Form(
# Tags with a `name` attr will have `name` auto-set to the same as `id` if not provided
Input(id='name', placeholder='Name'),
Input(id='pwd', type='password', placeholder='Password'),
Button('login'),
action='/login', method='post')
# If a user visits the URL directly, FastHTML auto-generates a full HTML page.
# However, if the URL is accessed by HTMX, then one HTML partial is created for each element of the tuple.
# To avoid this auto-generation of a full page, return a `HTML` object, or a Starlette `Response`.
# `Titled` returns a tuple of a `Title` with the first arg and a `Container` with the rest.
# See the comments for `Title` later for details.
return Titled("Login", frm)

@dataclass
class Login: name:str; pwd:str

@rt("/login")
def post(login:Login, sess):
if not login.name or not login.pwd: return login_redir
try: u = db.users[login.name]
except NotFoundError: u = db.users.insert(login)
if not compare_digest(u.pwd.encode("utf-8"), login.pwd.encode("utf-8")): return login_redir
sess['auth'] = u.name
return Redirect('/')

@rt
def logout(sess):
del sess['auth']
return login_redir

# Dashboard and control handlers
@rt
def index(auth):
top = Grid(Div(A('logout', href=logout), style='text-align: right'))
new_inp = Input(id="new-title", name="title", placeholder="New Todo")
add = Form(Group(new_inp, Button("Add")),
hx_post=add_todo, target_id='todo-list', hx_swap="afterbegin")
frm = Form(*db.todos(order_by='priority'),
id='todo-list', cls='sortable', hx_post=reorder, hx_trigger="end")

card = Card(Ul(frm), header=add, footer=Div(id='current-todo'))
return Titled(f"{auth}'s Todo list", Container(top, card))

@rt
def add_todo(todo:Todo, auth):
new_inp = Input(id="new-title", name="title", placeholder="New Todo", hx_swap_oob='true')
# `insert` returns the inserted todo, which is appended to the start of the list, because we used
# `hx_swap='afterbegin'` when creating the todo list form.
return db.todos.insert(todo), new_inp

@rt
def reorder(id:list[int]):
for i,id_ in enumerate(id): db.todos.update({'priority':i}, id_)
# HTMX by default replaces the inner HTML of the calling element, which in this case is the todo list form.
# Therefore, we return the list of todos, now in the correct order, which will be auto-converted to FT for us.
# In this case, it's not strictly necessary, because sortable.js has already reorder the DOM elements.
# However, by returning the updated data, we can be assured that there aren't sync issues between the DOM
# and the server.
return tuple(db.todos(order_by='priority'))

@rt
def todo_detail(id:int):
todo = db.todos[id]
# `hx_swap` determines how the update should occur. We use "outerHTML" to replace the entire todo `Li` element.
btn = Button('delete', hx_delete=todos_delete.to(id=id),
target_id=f'todo-{todo.id}', hx_swap="outerHTML")
# The "markdown" class is used here because that's the CSS selector we used in the JS earlier.
# Therefore this will trigger the JS to parse the markdown in the details field.
# Because `class` is a reserved keyword in Python, we use `cls` instead, which FastHTML auto-converts.
return Div(H2(todo.title), Div(todo.details, cls="markdown"), btn)

@rt
def todo_edit(id:int):
# The `hx_put` attribute tells HTMX to send a PUT request when the form is submitted.
# `target_id` specifies which element will be updated with the server's response.
res = Form(Group(Input(id="title"), Button("Save")),
Hidden(id="id"), CheckboxX(id="done", label='Done'),
Textarea(id="details", name="details", rows=10),
hx_put="/", target_id=f'todo-{id}', id="edit")
# `fill_form` populates the form with existing todo data, and returns the result.
# Indexing into a table (`todos`) queries by primary key, which is `id` here. It also includes
# `xtra`, so this will only return the id if it belongs to the current user.
return fill_form(res, db.todos[id])

# Refactoring components in FastHTML is as simple as creating Python functions.
# The `clr_details` function creates a Div with specific HTMX attributes.
# `hx_swap_oob='innerHTML'` tells HTMX to swap the inner HTML of the target element out-of-band,
# meaning it will update this element regardless of where the HTMX request originated from.
def clr_details(): return Div(hx_swap_oob='innerHTML', id='current-todo')

@rt("/")
def put(todo: Todo):
# `update` is part of the MiniDataAPI spec.
# Note that the updated todo is returned. By returning the updated todo, we can update the list directly.
# Because we return a tuple with `clr_details()`, the details view is also cleared.
return db.todos.update(todo), clr_details()


# This route handler uses a path parameter `{id}` which is automatically parsed and passed as an int.
@rt(methods=['DELETE'])
def todos_delete(id:int):
# The `delete` method is part of the MiniDataAPI spec, removing the item with the given primary key.
db.todos.delete(id)
# Returning `clr_details()` ensures the details view is cleared after deletion,
# leveraging HTMX's out-of-band swap feature.
# Note that we are not returning *any* FT component that doesn't have an "OOB" swap, so the target element
# inner HTML is simply deleted. That's why the deleted todo is removed from the list.
return clr_details()


Where HTTP methods in allcaps on a handler name constrains that handler to only respond to that method.

@rt_delete
def todos(id:int): # Constrained to just HTTP DELETE
db.todos.delete(id)
return clr_details()

serve()