UI Effects
UI effects are methods triggered when specific field values change in a form, allowing you to update other fields dynamically before saving.
Basic UI Effect
Section titled “Basic UI Effect”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.priceClearing 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 byif bom: self.bom = bomkeeps 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 frozen field is left alone for you
Section titled “A frozen field is left alone for you”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
Section titled “The @Model.ui_effect Decorator”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 passUI Effects vs Calculated Fields
Section titled “UI Effects vs Calculated Fields”| Feature | UI Effect | Calculated Field |
|---|---|---|
| Decorator | @Model.ui_effect | @Model.calculate |
| Trigger | User changes a dependency in the form | User changes a dependency in the form, and on save/read |
| Use Case | Form defaults, UI feedback | Calculations (optionally stored) |
| Storage | Can modify any field | Only the calculated field |
| Execution | During editing (API call) + not persisted itself | During editing (same API call) + on save/read |
When to Use UI Effects
Section titled “When to Use UI Effects”- Populating defaults based on selections (product → price)
- Updating related field options (country → state dropdown)
- Showing warnings or validation messages
- Calculating preview values before save
When to Use Calculated Fields
Section titled “When to Use Calculated Fields”- Totals and calculations that must be stored
- Values derived from relationships
- Data that needs to be filtered/sorted
Multiple Dependencies
Section titled “Multiple Dependencies”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_termCopying a related record’s own relations
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.currencyis 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.
A related record’s calculated fields are resolved on demand — await them
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 = TrueForgetting 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.
Chained UI Effects
Section titled “Chained UI Effects”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.currencyWhen contact changes:
on_contact_changeruns → setspriceliston_pricelist_changeruns → setscurrency
Manipulating O2M Lines
Section titled “Manipulating O2M Lines”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.
Adding Lines
Section titled “Adding Lines”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]Replacing the Whole List
Section titled “Replacing the Whole List”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_relatedon 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.
Removing Lines
Section titled “Removing Lines”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] returnUpdating Existing Lines
Section titled “Updating Existing Lines”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.currencyComplete Example
Section titled “Complete Example”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]Important Notes
Section titled “Important Notes”- Always
fetch_relatedfirst — O2M fields are not loaded by default in UI effects. Callawait self.fetch_related("lines")before accessing them. - Use
Model.new()for new records — Don’t pass raw dicts.Model.new()creates a properly hydrated in-memory instance. - 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. - Guard against None — Use
self.lines or []when iterating, as uninitialized O2M fields may beNone.
Manipulating M2M Fields
Section titled “Manipulating M2M Fields”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.
Adding Related Records
Section titled “Adding Related Records”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_tagsRemoving Related Records
Section titled “Removing Related Records”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] returnImportant Differences from O2M
Section titled “Important Differences from O2M”- No
Model.new()needed — M2M links existing records. The related records already exist in the database. - Same reassign rule — Reassign
self.tags = [...]instead of mutating in-place, just like O2M. - On save, the ORM translates the list diff into M2M commands (Link/Unlink) automatically.
API Endpoint
Section titled “API Endpoint”UI effects are called via the /api/ui-effect/{model_name} endpoint:
POST /api/ui-effect/OrderContent-Type: application/json
{ "id": 123, "values": { "product": 456, "quantity": 2 }, "trigger_field": "product"}Response contains updated field values:
{ "unit_price": 99.99}Frontend Integration
Section titled “Frontend Integration”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)Complete Example
Section titled “Complete Example”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) / 100Best Practices
Section titled “Best Practices”- Never persist - Recalculate values in memory only;
create()/update()/delete()raise inside an effect (see the note at the top) - Keep UI effects fast - Users wait for the response
- Only fetch what you need - Use selective
fetch_related() - Handle None values - Fields may be empty
- Don’t duplicate logic - Use
@Model.calculatefor stored values - Name clearly - Use
on_<field>_changeconvention
Next Steps
Section titled “Next Steps”- Calculated Fields - Automatic calculations
- Defining Models - Model structure