Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,12 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](http://keepachangelog.com/)
and this project adheres to [Semantic Versioning](http://semver.org/).

## [Unreleased]

### Fixed

- Fix Table question column type edge cases

## [1.3.0] - 2026-08-11

### Changed
Expand Down
65 changes: 48 additions & 17 deletions public/js/modules/AfTableQuestion.js
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,11 @@ export class AfTableQuestion {

this.#watchServerErrors();

// The first row is server-rendered, not cloned from the template, so
// its ajax-backed selects (unlike the static 'adapt' ones, which
// self-init through Dropdown::showFromArray) need the same wiring.
this.#initSelectsInRow(this.#body.querySelector('[data-af-table-row]'));

this.#addBtn.addEventListener('click', () => this.addRow());
this.#body.addEventListener('click', e => {
const btn = e.target.closest('[data-af-table-remove-row]');
Expand Down Expand Up @@ -335,23 +340,49 @@ export class AfTableQuestion {
}

#initSelectsInRow(row) {
if (!row || !window.setupAdaptDropdown) { return; }
const limit = parseInt(this.#table.dataset.afS2Limit, 10) || 100;
row.querySelectorAll('[data-af-needs-s2]').forEach(select => {
const id = 'dropdown_af_eu_' + Date.now() + '_' + Math.random().toString(36).slice(2, 7);
select.id = id;
const config = {
type: 'adapt',
field_id: id,
width: '100%',
dropdown_css_class: '',
placeholder: '',
ajax_limit_count: limit,
};
window.select2_configs = window.select2_configs || {};
window.select2_configs[id] = config;
window.setupAdaptDropdown(config);
});
if (!row) { return; }

if (window.setupAdaptDropdown) {
const limit = parseInt(this.#table.dataset.afS2Limit, 10) || 100;
row.querySelectorAll('[data-af-needs-s2]').forEach(select => {
const id = AfTableQuestion.#newFieldId(select);
const config = {
type: 'adapt',
field_id: id,
width: '100%',
dropdown_css_class: '',
placeholder: '',
ajax_limit_count: limit,
};
window.select2_configs = window.select2_configs || {};
window.select2_configs[id] = config;
window.setupAdaptDropdown(config);
});
}

if (window.setupAjaxDropdown) {
row.querySelectorAll('[data-af-needs-ajax-s2]').forEach(select => {
let config;
try {
config = JSON.parse(select.dataset.afS2Config ?? '');
} catch {
config = null;
}
if (!config || typeof config !== 'object') { return; }

const id = AfTableQuestion.#newFieldId(select);
const full_config = { ...config, field_id: id };
window.select2_configs = window.select2_configs || {};
window.select2_configs[id] = full_config;
window.setupAjaxDropdown(full_config);
});
}
}

static #newFieldId(select) {
const id = 'dropdown_af_eu_' + Date.now() + '_' + Math.random().toString(36).slice(2, 7);
select.id = id;
return id;
}

removeRow(rowElement) {
Expand Down
124 changes: 37 additions & 87 deletions src/Model/QuestionType/TableQuestion.php
Original file line number Diff line number Diff line change
Expand Up @@ -714,25 +714,25 @@ public function renderEndUserTemplate(Question $question): string
}

$cell_map = [];
$user_options = null;
$user_ajax_config = null;
$device_options = null;
$glpi_item_options = []; // keyed by itemtype FQCN to avoid duplicate DB queries
$item_ajax_configs = []; // keyed by itemtype FQCN to avoid duplicate IDOR tokens

foreach ($config->getColumns() as $index => $col) {
$fqcn = $col[TableQuestionConfig::COL_QUESTION_TYPE];
$type = $type_instances[$fqcn] ?? null;
$itemtype = $col[TableQuestionConfig::COL_ITEMTYPE] ?? '';

if (is_a($fqcn, AbstractQuestionTypeActors::class, true)) {
$user_options ??= $this->buildUserOptions();
$cell_map[$index] = ['mode' => 'select', 'options' => $user_options];
$user_ajax_config ??= $this->buildAjaxDropdownConfig(User::class, ['is_active' => 1, 'is_deleted' => 0]);
$cell_map[$index] = ['mode' => 'select-ajax', 'config' => $user_ajax_config];
} elseif (is_a($fqcn, QuestionTypeUserDevice::class, true)) {
$device_options ??= $this->buildUserDeviceOptions();
$cell_map[$index] = ['mode' => 'select', 'options' => $device_options];
} elseif (is_a($fqcn, QuestionTypeItem::class, true)) {
if ($itemtype !== '' && class_exists($itemtype)) {
$glpi_item_options[$itemtype] ??= $this->buildGlpiItemtypeOptions($itemtype);
$cell_map[$index] = ['mode' => 'select', 'options' => $glpi_item_options[$itemtype]];
$item_ajax_configs[$itemtype] ??= $this->buildAjaxDropdownConfig($itemtype);
$cell_map[$index] = ['mode' => 'select-ajax', 'config' => $item_ajax_configs[$itemtype]];
} else {
$cell_map[$index] = ['mode' => 'input', 'input_type' => 'text'];
}
Expand Down Expand Up @@ -804,6 +804,7 @@ public function getCompatibleQuestionTypes(): array
HostnameQuestion::class,
HiddenQuestion::class,
LdapQuestion::class,
ReservationQuestion::class,
self::class,
];

Expand All @@ -816,6 +817,11 @@ public function getCompatibleQuestionTypes(): array
}
}

// Exclude question types with a sub-type selector (Fields plugin types)
if (!is_a($fqcn, QuestionTypeItem::class, true) && $type->getSubTypes() !== []) {
continue;
}

$types[$fqcn] = $type->getName();
}

Expand Down Expand Up @@ -863,43 +869,6 @@ public function getCellInfo(string $fqcn, ?QuestionTypeInterface $type = null):
return ['mode' => 'input', 'input_type' => 'text'];
}

