boff

package
v2.10.23 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Aug 27, 2026 License: MIT Imports: 19 Imported by: 0

Documentation

Overview

Package boff holds the block-based rendering machinery used by backoffice pages. A page is a slice of Block; each block renders to a fragment of HTML, and the blocks are concatenated top to bottom into a page shell. This is the generic core, free of any particular page's models, so it can back a history ledger, an overview list, or any other backoffice page.

Index

Constants

View Source
const (
	RoleAdmin = "admin"
	RoleWrite = "write"
	RoleRead  = "read"
)
View Source
const PageParam = "page"

PageParam is the query parameter the overview pager pages with.

Variables

This section is empty.

Functions

func GateSlice

func GateSlice[T HasRequiredRole](rc RenderContext, values []T) []T

GateSlice returns the entries rc's viewer may see, dropping the rest. Returns nil when nothing survives, so an empty block renders nothing.

func HighlightJSON added in v2.10.2

func HighlightJSON(payload string) string

HighlightJSON colourises an already-indented JSON payload with Bootstrap text-colour utility classes, escaping it first. Those classes come from the stylesheet the page already has - server-side because a client-side highlighter would be dropped together with the <head> when the page is embedded as a backoffice fragment. Shared by every block that shows a JSON payload verbatim (a ledger record, a summary row) so the rendering stays consistent across backoffice pages.

func MayPerform

func MayPerform(viewer *jwt.Identity, required Role) bool

MayPerform reports whether viewer satisfies required. An empty required is ungated; no viewer denies everything gated.

ponytail: a role qualified with a foreign audience is denied unless the viewer was issued for that audience. Cross-audience display via the advisory Actor-Roles header is only worth building when a page actually mixes services.

func MustTemplatesFromFS

func MustTemplatesFromFS(fsys fs.FS) *template.Template

MustTemplatesFromFS is TemplatesFromFS for a package-level var: it panics on a parse error, since an embedded template that fails to parse is a build-time bug, not a runtime condition.

func Render

func Render(w io.Writer, cfg RenderConfig) error

Render renders cfg.Blocks and writes the full page shell to w, using the built-in shell. Each block renders with a RenderContext carrying cfg.Viewer, so blocks gate themselves. Use RenderWithShell to supply your own shell template (from Templates or TemplatesFromFS) carrying extra funcs or sub-templates.

func RenderOverview

func RenderOverview(w io.Writer, title string, headers []string, rows []OverviewRow) error

RenderOverview writes a standalone clickable table; each row links to Link.

func RenderOverviewWithConfig

func RenderOverviewWithConfig(w io.Writer, cfg OverviewConfig) error

RenderOverviewWithConfig is RenderOverview with the optional display elements (filter form, scope note).

func RenderTemplate

func RenderTemplate(rc RenderContext, tmpl *template.Template, name string, data any) (template.HTML, error)

RenderTemplate executes the named (sub-)template of tmpl with data, bound to rc, and returns its HTML. It is the plumbing behind TemplateBlock and the way to render a template from your own Block: the template can render a child Block inline via {{ . | render }}, which executes against the same rc. tmpl is cloned per call so the binding never leaks into a shared template, which also means tmpl itself is never executed and stays reusable.

func RenderWithShell

func RenderWithShell(w io.Writer, tpl *template.Template, cfg RenderConfig) error

RenderWithShell is Render with an explicit shell template, from Templates or TemplatesFromFS. tpl is never executed directly - RenderTemplate executes a render-bound clone so tpl stays pristine and reusable.

func Templates

func Templates() *template.Template

Templates returns a fresh, empty page template carrying only the shared default funcs (formatTime, add, formatMoney, render). Parse your own block sub-templates into it, then pass it to RenderWithShell. It is a fresh template every call, so callers never share (or accidentally execute) a common base.

func TemplatesFromFS

func TemplatesFromFS(fsys fs.FS) (*template.Template, error)

TemplatesFromFS is Templates with every .gohtml file in fsys parsed in, for a page that keeps its block templates in an embedded FS. It walks fsys, so the files may sit at any depth (a templates/ subdirectory, say) without the caller naming a glob. The parsed definitions and the shared funcs are all available to RenderWithShell.

func UpdateNavigation added in v2.10.2

func UpdateNavigation(c *echo.Context, links []NavLink)

UpdateNavigation writes links to the navigation cookie as the persistent top-level navigation, replacing whatever was stored before.

func ViewerOf

func ViewerOf(ctx context.Context, override *jwt.Identity) *jwt.Identity

