DocsUnité 4 : interactivité

Créer une application de notes avec Nexy, partie 14 : cocher une note sans recharger la page

La partie 14 ajoute un bouton Terminé à chaque note. C'est un formulaire qui fonctionne sans JavaScript, et quelques lignes de JavaScript le font sauvegarder sur place, sans recharger la page.

Avant de commencer : l'application de la partie 13.

Une note est faite, ou pas

src/models/note_model.py
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"})
src/apps/notes/note_model.py
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"})

Le tableau contient déjà des notes et SQLite ne peut pas ajouter une colonne qui doit avoir une valeur sans une à insérer dans les lignes existantes. server_default le donne : la migration écrit DEFAULT 0, et chaque note commence comme non terminée.

nx migrate -m "notes can be done"nx migrate --upgrade

Retournez-le dans la base de données, derrière une Action

src/notes.py
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.done
src/notes.py
from 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.done
src/actions/notes.py
from 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 done

toggle_note renvoie le nouvel état, un bool. Un formulaire qui y est posté renvoie le 303 habituel à la page ; un script qui demande du JSON obtient true ou false. Une action sert les deux.

Un bouton qui est un formulaire

La carte indique l'état connu du serveur et affiche l'identifiant de sa note :

src/components/NoteCard.nexy
---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 indique à un lecteur d'écran que le bouton est une bascule et s'il est activé.

Enregistrer sans recharger

La liste passe done à chaque carte, et ajoute un script pour toutes les cartes de la page :

src/routes/notes/index.nexy
---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>
src/apps/notes/notes_view.nexy
---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>

Le script écoute toutes les formes de la page et prend uniquement les formes marquées data-toggle. Il envoie le même note_id que le formulaire, en JSON, et met à jour le bouton avec la réponse. Si l'appel échoue, le formulaire est publié normalement, de sorte que le clic n'est jamais perdu.

Pourquoi pas une île React, comme dans la partie 13 ? L'état de chaque note est connu du serveur à chaque requête, et un formulaire l'affiche déjà correctement, avant l'exécution de tout script. Le script modifie uniquement la façon dont le formulaire est envoyé. C'est une amélioration progressive : la page fonctionne sans JavaScript, et mieux avec.

Ouvrez http://localhost:3000/notes et appuyez sur Pour faire sur une note : il devient Terminé, la note est barrée et la page ne se recharge pas. Rechargez-le : la note est toujours terminée.

Ce que tu as appris

  • server_default permet à une migration d'ajouter une colonne requise à une table comportant des lignes.
  • One Action répond à un formulaire avec un 303 et un script demandant JSON avec sa valeur de retour.
  • Amélioration progressive : un formulaire qui fonctionne seul, et un script qui l'envoie en place.

Prochaines étapes

  • Unité 5 : au-delà de la demande (à venir)
  • Actions : appels tapés depuis le navigateur, erreurs et sécurité

Modifier sur GitHub

Entrée ouvre le résultat, Échap ferme.