Updating State (toggle & edit)
Goal: Let users flip a Todo between active and completed and edit its text. You'll meet
#update
, #set
, stronger #param
validation and conditional #if
.
Estimated time: 20 minutes.
1 What we're building
- Before: you can list and add Todos (Parts 1-2).
- After: you can toggle completion, double-click to edit text, bulk-toggle all, and watch counts update live.
2 Extend templates/todos.pageql
2.1 Computed counts & flags
{{#set active_count COUNT(*) from todos WHERE completed = 0}} {{#set completed_count COUNT(*) from todos WHERE completed = 1}} {{#set total_count COUNT(*) from todos}} {{#set all_complete (:active_count == 0 AND :total_count > 0)}} {{#param edit_id type=integer optional}}
{{#set active_count COUNT(*) from todos WHERE completed = 0}} {{#set completed_count COUNT(*) from todos WHERE completed = 1}} {{#set total_count COUNT(*) from todos}} {{#set all_complete (:active_count == 0 AND :total_count > 0)}} {{#param edit_id type=integer optional}}
The #set
directive calculates values that will be used throughout the template. Here we're computing counts of todos in various states (active, completed, total) and deriving a flag to indicate if all todos are complete. These variables are scoped to the current template or partial.
Note how the all_complete
flag uses the colon syntax :variable
to reference other variables in expressions. In PageQL, the colon prefix is required when using variables in SQL-like expressions to distinguish them from column names and prevent SQL injection. :active_count
refers to the previously set variable.
The #param
directive declares and validates incoming request parameters. Here, edit_id
is declared as an optional integer parameter that will be used to track which todo is being edited.
2.2 Toggle checkbox inside the list
<li {{#if completed}}class="completed"{{/if}}> <form method="POST" action="/todos/{{id}}/toggle" style="display:inline"> <input class="toggle" type="checkbox" {{#if completed}}checked{{/if}} onchange="this.form.submit();"> </form> <label ondblclick="window.location='/todos?edit_id={{id}}'">{{text}}</label> </li>
<li {{#if completed}}class="completed"{{/if}}> <form method="POST" action="/todos/{{id}}/toggle" style="display:inline"> <input type="hidden" name="id" value="{{id}}"> <input class="toggle" type="checkbox" {{#if completed}}checked{{/if}} onchange="this.form.submit();"> </form> <label ondblclick="window.location='/todos?edit_id={{id}}'">{{text}}</label> </li>
This markup creates a list item for each todo with a checkbox for toggling completion status. Note how PageQL conditional syntax #if completed
is used twice:
- To conditionally add a CSS class when a todo is completed
- To check the checkbox for completed todos
In simple variable cases like {{#if completed}}
, PageQL allows omitting the colon prefix. Inside the #from
loop (not shown here), completed
, id
, and text
become available as variables from the query results.
The ondblclick
handler redirects to the same page with an edit_id
parameter, triggering edit mode.
2.3 Edit mode (conditional)
{{#if :edit_id == :id}} <li class="editing"> <form method="POST" action="/todos/save" style="margin:0"> <input type="hidden" name="id" value="{{id}}"> <input class="edit" name="text" value="{{text}}" autofocus> </form> </li> {{#else}} …view version from 2.2… {{/if}}
{{#if :edit_id == :id}} <li class="editing"> <form method="POST" action="/todos/save" style="margin:0"> <input type="hidden" name="id" value="{{id}}"> <input class="edit" name="text" value="{{text}}" autofocus> </form> </li> {{#else}} …view version from 2.2… {{/if}}
This conditional section demonstrates how PageQL handles more complex expressions. Notice that when comparing variables (:edit_id == :id
), the colon prefix is required on both sides because we're using a complex expression rather than a simple variable check.
When a todo's ID matches the edit_id
parameter, we show an edit form instead of the regular view. This creates an "inline editing" experience without requiring JavaScript for the basic functionality.
The #else
directive provides alternative content when the condition isn't met - in this case, showing the normal view mode from section 2.2.
2.4 Toggle-all checkbox & footer counts
<form method="POST" action="/todos/toggle_all" id="toggle-all-form" style="display:block"> <input id="toggle-all" class="toggle-all" type="checkbox" {{#if all_complete}}checked{{/if}} onchange="document.getElementById('toggle-all-form').submit();"> <label for="toggle-all">Mark all as complete</label> </form> <span class="todo-count"> <strong>{{active_count}}</strong> item{{#if :active_count != 1}}s{{/if}} left </span>
<form method="POST" action="/todos/toggle_all" id="toggle-all-form" style="display:block"> <input id="toggle-all" class="toggle-all" type="checkbox" {{#if all_complete}}checked{{/if}} onchange="document.getElementById('toggle-all-form').submit();"> <label for="toggle-all">Mark all as complete</label> </form> <span class="todo-count"> <strong>{{active_count}}</strong> item{{#if :active_count != 1}}s{{/if}} left </span>
This section showcases how PageQL variables can be used to:
- Control UI state - the
all_complete
flag determines whether the toggle-all checkbox is checked - Display dynamic counts -
active_count
shows how many todos remain active - Control pluralization - adding "s" conditionally based on count (
#if :active_count != 1
)
Notice that we need the colon syntax in :active_count != 1
because we're using a comparison operator, while the simple variable reference {{active_count}}
doesn't need it when just outputting the value.
2.5 New public partials
{{#partial post :id/toggle}} {{#param id required type=integer min=1}} {{#update todos set completed = 1 - completed WHERE id = :id}} {{#redirect '/todos'}} {{/partial}} {{#partial public save}} {{#param id required type=integer min=1}} {{#param text required minlength=1}} {{#update todos set text = :text WHERE id = :id}} {{#redirect '/todos'}} {{/partial}} {{#partial public toggle_all}} {{#set active_count COUNT(*) from todos WHERE completed = 0}} {{#update todos set completed = IIF(:active_count = 0, 0, 1)}} {{#redirect '/todos'}} {{/partial}}
{{#partial post :id/toggle}} {{#param id required type=integer min=1}} {{#update todos set completed = 1 - completed WHERE id = :id}} {{#redirect '/todos'}} {{/partial}} {{#partial public save}} {{#param id required type=integer min=1}} {{#param text required minlength=1}} {{#update todos set text = :text WHERE id = :id}} {{#redirect '/todos'}} {{/partial}} {{#partial public toggle_all}} {{#set active_count COUNT(*) from todos WHERE completed = 0}} {{#update todos set completed = IIF(:active_count = 0, 0, 1)}} {{#redirect '/todos'}} {{/partial}}
These partials define server-side endpoints that handle data modifications. The public
keyword exposes them directly via HTTP POST requests, making them accessible at paths like /todos/toggle
.
Each partial follows a consistent pattern:
- Validate inputs: The
#param
directive enforces type checking and validation rules - Modify data: Using
#update
to change database records - Redirect: Send the user back to the main todo list
Note that toggle_all
needs its own active_count
variable inside its scope. In PageQL, each partial has its own variable scope if it is called as a page, so we must recalculate values needed within the partial rather than referencing those from the outer template.
The toggle
partial uses a clever SQL trick completed = 1 - completed
to flip between 0 and 1 without needing an if
statement, since 1-0=1
and 1-1=0
.
3 Walk-through
Piece | Purpose |
---|---|
#update | Mutate an existing row (toggle, edit, bulk). |
#set | Compute derived counts used in the UI. |
#if … | Swaps markup between view and edit modes. |
1 - completed | A SQL trick to flip 0⇄1 with one statement. |
3.1 Request cycle (toggle example)
- Browser: POST
/todos/toggle
- Server:
toggle
partial updates row → 302 Redirect to/todos
- Browser: GET
/todos
→ counts & list refresh.
POST / todos/toggle → 302 → GET / todos
4 Try it out
- Open http://localhost:8000/todos.
- Click a checkbox—row turns grey and page reloads.
- Double-click a label, edit text, hit Enter.
- Use the header checkbox to toggle all items on/off.
5 Recap
- #update powers both inline edits and mass updates.
- #set variables keep UI reactive without JavaScript.
- #if renders alternate markup based on query params.