Skip to content

Lookups

Summary

A lookup binds an editable field to a related table. The user can browse and select a value (Ctrl+Enter) or type directly and have the value validated on blur. This page covers both the lookup list endpoint and the validation endpoint.

When to use lookups

Use lookups for relational data — any field whose value must exist in another table:

FieldLookup tableAutofill
Post CodePostal codesCity
Payment TermsPayment terms table
Item No.ItemsDescription, Unit Price
Customer No.CustomersName, Address
Currency CodeCurrenciesExchange Rate

Defining a lookup on a field

Add a lookup object to the field definition:

json
{
  "id": "post_code",
  "label": "Post Code",
  "type": "Text",
  "value": "100",
  "lookup": {
    "endpoint": "/lookup/post_code",
    "display_field": "code",
    "validate": "/validate/post_code",
    "display": "modal"
  }
}
PropertyTypeRequiredPurpose
endpointstringYesGET URL returning the lookup list
display_fieldstringNoHints which column in the lookup list represents the primary code. Used by the client to highlight the relevant column. Typically matches value_column on the lookup response.
validatestringNoGET URL for blur validation. If present, the field is strict.
displaystringNo"modal" for inline overlay, absent for full-screen list
contextarrayNoContext fields whose values are sent as query parameters. See Context-dependent lookups.

Strict vs helper lookups

  • Strict (validate is set): On blur, the client calls the validate endpoint. If the value is invalid, the user is locked on the field.
  • Helper (validate is absent): The lookup is just a convenience. The user can type any value.
  • Modal (display: "modal"): Ideal for small datasets (postal codes, payment terms). All rows load at once. Client-side fuzzy filtering, no server round-trips during filtering.
  • Full-screen (display absent): For large datasets (customers, items). Server-side search via ?query=.

The lookup list endpoint

Route

GET /lookup/{field_id} → ScreenContract (List)

Response

Return a standard List ScreenContract with two additional fields on the lines object:

json
{
  "layout": "List",
  "title": "Post Codes",
  "lines": {
    "columns": [
      {
        "id": "code",
        "label": "Code",
        "width": 8
      },
      {
        "id": "city_name",
        "label": "City",
        "width": "fill"
      }
    ],
    "rows": [
      {
        "index": 0,
        "values": [
          "100",
          "Tórshavn"
        ]
      },
      {
        "index": 1,
        "values": [
          "110",
          "Tórshavn"
        ]
      },
      {
        "index": 2,
        "values": [
          "160",
          "Argir"
        ]
      },
      {
        "index": 3,
        "values": [
          "175",
          "Kirkjubøur"
        ]
      }
    ],
    "selectable": true,
    "value_column": "code",
    "autofill": {
      "city_name": "city"
    }
  }
}

Lookup-specific TableSpec fields

PropertyTypePurpose
value_columnstringWhich column's value is returned to the originating field
autofillobjectMaps column IDs in this lookup tablefield IDs on the originating card. Direction: lookup → card.

Do not set on_select on a lookup list. on_select is for navigation lists. value_column is for lookup lists.

What happens when the user selects a row

If the user selects the row ["100", "Tórshavn"]:

  1. value_column: "code" → the value "100" is written to the post_code field
  2. autofill: { "city_name": "city" } → the value "Tórshavn" is written to the city field on the card

Autofill-updated fields are marked as dirty and included in the save changeset.

The validate endpoint

Route

GET /validate/{field_id}/{value} → ValidateResponse

The client calls this when the user leaves a strict lookup field (blur). It is only called when:

  • The field has lookup.validate set
  • The value is non-empty
  • The value has changed from the original

Valid response

json
{
  "valid": true,
  "autofill": {
    "city": "Tórshavn"
  }
}

The client writes autofill values to the card fields. The user continues editing.

Invalid response

json
{
  "valid": false,
  "error": "'999' is not a valid postal code."
}

The client navigates back to the field, enters edit mode, selects all text, and shows the error. The user is locked until they fix the value or press Esc to revert.

ValidateResponse fields

