v0.7.4 beta
Back-end UI Framework

for feature-rich, secure, and fast web apps in Go

Tutorial

Dynamic Navigation

1. Query Params

In the weather API, besides the city, we also have two variables: units (metric/imperial) and forecast days.

Add it to our path model:

type Path struct {
	Selector  bool `path:"/"`
	Dashboard bool `path:"/:Id"`
	Id        int
	Units     *driver.Units `query:"units"`
	Days      *int          `query:"days"`
}

Notice I used reference types. Otherwise, query parameters get a zero value and always appear in the URI.

Decoding and encoding of query parameters are provided by the go-playground/form v4 library. So refer to its documentation for all features.

2. Dashboard Fragment

./dashboard.templ

To keep the app simple, let’s move the dashboard to a separate fragment.

func dashboard(id int) templ.Component {
	return doors.F(&dashboardFragment{
		id: id,
	})
}

type dashboardFragment struct {
	id int
}

templ (f *dashboardFragment) Render() {
	<article>
		{{ city, _ := driver.Cities.Get(f.id) }}
		if city.Name == "" {
			@doors.Status(404)
			<h1>Location Not Found</h1>
		} else {
			<h1>{ city.Name }, { city.Country.Name }</h1>
		}
	</article>
}

The dashboard depends on the location ID (provided by the app already) and the days and units query parameters:

We derive those from the path:

func dashboard(id int, path doors.Beam[Path]) templ.Component {
	settings := doors.NewBeam(path, func(p Path) dashboardSettings {
		s := dashboardSettings{}

		// default unit value and some validation
		if p.Units == nil || *p.Units != driver.Imperial {
			s.units = driver.Metric
		} else {
			s.units = driver.Imperial
		}

		// default days value and some validation
		if p.Days == nil || *p.Days <= 1 {
			s.days = 1
		} else {
			s.days = min(*p.Days, 7)
		}

		return s
	})

	return doors.F(&dashboardFragment{
		id:       id,
		settings: settings,
	})
}

type dashboardFragment struct {
	id       int
	settings doors.Beam[dashboardSettings]
}

Render the dashboard on the page:

./app.templ

templ (a *app) Body() {
	@doors.Sub(a.id, func(id int) templ.Component {
		if id == -1 {
			return locationSelector(func(ctx context.Context, city driver.Place) {
				a.path.Mutate(ctx, func(p Path) Path {
					p.Selector = false
					p.Dashboard = true
					p.Id = city.Id
					return p
				})
			})
		}
		// render the dashboard component
		return dashboard(id, a.path)
	})
}

3. Menu

./dashboard.templ

Change City

For the location selector to appear, we need to render a link to /. It would also be nice if query parameters persisted, so we generate the link based on the settings beam:

templ (f *dashboardFragment) Render() {
	<article>
		/* h1 */
		// render the menu
		@f.menu()
	</article>
}

templ (f *dashboardFragment) menu() {
	<nav>
		// subscribe to the settings beam
		@doors.Sub(f.settings, func(s dashboardSettings) templ.Component {
			return f.changeLocation(s)
		})
	</nav>
}

templ (f *dashboardFragment) changeLocation(s dashboardSettings) {
	// generate href attribute and attach click listener
	// for live update
	@doors.AHref{
		// target path model
		Model: Path{
			Selector: true,
			Units:    &s.units,
			Days:     &s.days,
		},
	}
	<a>Change Location</a>
}

AHref also supports the Scopes and Indication APIs

Switch to the location selector via dynamic link:

Image description

After clicking, the query parameters appear with default values. It’s okay, but not ideal.

Provide nil for the defaults so the behavior is consistent:

templ (f *dashboardFragment) changeLocation(s dashboardSettings) {
	// evaluates the provided function during render
	@doors.E(func(ctx context.Context) templ.Component {
		m := Path{
			Selector: true,
		}
	
		// keeps nil for default values
		if s.units != driver.Metric {
			m.Units = &s.units
		}
		if s.days != 1 {
			m.Days = &s.days
		}
	
		return doors.AHref{
			Model: m,
		}
	})
	<a>Change Location</a>
}

Result:

Image description

Switch Units

Render a link to switch units back and forth:

