Skip to main content

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.

IntentFrappe / Odoo (web_client)Framework M (desk)
Get Recordcur_frm.docuseDoc() / currentDoc
Set Valuefrm.set_value('field', 'val')onChange callback / setFormData
Add Fetchfrm.add_fetch('link', 'src', 'target')useEffect + useOne (Link Watcher)
Get Fieldfrm.get_field('field')AutoField with props
Calculate Totalfrm.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

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.

FieldWhen to useMetadata keys
LinkFieldTarget DocType is always the same (for example: Customer)x-options or link (optional ui_meta.link_doctype)
DynamicLinkFieldTarget DocType depends on a sibling/twin selector fieldui_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:

  1. Recursive AutoField: Resolves array types to nested ListField instances.
  2. Scroll-Aware Indentation: Each nesting level increments a depth prop, applying specific horizontal scrolling (overflow-x: auto) and indentation to the child card.

3.2 Performance & Scale

For large tables (100+ rows):

  • Pagination: The ListField supports 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 Literal or Enum types.
  • Modular Enum Extensions: In a modular monolith, enums are decoupled. For example, m-africa can extend a Sales Invoice country list by registering a Metadata Decorator on the backend. The metadata_router merges these extensions at runtime, so the SelectField always shows the "Merged" list of options for the current installed context.
  • Regex: Enforced in real-time by the TextField based on ui_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:

  1. Model Binding: Verify that input at path items.0.amount correctly updates the root model.
  2. Reactivity: Verify that changing a child qty correctly triggers the parent grand_total update.
  3. Isolation: Verify that adding a row at index 1 does not overwrite data at index 0.

6. Migration "Cheat Sheet" Table

Frappe HookFramework M React Equivalent
refreshuseEffect(() => ..., [id])
onloaduseEffect(() => ..., [])
validateonValidate callback in AutoForm
before_saveonFinish (frontend) / before_save (backend)
after_saveonSuccess callback
on_changeonChange 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):

ValueFlex ProportionTypical use
1/31Narrow metadata column
2/32Main content column
1/21Side-by-side halves
auto1Equal 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

ShortcutBehaviourScope
Alt + 1Alt + 9Switch to tab 1–9Form tabs (web only)
Enter (in a text input)Move focus to next fieldForm body
TabNative browser field traversalEverywhere
Tab in table cellsMove to next cellChild table rows

React Native / Mobile: Alt+N shortcuts are silently skipped. Tab and Enter behave 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 FormView route instead, since the Drawer renders a minimal flat AutoForm without section/tab layout.