Help us shape the future of Go! Take the 2026 Go Developer Survey

admin

package module
v0.2.0 Latest Latest
Warning

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

Go to latest
Published: Oct 23, 2026 License: MIT Imports: 26 Imported by: 0

README

go-admin

tests Go Reference

go-admin turns resource definitions written in Go into a server-rendered admin panel for SaaS back offices: lists with search, filters, sorting, pagination and bulk actions, detail pages, validated create and edit forms, confirmed deletes and a dashboard. It is a plain http.Handler with embedded templates and no code generation, it never authenticates users itself, it asks your Authorizer before every page and write, and it ships with a strict Content-Security-Policy, cross-origin request protection and accessible markup. Records are read and written through a small generic repository interface, with an in-memory implementation and a PostgreSQL one on pgx v5.

Install

go get github.com/byteminus/go-admin

Requires Go 1.26 or later.

Quick start

package main

import (
	"context"
	"log"
	"net/http"
	"time"

	"github.com/jackc/pgx/v5/pgxpool"

	"github.com/byteminus/go-admin"
	"github.com/byteminus/go-admin/pgxrepo"
)

type Customer struct {
	ID        int64     `admin:"id"`
	Name      string    `admin:"name"`
	Email     string    `admin:"email"`
	Plan      string    `admin:"plan"`
	Active    bool      `admin:"active"`
	CreatedAt time.Time `admin:"created_at,readonly"`
	Version   int64     `admin:"version"`
}

type Staff struct{ Email string }

func (s Staff) DisplayName() string { return s.Email }

// staffFromRequest stands in for your own session lookup.
func staffFromRequest(r *http.Request) (admin.User, bool) {
	email := r.Header.Get("X-Staff-Email") // set by your auth proxy or middleware
	if email == "" {
		return nil, false
	}
	return Staff{Email: email}, true
}

func main() {
	ctx := context.Background()
	pool, err := pgxpool.New(ctx, "postgres://localhost:5432/app")
	if err != nil {
		log.Fatal(err)
	}
	defer pool.Close()

	customers, err := pgxrepo.New[Customer](pool, "customers",
		pgxrepo.WithContextTx(), pgxrepo.WithVersionColumn("version"))
	if err != nil {
		log.Fatal(err)
	}

	panel, err := admin.New(admin.Config{
		Prefix:      "/admin",
		Title:       "Acme back office",
		CurrentUser: staffFromRequest,
		Authorizer:  admin.AllowAll(),
		LoginURL:    "/login",
		Transact:    pgxrepo.Transactor(pool),
		Widgets: []admin.Widget{
			admin.Count("Active customers", "customers", customers,
				admin.Condition{Field: "active", Op: admin.OpEq, Value: "true"}),
		},
	})
	if err != nil {
		log.Fatal(err)
	}

	admin.MustRegister(panel, admin.Resource[Customer]{
		Name:         "customers",
		Group:        "Accounts",
		Repository:   customers,
		SearchFields: []string{"name", "email"},
		DefaultSort:  admin.Sort{Field: "created_at", Desc: true},
		VersionField: "version",
		Title:        func(c Customer) string { return c.Name },
		Fields: []admin.Field{
			{Name: "name", Required: true, Sortable: true},
			{Name: "email", Type: admin.FieldEmail, Required: true},
			{Name: "plan", Type: admin.FieldSelect, Options: []admin.Choice{
				{Value: "free", Label: "Free"},
				{Value: "pro", Label: "Pro"},
			}},
			{Name: "active"},
			{Name: "created_at", Type: admin.FieldReadOnly, Show: admin.OnList | admin.OnDetail, Sortable: true},
		},
		Filters: []admin.Filter{
			{Field: "plan", Type: admin.FilterSelect},
			{Field: "active", Type: admin.FilterBool},
			{Field: "created_at", Type: admin.FilterDateRange},
		},
		BulkActions: []admin.BulkAction[Customer]{{
			Name:    "deactivate",
			Label:   "Deactivate",
			Confirm: "Deactivate the selected customers?",
			Run: func(ctx context.Context, cs []Customer) error {
				for _, c := range cs {
					c.Active = false
					if err := customers.Update(ctx, &c); err != nil {
						return err
					}
				}
				return nil
			},
		}},
	})

	mux := http.NewServeMux()
	mux.Handle("/admin/", panel)
	log.Fatal(http.ListenAndServe(":8080", mux))
}

Concepts

Panel. admin.New(Config) returns an http.Handler. Mount it with mux.Handle(prefix+"/", panel). Config.CurrentUser and Config.Authorizer are required; everything else has a working zero value.

Resources. A Resource[T] describes one record type: Name (the URL segment), labels, IDField (default "id"), DefaultSort, SearchFields, Fields, Filters, actions, hooks and the Repository. Register it with admin.Register (or MustRegister); resources referenced by relation fields are registered first. Registration validates the whole definition against T and reports every mistake as an error.

Records and tags. T is a struct whose persisted fields carry admin:"column" tags. Supported field types are strings, booleans, integers, floats, time.Time, types implementing encoding.TextMarshaler and TextUnmarshaler (such as uuid.UUID), named types based on these, and pointers to any of them for nullable columns. The tag option readonly (admin:"created_at,readonly") marks columns the store manages; they are never bound from forms, whatever field type they are declared with.