ViewerOf returns the identity used for gating: the explicit override, else the verified identity of the request, else nil.

Types

type Action

type Action struct {
	Description    string // e.g. "Cancel item Sword-Pack"
	ButtonText     string // e.g. "Cancel"
	Endpoint       string // e.g. "/orders/backoffice/v1/orders/123/items/1/cancel"
	ConfirmMessage string // optional confirmation prompt; empty = submit immediately
	ConfirmText    string // label of the modal's confirm button; empty = "OK"
	StatusText     string // non-empty = show label instead of button

	// true renders a link to Endpoint instead of a form, for an action that only
	// navigates. Ignores ConfirmMessage.
	Link bool

	// RequiredRole gates the whole action: "write" or "admin" means that role on
	// this service's own audience, "payment-service:admin" names another
	// audience. Empty means always shown. A viewer who may not perform the action
	// does not see it at all - a disabled button still tells a read-only user
	// which endpoint to curl.
	RequiredRole Role
}

Action is one row in the actions table on a backoffice page. When StatusText is non-empty the row renders a static label instead of a button (e.g. "Cancelled"); otherwise a plain HTML form that POSTs to Endpoint.

The page ships no JavaScript: the form is a form, and a ConfirmMessage renders a Bootstrap modal driven by Bootstrap's own JS. Endpoint must therefore be a URL the browser can resolve - a service behind backoffice builds it from the base path backoffice sends with the fragment request, not from its own internal path.

func (Action) GatingRole

func (a Action) GatingRole() Role

GatingRole implements HasRequiredRole.

type ActionsBlock

type ActionsBlock []Action

ActionsBlock is an Action list rendered as the actions table. It gates itself at render time: the actions a viewer may not perform are dropped. Renders nothing when empty.

The slice is the block: boff.ActionsBlock{...} or a boff.ActionsBlock(actions) conversion both give you a Block.

func (ActionsBlock) Render

func (b ActionsBlock) Render(rc RenderContext) (template.HTML, error)

type Block

type Block interface {
	Render(rc RenderContext) (template.HTML, error)
}

Block is one renderable section of a page. Blocks are rendered top to bottom in the order given, so a page is just a slice of them. Providing your own Block is how you extend a page beyond its built-in sections without touching the page shell.

Render receives a RenderContext carrying the viewing identity and the shell template. A block gates itself here - it consults rc.May and emits only what the viewer may see. Because the context is passed on every call, a container block hands the same rc to its children, so gating and template resolution compose to any nesting depth.

A block is free to produce any HTML it likes. An empty block returns no bytes and is simply skipped.

func Blocks

func Blocks(children ...Block) Block

Blocks renders a sequence of child blocks and concatenates their output into one HTML fragment, so a container block can hold other blocks - and so a page turns its whole block slice into markup. Empty children (those that render to nothing) are skipped, and the same rc is passed to each, so gating reaches nested blocks unchanged.

func FilterableTableBlock added in v2.10.2

func FilterableTableBlock(filters []OverviewFilter, headers []string, rows []OverviewRow) Block

FilterableTableBlock renders the filter form and the results table together in one card, the form separated from the table by a divider. This is what RenderOverviewWithConfig uses by default. Renders the table even when filters is empty (the form section is simply omitted).

func FiltersBlock

func FiltersBlock(filters []OverviewFilter) Block

FiltersBlock renders the GET filter form on its own, outside any card. Reach for FilterableTableBlock instead when the filters belong above a results table - the common case, and the default overview layout. Renders nothing when filters is empty.

func Gate

func Gate(required Role, block Block) Block

Gate wraps a block so it renders only when the viewer satisfies required, in the notation of Action.RequiredRole. A denied viewer sees nothing - the whole wrapped block vanishes. This is the coarse counterpart to the fine-grained, per-item gating SummaryBlock and ActionsBlock do: reach for it to hide an entire section (a card, a whole custom block) behind one role.

func PagerBlock

func PagerBlock(m PagerModel) Block

PagerBlock renders the pagination nav. Renders nothing when there is no previous or next page.

func ScopeNoteBlock

func ScopeNoteBlock(note string) Block

ScopeNoteBlock renders the one-line scope note. Renders nothing when empty.

func SummaryCard added in v2.10.2

func SummaryCard(title string, items []SummaryItem) Block

SummaryCard is SummaryBlock under a card title, for a page that shows more than one summary section (e.g. account data next to profile data) and needs each one labeled. It gates and renders nothing exactly like SummaryBlock.

func TableBlock

func TableBlock(headers []string, rows []OverviewRow) Block

