Skip to content

Detail

The detail page shows one record. Without configuration every field renders with a sensible default; Detail[T] customizes projection and presentation.

posts.Detail(func(d *steward.Detail[Post]) {
    d.Field("Title")
    d.Field("Status").Badge(map[any]steward.BadgeColor{"draft": steward.BadgeSecondary, "published": steward.BadgeGreen})
    d.Field("Cover").Image(320, 0)
    d.Field("Body").Markdown()
    d.Field("Author.Name", "Author")
})

A field path may cross one relation, as Author.Name does; the relation is loaded for you.

Computed rows

FieldFunc(name, label, fn) adds a row whose value comes from the whole record rather than from one path — a collection, a summary, anything a struct field cannot name. It is ColumnFunc on the show view:

d.FieldFunc("tags", "Tags", func(p *Post) template.HTML {
    return template.HTML(strings.Join(tagNames(p.Tags), ", "))
})

The record is whatever the repository loaded, so a collection needs its relation preloaded — repo.With("Tags") — the same as for a computed column.

Layout

Each field is one row: label on the left, value on the right, with a rule between rows. On a narrow screen the label sits above its value.

HTML(), Markdown() and JSON() take the row’s full width, with the label above them — long markup needs the width, and the two-column grid this replaced paired unrelated fields into rows, so one long value left an empty column beside itself for its whole height. Block() asks for the same treatment on any other field.

d.Field("Notes").Block()

Field renderers

A row shows the value as text unless a renderer says otherwise. One example of each:

Values with a meaning of their own

d.Field("Status").Using(map[any]string{0: "Draft", 1: "Published"})
d.Field("Status").Badge(map[any]steward.BadgeColor{0: steward.BadgeSecondary, 1: steward.BadgeGreen})
d.Field("Featured").Bool()                       // Yes / No
d.Field("Featured").Bool("Ya", "Tidak")          // or words of your own
d.Field("Size").Filesize()                       // 2411724 → 2.3 MB

Badge and Using combine, in either order: the colour comes from the stored value and the text from Using’s map, so a status shows the word rather than the number underneath it.

d.Field("Status").Badge(map[any]steward.BadgeColor{1: steward.BadgeGreen}).
    Using(map[any]string{1: "Live"})

A value the map does not list renders as it is stored — a status added to the database later is readable rather than blank. Bool takes two words or none; one is a mistake Verify reports, since there is no telling which half it is.

Long values

d.Field("Body").Markdown()          // GitHub-flavoured, sanitized
d.Field("Content").HTML()           // the read side of a Richtext field
d.Field("Payload").JSON()           // pretty-printed in a code block
d.Field("Notes").Preformatted()     // keeps line breaks and runs of spaces

Each of these takes the row’s full width; see layout.

Lists

d.Field("Keywords").Tags()          // ["launch","steward"] → two chips

The read side of a Tags field: one text column holding a JSON array, drawn as chips. Values are escaped, and a column holding a plain string rather than an array shows as one chip.

Files and links

d.Field("Cover").Image(480, 0)      // 0 leaves that dimension free
d.Field("Attachment").Link()        // resolves through Storage
d.Field("Avatar").Image(96, 96).Disk("public")

Disk names which disk a stored path belongs to, for a field that does not live on the default one.

Anything else

As receives the raw value and the typed record, and returns markup:

d.FieldFunc("tags", "Tags", func(p *Post) template.HTML {
    return template.HTML(strings.Join(tagNames(p.Tags), ", "))
})

d.Field("PublishedAt").As(func(v any, p *Post) template.HTML {
    if p.PublishedAt == nil {
        return "not yet"
    }
    return template.HTML(p.PublishedAt.Format("2 January 2006"))
})

As reads one path and can see the whole record; FieldFunc needs no path at all. Use FieldFunc for a value no single field names — see computed rows.

Copyable

Copyable() puts a copy button beside the value. It copies what is stored, not what is shown, so a badge, a shortened path, or a formatted number still yields the value someone would paste elsewhere.

d.Field("ID").Copyable()
d.Field("Slug").Copyable()

Column.Copyable does the same in a grid cell. The button appears on hover, and stays visible where there is no hover to give.

Markdown

Markdown() renders the value as GitHub-flavoured markdown — headings, lists, tables, strikethrough, autolinks — and passes the result through the same allowlist a Richtext value goes through. Markdown permits raw HTML, so the allowlist is not optional decoration here: it is what keeps a <script> in a stored document from becoming a script on the page.

Preformatted() is the one to use for text that is not markdown: it escapes the value and keeps its line breaks and runs of spaces.

Uploads

Image(w, h) and Link() both put the stored value into a src or an href, so both resolve it through the configured Storage first. A File/Image form field stores a storage-relative path rather than a URL, which the browser would otherwise read relative to the current page.

d.Field("Cover").Image(480, 0)     // <img src="/app/_uploads/…">
d.Field("Attachment").Link()       // downloads instead of 404ing

Clicking the image opens it full size in the panel’s viewer. A stored file gets a Preview button beside its link when it is something the viewer can show — an image or a PDF — so a document can be read without leaving the record. An absolute URL pointing at another site is left to navigate.

Either way the value carries a glyph saying what following it does: a paperclip for a stored file, an arrow for a link that leaves the panel. Both read as plain text otherwise, which is what made a file path look like a value nobody could act on.

A value that is already absolute (http://, https://, /…, data:) is left alone, so a column of external URLs behaves as before. Link() shows the stored value and points at the resolved one, so the path stays readable rather than being replaced by its URL.

HTML

HTML() renders the value as markup rather than escaped text — the read side of a Form.Richtext field:

d.Field("Body").HTML()

The value is sanitized here too, not merely trusted for having been sanitized on save. Rows predating the field, rows written by a migration or a direct SQL fix, and rows from another writer never passed through that path, so cleaning on render is what makes the guarantee hold for the data actually in the table.

Relation grids

Embed another registered resource’s grid, scoped to the shown record:

steward.RelationGrid[Author, Post](d, "Posts by this author",
    func(q *steward.ListQuery, a *Author) {
        q.Conds = append(q.Conds, steward.Cond{Path: "AuthorID", Op: steward.OpEq, Val: a.ID})
    })

The embedded table reuses the related resource’s grid columns and links to its full listing. The related resource’s policy applies: its RowScoper narrows the rows, and if its ViewAny denies the viewer the section disappears entirely.

JSON

GET /{slug}/{id} with Accept: application/json returns the record itself — see JSON API.