diff --git a/examples/todo_series/kanban/app.py b/examples/todo_series/kanban/app.py new file mode 100644 index 0000000..af40685 --- /dev/null +++ b/examples/todo_series/kanban/app.py @@ -0,0 +1,136 @@ +from fasthtml.common import * +from monsterui.all import * +import ast + +app,rt = fast_app(hdrs=Theme.blue.headers()) + +status_categories = ['Not Started', 'In Progress', 'Blocked by Client', 'Internal Review', 'Client Review', 'Counterparty Review', 'Out for Signature', "Archive"] + +client_names = ['AnswerDotAI', 'Fastai', 'Dunder Mifflin'] +task_categories = ['Project', 'Hourly', 'Pro Bono', 'Other'] + +# Setup database +db = Database('kanban.db') +todos = db.t.todos +# if not IS_PROD: todos.drop() +if todos not in db.t: + todos.create(id=int, client_name=str, task_name=str, task_description=str, status=str, url=str, name=str, task_category=str, + owner=str, collaborators=str, notifiers=str, pk='id') +Todo = todos.dataclass() + +# Setup app +app, rt = fast_app(hdrs=Theme.blue.headers()) + +def tid(id): return f'todo-{id}' + +@patch +def __ft__(self:Todo): + delete = Button('delete', hx_delete=delete_todo.to(id=self.id), hx_target=f'#{tid(self.id)}', + hx_swap='outerHTML', cls=ButtonT.danger, hx_confirm='Are you sure you want to delete this todo?') + edit = Button('edit', hx_get =edit_todo. to(id=self.id), hx_target=f'#{tid(self.id)}', + hx_swap='outerHTML', cls=ButtonT.primary) + + status_category = Select(*map(lambda s: Option(s, value=s, selected=s==self.status), status_categories), id='status_category', name='status_category', + hx_trigger='change', post=update_status, + hx_target='#todo-kanban', hx_vals=f'{{"id": "{self.id}"}}') + + def create_owner_labels(vals): + parsed_vals = [o.split('@')[0] for o in ast.literal_eval(vals)] + return map(Label,parsed_vals) + + return Card(DivLAligned(Strong("Owner: "),*create_owner_labels(self.owner)), + DivFullySpaced( + H4(A(self.task_name, target_id='current-todo', href=self.url, target="_blank", cls='underline')), + P(self.client_name, cls=TextFont.muted_sm)), + A(self.task_description, href=self.url, target="_blank", cls=TextFont.muted_lg), + DivLAligned(P("Collaborators: ", cls=TextFont.muted_sm),*create_owner_labels(self.collaborators)), + DivLAligned(P("Notifiers: ", cls=TextFont.muted_sm),*create_owner_labels(self.notifiers)), + id=tid(self.id), + footer=DivFullySpaced(edit,delete,status_category,cls='space-x-2')) + +# Create/edit todo form +def create_input_field(name: str, todo, input_fn=Input, hidden: bool = False) -> Input: + return input_fn(id=f'new-{name}',name=name,value=getattr(todo, name, None), + placeholder=' '.join(word.title() for word in name.split('_')), hidden=hidden) + +_default_todo = Todo(client_name=None, status="Not Started", task_name=None, task_description=None, url=None, id=None) +def mk_todo_form(todo=_default_todo): + """Create a form for todo creation/editing with optional pre-filled values""" + _create_input_field = lambda name: create_input_field(name, todo) + + def _select_option(c, todo_item): + return Option(c, value=c, selected=c==str(todo_item)) + + inputs = [UkSelect(*map(lambda x: _select_option(x,todo.client_name), client_names), + id='new-client_name', name='client_name', placeholder='Select Client')] + inputs += [UkSelect(*map(lambda x: _select_option(x, todo.task_category), task_categories), + id='new-task_category', name='task_category', placeholder='Select Task Category')] + inputs += list(map(_create_input_field, ['task_name', 'url'])) + + # User tagging + users = list(r['name'] for r in db['users'].rows) + def _un_select_option(c, todo_item): + return Option(c.split('@')[0], value=c, selected=c in str(todo_item)) + + user_tagging = [UkSelect(*map(lambda x: _un_select_option(x, todo.owner), users), + id='new-owner', name='owner', placeholder='Select Owner', multiple=True), + UkSelect(*map(lambda x: _un_select_option(x, todo.collaborators), users), + id='new-collaborators', name='collaborators', placeholder='Select Collaborators', multiple=True), + UkSelect(*map(lambda x: _un_select_option(x, todo.notifiers), users), + id='new-notifiers', name='notifiers', placeholder='Select Notifiers', multiple=True)] + + inputs.append(Select(*map(lambda s: Option(s, value=s, selected=s==todo.status), status_categories), id=f'new-status', name='status')) + if todo.id: inputs.append(Input(id='new-id', name='id', value=todo.id, hidden=True)) + + return Form(Grid(*inputs),Grid(*user_tagging),TextArea(todo.task_description, id='new-task_description', name='task_description'), + Button("Create/Modify Task", cls=ButtonT.primary+'w-full', post=upsert_todo,hx_target='#todo-list'), + id='todo-input', cls='space-y-3 mb-6', hx_swap_oob='true') + +# Index page +@rt +async def index(sess): + return Container(mk_todo_form(), Divider(), + Div(mk_todo_list(sess['user_name']),id='todo-list'), + Details(Summary(H1("Archived Todos")),mk_archive_list())) + +# Upsert todo +@rt +async def upsert_todo(request, todo:Todo, sess): # owners: List[str] + form = await request.form() + todo.owner = form.getlist('owner[]') + todo.collaborators = form.getlist('collaborators[]') + todo.notifiers = form.getlist('notifiers[]') + print("Owners:", todo.owner) + print("Collaborators:", todo.collaborators) + print("Notifiers:", todo.notifiers) + + print("I made it into the route.") + if not todo.status: todo.status = "Not Started" + todos.insert(todo,replace=True) + return mk_todo_list(sess['user_name']),mk_todo_form() + +def mk_archive_list(): return Grid(*todos(where="status='Archive'"), id='archive-list', cols=1) + +def mk_todo_list(user_name): + un_filtered_todos = f"lower(owner) LIKE '%{user_name.lower()}%' OR lower(collaborators) LIKE '%{user_name.lower()}%' OR lower(notifiers) LIKE '%{user_name.lower()}%'" + def mk_status_card(status): + return Card(*todos(where=f"status='{status}' and ({un_filtered_todos})"), header=H3(status), body_cls='space-y-2') + return Div(Grid(*[mk_status_card(status) for status in status_categories if status != "Archive"], cols_max=3),id='todo-kanban') + +@app.delete +async def delete_todo(id:int): + try: todos.delete(id) + except NotFoundError: pass + +@rt +async def update_status(id:int, status_category:str, sess): + todo = todos.get(id) + todo.status = status_category + todos.update(todo) + return Div(mk_todo_list(sess['user_name'])),Div(mk_archive_list()(id='archive-list',hx_swap_oob=f'outerHTML:#archive-list')) + +@rt +async def edit_todo(id:int): return Card(mk_todo_form(todos.get(id))) + + +serve() diff --git a/examples/todo_series/kanban/card_thumbnail.gif b/examples/todo_series/kanban/card_thumbnail.gif new file mode 100644 index 0000000..730275e Binary files /dev/null and b/examples/todo_series/kanban/card_thumbnail.gif differ diff --git a/examples/todo_series/kanban/card_thumbnail.png b/examples/todo_series/kanban/card_thumbnail.png new file mode 100644 index 0000000..1e76e87 Binary files /dev/null and b/examples/todo_series/kanban/card_thumbnail.png differ diff --git a/examples/todo_series/kanban/info.md b/examples/todo_series/kanban/info.md new file mode 100644 index 0000000..951f1dd --- /dev/null +++ b/examples/todo_series/kanban/info.md @@ -0,0 +1,49 @@ +# Intermediate Todo App (SQLite) + +This project is a web-based implementation of an intermediate todo app built using FastHTML, HTMX, and SQLite. This builds on the minimal todo app by adding a due date and a done field to todos. Functionality related to these new fields has been added to the app. + +## Key Technologies and Techniques + +1. **FastHTML**: A Python-based framework for building web applications with a focus on web fundamentals. +2. **HTMX**: Used to create dynamic server-side content updates that let you interact with the app without page reloads. +3. **SQLite**: A lightweight, serverless database used to store and manage todo items. +4. **FastSQL**: A library that simplifies database operations and integrates well with FastHTML. +5. **MonsterUI**: A library that creates modern UI components for FastHTML + +## How It Works + +### Server-Side Logic + +The app uses FastHTML to define routes and handle todo list operations. Key routes include: + +- `GET /`: The main page that renders the initial todo list. +- `POST /`: Handles adding new todo items. +- `DELETE /{id}`: Handles deleting todo items. + +### Data Management + +Todo items are stored in an SQLite database: + +- `todos`: A table storing todo items with `id`, `title`, `done`, and `due`fields. + +### Dynamic Content + +HTMX is used to create a dynamic user interface: + +- `hx-post` attribute on the form triggers a POST request to add new todos. +- `hx-delete` attribute on delete links triggers DELETE requests to remove todos. +- `hx-target` specifies where the response from the server should be inserted. +- `hx-swap` determines how the new content should be added or replaced. + + `beforeend`: Adds the new content at the end of the target element. This is used to add the new list item to end of the todo list. + + `outerHTML`: Replaces the entire target element with the new content. This is used to replaces the todo list item completely with `None` to remove it from the list. + +### Key Features + +1. **Add Todo**: Users can add new todos using a form at the top of the list. +2. **Delete Todo**: Each todo item has a delete link to remove it from the list. +3. **Real-time Updates**: The list updates dynamically without full page reloads. +4. **Persistent Storage**: Todos are stored in an SQLite database for data persistence. +5. **Due Date**: Each todo item has a due date field and the list is sorted by due date. If the item is past due the date is displayed in red. +6. **Done**: Each todo item has a done field. Items can be marked as done and the list shows completed items crossed out. +7. **MonsterUI**: Simple styling is done using the MonsterUI library. +8. **Edit Todo**: Each todo item has an edit link to edit the item. The edit form is displayed in a card and the todo list is updated with the new values. diff --git a/examples/todo_series/kanban/metadata.ini b/examples/todo_series/kanban/metadata.ini new file mode 100644 index 0000000..ce8f499 --- /dev/null +++ b/examples/todo_series/kanban/metadata.ini @@ -0,0 +1,4 @@ +[REQUIRED] +ImageAltText=Intermediate Todo application +ComponentName=Intermediate Todo App (SQLite) +ComponentDescription=An intermediate todo app using SQLite backend.