diff --git a/SQL/0000-00-03-ConfigTables.sql b/SQL/0000-00-03-ConfigTables.sql index 3de025fcc3..9f1cdfd8ec 100644 --- a/SQL/0000-00-03-ConfigTables.sql +++ b/SQL/0000-00-03-ConfigTables.sql @@ -8,7 +8,7 @@ CREATE TABLE `ConfigSettings` ( `Description` varchar(255) DEFAULT NULL, `Visible` tinyint(1) DEFAULT '0', `AllowMultiple` tinyint(1) DEFAULT '0', - `DataType` ENUM('text','boolean','email','instrument','textarea','scan_type','date_format','lookup_center','path','web_path', 'log_level') DEFAULT NULL, + `DataType` ENUM('text','boolean','email','instrument','textarea','scan_type','date_format','lookup_center','path','web_path', 'log_level','mapping') DEFAULT NULL, `Parent` int(11) DEFAULT NULL, `Label` varchar(255) DEFAULT NULL, `OrderNumber` int(11) DEFAULT NULL, @@ -41,6 +41,19 @@ CREATE TABLE `Config` ( ON UPDATE CASCADE ) ENGINE=InnoDB DEFAULT CHARSET=utf8; +CREATE TABLE `ConfigMappings` ( + `ID` int(11) NOT NULL AUTO_INCREMENT, + `ConfigID` int(11) NOT NULL, + `Value` text, + PRIMARY KEY (`ID`), + KEY `fk_ConfigMappings_ConfigID_idx` (`ConfigID`), + CONSTRAINT `fk_ConfigMappings_ConfigID` + FOREIGN KEY (`ConfigID`) + REFERENCES `Config` (`ID`) + ON DELETE CASCADE + ON UPDATE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8; + -- -- Filling ConfigSettings table -- diff --git a/SQL/9999-99-99-drop_tables.sql b/SQL/9999-99-99-drop_tables.sql index c66e84c555..e8d6576120 100644 --- a/SQL/9999-99-99-drop_tables.sql +++ b/SQL/9999-99-99-drop_tables.sql @@ -91,6 +91,7 @@ DROP TABLE IF EXISTS `help`; -- 0000-00-03-ConfigTables.sql DROP TABLE IF EXISTS `ConfigI18n`; +DROP TABLE IF EXISTS `ConfigMappings`; DROP TABLE IF EXISTS `Config`; DROP TABLE IF EXISTS `ConfigSettings`; DROP TABLE IF EXISTS `menu_categories`; diff --git a/SQL/New_patches/2026-05-31_config-mapping.sql b/SQL/New_patches/2026-05-31_config-mapping.sql new file mode 100644 index 0000000000..a8ea519cb0 --- /dev/null +++ b/SQL/New_patches/2026-05-31_config-mapping.sql @@ -0,0 +1,15 @@ +CREATE TABLE `ConfigMappings` ( + `ID` int(11) NOT NULL AUTO_INCREMENT, + `ConfigID` int(11) NOT NULL, + `Value` text, + PRIMARY KEY (`ID`), + KEY `fk_ConfigMappings_ConfigID_idx` (`ConfigID`), + CONSTRAINT `fk_ConfigMappings_ConfigID` + FOREIGN KEY (`ConfigID`) + REFERENCES `Config` (`ID`) + ON DELETE CASCADE + ON UPDATE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8; + +ALTER TABLE `ConfigSettings` + MODIFY COLUMN `DataType` ENUM('text','boolean','email','instrument','textarea','scan_type','date_format','lookup_center','path','web_path', 'log_level','mapping') DEFAULT NULL; diff --git a/modules/configuration/ajax/process.php b/modules/configuration/ajax/process.php index 6b8ceb9dd0..d524e78a82 100644 --- a/modules/configuration/ajax/process.php +++ b/modules/configuration/ajax/process.php @@ -32,7 +32,35 @@ $client->initialize(); $factory = \NDB_Factory::singleton(); $DB = $factory->database(); + +$mappingValues = []; +foreach ($_POST as $key => $value) { + $key = (string) $key; + if (strpos($key, 'mapping-') === 0) { + $mappingValues[substr($key, strlen('mapping-'))] = $value; + } +} + +foreach ($_POST as $key => $value) { + $key = (string) $key; + if (strpos($key, 'mapping-') === 0 + || !array_key_exists($key, $mappingValues) + ) { + continue; + } + $valueIsEmpty = trim((string) $value) === ''; + $mappingValueIsEmpty = trim((string) $mappingValues[$key]) === ''; + if ($valueIsEmpty !== $mappingValueIsEmpty) { + displayError(400, 'Mapping rows need both a value and mapped value.'); + return; + } +} + foreach ($_POST as $key => $value) { + $key = (string) $key; + if (strpos($key, 'mapping-') === 0) { + continue; + } if (is_numeric($key)) { // When a $key is numeric, it means we are updating the entry in the // Config table with ID == $key. @@ -71,6 +99,9 @@ ['Value' => $value], ['ID' => $key] ); + if (array_key_exists($key, $mappingValues)) { + saveMappingValue($key, $mappingValues[$key]); + } } } else { // This branch is executed when the key is prefixed with the string @@ -83,13 +114,15 @@ // This is different from the above is_numeric case; this makes use of // Config.ID, not Config.ConfigID (which is a FK to ConfigSettings.ID). // The Config table is the one that will be modified here. - $keySplit = explode("-", $key); // e.g. 'add-17-1' or 'remove' - $action = $keySplit[0]; - $ConfigSettingsID = $keySplit[1]; - $valueSplit = explode("-", $value); // e.g. "remove-74" - $removeID = $valueSplit[1]; + $keySplit = explode("-", $key); // e.g. 'add-17-1' or 'remove' + $action = $keySplit[0]; //assert(count($keySplit) == 2); if ($action == 'add') { + $ConfigSettingsID = $keySplit[1] ?? null; + if ($ConfigSettingsID === null) { + displayError(400, 'Invalid action'); + return; + } // This branch adds a new entry to the Config table. if ($value === "") { continue; @@ -131,7 +164,16 @@ 'Value' => $value, ] ); + if (array_key_exists($key, $mappingValues)) { + saveMappingValue($DB->lastInsertID, $mappingValues[$key]); + } } elseif ($action == 'remove') { + $valueSplit = explode("-", $value); // e.g. "remove-74" + $removeID = $valueSplit[1] ?? null; + if ($removeID === null) { + displayError(400, 'Invalid action'); + return; + } // Delete an entry from the Config table. $DB->delete( 'Config', @@ -144,6 +186,29 @@ unset($pathIDs); } +/** + * Save the mapped value for a row in the Config table. + * + * @param string $configID The Config.ID value + * @param string $value The right-hand mapped value + * + * @return void + */ +function saveMappingValue($configID, $value): void +{ + $factory = \NDB_Factory::singleton(); + $DB = $factory->database(); + + $DB->delete('ConfigMappings', ['ConfigID' => $configID]); + $DB->unsafeInsert( + 'ConfigMappings', + [ + 'ConfigID' => $configID, + 'Value' => $value, + ] + ); +} + /** * Check Duplicate value * @@ -263,4 +328,3 @@ function validPath($value) } return true; } - diff --git a/modules/configuration/css/configuration.css b/modules/configuration/css/configuration.css index 6d99b3ea75..b1f7a7fcdb 100644 --- a/modules/configuration/css/configuration.css +++ b/modules/configuration/css/configuration.css @@ -16,4 +16,35 @@ .btn-container { display: flex; justify-content: center; -} \ No newline at end of file +} + +.configuration-mapping-row { + align-items: stretch; + display: flex; + gap: 10px; + width: 100%; +} + +.configuration-mapping-fields { + display: grid; + flex: 1 1 auto; + gap: 10px; + grid-template-columns: minmax(160px, 1fr) minmax(220px, 2fr); + min-width: 0; +} + +.configuration-mapping-fields .form-control { + height: 34px; + min-width: 0; +} + +@media (max-width: 767px) { + .configuration-mapping-row { + flex-wrap: wrap; + } + + .configuration-mapping-fields { + flex-basis: 100%; + grid-template-columns: 1fr; + } +} diff --git a/modules/configuration/jsx/configuration_helper.js b/modules/configuration/jsx/configuration_helper.js index 010d406bea..3afbf13a06 100644 --- a/modules/configuration/jsx/configuration_helper.js +++ b/modules/configuration/jsx/configuration_helper.js @@ -19,7 +19,7 @@ $(function() { // Setup the new form field let newField = currentField.clone(); - newField.find('.form-control').attr('name', name); + assignFormControlNames(newField, name); $(newField).find('.btn-remove') .addClass('remove-new') .removeClass('btn-remove'); @@ -39,7 +39,7 @@ $(function() { $('.btn-remove').on('click', function(e) { e.preventDefault(); - let selectedOption = $(this).parent().parent().children() + let selectedOption = $(this).parent().parent().find('.form-control:first') .prop('value'); let fieldName = $(this) @@ -76,9 +76,7 @@ $(function() { let name = 'add-' + parentID; resetForm($(button).parent().parent()); - $(button) - .parent().parent().children('.form-control') - .attr('name', name); + assignFormControlNames($(button).parent().parent(), name); $(button) .addClass('remove-new') .removeClass('btn-remove'); @@ -94,14 +92,22 @@ $(function() { }); // On form submit, process the changes through an AJAX call - $('form').on('submit', function(e) { + $('body').on('submit', 'form', function(e) { e.preventDefault(); - let form = $(this).serialize(); - // Clear previous feedback $('.submit-area > label').remove(); + if (hasIncompleteMappingFields()) { + $('') + .hide() + .appendTo('.submit-area') + .fadeIn(500).delay(1000); + return; + } + + let form = $(this).serialize(); + $.ajax({ type: 'post', url: loris.BaseURL + '/configuration/ajax/process.php', @@ -122,7 +128,7 @@ $(function() { }); // On form reset, to delete the elements added with the "Add field" button that were not submitted. - $('form').on('reset', function(e) { + $('body').on('reset', 'form', function(e) { $('.tab-pane.active').find('select[name^="add-"]').parent().remove(); }); }); @@ -150,3 +156,41 @@ function resetForm(form) { $(form).find('input:radio, input:checkbox') .removeAttr('checked').removeAttr('selected'); } + +/** + * Assign posted field names to simple and mapping configuration controls. + * + * @param {Element} field A configuration entry element + * @param {string} name The base POST field name + */ +function assignFormControlNames(field, name) { + 'use strict'; + + $(field).find('.form-control').attr('name', name); + $(field).find('.configuration-mapping-mapped-value') + .attr('name', 'mapping-' + name); +} + +/** + * Check whether any mapping row has only one side of the pair filled. + * + * @returns {boolean} True when an incomplete mapping row exists + */ +function hasIncompleteMappingFields() { + 'use strict'; + + let incomplete = false; + $('.configuration-mapping-fields').each(function() { + let value = $.trim($(this).find('.configuration-mapping-value').val()); + let mappedValue = $.trim( + $(this).find('.configuration-mapping-mapped-value').val() + ); + + if ((value === '') !== (mappedValue === '')) { + incomplete = true; + return false; + } + }); + + return incomplete; +} diff --git a/modules/configuration/php/configuration.class.inc b/modules/configuration/php/configuration.class.inc index d2de66893d..a1c2fd95b8 100644 --- a/modules/configuration/php/configuration.class.inc +++ b/modules/configuration/php/configuration.class.inc @@ -161,12 +161,22 @@ class Configuration extends \NDB_Form foreach ($configs as &$setting) { if ($setting['Disabled'] == 'No') { $value = $DB->pselect( - "SELECT ID, Value FROM Config WHERE ConfigID=:ID", + "SELECT c.ID, c.Value, cm.Value AS MappedValue + FROM Config c + LEFT JOIN ConfigMappings cm ON (c.ID=cm.ConfigID) + WHERE c.ConfigID=:ID", ['ID' => $setting['ID']] ); if ($value) { foreach ($value as $subvalue) { - $setting['Value'][$subvalue['ID']] = $subvalue['Value']; + if ($setting['DataType'] === 'mapping') { + $setting['Value'][$subvalue['ID']] = [ + 'Value' => $subvalue['Value'], + 'MappedValue' => $subvalue['MappedValue'] ?? '', + ]; + } else { + $setting['Value'][$subvalue['ID']] = $subvalue['Value']; + } } } } diff --git a/modules/configuration/templates/form_configuration.tpl b/modules/configuration/templates/form_configuration.tpl index 2ed16e8a29..b1a216133f 100644 --- a/modules/configuration/templates/form_configuration.tpl +++ b/modules/configuration/templates/form_configuration.tpl @@ -60,6 +60,20 @@ {/function} +{function name=createMapping} + {if isset($v.Value)} + {assign var=mappingValue value=$v.Value} + {assign var=mappedValue value=$v.MappedValue|default:''} + {else} + {assign var=mappingValue value=$v|default:''} + {assign var=mappedValue value=''} + {/if} +