Skip to content
webtypPublic

About

Tech-agnostic CRUD view contract: a module declares its Descriptor, any renderer draws it

Resources

Stars

0 stars

Watchers

0 watching

Forks

Repository files navigation

webtyp/view

Tech-agnostic CRUD view contract: a domain module declares its list, record and operations; any renderer (DOM, HTMX, SSR, native, headless test) draws it.

This README is the official usage document. It is written so that an agent/LLM with no prior context can create or edit a visual component correctly guided only by the typed signatures below. If something here requires reading the implementation, that is a defect — report it.

I want to… → use

I want to… Use
Create a list/detail view for my model view.New(lister, &X{}, opts...) where lister implements view.Lister
Make my rows appear in the list Implement Item() view.Item on the record type (view.Itemizer)
Enable saving Implement Save(recs []model.Model, done func(error)) — the returned Presenter then satisfies view.Saver
Enable field patches Implement Update(ids []string, rec model.Model, fields []string, done func(error)) — the Presenter then satisfies view.Updater
Enable deleting Implement Delete(ids []string, done func(error)) — the returned Presenter then satisfies view.Deleter
Know if the view can save/delete (renderer side) s, ok := p.(view.Saver) / d, ok := p.(view.Deleter)
Load / refresh the list p.Reload(func(err error){ … }) — results arrive asynchronously
Pick a record and get its full model m := p.Select(id) (nil if the id is unknown)
Clear the selection p.Deselect()
Filter the list as the user types p.Filter(term) (local, case-insensitive over Label+Description)
Connect over a transport (mcp, http) view.NewCallerLister(caller, view.Ops{…}, newList) then view.New(l, &X{}, …)
Run a command on the whole list Ops.Actions + presenter.(view.Actioner).Run(op, done)
Show an error/success message Renderer's job: branch on the error passed to the done callback of Reload/Save/Delete
Test a renderer implementation conformance.Run(t, factory) — it must pass every clause
Simulate a view without a browser view/mock.Renderer

Connecting a view to your data

Two cases, side by side.

In-process — implement Lister (+ the capabilities you have) directly on your store. There is no operation name to spell, no envelope to unpack:

type deviceStore struct{ db *orm.DB }

// Every operation is asynchronous: the result is handed to done, which is
// always non-nil. An in-process store may call it immediately; a transport
// calls it from its own callback. Never block, never return the result.
func (s *deviceStore) List(done func([]model.Model, error)) {
	var rows []model.Model
	err := s.db.Query(&Device{}).ReadAll(
		func() model.Model { return &Device{} },
		func(m model.Model) { rows = append(rows, m) },
	)
	done(rows, err)
}

func (s *deviceStore) Save(recs []model.Model, done func(error)) {
	for _, m := range recs {
		if err := s.upsert(m.(*Device)); err != nil {
			done(err)
			return
		}
	}
	done(nil)
}

func (s *deviceStore) Update(ids []string, rec model.Model, fields []string, done func(error)) {
	done(s.db.UpdateFields(rec, fields, storage.In("id", anyIDs(ids))))
}

func (s *deviceStore) Delete(ids []string, done func(error)) {
	done(s.db.Delete(&Device{}, storage.In("id", anyIDs(ids))))
}

view.New(&deviceStore{db: deviceDB}, &Device{}, view.WithTitle("Computadores"))

Remote — wrap your router.Caller transport in the adapter this package owns, then build the view over it:

l := view.NewCallerLister(caller,
	view.Ops{
		Module: "device", List: "list", Save: "save", Delete: "delete",
		Actions: []view.Action{{
			Op: "apply_network", Label: "Apply",
			Args: func(recs []model.Model) model.Encodable { return &ApplyArgs{Fingerprint: recs[0].(*Device).Plan} },
		}},
	},
	func() model.ModelSlice { return &DeviceList{} })
view.New(b, &Device{}, view.WithTitle("Computadores"))

Capabilities are methods, not strings — the renderer paints +/🗑/✏ from what your lister implements, so a missing method is a compile-time fact. There is no string to misspell and no silent success-without-write: if the lister does not implement Save, the presenter simply is not a view.Saver. The contract is shared: a lister declares capabilities with view.Saver/Updater/Deleter — the same interfaces the renderer asserts on the presenter. One name per capability, used on both sides.

Migration note (v0.5.0 → v0.6.0)

Lister.List, Saver.Save, Updater.Update, Deleter.Delete and Presenter.Reload no longer return an error — they take a done callback, delivered last, and every outcome (including validation errors like "Save requires at least one record") travels through it. The variadic Save(recs ...model.Model) / Delete(ids ...string) are gone: pass a slice (the one-record case is a slice of one).

// v0.5.0 — synchronous, variadic
rows, err := lister.List()
if err != nil { … }
err = saver.Save(rec1, rec2)

// v0.6.0 — asynchronous, slice + done
lister.List(func(rows []model.Model, err error) { … })
saver.Save([]model.Model{rec1, rec2}, func(err error) { … })