Fields. Each Field has a Label (derived from the column name when empty), a Type (inferred from the Go type when empty) and a Show visibility set (OnList, OnDetail, OnForm; zero means all three). Types: FieldText, FieldTextarea, FieldNumber, FieldBool, FieldSelect (with Options), FieldDate, FieldDateTime (edited in Config.Location), FieldEmail, FieldURL, FieldReadOnly and FieldRelation (a select of another resource's records, named by Relation). Required, MaxLength, Help, Placeholder and Sortable (for fields shown on the list) complete the definition.

What a form can write. Only fields declared in Fields, shown on the form (OnForm), of an editable type and not tagged readonly are bound from a submission; every other submitted value is ignored, so a crafted POST cannot set store-managed, computed or undeclared columns. A field kept off the form gets its value from Resource.New on create and keeps its stored value on edit. The ID can be entered when creating a record but never changed afterwards, and the version column is never bound.

Filters. FilterSelect (options from the filter, the field or the related resource), FilterBool and FilterDateRange (inclusive from and to dates).

Actions. Action[T] runs on one record from its detail page and list row; BulkAction[T] runs on the rows selected in a list. Both may require a confirmation page (Confirm) and a permission (default PermUpdate), and run inside Config.Transact.

Validation and hooks. Submitted values are checked by type (numbers, dates, emails, absolute http(s) URLs, select options, existing relation targets), Required and MaxLength, then by Resource.Validate. Errors are shown next to the inputs and in a summary, with the submitted values kept. Hooks run before and after create, update and delete; returning FieldErrors or a PublicError (see admin.Errorf) redisplays the form with that message, and any other error is logged and answered with a generic 500. Repository errors wrapping ErrConflict or ErrInvalidRecord are explained on the form.

Authorization. The Authorizer answers Access{Resource, Permission, Record} questions for PermView, PermCreate, PermUpdate and PermDelete, on the server, for every request: hiding a button is never the only check. Every request passes a resource-level check first; record-level checks follow for viewing (including each list row, which is replaced by a placeholder when denied), editing (against both the stored and the submitted record), deleting, running actions, creating once values are bound, and every record selected for a bulk action (one denied record refuses the whole action). Edit, delete and action pages also require PermView on the record, so a write permission never shows more than the detail page would. Relation selects, relation titles and relation filters only list target records the user may view.

Row-level scope. A per-record PermView denial hides a list row's data but not its existence: without more, the row still matches search and filters, still counts in the total and still takes its place in the sort order, so a user can recover the searchable, filterable and sortable values of records they may not view, one guess at a time. SearchFields may also name columns the list does not display. When some users may list a resource but not every record in it, set Resource.Scope: it returns conditions for the current user that the panel adds to every query for that resource (lists, search, filters, sorting, totals, relation options and titles, bulk selections and the lookup behind detail, edit, delete and action pages, where an out-of-scope record is not found). The Authorizer's per-record checks stay as the final guard. A repository already scoped to the user works too.

admin.Resource[Customer]{
	// ...
	Scope: func(ctx context.Context) ([]admin.Condition, error) {
		u, _ := admin.UserFrom(ctx)
		return []admin.Condition{{Field: "account_manager", Op: admin.OpEq, Value: u.DisplayName()}}, nil
	},
}

Count widgets query their repository directly; give them the same conditions.

The current user. Config.CurrentUser resolves the user from the request; the panel then stores it with admin.WithUser, so the Authorizer, hooks, actions, widgets and repositories read it with admin.UserFrom(ctx). When your middleware already keeps the user in the context, adapt that lookup once at startup:

panel, err := admin.New(admin.Config{
	CurrentUser: func(r *http.Request) (admin.User, bool) {
		u, ok := myapp.UserFrom(r.Context())
		return Staff{Email: u.Email}, ok && u.IsStaff
	},
	Authorizer: admin.AllowAll(),
})

Concurrent edits. Without a version, the second of two admins editing the same record silently overwrites the first, and an edit also writes back the columns it did not show as they were when the form was submitted. Set Resource.VersionField to an integer column and enable it on the repository (memrepo.WithVersionColumn, pgxrepo.WithVersionColumn): the edit form carries the version the user saw, and a save after someone else's is refused with a message asking the user to reload, instead of overwriting. pgxrepo checks and increments the version in the same UPDATE, so the check holds under concurrency; other writers of the table must increment the column too.

Unauthenticated requests are served by Config.Unauthenticated, or redirected to Config.LoginURL with the requested path in next (htmx requests get an HX-Redirect header), or answered with 401.

Security. Responses carry Content-Security-Policy: default-src 'none'; script-src 'self'; style-src 'self'; img-src 'self' data:; connect-src 'self'; form-action 'self'; frame-ancestors 'none'; base-uri 'none' plus nosniff, DENY framing and a same-origin referrer policy. Templates contain no inline scripts, styles or event handlers; htmx is configured through a meta tag with allowEval, indicator styles and history snapshots turned off. Non-safe requests from other origins are rejected by http.CrossOriginProtection (it checks Sec-Fetch-Site, then Origin against Host); Config.TrustedOrigins must be written exactly as browsers send them, and New rejects anything else. Forms therefore need no CSRF tokens, and no GET request changes state: deletes and actions show a confirmation page on GET and run only on POST, with no method override. Request bodies, URL-encoded or multipart, are capped by Config.MaxFormBytes; file uploads are refused. Redirects after a write go only to panel paths, and the login next parameter is always a same-site path. All output goes through html/template escaping; the only trusted HTML is what custom widgets return. Stored URL and email values are linked only when they are valid http(s) URLs and plain addresses; javascript: and data: URLs, and addresses containing ?, &, % or = (which would add mailto: headers such as bcc), show as plain text. Notices after redirects are fixed codes, never text taken from the URL. Every page works without JavaScript.

Dashboard. Config.Widgets are rendered in order. admin.Stat shows a computed value and admin.Count the number of matching records of a named resource. A widget with a Resource is shown only to users who may view that resource and links to its list; a widget without one is shown to everyone who can open the panel. Custom widgets return trusted template.HTML, so build it with html/template.

Lists and paging. Lists are paged by number with OFFSET, because people navigate admin lists by page. Page sizes are capped at MaxPageSize (500) and page numbers at 100000. On very large tables a deep page still makes the database read and discard every skipped row, and each page counts all matching rows; index the sort, filter and search columns and narrow lists with filters or tenant scoping.

PostgreSQL

pgxrepo maps a struct onto a table you own; go-admin creates no tables of its own, so there is no schema to install. A table for the quick start could be:

CREATE TABLE IF NOT EXISTS customers (
    id         bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
    name       text NOT NULL,
    email      text NOT NULL UNIQUE,
    plan       text NOT NULL DEFAULT 'free' CHECK (plan IN ('free', 'pro')),
    active     boolean NOT NULL DEFAULT true,
    created_at timestamptz NOT NULL DEFAULT now(),
    version    bigint NOT NULL DEFAULT 0
);
CREATE INDEX IF NOT EXISTS customers_created_at_idx ON customers (created_at);
  • pgxrepo.New[T](db, "schema.table", opts...) accepts a *pgxpool.Pool, *pgx.Conn or pgx.Tx. Identifiers come only from the struct tags and the table name, are validated against the struct's columns and quoted with pgx.Identifier; all values are bind parameters. Sort and condition fields from a request are checked against the resource definition by the panel and against the struct's columns by the repository, so a crafted sort parameter cannot reach the SQL.
  • Zero IDs get a UUID v7 for string and uuid.UUID keys; integer keys are left to the column's identity or serial default. readonly columns are never written and are read back with RETURNING after every write.
  • Search uses ILIKE with % and _ escaped. Add a pg_trgm index on searched columns for large tables.
  • Unique and foreign key violations map to admin.ErrConflict; NOT NULL, CHECK and data errors to admin.ErrInvalidRecord.
  • Times are returned in UTC, with PostgreSQL's microsecond precision (memrepo truncates the same way).
  • WithVersionColumn("version") makes Update conditional on the version and increments it; a stale update fails with admin.ErrVersionMismatch.

To run each write and its hooks in one transaction, give the panel a Transact function and let the repositories join the transaction carried in the context:

repo, err := pgxrepo.New[Customer](pool, "customers", pgxrepo.WithContextTx())
// ...
panel, err := admin.New(admin.Config{
	// ...
	Transact: pgxrepo.Transactor(pool),
})

Inside hooks and actions, other pgx code joins the same transaction through pgxrepo.TxFrom(ctx). A nested Transactor call runs in a savepoint. When your application carries its own transaction in the context, pass its lookup to pgxrepo.WithConn instead:

repo, err := pgxrepo.New[Customer](pool, "customers",
	pgxrepo.WithConn(func(ctx context.Context) pgxrepo.Querier {
		if tx, ok := myapp.TxFrom(ctx); ok {
			return tx
		}
		return nil // use the pool
	}))

API overview

Identifier Purpose
New(Config) (*Panel, error) Create a panel; Panel implements http.Handler.
Register, MustRegister Add a Resource[T] to a panel.
Panel.URL(resource, id) Path of the dashboard, a list or a record.
Resource[T], Field, FieldType, Visibility, Choice Resource definitions.
Filter, FilterType List filters.
Action[T], BulkAction[T], Hooks[T] Operations and write hooks.
Repository[T], ListQuery, Condition, Op, Sort, ListResult[T] Data access.
ErrNotFound, ErrInvalid, ErrConflict Family sentinels, checked with errors.Is.
ErrInvalidRecord, ErrInvalidQuery, ErrVersionMismatch Repository errors wrapping ErrInvalid or ErrConflict.
FieldErrors, PublicError, Errorf Errors shown to users.
User, UserFunc, Authorizer, AuthorizerFunc, AllowAll, Access, Permission Authentication hand-off and authorization.
WithUser, UserFrom The current user in a context.
Widget, Stat, Count Dashboard widgets.
DefaultPageSize, MaxPageSize, DefaultRelationLimit, DefaultMaxFormBytes, MaxBulkSelection Defaults and limits.
memrepo.New, WithIDColumn, WithUnique, WithVersionColumn In-memory repository (not transactional).
pgxrepo.New, WithIDColumn, WithConn, WithContextTx, WithVersionColumn, Transactor, WithTx, TxFrom PostgreSQL repository.
repotest.Run, repotest.Record, repotest.RunVersioned, repotest.VersionedRecord Conformance suites for repositories.

Testing

go test -race ./...
TEST_DATABASE_URL=postgres://postgres:postgres@localhost:5432/postgres?sslmode=disable go test -race -count=1 ./...

Handler tests cover rendering, the CSP header, the validation round trip, authorization denials and cross-origin rejection with httptest. PostgreSQL tests create a random schema per test and drop it afterwards, so they run in parallel against one database; under CI a missing TEST_DATABASE_URL is a failure, not a skip.

To check your own repository, run the conformance suites against it (RunVersioned only if it supports version columns):

func TestMyRepository(t *testing.T) {
	repotest.Run(t, func(t *testing.T) admin.Repository[repotest.Record] {
		return newMyRepository(t) // an empty repository of repotest.Record
	})
	repotest.RunVersioned(t, func(t *testing.T) admin.Repository[repotest.VersionedRecord] {
		return newMyVersionedRepository(t)
	})
}

Roadmap

  • File and image upload fields.
  • Inline editing of related records (has-many relations on the parent form).
  • CSV and JSON export of lists.
  • A searchable relation picker for large target tables (relation selects currently list the first RelationLimit records).
  • An audit trail of panel writes.
  • Configurable page sizes per request and saved list views.
  • Localization of built-in messages.

Stability

This package follows semantic versioning. Before v1.0.0, a minor release may contain breaking changes; each one is listed in CHANGELOG.md with upgrade steps, including SQL for schema changes. From v1.0.0, the exported API and the PostgreSQL schema change only in backwards-compatible ways until the next major version.

License

MIT; see LICENSE. htmx 2.0.11 is vendored under the Zero-Clause BSD license; see THIRD_PARTY_NOTICES.

Documentation

Overview

Package admin generates a server-rendered admin panel for SaaS back offices from resource definitions written in Go.

A Panel is an http.Handler. Each Resource[T] describes how records of a struct type T are listed, shown, created, edited and deleted: the fields and their input types, list search, filters and sorting, row and bulk actions, a validation hook and hooks around every write. Records are read and written through a Repository[T]; the memrepo subpackage keeps them in memory and the pgxrepo subpackage maps them to PostgreSQL tables through `admin:"column"` struct tags.

panel, err := admin.New(admin.Config{
	Prefix:      "/admin",
	CurrentUser: currentUser,        // from your own session middleware
	Authorizer:  admin.AllowAll(),   // or your role checks
})
if err != nil { ... }
err = admin.Register(panel, admin.Resource[Customer]{
	Name:         "customers",
	Repository:   customers,
	SearchFields: []string{"name", "email"},
	Fields: []admin.Field{
		{Name: "name", Required: true, Sortable: true},
		{Name: "email", Type: admin.FieldEmail, Required: true},
	},
})
mux.Handle("/admin/", panel)

Security

The panel does not authenticate anyone: Config.CurrentUser reads the user established by the host application, and unauthenticated requests go to Config.LoginURL or Config.Unauthenticated. The user is then stored with WithUser, so the Authorizer, hooks, actions, widgets and repositories read it with UserFrom. Every page and write asks the Authorizer, first for the resource and then for each record concerned, including list rows, bulk selections and relation options; every page that shows a record requires PermView on it. Per-record denials do not stop search and filters from matching hidden rows, so set Resource.Scope to keep a user's queries to the records they may reach.

Only fields declared on the form with an editable type are bound from a submission; columns tagged `readonly`, fields kept off the form, the version column and, after creation, the ID cannot be written by a crafted request.

Pages are rendered with html/template from embedded templates and served with a strict Content-Security-Policy that allows only the panel's own scripts and styles; there is no inline script or style anywhere. htmx 2 is vendored and embedded, configured through a meta tag so that it needs neither eval nor inline styles. State-changing requests from other origins are rejected with net/http's CrossOriginProtection, so forms need no CSRF tokens, and no GET request changes state. Every page works without JavaScript; htmx only makes list searching, filtering, sorting and paging update in place.

Concurrent edits

Set Resource.VersionField, and enable the same column on the repository, to refuse an edit saved after someone else's instead of silently overwriting it.

Transactions

Set Config.Transact to run each write together with its Before and After hooks in one transaction. pgxrepo.Transactor provides such a function, and pgxrepo.WithContextTx makes repositories join it.

Example

This example mounts a panel with one resource and drives it with httptest the way a browser would.

package main

import (
	"context"
	"fmt"
	"net/http"
	"net/http/httptest"
	"net/url"
	"strings"

	"github.com/byteminus/go-admin"
	"github.com/byteminus/go-admin/memrepo"
)

type Customer struct {
	ID    int64  `admin:"id"`
	Name  string `admin:"name"`
	Email string `admin:"email"`
	Plan  string `admin:"plan"`
}

type Staff struct{ Name string }

func (s Staff) DisplayName() string { return s.Name }

func main() {
	customers, err := memrepo.New[Customer]()
	if err != nil {
		panic(err)
	}
	panel, err := admin.New(admin.Config{
		Prefix: "/admin",
		CurrentUser: func(r *http.Request) (admin.User, bool) {
			// A real application reads its own session here.
			return Staff{Name: "Grace"}, true
		},
		Authorizer: admin.AllowAll(),
	})
	if err != nil {
		panic(err)
	}
	err = admin.Register(panel, admin.Resource[Customer]{
		Name:         "customers",
		Repository:   customers,
		SearchFields: []string{"name", "email"},
		Title:        func(c Customer) string { return c.Name },
		Fields: []admin.Field{
			{Name: "name", Required: true, Sortable: true},
			{Name: "email", Type: admin.FieldEmail, Required: true},
			{Name: "plan", Type: admin.FieldSelect, Options: []admin.Choice{
				{Value: "free", Label: "Free"},
				{Value: "pro", Label: "Pro"},
			}},
		},
	})
	if err != nil {
		panic(err)
	}
	mux := http.NewServeMux()
	mux.Handle("/admin/", panel)

	submit := func(form url.Values) *http.Response {
		req := httptest.NewRequest(http.MethodPost, "/admin/customers/new", strings.NewReader(form.Encode()))
		req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
		rec := httptest.NewRecorder()
		mux.ServeHTTP(rec, req)
		return rec.Result()
	}

	res := submit(url.Values{"name": {"Ada"}, "email": {"not an email"}, "plan": {"pro"}})
	fmt.Println("invalid:", res.StatusCode)

	res = submit(url.Values{"name": {"Ada"}, "email": {"ada@example.com"}, "plan": {"pro"}})
	fmt.Println("valid:", res.StatusCode, res.Header.Get("Location"))

	c, err := customers.Get(context.Background(), "1")
	if err != nil {
		panic(err)
	}
	fmt.Println("stored:", c.Name, c.Email, c.Plan)
}
Output:
invalid: 422
valid: 303 /admin/customers/1/?notice=created
stored: Ada ada@example.com pro

Index

Examples

Constants

View Source
const (
	// DefaultPageSize is the list page size when Resource.PageSize is zero.
	DefaultPageSize = 25
	// MaxPageSize caps Resource.PageSize and Resource.RelationLimit.
	MaxPageSize = 500
	// DefaultRelationLimit is the number of relation select options when
	// Resource.RelationLimit is zero.
	DefaultRelationLimit = 200
)

Limits and defaults applied to resource definitions.

View Source
const DefaultMaxFormBytes = 1 << 20

DefaultMaxFormBytes is the request body limit when Config.MaxFormBytes is zero.

View Source
const MaxBulkSelection = 1000

MaxBulkSelection is the most rows one bulk action may be applied to; a larger selection is refused with 413 Request Entity Too Large.

Variables

View Source
var (
	// ErrNotFound reports that no record has the requested ID.
	ErrNotFound = errors.New("admin: record not found")
	// ErrInvalid reports input that failed validation. ErrInvalidRecord,
	// ErrInvalidQuery and FieldErrors all satisfy errors.Is(err, ErrInvalid).
	ErrInvalid = errors.New("admin: invalid input")
	// ErrConflict reports a write that violates a uniqueness or reference
	// constraint, a create with an ID that already exists, or a record whose
	// state no longer allows the operation.
	ErrConflict = errors.New("admin: conflicting record")
	// ErrInvalidRecord reports a write the store rejected because a value
	// breaks one of its constraints (NOT NULL, CHECK, length or range).
	ErrInvalidRecord = fmt.Errorf("admin: invalid record: %w", ErrInvalid)
	// ErrInvalidQuery reports a list query naming an unknown field or
	// carrying a malformed value.
	ErrInvalidQuery = fmt.Errorf("admin: invalid query: %w", ErrInvalid)
	// ErrVersionMismatch reports an update whose record carries a version
	// other than the stored one: someone else saved the record after it was
	// read. See Resource.VersionField.
	ErrVersionMismatch = fmt.Errorf("admin: record was changed by someone else: %w", ErrConflict)
)

Errors returned by repositories and the panel. Check them with errors.Is. ErrNotFound, ErrInvalid and ErrConflict are the sentinels shared by every Byte Minus package; the more specific errors wrap one of them.

Functions

func Errorf

func Errorf(format string, args ...any) error

Errorf returns a PublicError whose message is formatted from format and args. It does not wrap; set PublicError.Err directly to keep a cause.

func MustRegister

func MustRegister[T any](p *Panel, r Resource[T])

MustRegister is like Register but panics on error. Definitions are written by programmers, so a failure is a bug to fix rather than a condition to handle at run time.

func Register

func Register[T any](p *Panel, r Resource[T]) error

Register adds a resource to the panel. Resources referenced by relation fields must be registered before the resources that reference them.

func WithUser

func WithUser(ctx context.Context, user User) context.Context

WithUser returns a context carrying user. The panel stores the user that Config.CurrentUser resolved this way before calling the Authorizer, hooks, actions, widgets and repositories.

Types

type Access

type Access struct {
	// Resource is the resource name.
	Resource string
	// Permission is the operation requested.
	Permission Permission
	// Record is the record concerned, of the resource's record type T (not a
	// pointer), or nil for resource-level checks. Every request first passes
	// a resource-level check, and every record the panel shows or changes
	// then passes a record-level one:
	//
	//   - viewing a record, and each row of a list: a row the user may not
	//     view is replaced by a placeholder that shows none of its data;
	//   - editing (against the stored record and again against the submitted
	//     values), deleting, and running a row action;
	//   - creating, once the submitted values are bound;
	//   - every record selected for a bulk action, which is refused as a
	//     whole when any one of them is denied;
	//   - relation options, relation titles and relation filters, which only
	//     list target records the user may view.
	//
	// Hiding which records exist at all, and how many, is the repository's
	// job (for example by scoping queries to the user's tenant).
	Record any
}

Access describes one authorization question.

type Action

type Action[T any] struct {
	// Name identifies the action in URLs; it must match ^[a-z][a-z0-9_-]*$.
	Name string
	// Label is the button text. It defaults to Name humanized.
	Label string
	// Confirm, when set, is shown on a confirmation page before running.
	Confirm string
	// Permission is required on the record. It defaults to PermUpdate.
	Permission Permission
	// Destructive styles the button as dangerous.
	Destructive bool
	// Run performs the action, inside Config.Transact when it is set.
	Run func(ctx context.Context, record T) error
}

Action is an operation on a single record, offered on its detail page and list row.

type Authorizer

type Authorizer interface {
	// Authorize reports whether user may perform access.
	Authorize(ctx context.Context, user User, access Access) (bool, error)
}

Authorizer decides whether a user may perform an operation. The application implements it. Returning an error aborts the request with a server error; returning false denies it with 403 Forbidden, or leaves the denied record out of the page. The user is never nil. Implementations must be safe for concurrent use and should be fast: a list page asks about every row it shows.

func AllowAll

func AllowAll() Authorizer

AllowAll returns an Authorizer that permits every operation to every authenticated user. It suits panels whose host already restricts access to administrators before the request reaches the panel.

type AuthorizerFunc

type AuthorizerFunc func(ctx context.Context, user User, access Access) (bool, error)

AuthorizerFunc adapts a function to the Authorizer interface.

Example

Authorizers decide per resource and, where a record is involved, per record.

package main

import (
	"context"
	"fmt"

	"github.com/byteminus/go-admin"
)

type Customer struct {
	ID    int64  `admin:"id"`
	Name  string `admin:"name"`
	Email string `admin:"email"`
	Plan  string `admin:"plan"`
}

type Staff struct{ Name string }

func (s Staff) DisplayName() string { return s.Name }

func main() {
	readOnlyBilling := admin.AuthorizerFunc(func(ctx context.Context, u admin.User, a admin.Access) (bool, error) {
		if a.Resource == "invoices" {
			return a.Permission == admin.PermView, nil
		}
		if c, ok := a.Record.(Customer); ok && a.Permission == admin.PermDelete {
			return c.Plan != "enterprise", nil
		}
		return true, nil
	})
	staff := Staff{Name: "Grace"}
	for _, a := range []admin.Access{
		{Resource: "invoices", Permission: admin.PermView},
		{Resource: "invoices", Permission: admin.PermUpdate},
		{Resource: "customers", Permission: admin.PermDelete, Record: Customer{Plan: "enterprise"}},
		{Resource: "customers", Permission: admin.PermDelete, Record: Customer{Plan: "free"}},
	} {
		ok, _ := readOnlyBilling.Authorize(context.Background(), staff, a)
		fmt.Println(a.Resource, a.Permission, ok)
	}
}
Output:
invoices view true
invoices update false
customers delete false
customers delete true

func (AuthorizerFunc) Authorize

func (f AuthorizerFunc) Authorize(ctx context.Context, user User, access Access) (bool, error)

Authorize calls f.

type BulkAction

type BulkAction[T any] struct {
	// Name identifies the action in forms; it must match ^[a-z][a-z0-9_-]*$.
	Name string
	// Label is the option text. It defaults to Name humanized.
	Label string
	// Confirm, when set, is shown on a confirmation page before running.
	Confirm string
	// Permission is required on every selected record. It defaults to
	// PermUpdate. At most MaxBulkSelection records may be selected.
	Permission Permission
	// Run performs the action, inside Config.Transact when it is set.
	Run func(ctx context.Context, records []T) error
}

BulkAction is an operation on the records selected in a list.

type Choice

type Choice struct {
	// Value is the submitted and stored value.
	Value string
	// Label is shown to the user.
	Label string
}

Choice is one option of a select field or filter.

type Condition

type Condition struct {
	// Field is a column name.
	Field string
	// Op is the comparison.
	Op Op
	// Value is the operand of OpEq, OpGte and OpLt.
	Value string
	// Values are the operands of OpIn.
	Values []string
}

Condition restricts a list to records whose Field satisfies Op. Values use the textual form of the field's Go type: decimal numbers, "true"/"false", RFC 3339 timestamps or YYYY-MM-DD dates (UTC), and the MarshalText form for types such as uuid.UUID.

type Config

type Config struct {
	// Prefix is the path the panel is mounted under, such as "/admin". It
	// defaults to "/admin"; use "/" to mount the panel at the root. Mount the
	// panel with mux.Handle(prefix+"/", panel).
	Prefix string
	// Title is shown in the header and page titles. It defaults to "Admin".
	Title string
	// CurrentUser resolves the authenticated user from the request,
	// reporting false (or a nil User) when there is none. It is how the
	// application supplies its own session lookup: the panel calls it once
	// per request, then stores the user with WithUser so that the
	// Authorizer, hooks, actions, widgets and repositories can read it back
	// with UserFrom. When your authentication middleware already keeps the
	// user in the request context, adapt that lookup here.
	CurrentUser UserFunc
	// Authorizer decides what each user may do.
	Authorizer Authorizer
	// Unauthenticated serves requests without a user. When nil, the panel
	// redirects to LoginURL, or responds 401 Unauthorized when LoginURL is
	// empty.
	Unauthenticated http.Handler
	// LoginURL receives unauthenticated requests, with the requested panel
	// path in the "next" query parameter. The panel only ever puts a
	// same-site path there (it starts with a single "/"); the login handler
	// should still check next the same way before redirecting to it.
	LoginURL string
	// TrustedOrigins are extra origins allowed to submit forms across
	// origins, each written exactly as a browser sends it in the Origin
	// header: a lower-case http or https scheme and host, an optional
	// non-default port, and nothing else ("https://admin.example.com").
	// Same-origin requests are always allowed. New rejects any other form,
	// because an origin that can never match would silently do nothing.
	TrustedOrigins []string
	// Location is the time zone for editing and displaying date-times. It
	// defaults to UTC.
	Location *time.Location
	// Transact, when set, runs every write together with its hooks so they
	// commit or roll back together. pgxrepo.Transactor provides one for
	// PostgreSQL.
	Transact func(ctx context.Context, fn func(ctx context.Context) error) error
	// Widgets are shown on the dashboard in order.
	Widgets []Widget
	// MaxFormBytes caps request bodies, URL-encoded and multipart alike. It
	// defaults to DefaultMaxFormBytes; larger bodies are refused with 413
	// Request Entity Too Large.
	MaxFormBytes int64
	// Logger receives server errors. It defaults to slog.Default().
	Logger *slog.Logger
}

Config configures a Panel. CurrentUser and Authorizer are required; every other field has a working zero value.

type Field

type Field struct {
	// Name is the column name from the record type's `admin` tag.
	Name string
	// Label defaults to Name humanized ("created_at" becomes "Created at").
	Label string
	// Type selects the input and display; empty infers it from the Go type.
	Type FieldType
	// Show restricts the pages the field appears on. A field not shown on
	// the form is never bound from submissions: its value comes from
	// Resource.New when creating and stays as stored when editing.
	Show Visibility
	// Help is shown under the form input.
	Help string
	// Required rejects empty submissions. For FieldBool it requires the box
	// to be ticked.
	Required bool
	// MaxLength caps the number of characters of text inputs.
	MaxLength int
	// Placeholder is shown in empty text inputs.
	Placeholder string
	// Options lists the choices of a FieldSelect.
	Options []Choice
	// Relation names the target resource of a FieldRelation. The target must
	// be registered first, or be this resource.
	Relation string
	// Sortable lets users order lists by this field. It takes effect only
	// when the field is shown on the list.
	Sortable bool
}

Field describes one column of a resource.

type FieldErrors

type FieldErrors map[string]string

FieldErrors maps field names to messages shown next to the form inputs. The empty key holds a message for the form as a whole. Validation hooks return it directly; Before and After hooks may return it as an error to redisplay the form. It satisfies errors.Is(err, ErrInvalid).

func (FieldErrors) Error

func (fe FieldErrors) Error() string

Error lists each field with its message, in field name order.

func (FieldErrors) Is added in v0.2.0

func (fe FieldErrors) Is(target error) bool

Is reports whether target is ErrInvalid, so validation failures are recognised the same way across packages.

type FieldType

type FieldType string

FieldType selects how a field is edited and displayed.

const (
	FieldText     FieldType = "text"
	FieldTextarea FieldType = "textarea"
	FieldNumber   FieldType = "number"
	FieldBool     FieldType = "bool"
	// FieldSelect offers Field.Options and rejects any other value.
	FieldSelect FieldType = "select"
	// FieldDate stores midnight UTC of the chosen day.
	FieldDate FieldType = "date"
	// FieldDateTime is edited and displayed in Config.Location.
	FieldDateTime FieldType = "datetime"
	FieldEmail    FieldType = "email"
	// FieldURL accepts absolute http and https URLs and links only to them,
	// so stored javascript: or data: URLs are shown as plain text.
	FieldURL FieldType = "url"
	// FieldReadOnly is displayed on forms but never bound from them. Columns
	// tagged `readonly` are never bound either, whatever their field type.
	FieldReadOnly FieldType = "readonly"
	// FieldRelation stores the ID of a record of the resource named by
	// Field.Relation and is edited with a select of that resource's records.
	FieldRelation FieldType = "relation"
)

Field types. When Field.Type is empty it is inferred from the Go type: strings and text types become FieldText, booleans FieldBool, numbers FieldNumber and time.Time FieldDateTime.

type Filter

type Filter struct {
	// Field names a field declared in Resource.Fields.
	Field string
	// Type selects the control.
	Type FilterType
	// Label defaults to the field's label.
	Label string
	// Options are the choices of a FilterSelect; values not listed are
	// ignored.
	Options []Choice
}

Filter narrows a list by one field.

type FilterType

type FilterType string

FilterType selects the control a filter uses.

const (
	// FilterSelect offers Filter.Options, falling back to the field's options
	// or, for relation fields, the target resource's records.
	FilterSelect FilterType = "select"
	// FilterBool offers yes and no.
	FilterBool FilterType = "bool"
	// FilterDateRange offers inclusive from and to dates.
	FilterDateRange FilterType = "daterange"
)

Filter types.

type Hooks

type Hooks[T any] struct {
	// BeforeCreate may change the bound record before it is stored.
	BeforeCreate func(ctx context.Context, record *T) error
	// AfterCreate receives the record as stored, with generated values.
	AfterCreate func(ctx context.Context, record T) error
	// BeforeUpdate receives the stored record and may change the updated
	// one before it is stored.
	BeforeUpdate func(ctx context.Context, old T, record *T) error
	// AfterUpdate receives the record before and after the update.
	AfterUpdate func(ctx context.Context, old, record T) error
	// BeforeDelete receives the record about to be deleted.
	BeforeDelete func(ctx context.Context, record T) error
	// AfterDelete receives the deleted record.
	AfterDelete func(ctx context.Context, record T) error
}

Hooks run around repository writes. Returning an error aborts the operation (and rolls back the transaction when Config.Transact is set). FieldErrors and PublicError values are shown to the user; other errors are logged and reported as a server error.

When Config.Transact retries its function, the hooks run again with fresh copies of the records.

type ListQuery

type ListQuery struct {
	// Search is matched case-insensitively as a literal substring (LIKE
	// wildcards in it have no special meaning) of any of SearchFields. It is
	// ignored when empty or when SearchFields is empty. SearchFields come
	// from the resource definition, never from the request.
	Search       string
	SearchFields []string
	// Conditions must all hold.
	Conditions []Condition
	// Sort orders the results; ties are broken by ID ascending. An empty
	// Sort.Field orders by ID ascending.
	Sort Sort
	// Offset skips that many records. Limit caps the page size; zero or a
	// negative value returns every matching record. The panel always sets
	// a positive Limit no larger than MaxBulkSelection.
	Offset int
	Limit  int
}

ListQuery selects a page of records. The zero value lists every record in ID order.

The panel pages lists by number, so Offset grows with the page: on a very large table the database still reads and discards every skipped row, and counting the matches for Total scans them too. Index the columns used for sorting, filtering and search, and keep the matching set small with filters or a repository scoped to the user; the panel caps the page size at MaxPageSize and the page number at 100000.

func (ListQuery) Validate

func (q ListQuery) Validate() error

Validate reports structural problems that do not depend on the record type, as an error wrapping ErrInvalidQuery.

type ListResult

type ListResult[T any] struct {
	// Items are the records on the page, in order.
	Items []T
	// Total counts every record matching the query, ignoring Offset and Limit.
	Total int
}

ListResult is one page of records.

type Op

type Op string

Op is a comparison operator in a Condition.

const (
	// OpEq matches records whose field equals Value. On a nullable field an
	// empty Value matches null.
	OpEq Op = "eq"
	// OpGte matches records whose field is greater than or equal to Value.
	OpGte Op = "gte"
	// OpLt matches records whose field is less than Value.
	OpLt Op = "lt"
	// OpIn matches records whose field equals any of Values.
	OpIn Op = "in"
)

Operators supported by every repository.

type Panel

type Panel struct {
	// contains filtered or unexported fields
}

Panel is an http.Handler serving the admin panel. Register resources before serving requests; a Panel is safe for concurrent use.

func New

func New(cfg Config) (*Panel, error)

New returns a panel configured by cfg.

func (*Panel) ServeHTTP

func (p *Panel) ServeHTTP(w http.ResponseWriter, r *http.Request)

ServeHTTP serves the panel.

func (*Panel) URL

func (p *Panel) URL(name, id string) string

URL returns the panel path for the given resource and record ID, or the dashboard when name is empty and the resource list when id is empty.

type Permission

type Permission string

Permission is an operation a user may be allowed to perform on a resource.

const (
	PermView   Permission = "view"
	PermCreate Permission = "create"
	PermUpdate Permission = "update"
	PermDelete Permission = "delete"
)

Permissions checked by the panel.

type PublicError

type PublicError struct {
	// Message is shown to the user as is (HTML-escaped).
	Message string
	// Err is the underlying cause, reachable with errors.Is and errors.As
	// but never shown.
	Err error
}

PublicError carries a message that is safe to show to panel users. Hooks and actions return it to explain why an operation was refused; any other error is logged and replaced with a generic message.

func (*PublicError) Error

func (e *PublicError) Error() string

Error returns the message prefixed with the package name.

func (*PublicError) Unwrap

func (e *PublicError) Unwrap() error

Unwrap returns the underlying cause, if any.

type Repository

type Repository[T any] interface {
	// List returns one page of records matching q and the total number of
	// matching records.
	List(ctx context.Context, q ListQuery) (ListResult[T], error)
	// Get returns the record with the given ID.
	Get(ctx context.Context, id string) (T, error)
	// Create stores a new record and updates *record with the stored values,
	// including generated IDs and store-managed columns.
	Create(ctx context.Context, record *T) error
	// Update replaces the record with the same ID and updates *record with
	// the stored values. When the resource sets VersionField, Update must
	// write only if the stored version equals the record's, increment it,
	// and otherwise return an error wrapping ErrVersionMismatch.
	Update(ctx context.Context, record *T) error
	// Delete removes the record with the given ID.
	Delete(ctx context.Context, id string) error
}

Repository is the data access the panel needs for one resource. The application implements it, or uses memrepo or pgxrepo. Field names in a ListQuery are the column names from the record type's `admin` tags, and values are strings in the formats described on Condition.

Implementations return errors wrapping ErrNotFound for missing records (including IDs that cannot be parsed), ErrInvalidQuery for unknown fields or malformed values, and ErrConflict or ErrInvalidRecord when the store rejects a write. Implementations must be safe for concurrent use and honour context cancellation. The repotest package checks these rules.

type Resource

type Resource[T any] struct {
	// Name identifies the resource in URLs and authorization checks. It must
	// match ^[a-z][a-z0-9_-]*$ and be unique within a panel.
	Name string
	// Singular and Plural are display labels. Plural defaults to Name
	// humanized ("line_items" becomes "Line items") and Singular to Plural
	// without a trailing "s"; set them when that rule does not fit.
	Singular string
	Plural   string
	// Group places the resource under a heading in the navigation.
	Group string
	// IDField names the primary key column. It defaults to "id".
	IDField string
	// DefaultSort orders lists when the request does not choose a column. It
	// defaults to the ID ascending.
	DefaultSort Sort
	// SearchFields are string columns matched by the list search box. They
	// may name columns the list does not display; a user can then probe
	// their contents through search, so name only columns every user who
	// may open the list may read.
	SearchFields []string
	// Fields are shown in declaration order.
	Fields []Field
	// Filters appear beside the list.
	Filters []Filter
	// RowActions run on one record; BulkActions on the records selected in
	// a list.
	RowActions  []Action[T]
	BulkActions []BulkAction[T]
	// Repository stores the records. It is required.
	Repository Repository[T]
	// PageSize is the number of rows per list page. It defaults to
	// DefaultPageSize and is capped at MaxPageSize. Lists page with OFFSET so
	// that people can jump to a page number; see ListQuery for the cost on
	// very large tables.
	PageSize int
	// RelationLimit caps the options a relation select offers when this
	// resource is the target of a FieldRelation. It defaults to
	// DefaultRelationLimit and is capped at MaxPageSize.
	RelationLimit int
	// VersionField, when set, names a non-pointer integer column used for
	// optimistic concurrency control. The edit form carries the version the
	// user saw; saving fails with a conflict message instead of overwriting
	// when the stored version differs. The Repository must reject an Update
	// whose record version differs from the stored one with an error
	// wrapping ErrVersionMismatch, and increment the version on success:
	// memrepo.WithVersionColumn and pgxrepo.WithVersionColumn do this.
	// Without that support the panel still compares versions before the
	// update, which narrows but does not close the race. The version is
	// never bound from forms.
	VersionField string
	// Title labels a record in headings and relation selects. It defaults to
	// the singular label followed by the ID.
	Title func(record T) string
	// New returns the initial values for the create form. It defaults to the
	// zero value of T.
	New func(ctx context.Context) T
	// Validate checks a record after the submitted form values are bound and
	// the built-in checks pass. Returning a non-empty FieldErrors redisplays
	// the form.
	Validate func(ctx context.Context, record T) FieldErrors
	// Hooks run around writes, inside Config.Transact when it is set.
	Hooks Hooks[T]
	// Scope, when set, returns conditions that limit the records the
	// current user may reach, such as {Field: "tenant_id", Op: OpEq, Value:
	// tenant}. The panel adds them to every query it makes for this
	// resource: list pages (and so search, filters, sorting, paging and the
	// total), relation options and titles, bulk selections, and the lookup
	// behind detail, edit, delete and action pages, where a record outside
	// the scope is not found. Returning an error fails the request. The
	// Authorizer's per-record checks still apply as the final guard.
	//
	// Without Scope, a per-record PermView denial hides a list row's data
	// but not its existence: the row still matches search and filters and
	// still counts, so a user can learn the searchable, filterable and
	// sortable values of records they may not view. Use Scope (or a
	// repository already scoped to the user) whenever some users may list
	// a resource but not view all of its records.
	//
	// Count widgets query their repository directly; give them the same
	// conditions yourself.
	Scope func(ctx context.Context) ([]Condition, error)
}

Resource defines how the panel lists, shows and edits records of type T. T must be a struct whose persisted fields carry `admin:"column"` tags; the tag option `readonly` (as in `admin:"created_at,readonly"`) marks columns the store manages, which the bundled repositories never write.

Supported field types are strings, booleans, integers, floats, time.Time, types implementing both encoding.TextMarshaler and encoding.TextUnmarshaler (such as uuid.UUID), named types based on these, and pointers to any of them for nullable columns.

Example (VersionField)

A version field turns a second, stale save into a conflict instead of a silent overwrite.

package main

import (
	"context"
	"fmt"
	"net/http"
	"net/http/httptest"
	"net/url"
	"strings"

	"github.com/byteminus/go-admin"
	"github.com/byteminus/go-admin/memrepo"
)

type Staff struct{ Name string }

func (s Staff) DisplayName() string { return s.Name }

type Article struct {
	ID      int64  `admin:"id"`
	Title   string `admin:"title"`
	Version int64  `admin:"version"`
}

func main() {
	articles, err := memrepo.New[Article](memrepo.WithVersionColumn("version"))
	if err != nil {
		panic(err)
	}
	panel, err := admin.New(admin.Config{
		CurrentUser: func(*http.Request) (admin.User, bool) { return Staff{Name: "Grace"}, true },
		Authorizer:  admin.AllowAll(),
	})
	if err != nil {
		panic(err)
	}
	admin.MustRegister(panel, admin.Resource[Article]{
		Name:         "articles",
		Repository:   articles,
		VersionField: "version",
		Fields:       []admin.Field{{Name: "title", Required: true}},
	})
	if err := articles.Create(context.Background(), &Article{Title: "Draft"}); err != nil {
		panic(err)
	}
	save := func(title, version string) int {
		form := url.Values{"title": {title}, "admin.version": {version}}
		req := httptest.NewRequest(http.MethodPost, "/admin/articles/1/edit", strings.NewReader(form.Encode()))
		req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
		rec := httptest.NewRecorder()
		panel.ServeHTTP(rec, req)
		return rec.Code
	}
	// Two editors opened the form at version 0.
	fmt.Println("first save:", save("Published", "0"))
	fmt.Println("second save:", save("Overwritten", "0"))
	a, _ := articles.Get(context.Background(), "1")
	fmt.Println("stored:", a.Title, a.Version)
}
Output:
first save: 303
second save: 409
stored: Published 1

type Sort

type Sort struct {
	// Field is a column name; empty means the ID.
	Field string
	// Desc reverses the order. Nulls sort last ascending and first
	// descending, as in PostgreSQL.
	Desc bool
}

Sort names the field to order by.

type User

type User interface {
	// DisplayName is shown in the panel header.
	DisplayName() string
}

User is the authenticated person using the panel. The application implements it. The panel never authenticates anyone itself; Config.CurrentUser extracts the user that the host application's own authentication middleware established.

func UserFrom

func UserFrom(ctx context.Context) (User, bool)

UserFrom returns the user stored by WithUser, reporting false when the context carries none (or a nil User).

type UserFunc

type UserFunc func(r *http.Request) (User, bool)

UserFunc resolves the authenticated user of a request, reporting false when the request is unauthenticated. It is the type of Config.CurrentUser.

type Visibility

type Visibility uint8

Visibility is a set of pages a field appears on. The zero value means every page.

const (
	OnList Visibility = 1 << iota
	OnDetail
	OnForm
)

Pages a field can appear on.

type Widget

type Widget struct {
	// Title is the widget heading.
	Title string
	// Resource, when set, shows the widget only to users allowed to view
	// that resource and links the heading to its list. A widget without a
	// Resource is shown to every user who can open the panel, so give every
	// widget that reveals data from a resource that resource's name.
	Resource string
	// Render produces the widget body and is called only for users who may
	// see the widget. The returned HTML is inserted verbatim: it is the one
	// place the panel trusts HTML from the application, so build it with
	// html/template or escape every dynamic value. Inline scripts and styles
	// are blocked by the panel's Content-Security-Policy. The current user
	// is available through UserFrom. An error is logged and the widget shows
	// as unavailable.
	Render func(ctx context.Context) (template.HTML, error)
}

Widget is a dashboard panel.

func Count

func Count[T any](title, resource string, repo Repository[T], conditions ...Condition) Widget

Count returns a widget showing how many records of repo match conditions. The widget belongs to the named resource: only users allowed to view it see the count, and the heading links to its list.

Example

Count widgets show live record counts on the dashboard.

package main

import (
	"context"
	"fmt"

	"github.com/byteminus/go-admin"
	"github.com/byteminus/go-admin/memrepo"
)

type Customer struct {
	ID    int64  `admin:"id"`
	Name  string `admin:"name"`
	Email string `admin:"email"`
	Plan  string `admin:"plan"`
}

func main() {
	customers, err := memrepo.New[Customer]()
	if err != nil {
		panic(err)
	}
	for _, plan := range []string{"pro", "free", "pro"} {
		if err := customers.Create(context.Background(), &Customer{Name: "c", Email: "c@example.com", Plan: plan}); err != nil {
			panic(err)
		}
	}
	w := admin.Count("Pro customers", "customers", customers, admin.Condition{Field: "plan", Op: admin.OpEq, Value: "pro"})
	html, err := w.Render(context.Background())
	fmt.Println(html, err)
}
Output:
<p class="stat">2</p> <nil>

func Stat

func Stat(title string, value func(ctx context.Context) (string, error)) Widget

Stat returns a widget showing the single value that value computes, such as a revenue figure. The value is escaped. Set the widget's Resource when the value comes from a resource some users may not view.

Directories

Path Synopsis
internal
structmap
Package structmap maps the `admin:"column"` struct tags of a record type to typed columns.
Package structmap maps the `admin:"column"` struct tags of a record type to typed columns.
Package memrepo is an in-memory admin.Repository for tests, examples and prototypes.
Package memrepo is an in-memory admin.Repository for tests, examples and prototypes.
Package pgxrepo is a generic PostgreSQL admin.Repository built on github.com/jackc/pgx/v5.
Package pgxrepo is a generic PostgreSQL admin.Repository built on github.com/jackc/pgx/v5.
Package repotest is a conformance suite for admin.Repository implementations.
Package repotest is a conformance suite for admin.Repository implementations.

Jump to

Keyboard shortcuts

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