Skip to content

Custom Asset Fields Developer Guide

Ed Mozley edited this page Aug 20, 2026 · 1 revision

Custom asset fields β€” Developer Guide

How an asset comes to have fields that aren't columns on assets: the catalogue, field sets, the two ways a set attaches, and every surface the values have to reach.

Read the typed fields engine guide first β€” this is the consumer, that's the machinery. The sibling is Asset import. User-facing page: Recording anything. Design doc: docs/design/flexible-asset-fields.md (local only β€” docs/design/ is gitignored).


1. πŸ“ The files involved

Colour key: πŸ—„οΈ schema Β· βš™οΈ engine Β· πŸ”Œ API Β· πŸ–₯️ UI Β· 🌍 i18n Β· πŸ§ͺ tests

🎨 File What it does
βš™οΈ includes/services/asset_fields.php AssetFieldsService β€” resolution, values, uniqueness, history, catalogue admin
βš™οΈ includes/typed_fields.php the shared engine β€” its own guide
πŸ—„οΈ database/freeitsm.sql the seven asset_field* / asset_type_field_sets tables
πŸ—„οΈ includes/db_verify_schema.php the same columns for the upgrade path. ⚠️ columns + PK only
πŸ—„οΈ includes/db_verify_indexes.php ⚠️ GENERATED β€” scripts/gen_db_verify_indexes.php, never by hand
πŸ—„οΈ api/system/db_verify.php the 12 foreign keys, in an explicit FK group
πŸ”Œ api/assets/get_asset_fields.php catalogue + sets + typeβ†’set map. Everything the settings screen needs, one call
πŸ”Œ api/assets/save_asset_field.php Β· delete_asset_field.php the catalogue
πŸ”Œ api/assets/save_asset_field_set.php Β· delete_asset_field_set.php Β· save_type_field_sets.php sets and attachment
πŸ”Œ api/assets/get_asset_custom_fields.php Β· save_asset_custom_fields.php one asset's fields and values
πŸ”Œ api/assets/set_asset_field_set.php attach/detach a set on ONE asset
πŸ”Œ api/assets/create_asset.php the manual add β€” the module had no UI create path at all before this
πŸ–₯️ asset-management/settings/index.php the tree, the three sections, the field/set editors
πŸ–₯️ asset-management/index.php Other details on the asset, and the Add dialog
πŸ–₯️ asset-management/table.php + assets/js/asset-table.js custom columns
πŸ–₯️ asset-management/help-custom-fields.php the in-app guide
🌍 lang/en/asset-management.php ⚠️ English only β€” 23 locales owed
πŸ§ͺ tests/asset-custom-fields.php 29 assertions, built around the real scenario

2. πŸ”‘ The catalogue is global

asset_fields holds a field once, install-wide. Sets reference it; types reference sets.

This is the one place the design deliberately departs from the CMDB, whose cmdb_class_properties.property_key is unique per class. Correct for 8 classes. Fatal for "absolutely anything": you'd get fourteen private "Serial Number" fields and could never answer "find the thing with serial X" or run one report across types.

Everything downstream β€” search, the table column, the REST key, the import mapping β€” depends on resolution meaning the same thing on a webcam as on a television.


3. The seven tables

Table Holds
asset_fields the catalogue. field_key is immutable; label is freely renameable
asset_field_options dropdown choices, with an optional colour
asset_field_sets a bundle you attach
asset_field_set_fields what's in a set β€” is_required lives here, not on the field
asset_type_field_sets a set attached to a TYPE
asset_field_set_assets a set attached to ONE asset
asset_field_values the answers

Things the schema does on purpose

field_key separate from label, and immutable. Import mappings and report definitions point at the key, so renaming "Size" to "Screen size" mustn't break a nightly import.

is_required on the set membership. A serial may be compulsory for laptops and optional for keyboards β€” same field, different obligation.

Soft delete on fields and sets. asset_field_values.field_id points at the field, and the FK is deliberately RESTRICT, so a hard delete is impossible even by hand.

seq in the values unique key, always 0. Reserved so multi-value fields can be added later without index surgery β€” Database Verification restores columns and primary keys, not changes to an existing UNIQUE index.

Composite (field_id, value_text(64)) indexes. The ones cmdb_object_properties never got. Without them, "every asset whose Warranty Provider is X" scans every value in the table.

No tenant_id on values. Inherited from the asset, as cmdb_object_properties and ticket_assets inherit theirs.

⚠️ UNIQUE (tenant_id, field_key) does not dedupe global fields β€” MySQL treats NULLs as distinct. Global-key dedup is enforced in application code, exactly as asset_types documents for the same reason. A unique index that silently doesn't apply is worse than none.


4. πŸ”‘ Field resolution

The fields for an asset are:

the fields of every set attached to its asset type βˆͺ the fields of every set attached to that asset directly

Resolved in one function, AssetFieldsService::fieldsForAsset(). That single point is what made per-asset sets cheap: one extra query rather than a change to everything that asks what fields an asset has.

A field in two attached sets appears once, and required wins. The stricter reading is the safe one β€” a field mandatory for laptops shouldn't go optional because a second set happens to include it too.

Detaching keeps the values

detachSetFromAsset() deletes the attachment row and nothing else. Un-ticking "Smart TV pilot" hides the fields; it doesn't throw away the IP address somebody recorded. Re-attach and it's all still there.

This is safe precisely because readValues() only ever returns values for fields that currently apply, so orphaned rows are inert rather than confusing.


5. Writing values