The change exists because the old shape blocked a channel over an inherently asynchronous transport — a program that deadlocks on Go WASM (all goroutines asleep) and only works on TinyGo. Every screen built on view now requests work and paints the result from inside the callback, exactly like a React useEffect or an Elm Cmd.

Migration note (v0.4.0 → v0.5.0)

Backend is now Lister — the seam finally follows the verb, like Saver/Updater/Deleter. Same for its derivatives: NewCallerBackend → NewCallerLister, conformance.FakeBackend → conformance.FakeLister. Pure renames; signatures and bodies are unchanged.

Migration note (v0.3.0 → v0.4.0)

BackendSaver/BackendUpdater/BackendDeleter are gone; implement Saver/Updater/Deleter instead:

// v0.3.0
func (s *deviceStore) Save(recs []model.Model) error
func (s *deviceStore) Delete(ids []string) error

// v0.4.0 — add the ellipsis; bodies are unchanged
func (s *deviceStore) Save(recs ...model.Model) error
func (s *deviceStore) Delete(ids ...string) error

Update is unchanged.

Migration note (from ≤ v0.2.x)

New lost listOp/newList; WithSaveOp/WithUpdateOp/WithDeleteOp and WithArgs are gone (WithArgs had zero call sites). A router.Caller consumer wraps it in NewCallerLister:

// before
view.New(caller, &User{}, OpListUsers, func() model.ModelSlice { return &UserList{} },
	view.WithTitle("Usuarios"), view.WithSaveOp(OpUpsertUser), view.WithDeleteOp(OpDeleteUser))

// after
l := view.NewCallerLister(caller,
	view.Ops{Module: "user", List: OpListUsers, Save: OpUpsertUser, Delete: OpDeleteUser},
	func() model.ModelSlice { return &UserList{} })
view.New(b, &User{}, view.WithTitle("Usuarios"))

API Reference

The complete public API of view:

package view

import (
	"webtyp.com/model"
	"webtyp.com/router"
)

// Item is ONE projected row of the list — the neutral form any renderer can draw.
// No markup: only what a list needs to display and let the user pick a record.
type Item struct {
	ID          string // selection key
	Label       string // primary text
	Description string // secondary text (a SKU, an IP, a subtitle)
}

// Itemizer is implemented by a domain record that knows how to project itself as a
// list row. It is the ONLY view-specific code a module writes on its model.
type Itemizer interface {
	Item() Item
}

// Presenter is the UI-agnostic core behind any CRUD view: list, select, reload.
// Always present. Save/Update/Delete are separate capabilities (see below).
type Presenter interface {
	Title() string
	SearchPlaceholder() string
	Record() model.Model

	Items() []Item             // projected list from the last Reload
	Filter(term string) []Item // local case-insensitive match over Label+Description; "" returns all

	// Reload asks the Lister for the records, then projects and indexes them.
	// The result is asynchronous: done runs once, after List delivers, and the
	// renderer paints Items() from inside it.
	Reload(done func(error))

	Selected() string             // currently selected id ("" if none)
	Select(id string) model.Model // marks id and returns its record from the internal index; unknown id → nil, selection unchanged
	Deselect()                    // clears the selection
}

// Capabilities. The renderer discovers them by type assertion at the seam.
// They are only present when the lister implements the matching interface:
// no Save method ⇒ the returned value has no Save method ⇒ p.(Saver) fails.
// All three deliver their outcome asynchronously through done (never nil,
// never blocking) — the same single channel every failure travels.
type Saver interface {
	Save(recs []model.Model, done func(error))
}
type Updater interface {
	Update(ids []string, rec model.Model, fields []string, done func(error))
}
type Deleter interface {
	Delete(ids []string, done func(error))
}

// Lister is what a view needs from the application: the records to show.
type Lister interface {
	// List asks for every record. The result arrives asynchronously through
	// done, which is always non-nil; List must NOT block waiting for it.
	List(done func(rows []model.Model, err error))
}

// Ops names the remote operations a CallerLister invokes. An empty name means
// the remote side does not offer that operation.
type Ops struct {
	Module string
	List   string
	Save   string
	Update string
	Delete string
}

func NewCallerLister(c router.Caller, ops Ops, newList func() model.ModelSlice) Lister

// Option is a functional configuration option for New.
type Option func(*config)

func WithTitle(title string) Option
func WithSearchPlaceholder(placeholder string) Option

// New builds the presenter over a Lister. Mandatory collaborators are
// positional (the compiler enforces their presence); a nil mandatory value
// panics at construction — a loud development diagnostic, never a deferred
// runtime mystery. The Presenter's capabilities MIRROR the lister's.
func New(l Lister, record model.Model, opts ...Option) Presenter

Quick Start

1. The domain module declares its view (3 steps)

package catalog

import (
	"webtyp.com/model"
	"webtyp.com/view"
)

// Step 1 — the record projects itself as a list row.
func (c *CatalogItem) Item() view.Item {
	return view.Item{ID: c.ID, Label: c.Name, Description: c.SKU}
}

// Step 2 — the store implements the domain seam (List + the writes it supports).
type catalogStore struct{ /* … */ }