TableBlock renders the clickable rows table in its own card. Always renders (shows a "No records." row when rows is empty). Use this for a table with no filter form (e.g. a fixed recent-N list); use FilterableTableBlock when the table has a filter form above it, so both sit in one card.

type BlockFunc

type BlockFunc func(rc RenderContext) (template.HTML, error)

BlockFunc adapts a plain function to a Block, so a one-off block needs no named type.

func (BlockFunc) Render

func (f BlockFunc) Render(rc RenderContext) (template.HTML, error)
type Breadcrumb struct {
	Key   string `json:"k"`
	Label string `json:"l"`
	Path  string `json:"p"`
}

Breadcrumb is a single entry in the cross-service navigation trail.

Key is a stable logical identifier for the resource shown on the page, e.g. "player:abcdef" or "order:123". It is used to look up an entry across services, independent of the actual URL structure.

Label and Path are only known to (and only ever set by) the service that owns the resource identified by Key. Other services may reference the same Key without knowing Label or Path.

func BreadcrumbLocal(key, label, path string) Breadcrumb

BreadcrumbLocal creates a Breadcrumb for a resource owned by the calling service, with an authoritative label and path.

func BreadcrumbRemote(key string) Breadcrumb

BreadcrumbRemote creates a Breadcrumb for an ancestor resource whose label and path are not known to the calling service. It will be rendered without a link unless a cached entry with the same Key is already known.

func BreadcrumbRemoteWithLabel(key, label string) Breadcrumb

BreadcrumbRemoteWithLabel creates a Breadcrumb for an ancestor resource whose label is known to the calling service, but whose path is not. It will be rendered without a link unless a cached entry with the same Key is already known. BreadcrumbRemote should be prefered.

func ReconcileBreadcrumbs added in v2.10.2

func ReconcileBreadcrumbs(c *echo.Context, entries ...Breadcrumb) []Breadcrumb

ReconcileBreadcrumbs loads the current breadcrumb cookie, reconciles it with the given breadcrumb chain (see BreadcrumbCookie.Reconcile), saves the result back to the cookie, and returns the reconciled breadcrumbs.

type BreadcrumbsBlock []Breadcrumb

BreadcrumbsBlock renders the breadcrumb trail as a bootstrap breadcrumb.

type CardBlock

type CardBlock struct {
	Title    string
	Subtitle string
	Body     Block
	// Raised adds a small drop shadow (Bootstrap shadow-sm) to lift the card off
	// the page.
	Raised bool
}

CardBlock wraps a body block in a Bootstrap card with a title and optional subtitle. The body is itself a Block, rendered inline by the card template via the render func (RenderTemplate), with the same rc - so gating and nested blocks compose inside a card like anywhere else.

func (CardBlock) Render

func (b CardBlock) Render(rc RenderContext) (template.HTML, error)

type DefaultOverviewBlocks

type DefaultOverviewBlocks struct {
	Header    Block
	Filters   Block
	ScopeNote Block
	Pager     Block
	Table     Block
}

DefaultOverviewBlocks are the built-in blocks an overview page renders out of the box, handed to an OverviewConfig.Blocks callback so custom layouts can reuse them. Pager is shared by the top and bottom pager. Filters is the form on its own (not part of All()) for a custom layout that wants it apart from Table, which by default already renders the filters and the table together.

func (DefaultOverviewBlocks) All

func (d DefaultOverviewBlocks) All() []Block

All returns the default blocks in their default order (header, scope note, pager, filters+table, pager).

type FilterOption

type FilterOption struct {
	Value string
	Label string
}

FilterOption is one entry of an OverviewFilter dropdown.

type HTMLBlock

type HTMLBlock template.HTML

HTMLBlock is a block of pre-rendered HTML, for callers that want to drop in arbitrary markup without a template.

func (HTMLBlock) Render

func (b HTMLBlock) Render(RenderContext) (template.HTML, error)

type HasRequiredRole

type HasRequiredRole interface {
	GatingRole() Role
}

HasRequiredRole is implemented by anything a viewer can be gated against - an Action, a NavLink. GateSlice uses it to drop the entries a viewer may not see, so a new gated element type only has to report its role, not its own filter.

type HeaderBlock

type HeaderBlock struct {
	Title        string
	Subtitle     string
	ErrorMessage string
}

HeaderBlock is the page heading: an h1 title, an optional monospace subtitle, and an optional error alert. It was the shell's built-in header; as a block a page places it wherever it likes (or drops it), for full control of the page layout.

func (HeaderBlock) Render

