Skip to Content
Docs@PortalRelation + @PortalLookup — relations

@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

RendererWhen to useField in entity
RendererType.RELATIONManyToOne, OneToOne — stores one foreign keyvar xyzId: Long? = null
RendererType.RELATION_LISTOneToMany, 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> = [] )
ParameterDefaultDescription
targetEntityUnit::classTarget JPA entity class — always provide explicitly
editabletrueWhether the picker can be modified in the form
inlineEditfalseRELATION_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
createAllowedfalsePicker shows “Create new” option
cascadeDeletefalseInformational — warning shown when deleting parent
orderBy""HQL ORDER BY fragment (no keyword), alias e
maxItems0Max 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 = "" )
ParameterDefaultDescription
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
maxResults100Max 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? = null

Examples

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? = null

Relation 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? = null

Cascading 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? = null

Editable 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>? = null

Relation 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>? = null

Common mistakes

MistakeEffectFix
Missing @PortalLookup on a RELATION fieldFrontend falls back to name/idAlways add @PortalLookup
RELATION_LIST without @TransientHibernate tries to map the collection — startup errorAdd @jakarta.persistence.Transient
filterQuery with alias other than eHQL error at runtimeAlways use e.targetFieldName
dependsOn pointing to non-existent fieldCascade filter silently brokenCheck exact field name (case-sensitive)
Last updated on