Routing and Title

In Doors, the URL is reactive state. Routing is what you do with it.

The Location struct represents URL state in Doors:

// Location is a parsed or generated URL path plus query string.
type Location struct {
	// Segments holds the decoded path segments without leading or trailing
	// slashes.
	Segments []string
	// Query holds the decoded query parameters.
	Query url.Values
}

Currently, our page content does not react to path changes because we have not established any binds to path state.

You can obtain doors.Source[Location] and work with it directly: derive, subscribe, bind, update, and so on.

Or use a Path Model: an annotated struct that saves a lot of boilerplate.

Path Model

Our app will serve two routes: the location selector / and the dashboard /:CityID.

Let's declare a path model that defines those two locations:

type Path struct {
	Route int `path:"/ | /:CityID"`
	CityID int
}

The basic syntax is very simple: annotate an exported integer field with path, then enumerate route patterns separated by |. Use :{FieldName} to capture a parameter.

The annotated field receives the index of the matched variant. In our case, Route will be either 0 or 1.

For the full syntax reference, see Routing.

To make this even more structured, let's introduce an integer enum:

type Route int

const (
	Selector Route = iota
	Dashboard
)

type Path struct {
	Route Route `path:"/ | /:CityID"`
	CityID int
}

Route Matching

Any reactive state in Doors has .Route methods that let you switch between different render branches based on the state value. URL routing is just a special case of this mechanism, and the framework ships a Route component to make it nicer.

app.gox

// path model declaration
type Path struct {
	Route Route `path:"/ | /:CityID"`
	CityID int
}

// Route variant enum.
type Route int
const (
	Selector Route = iota
	Dashboard
)

type App struct{}

elem (a App) Main() {
	<!doctype html>
	<html lang="en" data-theme="dark">
		<head>
			~/* ... */
		</head>
		<body>
			<main class="container">
                ~// Routing component with a path model route.
				~(doors.Route(
                    // Route that derives doors.Source[Path] from doors.Source[Location]
                    // and renders content on a successful match.
					doors.RouteModel[Path](a.content),
				))
			</main>
		</body>
	</html>
}

elem (a App) content(path doors.Source[Path]) {
	~/* ... */
}

Path branches into two variants: location selector and dashboard. For now, we are interested only in Selector. Let's route it:

app.gox

elem (a App) content(path doors.Source[Path]) {
	~(path.Route(
        // Route based on a match function.
		doors.RouteMatch(func(p Path) bool {
            // Selector variant.
			return p.Route == Selector
		}).Comp(LocationSelector()), // Render the component on match.
	))
}

If you check the web page, it seems like nothing changed. But if you check any path except /, no content will be shown on the page, because our routing matches only the Selector variant. We will add more routes soon.

Programmatic navigation

We could make Confirm a regular link (that navigates /:CityID), but this is a good place to learn programmatic navigation.

The path source is synchronized both ways. If you mutate it on the server, the browser URL updates too.

First, let the location selector accept a callback for the selected city:

location_selector.gox

func LocationSelector(apply func(ctx context.Context, city int)) gox.Comp {
	/* ... */
	return locationSelector{
        /* ... */
		apply: apply,
	}
}

type locationSelector struct {
    /* ... */
	apply func(ctx context.Context, city int)
}

Next, provide this callback in the app:

app.gox

elem (a App) content(path doors.Source[Path]) {
	~(path.Route(
		doors.RouteMatch(func(p Path) bool {
			return p.Route == Selector
		}).Comp(LocationSelector(func(ctx context.Context, city int) {
            // Change the URL to the dashboard variant with the provided city.
			path.Update(ctx, Path{
				Route: Dashboard,
				CityID: city,
			})
		})),
	))
}

Finally, apply city selection in LocationSelector:

elem (l locationSelector) Main() {
	<article>
		~/* ... */
        ~// Bind submit to city selection.
		~(l.city.Bind(l.submit))
	</article>
}

