From 6bf9d00e85a6c64362e35f9c5bce25cc1be90b58 Mon Sep 17 00:00:00 2001 From: infinityscroll <82277442+infinityscroll@users.noreply.github.com> Date: Mon, 31 Aug 2026 01:30:02 +0530 Subject: [PATCH] Translate configuration form labels with manager lexicons --- _build/data/transport.settings.php | 1 + .../clientconfig/js/mgr/sections/home.js | 16 +- .../clientconfig/controllers/home.class.php | 7 + core/components/clientconfig/docs/readme.txt | 5 +- .../clientconfig/docs/translations.md | 66 +++++++ .../clientconfig/lexicon/en/default.inc.php | 3 + .../model/clientconfig/clientconfig.class.php | 55 ++++++ .../processors/mgr/settings/save.class.php | 3 +- tests/README.md | 30 +++ tests/manager-translations.js | 41 ++++ tests/manager-translations.php | 184 ++++++++++++++++++ 11 files changed, 394 insertions(+), 17 deletions(-) create mode 100644 core/components/clientconfig/docs/translations.md create mode 100644 tests/README.md create mode 100644 tests/manager-translations.js create mode 100644 tests/manager-translations.php diff --git a/_build/data/transport.settings.php b/_build/data/transport.settings.php index 90bd549..dbe129b 100644 --- a/_build/data/transport.settings.php +++ b/_build/data/transport.settings.php @@ -6,6 +6,7 @@ 'vertical_tabs' => false, 'context_aware' => false, 'google_fonts_api_key' => '', + 'lexicon_topics' => '', ); $settings = array(); diff --git a/assets/components/clientconfig/js/mgr/sections/home.js b/assets/components/clientconfig/js/mgr/sections/home.js index eb9ab69..b1ac605 100755 --- a/assets/components/clientconfig/js/mgr/sections/home.js +++ b/assets/components/clientconfig/js/mgr/sections/home.js @@ -194,24 +194,10 @@ Ext.extend(ClientConfig.page.Home,MODx.Component,{ } if (field.xtype === 'modx-combo') { - var options = value.options.split('||'); - var data = []; - Ext.each(options, function(option, index) { - option = option.split('=='); - if (option[1]) { - data.push([ - option[1], - option[0] - ]); - } else { - data.push([option[0],option[0]]); - } - }); - field.store = new Ext.data.ArrayStore({ mode: 'local', fields: ['value','label'], - data: data + data: value.optionData }); field.hiddenName = value.key; field.valueField = 'value'; diff --git a/core/components/clientconfig/controllers/home.class.php b/core/components/clientconfig/controllers/home.class.php index 3033e7c..201590a 100755 --- a/core/components/clientconfig/controllers/home.class.php +++ b/core/components/clientconfig/controllers/home.class.php @@ -27,6 +27,8 @@ public function process(array $scriptProperties = []) { $groups = $this->modx->getCollection('cgGroup', $c); foreach ($groups as $group) { $grp = $group->toArray(); + $grp['label'] = $this->clientconfig->translate($grp['label']); + $grp['description'] = $this->clientconfig->translate($grp['description']); $grp['items'] = []; $c = $this->modx->newQuery('cgSetting'); @@ -34,6 +36,8 @@ public function process(array $scriptProperties = []) { $c->sortby('label','ASC'); foreach ($group->getMany('Settings', $c) as $setting) { $sa = $setting->toArray(); + $sa['label'] = $this->clientconfig->translate($sa['label']); + $sa['description'] = $this->clientconfig->translate($sa['description']); if (in_array($sa['xtype'], ['checkbox','xcheckbox'], true)) { $sa['value'] = (bool)$sa['value']; } @@ -48,6 +52,9 @@ public function process(array $scriptProperties = []) { $this->modx->parser->processElementTags('', $inputOpts, true, true); $sa['options'] = $inputOpts; } + if ($sa['xtype'] === 'modx-combo') { + $sa['optionData'] = $this->clientconfig->getOptionData($sa['options']); + } $grp['items'][] = $sa; } $tabs[] = $grp; diff --git a/core/components/clientconfig/docs/readme.txt b/core/components/clientconfig/docs/readme.txt index 689abdd..1dcdc5c 100644 --- a/core/components/clientconfig/docs/readme.txt +++ b/core/components/clientconfig/docs/readme.txt @@ -20,4 +20,7 @@ Possible uses include: - Keep contact details updated in one central location - Update the email-address a form sends notifications to. -Licensed under the MIT. \ No newline at end of file +For translating configuration labels, descriptions and selectbox labels, see +translations.md in this directory. + +Licensed under the MIT. diff --git a/core/components/clientconfig/docs/translations.md b/core/components/clientconfig/docs/translations.md new file mode 100644 index 0000000..3d78106 --- /dev/null +++ b/core/components/clientconfig/docs/translations.md @@ -0,0 +1,66 @@ +# Translating the configuration form + +ClientConfig can display setting labels, setting descriptions, group titles, +group descriptions and selectbox labels in the manager user's language. The +language of the selected website context does not change these translations. + +## Configure a lexicon + +Create a MODX namespace, for example `website`, and add lexicon files for the +languages your editors use. With the namespace path +`{core_path}components/website/`, an example file is +`core/components/website/lexicon/en/settings.inc.php`: + +```php +debug = (bool)$this->modx->getOption('clientconfig.debug',null,false); } + /** + * Translate an explicit lexicon reference for display in the manager. + * Plain text and unknown references are left intact. Do not use this on + * setting values or on the editable configuration returned by admin grids. + * + * @param string $text A %key or [[%key]] reference, or literal text. + * @return string + */ + public function translate($text) { + if (!is_string($text) || !preg_match('/^(?:%([a-zA-Z0-9_.-]+)|\[\[%([a-zA-Z0-9_.-]+)\]\])$/D', $text, $matches)) { + return $text; + } + + // MODX resolves the manager user's language into cultureKey before + // invoking both manager controllers and connector processors. + $language = $this->modx->getOption('cultureKey', null, 'en', true); + if (!$this->lexiconTopicsLoaded) { + $topics = $this->modx->getOption('clientconfig.lexicon_topics', null, ''); + foreach (explode(',', $topics) as $topic) { + $topic = trim($topic); + if ($topic !== '') { + $parts = explode(':', $topic); + $name = array_pop($parts); + $namespace = count($parts) ? array_pop($parts) : 'core'; + $this->modx->lexicon->load($language . ':' . $namespace . ':' . $name); + } + } + $this->lexiconTopicsLoaded = true; + } + + $key = $matches[1] !== '' ? $matches[1] : $matches[2]; + return $this->modx->lexicon->exists($key, $language) ? $this->modx->lexicon($key, array(), $language) : $text; + } + + /** + * Build selectbox data without translating the submitted option values. + * Keep labels separate from the option delimiters: translations may contain + * either || or ==. Match the existing manager's label==value parsing. + * + * @param string $options + * @return array + */ + public function getOptionData($options) { + $data = array(); + foreach (explode('||', (string)$options) as $option) { + $parts = explode('==', $option); + $value = isset($parts[1]) && $parts[1] !== '' ? $parts[1] : $parts[0]; + $data[] = array($value, $this->translate($parts[0])); + } + return $data; + } + /** * Grab settings (from cache if possible) as key => value pairs. * @param string $context diff --git a/core/components/clientconfig/processors/mgr/settings/save.class.php b/core/components/clientconfig/processors/mgr/settings/save.class.php index 0ffeab8..670e8dd 100644 --- a/core/components/clientconfig/processors/mgr/settings/save.class.php +++ b/core/components/clientconfig/processors/mgr/settings/save.class.php @@ -34,7 +34,8 @@ public function process() { if (trim($value) === '' && $setting->get('is_required') && !in_array($setting->get('xtype'), array('checkbox', 'xcheckbox'), true)) { - $this->addFieldError($key, $setting->get('label') . ': ' . $this->modx->lexicon('clientconfig.field_is_required')); + $label = $this->modx->clientconfig->translate($setting->get('label')); + $this->addFieldError($key, $label . ': ' . $this->modx->lexicon('clientconfig.field_is_required')); continue; } diff --git a/tests/README.md b/tests/README.md new file mode 100644 index 0000000..3b8cd27 --- /dev/null +++ b/tests/README.md @@ -0,0 +1,30 @@ +# Manager translation regression checks + +```sh +php tests/manager-translations.php +node tests/manager-translations.js +``` + +The PHP checks run the actual ClientConfig service, home controller and save +processor against small MODX infrastructure fixtures. They cover two resolved +manager languages, custom topic loading, missing/literal labels, group and +setting text, option delimiters, unchanged configuration records, dynamic +options, and required-field validation/saving. The JavaScript checks run the +actual manager form builder with ExtJS infrastructure fixtures to verify its +display labels and submitted values. + +These are standalone regression checks, not a full MODX installation test. +For integration testing on MODX 2 and 3: + +1. Follow `core/components/clientconfig/docs/translations.md` to add English + and German entries and a translated group and selectbox. +2. Open the configuration form as English and German manager users. Confirm + that group and setting labels/descriptions use each user's language. +3. Enable context-aware mode and select a website context with a different + language. The form labels should still use the manager user's language. +4. Save each choice, reload, and check that the same stored value is selected + in both languages. Leave a required field empty to inspect its error label. +5. Reopen the admin edit dialogs and export the configuration. References, + raw option definitions and defaults should be unchanged. +6. Check a literal-only group and selectbox, an unknown lexicon key, and a + dynamic option source returning `%key==value` with tag processing enabled. diff --git a/tests/manager-translations.js b/tests/manager-translations.js new file mode 100644 index 0000000..e444c6e --- /dev/null +++ b/tests/manager-translations.js @@ -0,0 +1,41 @@ +// Run with: node tests/manager-translations.js +// Exercise the real manager form builder with the controller's data format. +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const vm = require('node:vm'); +const ClientConfig = { + page: {}, isAdmin: false, reqAsterisk: '*', + data: [{ + id: 1, label: 'Darstellung', description: 'Darstellung bearbeiten', + items: [{ + key: 'font', xtype: 'modx-combo', label: 'Schrift', description: 'Schrift auswählen', + is_required: true, value: 'italic', default: 'normal', + options: '%website.normal==normal||%website.italic==italic', + optionData: [['normal', 'Normal'], ['italic', 'Kursiv || mit == Zeichen']] + }] + }] +}; +const context = { + ClientConfig, MODx: { Component: function () {}, config: {} }, + Ext: { + extend(target, parent, methods) { Object.assign(target.prototype, methods); }, + reg() {}, each(items, fn) { items.forEach(fn); }, iterate(items, fn) { items.forEach(fn); }, + data: { ArrayStore: function (config) { Object.assign(this, config); } } + } +}; +vm.runInNewContext(fs.readFileSync(require.resolve('../assets/components/clientconfig/js/mgr/sections/home.js'), 'utf8'), context); +const tabs = ClientConfig.page.Home.prototype.getTabs.call({}); +const fields = tabs[0].items[1].items; +const select = fields[0]; +assert.equal(tabs[0].title, 'Darstellung'); +assert.equal(tabs[0].items[0].html, '

Darstellung bearbeiten

'); +assert.equal(select.fieldLabel, 'Schrift*'); +assert.equal(fields[1].text, 'Schrift auswählen'); +assert.equal(select.name, 'font'); +assert.equal(select.hiddenName, 'font'); +assert.equal(select.value, 'italic'); +assert.equal(select.valueField, 'value'); +assert.equal(select.displayField, 'label'); +assert.deepEqual(select.store.data, ClientConfig.data[0].items[0].optionData); +assert.equal(select.store.data.length, 2); +console.log('Passed manager form translation checks.'); diff --git a/tests/manager-translations.php b/tests/manager-translations.php new file mode 100644 index 0000000..21f85ba --- /dev/null +++ b/tests/manager-translations.php @@ -0,0 +1,184 @@ + 'Label', 'clientconfig.field_is_required' => 'Required'); + public function __construct($language) { $this->language = $language; } + public function load($topic) { + $this->loaded[] = $topic; + if ($topic === $this->language . ':website:settings') { + $this->entries += $this->language === 'de' ? array( + 'website.group' => 'Darstellung', 'website.group.desc' => 'Darstellung bearbeiten', + 'website.font' => 'Schrift', 'website.font.desc' => 'Schrift auswählen', + 'website.normal' => 'Normal', 'website.italic' => 'Kursiv', + 'website.delimiters' => 'Text || mit == Zeichen', 'website.empty' => '', 'website.zero' => '0', + ) : array( + 'website.group' => 'Appearance', 'website.group.desc' => 'Edit appearance', + 'website.font' => 'Font', 'website.font.desc' => 'Choose a font', + 'website.normal' => 'Normal', 'website.italic' => 'Italic', + 'website.delimiters' => 'Text || with == delimiters', 'website.empty' => '', 'website.zero' => '0', + ); + } + } + public function exists($key, $language) { same($this->language, $language, 'Lookup uses the resolved manager language'); return array_key_exists($key, $this->entries); } + public function process($key, $params, $language) { return $this->exists($key, $language) ? $this->entries[$key] : $key; } +} +class TestQuery { + public function sortby($field, $direction) {} +} +class TestRecord { + public $data; + public $items = array(); + public $saved = false; + public function __construct($data) { $this->data = $data; } + public function toArray() { return $this->data; } + public function get($key) { return $this->data[$key]; } + public function set($key, $value) { $this->data[$key] = $value; } + public function getMany($relation, $query) { return $this->items; } + public function save() { $this->saved = true; } +} +class cgSetting extends TestRecord {} +class modContext extends TestRecord { + public function toJSON() { return json_encode($this->data); } +} +class TestParser { + public $calls = 0; + public function processElementTags($prefix, &$text, $processUncacheable, $removeUnprocessed) { + $this->calls++; + // Stand in for the existing administrator-enabled dynamic option source. + $text = str_replace('[[dynamicOptions]]', '%website.italic==italic', $text); + } +} +class TestCache { + public function delete($key, $options) {} +} +class xPDO { const OPT_CACHE_KEY = 'cache_key'; } +class modX { + public $lexicon; + public $clientconfig; + public $groups = array(); + public $parser; + public $error; + public $config = array('clientconfig.lexicon_topics' => ' website:settings, , website:extra ', 'manager_language' => 'fr'); + public function __construct($language) { $this->config['cultureKey'] = $language; $this->lexicon = new TestLexicon($language); $this->parser = new TestParser(); } + public function getOption($key, $options = null, $default = null, $skipEmpty = false) { return isset($this->config[$key]) && (!$skipEmpty || $this->config[$key] !== '') ? $this->config[$key] : $default; } + public function addPackage($name, $path) {} + public function lexicon($key, $params = array(), $language = '') { return $this->lexicon->process($key, $params, $language !== '' ? $language : $this->config['cultureKey']); } + public function newQuery($class) { return new TestQuery(); } + public function getCollection($class, $query) { return $this->groups; } + public function getParser() { return $this->parser; } + public function getObject($class, $criteria) { + if ($class === 'modContext') { return new modContext(array('key' => $criteria['key'], 'cultureKey' => 'fr')); } + foreach ($this->groups as $group) { + foreach ($group->items as $setting) { + if ($setting->get('key') === $criteria['key']) { return $setting; } + } + } + return null; + } + public function getCacheManager() { return new TestCache(); } + public function invokeEvent($event) {} +} +class modExtraManagerController { + public $modx; + public $html = array(); + public function __construct($modx) { $this->modx = $modx; } + public function addHtml($html) { $this->html[] = $html; } +} +class modProcessor { + public $modx; + public $errors = array(); + public function __construct($modx) { $this->modx = $modx; } + public function addFieldError($key, $message) { $this->errors[$key] = $message; } + public function hasErrors() { return count($this->errors) > 0; } + public function getFields() { return array_values($this->errors); } + public function failure($message) { return array('success' => false, 'message' => $message); } + public function success() { return array('success' => true); } +} + +require dirname(__DIR__) . '/core/components/clientconfig/model/clientconfig/clientconfig.class.php'; +require dirname(__DIR__) . '/core/components/clientconfig/controllers/home.class.php'; +require dirname(__DIR__) . '/core/components/clientconfig/processors/mgr/settings/save.class.php'; + +foreach (array('en', 'de') as $language) { + $modx = new modX($language); + $service = new ClientConfig($modx); + $modx->clientconfig = $service; + $italic = $language === 'de' ? 'Kursiv' : 'Italic'; + same(array('clientconfig:default'), $modx->lexicon->loaded, 'Extra topics are not loaded on frontend service creation'); + foreach (array('', '100% complete', 'website.italic', 'Ordinary label', '[[SomeSnippet]]', '[[++site_name]]', '%unknown', '[[%unknown]]') as $literal) { + same($literal, $service->translate($literal), 'Plain text, other tags and missing keys remain unchanged'); + } + same($italic, $service->translate('%website.italic'), 'Short reference in manager language'); + same($italic, $service->translate('[[%website.italic]]'), 'Lexicon tag in manager language'); + same('Label', $service->translate('[[%clientconfig.label]]'), 'Already loaded topic works'); + same('', $service->translate('%website.empty'), 'Empty translation is valid'); + same('0', $service->translate('%website.zero'), 'Zero translation is valid'); + same(array('clientconfig:default', $language . ':website:settings', $language . ':website:extra'), $modx->lexicon->loaded, 'Custom topics load only once in manager language'); + same(array(array('normal', 'Normal'), array('italic', $italic)), $service->getOptionData('%website.normal==normal||[[%website.italic]]==italic'), 'Option values remain stable across languages'); + same(array(array('%website.italic', $italic)), $service->getOptionData('%website.italic'), 'Implicit value remains the original key'); + same(array(array('Plain', 'Plain'), array('0', 'Zero'), array('Empty', 'Empty'), array('value', 'First'), array('', '')), $service->getOptionData('Plain||Zero==0||Empty==||First==value==ignored||'), 'Existing option parsing remains compatible'); + same(array(array('value', $language === 'de' ? 'Text || mit == Zeichen' : 'Text || with == delimiters')), $service->getOptionData('%website.delimiters==value'), 'Delimiters in translations stay inside a single label'); + + $group = new TestRecord(array('id' => 1, 'label' => '%website.group', 'description' => '[[%website.group.desc]]')); + $setting = new cgSetting(array('id' => 1, 'key' => 'font', 'label' => '[[%website.font]]', 'description' => '%website.font.desc', 'xtype' => 'modx-combo', 'value' => 'italic', 'default' => 'normal', 'is_required' => true, 'options' => '%website.normal==normal||%website.italic==italic', 'process_options' => false)); + $dynamic = new cgSetting(array_merge($setting->data, array('id' => 2, 'key' => 'dynamic', 'options' => '[[dynamicOptions]]', 'process_options' => true))); + $group->items = array($setting, $dynamic); + $modx->groups = array($group); + $beforeGroup = $group->toArray(); + $beforeSetting = $setting->toArray(); + $controller = new ClientConfigHomeManagerController($modx); + $controller->clientconfig = $service; + $controller->process(); + $tab = $controller->tabs[0]; + same($language === 'de' ? 'Darstellung' : 'Appearance', $tab['label'], 'Group title translated'); + same($language === 'de' ? 'Darstellung bearbeiten' : 'Edit appearance', $tab['description'], 'Group description translated'); + same($language === 'de' ? 'Schrift' : 'Font', $tab['items'][0]['label'], 'Setting label translated'); + same($language === 'de' ? 'Schrift auswählen' : 'Choose a font', $tab['items'][0]['description'], 'Setting description translated'); + same(array(array('normal', 'Normal'), array('italic', $italic)), $tab['items'][0]['optionData'], 'Controller sends translated option rows'); + same(array(array('italic', $italic)), $tab['items'][1]['optionData'], 'Dynamic option source is translated after existing processing'); + same(1, $modx->parser->calls, 'No general tag parsing unless process_options is enabled'); + same($beforeGroup, $group->toArray(), 'Stored group is unchanged'); + same($beforeSetting, $setting->toArray(), 'Stored setting, raw references and options are unchanged'); + same('italic', $tab['items'][0]['value'], 'Current value is untranslated'); + same('normal', $tab['items'][0]['default'], 'Default value is untranslated'); + + $modx->config['clientconfig.context_aware'] = true; + $controller->process(array('context' => 'fr-web')); + same($tab, $controller->tabs[0], 'Selecting a French website context keeps the manager form language'); + same(1, count($controller->html), 'Context-aware initialization still runs'); + + $processor = new cgSettingSaveProcessor($modx); + // MODX exposes error fields through modError; use the same collected fields. + $modx->error = $processor; + $_POST['values'] = json_encode(array('font' => '')); + same(false, $processor->process()['success'], 'Required field is rejected'); + same(array('font' => ($language === 'de' ? 'Schrift' : 'Font') . ': Required'), $processor->errors, 'Validation uses the displayed label'); + same(false, $setting->saved, 'Validation failure does not persist a value'); + $processor = new cgSettingSaveProcessor($modx); + $_POST['values'] = json_encode(array('font' => 'normal')); + same(true, $processor->process()['success'], 'Valid choice can be saved'); + same('normal', $setting->get('value'), 'Saved option value is not its translated label'); + same($beforeSetting['label'], $setting->get('label'), 'Saving a value does not overwrite its lexicon reference'); +} + +echo "Passed $assertions assertions.\n";