Add a Web UI

In this tutorial you will extend the task tracker module with API routes, HTML templates, and Unpoly v3 interactions to create a full-stack web interface. By the end you will have a working application that lists tasks, creates new ones, and manages their lifecycle -- all with SPA-like navigation and partial page updates, without writing any JavaScript.

Prerequisites: You should have completed Build a Task Tracker and be familiar with File-Based Routing, Templates & JQT, and API Routes.

What you'll learn:


Step 1: Project Structure

After this tutorial your project will look like this:

my-project/
  grove.toml
  modules/
    task_tracker/
      module.grove
      task_tracker_test.grove
  apps/
    main/
      layout.html
      tasks/
        page.html
        page.jq
        [id]/
          page.html
          page.jq
      api/
        task_tracker/
          route.grove
          [id]/
            route.grove

The modules/ directory already contains the task tracker module you built previously. In this tutorial you will add the apps/ tree that exposes it through a web interface.


Step 2: Add a Query

Before building the UI you need a way to list tasks. Open modules/task_tracker/module.grove and add a list_tasks query after the existing action declarations:

query list_tasks {
  args {
    status: String?
    limit: Int = 50
    offset: Int = 0
  }

  sql {
    "SELECT *
     FROM task_tracker
     ORDER BY created_at DESC
     LIMIT @limit
     OFFSET @offset"
  }
}

The query accepts optional status, limit, and offset arguments. Bind parameters use the @name syntax and correspond to declared argument names. Routes and data loaders will reference this query as task_tracker.list_tasks.


Step 3: API Routes

API routes give the UI (and any other client) a JSON interface to your module. You need two route files: one for the collection and one for individual records.

Collection route

Create apps/main/api/task_tracker/route.grove:

route {
  GET {
    data records {
      query task_tracker.list_tasks {
        limit: input.query.limit,
        offset: input.query.offset
      }
    }
  }

  POST {
    data result {
      invoke task_tracker.create_task {
        title: input.body.title,
        description: input.body.description,
        priority: input.body.priority,
        assignee: input.body.assignee
      }
    }
  }
}

GET /api/task_tracker returns the task list. POST /api/task_tracker creates a new task by invoking the create_task action you defined in the language guide.

Record route

Create apps/main/api/task_tracker/[id]/route.grove:

route {
  GET {
    data result {
      fetch task_tracker { id: input.params.id }
    }
  }

  POST start_task {
    data result {
      invoke task_tracker.start_task {
        id: input.params.id,
        assignee: input.body.assignee
      }
    }
  }

  POST complete_task {
    data result {
      invoke task_tracker.complete_task { id: input.params.id }
    }
  }

  POST delete_task {
    data result {
      invoke task_tracker.delete_task { id: input.params.id }
    }
  }
}

GET /api/task_tracker/:id fetches a single task. The three named POST handlers -- start_task, complete_task, and delete_task -- map to the corresponding actions in the module. Named handlers let you expose multiple operations under the same HTTP method on a single route.


Step 4: Root Layout

The root layout wraps every page in the app. It loads Unpoly v3 from a CDN, sets up a sidebar with up-nav, and marks the main content area with up-main.