func (b HeaderBlock) Render(rc RenderContext) (template.HTML, error)
type NavLink struct {
	Label        string `json:"l"`
	Path         string `json:"p"`
	RequiredRole Role   `json:"r"`

	// IsActive is set by LoadNavigation when Path is a prefix of the
	// current request path. It is never persisted in the cookie.
	IsActive bool `json:"-"`
}

NavLink is a persistent, top-level navigation link that, unlike a Breadcrumb, is not part of the current drill-down trail. It is stored alongside the breadcrumb trail so it survives across services too.

func LoadNavigation added in v2.10.2

func LoadNavigation(c *echo.Context) []NavLink

LoadNavigation returns the persistent top-level navigation links stored in the navigation cookie. It returns nil if the cookie is missing or could not be decoded.

type NavigationBlock []NavLink

NavigationBlock renders the persistent navigation links as a bootstrap nav bar.

type OverviewCell

type OverviewCell struct {
	Text string
	Link string
	Tone string
}

OverviewCell is one table cell with its optional own link.

type OverviewConfig

type OverviewConfig struct {
	Title     string
	Headers   []string
	Rows      []OverviewRow
	Filters   []OverviewFilter
	ScopeNote string

	// Page is the 1-based page number shown; 0 and 1 both mean the first page.
	// HasNext tells the pager that another page exists - the caller knows this by
	// loading one row more than it displays, so no count query is needed.
	Page    int
	HasNext bool
	// TotalPages enables the jump to the last page. Leave it 0 when counting the
	// whole result set is not worth a second query; the pager then only walks.
	TotalPages int

	// Blocks overrides the sections rendered on the page. When nil the page uses
	// its default layout: a HeaderBlock, ScopeNoteBlock, a PagerBlock, a
	// FilterableTableBlock (filters and table in one card) and a trailing
	// PagerBlock built from the fields above. When set, the callback receives
	// those default blocks and returns the blocks to render in order - so a
	// caller can reorder them, drop one, or splice its own Block in.
	Blocks func(defaults DefaultOverviewBlocks) []Block
}

OverviewConfig bundles everything the overview page renders.

type OverviewFilter

type OverviewFilter struct {
	Label       string
	Name        string
	Value       string
	Placeholder string
	// Type is the HTML input type; empty means "text". "date" gets the browser's
	// own date picker, no JavaScript needed.
	Type string
	// Options, when non-empty, renders a <select> instead of an input. The empty
	// value must be part of the list to allow "no filter".
	Options []FilterOption
	// Hidden keeps the value in the form and in the pagination links without
	// showing a control - for a filter the page receives from elsewhere (a deep
	// link carrying a player id) rather than one the viewer types.
	Hidden bool
}

OverviewFilter is one field of the overview filter form. Value is the currently applied value, echoed back so the form stays sticky.

func (OverviewFilter) InputType

func (f OverviewFilter) InputType() string

InputType is the type attribute of a text-ish filter input.

type OverviewRow

type OverviewRow struct {
	Link      string
	Cells     []string
	CellLinks []string
	// CellTones renders individual cells as a bootstrap badge in that tone
	// (index-aligned with Cells, empty entry = plain text) - for a state column an
	// operator scans down rather than reads, e.g. a status. A cell link wins over
	// a tone.
	CellTones []string
}

OverviewRow is one list entry; Cells aligns with the overview Headers.

Link makes the whole row navigate to one detail page. CellLinks instead links individual cells (index-aligned with Cells, empty entry = plain text), which is what a table with several targets needs - an id column pointing at this service's detail page, a foreign id pointing at the owning service. A cell link is rendered as a visible link; a row link stays inconspicuous.

func (OverviewRow) CellAt

func (r OverviewRow) CellAt(i int) OverviewCell

CellAt pairs a cell with its own link, so the template does not have to index two slices in parallel.

type PagerModel

type PagerModel struct {
	Page       int
	TotalPages int
	FirstLink  string
	PrevLink   string
	NextLink   string
	LastLink   string
}

PagerModel is the pagination state a PagerBlock renders. Both pagers (above and below the table) render the same model. The Link fields are empty when there is no such page.

type PanelBlock added in v2.10.2

type PanelBlock struct {
	Children []Block
	// Raised adds a small drop shadow (Bootstrap shadow-sm) to lift the card off
	// the page.
	Raised bool
}

