htmx-recepten: formulier, verversen, verwijderen, zoeken
Met hx-get en hx-target haalde je een zin op zonder herladen. Hetzelfde idee werkt voor alles wat je tot nu toe via een herlaad deed. Hieronder staat per recept de HTML en het endpoint dat erbij hoort. Kies wat je gastenboek nodig heeft; je hoeft ze niet allemaal te bouwen.
Voor elk recept geldt hetzelfde. De pagina met de hx--attributen heeft de script-tag van htmx in de <head>, net als home.html in Zonder herladen met htmx: zonder die regel doet een knop niets en verstuurt een formulier zichzelf als GET. Het endpoint geeft een stukje HTML terug: een zin in een HTMLResponse, of een template zonder <html> eromheen. En het geeft nooit een omleiding, want htmx volgt die en zet dan de hele pagina in het doel.
Een formulier versturen
Het gastenboekformulier zonder herladen. Het staat in static/pages/gastenboek_form.html uit Elk bericht bewaren: een eigen sleutel, en dat bestand heeft nog geen <head>. Zet er een <head> met de script-tag bij, boven de <body>:
<head>
<script src="/static/js/htmx.min.js"></script>
</head>
Vervang in de <form>-tag method en action door hx-post en hx-target, en zet onder het formulier een lege <p> erbij. De velden en de knop blijven zoals ze waren:
<form hx-post="/gastenboek" hx-target="#antwoord">
<input type="text" name="naam" placeholder="Je naam" required>
<input type="password" name="wachtwoord" placeholder="Je wachtwoord" required>
<input type="text" name="bericht" placeholder="Je bericht" required>
<button type="submit">Verstuur</button>
</form>
<p id="antwoord"></p>
In het endpoint uit Inloggen: een wachtwoord controleren verandert één regel: vervang de return.
@app.post("/gastenboek")
async def gastenboek_opslaan(
naam: str = Form(...),
wachtwoord: str = Form(...),
bericht: str = Form(...),
):
with SqliteDict("gebruikers.db") as gebruikers:
opgeslagen = gebruikers.get(naam)
if opgeslagen != wachtwoord:
raise HTTPException(status_code=401, detail="Naam of wachtwoord klopt niet")
with SqliteDict("gastenboek.db") as db:
db[f"bericht_{time.time_ns()}"] = {"naam": naam, "bericht": bericht}
db.commit()
return HTMLResponse(f"Bedankt, {escape(naam)}. Je bericht staat in het gastenboek.")
Zet from html import escape bovenaan je main.py. De naam komt van de bezoeker, en in een f-string komt hij letterlijk in je HTML: typt iemand <b>Sara</b>, dan staat er een vette naam. escape maakt er gewone tekst van. Een template doet dat vanzelf; een f-string niet. Meer hierover in HTML van een bezoeker.
De Form-parameters en de controle van het wachtwoord blijven hetzelfde; voor je endpoint is er geen verschil met een formulier zonder htmx. De omleiding met 303 is niet meer nodig, want de browser is nooit weggegaan.
Klopt het wachtwoord niet, dan antwoordt je endpoint met een 401, net als zonder htmx. Een antwoord met een foutcode zet htmx niet in de pagina: er gebeurt zichtbaar niets. In het tabblad Netwerk staat het verzoek dan in het rood, met de melding bij Reactie.
Zet je hx-on::after-request="this.reset()" op de <form>, dan maakt htmx de velden leeg zodra het antwoord binnen is. Dat doet hij ook als het antwoord een fout is, zoals een 401: wat de bezoeker typte, is dan weg. Zet je later een Content-Security-Policy op je site, dan werkt dit attribuut niet meer; wat je dan doet, staat in HTML van een bezoeker: in de praktijk.
Een stuk van de pagina verversen
Een knop die de lijst met berichten opnieuw ophaalt. Het antwoord is nu geen zin maar een lijst, dus die krijgt een eigen template, templates/berichten_lijst.html, zonder <html> of <head>. Knip het hele {% if berichten %}-blok uit je berichten.html en zet het in dat nieuwe bestand. Neem alles mee wat je lijst al had: de link naar de detailpagina uit Eén item tonen en het formuliertje om te verwijderen uit Een bericht verwijderen: een verborgen veld. Zo ziet het er dan ongeveer uit:
{% if berichten %}
<ul>
{% for sleutel, bericht in berichten %}
<li>
<a href="/bericht/{{ sleutel }}">{{ bericht.naam }}</a>: {{ bericht.bericht }}
<form method="post" action="/verwijderen">
<input type="hidden" name="sleutel" value="{{ sleutel }}">
<button type="submit">Verwijderen</button>
</form>
</li>
{% endfor %}
</ul>
{% else %}
<p>Nog geen berichten. Wees de eerste.</p>
{% endif %}
Het endpoint is nieuw, met een eigen pad:
@app.get("/berichten/lijst")
async def berichten_lijst(request: Request):
with SqliteDict("gastenboek.db") as db:
alle_berichten = list(db.items())
return templates.TemplateResponse(
request,
"berichten_lijst.html",
{"berichten": alle_berichten}
)
In berichten.html zet je de script-tag van htmx in de <head>. Op de plek waar het {% if %}-blok stond, komen de knop en een <div> met een id:
<button hx-get="/berichten/lijst" hx-target="#berichten-lijst">Ververs</button>
<div id="berichten-lijst">
{% include "berichten_lijst.html" %}
</div>
{% include %} plakt de lijst-template in de pagina, zodat de lijst maar één keer in je project staat: de pagina en het antwoord na een klik gebruiken dezelfde HTML.
Vanzelf verversen
Als anderen tegelijk berichten schrijven, wil je die zien verschijnen zonder F5. Dat gebeurt als je je server openzet voor de klas, wat je later doet in Laat het aan anderen zien. Met hx-trigger kies je wanneer het verzoek gaat:
<div id="berichten-lijst" hx-get="/berichten/lijst" hx-trigger="every 10s">
{% include "berichten_lijst.html" %}
</div>
Zonder hx-target komt het antwoord in het element zelf. Elke tien seconden komt er een regel in de terminal bij, ook als er niets veranderd is. Kies het interval naar wat de bezoeker nodig heeft: elke tien seconden is te doen, elke seconde met dertig bezoekers niet.
Verwijderen
GET haalt op, POST stuurt in. HTTP kent meer werkwoorden, en DELETE zegt precies wat het doet. Een formulier zonder htmx kan alleen GET en POST; htmx kan ze allemaal.
@app.delete("/bericht/{sleutel}")
async def bericht_verwijderen(sleutel: str):
with SqliteDict("gastenboek.db") as db:
if sleutel not in db:
raise HTTPException(status_code=404, detail="Dit bericht bestaat niet")
del db[sleutel]
db.commit()
return HTMLResponse("")
In berichten_lijst.html krijgt elke <li> een id, en vervang je het formuliertje door een knop. De link naar de detailpagina blijft staan:
<li id="bericht-{{ sleutel }}">
<a href="/bericht/{{ sleutel }}">{{ bericht.naam }}</a>: {{ bericht.bericht }}
<button hx-delete="/bericht/{{ sleutel }}"
hx-target="#bericht-{{ sleutel }}"
hx-swap="outerHTML"
hx-confirm="Dit bericht verwijderen?">Verwijderen</button>
</li>
Het antwoord is leeg, en dat is de bedoeling. hx-swap zegt hoe het antwoord in het doel komt: standaard innerHTML, alles bínnen het doel vervangen; outerHTML vervangt het doel zelf, dus met een leeg antwoord verdwijnt het hele <li>. hx-confirm laat de browser eerst de vraag stellen; bij Annuleren gaat er geen verzoek.
Zoeken terwijl je typt
Een zoekveld dat de server vraagt om berichten met een woord erin. Het komt in berichten.html, boven de <div id="berichten-lijst"> uit het verversrecept:
<input type="search" name="term" placeholder="Zoek een woord"
hx-get="/berichten/lijst" hx-target="#berichten-lijst"
hx-trigger="keyup changed delay:300ms">
Een <input> met hx-get stuurt zijn eigen waarde mee als query-parameter, dus /berichten/lijst?term=hoi. Vervang het endpoint van hierboven door deze versie, met een parameter erbij:
@app.get("/berichten/lijst")
async def berichten_lijst(request: Request, term: str = ""):
with SqliteDict("gastenboek.db") as db:
alle_berichten = list(db.items())
gevonden = []
for sleutel, bericht in alle_berichten:
if term.lower() in bericht["bericht"].lower():
gevonden.append((sleutel, bericht))
return templates.TemplateResponse(
request,
"berichten_lijst.html",
{"berichten": gevonden, "term": term}
)
De for-lus houdt alleen de berichten waar de zoekterm in staat. lower() maakt van een tekst kleine letters, zodat Hoi ook gevonden wordt als je hoi typt. delay:300ms wacht tot je even stopt met typen, anders gaat er bij elke letter een verzoek. En changed slaat toetsen over die de tekst niet veranderen, zoals de pijltjes.
Vindt de server niets, dan komt het {% else %}-blok in beeld. Daar staat "Nog geen berichten. Wees de eerste.", en dat klopt niet als er wel berichten zijn maar geen enkele past. Daarom gaat term mee naar de template. Vervang in berichten_lijst.html het {% else %}-blok door:
{% else %}
{% if term %}
<p>Geen bericht met "{{ term }}".</p>
{% else %}
<p>Nog geen berichten. Wees de eerste.</p>
{% endif %}
{% endif %}
Op /berichten en na Ververs is er geen term, en zie je de oude zin.
Zoeken en vanzelf verversen gaan niet samen: de <div> die elke tien seconden ververst, stuurt geen term mee, en zet dus steeds weer de hele lijst neer. Kies er één van de twee.
Waar het antwoord komt
| Attribuut | Betekenis | Zonder |
|---|---|---|
hx-target="#id" | het element dat het antwoord krijgt | het element met het attribuut zelf |
hx-swap="innerHTML" | vervang wat er in het doel staat | dit is de standaard |
hx-swap="outerHTML" | vervang het doel zelf | |
hx-swap="beforeend" | zet het antwoord achter wat er al staat |
Er gaat iets mis
Bij htmx zie je fouten niet op de pagina. Een 404, een 500 of een server die uit staat: htmx laat het doel dan met rust, en op het scherm gebeurt niets. Kijk daarom bij elk probleem eerst in het tabblad Netwerk. htmx meldt de fout ook in het tabblad Console, met een rode regel als Response Status Error Code 404 from /bericht/…; dat tabblad leer je kennen in Console en Elementen. Een rode regel in Netwerk zegt wat er mis is: 404 is een verkeerd pad in hx-get of hx-post, 500 een fout in je endpoint (kijk in de terminal), (mislukt) een server die niet draait, 405 een @app.post waar hx-delete op wijst.
Laat je server draaien en typ in een tweede terminal python controleer.py zonder-herladen. Het script kijkt of /tijd en de recepten die je bouwde een stukje HTML teruggeven, zonder omleiding; je downloadt het op Project checken.
Opdrachten
Opdracht 1: Predict - De omleiding
Je zet het gastenboekformulier om naar hx-post, maar laat in het endpoint return RedirectResponse(url="/berichten", status_code=303) staan.
Opdracht 2: Run - Het formulier
Zet je gastenboekformulier om naar hx-post, zoals in het eerste recept. Verstuur een bericht en kijk in de terminal: één regel, geen omleiding en geen tweede GET.
Opdracht 3: Investigate - Twee keer verwijderen
Bouw het recept Verwijderen. Open /berichten in twee tabbladen, verwijder hetzelfde bericht in het ene, en daarna in het andere.
Antwoord
In het tweede tabblad gebeurt niets: het bericht blijft staan. In Netwerk staat een rode regel met 404, want je endpoint zei "Dit bericht bestaat niet". htmx laat de pagina met rust bij een fout, dus de bezoeker ziet een bericht dat er niet meer is, tot hij op F5 drukt. Dat is de keerzijde van niet herladen: de pagina kan achterlopen op de server.
Opdracht 4: Make - Kies er twee
Kies twee recepten uit dit overzicht en bouw ze in je gastenboek. Schrijf bij allebei in één zin waarom je die koos.
Tip
Verversen en verwijderen horen bij elkaar: als je kunt verwijderen wil je ook dat de lijst klopt. Het formulier staat los van de rest. Zoeken heeft de <div id="berichten-lijst"> uit het verversrecept nodig, maar gaat niet samen met vanzelf verversen.
Antwoord
Er is geen fout antwoord, wel een valkuil: een recept verandert alleen zijn eigen doel, de rest van de pagina blijft staan. Verstuur je een bericht met het formulierrecept, dan staat het pas in de lijst na Ververs of F5, want het formulier staat op /gastenboek en de lijst op /berichten. Verwijderen haalt alleen dat ene <li> weg; wat een ander intussen schreef, zie je pas na Ververs. Verversen en verwijderen samen houden de lijst dus kloppend.
Opdracht 5: Make - Alles wissen
Maak een knop "Alles wissen" die het hele gastenboek leegt, na een bevestiging, en de lege lijst toont.
Tip
Een DELETE-endpoint op /berichten dat alle sleutels weghaalt. Het doel is de hele lijst, en het antwoord is de lijst-template met een lege lijst.
Antwoord
@app.delete("/berichten")
async def alles_wissen(request: Request):
with SqliteDict("gastenboek.db") as db:
for sleutel in list(db.keys()):
del db[sleutel]
db.commit()
return templates.TemplateResponse(request, "berichten_lijst.html", {"berichten": []})
<button hx-delete="/berichten" hx-target="#berichten-lijst" hx-confirm="Alle berichten verwijderen?">Alles wissen</button>
De list() om db.keys() maakt eerst een vaste lijst van de sleutels, en daarna haal je ze één voor één weg. Bij een gewone dictionary is dat nodig: verwijderen terwijl je erdoorheen loopt geeft daar een RuntimeError. SqliteDict klaagt niet, maar met list() hoef je daar niet over na te denken.