Banter Bus got me here, LettuceGo made the same lessons easier to show.
sequenceDiagram
participant User
participant HTMX
participant Server
User->>HTMX: Click / submit
HTMX->>Server: HTTP request
Server-->>HTMX: HTML fragment
HTMX-->>User: DOM swap
<form hx-post="/plants/timeline"
hx-target="#timeline"
>
<div class="flex flex-col gap-4">
<input name="event_type"
type="radio"
value="repotted"
checked />
<textarea name="notes" rows="3"></textarea>
<button type="submit">Add Event</button>
</div>
</form>
<input
name="q"
hx-get="/plants"
hx-trigger="input changed delay:300ms"
hx-target="#plants-list"
hx-push-url="true"
/>
<input bind:value={q} placeholder="Search plants" />
<script lang="ts">
let q = ''
let plants = []
$: params = new URLSearchParams({ q })
</script>
<script lang="ts">
$: if (q || location) {
loadPlants()
history.replaceState({}, '', `?${params}`)
}
async function loadPlants() {
const res = await fetch(`/plants?${params}`)
plants = await res.json()
}
</script>
{#if loading}
<p>Loading...</p>
{:else if error}
<p>{error}</p>
{:else}
<ul>
{#each plants as plant}
<li>{plant.name}</li>
{/each}
</ul>
{/if}
<div
hx-get="/plants/plant_1_id/timeline"
hx-trigger="load"
>
<p class="text-sm text-muted">Loading...</p>
</div>
flowchart LR
A[POST health entry]
B[record health]
C[toast]
D[health dot]
E[timeline]
F[last event]
G[history]
A --> B
B --> C
B --> D
B --> E
B --> F
B --> G
<div id="toast-container" hx-swap-oob="true">
Health recorded!
</div>
<div id="health-dot-plant-1-id" hx-swap-oob="true">
<span class="bg-health-4"></span>
</div>
<div id="timeline-section" hx-swap-oob="true">
<!-- updated timeline -->
</div>
<div id="last-event-oob" hx-swap-oob="true">
Repotted — just now
</div>
<div x-data="{ open: false }">
<button @click="open = true">Delete plant</button>
<dialog x-show="open">
<p>Are you sure?</p>
<button hx-delete="/plants/123"
hx-target="#plants-list">Yes
</button>
<button @click="open = false">Cancel</button>
</dialog>
</div>
func ErrorWithToast(
w http.ResponseWriter,
r *http.Request,
statusCode int,
message string,
) {
w.Header().Set("HX-Retarget", "#toast-container")
w.Header().Set("HX-Reswap", "innerHTML")
w.WriteHeader(statusCode)
_ = components.Toast(
message,
components.ToastError,
).Render(r.Context(), w)
}
func authRedirect(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("HX-Request") == "true" {
w.Header().Set("HX-Redirect", "/login")
w.WriteHeader(http.StatusOK)
return
}
http.Redirect(w, r, "/login", http.StatusFound)
}
flowchart LR
subgraph Browser
A[DOM]
H[HTMX]
A -->|User event| H
H -->|Swap target| A
end
subgraph GoApp[Go application]
B[Handler]
C[Service]
D[sqlc]
F[templ]
B --> C
C --> D
B --> F
end
E[(Postgres)]
H -->|HTTP request| B
D <--> E
F -->|HTML fragment| H
func (h *EventHandler) UpdateTimeline(
w http.ResponseWriter,
r *http.Request,
) {
// ...
timeline, err := h.service.UpdatePlantTimeline(
r.Context(),
plantID,
)
if err != nil {
response.ErrorWithToast(
w, r,
http.StatusInternalServerError,
"Failed to load timeline",
)
return
}
err = plantcomponents.Timeline(
plantID,
timeline,
).Render(r.Context(), w)
if err != nil {
h.logger.ErrorContext(
r.Context(),
"failed to render timeline content",
slog.Any("error", err),
)
}
}
package components
import "gitlab.com/.../service"
templ HealthHistory(entries []service.HealthEntry) {
for _, entry := range entries {
<div class="flex gap-3 items-center py-2">
@Health(entry.Rating)
<span class="text-sm font-medium text-text">
{ entry.Rating }
</span>
</div>
}
}
<div class="flex gap-3 items-center py-2">
<span class="inline-block w-3 h-3 rounded-full bg-health-3"></span>
<span class="text-sm font-medium text-text">
3
</span>
</div>
templ generate
// Code generated by templ - DO NOT EDIT.
func HealthHistory(entries []service.HealthEntry) templ.Component {
return templruntime.GeneratedTemplate(func(...) (err error) {
for _, entry := range entries {
templruntime.WriteString(buf, 1, "<div class=\"flex gap-3 items-center py-2\">")
Health(entry.Rating).Render(ctx, buf)
templruntime.WriteString(buf, 2, "<span class=\"text-sm font-medium text-text\">")
buf.WriteString(entry.Rating)
templruntime.WriteString(buf, 3, "</span></div>")
}
return
})
}
package layouts
import "gitlab.com/.../http/views/components"
templ Base(title string, environment string) {
<!DOCTYPE html>
<html lang="en">
<head>
</head>
<body class="bg-base-200 text-neutral">
{ children... }
</body>
</html>
}
templ PlantPage(title string, environment string) {
@Base(title, environment) {
<div>plant1</div>
// ...
}
}
<!DOCTYPE html>
<html lang="en">
<head>
<title>Basil</title>
</head>
<body class="bg-base-200 text-neutral">
<div>plant1</div>
</body>
</html>
-- name: ListHealthByPlant :many
SELECT id, plant_id, rating, recorded_at
FROM health_entries
WHERE plant_id = $1
ORDER BY recorded_at DESC
LIMIT $2 OFFSET $3;
sqlc generate
version: "2"
sql:
- engine: "postgresql"
queries: "internal/store/db/sqlc/queries"
schema: "internal/store/db/sqlc/migrations"
gen:
go:
package: "db"
out: "internal/store/db"
sql_package: "pgx/v5"
type HealthEntry struct {
ID uuid.UUID
PlantID uuid.UUID
UserID uuid.UUID
Rating int32
Notes *string
RecordedAt pgtype.Timestamptz
CreatedAt pgtype.Timestamptz
UpdatedAt pgtype.Timestamptz
}
type ListHealthByPlantParams struct {
PlantID uuid.UUID
Limit int32
Offset int32
}
func (q *Queries) ListHealthByPlant(
ctx context.Context,
arg ListHealthByPlantParams,
) ([]HealthEntry, error) {
rows, err := q.db.Query(
ctx, listHealthByPlant,
arg.PlantID, arg.Limit, arg.Offset,
)
if err != nil {
return nil, err
}
defer rows.Close()
var items []HealthEntry
for rows.Next() {
var i HealthEntry
if err := rows.Scan(
&i.ID, &i.PlantID, &i.UserID, &i.Rating,
&i.Notes, &i.RecordedAt, &i.CreatedAt,
&i.UpdatedAt,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
version: "3"
tasks:
dev:
cmds:
- docker compose up -d
- concurrently "air" "task watch"
watch:
cmds:
- templ generate -watch &
- tailwindcss -i ./static/css/tailwind.css \
-o ./static/css/styles.css --minify --watch
import (
"github.com/aarol/reload"
)
func (s *Server) wrapWithReload(handler http.Handler) http.Handler {
reloader := reload.New(
"internal/transport/http/views",
"static/css",
)
s.logger.Debug("starting reload server")
return reloader.Handle(handler)
}
myPackages = with pkgs; [
go
goose
air
golangci-lint
gofumpt
go-task
playwright-driver
playwright-cli
templ
sqlc
tailwindcss_4
];
devShellPackages =
with pkgs;
myPackages;
example on main via 🐹 v1.22.8
❯ which golangci-lint
example on main via 🐹 v1.22.8
❯ nix develop
example on main via 🐹 v1.22.8 ❄️ impure (nix-shell-env)
❯ which golangci-lint
/nix/store/kcd...golangci-lint-1.56.2/bin/golangci-lint
func TestTimelineRefreshesAfterAddEvent(t *testing.T) {
// setup ...
page.Locator(
"a:has-text('Timeline Refresh Plant')",
).First().Click()
timelineSection := page.Locator(
"#timeline-section",
)
expect.Locator(timelineSection).ToContainText(
"Repotted",
)
lastEventOOB := page.Locator("#last-event-oob")
expect.Locator(lastEventOOB).ToContainText("—")
}