5: Voting
A poll without votes is a list. In this chapter every option becomes a form, a POST route receives the vote, the count goes up, and a redirect re-renders the page. Plain HTTP, no JavaScript yet: this version works in any browser, with everything off.
5.1: The vote forms
In poll.phlo, turn each button in the choices view into a tiny form:
view choices:
<section.card>
<foreach type_poll::records() AS $option>
<form method=post action="/poll/vote/$option->id">
<button>$option->option</button>
</form>
</foreach>
</section>
Each form posts to its own URL: /poll/vote/1, /poll/vote/2, and so on. The id travels in the path, so the form needs no hidden fields. Note the quotes around the action value: attributes containing variables or slashes must be quoted. The form: display: inline rule from chapter 3 keeps the buttons on one row.
Reload http://localhost/poll: looks identical, but every button now submits a form. Clicking one gives a 404, because the route does not exist yet.
5.2: The POST route
Add the route to poll.phlo, near the GET route:
route POST poll vote $id {
if (!$option = type_poll::record(id: (int)$id)) return false
type_poll::change('id=?', (int)$id, votes: $option->votes + 1)
location('/poll')
}
Read it top to bottom:
route POST poll vote $idmatchesPOST /poll/vote/<id>. A$segmentin the path is a variable, available inside the block.type_poll::record(id: ...)fetches one record or null. Returning exactfalsesignals a route miss: an unknown id falls through and ends as a 404 instead of corrupting data.type_poll::change('id=?', $id, votes: ...)updates the record through the model: a where clause, its values, then named columns. With JSONDB you always write through the static model methods.location('/poll')redirects. POST, redirect, GET: refresh-safe voting since 2004.
Reload and click Phlo. The page reloads, the count reads 1, and the bar jumps to 100%. Click another option and watch the percentages redistribute. Check data/poll.json to see the votes on disk.
5.3: Reading a payload instead
The id in the path is one style. The other is a request body, which you read through %payload. First enable the resource in data/app.json:
{
"resources": [
"DB/DB",
"DB/model",
"DB/JSONDB",
"DB/JSON.result",
"payload"
]
}
A payload-based version of the same route looks like this:
route POST poll vote @option {
$id = (int)%payload->option
if (!$option = type_poll::record(id: $id)) return false
type_poll::change('id=?', $id, votes: $option->votes + 1)
location('/poll')
}
@option validates the request body: the payload must contain exactly the key option, or the route does not match. Body keys are never bound as parameters; you read them via %payload->option. The matching form would be one form with <button name=option value="$option->id"> per choice.
Both styles are idiomatic. Keep the $id variant from X.2 for this tutorial; it transitions cleanly into the async version.
5.4: What you have now
Run the checks once more:
php www/app.php build::run
php www/app.php build::lint
Both settle at []. You have a complete, working poll: server-rendered, refresh-safe, zero JavaScript. Vote a few times, watch data/poll.json change, and notice the one thing that still feels 2004: the full page reload on every vote. That is the next chapter.