Path and Title

Path

Currently, our page only serves the root path.

Add a CityID parameter to the path variant declaration to enable city selection via the path.

This is part of the Path Model.

main.go

type Path struct {
	Home      bool `path:"/"`
	Dashboard bool `path:"/:CityID"` // new path variant
	CityID    int
}

You can add as many path variants as you like. The matched one will have the true value.

Programmatic navigation

We could make Confirm a regular link, 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(update func(ctx context.Context, city int)) gox.Comp {
	loc := doors.NewSource(location{})
	city := doors.NewBeam(loc, func(l location) driver.Place {
		return l.city
	})
	country := doors.NewBeam(loc, func(l location) driver.Place {
		return l.country
	})
	return locationSelector{
		location: loc,
		city: city,
		country: country,
		update: update,
	}
}

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

elem (l locationSelector) Main() {
	~/* ... */
	~(doors.Sub(l.country, l.selectCity))
	~// show the submit button when the city becomes valid
	~(doors.Sub(l.city, l.submit))
}

elem (l locationSelector) submit(city driver.Place) {
	~// show the button only after a city is selected
	~(if city.IsValid() {
		<hr/>
		<a
			(doors.AClick{
				Indicator: doors.IndicatorOnlyAttr("aria-busy", "true"),
				PreventDefault: true,
				On: func(ctx context.Context, _ doors.RequestPointer) bool {
					// update the selected city through the callback
					l.update(ctx, city.Id)
					return false
				},
			})
			id="submit"
			href="#"
			role="button">
			Confirm
		</a>

		~// focus on the button when it is shown
		~(focus("submit"))
	})
}

Next, provide this callback in the app:

app.gox

type App struct {
	// keep the current path in reactive state
	path doors.Source[Path]
	/* ... */
}

elem (a App) Main() {
	~/* ... */
				~(doors.Sub(a.city, elem(city int) {
					~(if city == -1 {
						~(LocationSelector(func(ctx context.Context, city int) {
							a.path.Mutate(ctx, func(p Path) Path {
								p.Home = false
								p.Dashboard = true
								p.CityID = city
								return p
							})
						}))
					} else {
						~dashboard{
							city: city,
						}
					})
				}))
	~/* ... */
}

And update the model handler to pass the path source into the app:

main.go

/* ... */
func main() {
	/* ... */

	doors.UseModel(r, func(r doors.RequestModel, s doors.Source[Path]) doors.Response {
		/* ... */
		return doors.ResponseComp(App{path: s, city: city})
	})

	/* ... */
}

Subscription

Derive a separate piece with the city id and provide it to the page. The page content will depend on it:

The current path is reactive too, so this uses the same State ideas on top of the Path Model.

main.go

func main() {
	r := doors.NewRouter()

	doors.UseModel(r, func(r doors.RequestModel, s doors.Source[Path]) doors.Response {
		// derive beam with the city id
		city := doors.NewBeam(s, func(p Path) int {
			// city is not selected
			if p.Home {
				return -1
			}
			return p.CityID
		})
		return doors.ResponseComp(App{path: s, city: city})
	})

	if err := http.ListenAndServe(":8080", r); err != nil {
		panic(err)
	}
}

Write a stub dashboard component that accepts city id:

dashboard.gox

package main

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

type dashboard struct {
	city int
}

elem (d dashboard) Main() {
	~{
		city, _ := driver.Locations.CitiesGet(d.city)
	}
	~(if !city.IsValid() {
		~(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{Home: true},
		})
		role="button">
		Change
	</a>
}

doors.Status is a special utility to set the page HTTP status code. It affects only the initial render.

And subscribe the app body to it:

app.gox

elem (a App) Main() {
	~/* ... */
				<main class="container">
					~(doors.Sub(a.city, elem(city int) {
						~(if city == -1 {
							~(LocationSelector(func(ctx context.Context, city int) {
								a.path.Mutate(ctx, func(p Path) Path {
									p.Home = false
									p.Dashboard = true
									p.CityID = city
									return p
								})
							}))
						} else {
							~dashboard{
								city: city,
							}
						}
					})
				}))
			</main>
	~/* ... */
}

Title

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

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

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>
	})
	~/* ... */
}

location_selector.gox

elem (l locationSelector) Main() {
	~/* ... */
	<title>Select Location</title>
	~/* ... */
}

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

main.go

package main

import (
	"net/http"

	"github.com/doors-dev/doors"
)

