diff --git a/CRM/Core/BAO/CustomField.php b/CRM/Core/BAO/CustomField.php index b42cc14e5899..916172cf0ad6 100644 --- a/CRM/Core/BAO/CustomField.php +++ b/CRM/Core/BAO/CustomField.php @@ -2064,9 +2064,9 @@ protected static function prepareCreate($params) { // An option_type of 2 would be a 'message' from the form layer not to handle // the option_values key. If not set then it is not ignored. $optionsType = (int) ($params['option_type'] ?? 0); - if (($optionsType !== 2 && empty($params['id'])) - && (empty($params['option_group_id']) && !empty($params['option_value']) - ) + if ($optionsType === 1 && + empty($params['option_group_id']) && + !empty($params['option_value']) ) { // first create an option group for this custom group $customGroupTitle = CRM_Core_DAO::getFieldValue('CRM_Core_DAO_CustomGroup', $params['custom_group_id'], 'title'); diff --git a/CRM/Custom/Form/Field.php b/CRM/Custom/Form/Field.php index c87be7678bf5..6b683875370b 100644 --- a/CRM/Custom/Form/Field.php +++ b/CRM/Custom/Form/Field.php @@ -110,6 +110,9 @@ public function preProcess() { CRM_Core_Error::statusBounce("You cannot add or edit fields in a reserved custom field-set."); } + // Add crm-options-repeat web component. FIXME: need an autoloader for web components. + \Civi::resources()->addScriptFile('civicrm', 'js/CrmOptionsRepeat.js'); + if ($this->_gid) { $url = CRM_Utils_System::url('civicrm/admin/custom/group/field', "reset=1&gid={$this->_gid}" @@ -185,13 +188,6 @@ public function setDefaultValues() { } } - // Set defaults for option values. - for ($i = 1; $i <= self::NUM_OPTION; $i++) { - $defaults['option_status[' . $i . ']'] = 1; - $defaults['option_weight[' . $i . ']'] = $i; - $defaults['option_value[' . $i . ']'] = $i; - } - return $defaults; } @@ -291,9 +287,7 @@ public function buildQuickForm() { $element = &$this->addRadio('option_type', ts('Option Type'), $optionTypes, - [ - 'onclick' => "showOptionSelect();", - ], '
' + [], '
' ); // if empty option group freeze the option type. if ($emptyOptGroup) { @@ -319,52 +313,8 @@ public function buildQuickForm() { $this->add('hidden', 'filter_selected', 'Group', ['id' => 'filter_selected']); - // form fields of Custom Option rows - $defaultOption = []; - $_showHide = new CRM_Core_ShowHideBlocks(); - for ($i = 1; $i <= self::NUM_OPTION; $i++) { - - //the show hide blocks - $showBlocks = 'optionField_' . $i; - if ($i > 2) { - $_showHide->addHide($showBlocks); - if ($i == self::NUM_OPTION) { - $_showHide->addHide('additionalOption'); - } - } - else { - $_showHide->addShow($showBlocks); - } - - $optionAttributes = CRM_Core_DAO::getAttribute('CRM_Core_DAO_OptionValue'); - // label - $this->add('text', 'option_label[' . $i . ']', ts('Label'), - $optionAttributes['label'] - ); - - // value - $this->add('text', 'option_value[' . $i . ']', ts('Value'), - $optionAttributes['value'] - ); - - // weight - $this->add('number', "option_weight[$i]", ts('Order'), - $optionAttributes['weight'] - ); - - // is active ? - $this->add('checkbox', "option_status[$i]", ts('Active?')); - - $defaultOption[$i] = NULL; - - //for checkbox handling of default option - $this->add('checkbox', "default_checkbox_option[$i]", NULL); - } - - //default option selection - $this->addRadio('default_option', NULL, $defaultOption); - - $_showHide->addToTemplate(); + // Receives json from CrmOptionsRepeat element + $this->add('text', 'option_values'); // text length for alpha numeric data types $this->add('number', @@ -512,8 +462,6 @@ public static function formRule($fields, $files, $self) { $errors = []; - self::clearEmptyOptions($fields); - //validate field label as well as name. $title = $fields['label']; $name = CRM_Utils_String::munge($title, '_', 64); @@ -633,124 +581,46 @@ public static function formRule($fields, $files, $self) { } } - /** Check the option values entered - * Appropriate values are required for the selected datatype - * Incomplete row checking is also required. - */ - $_flagOption = $_rowError = 0; - $_showHide = new CRM_Core_ShowHideBlocks(); $htmlType = $fields['html_type']; if (isset($fields['option_type']) && $fields['option_type'] == 1) { - //capture duplicate Custom option values - if (!empty($fields['option_value'])) { - $countValue = count($fields['option_value']); - $uniqueCount = count(array_unique($fields['option_value'])); + if (!empty($fields['option_values'])) { + $optionValues = json_decode($fields['option_values'], TRUE); - if ($countValue > $uniqueCount) { + // Check for duplicate option values + $countValue = count($optionValues); - $start = 1; - while ($start < self::NUM_OPTION) { - $nextIndex = $start + 1; - while ($nextIndex <= self::NUM_OPTION) { - if ($fields['option_value'][$start] == $fields['option_value'][$nextIndex] && - strlen($fields['option_value'][$nextIndex]) - ) { - $errors['option_value[' . $start . ']'] = ts('Duplicate Option values'); - $errors['option_value[' . $nextIndex . ']'] = ts('Duplicate Option values'); - $_flagOption = 1; - } - $nextIndex++; - } - $start++; - } + $uniqueCount = count(array_unique(array_column($optionValues, 'value'))); + if ($countValue > $uniqueCount) { + $errors['option_values'] = ts('Duplicate Option values'); } - } - - //capture duplicate Custom Option label - if (!empty($fields['option_label'])) { - $countValue = count($fields['option_label']); - $uniqueCount = count(array_unique($fields['option_label'])); + $uniqueCount = count(array_unique(array_column($optionValues, 'label'))); if ($countValue > $uniqueCount) { - $start = 1; - while ($start < self::NUM_OPTION) { - $nextIndex = $start + 1; - while ($nextIndex <= self::NUM_OPTION) { - if ($fields['option_label'][$start] == $fields['option_label'][$nextIndex] && - !empty($fields['option_label'][$nextIndex]) - ) { - $errors['option_label[' . $start . ']'] = ts('Duplicate Option label'); - $errors['option_label[' . $nextIndex . ']'] = ts('Duplicate Option label'); - $_flagOption = 1; - } - $nextIndex++; - } - $start++; - } + $errors['option_values'] = ts('Duplicate Option labels'); } - } - for ($i = 1; $i <= self::NUM_OPTION; $i++) { - if (!$fields['option_label'][$i]) { - if ($fields['option_value'][$i]) { - $errors['option_label[' . $i . ']'] = ts('Option label cannot be empty'); - $_flagOption = 1; + foreach ($optionValues as $optionValue) { + if (empty($optionValue['label'])) { + $errors['option_values'] = ts('Option label cannot be empty'); } - else { - $_emptyRow = 1; - } - } - else { - if (!strlen(trim($fields['option_value'][$i]))) { - if (!$fields['option_value'][$i]) { - $errors['option_value[' . $i . ']'] = ts('Option value cannot be empty'); - $_flagOption = 1; - } + if (empty($optionValue['value'])) { + $errors['option_values'] = ts('Option value cannot be empty'); } - } - if ($fields['option_value'][$i] && $dataType != 'String') { - if ($dataType == 'Int') { - if (!CRM_Utils_Rule::integer($fields['option_value'][$i])) { - $_flagOption = 1; - $errors['option_value[' . $i . ']'] = ts('Please enter a valid integer.'); - } + if ($dataType === 'Int' && !CRM_Utils_Rule::integer($optionValue['value'])) { + $errors['option_values'] = ts('Please enter a valid integer.'); } - elseif ($dataType == 'Money') { - if (!CRM_Utils_Rule::money($fields['option_value'][$i])) { - $_flagOption = 1; - $errors['option_value[' . $i . ']'] = ts('Please enter a valid money value.'); - } + elseif ($dataType === 'Money' && !CRM_Utils_Rule::money($optionValue['value'])) { + $errors['option_values'] = ts('Please enter a valid money value.'); } - else { - if (!CRM_Utils_Rule::numeric($fields['option_value'][$i])) { - $_flagOption = 1; - $errors['option_value[' . $i . ']'] = ts('Please enter a valid number.'); - } + elseif (!CRM_Utils_Rule::numeric($optionValue['value'])) { + $errors['option_values'] = ts('Please enter a valid number.'); } } - - $showBlocks = 'optionField_' . $i; - if ($_flagOption) { - $_showHide->addShow($showBlocks); - $_rowError = 1; - } - - if (!empty($_emptyRow)) { - $_showHide->addHide($showBlocks); - } - else { - $_showHide->addShow($showBlocks); - } - if ($i == self::NUM_OPTION) { - $hideBlock = 'additionalOption'; - $_showHide->addHide($hideBlock); - } - - $_flagOption = $_emptyRow = 0; } } + elseif (in_array($htmlType, self::$htmlTypesWithOptions) && !in_array($dataType, ['Boolean', 'Country', 'StateProvince', 'ContactReference', 'EntityReference']) ) { @@ -774,39 +644,6 @@ public static function formRule($fields, $files, $self) { } } - $assignError = new CRM_Core_Page(); - if ($_rowError) { - $_showHide->addToTemplate(); - $assignError->assign('optionRowError', $_rowError); - } - else { - if (isset($htmlType)) { - switch ($htmlType) { - case 'Radio': - case 'CheckBox': - case 'Select': - $_fieldError = 1; - $assignError->assign('fieldError', $_fieldError); - break; - - default: - $_fieldError = 0; - $assignError->assign('fieldError', $_fieldError); - } - } - - for ($idx = 1; $idx <= self::NUM_OPTION; $idx++) { - $showBlocks = 'optionField_' . $idx; - if (!empty($fields['option_label'][$idx])) { - $_showHide->addShow($showBlocks); - } - else { - $_showHide->addHide($showBlocks); - } - } - $_showHide->addToTemplate(); - } - // we can not set require and view at the same time. if (!empty($fields['is_required']) && !empty($fields['is_view'])) { $errors['is_view'] = ts('Can not set this field Required and View Only at the same time.'); @@ -822,7 +659,7 @@ public static function formRule($fields, $files, $self) { $optionQuery = "SELECT value FROM civicrm_option_value WHERE option_group_id = " . (int) $fields['option_group_id']; } else { - $options = array_map(['CRM_Core_DAO', 'escapeString'], array_filter($fields['option_value'], 'strlen')); + $options = array_map(['CRM_Core_DAO', 'escapeString'], array_column($optionValues, 'value')); $optionQuery = '"' . implode('","', $options) . '"'; } $table = CRM_Core_BAO_CustomGroup::getGroup(['id' => $self->_gid])['table_name']; @@ -845,7 +682,13 @@ public static function formRule($fields, $files, $self) { public function postProcess() { // store the submitted values in an array $params = $this->controller->exportValues($this->_name); - self::clearEmptyOptions($params); + if (!empty($params['option_values']) && $params['option_type'] == 1) { + $params['option_values'] = json_decode($params['option_values'], TRUE); + $params['option_group_id'] = NULL; + } + else { + $params['option_values'] = NULL; + } // Automatically disable 'is_search_range' if the field does not support it if (in_array($params['data_type'], ['Int', 'Float', 'Money', 'Date'])) { @@ -902,13 +745,15 @@ public function postProcess() { if ($this->_action & CRM_Core_Action::UPDATE) { $params['id'] = $this->_id; } - $customField = CRM_Core_BAO_CustomField::create($params); - $this->_id = $customField->id; + $customField = civicrm_api4('CustomField', 'save', [ + 'records' => [$params], + ])->single(); + $this->_id = $customField['id']; // reset the cache Civi::cache('fields')->flush(); - $msg = '

' . ts("Custom field '%1' has been saved.", [1 => $customField->label]) . '

'; + $msg = '

' . ts("Custom field '%1' has been saved.", [1 => $customField['label']]) . '

'; $buttonName = $this->controller->getButtonName(); $session = CRM_Core_Session::singleton(); @@ -926,22 +771,7 @@ public function postProcess() { $session->setStatus($msg, ts('Saved'), 'success'); // Add data when in ajax contect - $this->ajaxResponse['customField'] = $customField->toArray(); - } - - /** - * Removes value from fields with no label. - * - * This allows default values to be set in the form, but ignored in post-processing. - * - * @param array $fields - */ - public static function clearEmptyOptions(&$fields) { - foreach ($fields['option_label'] as $i => $label) { - if (!strlen(trim($label))) { - $fields['option_value'][$i] = ''; - } - } + $this->ajaxResponse['customField'] = $customField; } /** diff --git a/Civi/Api4/Action/CustomField/CustomFieldSaveTrait.php b/Civi/Api4/Action/CustomField/CustomFieldSaveTrait.php index 87803fbe2b59..73a045c54cf7 100644 --- a/Civi/Api4/Action/CustomField/CustomFieldSaveTrait.php +++ b/Civi/Api4/Action/CustomField/CustomFieldSaveTrait.php @@ -22,9 +22,7 @@ trait CustomFieldSaveTrait { */ protected function write(array $items) { foreach ($items as &$field) { - if (empty($field['id'])) { - self::formatOptionValues($field); - } + self::formatOptionValues($field); } return parent::write($items); } @@ -35,7 +33,7 @@ protected function write(array $items) { * @param array $field */ private static function formatOptionValues(array &$field): void { - $field['option_type'] = !empty($field['option_values']); + $field['option_type'] = (int) !empty($field['option_values']); if (!empty($field['option_values'])) { $weight = 0; $field['option_label'] = $field['option_value'] = $field['option_status'] = $field['option_weight'] = @@ -50,7 +48,7 @@ private static function formatOptionValues(array &$field): void { } $field['option_label'][] = $value['label'] ?? $value['name']; $field['option_name'][] = $value['name'] ?? NULL; - $field['option_value'][] = $value['id']; + $field['option_value'][] = $value['value'] ?? $value['id']; $field['option_status'][] = $value['is_active'] ?? 1; $field['option_weight'][] = $value['weight'] ?? ++$weight; $field['option_color'][] = $value['color'] ?? NULL; diff --git a/api/v3/CustomField.php b/api/v3/CustomField.php index 8e86ccccecd0..68456bf04f21 100644 --- a/api/v3/CustomField.php +++ b/api/v3/CustomField.php @@ -71,6 +71,9 @@ function civicrm_api3_custom_field_create(array $params): array { // because that odd behaviour is locked in via a test. $params['option_value'] = 1; } + if (!empty($params['option_value'])) { + $params['option_type'] ??= 1; + } $values = []; $customField = CRM_Core_BAO_CustomField::create($params); _civicrm_api3_object_to_array_unique_fields($customField, $values[$customField->id]); diff --git a/js/CrmOptionsRepeat.js b/js/CrmOptionsRepeat.js new file mode 100644 index 000000000000..032152c93144 --- /dev/null +++ b/js/CrmOptionsRepeat.js @@ -0,0 +1,161 @@ +class CrmOptionsRepeat extends HTMLElement { + constructor() { + super(); + this.fieldMap = new Map(); + } + + connectedCallback() { + // Ensure initialization happens after DOM is fully loaded + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', () => this.init()); + } + else { + this.init(); + } + } + + init() { + this.table = this.querySelector('table tbody'); + this.hiddenInput = this.querySelector(':scope > input[type="hidden"]'); + + // Get the template row and store it + this.templateRow = this.table.querySelector('tr').cloneNode(true); + + // Create field mapping from the template row + const templateInputs = this.templateRow.querySelectorAll('input'); + templateInputs.forEach((input, index) => { + const originalName = input.getAttribute('name'); + this.fieldMap.set(index, originalName); + input.removeAttribute('name'); + input.removeAttribute('id'); + if (input.type === 'radio') { + input.setAttribute('name', originalName + Math.random().toString(36).substring(2)); + } + }); + + // Initialize data from hidden input or create new array + this.data = []; + try { + if (this.hiddenInput.value) { + this.data = JSON.parse(this.hiddenInput.value); + } + } catch (e) { + } + + // Clear existing rows + this.table.innerHTML = ''; + + // Add initial rows from data or at least one row + if (this.data.length > 0) { + this.data.forEach(rowData => this.addRow(rowData)); + } else { + this.addRow(); + } + + // Add event listeners + this.addEventListener('click', (e) => { + const addButton = e.target.closest('.crm-options-repeat-add'); + if (addButton) { + e.preventDefault(); + this.addRow(); + } + const removeButton = e.target.closest('.crm-options-repeat-remove'); + if (removeButton) { + e.preventDefault(); + this.removeRow(removeButton.closest('tr')); + } + const sortButton = e.target.closest('.crm-options-repeat-sort'); + if (sortButton) { + e.preventDefault(); + this.sortByColumn(sortButton.closest('th')); + } + }); + + // Listen for input changes + this.addEventListener('input', () => this.updateData()); + + // Add sortable + if (this.templateRow.querySelector('.crm-draggable')) { + CRM.$(this.table).sortable({ + handle: '.crm-draggable', + containment: this, + update: () => this.updateData(), + }); + } + } + + removeRow(row) { + if (this.table.querySelectorAll('tr').length > 1) { + row.remove(); + this.updateData(); + } + } + + sortByColumn(sortHeader) { + const columnIndex = Array.from(sortHeader.parentNode.children).indexOf(sortHeader); + + // Sort rows based on the clicked column's values + const rows = Array.from(this.table.querySelectorAll('tr')); + const sortedRows = rows.sort((a, b) => { + const cellA = a.children[columnIndex]?.querySelector('input')?.value || ''; + const cellB = b.children[columnIndex]?.querySelector('input')?.value || ''; + + // Sort numerically or alphabetically, if applicable + if (!isNaN(cellA) && !isNaN(cellB)) { + return Number(cellA) - Number(cellB); + } + return cellA.localeCompare(cellB, undefined, {numeric: true}); + }); + + // Reattach sorted rows to the table + this.table.innerHTML = ''; + sortedRows.forEach(row => this.table.appendChild(row)); + + // Update data after sorting + this.updateData(); + } + + addRow(data = {}) { + const newRow = this.templateRow.cloneNode(true); + + // Set values if data exists + newRow.querySelectorAll('input').forEach((input, index) => { + const fieldName = this.fieldMap.get(index); + + if (data[fieldName] !== undefined) { + if (input.type === 'checkbox' || input.type === 'radio') { + input.checked = data[fieldName]; + } else { + input.value = data[fieldName]; + } + } + else if (input.value && !isNaN(input.value)) { + // Iterate numeric default value + input.value = this.table.querySelectorAll('tr').length + (+input.value); + } + }); + + this.table.appendChild(newRow); + this.updateData(); + } + + updateData() { + const rows = Array.from(this.table.querySelectorAll('tr')); + this.data = rows.map(row => { + const rowData = {}; + row.querySelectorAll('input').forEach((input, index) => { + const fieldName = this.fieldMap.get(index); + if (input.type === 'checkbox' || input.type === 'radio') { + rowData[fieldName] = input.checked; + } else { + rowData[fieldName] = input.value; + } + }); + return rowData; + }); + this.hiddenInput.value = JSON.stringify(this.data); + } +} + +// Register the custom element +customElements.define('crm-options-repeat', CrmOptionsRepeat); diff --git a/templates/CRM/Custom/Form/Optionfields.tpl b/templates/CRM/Custom/Form/Optionfields.tpl index 1cdeb5c30522..ae424cda7192 100644 --- a/templates/CRM/Custom/Form/Optionfields.tpl +++ b/templates/CRM/Custom/Form/Optionfields.tpl @@ -20,74 +20,85 @@ -
{ts}Multiple Choice Options{/ts} - - {ts}Enter up to ten (10) multiple choice options in this table (click 'another choice' for each additional choice). If you need more than ten options, you can create an unlimited number of additional choices using the Edit Multiple Choice Options link after saving this new field. If desired, you can mark one of the choices as the default choice. The option 'label' is displayed on the form, while the option 'value' is stored in the contact record. The label and value may be the same or different. Inactive options are hidden when the field is presented.{/ts} - - {strip} - - - - - - - - - +
+ {ts}Multiple Choice Options{/ts} + + {$form.option_values.html|crmReplace:'type':'hidden'} +
  {ts}Default{/ts} {ts}Label{/ts} {ts}Value{/ts} {ts}Order{/ts} {ts}Active?{/ts}
+ + + + + - {section name=rowLoop start=1 loop=12} - {assign var=index value=$smarty.section.rowLoop.index} - - - - - - - - - {/section} -
{ts}Default{/ts} + {ts}Label{/ts} + + + {ts}Sort by label{/ts} + +
- {if $index GT 1} - - {/if} - - - - {$form.option_label.$index.html} {$form.option_value.$index.html} {$form.option_weight.$index.html} {$form.option_status.$index.html}
- - - {ts}If you need additional options - you can add them after you Save your current entries.{/ts} - - {/strip} - -
+ + {ts}Value{/ts} + + + {ts}Sort by value{/ts} + + + {ts}Enabled{/ts} + + + + + + + + + {ts}Change order{/ts} + + + + + + + + + + {ts}Delete{/ts} + + + + + + + + + + + + + +