diff --git a/examples/adv_app2.py b/examples/adv_app2.py new file mode 100644 index 00000000..0c1042fd --- /dev/null +++ b/examples/adv_app2.py @@ -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() \ No newline at end of file