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

Asking a question instead of answering one — request_ui_action

Section titled “Asking a question instead of answering one — request_ui_action”

An effect normally answers with values, and that is the right shape for almost everything it does. It is the wrong shape when the change the user made poses a question rather than implying an answer.

Picking a product that comes in several combinations is the case this exists for. Nothing the effect can compute decides Bamboo or Walnut on the buyer’s behalf, so it asks:

@Model.ui_effect("product")
async def on_product_change(self):
if not self.product:
return
await self.fetch_related("product")
variant = await self.product.sole_variant()
if variant is not None:
self.product_variant = variant # nothing to ask — just answer
return
self.request_ui_action({
"type": "wizard",
"model": "ProductConfigurator",
"title": t("Configure Product"),
"target": "row",
"derive_from": "product_variant",
"ctx": {"default_product": self.product.id},
})

The dict is an ordinary action result — the same vocabulary a button’s method returns (wizard, window_action, notify, url, …) — so the client dispatches it through the handler it already has, and an effect cannot express anything a button could not.

  • Values first, then the action. Whatever the effect recomputed is applied before the action is dispatched, so the surface that opens is seeded from a form that is already up to date.
  • One per round trip. The last request wins. Effects that only recompute values never touch this and pay nothing for it.
  • Ignored outside a form round trip. A create/update path has no client waiting on it, so the request is dropped rather than raising in a path a user reached by importing a spreadsheet.

target: "row" — answers come back to the line being typed

Section titled “target: "row" — answers come back to the line being typed”

Inside a grid, an effect that opens a wizard usually wants the wizard’s answers, not a document reload. target: "row" says so: the wizard’s result values are merged into the row that asked the question, marked dirty like anything else typed into it, and saved with the document.

The wizard opts in by returning values instead of writing records:

async def action_add(self):
variant, _, options = await self.product.get_or_create_variant(values)
return {
"type": "close",
"values": {
# Relations go back NAMED — a cell handed a bare id renders the number.
"product_variant": {"id": variant.id, "display_name": variant.display_name},
"quantity": self.quantity,
},
}

Return only the answers. Everything derivable from them — descriptions, units, taxes, prices — belongs in the row’s own effects, so a line completed through a wizard derives exactly like one filled in by hand. derive_from names the field that derivation hangs off: once the answers are merged, the row re-runs its effect on that field, exactly as if the value had been picked inline. Omit it and the row keeps the answers and derives nothing from them — which, for a required description, means a line that cannot be saved. A wizard that writes the record itself forces the document to be saved first and reloaded after, which discards every other unsaved edit in the grid, and the record it creates never runs the form effects that fill the rest of it in.

Some questions are answered with a table, not a value. A buyer restocking a shirt orders eight smalls, twenty mediums and twelve larges in one go; asking “which one” twenty times is not a configurator, it is data entry. target: "rows" says the wizard answers with a line per row ordered:

self.request_ui_action({
"type": "wizard",
"model": "ProductMatrix",
"title": t("Choose Quantities"),
"target": "rows",
"derive_from": "product_variant",
"ctx": {"default_product": self.product.id},
})

The wizard returns a list instead of a values dict, in the same values-only shape:

async def action_add(self):
return {"type": "close", "rows": [
{"product_variant": {"id": v.id, "display_name": v.display_name}, "quantity": n},
...
]}

The first answer completes the row that asked; the rest are inserted after it. Appending all of them and leaving the asking row empty would strand a blank line the document then refuses to save. Every line then derives itself from derive_from, exactly as one row would — in a single round trip, however many lines there are (see Deriving many rows at once).

replaces — when the answer stands for a whole set

Section titled “replaces — when the answer stands for a whole set”

A grid reopened to be edited usually stands for more than the line that was clicked. A quantity matrix opens showing every line of its product, so its answer replaces that whole set — otherwise the edit lands alongside the originals instead of instead of them. replaces names the field and value that defines the set:

"replaces": {"field": "product", "id": product.id},

Rows carrying that value are answered in order, surplus ones are removed, and extra answers are inserted. A combination cleared to zero is a line the user no longer wants — so with replaces, omitting a row means deleting it. Leave replaces out and the answer speaks only for the row that asked, which is what an ordinary “add” should do.

derive_from re-runs a row’s effects once its answers are merged. When an action fills in many rows, all of them derive in one request: the rows are independent, so the only reason to do it a row at a time was the shape of the call, and the round trip costs more than everything the server then does. Twenty lines derive in one request, not twenty.

Nothing needs declaring for this — it is how target: "rows" behaves. It matters when you write the effect: derivation for a row must not depend on another row having derived first. An effect that reads its siblings’ derived values sees them as they were before the batch, not after. Derive from the record and its parent, as an effect should anyway.

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

You can assign to a line in place, and the value is correct for everything that runs after it in the same effect — a calculated total that reads it, a later branch, the save:

@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. In-place, so the new price is correct for the
# save and for anything computed after this — but see the caution above: on its
# own it does not redraw the row in the browser.
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 — a collection read is a snapshot of the rows, rebuilt on every access. self.lines = [...] is what the change detection and the save can see; mutating the read (.append(), .pop(), del, item assignment) raises.
  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; within a UI effect you assign the field the set it should hold. Reading one is a snapshot exactly as an O2M read is, so mutating the result raises — build the list you want and assign it.

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 — Assign self.tags = [...]; mutating the read raises, 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
}

An effect that called request_ui_action adds one more key. It is underscored because it is not a field — it is what the effect asked the client to do:

{
"unit_price": 99.99,
"_action": {"type": "wizard", "model": "ProductConfigurator", "target": "row"}
}

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