DocsUnidad 4: interactividad

Cree una aplicación de notas con Nexy, parte 14: marque una nota sin recargar la página

La parte 14 agrega un botón Listo a cada nota. Es un formulario que funciona sin JavaScript, y unas pocas líneas de JavaScript hacen que se guarde en su lugar, sin recargar la página.

Antes de comenzar: la aplicación de la parte 13.

Una nota está hecha o no.

src/modelos/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"})

La tabla ya tiene notas y SQLite no puede agregar una columna que deba tener un valor sin uno para colocar en las filas existentes. server_default lo da: la migración escribe DEFAULT 0, y cada nota comienza como no terminada.

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

Voltéelo en la base de datos, detrás de una acción.

src/notas.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/notas.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/acciones/notas.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 devuelve el nuevo estado, un bool. Un formulario publicado en él devuelve el 303 habitual a la página; un script que solicita JSON obtiene true o false. Una acción sirve a ambos.

Un botón que es un formulario.

La tarjeta muestra el estado que conoce el servidor y publica la identificación de su nota:

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 le dice al lector de pantalla que el botón es un interruptor y si está activado.

Guardar sin recargar

La lista pasa done a cada tarjeta y agrega un script para todas las tarjetas de la página:

src/rutas/notas/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>

El script escucha todas las formas de la página y solo toma las formas marcadas data-toggle. Envía el mismo note_id que enviaría el formulario, como JSON, y actualiza el botón con la respuesta. Si la llamada falla, permite que el formulario se publique de la manera normal, por lo que el clic nunca se pierde.

¿Por qué no una isla React, como en la parte 13? El servidor conoce el estado de cada nota en cada solicitud, y un formulario ya lo muestra correctamente, antes de que se ejecute cualquier script. El script solo cambia la forma en que se envía el formulario. Eso es mejora progresiva: la página funciona sin JavaScript, y mejor con él.

Abra http://localhost:3000/notes y presione Para hacer en una nota: pasa a Listo, la nota está tachada y la página no se vuelve a cargar. Vuelve a cargarlo: la nota aún está hecha.

lo que aprendiste

  • server_default permite que una migración agregue una columna requerida a una tabla que tiene filas.
  • One Action responde a un formulario con 303 y un script que solicita JSON con su valor de retorno.
  • Mejora progresiva: un formulario que funciona solo y un script que lo envía en su lugar.

Próximos pasos

  • Unidad 5: más allá de la petición (próximamente)
  • Acciones: llamadas tecleadas desde el navegador, errores y seguridad

Editar en GitHub

Enter abre el resultado, Esc lo cierra.