templ (f *dashboardFragment) switchUnits(s dashboardSettings) {
    @doors.E(func(ctx context.Context) templ.Component {
        m := Path{
            Dashboard: true,
            Id:        f.id,
        }

        // maintain the days value
        if s.days != 1 {
            m.Days = &s.days
        }

        // switch units
        if s.units == driver.Metric {
            m.Units = driver.Imperial.Ref()
        } else {
            // metric is default
            m.Units = nil
        }

        return doors.AHref{
            Model: m,
        }
    })
    <a class="contrast">
        &#8644; { s.units.Label() }
    </a>
}

Add some styles and render the units switcher:


templ (f *dashboardFragment) menu() {
	// minifies css and converts inline styles to a cacheable <link rel="stylesheet"...>
	@doors.Style() {
		<style>
            nav.dashboard {
                display: flex;
                flex-direction: row;
                justify-content: start;
                gap: var(--pico-spacing);
                white-space: nowrap;
                flex-wrap: wrap;
            }
        </style>
	}
	<nav class="dashboard">
		@doors.Sub(f.settings, func(s dashboardSettings) templ.Component {
			return f.changeLocation(s)
		})
		@doors.Sub(f.settings, func(s dashboardSettings) templ.Component {
			return f.switchUnits(s)
		})
	</nav>
}

Query param switching:

Image description

Forecast Days

The forecast-days links must preserve the units query value. To avoid unnecessary updates, derive a beam for the units:

func dashboard(id int, path doors.Beam[Path]) templ.Component {
	settings := doors.NewBeam(path, func(p Path) dashboardSettings {
		s := dashboardSettings{}
		/* ... */
		return s
	})

	// derive units
	units := doors.NewBeam(settings, func(s dashboardSettings) driver.Units {
		return s.units
	})

	return doors.F(&dashboardFragment{
		id:       id,
		settings: settings,
		units:    units,
	})
}

type dashboardFragment struct {
	id       int
	settings doors.Beam[dashboardSettings]
	units    doors.Beam[driver.Units]
}

Subscribe the menu to it:

templ (f *dashboardFragment) menu() {
    /* ... */
    <nav class="dashboard">
        @doors.Sub(f.settings, func(s dashboardSettings) templ.Component {
            return f.changeLocation(s)
        })

        // subscribe days menu to the units beam
        @doors.Sub(f.units, func(u driver.Units) templ.Component {
            return f.changeDays(u)
        })

        @doors.Sub(f.settings, func(s dashboardSettings) templ.Component {
            return f.switchUnits(s)
        })
    </nav>
}

And maintain the units query value in the days menu:

templ (f *dashboardFragment) changeDays(u driver.Units) {
	for i := range 7 {
		{{ days := i + 1 }}
		@doors.E(func(ctx context.Context) templ.Component {
			m := Path{
				Dashboard: true,
				Id:        f.id,
			}
		
			// if not default
			if days != 1 {
				m.Days = &days
			}
			// if not default
			if u != driver.Metric {
				m.Units = driver.Imperial.Ref()
			}
		
			return doors.AHref{
				Model: m,
			}
		})
		<a class="secondary">
			if i == 0 {
				1 day
			} else {
				{ fmt.Sprintf("%d days", days) }
			}
		</a>
	}
}

Reactive menu:

Image description

doors doesn’t keep the whole DOM in memory. With beam derivation, you explicitly tie a specific HTML section to a specific piece of data. Diff data, not DOM.

4. Active Link Highlighting

The client can automatically apply active-link highlighting if you configure it in doors.AHref:

templ (f *dashboardFragment) changeDays(u driver.Units) {
	for i := range 7 {
		{{ days := i + 1 }}
		@doors.E(func(ctx context.Context) templ.Component {
			m := Path{
				Dashboard: true,
				Id:        f.id,
			}
			/* ... */
			return doors.AHref{
				Model: m,
				// active link highlighting configuration
				Active: doors.Active{
					// use Indication API to set the "aria-current" attribute
					Indicator: doors.IndicatorOnlyAttr("aria-current", "page"),
				},
			}
		})
		<a class="secondary">
			if i == 0 {
				1 day
			} else {
				{ fmt.Sprintf("%d days", days) }
			}
		</a>
	}
}

By default, it checks the whole path and all query values to apply the indication, but you can configure a narrower matching strategy.

