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 @@