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) {
	/* ... */
        <button
			(doors.AClick{
				Indicator: doors.IndicateAttr("aria-busy", "true"),
				On: func(ctx context.Context, _ doors.RequestPointer) bool {
                    // Add delay.
					<-time.After(1 * time.Second)
					l.apply(ctx, city.Id)
					return true
				},
			})
			id="confirm">
			Confirm
		</button>
	/* ... */
}

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(apply func(ctx context.Context, city int)) gox.Comp {
	/* ... */
	return locationSelector{
		/* ... */
		scope: new(doors.ScopeConcurrent),
	}
}

type locationSelector struct {
	/* ... */
	scope *doors.ScopeConcurrent
}

Add a scope field to the place selector:

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

Then add 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.IndicateAttr("aria-busy", "true"),
			// Now the event needs to go through two scopes.
            Scope: new(doors.ScopeBlocking).And(l.scope),
			On: func(ctx context.Context, _ doors.RequestPointer) bool {
				l.selected.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) {
	~/* ... */
		<button
			(doors.AClick{
				Indicator: doors.IndicateAttr("aria-busy", "true"),
				Scope: l.scope.Scope(0),
				On: func(ctx context.Context, _ doors.RequestPointer) bool {
					l.apply(ctx, city.Id)
					return true
				},
			})
			id="confirm">
			Confirm
		</button>
	~/* ... */
}

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

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

elem (l locationSelector) Main() {
	<article>
		<section>
			~placeSelector{
				title: "Country",
				options: new(doors.Door),
				search: driver.Locations.CountriesSearch,
				selected: l.country,
				scope: l.scope.Scope(1),
			}
		</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"),
				Scope: l.scope.Scope(0),
				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)
				},
				scope: l.scope.Scope(1),
				selected: l.city,
			}
		</section>
	})
}

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

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).And(l.scope),
			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>
}