Skip to content

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.

  1. Create a view with inherited_view pointing at the parent view’s identifier.
  2. Define operations that add, modify, replace, or remove elements.
  3. Target each operation by a stable handle — an anchor or a model-bound field name.
- 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.

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` container

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 view
target: {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: before
value: {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 append
value:
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.

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: Calendar
arch:
- 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 tab

statusbar 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: before
value: {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: before
value: {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.

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: [...]
- action: add
target: {field: email}
position: after # after (default) | before | inside | first
value:
type: field
name: phone
properties: {widget: TextInput}
PositionDescription
afterInsert after the target (default)
beforeInsert before the target
insideAppend to the target’s content
firstInsert as the target’s first child
- 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.

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 remove

add 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.

- action: replace
target: {field: old_field}
value:
type: field
name: new_field
properties: {widget: TextInput}
- action: remove
target: {field: deprecated_field}

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}}

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.

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 lands

rehome_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.

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: []
  1. Target an anchor or a model-bound field name — never a title, a button method, or a positional index.
  2. Anchor every titled container and button you author — it’s the stable extension point.
  3. Extend an existing section, don’t duplicate it — target its anchor position: inside.
  4. Keep operations focused — one inheriting view per concern, with a descriptive identifier (contact_google_extension, not contact_ext_1).
  5. Let the gate check you./fullfinity-server check --only views composes every view and fails on an unstable or unresolvable target.