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>← 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
- Build a Task Tracker -- Step-by-step module tutorial
- Add a Web UI -- Step-by-step UI tutorial
- CRUD with State Machines -- Action-event CRUD patterns
- File-Based Routing -- How directories map to URLs
- Templates & JQT -- Template syntax reference
- Data Loaders -- The
page.jqconvention