Custom Calc Term Types (for sources that are not a question)
- Version
- 4.0 preview
- Updated
How to add your own calculation term type to CBX: a new kind of building block an admin can drop into a formula calculation, which the pricing engine evaluates at configure-time. This guide is for terms about something that is not a question (an inventory figure, an exchange rate, an external price). CBX ships the term types that are not about a question (number, operator, function, calculation, customer group) plus ONE generic question term that every question type feeds; this guide shows how to add a non-question type without touching core.
A custom question type that stores a composite value (a width/height/quantity triple, say) does NOT need a term type to be priced: it declares calc fields (§0), and the generic question term reads them. That used to be the main reason to write a term class; it no longer is.
Read com_configbox_customization_overview.md first, and
technical/com_configbox_calculation_engine.md for how calculations are stored and evaluated. All
paths are relative to the component root docroot/components/com_configbox/;
getDirCustomization() is KenedoPlatform::p()->getDirCustomization(). Source references are
point-in-time (component 3.8.26); verify against the code.
0. A term about a question is a calc field on the question type
What a formula can read off a question is declared by the question's class:
ConfigboxQuestion::getCalcFields() lists the fields a term can use as a number, and
getFieldValue($key, $selection, $answerId, $selections) reads one from the GIVEN selection. The
generic question term (ConfigboxCalcTermQuestionProperty) does the rest: the chips under the
question in the formula editor, the evaluation with the term's fallback value, the delete guards,
the id remapping on copy and transfer, and the per-question fields list in
cbx_describe_calculations. Rules read getRuleFields() the same way, so one class declares what
both editors offer.
Two things a type does with the base declaration:
withoutField($fields, 'selected')drops the baseselectedfield when the type has no single entry (a composite). Add your own fields in its place; the base price and custom fields stay.- Per-answer fields are declared once per answer with
answerIdset; the stored term then carriesanswerIdnext tofield, so copy and transfer remap it without knowing the type.
§5 shows the declaration on a real type. The full contract (the ConfigboxQuestionField kinds,
getRuleFields(), "null means nothing there") is in com_configbox_custom_question_types.md,
§2.6 "Rules and formulas — the type declares its fields".
If you already ship a term class for such a number
It keeps working — discovered by the directory scan, evaluated for the formulas stored with its type,
listed in the formula editor under its own heading. Retire it the way the rule-conditions playbook
describes in its §0: declare the field on the type, rewrite the stored terms of your type into question
terms with ConfigboxRulesJsonHelper::walk() from a customization update script, then delete the
class. A term item reads {"type":"QuestionProperty","questionId":42,"field":"area","fallbackValue":"0"}
— fallbackValue in place of a rule's operator/value; the formulas live in
#__configbox_calculation_formulas.calc, so that is the column the script rewrites.
Write a term class only when the number comes from somewhere that is not a question.
1. How a term type resolves to your class
Unlike question types, term classes are loaded eagerly.
ConfigboxCalcTerm::loadTermClasses() (classes/ConfigboxCalcTerm.php:75) include_onces every
.php file in core classes/calc_term_types/ and in
getDirCustomization()/calc_term_types/. There is no autoloader involvement and no registration
step: dropping the file in is the installation.
The type name is derived from the class name by stripping either accepted prefix
(getTermTypeNames(), classes/ConfigboxCalcTerm.php:130):
$className = str_replace('ConfigboxCalcTerm', '', $className); // built-ins
$className = str_replace('CustomCalcTerm', '', $className); // yours
So both prefixes work, and the convention is:
classes/calc_term_types/ConfigboxCalcTermNumber.php ← shipped → type "Number"
getDirCustomization()/calc_term_types/CustomCalcTermStock.php ← yours → type "Stock"
That suffix is what gets stored as "type":"Stock" in the formula's JSON, so renaming the class
breaks every calculation already using it. Treat the suffix as a published identifier. The type
list is built from the FILE names in the two folders (getTermClassNames(), :101), so name the
file after the class.
2. The contract: three abstract methods
ConfigboxCalcTerm is abstract with exactly three methods to implement
(classes/ConfigboxCalcTerm.php:169-186). Two of them are admin-side; only one runs on the
storefront.
| Method | Runs | Job |
|---|---|---|
getTermsPanelHtml($calculationId, $productId) | Formula editor | The panel shown when your entry under "Other terms" (below the product's questions and the Regarding Question) is clicked: a <ul class="cb-calc-terms-list"> of <li> chips the admin drags into the formula |
getTermHtml($termData, $forEditing = true) | Formula editor | Render one term as a chip: span.item.term with data-type and your data-* keys, a cb-calc-term-name span, and an <input class="input" data-data-key="…"> for anything the admin types (a read-only cb-calc-term-value span when $forEditing is false) |
getTermResult($termData, $selections, $regardingQuestionId = null, $regardingAnswerId = null, $allowNonNumeric = false) | Every price calculation | The value the term contributes |
$termData is your own shape: every data-* attribute of the chip (kebab-case to camelCase) plus
every .input value under its data-data-key, exactly as calc-editor.js harvests them; the
editor has no per-type code. Keep it small and stable; it is persisted. Optional hooks:
getTypeTitle() (your entry's text), containsQuestionId/AnswerId/CalculationId() (delete guards,
if you hold ids) and getCopiedTermData() (id remapping on product copy; the transfer import remaps
the keys questionId, answerId and calcId of every type generically).
3. getTermResult() runs a lot
A formula is evaluated on every selection change, for every position in the cart, and again on
cart and checkout pages. getTermResult() therefore sits in the hottest path CBX has.
- No queries per call where a cached read will do. Question data is already cached; reach for
ConfigboxQuestion::getQuestion()rather than SQL. - Return a number. Unless
$allowNonNumericis true, the engine is composing an arithmetic expression; returning a string ornullpropagates into the whole formula. $selectionsis the configuration the engine is evaluating, which may be a simulated one. Read from it rather than re-fetching state.
4. The editor methods have a failure mode worth knowing
getTermHtml() renders terms an admin saved earlier, including terms that point at a question
which has since been deleted. If your implementation resolves a question id without checking,
the lookup throws, and the whole formula editor fails to render: the admin cannot open the
calculation, and cannot delete the stale term either, because they cannot reach it.
Guard it:
if (!ConfigboxQuestion::questionExists($questionId)) {
return '<span class="item term broken">' . KText::_('Deleted question') . '</span>';
}
ConfigboxQuestion::questionExists() (classes/ConfigboxQuestion.php:131) exists for exactly
this. A placeholder keeps the editor usable and lets the admin remove the term. (The generic question
term does the same: a deleted question renders as [deleted question #id].)
5. Worked shape: pricing a composite question (a field, not a term class)
The quantities type (quantityanswers: one quantity per answer, selection {"57":"3","58":"1"})
prices itself with two calc fields. There is no term class; this is the whole pricing path:
class ConfigboxQuestionQuantityanswers extends ConfigboxQuestion {
/** Formulas get the totals on top of the quantity fields. The base `selected` goes: a
* quantity map is not a number. */
public function getCalcFields() {
$fields = array_merge($this->getQuantityFields(), $this->withoutField(parent::getCalcFields(), 'selected'));
$pricing = KText::_('Related to pricing'); // the group the editor lists them under
$fields[] = new ConfigboxQuestionField('pricedtotal', ConfigboxQuestionField::compose(KText::_('%s of %s'), KText::_('Price × quantity total')), ConfigboxQuestionField::KIND_NUMBER, array(
'group' => $pricing,
'description' => 'Sum over the answers of price × quantity', // for the API / AI
));
$fields[] = new ConfigboxQuestionField('weighttotal', ConfigboxQuestionField::compose(KText::_('%s of %s'), KText::_('Weight × quantity total')), ConfigboxQuestionField::KIND_NUMBER, array(
'group' => $pricing,
'description' => 'Sum over the answers of weight × quantity',
));
return $fields;
}
public function getFieldValue($key, $selection, $answerId = null, $selections = null) {
if ($key === 'pricedtotal' || $key === 'weighttotal') {
if ($selection === null || $selection === '' || empty($this->answers)) {
return null; // nothing entered: the term uses its fallback
}
$column = ($key === 'pricedtotal') ? 'price' : 'weight';
$sum = 0.0;
foreach ($this->answers as $id => $answer) {
$qty = self::getSubValue($selection, (string) $id); // the type's own decoding
if ($qty === null || $qty <= 0) {
continue;
}
$sum += (isset($answer->$column) ? floatval($answer->$column) : 0.0) * $qty;
}
return $sum;
}
return parent::getFieldValue($key, $selection, $answerId, $selections);
}
}
The admin assigns a formula [Price × quantity total of Accessories] to the question's calcmodel
and the store prices it on every path (display, cart, order freeze). The stored term is
{"type":"QuestionProperty","questionId":42,"field":"pricedtotal","fallbackValue":"0"}, the same
shape as "Price of …". The same class declares quantity once per answer ('answerId' => $answerId
in its getQuantityFields()), which is how a formula reads one answer's quantity:
{…,"field":"quantity","answerId":57,…}.
The point is getFieldValue(): the selection is JSON, so nothing generic could read a number out of
it. Decoding it is the type's job, and declaring the field is what puts the result in the editor, in
the API and in front of an assistant.
6. Deployment checklist
- Check the number does not come from a question. If it does, declare a calc field (§0, §5); no term class.
getDirCustomization()/calc_term_types/CustomCalcTerm<Type>.php: implement all three methods.- Clear the CBX cache; on some platforms CBX keeps its own file cache the host's cache-clear does not touch.
- Open a formula calculation in the admin: your entry should be listed under "Other terms", after the product's questions and the Regarding Question. If it is absent, the file is not in the folder the platform resolves (§1).
- Insert the term, save, and check a price on the storefront.
getTermsPanelHtml()working does not provegetTermResult()does.
7. Gotchas
- A term about a question is a calc field, not a class (§0). The old per-type term classes (a
CustomCalcTermDimensionsreading asubValue) are gone; the stored terms were rewritten toQuestionPropertywith afield. - The class-name suffix is a stored identifier. Renaming the class orphans every formula using it (§1).
- Both prefixes are stripped, so
ConfigboxCalcTermFooandCustomCalcTermFooboth yield typeFoo: do not ship both. - Guard question lookups in
getTermHtml()or a deleted question takes the formula editor down with it (§4). - Loading is eager and unconditional: a parse error in your file breaks calculation loading everywhere, not just where the term is used.
getTermResult()is hot; see §3.
See also
com_configbox_custom_question_types.md: §2.6 "Rules and formulas — the type declares its fields", the field declaration a composite value is priced throughclasses/calc_term_types/ConfigboxCalcTermQuestionProperty.php: the generic question term that reads those fields;…Calculation.phpand…CustomerGroup.php: the shipped non-question types to copy patterns fromcom_configbox_customization_overview.md: the layer, and where it lives per platformtechnical/com_configbox_calculation_engine.md: storage, the three calculation types, evaluationcom_configbox_custom_rule_conditions.md: the same split for the rule engine