Skip to content

UI Effects

UI effects are methods triggered when specific field values change in a form, allowing you to update other fields dynamically before saving.

from fullfinity.engine.base import *
class Order(Model):
product = ManyToOne("Product", related_name="orders", on_delete="RESTRICT")
quantity = Integer(default=1)
unit_price = Monetary(default=0.0)
@Model.ui_effect("product")
async def on_product_change(self):
"""When product changes, update the unit price."""
if not self.product:
# Clearing the trigger clears what it derived — see below.
self.unit_price = 0.0
return
await self.fetch_related("product")
self.unit_price = self.product.price

Clearing the trigger is a change too — reset what it derived

Section titled “Clearing the trigger is a change too — reset what it derived”

An effect fires on every change of its trigger, and emptying a picker is one of them. So if not self.product: return is almost always a bug: the record keeps the previous product’s description, price, unit, account and taxes sitting beside an empty picker — and those stale values are what saves, prints and posts.

The rule: whatever the effect derives from the trigger, it resets when the trigger is cleared. Write the cleared case first, as the early return:

@Model.ui_effect("product")
async def on_product_change(self):
if not self.product:
self.description = ""
self.uom = None
self.unit_price = 0.0
self.taxes = [] # M2M: an empty list unlinks everything
return
await self.fetch_related("product", "product__uom")
self.description = self.product.display_name
self.uom = self.product.uom
self.unit_price = await self.product.get_sale_price()

Use the empty value the field’s type calls for — "" for text, 0.0 for a number or Monetary, None for a relation, [] for a M2M/O2M. A cleared relation and an emptied collection are both reported back to the browser as changes, so the form patches them in.

Two things that follow from the same rule:

  • Re-derive on every change, not just on a fill. if not self.description: before assigning means switching from product A to product B leaves A’s description on the line. Guard a field that way only when it is genuinely the user’s to write (a free-text field the product merely seeds) — and say so in a comment.
  • Assign the lookup result even when it comes back empty. bom = await find_bom(...) followed by if bom: self.bom = bom keeps the previous product’s BoM when the new one has none. Assign unconditionally; a change is a change.

Not every effect has something to reset, and forcing one is its own bug — clearing a quantity should not wipe the price it last fetched, and clearing an optional variant does not invalidate the product it belongs to. The question to answer is only: did this trigger produce the value? If yes, it takes the value with it when it goes.

A record that has reached a state where editing it would be wrong is frozen by _controlled_edits: those fields render read-only and update() refuses a genuine change to one.

An effect can still be triggered on such a record — the trigger field is often one the freeze deliberately leaves editable. Whatever the effect writes to a frozen field is dropped, and the record’s own value restored, before anything reaches the browser. So an edit to a still-editable field cannot produce a value the save will then reject:

@Model.ui_effect("lines")
async def on_lines_set_commitment_date(self):
# The order's lines stay editable after confirmation; the promised date does not.
# Written exactly as if nothing were frozen — on a confirmed order this assignment
# simply doesn't leave the server.
self.commitment_date = await self._earliest_delivery_date()

Write effects for the editable case and let the freeze decide the rest — an effect never has to know which states froze which of its outputs, and adding a rule later needs no change here. The two mechanisms agree by construction, which is the point: an effect that had to test the state itself would be one more place for the answer to drift.

Two edges worth knowing:

  • A record being created is never frozen. Freeze conditions are written about saved records, so evaluating one against a half-built create form would lock fields the save would accept. Effects on a new-record form always report everything they derive.
  • Collections (OneToMany/ManyToMany) are not filtered — a frozen collection is not a shape effects write. Freeze the fields on the child model instead.

The @Model.ui_effect decorator specifies which fields trigger the method:

@Model.ui_effect("field1", "field2")
async def on_fields_change(self):
# Called when field1 or field2 changes in the UI
pass
FeatureUI EffectCalculated Field
Decorator@Model.ui_effect@Model.calculate
TriggerUser changes a dependency in the formUser changes a dependency in the form, and on save/read
Use CaseForm defaults, UI feedbackCalculations (optionally stored)
StorageCan modify any fieldOnly the calculated field
ExecutionDuring editing (API call) + not persisted itselfDuring editing (same API call) + on save/read
  • Populating defaults based on selections (product → price)
  • Updating related field options (country → state dropdown)
  • Showing warnings or validation messages
  • Calculating preview values before save
  • Totals and calculations that must be stored
  • Values derived from relationships
  • Data that needs to be filtered/sorted
