ObjectUIObjectUI

Field Registry

Object UI uses a Field Registry system to decouple the core engine from specific UI implementations of fields. This allows for rich extensibility and plugin support.

Concept

The @object-ui/fields package serves as the "Universal Language" for rendering values.

When a component like <ObjectGrid> needs to render a date field, it doesn't import a DatePicker directly. Instead, it asks the registry:

"Hey, give me the component responsible for rendering type 'date'."

This architecture allows you to:

  1. Override standard fields (e.g. replace the native date picker with a fancy one).
  2. Add new field types (e.g. add a rating or signature field).
  3. Keep bundles small (heavy components like Code Editors are loaded only if their plugin is registered).

Usage

1. Registering a Custom Field

You can register a custom renderer globally, typically at your app's entry point.

// src/setup.tsx
import { registerFieldRenderer, type CellRendererProps } from '@object-ui/fields';

const MyRatingField = ({ value, onChange }: CellRendererProps) => {
  return (
    <div className="rating">
      {[1, 2, 3, 4, 5].map(star => (
        <span 
          key={star} 
          onClick={() => onChange?.(star)}
          style={{ color: star <= value ? 'gold' : 'grey' }}
        >
          ★
        </span>
      ))}
    </div>
  );
};

// Register it
registerFieldRenderer('rating', MyRatingField);

2. Using in Schema

Once registered, you can simply use the new type in your JSON schema.

{
  "type": "form",
  "fields": [
    {
      "name": "customer_satisfaction",
      "type": "rating", 
      "label": "Satisfaction"
    }
  ]
}

Standard Fields

Object UI comes with built-in support for the standard ObjectStack Protocol types:

TypeDescription
textSingle line text
textareaMulti-line text
numberNumeric input
currencyCurrency formatting
percentPercentage values
dateDate picker
datetimeDate & Time picker
booleanCheckbox / Switch
selectDropdown
lookupReference to another object
master_detailParent-child relationship
userPerson picker — searches the sys_user object (a lookup specialized to users)
ownerRecord owner — a user field, typically read-only and stamped with the current user

What a number field silently rewrites

number, currency, percent and geolocation render a native type="number" input. The browser — not ObjectUI — decides what that box will accept, and it rewrites some entries before any widget code runs. Two different things can happen, and only one of them is announced.

Announced: text the browser cannot read

If the box is left holding something that is not a complete number, the browser reports validity.badInput and these widgets now say so: the control is marked aria-invalid="true" and a message is drawn under it —

Not saved: the text in this box is not a number. Enter a plain decimal (example: 1234.56).

The sentence follows the reader's language (objectui#8148) — it resolves through the fields.number.badInput locale key, so a console running in Chinese, Japanese or Arabic refuses in that language. The example numeral is the widget's own (1234 for number, 1234.56 for currency, 12.5 for percent, 30.2741 / 120.1551 for the two geolocation boxes) and stays verbatim in every language.

Measured in Chromium 141, typing any of 1e, 1e-, 1e+, 5e, -, ., +, -. or e leaves the box visibly displaying what was typed while its value reads empty. Before this was announced, the field simply stored nothing and said nothing.

⚠️ NOT announced: entries the browser silently truncates

This is the important limitation, and it is deliberate rather than an oversight.

you paste / typethe field stores
1.2.31.23
0x1010
12abc12

No warning is shown for these, and no widget-side check can add one. The browser filters the keystrokes or the pasted text as it arrives, so by the time ObjectUI sees the field the discarded characters are already gone — there is nothing left to detect. This is native type="number" behaviour; recovering it would mean giving up the numeric keyboard on mobile and the min/max/step spinner on every numeric field in the product.

⛔ So do not read "no warning" as "the value is correct." A warning means the browser could not read the box at all. Silence means the browser read something — which may be less than you typed. When exact input matters (reference codes, serial numbers, anything where 1.2.3 is meaningful), declare a text field, not a numeric one.

Polymorphic pointer pairs (referenceVia)

A text field that declares referenceVia holds a record id of the object its sibling column names, row by row: sys_approval_request.record_id points at the record object_name names, and sys_activity.source_id at the one source_object names. The record details grid resolves the pair from the row with resolveRecordPointer(field, row) and draws it with the renderer registered under RECORD_POINTER_CARD_TYPE. The fields package's default draws the record id as text; the console registers a record preview card there, so a pointer field on a record page shows the record it points at. Nothing is authored for this: the referenceVia declaration on the field is the whole contract. List cells are not a call site yet.

Naming a registry item by name (position-ref, permission-ref)

A text field that stores the machine name of one item of a metadata registry — a position, a permission set — declares the matching widget, and the record form lists that registry instead of asking for a typed name:

{
  "position": {
    "type": "text",
    "label": "Position",
    "required": true,
    "maxLength": 100,
    "widget": "position-ref"
  }
}
WidgetListsStores
position-refthe position registry (GET /api/v1/meta/position)the position's name
permission-refthe permission registry (GET /api/v1/meta/permission)the permission set's name

Like every widget value, the name is looked up as field:<widget>. Each item reads as its label beside its name, and the value written is the name alone: no label, no id. A stored name the registry does not list stays on screen, flagged not found, so a renamed or removed item is visible rather than dropped. position-ref leaves out the audience anchors, the spec's AUDIENCE_ANCHOR_POSITIONS (everyone, guest), because the server refuses an anchor as a grant target; a stored anchor still shows as its own option. The list comes from the data source's listMetadataItems(type). When the adapter has none, the list is empty, or the load fails, the field is a text box that takes the typed name; a failure is shown with its cause, and the box stays editable.

Using Renderers in Custom Components

If you are building your own custom component (like a Kanban board card), you can leverage the registry to render fields without reinventing the wheel.

import { getCellRenderer } from '@object-ui/fields';

export const KanbanCard = ({ task }: { task: { name: string; assignee: string } }) => {
  // Get the standard renderer for a 'user' type field
  const UserRenderer = getCellRenderer('user');
  
  return (
    <div className="card">
      <h3>{task.name}</h3>
      <div className="assignee">
        <UserRenderer 
          value={task.assignee} 
          field={{ type: 'user', name: 'assignee' }} 
        />
      </div>
    </div>
  );
};

On this page