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 }} +