class Invoice(Model):
customer = ManyToOne("Contact", related_name="invoices", on_delete="RESTRICT")
currency = ManyToOne("Currency", related_name="invoices", on_delete="RESTRICT")
payment_term = ManyToOne("PaymentTerm", related_name="invoices", on_delete="SET_NULL")
@Model.ui_effect("customer")
async def on_customer_change(self):
"""Populate defaults from customer."""
if not self.customer:
self.currency = None
self.payment_term = None
return
await self.fetch_related("customer")
self.currency = self.customer.currency
self.payment_term = self.customer.payment_term
Section titled “Copying a related record’s own relations”

self.currency = self.customer.currency copies a relation that lives on the related record. You do not need to deep-prefetch (customer__currency) or guard it before assigning:

  • Assigning works whether or not customer.currency is hydrated — if it isn’t, the framework stores the foreign-key id (exactly as if you assigned the id).
  • The effect result serializes correctly either way: an unfetched relation is resolved to {id, display_name, …} when the response is built, so the form’s combo shows the right label.

So self.currency = self.customer.currency is enough. Only call fetch_related("customer__currency") when you need to read fields off customer.currency (e.g. self.customer.currency.symbol) inside the effect.

Section titled “A related record’s calculated fields are resolved on demand — await them”

A record reached through a relation inside an effect arrives with its stored fields and its display label. Its non-stored computed fields are not calculated up front — reading one hands you a value you must await:

@Model.ui_effect("order")
async def on_order_change(self):
order = self.order
self.date = order.date # stored — read it directly
self.description = order.display_name # the label is always there
invoiced = await order.invoices_count # computed — await it
if invoiced:
self.locked = True

Forgetting the await fails loudly rather than quietly — using the value raises RelationshipError: Calculated field 'invoices_count' on SaleOrder was not hydrated, and the message names the fix. There is no silent wrong answer to chase.

This is deliberate, and it is why opening a form is fast: a calculated field can summarize a whole document (totals, counts across children, a status rolled up from lines), and calculating every one of them on every record an effect happens to touch would make each keystroke pay for the entire graph behind the record. You pay for the ones you actually read.

The rule is the same however the related record got there — assigned in the form, carried in the payload, or prefilled from the action’s context.

When one UI effect modifies a field that another depends on, they trigger in sequence:

class SaleOrder(Model):
contact = ManyToOne("Contact", on_delete="RESTRICT")
pricelist = ManyToOne("Pricelist", on_delete="SET_NULL")
currency = ManyToOne("Currency", on_delete="RESTRICT")
@Model.ui_effect("contact")
async def on_contact_change(self):
if not self.contact:
self.pricelist = None
return
await self.fetch_related("contact")
self.pricelist = self.contact.pricelist
@Model.ui_effect("pricelist")
async def on_pricelist_change(self):
if not self.pricelist:
self.currency = None
return
await self.fetch_related("pricelist")
self.currency = self.pricelist.currency

When contact changes:

  1. on_contact_change runs → sets pricelist
  2. on_pricelist_change runs → sets currency

UI effects can add, remove, or update OneToMany child records. This is useful when a parent field change should automatically modify the lines — for example, selecting a shipping method adds a shipping line to an order.

Use Model.new() to create a new in-memory record, then reassign the list:

@Model.ui_effect("shipping_method")
async def on_shipping_method_change(self):
await self.fetch_related("lines", "shipping_method")
if not self.shipping_method:
return
OrderLine = get_model("OrderLine")
new_line = await OrderLine.new({
"product": self.shipping_method.product.id,
"description": self.shipping_method.name,
"quantity": 1.0,
"unit_price": self.shipping_method.price,
})
if self.lines:
self.lines = list(self.lines) + [new_line]
else:
self.lines = [new_line]

When the field the effect fires on defines what the children should be — a template, a bill of materials, a package — rebuild the list from scratch and assign it:

