Custom Question Types
- Version
- 4.0 preview
- Updated
A question type is a new kind of input in the configurator: what a question of that type
stores, how it is rendered, how the browser sends a value, what a rule or a price can read off it,
and what an AI assistant is told about it. CBX ships twelve types (textbox, dropdown,
radiobuttons, images, checkbox, slider, calendar, upload, colorpicker,
ralcolorpicker, choices, textarea); this guide is everything you need to add one of your own
without touching core — the big picture first, then one type built from nothing in seven steps,
then the reference.
Read com_configbox_customization_overview.md once for where the customization layer lives per
platform. Paths below are relative to the component root docroot/components/com_configbox/;
getDirCustomization() is KenedoPlatform::p()->getDirCustomization() — Joomla data/customization/,
WordPress the customization plugin, Magento the ConfigboxCustomizations module. The six types in
the cbx-joomla site customization (data/customization/question_types/) are the worked examples this
guide points at; source line references are point-in-time.
1. The big picture
A question has one stored selection: a string, per question and per cart position, kept in the
session while the customer configures and in #__configbox_cart_position_configurations once the
line is in the cart (ConfigboxConfiguration). For a stock choice question that string is an answer
id; for a text box it is the text. A custom type decides what its string is — a number, a date, a
JSON object with several members — and everything else in the system asks the type class what that
string means. That is the whole design: the type class is the one place the meaning lives, and
five consumers read it.
| Consumer | What it asks the type | Where you answer |
|---|---|---|
| The admin (question form) | Does this type exist, what is it called | the view folder (§2.1), a language string (§9) |
| The configurator page | How does a question of this type render | the view class + template (§2.1, §2.2) |
| The browser | How does a value get from the widget to the server | the JS type object (§2.3) — mandatory |
| The engine (validation, rules, prices, cart, order) | Is the value valid, what does it mean, what can a rule compare and a formula compute | the type class (§2.4–§2.6) |
| AI and headless clients (chat advisor, MCP tools, runtime API) | What shape is the value, what are its bounds, what do its fields mean | the descriptors on the type class (§2.7) |
Three facts shape every decision:
- The class is optional and its absence is silent. A type with no class runs on the base class:
it stores what the browser sends, compares it with
floatval(), prints it as it is. Fine for a presentational type whose value is an answer id; wrong for anything else. A misnamed class file falls back to the base class without an error (§2.4). - The value the engine uses is always the STORED string, decoded by your type — never the live
widget. Rules, prices, the cart and the order freeze all read
getFieldValue(),getComparableValue(),getOutputValue()andgetSku()off the string. Composite values must be decoded in every one of them. - Rules and formulas read fields the type declares, not the raw value.
getRuleFields(),getCalcFields()andgetFieldValue()are the contract; the rule editor, the formula editor, the save-time validation, product copy and transfer, the runtime API and the AI tools all derive from it. You write no condition class and no term class for a question — ever (§2.6).
What follows builds one type, panel: the customer enters a width and a height in millimetres,
the selection is stored as {"w":"1200","h":"800"}, the area is a field a formula prices per m² and a
rule can compare, the order line gets a SKU, and the chat advisor knows the exact value to send.
2. Building a type, step by step
2.1 The view folder — the type exists
A type becomes selectable in the admin because a view folder exists in the customization:
ConfigboxModelQuestions::getCustomQuestionTypes() (models/questions.php:1593) lists
views/question_* folders and merges them into the question_type dropdown. No XML, no install
step, no database row.
getDirCustomization()/views/question_panel/
view.html.php ← the view class (required - a missing class throws on the configurator page)
tmpl/default.php ← the template
metadata.xml ← optional (see below)
view.html.php is usually empty apart from the class declaration. Its name follows
ConfigboxViewQuestion_<Ucfirst(type)> — with an underscore, unlike the model class in §2.4:
<?php
defined('CB_VALID_ENTRY') or die();
/**
* The "panel" question type: width × height in millimetres, stored as {"w":"1200","h":"800"}.
* The base view prepares everything a question template needs (selection, applies, validation,
* data attributes); the decoding of the JSON happens in the template.
*/
class ConfigboxViewQuestion_Panel extends ConfigboxViewQuestion {
}
metadata.xml answers one question for the calculation editor: can a question of this type be an
axis of a calc matrix, and if so what does the axis compare — the answer ids (answers, the
default), the entered value (value), or nothing (none)? A composite type says none or names the
member it wants compared through value; a type whose selection is an answer id can leave the file out
(models/calcmatrices.php:451).
<?xml version="1.0" encoding="utf-8"?>
<metadata>
<view hidden="true" calc-matrix-axis="none" />
</metadata>
2.2 The template — the type renders
ConfigboxViewQuestion::renderView() (views/question/view.html.php:496) picks the first template
that exists, in this order:
| # | Path | Use |
|---|---|---|
| 1 | platform template override for com_configbox / question_panel | a site template overriding your type |
| 2 | getDirCustomization()/templates/question_panel/default.php | re-skin your type on one site |
| 3 | getDirCustomization()/templates/question/default.php | re-skin every question |
| 4 | getDirCustomization()/views/question_panel/tmpl/default.php | your type's own template — the normal home |
| 5 | core views/question/tmpl/default.php | the base fallback |
Copy the stock template of the closest type as your starting point (views/question_textbox/tmpl/default.php
for a free-entry type, views/question_images/tmpl/default.php for a choice type) and keep its
skeleton: the wrapper carries the id, classes and data attributes the JS dispatches on, and the
sub-templates render the parts every question has. The parts that are contract, not decoration:
- the wrapper:
id="<?php echo hsc($this->questionCssId);?>"(question-<id>),class="<?php echo hsc($this->questionCssClasses);?>"(includesquestionandtype-panel) and<?php echo $this->questionDataAttributes;?>(data-question-id,data-question-type, …) — the JS finds and dispatches on these; $this->getViewOutput('question_edit_buttons'),'question_heading','question_decoration'— the quick-edit controls, the title with its description modes, the illustration;<div class="help-block validation-message-target">— where validation messages land;- for a choice type, the
.answer/#answer-<id>/#answer-input-<id>markup and theanswer-price-*classes the live price update writes into (read the images template's comments).
What the base view hands the template: $this->question (your ConfigboxQuestion<Type> instance,
with its record columns and your methods), $this->selection (the stored string), $this->answers
(presentation objects for the answer-taking types), $this->showLabel, $this->description,
$this->disableControl, $this->hasValidationMessage / $this->validationMessage, and the pricing
fields. A composite type decodes $this->selection at the top and renders one input per member:
<?php
defined('CB_VALID_ENTRY') or die();
/** @var ConfigboxViewQuestion_Panel $this */
// Decode defensively: empty for a fresh question, and never trust it to be JSON.
$values = array('w' => '', 'h' => '');
if (is_string($this->selection) && $this->selection !== '') {
$decoded = json_decode($this->selection, true);
if (is_array($decoded)) {
foreach ($values as $key => $unused) {
$values[$key] = isset($decoded[$key]) ? $decoded[$key] : '';
}
}
}
$labels = array('w' => KText::_('Width'), 'h' => KText::_('Height'));
?>
<div id="<?php echo hsc($this->questionCssId);?>" class="<?php echo hsc($this->questionCssClasses);?>" <?php echo $this->questionDataAttributes;?>>
<?php echo $this->getViewOutput('question_edit_buttons');?>
<?php echo $this->getViewOutput('question_heading');?>
<div class="answers">
<?php echo $this->getViewOutput('question_decoration');?>
<div class="form-group row">
<?php foreach ($labels as $key => $label) { ?>
<div class="col-6">
<label for="panel-<?php echo hsc($key);?>-<?php echo intval($this->question->id);?>"><?php echo hsc($label);?></label>
<div class="input-group">
<input type="number" class="form-control panel-input" data-panel-key="<?php echo hsc($key);?>"
id="panel-<?php echo hsc($key);?>-<?php echo intval($this->question->id);?>"
value="<?php echo hsc($values[$key]);?>" <?php echo ($this->disableControl) ? 'disabled="disabled"' : '';?> />
<span class="input-group-append"><span class="input-group-text">mm</span></span>
</div>
</div>
<?php } ?>
</div>
<div class="help-block validation-message-target">
<?php echo ($this->hasValidationMessage) ? hsc($this->validationMessage) : '';?>
</div>
</div>
</div>
Render all text server-side. Do not inject titles or labels from JS — translations, quick-edit and the AI's reading of the page all rely on the HTML being complete.
2.3 The JavaScript — the type sends its value
Registration is mandatory. configurator.initQuestions() looks every question's
data-question-type up among the registered types and throws on a miss, inside its loop: one
question of an unregistered type stops every question after it, and the page never reaches its
questions-init-done state. That holds for a purely presentational type too — the site's cards
type stores an ordinary answer id and still registers, because a click on a card would otherwise
highlight it (CSS) while nothing is ever sent.
The type object is an AMD module under getDirCustomization()/assets/javascript/ — the
configbox/custom namespace maps there — listed in the auto-loaded aggregator
custom_questions.js, which CBX pulls in right before initQuestions() runs
(com_configbox_assets_and_amd.md §2–§3):
// getDirCustomization()/assets/javascript/custom_questions.js
define([
'configbox/custom/questiontypes/panel'
], function () {
'use strict';
// Each dependency registers its type in its factory; nothing to do here.
});
// getDirCustomization()/assets/javascript/questiontypes/panel.js
define(['cbj', 'configbox/configurator'], function ($, configurator) {
'use strict';
const timeouts = {};
/** Reads both inputs, builds the stored string, mirrors it on the wrapper, sends it. */
function store(questionId) {
const question = configurator.getQuestionDiv(questionId);
const values = {};
let hasAny = false;
question.find('.panel-input').each(function () {
const value = $(this).val().trim();
values[$(this).data('panelKey')] = value;
hasAny = hasAny || value !== '';
});
const selection = hasAny ? JSON.stringify(values) : '';
// The core writes data('selection') from the SERVER's response. Anything that reads the
// current selections before that response lands (a live overlay, a summary panel) would
// see the old value - so mirror what we are about to send.
question.data('selection', selection);
configurator.sendSelectionToServer(questionId, selection);
}
return configurator.defineQuestionType('panel', {
/** Once per page: handlers delegated on document survive page switches and re-renders. */
init: function () {
$(document).on('input', '.question.type-panel .panel-input', function () {
const questionId = $(this).closest('.question').data('questionId');
window.clearTimeout(timeouts[questionId]);
timeouts[questionId] = window.setTimeout(function () {
delete timeouts[questionId];
store(questionId);
}, 400);
});
},
/** A selection the SERVER made: a restored configuration, a rule that cleared the question. */
onSystemSelectionChange: function (event, questionId, selection) {
const question = configurator.getQuestionDiv(questionId);
if (question.is('.type-panel') === false) {
return;
}
let values = {};
try { values = selection ? JSON.parse(selection) : {}; } catch (e) { values = {}; }
question.find('.panel-input').each(function () {
$(this).val(values[$(this).data('panelKey')] || '');
});
},
onQuestionDeactivation: function (event, questionId) {
configurator.getQuestionDiv(questionId).find('.panel-input').prop('disabled', true);
},
onQuestionActivation: function (event, questionId) {
configurator.getQuestionDiv(questionId).find('.panel-input').prop('disabled', false);
}
});
});
The basics every type follows:
- All roads lead to
configurator.sendSelectionToServer(questionId, value). It runs the standard round-trip — validation, rules, prices, the response that updates every other question — for whatever string you hand it. Send''to clear. There is no batch: one question per call. initruns once per page (delegate handlers ondocument, scoped to.question.type-panel);initEachruns per question on every paint and must be idempotent (guard with a class).onSystemSelectionChangeis where the widget follows the server: a restored cart line, a default, a rule clearing the question. Without it the widget shows a value the configuration no longer holds.- Debounce free text (the stock text box uses 400 ms on
input, notkeyup— a paste never comes up as a key). configurator.defineQuestionType(type, methods)fills the handlers you do not write with no-ops and a standard validation-message display;configurator.registerQuestionType(type, object)underneath requires all nine (init,onQuestionActivation,onQuestionDeactivation,onAnswerActivation,onAnswerDeactivation,onSystemSelectionChange,onValidationChange,onValidationMessageShown,onValidationMessageCleared) and throws with the missing names if you hand it a bare object.- jQuery and Bootstrap come from AMD (
cbj,bootstrap), never from a page global; Bootstrap 5 events are native (element.addEventListener('hidden.bs.modal', …)), a jQuery.on()never hears them.
The main article for the frontend is technical/com_configbox_configurator_questions.md: §4
how the JS binds to the markup and the DOM contract (data-question-id, type-<name>,
data('selection'), the applying/greying classes, .validation-message-target), §5–§6 the
round-trip both ways, §7.1–§7.3 the registration contract, the client API a type can call
(getCurrentSelection, getQuestionPropValue, server.makeRequest for your own controller tasks)
and every event a type or a page flow can listen to. Read it before writing more than the module
above. com_configbox_assets_and_amd.md covers loading, the view-asset engine and cache busting.
At this point the type exists, renders and stores a value. Everything below is what makes that value mean something.
2.4 The class — the type has meaning
ConfigboxQuestion::getQuestion() (classes/ConfigboxQuestion.php:100) builds one object per
question from ConfigboxQuestion<Ucfirst(type)>, looking in this order: already declared, core
classes/question_types/<ClassName>.php, getDirCustomization()/question_types/<ClassName>.php,
and — finding none — the plain ConfigboxQuestion, silently. A typo in the class or file name does
not raise; the type works with base behaviour and every override is ignored. When "my logic does
nothing", check this first. getQuestion() returns a clone: state set on the object does not
survive to the next call.
ConfigboxQuestion is concrete, not abstract: override only what your type needs. For a composite
value that is most of the "what a value is" group — the base class sees your JSON as a string,
floatval() of which is 0, so without these overrides comparisons, validation and output silently
see zero.
<?php
defined('CB_VALID_ENTRY') or die();
/**
* The "panel" question type: width × height in millimetres, stored as {"w":"1200","h":"800"}.
*/
class ConfigboxQuestionPanel extends ConfigboxQuestion {
/** The members of the stored object, in display order. */
const MEMBERS = array('w', 'h');
/** One member of a stored selection as a float, null when it is missing or the selection is empty. */
public static function getMember($selection, $key) {
if ($selection === null || $selection === '') {
return null;
}
$data = json_decode($selection, true);
return (is_array($data) && isset($data[$key]) && $data[$key] !== '') ? floatval($data[$key]) : null;
}
/** Canonical storage: the two members as strings, nothing else, keys in one order. */
public function getStorableValue($selection) {
if ($selection === null || $selection === '') {
return $selection;
}
$data = json_decode($selection, true);
if (!is_array($data)) {
return $selection;
}
$stored = array();
foreach (self::MEMBERS as $key) {
$stored[$key] = isset($data[$key]) ? (string) $data[$key] : '';
}
return json_encode($stored);
}
/** Nothing entered in either member is an unanswered question. */
public function isEmptySelection($selection) {
return (self::getMember($selection, 'w') === null && self::getMember($selection, 'h') === null);
}
/** The one number the min/max validation and the base `selected` field see: the area in m². */
function getComparableValue($selection) {
return $this->getArea($selection);
}
/** Width × height in m², null while either is missing. */
public function getArea($selection) {
$w = self::getMember($selection, 'w');
$h = self::getMember($selection, 'h');
return ($w === null || $h === null) ? null : round($w * $h / 1000000, 4);
}
/** Both members present, whole millimetres, within the question's bounds. */
function isValidValue($value) {
$w = self::getMember($value, 'w');
$h = self::getMember($value, 'h');
if ($w === null || $h === null) {
return KText::_('Please enter a width and a height.');
}
if ($w < 100 || $h < 100 || $w > 3000 || $h > 3000) {
return KText::_('Width and height must be between 100 and 3000 mm.');
}
return true;
}
/** What a human reads: cart, order confirmation, e-mails, the chat advisor's live state. */
function getOutputValue($selection = null) {
if ($selection === null) {
$selection = ConfigboxConfiguration::getInstance()->getSelection($this->id);
}
if ($this->isEmptySelection($selection)) {
return KText::_('No size entered');
}
return number_format(self::getMember($selection, 'w'), 0).' × '.number_format(self::getMember($selection, 'h'), 0).' mm';
}
/** The SKU the order line freezes for this selection. */
public function getSku($selection) {
if ($this->isEmptySelection($selection)) {
return null;
}
return 'PANEL-'.intval(self::getMember($selection, 'w')).'-'.intval(self::getMember($selection, 'h'));
}
}
What each of these is for, and why it is not optional for a composite type:
| Method | Job | If you skip it (composite value) |
|---|---|---|
getStorableValue($selection) | What goes into the store: a canonical serialization, whatever shape a client sent ({"h":..,"w":..} from an assistant, {"w":"","h":..} from a half-typed widget) | two writers of the same value store two different strings; isSameSelection() and the confirmation guard misfire |
isEmptySelection($selection) | What "not answered" is for this type. Base: '' and null | a {"w":"","h":""} counts as answered: a required question passes empty |
getComparableValue($selection) | The scalar the min/max validation compares, and what the base selected field reads | floatval(<json>) is 0 |
isValidValue($value) | The gate; return true, or the message the customer reads | anything is accepted |
getOutputValue($selection) | What a human sees — everywhere. The chat advisor reads it to the visitor after every change | the customer hears {"w":"1200","h":"800"} |
getSku($selection) | The SKU frozen onto the order line (#__cbcheckout_order_configurations.option_sku). Base: the picked answer's sku; a free-entry type returns null without an override | the order line has no SKU however precisely the selection describes a part |
getInitialValue() | The value a fresh configuration starts with, if not empty | — |
Rules for getSku(): return null for "no SKU", never ''; work from the $selection you are given
(the order record calls it while freezing a cart position's stored value, which is not the live
configuration); decode composite values yourself; treat it as a pure, cheap function. It is a freeze:
changing the derivation moves new orders only. Composing a whole product code from every question's
getSku() is described in com_configbox_overriding_views_and_templates.md; the Beta Calco
configurator in the site customization is the worked example (lib/BetacalcoCode.php).
2.5 The selection lifecycle — when the type owns something
Four hooks run around the store. The base implementations do nothing; override them when your selection owns something outside the string — a file on disk, a row in a table of yours, a reservation.
| Hook | When | The upload type does |
|---|---|---|
onBeforeSetSelection(&$selection, $prevSelection, $cartPositionId) | before a value is stored; $selection is by reference | lands the uploaded file, rewrites the selection to the file record; deletes the previous file on a clear or replace |
onAfterSetSelection($selection, $prevSelection, $cartPositionId) | after it is stored | — |
onSelectionCopied($selection, $sourcePositionId, $targetPositionId) | the position is copied (cart line copy, re-order); return what the copy stores | copies the file under the new position's name, so the two lines never share one file |
onSelectionDiscarded($selection, $cartPositionId) | the position is removed for good (cart remove, cleanup) | deletes the file — unless another position or a frozen order still points at it |
The stock ConfigboxQuestionUpload (classes/question_types/) is the complete worked example of
all four, including the reference guard and the sweeper for files the hooks cannot reach. A type
whose selection is a plain value needs none of these.
2.6 Rules and formulas — the type declares its fields
This is the part developers most often expect to be hard, and it is three methods. A field is
one thing a rule can compare or a formula can compute with; the type declares its fields once and
everything else — the rule editor's chips, the formula editor's terms, the save-time validation, the
evaluation, product copy and transfer, the runtime API's explanations and the MCP describe tools —
reads the declaration. No condition class, no term class, no editor sub-view. Those exist only
for sources that are not a question (com_configbox_custom_rule_conditions.md,
com_configbox_custom_calc_term_types.md).
class ConfigboxQuestionPanel extends ConfigboxQuestion {
// … the value methods of §2.4 …
/** What a rule can compare: the members and the area, then the base price and custom fields. */
public function getRuleFields() {
return array_merge($this->getPanelFields(), $this->withoutField(parent::getRuleFields(), 'selected'));
}
/** What a formula can compute with: the same - a composite has no single `selected` number. */
public function getCalcFields() {
return array_merge($this->getPanelFields(), $this->withoutField(parent::getCalcFields(), 'selected'));
}
protected function getPanelFields() {
$group = KText::_('Size');
return array(
new ConfigboxQuestionField('w', ConfigboxQuestionField::compose(KText::_('%s of %s'), KText::_('Width')), ConfigboxQuestionField::KIND_NUMBER, array(
'group' => $group,
'description' => 'Width in mm, as entered.',
)),
new ConfigboxQuestionField('h', ConfigboxQuestionField::compose(KText::_('%s of %s'), KText::_('Height')), ConfigboxQuestionField::KIND_NUMBER, array(
'group' => $group,
'description' => 'Height in mm, as entered.',
)),
new ConfigboxQuestionField('area', ConfigboxQuestionField::compose(KText::_('%s of %s'), KText::_('Area (m²)')), ConfigboxQuestionField::KIND_NUMBER, array(
'group' => $group,
'description' => 'Width × height in m², rounded to four decimals.',
)),
);
}
/** Reads a declared field from the GIVEN selection. Every key above needs a case; the rest goes to parent. */
public function getFieldValue($key, $selection, $answerId = null, $selections = null) {
if ($key === 'w' || $key === 'h') {
return self::getMember($selection, $key);
}
if ($key === 'area') {
return $this->getArea($selection);
}
return parent::getFieldValue($key, $selection, $answerId, $selections);
}
}
With that in place an admin drags "Area of Panel is or above 2" into a rule, a formula on the
question's calcmodel reads {"question": <id>, "field": "area"} × a price per m², and the
assistant is told {"field":"area","kind":"number","operators":["==","!=","<","<=",">",">="],"description":"Width × height in m² …"}.
The pieces of a field (classes/ConfigboxQuestionField.php):
-
key— what a stored rule or term names the field by. It is stored data: every rule and formula on the field carries it, so renaming it orphans them. Short and stable (w, notwidth). -
label— with one%sfor the question title; build it withcompose()so a%in a name cannot break it. The chip reads as a sentence: "Width of Panel is or above 1200". -
kind— decides the comparison, the editor and the API description:Kind Compared Editor offers Operators choiceas a string against a fixed set (an answer id, a slot name, yes/no)one chip per choice plus "not answered"; with selectInput, one chip with a dropdown==,!=numbernumerically ( 2.5is above2.10)a text input all six dateby day; the stored value a timestamp, the condition value a date string a date input all six textas a string a text input ==,!= -
options —
group(the heading the editors put the field under),description(what the value means: this is what the AI reads, write it),choices/choicesAreAnswers/emptyLabel/selectInputfor a choice field,answerIdfor a field declared once per answer.choicesAreAnswersis also what tells product copy and transfer that the storedvalueis an answer id to remap — by declaration, not by the field's name.
Five rules that keep the declaration honest:
- Return
nullfor "nothing there" fromgetFieldValue()— an unanswered question, a missing member. A condition reads null as "not answered", a term uses its fallback. Never turn it into0: a quantity of zero and no quantity are different things to a rule. - Read the
$selectionyou are given, never the live configuration: the engine passes simulated selections when it asks "what would happen if".$selections(every selection of the configuration being evaluated) exists for a field whose value depends on other questions; hand it down to whatever you call —ConfigboxPricesrequires the set as its second argument. - Change what
selectedmeans withgetSelectedField()when your selection is a single value of another kind: the calendar type makes it adate, the upload type achoicebetween "uploaded" and "not uploaded", the slider anumber. Drop it withwithoutField()when there is no single value, as above. - A field per answer is declared once per answer with
'answerId' => $answerId; the stored condition then carriesanswerIdnext tofield, and copy and transfer remap it without knowing the type. The site'squantityanswerstype (a quantity per answer, withpricedtotalandweighttotalsums) is the worked example. - Prices and weight of a value-dependent type go through the fields, not through
getPrice(). The basegetPrice()/getWeight()are thin delegates nobody consults; the engine prices a selection only when it is a bare answer id, so a composite prices at 0. Assign a formula calculation to the question'scalcmodel(andcalcmodel_weight) built from your calc fields — it runs on every path: display, cart, order freeze.
Stored shape, one vocabulary for rules and formulas:
{"type":"QuestionProperty","questionId":42,"field":"area","operator":">=","value":"2"}
{"type":"QuestionProperty","questionId":42,"field":"quantity","answerId":57,"operator":">=","value":"3"}
{"type":"QuestionProperty","questionId":42,"field":"area","fallbackValue":"0"}
The engine's side of this — how a condition is compiled and evaluated, the comparison semantics per
kind, what the runtime API's explainQuestion says about a blocking condition — is in
technical/com_configbox_rule_engine.md §4.3 and technical/com_configbox_calculation_engine.md.
2.7 AI and headless clients — the type describes its value
The chat advisor, the MCP tools and any headless client see a question through the runtime API's
projection. For a stock type the value shape is obvious; for yours it is a black box unless the type
says what it is — and a model that has to guess guesses wrong (observed: "1200x800",
{"width": …}, four failed attempts, then a bug report). Two methods, built from the same bound
code so they can never disagree:
/** One or two sentences: the EXACT format, the bounds, one example. What the model reads first. */
public function getSelectionFormatHint() {
return 'JSON object with STRING values and exactly the keys w and h: {"w":"<width mm>","h":"<height mm>"}. '
.'Example: {"w":"1200","h":"800"}. Width and height 100-3000 mm. Always send both keys.';
}
/** The machine-readable companion: JSON Schema of the stored value, a description on every property. */
public function getSelectionSchema() {
return array(
'type' => 'object',
'description' => 'Stored selection of this panel question; the selection STRING is this object serialized as JSON.',
'properties' => array(
'w' => array('type' => 'string', 'description' => 'Width in mm, 100-3000.'),
'h' => array('type' => 'string', 'description' => 'Height in mm, 100-3000.'),
),
'required' => array('w', 'h'),
);
}
They surface as selectionFormat and selectionSchema in the projection and the advisor's catalog
digest; cbx_describe_question_types reports the type as self-describing when either is declared
on your class rather than inherited. Two more descriptors apply to other shapes: getChoiceList() for
enumerable values without answer rows, getConstraintHints() for scalar constraints beyond min/max
(the upload type's extensions, the slider's steps). And selectionIsAnswerId() must return false
for a composite that references answers (a quantity map), or the engine treats the JSON as an id.
The full contract — designing the selection shape, the comparison contract (isSameSelection()),
how to test it, the checklist of an AI-ready type — is com_configbox_question_types_and_ai.md.
The dimensions type in the site customization implements all of it with live per-question bounds.
2.8 Admin fields — settings of your own on the question form
Per-type settings (bounds, a unit, a toggle) are properties added to the questions model from the customization, gated on your type:
// getDirCustomization()/model_property_customization/questions.php
function customPropertyDefinitionsQuestions(&$propDefs) {
$panelOnly = array('question_type' => 'panel');
$propDefs['panel_max_width'] = array(
'name' => 'panel_max_width',
'label' => KText::_('Maximum width (mm)'),
'type' => 'number',
'appliesWhen' => $panelOnly,
'positionForm' => 7100,
'apiTitle' => 'Maximum width',
'apiDescription' => 'Upper bound in mm for the panel widget, enforced server-side. Empty means no limit.',
);
}
KenedoModel::getCustomPropertyDefinitions() loads the file by the model's base name (questions)
and calls the function it derives from the filename — rename one, rename the other, or the fields
silently never appear. appliesWhen ANDs several keys, so a field can depend on your type and on
one of your own toggles. Every stored property needs apiTitle / apiDescription: the fields become
part of the question entity's API (generated JSON schemas, PHP records, TypeScript types, marked as
customization origin), and php cli/joomla.php configbox:generate-types regenerates them. Storage
is a migration under the customization's updates/ (technical/com_configbox_migrations.md) — or
external storage, com_configbox_custom_properties.md.
Form positions are group-scoped. The form sorts every property by
positionForm, and agroupstart…groupendpair swallows every field whose position falls between them — another type's included. If that group is gated on a differentquestion_type, your fields render inside adisplay:nonecontainer: in the DOM, invisible, no error. Wrap your type's fields in their own group (gated like the fields) and pick a position range no other group spans; greppositionFormover the customization's property files before choosing. (Found the hard way: fields at 6100–6156 vanished into another type's group spanning 6100–6160.)
Your class reads the settings straight off the record ($this->panel_max_width), and isValidValue(),
getSelectionFormatHint() and getSelectionSchema() should all read them through one resolver method.
2.9 Two more things a type may declare
getCompatibleTypes()— once a question is saved its type is fixed: rules, formulas, settings and stored selections assume the kind of value it produces. The model refuses a change from every writer (form, entity API, MCP, transfer) except to a type the stored type lists here. Name only types that read the same kind of selection and declare the same fields; the base declares none, so a custom type without the method cannot be changed once saved. The check asks the type being left, so a stock type never offers your type.providesCartQuantity()/getCartQuantity($selection)— when your selection carries "how many", declare it and the cart line's quantity follows the selection on every store; a client that wants three changes the question, not the line (com_configbox_question_types_and_ai.md, §6).
3. Deployment checklist
getDirCustomization()/views/question_<type>/view.html.php+tmpl/default.php(+metadata.xmlif the type cannot be a matrix axis) — the type is in the dropdown and renders.assets/javascript/questiontypes/<type>.jsregistered throughcustom_questions.js— mandatory before a question of the type can be on a page.question_types/ConfigboxQuestion<Type>.php— the value methods (§2.4); the lifecycle hooks if the selection owns something (§2.5); the fields as soon as a rule or a price should read the value (§2.6); the descriptors (§2.7).model_property_customization/questions.phpfor per-type settings, a migration for their storage, thenphp cli/joomla.php configbox:generate-typesand commitgenerated/.- A language override for the dropdown label (§9), in every active language.
- Price by a formula on
calcmodelbuilt from your calc fields; never by overridinggetPrice(). - Clear the CBX cache: question data is cached, and CBX keeps its own file cache the host's cache-clear does not touch.
- Test what a real question of the type does through the runtime API — start a configuration, set a
value, read
explainQuestionandgetConfiguration— and the digest the advisor gets (testautomation::getAdvisorDigest), not the model's prose (com_configbox_question_types_and_ai.md§8).
4. Gotchas
- A missing class is silent (§2.4). Prove your logic by behaviour, never by the file existing.
getQuestion()clones. Per-call state only.- Write
data('selection')when you store (§2.3). The core sets it from the server's response; anything composing a live overlay in between reads the old value. - Composite values need every value method AND the field declaration.
isValidValue()alone leaves prices, rules and confirmations readingfloatval(<json>) === 0; without the fields the editors offer only the base ones and the baseselectedcomparesgetComparableValue(). - A field key is stored data. Renaming it orphans every rule and formula that carries it.
null, not0, for "nothing there" fromgetFieldValue().- The order line's SKU comes from
getSku(), not from a system override of the order record — that gets you the same value and a copy of a 1000-line method to keep in sync. - The label of the dropdown entry is a KText key (§9). Untranslated, the customer-facing admin
shows
Lengthofrun. - Registration is not optional (§2.3), and the throw takes the questions after yours down too.
5. Reference — the type class at a glance
| Concern | Methods | Required when |
|---|---|---|
| Value | getStorableValue, isEmptySelection, getComparableValue, isValidValue, getOutputValue, getSku, getInitialValue, isSameSelection | the selection is anything but an answer id or a plain scalar |
| Validation bounds | getMinimumValue, getMaximumValue, isValueTooLow, isValueTooHigh, getValidationMessage | the type has bounds the stock min/max cannot express |
| Lifecycle | onBeforeSetSelection, onAfterSetSelection, onSelectionCopied, onSelectionDiscarded | the selection owns something outside the string |
| Rules and formulas | getRuleFields, getCalcFields, getFieldValue, getSelectedField, withoutField | a rule or a price should read the value |
| AI and clients | getSelectionFormatHint, getSelectionSchema, getChoiceList, getConstraintHints, selectionIsAnswerId | the value shape is not obvious from the type name |
| Type change | getCompatibleTypes (static) | the type may be swapped for another after saving |
| Cart quantity | providesCartQuantity, getCartQuantity | the selection carries "how many" |
6. Worked examples in the site customization
data/customization/ of the cbx-joomla project, six types with their view folders, JS modules and
admin fields:
| Type | Selection | Shows |
|---|---|---|
cards | an answer id | the minimal type: presentation only, no class methods, a JS object that re-scopes the stock images behaviour; keyboard and focus handling around a Bootstrap modal |
dimensions | {"w","h","q"} + optional division maxima | the full contract: canonical storage, comparable value, bounds from admin fields, format hint and schema from one resolver, members and area as fields |
quantityanswers | {"<answerId>": qty, …} | a field per answer, pricedtotal / weighttotal as calc fields, selectionIsAnswerId() false |
lengthofrun | a length in feet or feet + inches | getSku(), a custom controller task from JS through server.makeRequest() before the standard round-trip |
stabilizer, cableoutlet | structured JSON | more of the same on different shapes |
7. How a type resolves — the two names
| What | Convention | Example |
|---|---|---|
| View folder (registers the type) | views/question_<type>/ | question_panel |
| View class | ConfigboxViewQuestion_<Ucfirst(type)> — underscore | ConfigboxViewQuestion_Panel |
| Type class | ConfigboxQuestion<Ucfirst(type)> — no underscore | ConfigboxQuestionPanel |
| JS type name | the question_type value, as data-question-type | 'panel' |
| Dropdown label | KText key Ucfirst(type) | Panel="Panel" |
8. Where the type shows up without your doing anything
Once the fields and descriptors are declared: the rule and formula editors list the fields under the
question; the save-time validation refuses an undeclared field, an operator the kind cannot take, a
value of the wrong kind; product copy and transfer remap questionId, answerId and — for any field
declared with choicesAreAnswers — the value too (it goes by the declaration, not by the field's
name, so an answer-valued field of your own is remapped as selected is, and a field that merely
holds a number is left alone even when the question has answers); the runtime API's explainQuestion labels a blocking condition with the
field's title and value label; cbx_describe_rules and cbx_describe_calculations list the fields per
question with kind, operators, choices and description; cbx_describe_question_types lists the type and
whether it is self-describing; the chat advisor's catalog digest carries selectionFormat and
selectionSchema, and its live state reads getOutputValue() to the visitor.
9. The dropdown label
The label is ucfirst() of the folder suffix, and that string doubles as a KText key. A folder
question_lengthofrun looks up Lengthofrun; add Lengthofrun="Length of run" to the customization's
language override for every active language (com_configbox_language_overrides.md). An untranslated
key passes through unchanged.
See also
com_configbox_question_types_and_ai.md— the AI-ready type in depth: selection shape design, the descriptors, the comparison contract, the cart-quantity contract, testingtechnical/com_configbox_configurator_questions.md— the frontend article: how questions render, the DOM contract, the round-trip, registering a type, the client API and eventscom_configbox_assets_and_amd.md— loading your JS and CSS, theconfigbox/customnamespace, cache bustingtechnical/com_configbox_rule_engine.md,technical/com_configbox_calculation_engine.md— what the engine does with the fieldscom_configbox_custom_rule_conditions.md,com_configbox_custom_calc_term_types.md— sources that are not a questioncom_configbox_extending_stock_models.md,com_configbox_custom_properties.md— admin fields and their storagecom_configbox_overriding_views_and_templates.md— the wider template override story, composing a product code fromgetSku()com_configbox_customization_overview.md— the layer, and where it lives per platform