From b0cf3640baa1323f72b2376cabb122f01aaa295b Mon Sep 17 00:00:00 2001 From: Kevin Brown Date: Sat, 3 Aug 2019 23:38:10 -0400 Subject: [PATCH 1/8] Made the test suite for translations more complete There were previously tests for translations within the test suite, but they only covered a select few problem cases that had previously been fixed. They did not cover the majority of cases, which makes changes to how the translation mechanism for Select2 works a bit more challenging. So this adds tests for the majority of edge cases around translations, including how one would expect the fallback chains to work and also around how defaults interact with the language options. This should not be considered an exhaustive list of all of the edge cases, but it should be good enough to refactor the internals and not have to worry as much. The one change of note to this test file is that we are now properly resetting the defaults in between tests. This should fix any issues that we may have seen where the defaults were not being reset, and thus tests were not properly isolated and would start to interfere with each other. This required pulling the module definition down below the imports, since we need to reference the defaults within the module definition. Many of these tests will fail because the translation system is broken in many small, unrealized ways. The next few commits should make these pass and fix the issues that we are seeing. --- tests/options/translation-tests.js | 225 ++++++++++++++++++++++++++++- 1 file changed, 223 insertions(+), 2 deletions(-) diff --git a/tests/options/translation-tests.js b/tests/options/translation-tests.js index ab433b6157..6a6c2d6c29 100644 --- a/tests/options/translation-tests.js +++ b/tests/options/translation-tests.js @@ -1,17 +1,106 @@ -module('Options - Translations'); - var $ = require('jquery'); var Options = require('select2/options'); +var Defaults = require('select2/defaults'); + +module('Options - Translations', { + beforeEach: function () { + Defaults.reset(); + }, + afterEach: function () { + Defaults.reset(); + } +}); + +test('partial dictonaries are reset when default reset', function (assert) { + Defaults.set('language', { + test: 'testing' + }); + + Defaults.reset(); + + assert.ok( + !Defaults.defaults.language.test, + 'The partial dictionary should have been reset' + ); +}); + +test('default language chain is English', function (assert) { + var $element = $(''); + + var options = new Options({}, $element); + + assert.deepEqual( + options.get('language'), + ['en'] + ); +}); + +test( + 'default translation includes all of the required messages', + function (assert) { + var $element = $(''); + + var options = new Options({}, $element); + + assert.deepEqual( + Object.keys(options.get('translations').all()), + [ + 'errorLoading', + 'inputTooLong', + 'inputTooShort', + 'loadingMore', + 'maximumSelected', + 'noResults', + 'searching', + 'removeAllItems' + ] + ); + } +); test('partial dictionaries can be passed', function (assert) { + var $element = $(''); + var options = new Options({ language: { searching: function () { return 'Something'; } } + }, $element); + + var translations = options.get('translations'); + + assert.equal( + translations.get('searching')(), + 'Something', + 'The partial dictionary still overrides translations' + ); + + assert.equal( + translations.get('noResults')(), + 'No results found', + 'You can still get English translations for keys not passed in' + ); +}); + +test('partial dictionaries can be combined with defaults', function (assert) { + var $element = $(''); + + Defaults.set('language', { + test: function () { + return 'Testing'; + } }); + var options = new Options({ + language: { + searching: function () { + return 'Something'; + } + } + }, $element); + var translations = options.get('translations'); assert.equal( @@ -20,9 +109,141 @@ test('partial dictionaries can be passed', function (assert) { 'The partial dictionary still overrides translations' ); + assert.equal( + translations.get('test')(), + 'Testing', + 'The defaults were included in the fallback chain' + ); + assert.equal( translations.get('noResults')(), 'No results found', 'You can still get English translations for keys not passed in' ); }); + +test('language can be set via the options', function (assert) { + var $element = $(''); + + var options = new Options({ + language: 'es' + }, $element); + + assert.deepEqual( + options.get('language'), + ['es', 'en'] + ); +}); + +test('multi-part language is broken out', function (assert) { + var $element = $(''); + + var options = new Options({ + language: 'pt-BR' + }, $element); + + assert.deepEqual( + options.get('language'), + ['pt-BR', 'pt', 'en'] + ); +}); + +test('default language can be set', function (assert) { + var $element = $(''); + + Defaults.set('language', 'es'); + + var options = new Options({}, $element); + + assert.deepEqual( + options.get('language'), + ['es', 'en'] + ); +}); + +test('lanugage set via options adds to default chain', function (assert) { + var $element = $(''); + + Defaults.set('language', 'es'); + + var options = new Options({ + language: 'it' + }, $element); + + assert.deepEqual( + options.get('language'), + ['it', 'es', 'en'] + ); +}); + +test('default language chain can be set', function (assert) { + var $element = $(''); + + Defaults.set('language', ['es', 'it', 'en']); + + var options = new Options({}, $element); + + assert.deepEqual( + options.get('language'), + ['es', 'it', 'en'] + ); +}); + +test('language can be set by lang attr', function (assert) { + var $element = $(''); + + var options = new Options({}, $element); + + assert.deepEqual( + options.get('language'), + ['es', 'en'] + ); +}); + +test('language can be inherited by lang attr', function (assert) { + var $element = $('
').find('select'); + + var options = new Options({}, $element); + + assert.deepEqual( + options.get('language'), + ['es', 'en'] + ); +}); + +test('multi-part language can be inherited by lang attr', function (assert) { + var $element = $('
').find('select'); + + var options = new Options({}, $element); + + assert.deepEqual( + options.get('language'), + ['pt-BR', 'pt', 'en'] + ); +}); + +test('lang attr overrides default language', function (assert) { + var $element = $(''); + + Defaults.set('language', 'es'); + + var options = new Options({}, $element); + + assert.deepEqual( + options.get('language'), + ['it', 'es', 'en'] + ); +}); + +test('default language overrides inherited lang attr', function (assert) { + var $element = $('
').find('select'); + + Defaults.set('language', 'es'); + + var options = new Options({}, $element); + + assert.deepEqual( + options.get('language'), + ['es', 'it', 'en'] + ); +}); From d232459d27341c802ba86a4dc1a8fbb7bc498941 Mon Sep 17 00:00:00 2001 From: Kevin Brown Date: Sat, 3 Aug 2019 23:49:24 -0400 Subject: [PATCH 2/8] Consistently resolve the language option This fixes an issue that we have had for a while where we did not have a way to consistently take the `language` option from a string, array, object, or whatever else is specified and resolve it into `Translation`-compatible objects or strings. Now there is a new internal `_resolveLanguage` function which is able to take any of the supported ways of specifying a language and resolve it down into the supported language chain. This now means that we can properly resolve the following cases, and we can do it in a consistent manner. * When the language is specified as just a string (for example: "en") * When the language is specified as a string containing a region (for example: "en-US") * When the langugae chian is specified as a list of strings (for example: ["es", "en"]) * When the language is specifid as an object containing messages (for example, when a user overrides only a subset of messages) * When the language is specified as a list of strings and objects (for example, when a user wants to use a language other than English and also wants to ovverride some default messages) * When the language is not specified at all (the most common case) * When the language is specified as an empty object (an edge case that allows us to skip processing it) This allows us to consistently produce the language fallback chain based on the given `language` option, something which we could not actually do before because we didn't have a consistent chain. This also means that now the `language` option will consistently be an array after going through this process, instead of being any number of types before. The translation generation currently does not support having objects and strings mixed as a part of the fallback chain, despite that being how the default chain has always worked, and as such there are still failing tests around this. --- src/js/select2/defaults.js | 51 +++++++++++++++++++++++++++++--------- 1 file changed, 39 insertions(+), 12 deletions(-) diff --git a/src/js/select2/defaults.js b/src/js/select2/defaults.js index 575fc8a134..ec19127af2 100644 --- a/src/js/select2/defaults.js +++ b/src/js/select2/defaults.js @@ -232,18 +232,7 @@ define([ ); } - if (typeof options.language === 'string') { - // Check if the language is specified with a region - if (options.language.indexOf('-') > 0) { - // Extract the region information if it is included - var languageParts = options.language.split('-'); - var baseLanguage = languageParts[0]; - - options.language = [options.language, baseLanguage]; - } else { - options.language = [options.language]; - } - } + options.language = this._resolveLanguage(options.language); if ($.isArray(options.language)) { var languages = new Translation(); @@ -380,6 +369,44 @@ define([ }; }; + Defaults.prototype._resolveLanguage = function (language) { + if (!language) { + return []; + } + + if ($.isEmptyObject(language)) { + return []; + } + + if ($.isPlainObject(language)) { + return [language]; + } + + var languages; + + if (!$.isArray(language)) { + languages = [language]; + } else { + languages = language; + } + + var resolvedLanguages = []; + + for (var l = 0; l < languages.length; l++) { + resolvedLanguages.push(languages[l]); + + if (languages[l].indexOf('-') > 0) { + // Extract the region information if it is included + var languageParts = languages[l].split('-'); + var baseLanguage = languageParts[0]; + + resolvedLanguages.push(baseLanguage); + } + } + + return resolvedLanguages; + }; + Defaults.prototype.set = function (key, value) { var camelKey = $.camelCase(key); From 234640ce5ca95eac0ea759514274a84707459f3f Mon Sep 17 00:00:00 2001 From: Kevin Brown Date: Sun, 4 Aug 2019 00:13:40 -0400 Subject: [PATCH 3/8] Move English to always be at the end of the language chain This was technically true in most cases in the past, because if a language chain was manually specified then it would have English injected into the end of it anyway. This is needed because not all translations are complete, but we know the English one is, and Select2 relies on the translation that it uses being complete. This will result in cases where a user specifies a language but still receives English translation for some things, which is what users have historically seen when using partial translations anyway. This just ensures that there will always be a complete translation that is being used, so they won't get unexpected errors and will instead get unexpected English translations. --- src/js/select2/defaults.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/js/select2/defaults.js b/src/js/select2/defaults.js index ec19127af2..5c98bba739 100644 --- a/src/js/select2/defaults.js +++ b/src/js/select2/defaults.js @@ -234,9 +234,11 @@ define([ options.language = this._resolveLanguage(options.language); + // Always fall back to English since it will always be complete + options.language.push('en'); + if ($.isArray(options.language)) { var languages = new Translation(); - options.language.push('en'); var languageNames = options.language; From 87c1ceeb600b82931491b3dfbf360ea120c192bc Mon Sep 17 00:00:00 2001 From: Kevin Brown Date: Sun, 4 Aug 2019 00:23:39 -0400 Subject: [PATCH 4/8] Filter out repeated languages in fallback chain This is mostly being done for performance reasons, since Select2 will not behave any differently when there are duplicates, but it makes things cleaner when you ask for the fallback chain and it only contains unique values. This cannot distinguish between languages specified by name (string) and languages specified by the contents of their language file (such as the default, English), but this should generally not be an issue. --- src/js/select2/defaults.js | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/js/select2/defaults.js b/src/js/select2/defaults.js index 5c98bba739..73262cca63 100644 --- a/src/js/select2/defaults.js +++ b/src/js/select2/defaults.js @@ -237,6 +237,18 @@ define([ // Always fall back to English since it will always be complete options.language.push('en'); + var uniqueLanguages = []; + + for (var l = 0; l < options.language.length; l++) { + var language = options.language[l]; + + if (uniqueLanguages.indexOf(language) === -1) { + uniqueLanguages.push(language); + } + } + + options.language = uniqueLanguages; + if ($.isArray(options.language)) { var languages = new Translation(); From 5d5f3053d0b4e3d79503b23e1714b207d5034ee5 Mon Sep 17 00:00:00 2001 From: Kevin Brown Date: Sun, 4 Aug 2019 00:28:25 -0400 Subject: [PATCH 5/8] Convert the language chain into a finalized translation This extracts the logic for converting parts of the language chain into the finalized `Translation` objects out into its own method, with some small fixes for edge cases. This can now properly convert a language chain containing both strings and objects into a translation object that contains them both. We no longer need to special case the `language` option being an array since we know that it will be an array once the language resolution process is completed. --- src/js/select2/defaults.js | 91 +++++++++++++++++++------------------- 1 file changed, 45 insertions(+), 46 deletions(-) diff --git a/src/js/select2/defaults.js b/src/js/select2/defaults.js index 73262cca63..b4f49871b8 100644 --- a/src/js/select2/defaults.js +++ b/src/js/select2/defaults.js @@ -249,52 +249,10 @@ define([ options.language = uniqueLanguages; - if ($.isArray(options.language)) { - var languages = new Translation(); - - var languageNames = options.language; - - for (var l = 0; l < languageNames.length; l++) { - var name = languageNames[l]; - var language = {}; - - try { - // Try to load it with the original name - language = Translation.loadPath(name); - } catch (e) { - try { - // If we couldn't load it, check if it wasn't the full path - name = this.defaults.amdLanguageBase + name; - language = Translation.loadPath(name); - } catch (ex) { - // The translation could not be loaded at all. Sometimes this is - // because of a configuration problem, other times this can be - // because of how Select2 helps load all possible translation files. - if (options.debug && window.console && console.warn) { - console.warn( - 'Select2: The language file for "' + name + '" could not be ' + - 'automatically loaded. A fallback will be used instead.' - ); - } - - continue; - } - } - - languages.extend(language); - } - - options.translations = languages; - } else { - var baseTranslation = Translation.loadPath( - this.defaults.amdLanguageBase + 'en' - ); - var customTranslation = new Translation(options.language); - - customTranslation.extend(baseTranslation); - - options.translations = customTranslation; - } + options.translations = this._processTranslations( + options.language, + options.debug + ); return options; }; @@ -421,6 +379,47 @@ define([ return resolvedLanguages; }; + Defaults.prototype._processTranslations = function (languages, debug) { + var translations = new Translation(); + + for (var l = 0; l < languages.length; l++) { + var languageData = new Translation(); + + var language = languages[l]; + + if (typeof language === 'string') { + try { + // Try to load it with the original name + languageData = Translation.loadPath(language); + } catch (e) { + try { + // If we couldn't load it, check if it wasn't the full path + language = this.defaults.amdLanguageBase + language; + languageData = Translation.loadPath(language); + } catch (ex) { + // The translation could not be loaded at all. Sometimes this is + // because of a configuration problem, other times this can be + // because of how Select2 helps load all possible translation files + if (debug && window.console && console.warn) { + console.warn( + 'Select2: The language file for "' + language + '" could ' + + 'not be automatically loaded. A fallback will be used instead.' + ); + } + } + } + } else if ($.isPlainObject(language)) { + languageData = new Translation(language); + } else { + languageData = language; + } + + translations.extend(languageData); + } + + return translations; + }; + Defaults.prototype.set = function (key, value) { var camelKey = $.camelCase(key); From 782c60a0042b43bd91e0e6f70d4c23c275ccabd3 Mon Sep 17 00:00:00 2001 From: Kevin Brown Date: Sun, 4 Aug 2019 00:35:35 -0400 Subject: [PATCH 6/8] Switch default translation to be empty This should have no external effects, but it fixes an interesting bug where resetting the defaults would not always reset custom translations. This was because it was possible to modify the included English translation when you were setting a default for the language option. This should not cause any issues because the English translation is now appended to the end of the language chain when the defaults are applied, which means that English will continue to exist as the final fallback. --- src/js/select2/defaults.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/js/select2/defaults.js b/src/js/select2/defaults.js index b4f49871b8..b0de0d1733 100644 --- a/src/js/select2/defaults.js +++ b/src/js/select2/defaults.js @@ -319,7 +319,7 @@ define([ debug: false, dropdownAutoWidth: false, escapeMarkup: Utils.escapeMarkup, - language: EnglishTranslation, + language: {}, matcher: matcher, minimumInputLength: 0, maximumInputLength: 0, From f8e0313910d8294b423dcf6255fda5171041a889 Mon Sep 17 00:00:00 2001 From: Kevin Brown Date: Sun, 4 Aug 2019 00:42:06 -0400 Subject: [PATCH 7/8] Inherited `lang` attribute should be below the default in the chain It was pointed out in #5468 that the `lang` attribute, when inherited from a parent element, was above the option set in the global default within the inheritance chain. While this makes sense because of how we inherit other properties, it does not make sense for the `lang` attribute. The inheritance chain for the `language` option has been adjusted to be the following: 1. The `lang` attribute on the original `` element (because of how `data-*` attribute resolution affects options) 3. The `language` option specified when initiailizing Select2 4. The `language` Select2 default 5. The `lang` attribute on a parent of the `` element that it is applying the options for. Closes #5468 --- src/js/select2/defaults.js | 20 ++++++++++++++++++-- src/js/select2/options.js | 12 ++++-------- 2 files changed, 22 insertions(+), 10 deletions(-) diff --git a/src/js/select2/defaults.js b/src/js/select2/defaults.js index b0de0d1733..7f3bc3c6f1 100644 --- a/src/js/select2/defaults.js +++ b/src/js/select2/defaults.js @@ -232,8 +232,6 @@ define([ ); } - options.language = this._resolveLanguage(options.language); - // Always fall back to English since it will always be complete options.language.push('en'); @@ -341,6 +339,24 @@ define([ }; }; + Defaults.prototype.applyFromElement = function (options, $element) { + var optionLanguage = options.language; + var defaultLanguage = this.defaults.language; + var elementLanguage = $element.prop('lang'); + var parentLanguage = $element.closest('[lang]').prop('lang'); + + var languages = Array.prototype.concat.call( + this._resolveLanguage(elementLanguage), + this._resolveLanguage(optionLanguage), + this._resolveLanguage(defaultLanguage), + this._resolveLanguage(parentLanguage) + ); + + options.language = languages; + + return options; + }; + Defaults.prototype._resolveLanguage = function (language) { if (!language) { return []; diff --git a/src/js/select2/options.js b/src/js/select2/options.js index 8e0dc07efe..b3d67cee1c 100644 --- a/src/js/select2/options.js +++ b/src/js/select2/options.js @@ -11,6 +11,10 @@ define([ this.fromElement($element); } + if ($element != null) { + this.options = Defaults.applyFromElement(this.options, $element); + } + this.options = Defaults.apply(this.options); if ($element && $element.is('input')) { @@ -34,14 +38,6 @@ define([ this.options.disabled = $e.prop('disabled'); } - if (this.options.language == null) { - if ($e.prop('lang')) { - this.options.language = $e.prop('lang').toLowerCase(); - } else if ($e.closest('[lang]').prop('lang')) { - this.options.language = $e.closest('[lang]').prop('lang'); - } - } - if (this.options.dir == null) { if ($e.prop('dir')) { this.options.dir = $e.prop('dir'); From 8bac5c2ecc6c8205837f834df7dbc9199f140ba3 Mon Sep 17 00:00:00 2001 From: Kevin Brown Date: Sun, 4 Aug 2019 01:06:32 -0400 Subject: [PATCH 8/8] Properly resolve language chains with dictionaries It is possible for a language chain to be specified that includes both a dictionary and the string translation as a part of the same chain, but we would previously throw an error because we assumed that the list could only contain strings and that dictionaries would never be included in lists. This fixes the issue so language region normalization only occurs when a string is specified in the language chain, which is what we were previously assuming was the case but was not actually. This also now resolves the entire language option during the `Defaults.apply` method. This should be a no-op except for internal tests, because the `Defaults.applyFromElement` method should almost always be called in real-world scenarios. --- src/js/select2/defaults.js | 6 ++++- tests/options/translation-tests.js | 39 ++++++++++++++++++++++++++++++ 2 files changed, 44 insertions(+), 1 deletion(-) diff --git a/src/js/select2/defaults.js b/src/js/select2/defaults.js index 7f3bc3c6f1..f694db6b5a 100644 --- a/src/js/select2/defaults.js +++ b/src/js/select2/defaults.js @@ -232,6 +232,10 @@ define([ ); } + // If the defaults were not previously applied from an element, it is + // possible for the language option to have not been resolved + options.language = this._resolveLanguage(options.language); + // Always fall back to English since it will always be complete options.language.push('en'); @@ -383,7 +387,7 @@ define([ for (var l = 0; l < languages.length; l++) { resolvedLanguages.push(languages[l]); - if (languages[l].indexOf('-') > 0) { + if (typeof languages[l] === 'string' && languages[l].indexOf('-') > 0) { // Extract the region information if it is included var languageParts = languages[l].split('-'); var baseLanguage = languageParts[0]; diff --git a/tests/options/translation-tests.js b/tests/options/translation-tests.js index 6a6c2d6c29..34b86d3356 100644 --- a/tests/options/translation-tests.js +++ b/tests/options/translation-tests.js @@ -122,6 +122,45 @@ test('partial dictionaries can be combined with defaults', function (assert) { ); }); +test('partial dictionaries can used in fallback chains', function (assert) { + var $element = $(''); + + var options = new Options({ + language: [ + { + searching: function () { + return 'Something'; + } + }, + { + test: function () { + return 'Testing'; + } + } + ] + }, $element); + + var translations = options.get('translations'); + + assert.equal( + translations.get('searching')(), + 'Something', + 'The partial dictionary still overrides translations' + ); + + assert.equal( + translations.get('test')(), + 'Testing', + 'The defaults were included in the fallback chain' + ); + + assert.equal( + translations.get('noResults')(), + 'No results found', + 'You can still get English translations for keys not passed in' + ); +}); + test('language can be set via the options', function (assert) { var $element = $('');