Active link is highlighted:

Image description

Next: Charts

Code

package main

import (
	"context"
	"fmt"
	"github.com/derstruct/doors-dashboard/driver"
	"github.com/doors-dev/doors"
)

type dashboardSettings struct {
	units driver.Units
	days  int
}

func dashboard(id int, path doors.Beam[Path]) templ.Component {
	settings := doors.NewBeam(path, func(p Path) dashboardSettings {
		s := dashboardSettings{}
		// default unit value and some validation
		if p.Units == nil || *p.Units != driver.Imperial {
			s.units = driver.Metric
		} else {
			s.units = driver.Imperial
		}
		// default days value and some validation
		if p.Days == nil || *p.Days <= 1 {
			s.days = 1
		} else {
			s.days = min(*p.Days, 7)
		}
		return s
	})

	// derive units
	units := doors.NewBeam(settings, func(s dashboardSettings) driver.Units {
		return s.units
	})

	return doors.F(&dashboardFragment{
		id:       id,
		settings: settings,
		units:    units,
	})
}

type dashboardFragment struct {
	id       int
	settings doors.Beam[dashboardSettings]
	units    doors.Beam[driver.Units]
}

templ (f *dashboardFragment) Render() {
	<article>
		{{ city, _ := driver.Cities.Get(f.id) }}
		if city.Name == "" {
			@doors.Status(404)
			<h1>Location Not Found</h1>
		} else {
			<h1>{ city.Name }, { city.Country.Name }</h1>
		}
		@f.menu()
	</article>
}

templ (f *dashboardFragment) menu() {
	// minifies css and converts inline styles to a cacheable <link rel="stylesheet"...>
	@doors.Style() {
		<style>
            nav.dashboard {
                display: flex;
                flex-direction: row;
                justify-content: start;
                gap: var(--pico-spacing);
                white-space: nowrap;
                flex-wrap: wrap;
            }
        </style>
	}
	<nav class="dashboard">
		@doors.Sub(f.settings, func(s dashboardSettings) templ.Component {
			return f.changeLocation(s)
		})
		@doors.Sub(f.units, func(u driver.Units) templ.Component {
			return f.changeDays(u)
		})
		@doors.Sub(f.settings, func(s dashboardSettings) templ.Component {
			return f.switchUnits(s)
		})
	</nav>
}

templ (f *dashboardFragment) changeLocation(s dashboardSettings) {
	// evaluates the provided function during render
	@doors.E(func(ctx context.Context) templ.Component {
		m := Path{
			Selector: true,
		}
	
		// keeps nil for default values
		if s.units != driver.Metric {
			m.Units = &s.units
		}
		if s.days != 1 {
			m.Days = &s.days
		}
	
		return doors.AHref{
			Model: m,
		}
	})
	<a>Change Location</a>
}

templ (f *dashboardFragment) changeDays(u driver.Units) {
	for i := range 7 {
		{{ days := i + 1 }}
		@doors.E(func(ctx context.Context) templ.Component {
			m := Path{
				Dashboard: true,
				Id:        f.id,
			}
		
			// if not default
			if days != 1 {
				m.Days = &days
			}
			// if not default
			if u != driver.Metric {
				m.Units = driver.Imperial.Ref()
			}
		
			return doors.AHref{
				Model: m,
				Active: doors.Active{
					// use Indication API to set the "aria-current" attribute
					Indicator: doors.IndicatorOnlyAttr("aria-current", "page"),
				},
			}
		})
		<a class="secondary">
			if i == 0 {
				1 day
			} else {
				{ fmt.Sprintf("%d days", days) }
			}
		</a>
	}
}

templ (f *dashboardFragment) switchUnits(s dashboardSettings) {
	@doors.E(func(ctx context.Context) templ.Component {
		m := Path{
			Dashboard: true,
			Id:        f.id,
		}
	
		// maintain the days value
		if s.days != 1 {
			m.Days = &s.days
		}
	
		// switch units
		if s.units == driver.Metric {
			m.Units = driver.Imperial.Ref()
		} else {
			// metric is default
			m.Units = nil
		}
	
		return doors.AHref{
			Model: m,
		}
	})
	<a class="contrast">
		&#8644; { s.units.Label() }
	</a>
}