Full-Stack Todo App

A complete, copy-paste-ready todo application with a Grove module, API routes, and a web UI using Unpoly v3. Copy these files into a project and run manzano dev.

Project Structure

my-project/
  grove.toml
  modules/
    todo/
      module.grove
      todo_test.grove
  apps/
    main/
      layout.html
      todos/
        page.html
        page.jq
        [id]/
          page.html
          page.jq
      api/
        todo/
          route.grove
          [id]/
            route.grove

Module

modules/todo/module.grove

module todo

enum TodoStatus { open, in_progress, done }

record {
  kind "todo"
  title: String
  notes: String = ""
  status: TodoStatus = TodoStatus.open
  priority: Int = 1

  invariant title_not_blank {
    record.title.trim().len() > 0
  }

  state status {
    TodoStatus.open -> TodoStatus.in_progress on started
    TodoStatus.in_progress -> TodoStatus.done on completed
  }
}

event created {
  title: String
  notes: String = ""
  priority: Int = 1
}

event started {
  status: TodoStatus = TodoStatus.in_progress
}

event completed {
  status: TodoStatus = TodoStatus.done
}

@delete
event deleted {}

@create
action create {
  title: String
  notes: String = ""
  priority: Int = 1

  validate {
    params.title.trim().len() > 0
  }

  apply {
    emit created {
      title: params.title
      notes: params.notes
      priority: params.priority
    }
  }
}

action start_todo {
  apply {
    emit started {}
  }
}

action complete_todo {
  apply {
    emit completed {}
  }
}

action delete_todo {
  apply {
    emit deleted {}
  }
}

query list_todos {
  args {
    limit: Int = 50
    offset: Int = 0
  }

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

Test

modules/todo/todo_test.grove

module todo

test todo_lifecycle {
  run create_todo {
    request create {
      title: "Buy groceries"
      priority: 2
    }
    expect ok
    assert record.title == "Buy groceries"
    assert record.status == TodoStatus.open
    assert record.priority == 2
  }

  run start {
    request start_todo {}
    expect ok
    assert record.status == TodoStatus.in_progress
  }

  run complete {
    request complete_todo {}
    expect ok
    assert record.status == TodoStatus.done
  }
}

API Routes

apps/main/api/todo/route.grove

Collection route -- list and create.

route {
  GET {
    data records {
      query todo.list_todos {
        limit: input.query.limit,
        offset: input.query.offset
      }
    }
  }

  POST {
    data result {
      invoke todo.create {
        title: input.body.title,
        notes: input.body.notes,
        priority: input.body.priority
      }
    }
  }
}

apps/main/api/todo/[id]/route.grove

Record route -- fetch, start, complete, delete.

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

  POST start_todo {
    data result {
      invoke todo.start_todo { id: input.params.id }
    }
  }

  POST complete_todo {
    data result {
      invoke todo.complete_todo { id: input.params.id }
    }
  }

  POST delete_todo {
    data result {
      invoke todo.delete_todo { id: input.params.id }
    }
  }
}

Layout

apps/main/layout.html

Minimal HTML5 shell with Unpoly v3 from CDN.

<!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, sans-serif; margin: 0; background: #f9fafb; color: #111; }
    main { max-width: 720px; margin: 2rem auto; padding: 0 1rem; }
    h1 { margin-top: 0; }
    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-size: 0.8rem; 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; cursor: pointer; background: #2563eb; color: #fff; font-size: 0.9rem; }
    button:hover { background: #1d4ed8; }
    button.danger { background: #dc2626; }
    button.danger:hover { background: #b91c1c; }
    button.secondary { background: #6b7280; }
    button.secondary:hover { background: #4b5563; }
    .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-done { background: #d1fae5; color: #065f46; }
    .form-row { display: flex; gap: 0.5rem; align-items: end; margin-bottom: 0.75rem; }
    .form-row label { display: flex; flex-direction: column; font-size: 0.85rem; gap: 0.25rem; }
    .detail-grid { display: grid; grid-template-columns: 8rem 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>
  <main up-main>
    {​{ "{​{ .children | trusted }​}" }​}
  </main>
</body>
</html>

Pages

apps/main/todos/page.jq

{
  "records": .data.records
}

apps/main/todos/page.html

---
title: Todos
---

<h1>Todos</h1>

<section id="new-todo">
  <h2>New Todo</h2>
  <form action="/api/todo" method="POST"
        up-submit up-target="#todo-list">
    <div class="form-row">
      <label>
        Title
        <input type="text" name="title" required>
      </label>
      <label>
        Priority
        <select name="priority">
          <option value="1" selected>1 (low)</option>
          <option value="2">2</option>
          <option value="3">3 (high)</option>
        </select>
      </label>
      <button type="submit">Add</button>
    </div>
    <div class="form-row">
      <label style="flex:1">
        Notes
        <textarea name="notes" rows="2" style="width:100%"></textarea>
      </label>
    </div>
  </form>
</section>

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

apps/main/todos/[id]/page.jq

{
  "todo": .data.result
}

apps/main/todos/[id]/page.html

---
title: Todo Detail
---

<a href="/todos" up-follow>&larr; Back to list</a>

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

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

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

  <dt>Notes</dt>
  <dd>{​{ "{​{ if .data.todo.notes then .data.todo.notes else \"-\" end }​}" }​}</dd>

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

<div class="actions">
  {​{ "{​{ if .data.todo.status == \"open\" then \"<form action=\\\"/api/todo/\" + .data.todo.id + \"?handler=start_todo\\\" method=\\\"POST\\\" up-submit up-target=\\\"main\\\"><button type=\\\"submit\\\">Start</button></form>\" else \"\" end }​}" }​}

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

  <form action="/api/todo/{​{ "{​{ .data.todo.id }​}" }​}?handler=delete_todo" method="POST"
        up-submit up-target="main"
        up-confirm="Delete this todo?">
    <button type="submit" class="danger">Delete</button>
  </form>
</div>

Running It

manzano dev

Open main.localhost:3000/todos. Create a todo, click its title to view the detail page, then use the action buttons to advance it through open -> in_progress -> done.

See Also