Skip to content
Merged
Show file tree
Hide file tree
Changes from 7 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
16 changes: 16 additions & 0 deletions templates/table_end_user.html.twig
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,14 @@
]) %}
{% 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 %}
></select>
{% else %}
<input
class="form-control form-control-sm"
Expand Down Expand Up @@ -143,6 +151,14 @@
<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 %}
></select>
{% else %}
<input
class="form-control form-control-sm"
Expand Down
Loading