standards
SDK

Views

Declare detail and list views with the fluent view builders.

Views define how records are displayed and edited. There are two builders: detailView() for a single record (form tabs, related tables, activity) and listView() for collections (tables, kanban). Like objects, views are registered in a registry and pushed to the platform on sync.

Detail views

A detail view targets one object, then declares tabs. The smallest useful one has a single form tab:

import { detailView, group } from "@stndrds/schema";

const CONTACT_DETAIL = detailView("contact-detail", "Contact")
  .for("contacts")
  .default()
  .tab("general", "Info")
  .form(group("identity", "Identity").fields("firstName", "lastName", "email"))
  .build();

Closing a tab

.form() and .forms() hand you back the view builder, so the next .tab() or .build() follows directly. Every other tab type returns a configuration object instead — close it with .done() before moving on.

Adding more tabs, each of a different type:

import { detailView, group, relationGroup } from "@stndrds/schema";

const CONTACT_DETAIL = detailView("contact-detail", "Contact")
  .for("contacts")
  .default()
  .icon("user")
  .tab("general", "Info")
    .icon("info")
    .form(
      group("identity", "Identity")
        .field("firstName", { span: 6 })
        .field("lastName", { span: 6 })
        .fields("email", "phone"),
      relationGroup("companies", "Companies", "companies")
        .columns("name", "status")
        .allowCreate(),
    ) // .form() returns the view builder — no .done() needed
  .tab("deals", "Deals")
    .icon("dollar")
    .tableFrom("deals", "contact") // inverse lookup: deals whose `contact` is this record
    .columns("name", "amount", "stage")
    .crud()
    .sort("createdAt", "desc")
    .done() // table tab: close it
  .tab("documents", "Documents")
    .documents()
    .done()
  .tab("activity", "Activity")
    .activity()
    .done()
  .build();

Indentation here is cosmetic — the chain is flat, and .tab() simply starts a new tab.

detailView(name, label) methods

MethodEffect
.for(objectName)Target object.
.default()Mark as the object's default detail view.
.description(text) / .icon(name)Shown in the app's view switcher.
.sidePanel({ attributes, width? })Persistent fields shown alongside every tab.
.tab(name, label)Start a tab (see below).
.addTab(tab)Add a pre-built tab object.
.metadata(object)Arbitrary key/value data carried with the view.
.build()Finalize.

Detail tabs

After .tab(name, label) (plus optional .icon() and .order()), pick exactly one tab type:

Tab typeContent
.form(...groups)Editable field groups.
.table(relationAttribute)One-hop lookup — records linked by a relation on the current object.
.tableFrom(sourceObject, relationAttribute)Inverse one-hop lookup — records of sourceObject whose relation points at this record.
.custom(component)A registered custom component; pass data with .props({...}).
.richtext(attribute)Rich text editor bound to an attribute; .titleAttribute(name) binds a title.
.activity()Record activity feed; .limit(n).
.documents()Record documents; .disableUpload(), .disableRemove(), .hideAttachments().
.emails()Email threads; .emailAttributes(...ids) selects the address attributes.
.forms(options?)Form submissions linked to the record.

Both one-hop table tabs (.table() and .tableFrom()) accept the same configuration methods:

MethodEffect
.columns(...names)Visible columns.
.sort(attr, dir?) / .sorts(rules)Default sort.
.filters(filterState)Restrict the rows shown (see Filters).
.create() / .edit() / .delete()Enable one action; .crud() enables all three.
.createMode("redirect" | "inline" | "peek")How record creation opens.

Form groups

BuilderMethods
group(id, label).field(attribute, { span?, label?, readOnly? }) (span is 1–12), .fields(...names), .attributeGroup({ id, label, attributes, description? }) to fold several attributes behind one composite control, .collapsible(collapsed?), .order(n), .description(text).
relationGroup(id, label, attribute).columns(...names), .allowCreate(v?), .readOnly(v?), .collapsible(collapsed?), .order(n), .description(text).

List views

A list view also targets one object. Each tab is a saved table or kanban configuration over the same records. Unlike detail tabs, list tabs never need .done().tab() and .build() close the previous one:

import { listView } from "@stndrds/schema";

const CONTACTS_LIST = listView("contacts-list", "Contacts")
  .for("contacts")
  .default()
  .icon("users")
  .tab("all", "All")
    .columns("firstName", "lastName", "email", "company", "status")
    .columnWidth("email", 200)
    .sort("lastName", "asc")
    .default()
  .tab("pipeline", "Pipeline")
    .kanban("status")
    .columns("firstName", "lastName", "company")
    .cardUserAttribute("owner")
    .cardDateAttribute("createdAt")
  .build();

