DocsUnit 4: interactivity
Build a notes app with Nexy, part 14: tick a note without reloading the page
Part 14 adds a Done button to each note. It is a form that works with no JavaScript, and a few lines of JavaScript make it save in place, without reloading the page.
Before you start: the app of part 13.
A note is done, or not
from sqlmodel import Field, SQLModel class Note(SQLModel, table=True): id: int | None = Field(default=None, primary_key=True) text: str user_id: int | None = Field(default=None, foreign_key="user.id", index=True) done: bool = Field(default=False, sa_column_kwargs={"server_default": "0"})from sqlmodel import Field, SQLModel class Note(SQLModel, table=True): id: int | None = Field(default=None, primary_key=True) text: str user_id: int | None = Field(default=None, foreign_key="user.id", index=True) done: bool = Field(default=False, sa_column_kwargs={"server_default": "0"})The table already has notes, and SQLite cannot add a column that must have a value without one to
put in the existing rows. server_default gives it: the migration writes DEFAULT 0, and every
note starts as not done.
nx migrate -m "notes can be done"nx migrate --upgrade
Flip it in the database, behind an Action
from sqlmodel import Session, select from src.configs.db import enginefrom src.models.note_model import Note PAGE_SIZE = 5 def notes_page(user_id: int, number: int) -> tuple[list[Note], bool]: """One page of the notes of *user_id*, oldest first, and whether a page comes after it.""" query = select(Note).where(Note.user_id == user_id).order_by(Note.id) with Session(engine) as session: found = list(session.exec(query.offset((number - 1) * PAGE_SIZE).limit(PAGE_SIZE + 1))) return found[:PAGE_SIZE], len(found) > PAGE_SIZE def all_notes(user_id: int) -> list[Note]: with Session(engine) as session: return list(session.exec(select(Note).where(Note.user_id == user_id).order_by(Note.id))) def find(user_id: int, note_id: int) -> Note | None: """The note, when it belongs to *user_id*: someone else's note is not found.""" with Session(engine) as session: note = session.get(Note, note_id) return note if note is not None and note.user_id == user_id else None def add_note(user_id: int, text: str) -> Note: note = Note(text=text, user_id=user_id) with Session(engine) as session: session.add(note) session.commit() session.refresh(note) return note def change_note(user_id: int, note_id: int, text: str) -> bool: with Session(engine) as session: note = session.get(Note, note_id) if note is None or note.user_id != user_id: return False note.text = text session.commit() return True def remove_note(user_id: int, note_id: int) -> bool: with Session(engine) as session: note = session.get(Note, note_id) if note is None or note.user_id != user_id: return False session.delete(note) session.commit() return True def toggle_done(user_id: int, note_id: int) -> bool | None: """The new state of the note, or None when the user has no such note.""" with Session(engine) as session: note = session.get(Note, note_id) if note is None or note.user_id != user_id: return None note.done = not note.done session.commit() return note.donefrom sqlmodel import Session, select from src.apps.notes.note_model import Notefrom src.configs.db import engine PAGE_SIZE = 5 def notes_page(user_id: int, number: int) -> tuple[list[Note], bool]: """One page of the notes of *user_id*, oldest first, and whether a page comes after it.""" query = select(Note).where(Note.user_id == user_id).order_by(Note.id) with Session(engine) as session: found = list(session.exec(query.offset((number - 1) * PAGE_SIZE).limit(PAGE_SIZE + 1))) return found[:PAGE_SIZE], len(found) > PAGE_SIZE def all_notes(user_id: int) -> list[Note]: with Session(engine) as session: return list(session.exec(select(Note).where(Note.user_id == user_id).order_by(Note.id))) def find(user_id: int, note_id: int) -> Note | None: """The note, when it belongs to *user_id*: someone else's note is not found.""" with Session(engine) as session: note = session.get(Note, note_id) return note if note is not None and note.user_id == user_id else None def add_note(user_id: int, text: str) -> Note: note = Note(text=text, user_id=user_id) with Session(engine) as session: session.add(note) session.commit() session.refresh(note) return note def change_note(user_id: int, note_id: int, text: str) -> bool: with Session(engine) as session: note = session.get(Note, note_id) if note is None or note.user_id != user_id: return False note.text = text session.commit() return True def remove_note(user_id: int, note_id: int) -> bool: with Session(engine) as session: note = session.get(Note, note_id) if note is None or note.user_id != user_id: return False session.delete(note) session.commit() return True def toggle_done(user_id: int, note_id: int) -> bool | None: """The new state of the note, or None when the user has no such note.""" with Session(engine) as session: note = session.get(Note, note_id) if note is None or note.user_id != user_id: return None note.done = not note.done session.commit() return note.donefrom urllib.parse import urlencode from nexy import notFoundfrom nexy.decorators import Actionfrom nexy.http import RedirectResponsefrom src.guard import signed_in_userfrom src.notes import add_note, change_note, remove_note, toggle_done MAX_LENGTH = 200 def back(path: str = "/notes", error: str = "") -> RedirectResponse: query = f"?{urlencode({'error': error})}" if error else "" return RedirectResponse(f"{path}{query}", status_code=303) def problem(text: str) -> str: if not text: return "A note cannot be blank." if len(text) > MAX_LENGTH: return f"A note is at most {MAX_LENGTH} characters." return "" @Actionasync def create_note(text: str) -> RedirectResponse: user_id = signed_in_user() text = text.strip() if error := problem(text): return back(error=error) add_note(user_id, text) return back() @Actionasync def update_note(note_id: int, text: str) -> RedirectResponse: user_id = signed_in_user() text = text.strip() if error := problem(text): return back(f"/notes/{note_id}", error) if not change_note(user_id, note_id, text): notFound() return back(f"/notes/{note_id}") @Actionasync def delete_note(note_id: int) -> RedirectResponse: if not remove_note(signed_in_user(), note_id): notFound() return back() @Actionasync def toggle_note(note_id: int) -> bool: done = toggle_done(signed_in_user(), note_id) if done is None: notFound() return donetoggle_note returns the new state, a bool. A form posted to it gets the usual 303 back to the
page; a script that asks for JSON gets true or false. One Action serves both.
A button that is a form
The card shows the state the server knows, and posts the id of its note:
id: prop[int]text: prop[str]done: prop[bool]<article class="note{% if done %} note-done{% endif %}"> <a href="/notes/{{ id }}">{{ text | e }}</a> <form method="post" action="{{ __actions_path['toggle_note'] }}" data-toggle> <input type="hidden" name="note_id" value="{{ id }}"> <button type="submit" aria-pressed="{{ 'true' if done else 'false' }}">{{ 'Done' if done else 'To do' }}</button> </form></article> <style> .note { display: flex; align-items: center; justify-content: space-between; gap: 1rem; padding: 0.75rem 1rem; border: 1px solid #ddd; border-radius: 0.5rem; } .note-done a { text-decoration: line-through; opacity: 0.6; }</style>aria-pressed tells a screen reader that the button is a toggle, and whether it is on.
Save without reloading
The list passes done to each card, and adds one script for all the cards of the page:
from "@/components/NoteCard.nexy" import NoteCardfrom "@/components/NoteSearch.tsx" import NoteSearchfrom nexy import useSearchParamsfrom src.guard import signed_in_userfrom src.notes import all_notes, notes_page user_id = signed_in_user()params = useSearchParams()error = params.get("error", "")asked = params.get("page", "1")number = max(int(asked), 1) if asked.isdigit() else 1found = notes_page(user_id, number)notes = found[0]more = found[1]searchable = [{"id": note.id, "text": note.text} for note in all_notes(user_id)]<section class="app-page"> <h1>Notes</h1> <form method="post" action="{{ __actions_path['sign_out'] }}"> <button type="submit">Sign out</button> </form> {% if error %} <p class="note-error" role="alert">{{ error | e }}</p> {% endif %} <NoteSearch notes="{{ searchable }}" directive="client:idle" /> <form method="post" action="{{ __actions_path['create_note'] }}"> <input name="text" placeholder="A new note" maxlength="200" required> <button type="submit">Add</button> </form> {% for note in notes %} <NoteCard id="{{ note.id }}" text="{{ note.text }}" done="{{ note.done }}" /> {% else %} <p>No notes yet: write the first one above.</p> {% endfor %} <nav class="note-pages" aria-label="Pages of notes"> {% if number > 1 %}<a href="/notes?page={{ number - 1 }}" rel="prev">Newer</a>{% endif %} {% if more %}<a href="/notes?page={{ number + 1 }}" rel="next">Older</a>{% endif %} </nav> <script type="module"> // With JavaScript on, the Done buttons save without leaving the page. document.addEventListener("submit", async (event) => { const form = event.target.closest("form[data-toggle]") if (!form) return event.preventDefault() const button = form.querySelector("button") button.disabled = true const answer = await fetch(form.action, { method: "POST", headers: { "content-type": "application/json", accept: "application/json" }, body: JSON.stringify({ note_id: Number(form.elements.note_id.value) }), }) button.disabled = false if (!answer.ok) return form.submit() const done = await answer.json() button.setAttribute("aria-pressed", String(done)) button.textContent = done ? "Done" : "To do" form.closest(".note").classList.toggle("note-done", done) }) </script></section>from "@/components/NoteCard.nexy" import NoteCardfrom "@/components/NoteSearch.tsx" import NoteSearchfrom nexy import useSearchParamsfrom src.guard import signed_in_userfrom src.notes import all_notes, notes_page user_id = signed_in_user()params = useSearchParams()error = params.get("error", "")asked = params.get("page", "1")number = max(int(asked), 1) if asked.isdigit() else 1found = notes_page(user_id, number)notes = found[0]more = found[1]searchable = [{"id": note.id, "text": note.text} for note in all_notes(user_id)]<section class="app-page"> <h1>Notes</h1> <form method="post" action="{{ __actions_path['sign_out'] }}"> <button type="submit">Sign out</button> </form> {% if error %} <p class="note-error" role="alert">{{ error | e }}</p> {% endif %} <NoteSearch notes="{{ searchable }}" directive="client:idle" /> <form method="post" action="{{ __actions_path['create_note'] }}"> <input name="text" placeholder="A new note" maxlength="200" required> <button type="submit">Add</button> </form> {% for note in notes %} <NoteCard id="{{ note.id }}" text="{{ note.text }}" done="{{ note.done }}" /> {% else %} <p>No notes yet: write the first one above.</p> {% endfor %} <nav class="note-pages" aria-label="Pages of notes"> {% if number > 1 %}<a href="/notes?page={{ number - 1 }}" rel="prev">Newer</a>{% endif %} {% if more %}<a href="/notes?page={{ number + 1 }}" rel="next">Older</a>{% endif %} </nav> <script type="module"> // With JavaScript on, the Done buttons save without leaving the page. document.addEventListener("submit", async (event) => { const form = event.target.closest("form[data-toggle]") if (!form) return event.preventDefault() const button = form.querySelector("button") button.disabled = true const answer = await fetch(form.action, { method: "POST", headers: { "content-type": "application/json", accept: "application/json" }, body: JSON.stringify({ note_id: Number(form.elements.note_id.value) }), }) button.disabled = false if (!answer.ok) return form.submit() const done = await answer.json() button.setAttribute("aria-pressed", String(done)) button.textContent = done ? "Done" : "To do" form.closest(".note").classList.toggle("note-done", done) }) </script></section>The script listens to every form of the page, and only takes the forms marked data-toggle. It
sends the same note_id the form would, as JSON, and updates the button with the answer. If the
call fails, it lets the form post the normal way, so the click is never lost.
Why not a React island, as in part 13? The state of each note is known by the server at each request, and a form already shows it right, before any script runs. The script only changes how the form is sent. That is progressive enhancement: the page works without JavaScript, and better with it.
Open http://localhost:3000/notes and press To do on a note: it turns to Done, the note is struck through, and the page does not reload. Reload it: the note is still done.
What you learned
server_defaultlets a migration add a required column to a table that has rows.- One Action answers a form with a
303, and a script asking for JSON with its return value. - Progressive enhancement: a form that works alone, and a script that sends it in place.
Next steps
- Unit 5: beyond the request (coming next)
- Actions: typed calls from the browser, errors and security