-
Notifications
You must be signed in to change notification settings - Fork 15
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).
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. |
| ποΈ | includes/db_verify_indexes.php |
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 |
|
| π§ͺ | tests/asset-custom-fields.php |
29 assertions, built around the real scenario |
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
resolutionmeaning the same thing on a webcam as on a television.
| 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 |
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 asasset_typesdocuments for the same reason. A unique index that silently doesn't apply is worse than none.
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.
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.
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.
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.
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_propertyis aVARCHAR(50)holding a physical column name, so it has to learn to speakfield:<key>without breaking existing widgets.
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.
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.
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.
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 “ 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.
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
- 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
- Typed fields engine β Developer Guide
- Asset import β Developer Guide
- Recording anything β the user-facing page
- Assets
FreeITSM β an open-source IT Service Management platform Β· github.com/edmozley/freeitsm Β· MIT licence
- Installation
- β° Scheduled tasks (cron jobs)
- Architecture
- AI Providers
- Internationalisation (i18n)
- Timezones & Time Handling
- Theming & Dark Mode
- β¨οΈ Command palette (βK)
- π Searching inside tickets
- π Attached documents
- MobileβFriendly
-
Security
- Layer 1 β which modules you can enter
- β³ π§© Module Access Control
- β³ π οΈ Module Access β Developer Guide
- Layer 2 β what you can administer
- β³ π Roles & Permissions
- β³ π οΈ Roles β Developer Guide
- β³ π€ Why capabilities are constants
- Layer 3 β the System module
- β³ π Admin Access Control
- Hardening
- β³ π Security review response 2026-08
- β³ π‘οΈ Security hardening 2026-08
- β³ π οΈ Security hardening 2026-08 β Developer Guide
- β³ π‘οΈ Round three β plain English
- β³ π οΈ Round three β Developer Guide
- Single Sign-On (SSO)
- ποΈ LDAP & Active Directory
- Browser Extension
- API Reference
-
π REST API β how it works
- β³ π« REST API: Tickets
- β³ π» REST API: Assets
- β³ π΄ REST API: Problems
- β³ π REST API: Changes
- β³ π REST API: Knowledge
- β³ β REST API: Tasks
- β³ ποΈ REST API: CMDB
- β³ π REST API: Contracts
- β³ ποΈ REST API: Calendar
- β³ πΏ REST API: Software
- β³ π¦ REST API: Service Status
- β³ βοΈ REST API: Morning Checks
- β³ π REST API: Forms
- β³ βοΈ REST API: Workflow
- β³ πΊοΈ REST API: Network Mapper
- β³ π§ Using the API docs page
- β³ π OpenAPI specification
- β³ β OpenAPI: kept correct
- β³ π οΈ Maintaining the catalogue
- Watchtower
-
Tickets
- β³ Mailbox Authentication
- β³ π€ Email send log
- β³ Basic IMAP mailboxes
- β³ Email rendering & images
- β³ SLA Management
- β³ WhatsApp channel
- β³ π¬ Web chat channel
- β³ π£ Slack channel
- β³ π Linking tickets
- β³ ποΈ Canned responses
- β³ βοΈ Limiting replies to particular senders
- β³ βοΈ Email signatures
- β³ π The public web address
- β³ π’ Ticket numbering
- β³ π Raising a ticket for someone else
- β³ π Merging tickets
- β³ β Splitting tickets
- β³ β Selecting several tickets
- β³ π οΈ Snoozing tickets β Developer Guide
- β³ π₯ Collision detection
- β³ β±οΈ Time tracking
- Problem Management
- Tasks
- Assets
- Knowledge
- Change Management
- Calendar
- Morning Checks
- Reporting
- Software
- Forms
- Contracts
- Service Status
- π Notifications
- π¨ War Room
- Self-Service Portal
- LMS
- Process Mapper
- CMDB
- Network Mapper
- Workflows
- Issue trackers (Jira, Azure DevOps)
- System
-
Overview
- β³ π Progress tracker
- β³ Concepts & vocabulary
- β³ Email routing & mailboxes
- β³ Settings: global vs per-company
- β³ Users & self-service
- β³ Staff cross-company access
- β³ Worked examples
- β³ Pitfalls & gotchas
- β³ Scope: what it's for
- β³ π οΈ Developer Guide (make a module multi-company)
- β³ ποΈ Case study: CMDB (a linked graph)
- β³ π§ͺ Test harness (prove it's isolated)