saveValues() does: load the asset β†’ resolve its fields β†’ uniqueness β†’ required β†’ TypedFields::writeValues() β†’ history, inside one transaction.

Only fields that actually apply may be written. An inapplicable key is a 422, never a silent drop, because a mapping mistake in an import must be visible.

Uniqueness is scoped to the asset's own company. Two customers may each legitimately hold a printer tagged PR0001 β€” the same reasoning that keeps hostname uniqueness in application code rather than an index.

History writes one asset_history row per changed field, with field_name as field:<field_key>. The key, not the label, so history stays readable after a rename. An estate where core edits are audited and custom ones vanish is worse than no audit.


6. The type lock

field_type can't change once any value exists β€” saveField() refuses with a message naming the count.

The editor disables the control and says so before you try. A locked control that explains itself beats an error after the fact. The label stays renameable forever, and config modes (multiline, decimals, date_mode) stay editable, because those are presentation rather than storage.


7. The surfaces β€” and why this list matters

A custom field is only useful if its values come back out. Each of these had to be taught about them separately:

Surface Where
The asset page asset-management/index.php β€” Other details, grouped by set, blanks collapsed with a filled count
The Add dialog same file β€” the type's fields render inline
The table table.php + asset-table.js β€” show_in_list fields join the column picker
CSV / PDF export free β€” the shared exporter walks visibleColumns()
Search and ⌘K api/system/global_search.php β€” is_searchable fields
Handover documents includes/services/handover_templates.php β€” every field offered as a cf:<key> column
REST v1 api/v1/resources/assets.php β€” an additive fields object
Asset history AssetFieldsService::logChanges()

⚠️ Still not done: dashboard widgets and mobile. asset_dashboard_widgets.aggregate_property is a VARCHAR(50) holding a physical column name, so it has to learn to speak field:<key> without breaking existing widgets.

πŸ”‘ One renderer, two modes

cfFieldRow(f, mode) in asset-management/index.php renders both the asset page and the Add dialog. The type rules are shared β€” three-state booleans, unit suffixes, date-mode mapping β€” and must not end up subtly different in two places. The classes are chosen by mode, because detail-grid classes inside a form look like something bolted on.

The first version shared the chrome too, and a custom field visibly didn't match the built-in field above it.

Why the table columns are rendered server-side

table.php emits window.assetCustomColumns rather than the JS fetching it. createDataTable() boots on DOMContentLoaded, so awaiting anything before calling it means the event has already fired and the table never builds at all.


8. The settings screen

Three sections in a deliberate order β€” by asset type (the guided path), all fields (the catalogue), field sets (the bundles) β€” plus a read-only tree at the top.

The guided path hides the abstraction. Adding a field from the type view creates or reuses a set behind the scenes, so somebody who never wants to meet the concept of a field set never does.

The tree exists because three lists can't show the shape. Each list was correct; which set was on which type, and which field was shared between them, was invisible. It shows both directions β€” type β†’ set β†’ field, and each field with everywhere it's used β€” and lists types that record nothing, because a type you forgot to set up is exactly the mistake it's for.

The built-in name warning. Typing "Make" warns that every asset already has manufacturer. Advisory, never a block, and only on new fields β€” warning every time an existing one is edited is a nag about a decision already taken.


9. ⚠️ Traps hit while building

Two phantom theme tokens. --warning and --link don't exist; the real ones are the --warning-bg/-border/-text trio and --accent. A phantom token is invisible until somebody opens the page in the other theme. Grep every var(--x) against theme.css.

The settings <script> runs even when its capability-gated tab isn't rendered. Every DOM touch is guarded (cfPresent(), cfOn()) β€” one addEventListener on a null element throws and silently kills every function defined after it.

.info-item is a column flex, so a unit <span> after the input drops onto its own line. Only a screenshot caught it.

t() isn't bootstrapped in API endpoints. Using it without require i18n.php is a PHP fatal served as HTTP 200.

toast.js assigns textContent. Most lang/ strings assume markup, so &ldquo; renders literally in a toast.

Modal markup isn't portable between pages of the same module. inbox.css is the canonical source for .modal / .modal-content / .modal-header / .modal-body / .modal-footer / .modal-actions, and documents two layouts: fields directly inside .modal-content use .modal-actions, header/body/footer uses .modal-footer. But asset-management/index.php overrides .modal-content locally (500px / 80vh, no overflow), so it isn't a scroll container and .modal-actions has nothing to stick to. A dialog written with the settings page's markup rendered with no padding and its buttons on top of the content. Before copying modal markup between pages, grep the target page for .modal-content {. See the comment at asset-management/index.php ~line 464.


10. πŸ§ͺ The test

tests/asset-custom-fields.php runs the scenario the feature was designed from, literally: ten televisions with three fields, then three of them piloted with three more, six months later.

The assertions that matter aren't "a value round-trips". They're:

  • the seven unpiloted TVs have no value rows at all
  • an unset boolean is absent, not false
  • the estate holds 39 value rows, not 60
  • detaching keeps the values; re-attaching brings them straight back
  • a failed write leaves nothing behind

11. Owed

  • 23 locales β€” everything here is English only
  • Dashboard widgets and mobile (Β§7)
  • Link fields are read-only on the asset β€” a searchable picker is its own job
  • Two open questions: what a type change does to values from sets it no longer has, and whether per-asset sets should show in the type's settings

See also

FreeITSM

Getting Started

Modules

Multi-tenancy (planned)

Blue sky thinking

Bugs resolved

Links

Clone this wiki locally