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.

« Part 2: Adding Data

1 What we're building

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:

  1. To conditionally add a CSS class when a todo is completed
  2. 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:

  1. Control UI state - the all_complete flag determines whether the toggle-all checkbox is checked
  2. Display dynamic counts - active_count shows how many todos remain active
  3. 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:

  1. Validate inputs: The #param directive enforces type checking and validation rules
  2. Modify data: Using #update to change database records
  3. 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

PiecePurpose
#updateMutate an existing row (toggle, edit, bulk).
#setCompute derived counts used in the UI.
#if …Swaps markup between view and edit modes.
1 - completedA SQL trick to flip 0⇄1 with one statement.

3.1 Request cycle (toggle example)

  1. Browser: POST /todos/toggle
  2. Server: toggle partial updates row → 302 Redirect to /todos
  3. Browser: GET /todos → counts & list refresh.

POST / todos/toggle → 302 → GET / todos

4 Try it out

  1. Open http://localhost:8000/todos.
  2. Click a checkbox—row turns grey and page reloads.
  3. Double-click a label, edit text, hit Enter.
  4. Use the header checkbox to toggle all items on/off.

5 Recap

« Back: Part 2: Adding Data Next: Part 4: Deleting & Bulk Clear