Advanced Scope Usage

In practice, the submit handler will not respond instantly. What happens if we interact with the UI while it is still processing?

Let’s simulate conflicting actions:

location_selector.gox

elem (l locationSelector) submit(city driver.Place) {
	/* ... */
		<a
			(doors.AClick{
                /* ... */
				On: func(ctx context.Context, _ doors.RequestPointer) bool {
					// add delay
					<-time.After(1 * time.Second)
					l.update(ctx, city.Id)
					return false
				},
			})
			id="submit"
			href="#"
			role="button">
			Confirm
		</a>
	/* ... */
}

Submit can conflict with place changes without concurrent scopes

Nothing catastrophic happens if we leave it like this, but the interaction can be polished further with Concurrent Scope from the Scopes API.

For the full scope model, see Scopes.

Concurrent

A concurrent scope can be occupied only by events with the same group id.

Add a concurrent scope parent to the location selector:

func LocationSelector(update func(ctx context.Context, city int)) gox.Comp {
	/* ... */
	return locationSelector{
		/* ... */
		update: update,
		scope: new(doors.ScopeConcurrent),
	}
}

type locationSelector struct {
	/* ... */
	update func(ctx context.Context, city int)
	scope *doors.ScopeConcurrent
}

Add a scope field to the place selector:

type placeSelector struct {
	/* ... */
	scope doors.Scope
}

Then include it in the existing scope pipeline:

elem (l placeSelector) place(p driver.Place) {
	<h3>~(l.title): <b>~(p.Name)</b></h3>
	<button
		(doors.AClick{
			Indicator: doors.IndicatorOnlyAttr("aria-busy", "true"),
			// now the event needs to go through two scopes
			Scope: []doors.Scope{new(doors.ScopeBlocking), l.scope},
			On: func(ctx context.Context, _ doors.RequestPointer) bool {
				l.update(ctx, driver.Place{})
				return true
			},
		})
		class="secondary">
		Change
	</button>
}

Provide concurrent scope group 1 to both place selectors:

elem (l locationSelector) Main() {
	/* ... */
	<section>
		~placeSelector{
			/* ... */
			scope: l.scope.Scope(1),
		}
	</section>
	/* ... */
}

elem (l locationSelector) selectCity(country driver.Place) {
	~(if country.IsValid() {
		<section>
			~placeSelector{
				/* ... */
				scope: l.scope.Scope(1),
			}
		</section>
	})
}

Finally, apply the concurrent scope to the submit button with a different group id:

elem (l locationSelector) submit(city driver.Place) {
	~/* ... */
	<a
		(doors.AClick{
			Scope: []doors.Scope{l.scope.Scope(0)},
			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>
	~/* ... */
}

The Scopes API is purely optional and is usually used at later stages to give the UI a more polished feel. Most of the time, doors.ScopeOnlyBlocking() is already enough.

Result

This setup ensures that either the submit action or the change-place action can run, but not both at the same time:

Concurrent scope prevents submit and change actions from overlapping

At the same time, changes to country and city do not block each other, because they share the same concurrent group:

Country and city changes can still run within the shared concurrent group


Next: Query

Code

./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,
		scope: new(doors.ScopeConcurrent),
	}
}

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

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,
					})
				},
				scope: l.scope.Scope(1),
			}
		</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
					})
				},
				scope: l.scope.Scope(1),
			}
		</section>
	})
}

elem (l locationSelector) submit(city driver.Place) {
	~(if city.IsValid() {
		<hr/>
		<a
			(doors.AClick{
				Scope: []doors.Scope{l.scope.Scope(0)},
				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)
	scope doors.Scope
}

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.Scope{new(doors.ScopeBlocking), l.scope},
			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>
}