Skip to content

Tables

A screen.hcl registers one database table with the panel and describes how it renders. It lives in a table folder under a group — screens/<group>/<table>/screen.hcl — and the folder name is the table name: screens/sales/orders/screen.hcl configures the orders table. An empty screen.hcl is valid — the table then renders entirely from introspected defaults.

Only tables with a screen.hcl are exposed. See the allowlist model.

A worked example

The 80% case: pick list columns, wire up search/filters/sort, style a column or two, and cap what can be changed. From the reference config, lightly abridged:

hcl
# screens/sales/orders/screen.hcl
label        = "order"
label_plural = "Orders"

list {
  columns  = ["id", "customer_id", "status", "total", "item_count", "placed_at"]
  search   = ["status"]
  filters  = ["status"]
  sort     = "-placed_at"

  filter_def "needs_attention" {
    label = "Needs attention"
    sql   = "t.status = 'pending' AND t.placed_at < now() - interval '2 days'"
  }
}

display {
  title = "Order #{id}"
}

detail {
  section {
    title  = "Identity"
    fields = ["id", "customer_id", "status"]
  }
  section {
    title  = "Amounts"
    fields = ["total", "item_count", "placed_at"]
  }
}

edit {
  readonly = ["id", "customer_id", "placed_at"]
}

relations {
  inlines = ["order_items"]
}

permissions {
  create = false
  delete = false
}

field "status" {
  widget = "badge"
  params = { colors = { pending = "gray", paid = "blue", shipped = "green", refunded = "orange", cancelled = "red" } }
}

field "item_count" {
  label  = "Items"
  widget = "custom:minibar"
  sql    = "(SELECT count(*) FROM order_items oi WHERE oi.order_id = t.id)::int"
  params = { field = "item_count", max = 10, warn_at = 8 }
}

action "refund" {
  label   = "Refund"
  kind    = "update"
  set     = { status = "refunded" }
  confirm = "Refund {count} orders? This is a demo — no money moves."
  danger  = true
}

Reference: everything a screen.hcl can hold

Every entry is optional; leave it out and cartapel uses a sensible introspected default.

KeyTypeDescription
label / label_pluralstringSingular / plural label (nav + list heading). Default: the humanized table name.
labels / labels_pluralmapPer-locale label overrides; the instance locale picks one. See Localization.
from { }blockServe from another postgres source / schema / physical table (see below).
list { }blockThe list view: columns, search, filters, sort, page size.
display { }blockThe record title template.
detail { }blockThe detail-view layout — Detail views.
edit { }blockColumns read-only on the edit form.
relations { }blockInline child tables — Inlines.
permissions { }blockCreate / update / delete ceilings for the whole table.
field "col" { }blockPer-column widget & presentation (repeatable) — Fields & widgets.
action "name" { }blockBulk actions (repeatable).

list { } — the list view

hcl
list {
  columns  = ["id", "name", "sku", "price", "active"]
  search   = ["name", "sku"]
  filters  = ["active"]
  sort     = "-id"
  per_page = 50
}
KeyTypeDescription
columnslistColumns shown in the list, in order. Omit → the primary key plus the first few introspected columns (six total, JSON and binary columns skipped).
searchlistColumns the search box matches against. Omit → the first four text columns.
filterslistFeatured filters. Every real column is always filterable — the "+ Filter" picker lists declared names first (with value options preloaded), then every remaining column. Declaring a name here features it and, for enum-ish columns, populates its value dropdown; a name matching a filter_def surfaces that custom filter. Masked columns can never be filtered, for anyone.
sortstringDefault sort column. Prefix with - for descending ("-created_at"). Omit → the primary key, descending (pk-less tables: the first column, ascending).
per_pagenumberPage size for this table (overrides the global per_page; 100 when neither is set).
filter_def "name" { }blockA custom filter: a label plus a raw sql predicate.

Custom filters

A filter_def is a named boolean predicate. The sql is a trusted fragment from your config (never user input) and can reference the current table as t:

hcl
filter_def "needs_attention" {
  label = "Needs attention"
  sql   = "t.status = 'past_due' OR (t.renews_at IS NOT NULL AND t.renews_at < now() + interval '7 days')"
}

List "needs_attention" in filters to surface it in the list's "+ Filter" picker; adding it applies the predicate as an on/off filter chip.

display { } — the record title

hcl
display {
  title = "{name} · {country}"
}

title is a template with {column} placeholders, used wherever a single record needs a human label (detail heading, breadcrumbs, inline row labels). Omit it and cartapel picks a name-ish text column (name, title, email, username, …) when one exists; otherwise the title is Label #pk ("Subscription #8"), never a bare id.

edit { } — read-only columns

hcl
edit {
  readonly = ["id", "customer_id", "placed_at"]
}

readonly columns render on the detail/edit form but cannot be changed. This is distinct from role-level editable whitelists (see Roles & permissions) — edit.readonly applies to everyone.

permissions { } — table-level gates

hcl
permissions {
  create = false
  delete = false
  write  = true
}
KeyDefaultDescription
createtrueWhether new rows can be created.
deletetrueWhether rows can be deleted.
writetrueWhether existing rows can be updated.

These are the ceiling for the whole table. A role can only ever narrow them further — never widen them. A structurally read-only table (a view, or a table with no primary key) is read-only regardless of what you set here.

from { } — serve from another source

hcl
from {
  source = "replica"
  schema = "billing"
  table  = "orders_v"
}

All three keys are optional: source names another postgres source (an unknown or non-postgres alias is a load error), schema and table point at the physical relation when they differ from the defaults (primary source, introspected schemas, folder name).

detail { } and relations { }

Detail layout (sections, sidebar, stats, tabs, mode) and inline child tables have their own page: Detail views. Two things worth knowing from here:

  • With no relations block at all, cartapel derives zero-config inlines from introspected reverse foreign keys — every exposed table pointing at this one becomes an inline. auto = false opts out.
  • An inlines entry is a table name ("order_items") or a full object ({ table = "...", fk_col = "...", columns = [...], can_create = false, can_delete = false }).

field "col" { } — per-column presentation

Each field block styles one column: its widget, formatting, color rules, computed SQL, and more. This is the heart of customization — Fields & widgets covers every option.

hcl
field "plan" {
  widget = "badge"
  params = { colors = { free = "gray", pro = "blue", enterprise = "violet" } }
}

field "active" {
  widget = "toggle"
}

action "name" { } — bulk actions

Actions apply to the rows a user selects in the list. Three kinds:

hcl
action "deactivate" {
  label   = "Deactivate"
  kind    = "update"
  set     = { active = false }
  confirm = "Deactivate {count} products?"
  danger  = false
}
KeyTypeDescription
labelstringButton label. Required.
labelsmapPer-locale label overrides; the instance locale picks one.
kindenumupdate, delete, or webhook. Required.
setmapFor update: the column → value assignments applied to selected rows.
urlstringFor webhook: the endpoint to call.
methodstringFor webhook: HTTP method (default POST).
confirmstringConfirmation prompt. {count} interpolates the selection size.
dangerboolStyle the action as destructive (red).
  • update runs a single parameterized UPDATE … SET … WHERE pk IN (…).
  • delete deletes the selected rows.
  • webhook calls url with a JSON body of {action, table, pks, actor, ts} — an escape hatch into your real backend. Signed with X-Cartapel-Signature (HMAC-SHA256 of the body) when CARTAPEL_WEBHOOK_SECRET is set; disabled entirely by the disable_webhooks hardening toggle. See Security.

Which roles may invoke an action is controlled in config/auth.hcl via the role's actions list, entries of the form "<table>.<action>".

Released under the MIT License.