listView(name, label) methods

MethodEffect
.for(objectName)Target object.
.default()Mark as the object's default list view.
.baseFilter(filterState)Filter applied to every tab.
.description(text) / .icon(name)Shown in the app's view switcher.
.metadata(object)Arbitrary key/value data carried with the view.
.tab(id, label)Start a tab.
.build()Finalize.

List tabs

MethodEffect
.table()Table layout (the default).
.kanban(groupByAttribute)Kanban grouped by a select or status attribute.
.icon(name)Tab icon.
.columns(...names) / .columnWidth(col, px)Visible columns.
.filter(filterState)Tab-specific filter.
.visibility("all" | "workspace" | "private")Which records the tab reads. Defaults to "all".
.sort(attr, dir?) / .sorts(rules)Default sort.
.createMode("redirect" | "inline" | "peek")How record creation opens.
.cardUserAttribute(name) / .cardDateAttribute(name)Avatar and date shown on kanban cards.
.kanbanColumnOrder(values) / .kanbanColumnVisibility(map) / .kanbanPinnedColumns(values)Kanban column tuning.
.default()Default tab.
.done()Close the tab explicitly. Optional — .tab() and .build() close it for you.

Tab visibility

Records carry a visibility of either workspace (everyone your permissions already allow) or private (only its owner, including when that owner acts through an agent). workspace is the product meaning of "public" — it never means reachable from the internet.

A tab chooses which of those it reads:

ValueThe tab shows
"all" (default)Every workspace record, plus your own private ones.
"workspace"Workspace records only.
"private"Only your own private records.
export const SKILL_LIST_VIEW = listView("skill-list", "Skills")
  .for("skill")
  .default()
  .tab("workspace", "Skills")
  .default()
  .icon("table")
  .visibility("workspace")
  .columns("name", "description")
  .tab("mine", "My skills")
  .icon("lock")
  .visibility("private")
  .columns("name", "description")
  .build();

Two rules follow from this:

  • Creating from a tab uses the tab's scope, except an "all" tab, which creates workspace records — a record has to be exactly one scope, and that is the one that restricts nobody.
  • .filter() rejects rules targeting visibility or ownedBy. Use .visibility(...) instead; two mechanisms could otherwise describe contradictory tabs.

Visibility is never enforced by the tab. The server applies the viewer's visibility predicate below your filters on every read path, so narrowing or widening a tab can only change what is shown, never what a user is allowed to see.

Filters

Three methods apply filters, one per level — they differ only in scope:

MethodWhereScope
.baseFilter(state)List viewEvery tab of the view.
.filter(state)List view tabThat tab only.
.filters(state)Detail view table tabThe rows of that table.

All three take the same shape as the React hooks and the REST API:

{
  combinator: "and", // or "or"
  rules: [{ attribute: "status", operator: "is", value: "active" }],
}

Dynamic values

A view filter is stored once and evaluated for every viewer, so hard-coding a user or a date rarely does what you want. Three helpers resolve at query time instead:

import { currentActor, listView, today } from "@stndrds/schema";

listView("my-deals", "My deals")
  .for("deals")
  .baseFilter({
    combinator: "and",
    rules: [
      { attribute: "owner", operator: "is", value: currentActor() },
      { attribute: "closeDate", operator: "greater_or_equal", value: today() },
    ],
  });

currentActor() resolves to the actor viewing the records, today() to the current date, and now() to the current timestamp. The REST API accepts the same values in raw form.

A view stores the helper, not its result: the server resolves it on every request. today() therefore follows the timezone the request carries — which a view can't pin, since it belongs to the caller — and falls back to UTC when none is sent. currentActor() resolves to whoever is viewing, and if no actor is in context the rule is dropped rather than the request failing.

Registration

Registering a view makes it available to the app and to the next sync:

import { viewRegistry } from "@stndrds/schema";

viewRegistry.register([CONTACT_DETAIL, CONTACTS_LIST]);

register takes one view or an array, and returns the registry so calls can chain.

Generated views

Registering views is optional. When an object has none, the platform generates one from its definition on first render and saves it. Every object is therefore usable before you lay one out by hand.

To pull a saved view back in line with its object, call resetViewToDefault. It regenerates the view from the object definition while keeping the view's id, name, label, description, and icon — and, by default, preserving any custom-component tabs and table (relation) tabs already on the view, merging them back into the regenerated result.

You can also call the generators directly: generateDefaultDetailView(object, options?) and generateDefaultListView(object, options?). Both accept excludeAttributes, maxListColumns, and includeDocumentsTab.