type Path struct {
	Home      bool `path:"/"`
	Dashboard bool `path:"/:CityID"`
	CityID    int
}

func main() {
	r := doors.NewRouter()

	doors.UseModel(r, func(r doors.RequestModel, s doors.Source[Path]) doors.Response {
		city := doors.NewBeam(s, func(p Path) int {
			if p.Home {
				return -1
			}
			return p.CityID
		})
		return doors.ResponseComp(App{path: s, city: city})
	})

	if err := http.ListenAndServe(":8080", r); err != nil {
		panic(err)
	}
}

./app.gox

package main

import (
	"context"

	"github.com/doors-dev/doors"
	"github.com/doors-dev/gox"
)

type App struct {
	path doors.Source[Path]
	city doors.Beam[int]
}

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.Sub(a.city, elem(city int) {
					~(if city == -1 {
						~(LocationSelector(func(ctx context.Context, city int) {
							a.path.Mutate(ctx, func(p Path) Path {
								p.Home = false
								p.Dashboard = true
								p.CityID = city
								return p
							})
						}))
					} else {
						~dashboard{
							city: city,
						}
					})
				}))
			</main>
		</body>
	</html>
}

./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(update func(ctx context.Context, city int)) gox.Comp {
	loc := doors.NewSource(location{})
	city := doors.NewBeam(loc, func(l location) driver.Place {
		return l.city
	})
	country := doors.NewBeam(loc, func(l location) driver.Place {
		return l.country
	})
	return locationSelector{
		location: loc,
		city: city,
		country: country,
		update: update,
	}
}

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

elem (l locationSelector) Main() {
	<article>
		<title>Select Location</title>
		<section>
			~placeSelector{
				title: "Country",
				options: new(doors.Door),
				search: driver.Locations.CountriesSearch,
				selected: l.country,
				update: func(ctx context.Context, place driver.Place) {
					l.location.Update(ctx, location{
						country: place,
					})
				},
			}
		</section>
		~(doors.Sub(l.country, l.selectCity))
		~(doors.Sub(l.city, l.submit))
	</article>
}

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,
				update: func(ctx context.Context, place driver.Place) {
					l.location.Mutate(ctx, func(l location) location {
						l.city = place
						return l
					})
				},
			}
		</section>
	})
}

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

type placeSelector struct {
	title string
	options *doors.Door
	search func(input string) ([]driver.Place, error)
	selected doors.Beam[driver.Place]
	update func(ctx context.Context, place driver.Place)
}

func (l placeSelector) Main() gox.Elem {
	return doors.Sub(l.selected, func(p driver.Place) gox.Elem {
		if p.IsValid() {
			return l.place(p)
		}
		return l.input()
	}).Main()
}

elem (l placeSelector) place(p driver.Place) {
	<h3>~(l.title): <b>~(p.Name)</b></h3>
	<button
		(doors.AClick{
			Indicator: doors.IndicatorOnlyAttr("aria-busy", "true"),
			Scope: doors.ScopeOnlyBlocking(),
			On: func(ctx context.Context, _ doors.RequestPointer) bool {
				l.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.ScopeOnlyDebounce(300 * time.Millisecond, 600 * time.Millisecond),
			Indicator: doors.IndicatorOnlyAttrQuery("#" + loaderID, "aria-busy", "true"),
			On: func(ctx context.Context, r doors.RequestInput) bool {
				l.options.Update(ctx, l.results(r.Event().Value))
				return false
			},
		})
		type="search"
		placeholder=(l.title)
		autocomplete="off"/>
	~(focus(inputID))
	~{
		l.options.Clear(ctx)
	}
	~(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 := doors.ScopeOnlyBlocking()
	return <ul>
		~(for _, place := range results {
			<li>
				<a
					(doors.AClick{
						PreventDefault: true,
						Scope: scope,
						On: func(ctx context.Context, _ doors.RequestPointer) bool {
							l.update(ctx, place)
							return true
						},
					}, doors.AKeyDown{
						Scope: scope,
						Filter: []string{"Enter"},
						On: func(ctx context.Context, _ doors.RequestKeyboard) bool {
							l.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"
)

type dashboard struct {
	city int
}

elem (d dashboard) Main() {
	~{
		city, _ := driver.Locations.CitiesGet(d.city)
	}
	~(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{Home: true},
		})
		role="button">
		Change
	</a>
}