Skip to main content
Version: 4.0 preview

Custom Question Types

Version
4.0 preview
Updated
View markdown

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.

ConsumerWhat it asks the typeWhere you answer
The admin (question form)Does this type exist, what is it calledthe view folder (§2.1), a language string (§9)
The configurator pageHow does a question of this type renderthe view class + template (§2.1, §2.2)
The browserHow does a value get from the widget to the serverthe 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 computethe 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 meanthe 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() and getSku() 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() and getFieldValue() 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:

#PathUse
1platform template override for com_configbox / question_panela site template overriding your type
2getDirCustomization()/templates/question_panel/default.phpre-skin your type on one site
3getDirCustomization()/templates/question/default.phpre-skin every question
4getDirCustomization()/views/question_panel/tmpl/default.phpyour type's own template — the normal home
5core views/question/tmpl/default.phpthe 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);?>" (includes question and type-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 the answer-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.
  • init runs once per page (delegate handlers on document, scoped to .question.type-panel); initEach runs per question on every paint and must be idempotent (guard with a class).
  • onSystemSelectionChange is 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, not keyup — 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:

MethodJobIf 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 nulla {"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 readsfloatval(<json>) is 0
isValidValue($value)The gate; return true, or the message the customer readsanything is accepted
getOutputValue($selection)What a human sees — everywhere. The chat advisor reads it to the visitor after every changethe 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 overridethe 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.

HookWhenThe upload type does
onBeforeSetSelection(&$selection, $prevSelection, $cartPositionId)before a value is stored; $selection is by referencelands 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 storescopies 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, not width).

  • label — with one %s for the question title; build it with compose() 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:

    KindComparedEditor offersOperators
    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.5 is above 2.10)a text inputall six
    dateby day; the stored value a timestamp, the condition value a date stringa date inputall six
    textas a stringa 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 / selectInput for a choice field, answerId for a field declared once per answer. choicesAreAnswers is also what tells product copy and transfer that the stored value is an answer id to remap — by declaration, not by the field's name.

Five rules that keep the declaration honest:

  1. Return null for "nothing there" from getFieldValue() — an unanswered question, a missing member. A condition reads null as "not answered", a term uses its fallback. Never turn it into 0: a quantity of zero and no quantity are different things to a rule.
  2. Read the $selection you 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 — ConfigboxPrices requires the set as its second argument.
  3. Change what selected means with getSelectedField() when your selection is a single value of another kind: the calendar type makes it a date, the upload type a choice between "uploaded" and "not uploaded", the slider a number. Drop it with withoutField() when there is no single value, as above.
  4. A field per answer is declared once per answer with 'answerId' => $answerId; the stored condition then carries answerId next to field, and copy and transfer remap it without knowing the type. The site's quantityanswers type (a quantity per answer, with pricedtotal and weighttotal sums) is the worked example.
  5. Prices and weight of a value-dependent type go through the fields, not through getPrice(). The base getPrice() / 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's calcmodel (and calcmodel_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 a groupstart…groupend pair swallows every field whose position falls between them — another type's included. If that group is gated on a different question_type, your fields render inside a display:none container: 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; grep positionForm over 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​

  1. getDirCustomization()/views/question_<type>/view.html.php + tmpl/default.php (+ metadata.xml if the type cannot be a matrix axis) — the type is in the dropdown and renders.
  2. assets/javascript/questiontypes/<type>.js registered through custom_questions.js — mandatory before a question of the type can be on a page.
  3. 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).
  4. model_property_customization/questions.php for per-type settings, a migration for their storage, then php cli/joomla.php configbox:generate-types and commit generated/.
  5. A language override for the dropdown label (§9), in every active language.
  6. Price by a formula on calcmodel built from your calc fields; never by overriding getPrice().
  7. Clear the CBX cache: question data is cached, and CBX keeps its own file cache the host's cache-clear does not touch.
  8. Test what a real question of the type does through the runtime API — start a configuration, set a value, read explainQuestion and getConfiguration — 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 reading floatval(<json>) === 0; without the fields the editors offer only the base ones and the base selected compares getComparableValue().
  • A field key is stored data. Renaming it orphans every rule and formula that carries it.
  • null, not 0, for "nothing there" from getFieldValue().
  • 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​

ConcernMethodsRequired when
ValuegetStorableValue, isEmptySelection, getComparableValue, isValidValue, getOutputValue, getSku, getInitialValue, isSameSelectionthe selection is anything but an answer id or a plain scalar
Validation boundsgetMinimumValue, getMaximumValue, isValueTooLow, isValueTooHigh, getValidationMessagethe type has bounds the stock min/max cannot express
LifecycleonBeforeSetSelection, onAfterSetSelection, onSelectionCopied, onSelectionDiscardedthe selection owns something outside the string
Rules and formulasgetRuleFields, getCalcFields, getFieldValue, getSelectedField, withoutFielda rule or a price should read the value
AI and clientsgetSelectionFormatHint, getSelectionSchema, getChoiceList, getConstraintHints, selectionIsAnswerIdthe value shape is not obvious from the type name
Type changegetCompatibleTypes (static)the type may be swapped for another after saving
Cart quantityprovidesCartQuantity, getCartQuantitythe 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:

TypeSelectionShows
cardsan answer idthe 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 maximathe 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
lengthofruna length in feet or feet + inchesgetSku(), a custom controller task from JS through server.makeRequest() before the standard round-trip
stabilizer, cableoutletstructured JSONmore of the same on different shapes

7. How a type resolves — the two names​

WhatConventionExample
View folder (registers the type)views/question_<type>/question_panel
View classConfigboxViewQuestion_<Ucfirst(type)> — underscoreConfigboxViewQuestion_Panel
Type classConfigboxQuestion<Ucfirst(type)> — no underscoreConfigboxQuestionPanel
JS type namethe question_type value, as data-question-type'panel'
Dropdown labelKText 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, testing
  • technical/com_configbox_configurator_questions.md — the frontend article: how questions render, the DOM contract, the round-trip, registering a type, the client API and events
  • com_configbox_assets_and_amd.md — loading your JS and CSS, the configbox/custom namespace, cache busting
  • technical/com_configbox_rule_engine.md, technical/com_configbox_calculation_engine.md — what the engine does with the fields
  • com_configbox_custom_rule_conditions.md, com_configbox_custom_calc_term_types.md — sources that are not a question
  • com_configbox_extending_stock_models.md, com_configbox_custom_properties.md — admin fields and their storage
  • com_configbox_overriding_views_and_templates.md — the wider template override story, composing a product code from getSku()
  • com_configbox_customization_overview.md — the layer, and where it lives per platform