PanelBlock wraps child blocks in a bare Bootstrap card frame, one that adds no card-body padding of its own - unlike CardBlock, whose Body always sits in a padded card-body. Reach for PanelBlock when a card holds sections with different padding needs (e.g. a padded filter form above a table that must sit flush against the card's edges); each child renders itself and is responsible for its own spacing. A child that renders to nothing (Skip, or an empty Blocks) simply contributes nothing.

func (PanelBlock) Render added in v2.10.2

func (b PanelBlock) Render(rc RenderContext) (template.HTML, error)

type RenderConfig

type RenderConfig struct {
	Title  string
	Viewer *jwt.Identity
	Blocks []Block
}

RenderConfig is the caller-supplied config for the shell. Title feeds the document <title>; the visible page heading is a HeaderBlock a page adds to Blocks. Blocks are rendered into the shell body in order.

Viewer is the identity the page's gated blocks filter themselves against. It is put into the RenderContext each block renders with. Leave it nil to fail closed - every gated element is then hidden. Use ViewerOf to derive it from the request context.

type RenderContext

type RenderContext struct {
	// Viewer is the identity gated blocks filter themselves against. Nil means
	// fail closed: May reports false for everything gated.
	Viewer *jwt.Identity
}

RenderContext carries everything a Block needs while it renders - currently the viewing identity to gate against. It is passed to every Block.Render, including nested ones: a container block hands its own rc straight to its children, so gating reaches any depth.

It is a struct (not a bare *jwt.Identity) so more render-time values can be propagated later - a base path for URL building, a nonce, feature flags - without churning the Block interface again.

func (RenderContext) May

func (rc RenderContext) May(required Role) bool

May reports whether the context's viewer satisfies required, in the notation of Action.RequiredRole ("write", "admin", or "audience:role"). An empty required is ungated and always allowed; a nil viewer denies everything gated.

This is the one check both Go blocks and the "may" template function share, so a block's Render and its template agree on who may see what. Reuse it when you write your own block instead of reaching for jwt directly.

type Role

type Role string

Role names the permission a gated element requires, in the notation the whole package shares: "write" or "admin" means that role on this service's own audience; "payment-service:admin" names a role on a foreign audience. The empty Role is ungated - always shown.

func RoleOf

func RoleOf(audience, role string) Role

type SummaryBlock

type SummaryBlock []SummaryItem

SummaryBlock is a SummaryItem list rendered as the current-state summary card. It gates itself at render time: the links a viewer may not follow are demoted to plain values. Renders nothing when empty.

The slice is the block: boff.SummaryBlock{...} or a boff.SummaryBlock(items) conversion both give you a Block. Use SummaryCard instead for a titled card.

func (SummaryBlock) Render

func (b SummaryBlock) Render(rc RenderContext) (template.HTML, error)

type SummaryItem

type SummaryItem struct {
	Label string
	Value string
	// Link, when non-empty, renders Value as an <a href> to this URL instead of
	// plain text - e.g. a cross-service backoffice link to the page that owns the
	// referenced entity (a payment or draw history page behind the api-gateway).
	Link string

	// RequiredRole gates the Link only, in the same notation as
	// Action.RequiredRole. A denied item still shows Label and Value, just not as
	// an anchor: the value itself is not the secret, the page behind it is.
	RequiredRole Role

	// JSON, when non-empty, renders Value as a collapsible <details> whose body is
	// this payload, highlighted like a ledger record - for a row that summarises a
	// structure the operator sometimes needs verbatim (an order item as stored).
	JSON string

	// Tone, when non-empty, renders Value as a bootstrap badge in that tone
	// ("success", "danger", "warning", "info", "secondary", ...) - for a state
	// value an operator scans for rather than reads, e.g. an order status. Ignored
	// for a JSON row.
	Tone string
}

SummaryItem is one label/value row shown above the page content, describing the current state of the tracked object. Ordered slice (not a map) so the page renders stably.

func (SummaryItem) JSONHTML added in v2.10.2

func (i SummaryItem) JSONHTML() template.HTML

JSONHTML is SummaryItem.JSON colourised, same treatment as a record payload.

type TemplateBlock

type TemplateBlock struct {
	Name     string
	Model    any
	Skip     bool
	Template *template.Template
}

TemplateBlock renders a named (sub-)template with a model. It is the common shape of the built-in blocks and the easiest way to add your own: give it the Name of a template and a Model to execute it with.

Template must contain a definition for Name. The built-in blocks set it to the package shell, so they resolve the sub-templates defined alongside the shell. Set Skip to render nothing, which is how a section vanishes when it has no content.

func (TemplateBlock) Render

func (b TemplateBlock) Render(rc RenderContext) (template.HTML, error)

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL