Skip to content

Todos Example

The starter template comes with a todos application.

You can find this example at https://github.com/razshare/frizzante-starter.

alt text

Although the application itself is simple, there are many things that might need some explanation.

First of all every interaction happens through a GET verb.

main.go
//go:embed app/dist
var efs embed.FS
var server = servers.New()
func main() {
server.Efs = efs
server.Routes = []routes.Route{
{Pattern: "GET /", Handler: handlers.Default},
{Pattern: "GET /welcome", Handler: handlers.Welcome},
{Pattern: "GET /todos", Handler: handlers.Todos},
{Pattern: "GET /check", Handler: handlers.Check},
{Pattern: "GET /uncheck", Handler: handlers.Uncheck},
{Pattern: "GET /add", Handler: handlers.Add},
{Pattern: "GET /remove", Handler: handlers.Remove},
}
server.Start()
}

As you can see, all handlers are exposed with a GET /... pattern.

The Default handler is exposed with a GET / pattern, which acts as a fallback handler.

It is for that reason that this handler tries to send a matching file with SendFileOrElse() before doing anything else.

lib/handlers/default.go
func Default(connection *connections.Connection) {
connection.SendFileOrElse(func() { Welcome(connection) })
}

All this handler does is send the "Welcome" view to the user with SendView().

lib/handlers/welcome.go
func Welcome(connection *connections.Connection) {
connection.SendView(views.View{Name: "Welcome"})
}

This "Welcome" view is exported by the application for both the client and the server.

app/exports/server.ts
import Welcome from "$lib/views/Welcome.svelte"
import Todos from "$lib/views/Todos.svelte"
export const views = {
"Welcome": Welcome, // Exporting "Welcome".
"Todos": Todos,
}
app/exports/client.ts
export const views = {
"Welcome": import("$lib/views/Welcome.svelte"), // Exporting "Welcome".
"Todos": import("$lib/views/Todos.svelte"),
}

These are key/value records.

The keys are important, because they dictate the Name of the view, the actual property Name of the view.

views.View{
Name: "Welcome", // This.
}

The "Welcome" view is simply laying down a header message and a link pointing to "/todos".

app/lib/views/Welcome.svelte
<script lang="ts">
import Layout from "$lib/components/Layout.svelte"
import { href } from "$frizzante/scripts/href.ts"
</script>
<Layout title="Welcome">
<h1>Welcome to Frizzante.</h1>
<a class="link" {...href("/todos")}> Show todos </a>
</Layout>

This hyperlink is a bit special, because it doesn’t just specify an href attribute as you would normally expect.

Instead it uses the href() function to set said attribute.

This is because in reality href() also returns a onclick handler.

frizzante/scripts/href.ts
export function href(path = ""): {
href: string
onclick: (e: MouseEvent) => void
}

When the view is allowed to execute JavaScript inside the users’s browser, the hyperlink will swap the view dynamically instead of navigating away to the given path.

However, if the view is not allowed to execute JavaScript code, then the hyperlink will fallback to using web standards, and it will navigate away to the given path when clicked.

It sends the "Todos" view to the user, along with a list of todos, which is retrieved from the session state.

lib/handlers/todos.go
func Todos(connection *connections.Connection) {
session := sessions.New(connection, state.Default()).Start()
defer sessions.Save(session)
connection.SendView(views.View{
Name: "Todos",
Data: map[string]any{
"todos": session.State.Todos,
},
})
}

As mentioned in the sessions section, the default session operator handles things in a local sessions directory.

By default the session state has a few items in it, initialized by state.Default().

lib/state/functions.go
func Default() State {
return State{
Todos: []Todo{
{Checked: false, Description: "Pet the cat."},
{Checked: false, Description: "Do laundry"},
{Checked: false, Description: "Pet the cat."},
{Checked: false, Description: "Cook"},
{Checked: false, Description: "Pet the cat."},
},
}
}

This state is immediately overwritten by the session operator if an existing session is found for the user.

If the session is a fresh one instead, the initial state remains untouched, thus the final session.State.Todos property will always contain the 5 todo items when initialized.

This "Todos" view does quite a few things.

  1. Lists Items
  2. Removes items.
  3. Checks items.
  4. Unchecks items.
  5. Adds items.
app/lib/views/Todos.svelte
<script lang="ts">
import Layout from "$lib/components/Layout.svelte"
import { href } from "$frizzante/core/scripts/href.ts"
import {action} from "$frizzante/core/scripts/action.ts";
type Todo = {
Checked: boolean
Description: string
}
type Props = {
todos: Todo[]
error: string
}
let { todos, error }: Props = $props()
</script>
<Layout title="Todos">
<ol>
{#each todos as todo, index (index)}
<li>
<form {...action("/remove")}>
<input type="hidden" name="index" value={index} />
<button class="link">[Remove]</button>
</form>
{#if todo.Checked}
<form {...action("/uncheck")}>
<input
type="hidden"
name="index"
value={index}
/>
<button class="link">
<!---->
(x) {todo.Description}
<!---->
</button>
</form>
{:else}
<form {...action("/check")}>
<input
type="hidden"
name="index"
value={index}
/>
<button class="link">
<!---->
(&nbsp;&nbsp;) {todo.Description}
<!---->
</button>
</form>
{/if}
</li>
{/each}
</ol>
<form {...action("/add")}>
<span class="link">Description</span>
<input type="text" value="" name="description" />
<button class="link" type="submit">Add +</button>
</form>
{#if error}
<br />
<span class="error">{error}</span>
{/if}
<br />
<a class="link" {...href("/")}>&lt; Back</a>
</Layout>

The component itself receives the todo list as a todos property from the server

app/lib/views/Todos.svelte
let {todos, error}:Props = $props()

which is then iterated upon to render the items

app/lib/views/Todos.svelte
{#each todos as todo, index (index)}
<li>
<form {...action("/remove")}>
<input type="hidden" name="index" value={index} />
<button class="link">[Remove]</button>
</form>
{#if todo.Checked}
<form {...action("/uncheck")}>
<input
type="hidden"
name="index"
value={index}
/>
<button class="link">
<!---->
(x) {todo.Description}
<!---->
</button>
</form>
{:else}
<form {...action("/check")}>
<input
type="hidden"
name="index"
value={index}
/>
<button class="link">
<!---->
(&nbsp;&nbsp;) {todo.Description}
<!---->
</button>
</form>
{/if}
</li>
{/each}

Each item has two buttons, a remove button and a toggle button.

Removing an item from the list involves submitting a form to /remove, along with the index of the item, which is hidden.

app/lib/views/Todos.svelte
<form {...action("/remove")}>
<input type="hidden" name="index" value={index} />
<button class="link">[Remove]</button>
</form>

This action is then captured by the Remove handler, which does some basic validation, error handling and then finally removes the item from the session.

lib/handlers/remove.go
func Remove(connection *connections.Connection) {
session := sessions.New(connection, state.Default()).Start()
defer sessions.Save(session)
count := int64(len(session.State.Todos))
if 0 == count {
// No todos found, ignore the request.
connection.SendNavigate("/todos")
return
}
indexString := connection.ReceiveQuery("index")
if "" == indexString {
// No index found, ignore the request.
connection.SendNavigate("/todos")
return
}
index, indexError := strconv.ParseInt(indexString, 10, 64)
if nil != indexError {
connection.SendView(views.View{Name: "Todos", Data: map[string]any{
"error": indexError.Error(),
}})
return
}
if index >= count {
// Index is out of bounds, ignore the request.
connection.SendNavigate("/todos")
return
}
session.State.Todos = append(
session.State.Todos[:index],
session.State.Todos[index+1:]...,
)
connection.SendNavigate("/todos")
}

Checking and unchecking items is also done using forms.

app/lib/views/Todos.svelte
{#if todo.Checked}
<form {...action("/uncheck")}>
<input type="hidden" name="index" value={index} />
<button class="link">
<!---->
(x) {todo.Description}
<!---->
</button>
</form>
{:else}
<form {...action("/check")}>
<input type="hidden" name="index" value={index} />
<button class="link">
<!---->
(&nbsp;&nbsp;) {todo.Description}
<!---->
</button>
</form>
{/if}

Checking an item sends a form to /check and unchecking it sends the form to /uncheck.

Both forms indicate the index of the item using a hidden input field.

lib/views/Todos.svelte
<input type="hidden" name="index" value={index} />

Checking is handled by the Check handler.

lib/handlers/check.go
func Check(connection *connections.Connection) {
session := sessions.New(connection, state.Default()).Start()
defer session.Save()
indexString := connection.ReceiveQuery("index")
if "" == indexString {
// No index found, ignore the request.
connection.SendNavigate("/todos")
return
}
index, indexError := strconv.ParseInt(indexString, 10, 64)
if nil != indexError {
connection.SendView(views.View{
Name: "Todos",
Data: map[string]any{
"error": indexError.Error(),
},
})
return
}
count := int64(len(session.State.Todos))
if index >= count {
// Index is out of bounds, ignore the request.
connection.SendNavigate("/todos")
return
}
session.State.Todos[index].Checked = true
connection.SendNavigate("/todos")
}

While unchecking is handled by the Uncheck handler, which does the exact same thing as Check, except it sets Checked to false instead of true.

lib/handlers/uncheck.go
// ...
session.State.Todos[id].Checked = false
// ...

The final piece of the puzzle is adding items to the list, which is done by sending a form to /add.

lib/views/Todos.svelte
<form {...action("/add")}>
<span class="link">Description</span>
<input type="text" value="" name="description" />
<button class="link" type="submit">Add +</button>
</form>

This form is then captured by the Add handler.

lib/handlers/add.go
func Add(connection *connections.Connection) {
session := sessions.New(connection, state.Default()).Start()
defer sessions.Save(session)
description := connection.ReceiveQuery("description")
if "" == description {
connection.SendView(views.View{
Name: "Todos",
Data: map[string]any{
"todos": session.State.Todos,
"error": "todo description cannot be empty",
},
})
return
}
session.State.Todos = append(session.State.Todos, state.Todo{
Checked: false,
Description: description,
})
connection.SendNavigate("/todos")
}
DescriptionHyperlink
A live chat applicationhttps://github.com/razshare/frizzante-example-chat
A blog application with login and registration formshttps://github.com/razshare/frizzante-example-blog