elem (l locationSelector) submit(city driver.Place) {
	~(if city.IsValid() {
		<hr/>
		<button
			(doors.AClick{
				Indicator: doors.IndicateAttr("aria-busy", "true"),
				On: func(ctx context.Context, _ doors.RequestPointer) bool {
					l.apply(ctx, city.Id)
					return true
				},
			})
			id="confirm">
			Confirm
		</button>
		~// Focus the button when it is shown.
		~(focus("confirm"))
	})
}

On city selection, the app shows an empty page. Time to fix that.

Dashboard

Prepare the initial dashboard component.

dashboard.gox

package main

import (
	"github.com/doors-dev/doors"
	"github.com/doors-dev/gox"
	"github.com/doors-dev/tutorial/driver"
)

func WeatherDashboard(path doors.Source[Path]) gox.Comp {
	// Derive a read-only view of path with the city id.
	city := doors.DeriveBeam(path, func(p Path) int {
		return p.CityID
	})
	// Reactive Bind implements component,
	// so we can return it directly.
	return city.Bind(elem(cityID int) {
		~dashboard{
			cityID: cityID,
		}
	})
}

type dashboard struct {
	cityID int
}

elem (d dashboard) Main() {
	~{
		city, _ := driver.Locations.CitiesGet(d.cityID)
	}
	~(if !city.IsValid() {
		~// Set the page HTTP status.
		~(doors.Status(404))
		<h1>City Not Found</h1>
	} else {
		<h1>Weather in ~(city.Name, ", ", city.Country.Name)</h1>
	})
	~// Reset selection.
	<a
		class="secondary"
		(doors.ALink{
			Model: Path{Route: Selector},
		})
		role="button">
		Change
	</a>
}

Finally, route it:

app.gox

elem (a App) content(path doors.Source[Path]) {
	~(path.Route(
		doors.RouteMatch(func(p Path) bool {
			return p.Route == Selector
		}).Comp(LocationSelector(func(ctx context.Context, city int) {
			path.Update(ctx, Path{
				Route: Dashboard,
				CityID: city,
			})
		})),
        // Since there are only two path variants,
        // we can use the RouteDefault form.
		doors.RouteDefault(WeatherDashboard),
	))
}

Result:

Form state is maintained

Notice how form state is maintained. That is because our LocationSelector constructor runs once during routing declaration, and the initialized state values are reused across re-renders of the returned component. We can leave it like this, but I prefer a reset form.

To fix that, wrap it in element syntax <>...</>:

elem (a App) content(path doors.Source[Path]) {
	~(path.Route(
		doors.RouteMatch(func(p Path) bool {
			return p.Route == Selector
		}).Comp(<>
			~(LocationSelector(func(ctx context.Context, city int) {
				path.Update(ctx, Path{
					Route: Dashboard,
					CityID: city,
				})
			}))
		</>),
		doors.RouteDefault(WeatherDashboard),
	))
}

That makes the LocationSelector constructor run during render.

Not Found

Our main router still has one branch and will render nothing if the model patterns do not match.

Let's add basic 404 component:

app.gox

elem (a App) Main() {
	~/* ... */
				~(doors.Route(
					doors.RouteModel(a.content),
                    // Add a default location route with a component.
					doors.RouteLocationDefaultComp(<>
						~(doors.Status(404))
						<h1>Location Not Found</h1>
					</>),
				))
	~/* ... */
}

Title

The title tag and meta tags are processed differently from other tags. Regardless of where you put them, they are moved to <head> and synchronized with the frontend.

For <title>, <meta>, and doors.Status(...), see Head and Status.

app.gox

type App struct{}

elem (a App) Main() {
	~/* ... */
				~(doors.Route(
					doors.RouteModel(a.content),
					doors.RouteLocationDefaultComp(<>
                        ~// Add the not found title.
						<title>Not Found</title>
						~(doors.Status(404))
						<h1>Location Not Found</h1>
					</>),
				))
	~/* ... */
}


