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 self.product:
await self.fetch_related("product")
self.unit_price = self.product.price

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 EffectComputed Field
Decorator@Model.ui_effect@Model.calculate
TriggerUser changes field in formSave or dependency changes
Use CaseForm defaults, UI feedbackStored calculations
StorageCan modify any fieldOnly the computed field
ExecutionDuring editing (API call)On save or 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 self.customer:
await self.fetch_related("customer")
self.currency = self.customer.currency
self.payment_term = self.customer.payment_term

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 self.contact:
await self.fetch_related("contact")
self.pricelist = self.contact.pricelist
@Model.ui_effect("pricelist")
async def on_pricelist_change(self):
if self.pricelist:
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]

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 UI effects when fields with dependencies are modified. The form updates with returned values.

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. Keep UI effects fast - Users wait for the response
  2. Only fetch what you need - Use selective fetch_related()
  3. Handle None values - Fields may be empty
  4. Don’t duplicate logic - Use @Model.calculate for stored values
  5. Name clearly - Use on_<field>_change convention