View Inheritance
View inheritance lets a module extend an existing view without editing the original — you ship a
new view that points at the parent and lists operations that patch it.
How It Works
Section titled “How It Works”- Create a view with
inherited_viewpointing at the parent view’s identifier. - Define
operationsthatadd,modify,replace, orremoveelements. - Target each operation by a stable handle — an
anchoror a model-bound field name.
Basic Example
Section titled “Basic Example”- data_type: UiView identifier: contact_twitter_extension type: Form model: Contact inherited_view: contact_form_view arch: operations: - action: add target: field: email position: after value: type: field name: twitter_handle properties: {widget: TextInput}You may extend another module’s contribution, not only the root view
Section titled “You may extend another module’s contribution, not only the root view”inherited_view may point at any view — the original, or another module’s extension of
it. Composition walks the whole tree, so a contribution nested two or more levels deep is
applied just like a direct one:
# invoicing extends the root form and contributes an `invoicing` tab- data_type: UiView identifier: contact_form_view_invoicing inherited_view: contact_form_view ...
# a second module extends THAT contribution, and may target what it added- data_type: UiView identifier: contact_form_view_accounting inherited_view: contact_form_view_invoicing arch: operations: - action: add target: {anchor: invoicing, field: tax_rule} # a node the parent contributed position: after value: {type: field, name: followup_responsible, properties: {widget: DataCombo}}Depth does not decide order — module dependencies do. Every contribution to a view, at any depth, is applied in module dependency order. So targeting a node another module contributed works whether you inherit that module’s view or the root, as long as your module depends on the one that contributes the node. Inheriting its view is the way to say that the relationship is deliberate; depending on the module is what makes it resolve.
Targeting: use a stable handle, never a display string or position
Section titled “Targeting: use a stable handle, never a display string or position”A target must key on something that does not change when the parent view is renamed,
retitled, or restructured. This is enforced mechanically — ./fullfinity-server check --only views
(run in CI and the pre-commit hook) and the same check at view load time will reject a view
that breaks the rules below.
Target a field by its model field name
Section titled “Target a field by its model field name”A field (and a statusbar or linkButton) is keyed by its model field name, which is a
stable handle — a field rename is carried forward for you.
target: {field: email}If the same field appears more than once in a view (e.g. a read-only summary plus an
editable input), an unqualified {field: …} is ambiguous and rejected. Scope it to the
enclosing anchored container:
target: {anchor: billing, field: total} # the `total` field inside the `billing` containerTarget a section or button by its anchor
Section titled “Target a section or button by its anchor”Every targetable non-field element — tab, accordion, fieldset, paper, actionButton,
inlineButton, and any titled column — carries a stable, unique anchor.
Target that anchor:
# parent view- type: tab title: Details anchor: details content: [...]
# inheriting viewtarget: {anchor: details}Do not target these by their display string (a tab title, a button method) or by a
positional index — those move, and targeting them is rejected. Give the element an anchor
and target that.
A titled column — a named section like “Shipping” or “Invoicing” inside a form tab —
carries an anchor for the same reason a tab does: it is the handle for placing a field into
that section. An untitled column is layout scaffolding with no identity to target, so it needs
no anchor.
Target the arch root to contribute a button
Section titled “Target the arch root to contribute a button”A view’s action bar is built from the arch’s top-level nodes — the renderer collects
actionButtons from the root and does not descend. So an actionButton must land at the
root, whatever section of the screen it conceptually belongs to.
When the base view has a root-level actionButton to hang off, target its anchor:
target: {anchor: test_connection}position: beforevalue: {type: actionButton, anchor: connect_gmail, properties: {...}}When it has none, target the root itself:
target: {root: true}position: first # prepend; omit or use any other value to appendvalue: type: actionButton anchor: sync_calendar properties: {label: Sync Calendar, method: sync_calendar, icon: Calendar}{root: true} is the only target that names the arch rather than a node in it, so before
and after have no meaning there — first prepends, anything else appends.
What a data view’s arch root holds
Section titled “What a data view’s arch root holds”A List, Kanban, Calendar, Gantt, Pivot, Map, OrgChart, FieldDeck, TimeGrid or
TimelineHeatmap arch is chrome plus exactly one container node. The container carries
the fields or columns the view draws and its configuration (cols, color_field, stats,
data_method, nonworking_method, the QuickCreate entry). Chrome is actionButton,
inlineButton, linkButton, button and statusbar — the container’s siblings, because
that is where the action bar is collected from:
type: Calendararch:- content: # the container — exactly one, anywhere in the list - {type: field, name: name, display_mode: title} - {type: field, name: start_time, display_mode: start}- type: actionButton # chrome — a sibling, not a child anchor: sync_calendar properties: {label: Sync Calendar, method: sync_calendar}Position carries no meaning. Contribute a button with position: first or last; the
renderer resolves the container as the one top-level node that is not chrome, so it does not
matter where yours lands. check --only views enforces the “exactly one” half — a view with
two containers (ambiguous) or none (nothing to draw) fails the build with the view named.
Form, Wizard, PortalDetail, Search and Dashboard arches are different: they are genuine flat lists of many top-level nodes — rows, tabs, fields, filters — dispatched by type, with no single container. Chart views carry no container at all and take their configuration from the action.
A button that belongs to one tab or section keeps that association through its visible:
Q-expression, not its position — mirror the section’s own gate:
value: type: actionButton anchor: action_generate_variants properties: method: action_generate_variants visible: Q(has_variants=True) # same condition as the Variants tabstatusbar and linkButton are not subject to this — those are collected recursively, so
they may sit wherever they read best.
Scope a field target by the section that owns it
Section titled “Scope a field target by the section that owns it”Adding a field beside an existing one is a common hook, and the naive spelling is a trap:
target: {field: incoterm} # "next to incoterm, wherever it lives"position: beforevalue: {type: field, name: warehouse, ...}That expresses proximity to a field, when the intent is membership of a section. If a later
release moves incoterm into a different column, this operation still resolves — cleanly, and
in the wrong place — so your field quietly relocates with it and the section you meant to extend
is left empty. Nothing reports it, because nothing broke.
Scope the target with the owning column’s anchor instead:
target: {anchor: shipping, field: incoterm} # "the incoterm field, inside the Shipping column"position: beforevalue: {type: field, name: warehouse, ...}Now the placement you meant is the thing being checked. Move incoterm out of that column and
the target no longer resolves, so check --only views reports it (R4) instead of shipping a
silently rearranged form.
An anchor must be unique across the fully composed view, not just within your own module.
Because several modules can extend the same base view, two add-ons that introduce the same
anchor collide. check --only views composes the whole inheritance forest and rejects a
duplicate anchor (including one your module adds into a base view it doesn’t own), so pick a
distinct, module-specific anchor name (e.g. prefix it) rather than a generic one like details.
If a collision ever reaches runtime (an extension that never ran the gate), the ambiguous
operation fails loudly and is skipped — it is never silently applied to the wrong element.
Add a whole new tab
Section titled “Add a whole new tab”To add a new tab, target the tabs container by type and insert inside it (give your new tab its
own anchor):
- action: add target: {type: pageTabs} position: inside value: type: tab title: CRM anchor: crm content: [...]Extend an existing section — don’t duplicate it
Section titled “Extend an existing section — don’t duplicate it”If a module you depend on already contributes a tab, add your content into it by targeting its anchor, rather than creating a second tab with the same title:
- action: add target: {anchor: payroll} # the Payroll tab from the module you depend on position: inside value: type: settingsPanel properties: {title: Payroll Posting} content: [...]Operations
Section titled “Operations”add — insert a new element
Section titled “add — insert a new element”- action: add target: {field: email} position: after # after (default) | before | inside | first value: type: field name: phone properties: {widget: TextInput}| Position | Description |
|---|---|
after | Insert after the target (default) |
before | Insert before the target |
inside | Append to the target’s content |
first | Insert as the target’s first child |
modify — update properties
Section titled “modify — update properties”- action: modify target: {field: description} value: properties: {widget: RichTextEditor, required: true}Every property you name is assigned — the new value replaces the old one outright.
Contributing to a collection property
Section titled “Contributing to a collection property”Assignment is wrong when a property holds a collection you share with other modules. Writing
groups: [core_admin, my_group] doesn’t add your group; it replaces the whole list, silently
discarding whatever another module contributed. Nothing errors — the symptom shows up later as a
role that mysteriously can’t see a tab.
Use command notation to contribute your entry without restating (or knowing) the rest:
- action: modify target: {type: tab, name: product} value: properties: groups: [["add", [inventory_manager_group]]] # add # [["remove", [some_group]]] to removeadd appends, skipping entries already present, so two modules contributing the same value
converge instead of duplicating it. remove removes: scalars match by equality, and a dict entry
matches when every key you name matches — so you can drop one row action by its method without
repeating the whole node. Commands apply in the order written.
Supported on groups, rowActions, totals, fieldTypes, choices and hideInactive.
Structural content (content, operations) is composed with add/remove/replace and
position instead, and rejects commands.
The rule: assign only what you own; contribute with add/remove. If you declared the node, assign
freely. If you’re extending someone else’s, use commands — nothing detects the mistake for you,
and the module that composes last is the one that wins.
A bare list is always a literal value, so existing views are unaffected and a list only becomes a
command batch when every entry is a [verb, payload] pair with verb add or remove.
replace — swap an element entirely
Section titled “replace — swap an element entirely”- action: replace target: {field: old_field} value: type: field name: new_field properties: {widget: TextInput}remove — delete an element
Section titled “remove — delete an element”- action: remove target: {field: deprecated_field}Multiple operations
Section titled “Multiple operations”Operations apply in order, and a later operation may target an element an earlier one introduced (chained adds):
arch: operations: - action: add target: {field: email} position: after value: {type: field, name: linkedin_url, properties: {widget: TextInput}} - action: add target: {field: linkedin_url} # added by the previous operation position: after value: {type: field, name: twitter_handle, properties: {widget: TextInput}} - action: modify target: {field: phone} value: {properties: {required: true}}Authoring a view others will extend
Section titled “Authoring a view others will extend”If you write a view that other modules will hook into, give every tab, accordion,
fieldset, paper, actionButton, and titled column a stable, unique anchor. That anchor is the contract:
you can freely rename its title (including translating it) without breaking any extension,
because extensions target the anchor, not the title.
An anchor you declare is a promise: it may not disappear from your view without a sanctioning ledger entry, so a customization pinned to it survives your restructure. The anchor you target is someone else’s declaration — moving an operation from one of a base’s anchors to another is an ordinary edit and needs nothing recorded.
Removing an anchor you declared
Section titled “Removing an anchor you declared”Dropping an anchored node (dissolving a tab, folding a fieldset away) fails
check --only views until you record it in your module’s schema_changes.yaml — the same
ledger that records a field rename:
- id: 4 module: hr_recruitment model: Applicant action: remove_anchor # or: rename_anchor, with `from:` and `to:` anchor: cv_resume rehome_to: candidate_details # where content targeting the old anchor landsrehome_to is the important half. On -u all every customization that targeted the removed
anchor has only its target rewritten to rehome_to — the added field or section itself is
untouched, so a customer’s work survives your restructure and shows up in the surviving section.
Setting rehome_to: null instead discards those customizations; use it only when nothing in
the new layout stands in for what you removed.
The destination must be an anchor that still exists on that model — the gate rejects one that
doesn’t, because re-homing onto a name nothing declares would drop the customization just as
silently as pruning it. After recording the entry, re-snapshot your module’s anchor baseline and
commit it alongside schema_changes.yaml.
Removing the anchor is your side of the contract; inheriting views in other modules that targeted it don’t get rewritten — they fail the gate with an unresolvable target, and their authors retarget them.
Empty rows as contribution bands
Section titled “Empty rows as contribution bands”An anchored empty row is the usual way to invite other modules to add sections
(target: {anchor: <band>}, position: inside). A row is a 12-column grid, so it holds two
span: 6 columns — a third wraps onto its own line inside the same row, landing under
whichever sibling is tallest and separated by the row’s full gutter. That reads as a broken
layout, and the module contributing the third column can’t see it coming.
So size the bands for the contributions you expect: declare a second empty row when a third section is plausible, and say in a comment which band to fill first.
- type: row anchor: category_settings # first two span-6 contributions land here content: []- type: row anchor: category_settings_secondary # third and fourth content: []Best Practices
Section titled “Best Practices”- Target an
anchoror a model-boundfieldname — never a title, a button method, or a positional index. - Anchor every titled container and button you author — it’s the stable extension point.
- Extend an existing section, don’t duplicate it — target its anchor
position: inside. - Keep operations focused — one inheriting view per concern, with a descriptive identifier
(
contact_google_extension, notcontact_ext_1). - Let the gate check you —
./fullfinity-server check --only viewscomposes every view and fails on an unstable or unresolvable target.
Next Steps
Section titled “Next Steps”- Form Views - Creating form views
- Creating Modules - Module development