elem (a App) content(path doors.Source[Path]) {
	~(path.Route(
		doors.RouteMatch(func(p Path) bool {
			return p.Route == Selector
		}).Comp(<>
            ~// Add the location selector title.
			<title>Select Location</title>
			~(LocationSelector(func(ctx context.Context, city int) {
				path.Update(ctx, Path{
					Route: Dashboard,
					CityID: city,
				})
			}))
		</>),
		doors.RouteDefault(WeatherDashboard),
	))
}

dashboard.gox

elem (d dashboard) Main() {
	~/* ... */
	~(if !city.IsValid() {
		~(doors.Status(404))
		<title>Not Found</title>
		<h1>City Not Found</h1>
	} else {
		<title>~(city.Name)</title>
		<h1>Weather in ~(city.Name, ", ", city.Country.Name)</h1>
	})
	~/* ... */
}

This feels unusual at first, but it is actually very convenient, because you can update the title where you query the data.

Location selection and page title updates


Next: Scopes

Code

./app.gox

package main

import (
	"context"
	
	"github.com/doors-dev/doors"
	"github.com/doors-dev/gox"
)

type Path struct {
	Route Route `path:"/ | /:CityID"`
	CityID int
}

type Route int

const (
	Selector Route = iota
	Dashboard
)

type App struct{}

elem (a App) Main() {
	<!doctype html>
	<html lang="en" data-theme="dark">
		<head>
			<meta charset="utf-8">
			<meta name="viewport" content="width=device-width, initial-scale=1">
			<link
				(doors.ResourceExternal("https://cdn.jsdelivr.net/npm/@picocss/pico@2/css/pico.min.css"))
				rel="stylesheet">
		</head>
		<body>
			<main class="container">
				~(doors.Route(
					doors.RouteModel(a.content),
					doors.RouteLocationDefaultComp(<>
						<title>Not Found</title>
						~(doors.Status(404))
						<h1>Location Not Found</h1>
					</>),
				))
			</main>
		</body>
	</html>
}

elem (a App) content(path doors.Source[Path]) {
	~(path.Route(
		doors.RouteMatch(func(p Path) bool {
			return p.Route == Selector
		}).Comp(<>
			<title>Select Location</title>
			~(LocationSelector(func(ctx context.Context, city int) {
				path.Update(ctx, Path{
					Route: Dashboard,
					CityID: city,
				})
			}))
		</>),
		doors.RouteDefault(WeatherDashboard),
	))
}

./location_selector.gox

package main

import (
	"context"
	"time"
	
	"github.com/doors-dev/doors"
	"github.com/doors-dev/gox"
	"github.com/doors-dev/tutorial/driver"
)

type location struct {
	country driver.Place
	city driver.Place
}

func LocationSelector(apply func(ctx context.Context, city int)) gox.Comp {
	loc := doors.NewSource(location{})
	country := doors.DeriveSource(loc,
		func(l location) driver.Place {
			return l.country
		},
		func(_ location, c driver.Place) location {
			return location{
				country: c,
			}
		},
	)
	city := doors.DeriveSource(loc,
		func(l location) driver.Place {
			return l.city
		},
		func(l location, c driver.Place) location {
			l.city = c
			return l
		},
	)
	return locationSelector{
		location: loc,
		city: city,
		country: country,
		apply: apply,
	}
}

type locationSelector struct {
	location doors.Source[location]
	country doors.Source[driver.Place]
	city doors.Source[driver.Place]
	apply func(ctx context.Context, city int)
}

elem (l locationSelector) Main() {
	<article>
		<section>
			~placeSelector{
				title: "Country",
				options: new(doors.Door),
				search: driver.Locations.CountriesSearch,
				selected: l.country,
			}
		</section>
		~(l.country.Bind(l.selectCity))
		~(l.city.Bind(l.submit))
	</article>
}

elem (l locationSelector) submit(city driver.Place) {
	~(if city.IsValid() {
		<hr/>
		<button
			(doors.AClick{
				Indicator: doors.IndicateAttr("aria-busy", "true"),
				On: func(ctx context.Context, _ doors.RequestPointer) bool {
					l.apply(ctx, city.Id)
					return true
				},
			})
			id="confirm">
			Confirm
		</button>
		~(focus("confirm"))
	})
}