func (s *catalogStore) List(done func([]model.Model, error)) { /* … done(rows, err) */ }
func (s *catalogStore) Save(recs []model.Model, done func(error)) { /* … done(err) */ }

// Step 3 — build the presenter. No projection loop, no cache, no fill:
// the presenter lists through the lister and indexes id → model itself.
func NewCatalogView(store *catalogStore) view.Presenter {
	return view.New(store, &CatalogItem{}, view.WithTitle("Catalog Management"))
}

That is the entire module-side code. CatalogItem is generated by ormc and already satisfies model.Model.

2. The renderer wraps the presenter (UI layer)

A renderer never imports the domain module. It draws Items(), generates form inputs from Record().Schema(), and discovers capabilities by assertion:

package crudview

import "webtyp.com/view"

type Renderer struct{ p view.Presenter }

// Mount: request the list and paint from INSIDE the callback — the result is
// not available when Reload returns.
func (r *Renderer) Mount() {
	r.p.Reload(func(err error) {
		if err != nil {
			r.ShowError(err) // messages are the renderer's concern
			return
		}
		r.drawList(r.p.Items())
	})
	if _, ok := r.p.(view.Saver); ok {
		r.drawSaveButton() // only exists if the lister implements Save
	}
	if _, ok := r.p.(view.Deleter); ok {
		r.drawDeleteButton()
	}
}

func (r *Renderer) OnSaveClicked() {
	s := r.p.(view.Saver) // safe: the button only exists if the assertion held
	rec := r.p.Record()
	r.syncFormToRecord(rec) // explicit, unidirectional: form → record → Save
	s.Save([]model.Model{rec}, func(err error) {
		if err != nil {
			r.ShowError(err)
			return
		}
		r.ShowSuccess()
	})
}

func (r *Renderer) OnSearchTyped(term string) {
	r.drawList(r.p.Filter(term)) // local filtering
}

Error model

  • Reload, Save, Update, Delete are asynchronous: each takes a done func(error) (always non-nil) and returns nothing. That callback IS the single user-message channel — validation errors and transport errors alike arrive through it, and the renderer decides how to present them (toast, inline, console). view never renders, logs, or swallows messages — there is no SetLog, and there is no second way to report a result.
  • New and NewCallerLister panic on nil/empty mandatory collaborators. These are programmer wiring bugs, detected deterministically at startup during development (the template.Must pattern). Logging and continuing would return a half-built presenter that crashes far from the cause — a deferred silent failure, which the harness forbids.
  • Misuse that cannot be made a compile error is a loud error, never silence: Delete of an unknown id errors (nothing is sent); a row that does not implement Itemizer makes Reload fail naming the offending record (via model.ModuleNaming.ModelName() when the row provides it — webtyp/fmt has no reflect-based type-name formatter by design).

Design Goals

  1. Agnostic to UI technology — the module never imports dom, form or html; any renderer can draw the contract.
  2. Agnostic to codec and transport — in-process consumers import only model; the transport's codec decodes into the module's typed list (model.ModelSlice) inside NewCallerLister, the single place that knows the wire shape.
  3. Compile-time safety — mandatory collaborators are positional in New; capabilities are method sets, so a view whose lister cannot save simply has no Save method to call.
  4. Callback-based, runtime-agnostic — Reload/Save/Update/Delete take a done callback; view never blocks and never uses a channel. The async network caller is adopted as-is by NewCallerLister, so the same library works on Go WASM and TinyGo alike. The renderer paints from inside the callback.
  5. Explicit form synchronization — Save([]model.Model{…}, done) takes the synchronized records explicitly: unidirectional data flow, no hidden shared-pointer mutations.
  6. Glue lives here, once — projection loop, id→model index, capability wiring and the transport adapter are implemented in this package, not repeated in every module.

WebAssembly/TinyGo Compatibility

To ensure 100% compatibility with WebAssembly (WASM) and TinyGo targets, standard library packages (such as fmt, encoding/json, or encoding/binary) should be avoided in production code. Use the following tech-agnostic, low-allocation alternatives instead:

  • webtyp.com/fmt for formatting and error creation.
  • webtyp.com/json for JSON serialization/deserialization.
  • webtyp.com/binary for binary protocols.

Reference and Conformance

  • view/mock — headless reference renderer for browser-less simulation and unit tests.
  • view/conformance — exports conformance.Run(t, Factory) plus the conformance.FakeLister typed double. A renderer is correct only if it passes every clause (list load on mount, label rendering via Itemizer, select/deselect, save/delete capability assertions, filter semantics, loud errors on unknown ids). conformance.Payload/conformance.Has assert the wire shape a router.Caller double saw — the tool for testing the transport path.

Documentation

  • AGENTS.md — contributor rules: the async callback contract (never block, never channels), WASM/TinyGo restrictions, the capability-wrapper pattern, and testing with gotest. Read before touching any code.
  • docs/SPECS.md — the exact contract: public surface, callback semantics, capability mirroring, every error message word for word, the wire shapes, and the conformance clause list. Every table is a test assertion.

About

Tech-agnostic CRUD view contract: a module declares its Descriptor, any renderer draws it

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages