文档第四单元:互动性

使用 Nexy 构建笔记应用程序,第 14 部分:勾选笔记而不重新加载页面

第 14 部分为每个音符添加了 Done 按钮。它是一种无需 JavaScript 即可使用的表单,几行 JavaScript 使其保存在适当的位置,而无需重新加载页面。

开始之前:第 13 部分的应用程序。

注释完成或未完成

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"})

该表已经有注释,并且 SQLite 无法添加必须有值但没有值放入现有行的列。 server_default 给出:迁移写入 DEFAULT 0,并且每个注释都以未完成的方式开始。

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

将其翻转到数据库中,位于 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 返回新状态,bool。发布到它的表单将通常的 303 返回到页面;请求 JSON 的脚本获取 true 或 false。一项行动服务于两者。

一个按钮就是一个表单

该卡片显示服务器知道的状态,并发布其注释的 id:

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 告诉屏幕阅读器该按钮是一个切换按钮,以及它是否打开。

保存而不重新加载

该列表将 done 传递给每张卡片,并为该页面的所有卡片添加一个脚本:

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>

该脚本侦听页面的每种形式,并且仅采用标记为 data-toggle 的形式。它发送与表单相同的 note_id,如 JSON,并用答案更新按钮。如果调用失败,它会让表单以正常方式发布,因此点击永远不会丢失。

为什么不是像第 13 部分中那样的 React 岛?服务器在每次请求时都知道每个注释的状态,并且在任何脚本运行之前,表单已经正确显示了它。该脚本仅更改表单的发送方式。这就是“渐进增强”:页面在没有 JavaScript 的情况下也能正常工作,并且使用它会更好。

打开 http://localhost:3000/notes 并按注释上的 To do:它会变成 Done,注释被删除,并且页面不会重新加载。重新加载:笔记仍然完成。

你学到了什么

  • server_default 允许迁移将所需的列添加到包含行的表中。
  • 一个操作使用 303 响应表单,以及一个请求 JSON 及其返回值的脚本。
  • 渐进增强:单独工作的表单,以及将其发送到位的脚本。

后续步骤

  • 第五单元:超越要求(下一个)
  • 操作:来自浏览器的键入调用、错误和安全性

在 GitHub 上编辑

Enter 打开结果,Esc 关闭。