PropertyTypePurpose
validbooleanWhether the value exists in the related table
autofillobjectField IDs on the card → values to write. Direction: server → card. (Note: this is the reverse of TableSpec.autofill which maps lookup column IDs → card field IDs.)
errorstringError message (only when invalid)

Implementation rules

  • Empty values: return { "valid": true } — use the field's required validation rule for mandatory fields
  • This is a GET request — pure read, no side effects, can be cached
  • Error messages should be specific: "'999' is not a valid postal code." not "Validation failed."

Autofill

Autofill means: when one field's value is resolved, other fields on the card update automatically with related values.

Keep autofill consistent

Both the lookup list (TableSpec.autofill) and the validate endpoint (ValidateResponse.autofill) produce autofill values. They should return the same data for the same value. In your backend, both endpoints should query the same data source.

Autofill keys are field IDs

The keys in the autofill map must match the id property of fields on the card. If the card has a field with id: "city", the autofill key must be "city".

Autofill does not trigger further validation

When autofill writes a value to a field, the client does not trigger that field's blur validation. Autofill values come from your server — they are already valid.

Lookup endpoints are intentionally shared

A single lookup endpoint (e.g., /lookup/post_code) can be referenced from multiple screens and resources. This is by design — define the lookup once in your backend and reuse it everywhere. Use context parameters when you need screen-specific filtering.

Context-dependent lookups

A lookup can depend on another field's value. For example, a postal code lookup that filters by country, or an item lookup that filters by line type.

Add a context array to the lookup definition:

json
{
  "id": "post_code",
  "label": "Post Code",
  "type": "Text",
  "value": "100",
  "lookup": {
    "endpoint": "/lookup/post_code",
    "validate": "/validate/post_code",
    "context": [
      { "field": "country_code" }
    ]
  }
}

The client reads the current value of country_code from the card and appends it as a query parameter:

GET /lookup/post_code?country_code=FO
GET /validate/post_code/100?country_code=FO

Context on grid columns

The same mechanism works on grid column lookups. The client reads the value from another column in the same row:

json
{
  "id": "no",
  "label": "No.",
  "type": "Text",
  "lookup": {
    "endpoint": "/lookup/no",
    "validate": "/validate/no",
    "context": [
      { "field": "type" }
    ]
  }
}

When the type column value is "Resource":

GET /lookup/no?type=Resource
GET /validate/no/8100?type=Resource

Your endpoints use the query parameter to serve different datasets:

?type=Item      → query the Items table
?type=Resource  → query the Resources table
?type=Text      → query the Standard Text table

LookupContext properties

PropertyTypeRequiredPurpose
fieldstringYesThe id of the field (card) or column (grid) to read
paramstringNoQuery parameter name. Defaults to field if omitted

Use param when the query parameter name differs from the field ID:

json
{
  "context": [
    { "field": "account_type", "param": "type" }
  ]
}

GET /lookup/account_no?type=Customer

Multiple context fields

A lookup can depend on multiple fields:

json
{
  "context": [
    { "field": "country_code" },
    { "field": "region_code" }
  ]
}

GET /lookup/post_code?country_code=FO&region_code=ST

Empty context values

If a context field is empty, the parameter is omitted. The server receives an unfiltered request.

Search on lookup lists

For full-screen lookups, the client sends search queries:

GET /lookup/customer_no?query=cannon

Filter your rows based on the query and return matching results.

For modal lookups (display: "modal"), all rows are loaded at once and the client handles filtering locally. No ?query= parameter is used.

Pre-selection (?selected=)

When opening a lookup, the client sends the current field value as ?selected=:

GET /lookup/sell_to_no?selected=10000
GET /lookup/no?type=Item&selected=1200

Your server should use this to position the result set so the selected record is visible. For large datasets with pagination, return the page containing the selected value — for example, if 10000 is at position 312 in the sort order, return rows 312–362 with the selected row at the top.

If ?selected= is absent or empty (new record, no value yet), return the first page normally.

The client pre-selects the matching row in value_column after receiving the response. If the row is not in the result set, it defaults to the first row.

Autofill target fields should typically be non-editable ("editable": false) since their value comes from the lookup.