/**
* Builds a [value => label] options array for actor-type columns.
* Loads up to 200 active users from the database.
*
* @return array<int|string, string>
*/
private function buildUserOptions(): array
{
global $DB;

$options = ['' => Dropdown::EMPTY_VALUE];

$rows = $DB->request([
'SELECT' => ['id', 'name', 'realname', 'firstname'],
'FROM' => User::getTable(),
'WHERE' => ['is_active' => 1, 'is_deleted' => 0],
'ORDER' => ['realname', 'firstname', 'name'],
'LIMIT' => 200,
]);

foreach ($rows as $row) {
if (!is_array($row)) {
continue;
}

$id = is_numeric($row['id']) ? (int) $row['id'] : 0;
$options[(string) $id] = formatUserName(
$id,
is_string($row['name'] ?? null) ? $row['name'] : null,
is_string($row['realname'] ?? null) ? $row['realname'] : null,
is_string($row['firstname'] ?? null) ? $row['firstname'] : null,
);
}

return $options;
}

/**
* Builds an optgroup-keyed options array for the User Device column type.
* Keys at the top level are group labels; inner keys are "Itemtype_id" strings.
Expand All @@ -917,57 +886,38 @@ private function buildUserDeviceOptions(): array
}

/**
* Builds a [id => name] options array for a GLPI itemtype (used by Item/ItemDropdown columns).
* Applies entity and soft-delete filters when applicable.
* Builds a select2 "ajax" widget config for a GLPI itemtype-backed column
* (Item/ItemDropdown columns, and the User picker behind Actor columns).
*
* @param class-string $itemtype
* @return array<int|string, string>
* @param array<string, mixed> $condition
* @return array<string, mixed>
*/
private function buildGlpiItemtypeOptions(string $itemtype): array
private function buildAjaxDropdownConfig(string $itemtype, array $condition = []): array
{
global $DB;

$options = ['' => Dropdown::EMPTY_VALUE];
$item = getItemForItemtype($itemtype);
if ($item === false) {
return $options;
}

$where = [];

if ($item->maybeDeleted()) {
$where['is_deleted'] = 0;
}
global $CFG_GLPI;

if ($item->isEntityAssign()) {
$where = array_merge($where, getEntitiesRestrictCriteria(
$item->getTable(),
'',
'',
$item->maybeRecursive(),
));
}
$condition_key = $condition !== [] ? Dropdown::addNewCondition($condition) : '';
$root_doc = is_string($CFG_GLPI['root_doc'] ?? null) ? $CFG_GLPI['root_doc'] : '';
$dropdown_max = $CFG_GLPI['dropdown_max'] ?? 50;

$criteria = [
'SELECT' => ['id', 'name'],
'FROM' => $item->getTable(),
'ORDER' => 'name',
'LIMIT' => 200,
return [
'url' => $root_doc . '/ajax/getDropdownValue.php',
'params' => [
'itemtype' => $itemtype,
'condition' => $condition_key,
'_idor_token' => Session::getNewIDORToken($itemtype, ['condition' => $condition_key]),
],
'dropdown_max' => is_numeric($dropdown_max) ? (int) $dropdown_max : 50,
'ajax_limit_count' => $this->ajaxLimitCount(),
'width' => '100%',
'container_css_class' => '',
'multiple' => false,
'placeholder' => Dropdown::EMPTY_VALUE,
'allowclear' => false,
'parent_id_field' => '',
'on_change' => '',
];

if ($where !== []) {
$criteria['WHERE'] = $where;
}

foreach ($DB->request($criteria) as $row) {
if (!is_array($row)) {
continue;
}

$options[(string) (is_numeric($row['id']) ? (int) $row['id'] : 0)] = is_string($row['name']) ? $row['name'] : '';
}

return $options;
}

private function loadConfig(Question $question): TableQuestionConfig
Expand Down
20 changes: 20 additions & 0 deletions templates/table_end_user.html.twig
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,16 @@
]) %}
{% endset %}
{{ sel_html|raw }}
{% elseif cell.mode == 'select-ajax' %}
<select
class="form-select form-select-sm"
name="{{ input_base }}[{{ row_index }}][col_{{ col_index }}]"
data-af-needs-ajax-s2
Comment thread
RomainLvr marked this conversation as resolved.
data-af-s2-config="{{ cell.config|json_encode|e('html_attr') }}"
{% if col.required %} required {% endif %}
>
<option value="" disabled selected>{{ __('Select a value', 'advancedforms') }}</option>
</select>
{% else %}
<input
class="form-control form-control-sm"
Expand Down Expand Up @@ -143,6 +153,16 @@
<option value="{{ val }}">{{ lbl }}</option>
{% endfor %}
</select>
{% elseif cell.mode == 'select-ajax' %}
<select
class="form-select form-select-sm"
name="{{ input_base }}[__ROW__][col_{{ col_index }}]"
data-af-needs-ajax-s2
Comment thread
RomainLvr marked this conversation as resolved.
data-af-s2-config="{{ cell.config|json_encode|e('html_attr') }}"
{% if col.required %} required {% endif %}
>
<option value="" disabled selected>{{ __('Select a value', 'advancedforms') }}</option>
</select>
{% else %}
<input
class="form-control form-control-sm"
Expand Down
Loading