@PortalRelation + @PortalLookup — relations
Both annotations must be placed together on the same field. MetadataService merges them into a RelationMetadata object sent to the frontend.
Two rendering modes
| Renderer | When to use | Field in entity |
|---|---|---|
RendererType.RELATION | ManyToOne, OneToOne — stores one foreign key | var xyzId: Long? = null |
RendererType.RELATION_LIST | OneToMany, ManyToMany — list of related entities | @Transient var items: List<Entity>? = null |
RELATION_LIST requires @jakarta.persistence.Transient — not a database column.
@PortalRelation — parameters
annotation class PortalRelation(
val targetEntity: KClass<*> = Unit::class,
val editable: Boolean = true,
val inlineEdit: Boolean = false,
val displayFields: Array<String> = [],
val searchFields: Array<String> = [],
val createAllowed: Boolean = false,
val cascadeDelete: Boolean = false,
val orderBy: String = "",
val maxItems: Int = 0,
val downloadAction: String = "",
val actions: Array<RelationRowAction> = []
)| Parameter | Default | Description |
|---|---|---|
targetEntity | Unit::class | Target JPA entity class — always provide explicitly |
editable | true | Whether the picker can be modified in the form |
inlineEdit | false | RELATION_LIST only: edit records directly in the table |
displayFields | [] | Columns in RELATION_LIST or extra info in the picker |
searchFields | [] | Fields searched when the user types in the picker |
createAllowed | false | Picker shows “Create new” option |
cascadeDelete | false | Informational — warning shown when deleting parent |
orderBy | "" | HQL ORDER BY fragment (no keyword), alias e |
maxItems | 0 | Max items in RELATION_LIST (0 = unlimited) |
actions | [] | Per-row action buttons in RELATION_LIST |
@PortalLookup — parameters
annotation class PortalLookup(
val labelField: String = "name",
val valueField: String = "id",
val filterQuery: String = "",
val dependsOn: String = "",
val maxResults: Int = 100,
val parentField: String = ""
)| Parameter | Default | Description |
|---|---|---|
labelField | "name" | Target entity field shown as label in picker and table cell |
valueField | "id" | Field stored as the foreign key |
filterQuery | "" | Constant HQL WHERE filter (alias e.), e.g. "e.isActive = true" |
dependsOn | "" | Name of another form field — cascading dropdown |
maxResults | 100 | Max options from /lookup endpoint |
parentField | "" | RELATION_LIST only: FK field name in the target entity |
Required annotation order
@Column(...) // 1. JPA
@PortalField( // 2. UI field declaration
renderer = RendererType.RELATION, ...
)
@PortalRelation( // 3. Relation config
targetEntity = Country::class, ...
)
@PortalLookup( // 4. Lookup config
labelField = "name", valueField = "id"
)
var countryId: Long? = nullExamples
Simple ManyToOne relation (customer’s country):
@Column
@PortalField(
label = "Country", tab = "CONTACT", order = 6,
renderer = RendererType.RELATION,
filterType = FilterType.EXACT,
showInTable = false
)
@PortalRelation(
targetEntity = Country::class,
displayFields = ["name", "code"],
searchFields = ["name", "code"]
)
@PortalLookup(labelField = "name", valueField = "id")
var countryId: Long? = nullRelation with filter (active categories only):
@Column
@PortalField(label = "Category", order = 3, renderer = RendererType.RELATION)
@PortalRelation(targetEntity = Category::class, displayFields = ["name"], searchFields = ["name"])
@PortalLookup(labelField = "name", valueField = "id", filterQuery = "e.isActive = true")
var categoryId: Long? = nullCascading dropdowns (country → city):
// Source field
@Column
@PortalField(label = "Country", order = 5, renderer = RendererType.RELATION)
@PortalRelation(targetEntity = Country::class, searchFields = ["name"])
@PortalLookup(labelField = "name", valueField = "id")
var countryId: Long? = null
// Dependent field — filtered by selected country
@Column
@PortalField(label = "City", order = 6, renderer = RendererType.RELATION)
@PortalRelation(targetEntity = City::class, searchFields = ["name"])
@PortalLookup(labelField = "name", valueField = "id", dependsOn = "countryId")
var cityId: Long? = nullEditable inline list (order items):
@jakarta.persistence.Transient
@PortalField(
label = "Order Items", tab = "ITEMS", order = 1,
renderer = RendererType.RELATION_LIST,
filterType = FilterType.NONE,
showInTable = false, showInFilter = false
)
@PortalRelation(
targetEntity = OrderItem::class,
editable = true, inlineEdit = true,
displayFields = ["productId", "quantity", "unitPrice"],
maxItems = 100, orderBy = "id ASC"
)
@PortalLookup(labelField = "productId", valueField = "id")
var items: List<OrderItem>? = nullRelation list with a download button:
@jakarta.persistence.Transient
@PortalField(label = "Files", renderer = RendererType.RELATION_LIST, showInFilter = false, showInTable = false)
@PortalRelation(
targetEntity = TaskRunFile::class,
editable = false,
displayFields = ["fileName", "fileSize"],
actions = [RelationRowAction.DOWNLOAD]
)
@PortalLookup(labelField = "fileName", valueField = "id", parentField = "taskRunId")
var files: List<TaskRunFile>? = nullCommon mistakes
| Mistake | Effect | Fix |
|---|---|---|
Missing @PortalLookup on a RELATION field | Frontend falls back to name/id | Always add @PortalLookup |
RELATION_LIST without @Transient | Hibernate tries to map the collection — startup error | Add @jakarta.persistence.Transient |
filterQuery with alias other than e | HQL error at runtime | Always use e.targetFieldName |
dependsOn pointing to non-existent field | Cascade filter silently broken | Check exact field name (case-sensitive) |
Last updated on