From f19f0f63854b82d5548d98aa81a5e045d19289e5 Mon Sep 17 00:00:00 2001 From: alisher372 Date: Sat, 1 Aug 2026 14:07:19 +0500 Subject: [PATCH] feat(table): support embeddable question types inside table columns ## Summary Add support for question types implementing the new embeddable interface inside table question columns. Instead of knowing about specific plugins, the table question now delegates rendering to any question type implementing the interface. ## What is implemented - support for embeddable question types; - dynamic configuration loading; - embedded administration rendering; - embedded end-user rendering; - server-side rendering for table rows; - validation and preparation of embedded question extra data. ## Motivation The table question previously only supported built-in question types. With this change, any question type implementing the embeddable interface can be used inside a table without adding plugin-specific logic to Advanced Forms. This keeps Advanced Forms independent from external plugins. ## Backward compatibility Existing built-in question types continue to work exactly as before. Plugins not implementing the new interface are unaffected. ## Related Depends on: - GLPI Core PR: Example implementation: - Fields PR: --- public/js/modules/AfTableQuestion.js | 64 +++- public/js/modules/AfTableQuestionConfig.js | 180 ++++++++++ .../TableColumnQuestionConfigController.php | 54 +++ src/Model/QuestionType/TableQuestion.php | 313 ++++++++++++++++-- .../QuestionType/TableQuestionConfig.php | 13 +- .../question_types/table_config.html.twig | 27 ++ templates/table_end_user.html.twig | 110 +----- templates/table_end_user_row.html.twig | 56 ++++ 8 files changed, 665 insertions(+), 152 deletions(-) create mode 100644 src/Controller/TableColumnQuestionConfigController.php create mode 100644 templates/table_end_user_row.html.twig diff --git a/public/js/modules/AfTableQuestion.js b/public/js/modules/AfTableQuestion.js index d0abb10..4029656 100644 --- a/public/js/modules/AfTableQuestion.js +++ b/public/js/modules/AfTableQuestion.js @@ -34,7 +34,6 @@ export class AfTableQuestion { #table; #body; - #template; #addBtn; #minRows; #maxRows; @@ -42,12 +41,11 @@ export class AfTableQuestion { constructor(tableElement) { this.#table = tableElement; this.#body = tableElement.querySelector('[data-af-table-body]'); - this.#template = tableElement.querySelector('[data-af-table-row-template]'); this.#addBtn = tableElement.querySelector('[data-af-table-add-row]'); this.#minRows = parseInt(tableElement.dataset.afMinRows, 10) || 1; this.#maxRows = parseInt(tableElement.dataset.afMaxRows, 10) || 50; - if (!this.#body || !this.#template || !this.#addBtn) { + if (!this.#body || !this.#addBtn) { return; } @@ -232,18 +230,62 @@ export class AfTableQuestion { control.closest('td')?.querySelector('[data-af-cell-error]')?.remove(); } - addRow() { + async addRow() { const rowCount = this.#rowCount(); - if (rowCount >= this.#maxRows) { + if (rowCount >= this.#maxRows || this.#addBtn.getAttribute('aria-busy') === 'true') { return; } - const clone = this.#template.content.cloneNode(true); - clone.querySelectorAll('[name]').forEach(el => { - el.name = el.name.replace('__ROW__', rowCount); + + const questionId = parseInt(this.#table.dataset.afQuestionId, 10) || 0; + if (questionId <= 0) { + return; + } + + this.#addBtn.setAttribute('aria-busy', 'true'); + this.#addBtn.classList.add('opacity-25', 'pe-none'); + + try { + const url = new URL(`${CFG_GLPI.root_doc}/plugins/advancedforms/TableQuestionRow`, window.location.origin); + url.searchParams.set('questions_id', String(questionId)); + url.searchParams.set('row_index', String(rowCount)); + url.searchParams.set('render_identity', `dynamic-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`); + + const response = await fetch(url, { credentials: 'same-origin' }); + if (!response.ok) { + return; + } + + const html = await response.text(); + if (html.trim() === '') { + return; + } + + this.#appendServerRenderedRow(html); + this.#initSelectsInRow(this.#body.lastElementChild); + } finally { + this.#addBtn.removeAttribute('aria-busy'); + this.#updateButtonStates(); + } + } + + #appendServerRenderedRow(html) { + if (window.$) { + const nodes = window.$.parseHTML(html, document, true); + window.$(this.#body).append(nodes); + return; + } + + const template = document.createElement('template'); + template.innerHTML = html; + template.content.querySelectorAll('script').forEach(oldScript => { + const script = document.createElement('script'); + Array.from(oldScript.attributes).forEach(attribute => { + script.setAttribute(attribute.name, attribute.value); + }); + script.textContent = oldScript.textContent; + oldScript.replaceWith(script); }); - this.#body.appendChild(clone); - this.#initSelectsInRow(this.#body.lastElementChild); - this.#updateButtonStates(); + this.#body.appendChild(template.content); } #initSelectsInRow(row) { diff --git a/public/js/modules/AfTableQuestionConfig.js b/public/js/modules/AfTableQuestionConfig.js index 2ca5685..1a98799 100644 --- a/public/js/modules/AfTableQuestionConfig.js +++ b/public/js/modules/AfTableQuestionConfig.js @@ -58,6 +58,7 @@ export class AfTableQuestionConfig { container.querySelectorAll('[data-af-table-column]').forEach(row => { AfTableQuestionConfig.#bindItemtypeVisibility(row); AfTableQuestionConfig.#bindPatternVisibility(row); + AfTableQuestionConfig.#bindEmbeddedQuestionConfig(row); }); if (addBtn && template) { @@ -84,6 +85,7 @@ export class AfTableQuestionConfig { AfTableQuestionConfig.#initNewColumnSelect(newSelect, container.dataset.afTableColumnsContainer); AfTableQuestionConfig.#bindItemtypeVisibility(newRow); AfTableQuestionConfig.#bindPatternVisibility(newRow); + AfTableQuestionConfig.#bindEmbeddedQuestionConfig(newRow, true); } static #initNewColumnSelect(select, rand) { @@ -166,6 +168,184 @@ export class AfTableQuestionConfig { update(); } + static #bindEmbeddedQuestionConfig(columnRow, loadImmediately = false) { + const typeSelect = columnRow.querySelector('select[name*="[question_type]"]'); + const target = columnRow.querySelector('[data-af-column-question-config-container]'); + if (!typeSelect || !target) { return; } + + const setLayoutState = () => { + const hasConfig = target.querySelector('[name]') !== null; + target.classList.toggle('d-none', !hasConfig); + columnRow.classList.toggle('af-has-embedded-config', hasConfig); + }; + + const cancelPendingRequest = () => { + target.dataset.afEmbeddedRequestId = String( + Number(target.dataset.afEmbeddedRequestId || 0) + 1, + ); + }; + + const clearConfig = () => { + cancelPendingRequest(); + target.replaceChildren(); + target.classList.add('d-none'); + columnRow.classList.remove('af-has-embedded-config'); + }; + + const reload = async ({ keepBlock = false } = {}) => { + const container = columnRow.closest('[data-af-table-columns-container]'); + const rows = Array.from(container?.querySelectorAll('[data-af-table-column]') ?? []); + const columnIndex = Math.max(0, rows.indexOf(columnRow)); + const requestId = String(Number(target.dataset.afEmbeddedRequestId || 0) + 1); + target.dataset.afEmbeddedRequestId = requestId; + + const payload = new FormData(); + payload.append('type', typeSelect.value); + payload.append('column_index', String(columnIndex)); + + if (keepBlock) { + const block = target.querySelector('[name$="[block_id]"]'); + if (block) { + payload.append('question_extra_data[block_id]', block.value); + } + } + + target.setAttribute('aria-busy', 'true'); + try { + let html; + + if (window.$?.ajax) { + // GLPI configures jQuery globally to add the current CSRF + // token to POST requests. Native fetch does not receive that + // configuration and is rejected by the firewall with 403. + html = await window.$.ajax({ + url: `${CFG_GLPI.root_doc}/plugins/advancedforms/TableColumnQuestionConfig`, + method: 'POST', + data: payload, + processData: false, + contentType: false, + dataType: 'html', + }); + } else { + const response = await fetch( + `${CFG_GLPI.root_doc}/plugins/advancedforms/TableColumnQuestionConfig`, + { + method: 'POST', + body: payload, + credentials: 'same-origin', + }, + ); + + if (!response.ok) { + clearConfig(); + return; + } + + html = await response.text(); + } + + if (target.dataset.afEmbeddedRequestId !== requestId) { return; } + + target.innerHTML = html; + setLayoutState(); + AfTableQuestionConfig.#bindEmbeddedQuestionConfig(columnRow); + } catch { + clearConfig(); + } finally { + if (target.dataset.afEmbeddedRequestId === requestId) { + target.removeAttribute('aria-busy'); + } + } + }; + + const triggerAutosave = control => { + if (!control) { return; } + control.dataset.afEmbeddedCommit = '1'; + if (window.$) { + window.$(control).trigger('change'); + } else { + control.dispatchEvent(new Event('change', { bubbles: true })); + } + }; + + const onTypeChange = async event => { + if (typeSelect.dataset.afEmbeddedCommit === '1') { + delete typeSelect.dataset.afEmbeddedCommit; + return; + } + + const container = columnRow.closest('[data-af-table-columns-container]'); + const columnIndex = Array.from( + container?.querySelectorAll('[data-af-table-column]') ?? [], + ).indexOf(columnRow); + + event.preventDefault(); + event.stopImmediatePropagation(); + clearConfig(); + await reload(); + + // GLPI may replace the complete question editor during autosave. + // Start observing before triggering autosave so the replacement + // cannot happen between those two operations. + const questionEditor = columnRow.closest( + '[data-glpi-form-editor-question]', + ) ?? container; + + if (!questionEditor) { + triggerAutosave(typeSelect); + return; + } + + const selectedType = typeSelect.value; + const observer = new MutationObserver(() => { + const currentContainer = questionEditor.querySelector( + '[data-af-table-columns-container]', + ); + const currentRows = Array.from( + currentContainer?.querySelectorAll('[data-af-table-column]') ?? [], + ); + const currentRow = currentRows[columnIndex]; + if (!currentRow || currentRow === columnRow) { + return; + } + + const currentType = currentRow.querySelector( + 'select[name*="[question_type]"]', + ); + if (!currentType || currentType.value !== selectedType) { + return; + } + + observer.disconnect(); + AfTableQuestionConfig.#bindEmbeddedQuestionConfig(currentRow, true); + }); + + observer.observe(questionEditor, { + childList: true, + subtree: true, + }); + + triggerAutosave(typeSelect); + + // Avoid leaving an observer alive if autosave fails or is cancelled. + window.setTimeout(() => observer.disconnect(), 10000); + }; + + const $type = window.$ ? window.$(typeSelect) : null; + if ($type) { + $type.off('.afEmbeddedType'); + $type.on('change.afEmbeddedType', onTypeChange); + } else if (!typeSelect.dataset.afEmbeddedNativeBound) { + typeSelect.dataset.afEmbeddedNativeBound = '1'; + typeSelect.addEventListener('change', onTypeChange, { capture: true }); + } + + setLayoutState(); + if (loadImmediately && typeSelect.value && !target.querySelector('[name]')) { + void reload(); + } + } + static #initItemtypeSelect(select, rand) { if (!select || !window.setupAdaptDropdown) { return; } if (!select.id) { diff --git a/src/Controller/TableColumnQuestionConfigController.php b/src/Controller/TableColumnQuestionConfigController.php new file mode 100644 index 0000000..c60c9f0 --- /dev/null +++ b/src/Controller/TableColumnQuestionConfigController.php @@ -0,0 +1,54 @@ +request->get('type', ''); + $column_index = $request->request->getInt('column_index', 0); + $payload = $request->request->all(); + $extra_data = $payload['question_extra_data'] ?? []; + + $renderer = new TableQuestion(); + $html = $renderer->renderEmbeddedQuestionConfiguration( + $type, + max(0, $column_index), + is_array($extra_data) ? $extra_data : [], + ); + + return new Response( + $html, + Response::HTTP_OK, + ['Content-Type' => 'text/html; charset=UTF-8'], + ); + } +} diff --git a/src/Model/QuestionType/TableQuestion.php b/src/Model/QuestionType/TableQuestion.php index 84889cf..584da15 100644 --- a/src/Model/QuestionType/TableQuestion.php +++ b/src/Model/QuestionType/TableQuestion.php @@ -49,6 +49,7 @@ use Glpi\Form\QuestionType\AbstractQuestionTypeShortAnswer; use Glpi\Form\QuestionType\QuestionTypeCategoryInterface; use Glpi\Form\QuestionType\QuestionTypeInterface; +use Glpi\Form\QuestionType\QuestionTypeEmbeddableInterface; use Glpi\Form\QuestionType\QuestionTypeItem; use Glpi\Form\QuestionType\QuestionTypeItemDropdown; use Glpi\Form\QuestionType\QuestionTypeRequestType; @@ -133,6 +134,17 @@ public function validateExtraDataInput(array $input): bool return false; } + $column_extra_data = $col[TableQuestionConfig::COL_EXTRA_DATA] ?? []; + if (!is_array($column_extra_data)) { + return false; + } + + $question_type = $this->getQuestionTypeInstance($fqcn); + + if ($column_extra_data !== [] && (!$question_type || !$question_type->validateExtraDataInput($column_extra_data))) { + return false; + } + $pattern = $col[TableQuestionConfig::COL_PATTERN] ?? ''; if (!is_string($pattern)) { return false; @@ -164,18 +176,32 @@ public function validateExtraDataInput(array $input): bool public function prepareExtraData(array $input): array { $columns = array_values(array_map( - static function (mixed $col): array { + function (mixed $col): array { $col = is_array($col) ? $col : []; $name = $col[TableQuestionConfig::COL_NAME] ?? ''; $type = $col[TableQuestionConfig::COL_QUESTION_TYPE] ?? ''; $itemtype = $col[TableQuestionConfig::COL_ITEMTYPE] ?? ''; $pattern = $col[TableQuestionConfig::COL_PATTERN] ?? ''; + $extra_data = is_array($col[TableQuestionConfig::COL_EXTRA_DATA] ?? null) + ? $col[TableQuestionConfig::COL_EXTRA_DATA] + : []; + $fqcn = is_scalar($type) ? (string) $type : ''; + $question_type = $this->getQuestionTypeInstance($fqcn); + if ($question_type && $extra_data !== []) { + if ($question_type->validateExtraDataInput($extra_data)) { + $extra_data = $question_type->prepareExtraData($extra_data); + } else { + $extra_data = []; + } + } + return [ TableQuestionConfig::COL_NAME => is_scalar($name) ? (string) $name : '', - TableQuestionConfig::COL_QUESTION_TYPE => is_scalar($type) ? (string) $type : '', + TableQuestionConfig::COL_QUESTION_TYPE => $fqcn, TableQuestionConfig::COL_REQUIRED => (bool) ($col[TableQuestionConfig::COL_REQUIRED] ?? false), TableQuestionConfig::COL_ITEMTYPE => is_scalar($itemtype) ? (string) $itemtype : '', TableQuestionConfig::COL_PATTERN => is_scalar($pattern) ? (string) $pattern : '', + TableQuestionConfig::COL_EXTRA_DATA => $extra_data, ]; }, array_filter((array) ($input[TableQuestionConfig::COLUMNS] ?? []), is_array(...)), @@ -270,7 +296,7 @@ public function validateAnswer(Question $question, mixed $answer): ValidationRes foreach ($required_columns as $index => $name) { $value = $row['col_' . $index] ?? ''; - if (!is_scalar($value) || (string) $value === '') { + if (!$this->cellHasValue($value)) { $result->addError($question, sprintf( __('Row %1$s: the column "%2$s" is required.', 'advancedforms'), $row_number, @@ -350,7 +376,7 @@ private function stripRegexDelimiters(string $pattern): string private function rowHasValue(array $row): bool { foreach ($row as $value) { - if ($value !== '' && $value !== null) { + if ($this->cellHasValue($value)) { return true; } } @@ -358,6 +384,20 @@ private function rowHasValue(array $row): bool return false; } + private function cellHasValue(mixed $value): bool + { + if (is_array($value)) { + foreach ($value as $nested) { + if ($this->cellHasValue($nested)) { + return true; + } + } + return false; + } + + return $value !== '' && $value !== null; + } + #[Override] public function transformConditionValueForComparisons(mixed $value, ?JsonFieldInterface $question_config): string|array { @@ -372,15 +412,28 @@ public function transformConditionValueForComparisons(mixed $value, ?JsonFieldIn } foreach ($row as $cell) { - if (is_scalar($cell) && (string) $cell !== '') { - $flat[] = (string) $cell; - } + $this->flattenCellValue($cell, $flat); } } return $flat; } + /** @param list $flat */ + private function flattenCellValue(mixed $value, array &$flat): void + { + if (is_array($value)) { + foreach ($value as $nested) { + $this->flattenCellValue($nested, $flat); + } + return; + } + + if (is_scalar($value) && (string) $value !== '') { + $flat[] = (string) $value; + } + } + #[Override] public function formatRawAnswer(mixed $answer, Question $question): string { @@ -446,8 +499,17 @@ private function buildDisplayRows(array $rows, array $columns): array $display_rows = []; foreach ($rows as $row) { $cells = []; - foreach (array_keys($columns) as $index) { - $raw = $row['col_' . $index] ?? ''; + foreach ($columns as $index => $column) { + $raw = $row['col_' . $index] ?? ''; + $extra_data = is_array($column[TableQuestionConfig::COL_EXTRA_DATA] ?? null) + ? $column[TableQuestionConfig::COL_EXTRA_DATA] + : []; + + if ($extra_data !== []) { + $cells[] = $this->formatEmbeddedCellValue($raw, $column, $index); + continue; + } + $value = is_scalar($raw) ? (string) $raw : ''; $cells[] = $value === '' ? '' : ($label_maps[$index][$value] ?? $value); } @@ -458,6 +520,29 @@ private function buildDisplayRows(array $rows, array $columns): array return $display_rows; } + private function formatEmbeddedCellValue(mixed $value, array $column, int $index): string + { + if (!$this->cellHasValue($value)) { + return ''; + } + + $fqcn = (string) ($column[TableQuestionConfig::COL_QUESTION_TYPE] ?? ''); + $type = $this->getQuestionTypeInstance($fqcn); + if (!$type instanceof QuestionTypeInterface) { + return is_scalar($value) ? (string) $value : json_encode($value); + } + + $question = $this->buildEmbeddedQuestion( + $fqcn, + is_array($column[TableQuestionConfig::COL_EXTRA_DATA] ?? null) + ? $column[TableQuestionConfig::COL_EXTRA_DATA] + : [], + 'display-' . $index, + ); + + return $type->formatRawAnswer($value, $question); + } + /** * @param list> $rows * @return list Unique non-empty stored values found in the column. @@ -709,6 +794,17 @@ public function renderAdvancedConfigurationTemplate(?Question $question): string ), ]; + $column_configs = []; + foreach ($config->getColumns() as $index => $column) { + $column_configs[$index] = $this->renderEmbeddedQuestionConfiguration( + (string) ($column[TableQuestionConfig::COL_QUESTION_TYPE] ?? ''), + $index, + is_array($column[TableQuestionConfig::COL_EXTRA_DATA] ?? null) + ? $column[TableQuestionConfig::COL_EXTRA_DATA] + : [], + ); + } + $twig = TemplateRenderer::getInstance(); return $twig->render( '@advancedforms/editor/question_types/table_config.html.twig', @@ -723,11 +819,13 @@ public function renderAdvancedConfigurationTemplate(?Question $question): string 'COL_REQUIRED' => TableQuestionConfig::COL_REQUIRED, 'COL_ITEMTYPE' => TableQuestionConfig::COL_ITEMTYPE, 'COL_PATTERN' => TableQuestionConfig::COL_PATTERN, + 'COL_EXTRA_DATA' => TableQuestionConfig::COL_EXTRA_DATA, 'MIN_ROWS' => TableQuestionConfig::MIN_ROWS, 'MAX_ROWS' => TableQuestionConfig::MAX_ROWS, 'itemtype_options' => $itemtype_options, 'short_answer_fqcns' => $short_answer_fqcns, 'short_answer_fqcns_json' => json_encode($short_answer_fqcns), + 'column_configs' => $column_configs, ], ); } @@ -736,28 +834,93 @@ public function renderAdvancedConfigurationTemplate(?Question $question): string public function renderEndUserTemplate(Question $question): string { $config = $this->loadConfig($question); + $initial_rows = []; + for ($row_index = 0; $row_index < $config->getMinRows(); $row_index++) { + $initial_rows[] = $this->renderEndUserRow($question, $row_index); + } + + return TemplateRenderer::getInstance()->render( + '@advancedforms/table_end_user.html.twig', + [ + 'question' => $question, + 'config' => $config, + 'initial_rows' => $initial_rows, + 'ajax_limit_count' => $this->ajaxLimitCount(), + ], + ); + } + + public function renderEndUserRow( + Question $question, + int $row_index, + string $render_identity = '', + ): string + { + $config = $this->loadConfig($question); + if ($row_index < 0 || $row_index >= $config->getMaxRows()) { + return ''; + } + + $render_identity = $render_identity !== '' ? $render_identity : 'initial-' . $row_index; + $cell_map = $this->buildEndUserCellMap($question, $config, $row_index, $render_identity); + + return TemplateRenderer::getInstance()->render( + '@advancedforms/table_end_user_row.html.twig', + [ + 'question' => $question, + 'config' => $config, + 'row_index' => $row_index, + 'column_cell_map' => $cell_map, + ], + ); + } + /** @return array> */ + private function buildEndUserCellMap( + Question $question, + TableQuestionConfig $config, + int $row_index, + string $render_identity, + ): array { $type_instances = []; foreach (QuestionTypesManager::getInstance()->getQuestionTypes() as $type) { $type_instances[$type::class] = $type; } - $cell_map = []; - $user_options = null; - $device_options = null; - $glpi_item_options = []; // keyed by itemtype FQCN to avoid duplicate DB queries + $cell_map = []; + $input_base = $question->getEndUserInputName(); + $user_options = null; + $device_options = null; + $glpi_item_options = []; 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]; + $column_extra_data = is_array($col[TableQuestionConfig::COL_EXTRA_DATA] ?? null) + ? $col[TableQuestionConfig::COL_EXTRA_DATA] + : []; + + if ( + $column_extra_data !== [] + && $type instanceof QuestionTypeInterface + && !$this->usesNativeTableColumnRendering($fqcn, $type) + ) { + $cell_map[$index] = [ + 'mode' => 'embedded', + 'html' => $this->renderEmbeddedEndUserControl( + $fqcn, + $col, + sprintf('%s[%d][col_%d]', $input_base, $row_index, $index), + sprintf('%s-%d', $render_identity, $index), + ), + ]; + } elseif (is_a($fqcn, AbstractQuestionTypeActors::class, true)) { + $user_options ??= $this->buildUserOptions(); + $cell_map[$index] = ['mode' => 'select', 'options' => $user_options]; } elseif (is_a($fqcn, QuestionTypeUserDevice::class, true)) { - $device_options ??= $this->buildUserDeviceOptions(); - $cell_map[$index] = ['mode' => 'select', 'options' => $device_options]; + $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); @@ -766,7 +929,7 @@ public function renderEndUserTemplate(Question $question): string $cell_map[$index] = ['mode' => 'input', 'input_type' => 'text']; } } else { - $cell_map[$index] = $this->getCellInfo($fqcn, $type); + $cell_map[$index] = $this->getCellInfo($fqcn, $type); } $pattern = $col[TableQuestionConfig::COL_PATTERN] ?? ''; @@ -775,16 +938,7 @@ public function renderEndUserTemplate(Question $question): string } } - $twig = TemplateRenderer::getInstance(); - return $twig->render( - '@advancedforms/table_end_user.html.twig', - [ - 'question' => $question, - 'config' => $config, - 'column_cell_map' => $cell_map, - 'ajax_limit_count' => $this->ajaxLimitCount(), - ], - ); + return $cell_map; } /** @@ -985,6 +1139,105 @@ private function buildGlpiItemtypeOptions(string $itemtype): array return $options; } + public function renderEmbeddedQuestionConfiguration( + string $fqcn, + int $column_index, + array $extra_data = [], + ): string { + $type = $this->getQuestionTypeInstance($fqcn); + if ( + !$type instanceof QuestionTypeEmbeddableInterface + || $this->usesNativeTableColumnRendering($fqcn, $type) + ) { + return ''; + } + + if ($extra_data !== [] && !$type->validateExtraDataInput($extra_data)) { + $extra_data = []; + } + + try { + $question = $this->buildEmbeddedQuestion($fqcn, $extra_data, $column_index); + $extra_data_prefix = sprintf( + 'extra_data[columns][%d][%s]', + $column_index, + TableQuestionConfig::COL_EXTRA_DATA, + ); + + return $type->renderEmbeddedAdministrationTemplate( + $question, + $extra_data_prefix, + ); + } catch (\Throwable) { + return ''; + } + } + + private function usesNativeTableColumnRendering( + string $fqcn, + QuestionTypeInterface $type, + ): bool { + return is_a($fqcn, AbstractQuestionTypeActors::class, true) + || is_a($fqcn, QuestionTypeUserDevice::class, true) + || is_a($fqcn, QuestionTypeItem::class, true) + || is_a($fqcn, AbstractQuestionTypeSelectable::class, true) + || $type instanceof AbstractQuestionTypeShortAnswer; + } + + private function getQuestionTypeInstance(string $fqcn): ?QuestionTypeInterface + { + foreach (QuestionTypesManager::getInstance()->getQuestionTypes() as $type) { + if (ltrim($type::class, '\\') === ltrim($fqcn, '\\')) { + return $type; + } + } + + return null; + } + + private function buildEmbeddedQuestion( + string $fqcn, + array $extra_data, + int|string $identity, + ): Question { + $question = new Question(); + $question->fields = [ + 'id' => 0, + 'uuid' => 'advancedforms-table-' . $identity, + 'type' => $fqcn, + 'extra_data' => json_encode($extra_data), + 'default_value' => null, + 'is_mandatory' => 0, + ]; + + return $question; + } + + private function renderEmbeddedEndUserControl( + string $fqcn, + array $column, + string $target_name, + int|string $identity, + ): string { + $type = $this->getQuestionTypeInstance($fqcn); + if (!$type instanceof QuestionTypeEmbeddableInterface) { + return ''; + } + + $question = $this->buildEmbeddedQuestion( + $fqcn, + is_array($column[TableQuestionConfig::COL_EXTRA_DATA] ?? null) + ? $column[TableQuestionConfig::COL_EXTRA_DATA] + : [], + $identity, + ); + + return $type->renderEmbeddedEndUserTemplate( + $question, + $target_name, + ); + } + private function loadConfig(Question $question): TableQuestionConfig { $decoded = []; diff --git a/src/Model/QuestionType/TableQuestionConfig.php b/src/Model/QuestionType/TableQuestionConfig.php index 03d90f1..496ae26 100644 --- a/src/Model/QuestionType/TableQuestionConfig.php +++ b/src/Model/QuestionType/TableQuestionConfig.php @@ -55,8 +55,10 @@ public const COL_PATTERN = 'pattern'; + public const COL_EXTRA_DATA = 'question_extra_data'; + /** - * @param array $columns + * @param array}> $columns */ public function __construct( private array $columns = [], @@ -66,7 +68,7 @@ public function __construct( /** * @param array{ - * columns?: array, + * columns?: array}>, * min_rows?: int, * max_rows?: int * } $data @@ -81,6 +83,9 @@ public static function jsonDeserialize(array $data): self self::COL_REQUIRED => (bool) ($col[self::COL_REQUIRED] ?? false), self::COL_ITEMTYPE => (string) ($col[self::COL_ITEMTYPE] ?? ''), self::COL_PATTERN => (string) ($col[self::COL_PATTERN] ?? ''), + self::COL_EXTRA_DATA => is_array($col[self::COL_EXTRA_DATA] ?? null) + ? $col[self::COL_EXTRA_DATA] + : [], ], array_filter($data[self::COLUMNS] ?? [], is_array(...)), )); @@ -97,7 +102,7 @@ public static function jsonDeserialize(array $data): self /** * @return array{ - * columns: array, + * columns: array}>, * min_rows: int, * max_rows: int * } @@ -112,7 +117,7 @@ public function jsonSerialize(): array ]; } - /** @return array */ + /** @return array}> */ public function getColumns(): array { return $this->columns; diff --git a/templates/editor/question_types/table_config.html.twig b/templates/editor/question_types/table_config.html.twig index f5e519c..84e834d 100644 --- a/templates/editor/question_types/table_config.html.twig +++ b/templates/editor/question_types/table_config.html.twig @@ -56,6 +56,29 @@ > + + {% endfor %} +
+ {{ column_configs[index]|default('')|raw }} +