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:
| Field | Lookup table | Autofill |
|---|---|---|
| Post Code | Postal codes | City |
| Payment Terms | Payment terms table | — |
| Item No. | Items | Description, Unit Price |
| Customer No. | Customers | Name, Address |
| Currency Code | Currencies | Exchange 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"
}
}| Property | Type | Required | Purpose |
|---|---|---|---|
endpoint | string | Yes | GET URL returning the lookup list |
display_field | string | No | Hints 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. |
validate | string | No | GET URL for blur validation. If present, the field is strict. |
display | string | No | "modal" for inline overlay, absent for full-screen list |
context | array | No | Context fields whose values are sent as query parameters. See Context-dependent lookups. |
Strict vs helper lookups
- Strict (
validateis set): On blur, the client calls the validate endpoint. If the value is invalid, the user is locked on the field. - Helper (
validateis absent): The lookup is just a convenience. The user can type any value.
Modal vs full-screen
- 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 (
displayabsent): 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
| Property | Type | Purpose |
|---|---|---|
value_column | string | Which column's value is returned to the originating field |
autofill | object | Maps column IDs in this lookup table → field 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"]:
value_column: "code"→ the value"100"is written to thepost_codefieldautofill: { "city_name": "city" }→ the value"Tórshavn"is written to thecityfield 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} → ValidateResponseThe client calls this when the user leaves a strict lookup field (blur). It is only called when:
- The field has
lookup.validateset - 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
| Property | Type | Purpose |
|---|---|---|
valid | boolean | Whether the value exists in the related table |
autofill | object | Field 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.) |
error | string | Error message (only when invalid) |
Implementation rules
- Empty values: return
{ "valid": true }— use the field'srequiredvalidation 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=FOContext 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=ResourceYour 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 tableLookupContext properties
| Property | Type | Required | Purpose |
|---|---|---|---|
field | string | Yes | The id of the field (card) or column (grid) to read |
param | string | No | Query 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®ion_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=cannonFilter 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=1200Your 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.