@Model.ui_effect("template")
async def on_template_change(self):
if not self.template:
self.lines = []
return
await self.fetch_related("template")
await self.template.fetch_related("lines")
OrderLine = get_model("OrderLine")
self.lines = [
await OrderLine.new({"product": t.product_id, "quantity": t.quantity})
for t in (self.template.lines or [])
]

What you assign is what the field holds, and nothing later undoes it:

  • The replacement is detected even when the rows being replaced are themselves unsaved (the form has not been saved yet) and the new list has the same number of rows — switching between two templates with three lines each still swaps all three.
  • An assigned collection counts as loaded, so a later fetch_related on the same field (your own, or one inside a calculated field that depends on it) will not go back to the database and overwrite it. Assigning [] genuinely clears the field on a saved record.

The same holds for ManyToMany and for a ManyToOne assigned a Model.new() record.

The form turns that into the right commands on save: rows it had already saved are unlinked (deleted, since the child’s back-reference is required) and the new rows are created.

Filter out the lines you want to remove and reassign:

@Model.ui_effect("shipping_method")
async def on_shipping_method_change(self):
await self.fetch_related("lines")
if not self.shipping_method:
# Remove shipping line when method is cleared
self.lines = [l for l in (self.lines or []) if not l.is_shipping_line]
return

Modify properties in-place on existing line objects:

@Model.ui_effect("currency")
async def on_currency_change(self):
if self.currency and self.lines:
for line in self.lines:
line.currency = self.currency

A shipping method UI effect that handles all three cases — add, update, and remove:

class SaleOrder(Model):
shipping_method = ManyToOne("ShippingMethod", on_delete="SET_NULL", related_name="orders")
lines = OneToMany(related_model="SaleOrderLine", related_name="order")
@Model.ui_effect("shipping_method")
async def on_shipping_method_change(self):
await self.fetch_related("lines", "shipping_method")
# Find existing shipping line
shipping_line = None
for line in (self.lines or []):
if line.is_shipping_line:
shipping_line = line
break
if not self.shipping_method:
# Remove shipping line if method cleared
if shipping_line:
self.lines = [l for l in self.lines if not l.is_shipping_line]
return
if shipping_line:
# Update existing shipping line
shipping_line.description = self.shipping_method.name
shipping_line.unit_price = self.shipping_method.price
else:
# Add new shipping line
SaleOrderLine = get_model("SaleOrderLine")
new_line = await SaleOrderLine.new({
"is_shipping_line": True,
"description": self.shipping_method.name,
"quantity": 1.0,
"unit_price": self.shipping_method.price,
})
self.lines = list(self.lines or []) + [new_line]
  1. Always fetch_related first — O2M fields are not loaded by default in UI effects. Call await self.fetch_related("lines") before accessing them.
  2. Use Model.new() for new records — Don’t pass raw dicts. Model.new() creates a properly hydrated in-memory instance.
  3. Reassign, don’t mutate — The ORM detects O2M changes by comparing the list before and after the UI effect. Reassigning self.lines = [...] triggers change detection; .append() does not.
  4. Guard against None — Use self.lines or [] when iterating, as uninitialized O2M fields may be None.

ManyToMany fields in UI effects work differently from O2M — you link or unlink existing records rather than creating new ones. M2M changes use command arrays via update() after save, but within UI effects you manipulate the in-memory RecordList directly.

Fetch the related records and reassign the list:

@Model.ui_effect("category")
async def on_category_change(self):
if self.category:
await self.fetch_related("category", "tags")
await self.category.fetch_related("default_tags")
# Merge existing tags with category defaults (avoid duplicates)
existing_ids = {t.id for t in (self.tags or [])}
new_tags = [t for t in (self.category.default_tags or []) if t.id not in existing_ids]
self.tags = list(self.tags or []) + new_tags

Filter and reassign:

@Model.ui_effect("category")
async def on_category_change(self):
await self.fetch_related("tags")
if not self.category:
# Clear all auto-assigned tags
self.tags = [t for t in (self.tags or []) if not t.is_auto_assigned]
return
  1. No Model.new() needed — M2M links existing records. The related records already exist in the database.
  2. Same reassign rule — Reassign self.tags = [...] instead of mutating in-place, just like O2M.
  3. On save, the ORM translates the list diff into M2M commands (Link/Unlink) automatically.

UI effects are called via the /api/ui-effect/{model_name} endpoint:

POST /api/ui-effect/Order
Content-Type: application/json
{
"id": 123,
"values": {
"product": 456,
"quantity": 2
},
"trigger_field": "product"
}

Response contains updated field values:

{
"unit_price": 99.99
}

The frontend automatically calls the effect endpoint when a field that other logic depends on is modified, then patches the form/list with the returned values. This covers both @Model.ui_effect dependencies and @Model.calculate dependencies — any field that is a declared dependency of either is treated as a live trigger, with no extra wiring in the view YAML. The call happens on blur/change (a backend round-trip), not per keystroke.

Combining UI Effects with Calculated Fields

Section titled “Combining UI Effects with Calculated Fields”

For fields that need both UI feedback and stored calculation:

class OrderLine(Model):
product = ManyToOne("Product", related_name="order_lines", on_delete="RESTRICT")
quantity = Integer(default=1)
unit_price = Monetary(default=0.0)
subtotal = Monetary(calculate="calculate_subtotal", store=True)
@Model.ui_effect("product")
async def on_product_change(self):
"""Set price when product selected (immediate UI feedback)."""
if self.product:
await self.fetch_related("product")
self.unit_price = self.product.price
@Model.calculate("quantity", "unit_price")
async def calculate_subtotal(self):
"""Calculate subtotal (stored on save)."""
self.subtotal = (self.quantity or 0) * (self.unit_price or 0)
from fullfinity.engine.base import *
class PurchaseOrder(Model):
_verbose_name = "Purchase Order"
_collaborate = True
name = Char(max_length=100, required=True)
vendor = ManyToOne("Contact", related_name="purchase_orders", required=True, on_delete="RESTRICT")
currency = ManyToOne("Currency", related_name="purchase_orders", on_delete="RESTRICT")
payment_term = ManyToOne("PaymentTerm", related_name="purchase_orders", on_delete="SET_NULL")
lines = OneToMany(related_model="PurchaseOrderLine", related_name="order")
subtotal = Monetary(calculate="calculate_totals", store=True)
tax_total = Monetary(calculate="calculate_totals", store=True)
total = Monetary(calculate="calculate_totals", store=True)
@Model.ui_effect("vendor")
async def on_vendor_change(self):
"""Populate defaults from vendor."""
if self.vendor:
await self.fetch_related("vendor")
self.currency = self.vendor.currency
self.payment_term = self.vendor.payment_term
@Model.calculate("lines", "lines__subtotal", "lines__tax")
async def calculate_totals(self):
await self.fetch_related("lines")
self.subtotal = sum(line.subtotal for line in self.lines) if self.lines else 0
self.tax_total = sum(line.tax for line in self.lines) if self.lines else 0
self.total = self.subtotal + self.tax_total
class PurchaseOrderLine(Model):
_verbose_name = "Purchase Order Line"
order = ManyToOne("PurchaseOrder", related_name="lines", required=True, on_delete="CASCADE")
product = ManyToOne("Product", related_name="purchase_lines", required=True, on_delete="RESTRICT")
quantity = Integer(default=1)
unit_price = Monetary(default=0.0)
tax_rate = Float(default=10.0)
subtotal = Monetary(calculate="calculate_amounts", store=True)
tax = Monetary(calculate="calculate_amounts", store=True)
@Model.ui_effect("product")
async def on_product_change(self):
if self.product:
await self.fetch_related("product")
self.unit_price = self.product.cost_price
@Model.calculate("quantity", "unit_price", "tax_rate")
async def calculate_amounts(self):
self.subtotal = (self.quantity or 0) * (self.unit_price or 0)
self.tax = self.subtotal * (self.tax_rate or 0) / 100
  1. Never persist - Recalculate values in memory only; create()/update()/delete() raise inside an effect (see the note at the top)
  2. Keep UI effects fast - Users wait for the response
  3. Only fetch what you need - Use selective fetch_related()
  4. Handle None values - Fields may be empty
  5. Don’t duplicate logic - Use @Model.calculate for stored values
  6. Name clearly - Use on_<field>_change convention