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 self.product: await self.fetch_related("product") self.unit_price = self.product.priceThe @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 Computed Fields
Section titled “UI Effects vs Computed Fields”| Feature | UI Effect | Computed Field |
|---|---|---|
| Decorator | @Model.ui_effect | @Model.calculate |
| Trigger | User changes field in form | Save or dependency changes |
| Use Case | Form defaults, UI feedback | Stored calculations |
| Storage | Can modify any field | Only the computed field |
| Execution | During editing (API call) | On save or 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 Computed Fields
Section titled “When to Use Computed 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 self.customer: await self.fetch_related("customer") self.currency = self.customer.currency self.payment_term = self.customer.payment_termChained 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 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.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]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 UI effects when fields with dependencies are modified. The form updates with returned values.
Combining UI Effects with Computed Fields
Section titled “Combining UI Effects with Computed 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”- 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”- Computed Fields - Automatic calculations
- Defining Models - Model structure