Create apps/main/layout.html:

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>{​{ "{​{ .page.title }​}" }​}</title>
  <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/unpoly@3/unpoly.min.css">
  <script src="https://cdn.jsdelivr.net/npm/unpoly@3/unpoly.min.js"></script>
  <style>
    *, *::before, *::after { box-sizing: border-box; }
    body {
      font-family: system-ui, -apple-system, sans-serif;
      margin: 0;
      display: flex;
      min-height: 100vh;
      color: #1a1a1a;
      background: #f5f5f5;
    }
    nav {
      width: 220px;
      background: #fff;
      border-right: 1px solid #e0e0e0;
      padding: 1.5rem 1rem;
      flex-shrink: 0;
    }
    nav a {
      display: block;
      padding: 0.5rem 0.75rem;
      border-radius: 6px;
      color: #333;
      text-decoration: none;
      margin-bottom: 0.25rem;
    }
    nav a:hover { background: #f0f0f0; }
    nav a.up-current { background: #e8f0fe; color: #1a56db; font-weight: 600; }
    main {
      flex: 1;
      padding: 2rem 3rem;
      max-width: 960px;
    }
    h1 { margin-top: 0; }
    .badge {
      display: inline-block;
      padding: 0.15rem 0.5rem;
      border-radius: 4px;
      font-size: 0.75rem;
      font-weight: 600;
      text-transform: uppercase;
    }
    .badge-open       { background: #dbeafe; color: #1e40af; }
    .badge-in_progress { background: #fef3c7; color: #92400e; }
    .badge-blocked    { background: #fee2e2; color: #991b1b; }
    .badge-done       { background: #d1fae5; color: #065f46; }
    .badge-cancelled  { background: #e5e7eb; color: #374151; }
    table { width: 100%; border-collapse: collapse; margin: 1rem 0; }
    th, td { text-align: left; padding: 0.5rem 0.75rem; border-bottom: 1px solid #e5e7eb; }
    th { font-weight: 600; font-size: 0.85rem; color: #6b7280; text-transform: uppercase; }
    input, select, textarea {
      padding: 0.4rem 0.6rem;
      border: 1px solid #d1d5db;
      border-radius: 4px;
      font-size: 0.9rem;
    }
    button {
      padding: 0.4rem 1rem;
      border: none;
      border-radius: 4px;
      font-size: 0.9rem;
      cursor: pointer;
      background: #1a56db;
      color: #fff;
    }
    button:hover { background: #1e40af; }
    button.danger { background: #dc2626; }
    button.danger:hover { background: #b91c1c; }
    button.secondary { background: #6b7280; }
    button.secondary:hover { background: #4b5563; }
    .form-row { display: flex; gap: 0.5rem; align-items: end; margin-bottom: 1rem; }
    .form-row label { display: flex; flex-direction: column; font-size: 0.85rem; gap: 0.25rem; }
    .detail-grid { display: grid; grid-template-columns: 10rem 1fr; gap: 0.5rem 1rem; margin: 1rem 0; }
    .detail-grid dt { font-weight: 600; color: #6b7280; }
    .actions { display: flex; gap: 0.5rem; margin-top: 1.5rem; }
  </style>
</head>
<body>
  <nav up-nav>
    <h3>Task Tracker</h3>
    <a href="/tasks" up-follow>All Tasks</a>
  </nav>
  <main up-main>
    {​{ "{​{ .children | trusted }​}" }​}
  </main>
</body>
</html>

What Unpoly gives you


Step 5: Task List Page

The task list page shows all tasks in a table and provides a form to create new ones.

Data loader

Create apps/main/tasks/page.jq:

{
  "records": .data.records
}

This data loader passes through the records returned by the list_tasks query. The template will access them as .data.records.

Template

Create apps/main/tasks/page.html:

---
title: All Tasks
---

<h1>Tasks</h1>

<section id="new-task">
  <h2>New Task</h2>
  <form action="/api/task_tracker" method="POST"
        up-submit up-target="#task-list">
    <div class="form-row">
      <label>
        Title
        <input type="text" name="title" required>
      </label>
      <label>
        Priority
        <select name="priority">
          <option value="medium" selected>Medium</option>
          <option value="low">Low</option>
          <option value="high">High</option>
          <option value="critical">Critical</option>
        </select>
      </label>
      <label>
        Assignee
        <input type="text" name="assignee" placeholder="Optional">
      </label>
      <button type="submit">Create</button>
    </div>
    <div class="form-row">
      <label style="flex:1">
        Description
        <textarea name="description" rows="2" style="width:100%"></textarea>
      </label>
    </div>
  </form>
</section>

<section id="task-list">
  <table>
    <thead>
      <tr>
        <th>Title</th>
        <th>Status</th>
        <th>Priority</th>
        <th>Assignee</th>
        <th>Created</th>
      </tr>
    </thead>
    <tbody>
      {​{ "{​{ .data.records[] | \"<tr><td><a href=\\\"/tasks/\" + .id + \"\\\" up-follow>\" + .title + \"</a></td><td><span class=\\\"badge badge-\" + .status + \"\\\">\" + .status + \"</span></td><td>\" + .priority + \"</td><td>\" + (.assignee // \"-\") + \"</td><td>\" + .created_at + \"</td></tr>\" }​}" }​}
    </tbody>
  </table>
</section>

How it works

  1. The <form> posts to /api/task_tracker -- the collection API route you created in Step 3. up-submit tells Unpoly to submit the form via AJAX instead of a full page navigation.
  2. up-target="#task-list" tells Unpoly to reload only the #task-list section after the form submits. Unpoly re-fetches the current page, extracts the #task-list fragment from the response, and swaps it in. The new task appears in the table without a full page reload.
  3. Each task title is a link with up-follow. Clicking it navigates to the detail page by swapping only the up-main content area. The sidebar stays in place.
  4. The JQT iteration expression iterates over every record and produces a table row for each one. The // operator provides a fallback value when assignee is null.

Step 6: Task Detail Page

The detail page shows a single task and provides action buttons to advance it through its lifecycle.

Data loader

Create apps/main/tasks/[id]/page.jq:

{
  "task": .data.result
}

Template

Create apps/main/tasks/[id]/page.html:

---
title: Task Detail
---

<a href="/tasks" up-follow>&larr; Back to all tasks</a>

<h1>{​{ "{​{ .data.task.title }​}" }​}</h1>

<dl class="detail-grid">
  <dt>Status</dt>
  <dd><span class="badge badge-{​{ "{​{ .data.task.status }​}" }​}">{​{ "{​{ .data.task.status }​}" }​}</span></dd>

  <dt>Priority</dt>
  <dd>{​{ "{​{ .data.task.priority }​}" }​}</dd>

  <dt>Assignee</dt>
  <dd>{​{ "{​{ if .data.task.assignee then .data.task.assignee else \"Unassigned\" end }​}" }​}</dd>

  <dt>Description</dt>
  <dd>{​{ "{​{ if .data.task.description then .data.task.description else \"No description\" end }​}" }​}</dd>

  <dt>Created</dt>
  <dd>{​{ "{​{ .data.task.created_at }​}" }​}</dd>

  <dt>Completed</dt>
  <dd>{​{ "{​{ if .data.task.completed_at then .data.task.completed_at else \"-\" end }​}" }​}</dd>
</dl>

<div class="actions">
  {​{ "{​{ if .data.task.status == \"open\" then \"<form action=\\\"/api/task_tracker/\" + .data.task.id + \"?handler=start_task\\\" method=\\\"POST\\\" up-submit up-target=\\\"main\\\"><label>Assignee <input type=\\\"text\\\" name=\\\"assignee\\\" required></label> <button type=\\\"submit\\\">Start Task</button></form>\" else \"\" end }​}" }​}

  {​{ "{​{ if .data.task.status == \"in_progress\" then \"<form action=\\\"/api/task_tracker/\" + .data.task.id + \"?handler=complete_task\\\" method=\\\"POST\\\" up-submit up-target=\\\"main\\\"><button type=\\\"submit\\\">Complete Task</button></form>\" else \"\" end }​}" }​}

  <form action="/api/task_tracker/{​{ "{​{ .data.task.id }​}" }​}?handler=delete_task" method="POST"
        up-submit up-target="main"
        up-confirm="Are you sure you want to delete this task?">
    <button type="submit" class="danger">Delete</button>
  </form>
</div>

How it works

  1. The back link uses up-follow so clicking it swaps only the up-main content, preserving the sidebar highlight transition.
  2. The detail grid shows all task fields. JQT conditionals (if ... then ... else ... end) handle null values gracefully.
  3. Action buttons are plain HTML forms that post to named handlers on the record API route. The ?handler=start_task query parameter tells the route which named POST handler to invoke.
  4. Each form uses up-submit to send the request via AJAX and up-target="main" to replace the page content with the refreshed detail view. After starting a task, for example, the status badge updates to "in_progress" and the "Start Task" button disappears, replaced by "Complete Task".
  5. The delete form adds up-confirm to prompt the user before submitting.

Step 7: Running It

Start the development server:

manzano dev

Open your browser to main.localhost:3000. The sidebar shows "All Tasks" and the main area is the task list.

Walkthrough

  1. Create a task. Fill in the title, pick a priority, and click "Create". The task appears in the table without a page reload.

  2. View the detail page. Click the task title. Unpoly swaps the main content to show the detail view while the sidebar stays in place.

  3. Start the task. Enter an assignee name and click "Start Task". The status badge changes from "open" to "in_progress" and the action buttons update.

  4. Complete the task. Click "Complete Task". The status moves to "done" and the completed_at timestamp appears.

  5. Navigate back. Click the back link. The task list shows the updated status without a full page reload.

Every interaction is a standard HTML form submission -- Unpoly intercepts each one and upgrades it to a partial page update. If JavaScript fails to load, the app still works as a traditional server-rendered application with full page navigations.


Unpoly Patterns Reference

Here is a summary of the Unpoly attributes used in this tutorial:

AttributePurpose
up-mainMarks the default content swap target. Unpoly replaces this element's contents during navigation.
up-navMarks a navigation container. Unpoly adds .up-current to links whose href matches the current URL.
up-followTurns a link into a partial navigation request. Unpoly fetches the target page and swaps only the up-main content.
up-submitSubmits a form via AJAX instead of a full page navigation. Combined with up-target to control which part of the page updates.
up-targetSpecifies a CSS selector identifying which element to replace after a navigation or form submission.
up-confirmShows a confirmation dialog before submitting a form. The request is cancelled if the user declines.

These six attributes cover the most common patterns for building interactive server-rendered applications. See the Unpoly documentation for additional attributes like up-poll, up-transition, and up-layer.


See Also