elem (l locationSelector) selectCity(country driver.Place) {
	~(if country.IsValid() {
		<section>
			~placeSelector{
				title: "City",
				options: new(doors.Door),
				search: func(input string) ([]driver.Place, error) {
					return driver.Locations.CitiesSearch(country.Id, input)
				},
				selected: l.city,
			}
		</section>
	})
}

type placeSelector struct {
	title string
	options *doors.Door
	search func(input string) ([]driver.Place, error)
	selected doors.Source[driver.Place]
}

elem (l placeSelector) Main() {
	~(l.selected.Bind(elem(p driver.Place) {
		~(if p.IsValid() {
			~(l.place(p))
		} else {
			~(l.input())
		})
	}))
}

elem (l placeSelector) place(p driver.Place) {
	<h3>~(l.title): <b>~(p.Name)</b></h3>
	<button
		(doors.AClick{
			Indicator: doors.IndicateAttr("aria-busy", "true"),
			Scope: new(doors.ScopeBlocking),
			On: func(ctx context.Context, _ doors.RequestPointer) bool {
				l.selected.Update(ctx, driver.Place{})
				return true
			},
		})
		class="secondary">
		Change
	</button>
}

elem (l placeSelector) input() {
	~{
		loaderID := "loader-" + doors.IDString(l.title)
		inputID := "input-" + doors.IDString(l.title)
	}
	<h3>Select ~(l.title)<span id=(loaderID)></span></h3>
	<input
		id=(inputID)
		(doors.AInput{
			Scope: &doors.ScopeDebounce{
				Duration: 300 * time.Millisecond,
				Limit: 600 * time.Millisecond,
			},
			Indicator: doors.IndicateAttrQuery("#" + loaderID, "aria-busy", "true"),
			On: func(ctx context.Context, r doors.RequestInput) bool {
				l.options.Inner(ctx, l.results(r.Event().Value))
				return false
			},
		})
		type="search"
		placeholder=(l.title)
		autocomplete="off"/>
	~(focus(inputID))
	~{
		l.options.Inner(ctx, nil)
	}
	~(l.options)
}

func (l placeSelector) results(input string) gox.Elem {
	if len(input) == 0 {
		return nil
	}
	if len(input) < 2 {
		return <p>
			<mark>Type at least two letters to search</mark>
		</p>
	}
	results, _ := l.search(input)
	if len(results) == 0 {
		return <p>
			<i>nothing found</i>
		</p>
	}
	scope := new(doors.ScopeBlocking)
	return <ul>
		~(for _, place := range results {
			<li>
				<a
					(doors.AClick{
						Scope: scope,
						PreventDefault: true,
						On: func(ctx context.Context, _ doors.RequestPointer) bool {
							l.selected.Update(ctx, place)
							return true
						},
					})
					href="#">
					~(place.Name)
				</a>
			</li>
		})
	</ul>
}

elem focus(id string) {
	<script data:id=(id)>
		const id = $data("id")
		const el = document.getElementById(id)
		el.focus()
	</script>
}

dashboard.gox

package main

import (
	"github.com/doors-dev/doors"
	"github.com/doors-dev/gox"
	"github.com/doors-dev/tutorial/driver"
)

func WeatherDashboard(path doors.Source[Path]) gox.Comp {
	city := doors.DeriveBeam(path, func(p Path) int {
		return p.CityID
	})
	return city.Bind(elem(cityID int) {
		~dashboard{
			cityID: cityID,
		}
	})
}

type dashboard struct {
	cityID int
}

elem (d dashboard) Main() {
	~{
		city, _ := driver.Locations.CitiesGet(d.cityID)
	}
	~(if !city.IsValid() {
		~(doors.Status(404))
		<title>Not Found</title>
		<h1>City Not Found</h1>
	} else {
		<title>~(city.Name)</title>
		<h1>Weather in ~(city.Name, ", ", city.Country.Name)</h1>
	})
	<a
		class="secondary"
		(doors.ALink{
			Model: Path{Route: Selector},
		})
		role="button">
		Change
	</a>
}