Frontend DX: Frappe & Odoo → Framework M
This guide helps developers transition from classic monolithic frontend patterns (global mutable state) to the reactive, schema-driven architecture of Framework M.
1. The Conceptual Shift: Reactive vs. Global
In Frappe, the cur_frm.doc is a global, mutable object. In Framework M, the document is immutable state managed by React and Refine.
| Intent | Frappe / Odoo (web_client) | Framework M (desk) |
|---|---|---|
| Get Record | cur_frm.doc | useDoc() / currentDoc |
| Set Value | frm.set_value('field', 'val') | onChange callback / setFormData |
| Add Fetch | frm.add_fetch('link', 'src', 'target') | useEffect + useOne (Link Watcher) |
| Get Field | frm.get_field('field') | AutoField with props |
| Calculate Total | frm.cscript.calculate_total() | useMemo / onChange Logic |
2. Practical Comparisons (The Rosetta Stone)
2.1 Row Calculation (Qty * Rate = Amount)
Frappe (Client Script):
frappe.ui.form.on("Sales Invoice Item", {
qty: function (frm, cdt, cdn) {
let item = locals[cdt][cdn];
frappe.model.set_value(cdt, cdn, "amount", item.qty * item.rate);
},
rate: function (frm, cdt, cdn) {
let item = locals[cdt][cdn];
frappe.model.set_value(cdt, cdn, "amount", item.qty * item.rate);
},
});
Framework M (Reactive Update):
// Inside your Row component or AutoForm onChange
const onRowChange = row => {
const amount = (row.qty || 0) * (row.rate || 0);
return { ...row, amount };
};
2.2 Grand Total (Summing Child Table)
Frappe (Client Script):
frappe.ui.form.on("Sales Invoice", {
validate: function (frm) {
let total = 0;
(frm.doc.items || []).forEach(item => {
total += item.amount;
});
frm.set_value("grand_total", total);
},
});
Framework M (Reactive State):
const onFormChange = newData => {
const total = (newData.items || []).reduce(
(sum, item) => sum + (item.amount || 0),
0,
);
// Setting state triggers reactive re-render of the Total field
setFormData({ ...newData, grand_total: total });
};
2.3 Wiring it Together: The Form Override
In Framework M, instead of global frappe.ui.form.on events, you place your logic inside a FormView Override. This is where you defined the "Independent" functions and wire them into the form state.
File Location: frontend/src/overrides/SalesInvoice/FormView.tsx
import { AutoForm, FormViewProps } from "@framework-m/desk";
import { YStack, Heading } from "@framework-m/ui";
export default function SalesInvoiceForm({
children,
...props
}: FormViewProps) {
// 1. Define independent logic
const handleFormChange = (newData: any) => {
const total = (newData.items || []).reduce(
(sum: number, item: any) => sum + (item.amount || 0),
0,
);
// This is a simplified example of the internal setFormData logic
return { ...newData, grand_total: total };
};
const handleRowChange = (row: any) => {
return { ...row, amount: (row.qty || 0) * (row.rate || 0) };
};
return (
<YStack gap="$md" flex={1}>
<Heading padding="$md">🚀 Calculated Invoice</Heading>
{/* 2. Wire logic into the AutoForm via props */}
<AutoForm
{...props}
onFormChange={handleFormChange}
onRowChange={{ items: handleRowChange }}
/>
{/* 3. Standard children (buttons, sidebar) are still rendered */}
{children}
</YStack>
);
}
3. Form Management & Field Logic
2.1 Dependent Field Logic (Dynamic Links)
In Frappe, you might use set_query. In Framework M, we use Field Watchers.
Scenario: A "DocType Selector" determines which IDs appear in a "Reference ID" link field.
// M Pattern (Simplified)
const { docType } = useFormContext(); // Watch the sibling field
return (
<LinkField
name="reference_id"
resource={docType} // Dynamically bound resource
label="Select Record"
/>
);
2.2 LinkField vs DynamicLinkField
Use LinkField when the target DocType is static, and DynamicLinkField when the target DocType comes from another field in the same form.
| Field | When to use | Metadata keys |
|---|---|---|
LinkField | Target DocType is always the same (for example: Customer) | x-options or link (optional ui_meta.link_doctype) |
DynamicLinkField | Target DocType depends on a sibling/twin selector field | ui_widget: "dynamic_link" + watch (or ui_meta.watch) |
Static Link (LinkField)
customer_id: str | None = Field(
default=None,
json_schema_extra={
"ui_widget": "link",
"x-options": "Customer",
},
)
Dynamic Link (DynamicLinkField)
reference_doctype: str | None = Field(default=None)
reference_id: str | None = Field(
default=None,
json_schema_extra={
"ui_widget": "dynamic_link",
"watch": "reference_doctype",
},
)
Behavior notes:
- Changing the watched DocType clears stale selected IDs automatically.
- Dynamic links stay disabled until the watched field has a valid DocType value.
- Both variants support quick-entry Drawer creation unless explicitly disabled with
x-quick-entry: false.
2.3 Summation of Child Items
Instead of an imperative loop that sets a field, we use a Reactive Update triggered by the child table's change event.
// M Pattern (In FormView Override or Plugin)
const onFormChange = newData => {
const total = newData.items.reduce(
(sum, item) => sum + (item.amount || 0),
0,
);
setFormData({ ...newData, grand_total: total });
};
3. Child Table Excellence (Spreadsheet UX)
Framework M implements child tables using a specialized ListField that is optimized for "Sales Invoice"-style data entry.
3.1 Deeply Nested Recursion
To handle "Child of Child of Child" (infinite nesting), the framework uses:
- Recursive
AutoField: Resolvesarraytypes to nestedListFieldinstances. - Scroll-Aware Indentation: Each nesting level increments a
depthprop, applying specific horizontal scrolling (overflow-x: auto) and indentation to the child card.
3.2 Performance & Scale
For large tables (100+ rows):
- Pagination: The
ListFieldsupports standard pagination and "Load More" to prevent DOM bloat. - Virtualization: Only rendered rows are kept in the DOM, while state integrity is maintained for the entire array.
4. Metadata-Driven UX (ui_meta)
The frontend is driven by DocType.Meta and ui_meta from the backend (Pydantic models).
- Enums: Automatically resolved from Pydantic
LiteralorEnumtypes. - Modular Enum Extensions: In a modular monolith, enums are decoupled. For example,
m-africacan extend aSales Invoicecountry list by registering a Metadata Decorator on the backend. Themetadata_routermerges these extensions at runtime, so theSelectFieldalways shows the "Merged" list of options for the current installed context. - Regex: Enforced in real-time by the
TextFieldbased onui_meta.pattern. - Visibility: Controlled by the
UIContextRegistry(e.g., hiding fields in "Mobile" context vs. "POS" context).
4.1 Backend Integration: Registering Decorators
To support modularity, Framework M uses specialized registries to "decorate" base metadata without modifying the source DocType.
Layout Overrides (UIContextRegistry)
If your app (e.g., m-pos) needs a specific layout for a shared DocType like Invoice, register a context override:
# In your app's startup or __init__
from framework_m_core.registry import UIContextRegistry
registry = UIContextRegistry.get_instance()
registry.register_context_override(
doctype_name="Invoice",
context="pos",
ui_meta={
"form": {
"sections": [
{"label": "Quick POS", "fields": ["customer", "total_amount"]},
]
}
}
)
Modular Enum Extensions (Pattern)
For extending select options (e.g., m-africa adding countries to Sales Invoice), we use the Schema Augmentation pattern:
# Future Pattern: MetadataDecoratorRegistry
from framework_m_core.registry import MetadataDecoratorRegistry
def add_african_countries(schema: dict):
# Injected into the standard JSON Schema generation
enum = schema["properties"]["country"]["enum"]
enum.extend(["Nigeria", "Kenya", "South Africa"])
registry = MetadataDecoratorRegistry.get_instance()
registry.register_schema_decorator("Sales Invoice", add_african_countries)
Where to Register (Bootstrap Entry Point)
To maintain decoupling, never modify the base create_app or container. Instead, register your decorators in an app-specific Bootstrap Step with an order of 50+.
1. Create your Bootstrap Class (m_africa/bootstrap.py):
from framework_m_core.interfaces.bootstrap import BootstrapProtocol
from framework_m_core.registry import MetadataDecoratorRegistry
class AfricaMetadataDecoration:
name = "africa_meta_decoration"
order = 55 # Runs after core registries (20) are initialized
def run(self, container) -> None:
registry = MetadataDecoratorRegistry.get_instance()
registry.register_schema_decorator("Sales Invoice", add_african_countries)
2. Register via Entry Points (pyproject.toml):
[project.entry-points."framework_m.bootstrap"]
africa_meta = "m_africa.bootstrap:AfricaMetadataDecoration"
5. Frontend TDD Mandatory Guidelines
To prevent "recursive leakage" (state from one child accidentally updating a sibling), TDD is mandatory for all custom form logic.
Mandatory Test Scenarios:
- Model Binding: Verify that input at path
items.0.amountcorrectly updates the root model. - Reactivity: Verify that changing a child
qtycorrectly triggers the parentgrand_totalupdate. - Isolation: Verify that adding a row at index
1does not overwrite data at index0.
6. Migration "Cheat Sheet" Table
| Frappe Hook | Framework M React Equivalent |
|---|---|
refresh | useEffect(() => ..., [id]) |
onload | useEffect(() => ..., []) |
validate | onValidate callback in AutoForm |
before_save | onFinish (frontend) / before_save (backend) |
after_save | onSuccess callback |
on_change | onChange event in AutoField |
6.1 Pattern: currentDoc for Field Sums (items.reduce)
Use frm.doc (or your current form model) as the source of truth and derive totals in a pure function.
// Inside a FormView override
function recalculateTotals(currentDoc: any) {
const items = Array.isArray(currentDoc.items) ? currentDoc.items : [];
const grandTotal = items.reduce((sum: number, row: any) => {
return sum + Number(row.amount || 0);
}, 0);
return { ...currentDoc, grand_total: grandTotal };
}
// Example wiring:
const handleFormChange = (nextDoc: any) => {
frm.setDoc(recalculateTotals(nextDoc));
};
Key rule: derive values from currentDoc/frm.doc, do not mutate rows in place.
6.2 Pattern: frm.set_value Equivalent with React Hooks
Frappe style:
frm.set_value("status", "Open");
Framework M equivalent:
import { useCallback } from "react";
const setValue = useCallback(
(field: string, value: unknown) => {
frm.setDoc((prev: Record<string, unknown>) => ({
...prev,
[field]: value,
}));
},
[frm],
);
// Usage
setValue("status", "Open");
setValue("priority", "High");
For multiple fields, batch into one update to avoid extra re-renders:
frm.setDoc((prev: Record<string, unknown>) => ({
...prev,
status: "Open",
priority: "High",
}));
6.3 Pattern: Split/Concat Fields (first_name + last_name -> full_name)
function syncFullName(doc: any) {
const first = String(doc.first_name ?? "").trim();
const last = String(doc.last_name ?? "").trim();
const fullName = [first, last].filter(Boolean).join(" ");
if (doc.full_name === fullName) {
return doc;
}
return { ...doc, full_name: fullName };
}
const handleNameChange = (nextDoc: any) => {
frm.setDoc(syncFullName(nextDoc));
};
Use this pattern when full_name is derived. Keep one direction of truth (first_name + last_name) to avoid circular update loops.
7. Tabbed Layout Design Pattern
Framework M replaces Frappe's "infinite scroll" form with a first-class Tabbed Layout Engine. Layout is driven exclusively by UIMeta returned from the /api/meta/{doctype} endpoint — never derived from model data.
7.1 UIMeta Form Layout Schema
{
"doctype": "SalesInvoice",
"form": {
"layout_type": "tabs",
"tabs": [
{
"label": "Basic Info",
"sections": [
{
"label": "Customer Details",
"fields": ["customer", "posting_date", "due_date"],
"collapsible": false,
"columns": 2
}
]
},
{
"label": "Items",
"sections": [
{
"label": "Line Items",
"fields": ["items"],
"collapsible": false,
"columns": 1
}
]
},
{
"label": "Accounting",
"sections": [
{
"label": "Tax & Totals",
"fields": ["tax_rate", "grand_total"],
"collapsible": true,
"collapsed_by_default": false,
"columns": 1
}
]
}
]
}
}
For flat sections (no tabs), use form.sections instead of form.tabs:
{
"doctype": "Todo",
"form": {
"sections": [
{
"label": "Details",
"fields": ["description", "status"],
"collapsible": true
}
]
}
}
7.2 Column Widths (Named Fractions)
Column widths are specified as named fractions for cross-platform safety (no CSS percentages):
| Value | Flex Proportion | Typical use |
|---|---|---|
1/3 | 1 | Narrow metadata column |
2/3 | 2 | Main content column |
1/2 | 1 | Side-by-side halves |
auto | 1 | Equal flexible width |
7.3 Tab Persistence
The last active tab is remembered per-DocType in localStorage:
Key: m_tab_{doctype}_active
Value: 0 (integer index)
This is scoped per-form-type (not per-record) to minimize storage. It is cleared automatically if the stored index exceeds the current tab count.
7.4 Registering Tabbed Layout on the Backend
Return the UIMeta from your DocType's meta endpoint:
# In your MetaRouter or DocType meta handler
class SalesInvoiceMeta(DocTypeMeta):
@staticmethod
def get_ui_meta() -> dict:
return {
"doctype": "SalesInvoice",
"form": {
"tabs": [
{"label": "Basic Info", "sections": [{"label": "Customer", "fields": ["customer"]}]},
{"label": "Items", "sections": [{"label": "Items", "fields": ["items"]}]},
]
}
}
8. Keyboard-First Navigation
Framework M implements first-class keyboard shortcuts for power users.
8.1 Shortcut Reference
| Shortcut | Behaviour | Scope |
|---|---|---|
Alt + 1 – Alt + 9 | Switch to tab 1–9 | Form tabs (web only) |
Enter (in a text input) | Move focus to next field | Form body |
Tab | Native browser field traversal | Everywhere |
Tab in table cells | Move to next cell | Child table rows |
React Native / Mobile:
Alt+Nshortcuts are silently skipped.TabandEnterbehave natively.
8.2 Implementing Custom Shortcuts (Plugin)
import { useTabKeyboard } from "@framework-m/desk";
function MyFormPlugin({ tabs, activeTab, setActiveTab }) {
// Delegates to the built-in Alt+[1-9] binding
useTabKeyboard(tabs.length, setActiveTab);
return null;
}
9. Slide-over Drawer (LinkField Quick Entry)
The LinkWidget in Framework M shows a "+ New" button next to every Link field by default. Clicking it opens a Drawer (slide-over panel) with an inline AutoForm for the linked DocType.
9.1 Default Behaviour (Opt-Out)
// JSON Schema property — Drawer enabled by default
{
"customer": {
"type": "string",
"x-fieldtype": "link",
"x-options": "Customer"
}
}
To disable the Drawer for a specific Link field:
{
"internal_reference_id": {
"type": "string",
"x-fieldtype": "link",
"x-options": "InternalRef",
"x-quick-entry": false
}
}
9.2 Drawer Width
The Drawer defaults to 480px. This is not currently configurable per-field (consistent UX).
9.3 Anti-pattern Warning
Do NOT use the Drawer for DocTypes with complex inter-dependent fields (e.g., child tables with calculated totals). Use the full
FormViewroute instead, since the Drawer renders a minimal flatAutoFormwithout section/tab layout.