From 4e956d830b5a80f3e86f32c1c2b77cd07ad0d62c Mon Sep 17 00:00:00 2001 From: 66Ton99 <66ton99@gmail.com> Date: Sat, 22 Aug 2026 20:01:18 +0300 Subject: [PATCH] feat(js): register constraints and transformers in a Svaroh namespace Every constraint and every view transformer exported its class name as a separate property of "window", about thirty unprefixed globals for a library that only needs one name. They are now registered in a single "Svaroh" namespace object, and parseConstraints() and parseTransformers() resolve the class name the PHP side serialises there instead of on "window". The old global names are kept as deprecated aliases, and a class an application defines as a global is still resolved from the global scope, so custom constraints, custom transformers and Callback handlers written against them keep working. Fixes formapro/JsFormValidatorBundle#129 --- README.md | 20 +++ src/Resources/doc/3_7.md | 21 ++- src/Resources/doc/3_8.md | 11 +- .../public/js/SvarohJsFormValidator.js | 34 ++++- .../public/js/constraints/globals.test.js | 17 ++- src/Resources/public/js/constraints/index.js | 107 +++++++++----- src/Resources/public/js/namespace.js | 32 +++++ src/Resources/public/js/namespace.test.js | 131 ++++++++++++++++++ .../public/js/transformers/globals.test.js | 17 ++- src/Resources/public/js/transformers/index.js | 47 +++++-- 10 files changed, 376 insertions(+), 61 deletions(-) create mode 100644 src/Resources/public/js/namespace.js create mode 100644 src/Resources/public/js/namespace.test.js diff --git a/README.md b/README.md index d4d7b07a..a2f2aa15 100644 --- a/README.md +++ b/README.md @@ -161,6 +161,26 @@ Then include the entry in your template: Adjust the import path to your application structure. +### JavaScript Namespace + +The library registers its constraints and its view transformers in a single +global object named after the `Svaroh\JsFormValidatorBundle` PHP namespace: + +```js +Svaroh.constraints // constraints, keyed by class name without the separators +Svaroh.transformers // view transformers, keyed the same way +``` + +Register your own classes there, as shown in +[custom constraints](src/Resources/doc/3_7.md) and +[custom data transformers](src/Resources/doc/3_8.md). The object is created by +the library, so run your registrations after its script is loaded. + +Every bundled constraint and transformer is still exported under its own global +name, such as `window.SymfonyComponentValidatorConstraintsNotBlank`, and a class +your application defines as a global is still picked up. Both are deprecated and +will be dropped in a future release. + ### Render Bundle Config And Form Models After the scripts are loaded, render the generated config and queued form models: diff --git a/src/Resources/doc/3_7.md b/src/Resources/doc/3_7.md index b6bf405f..32b6c4e9 100644 --- a/src/Resources/doc/3_7.md +++ b/src/Resources/doc/3_7.md @@ -1,8 +1,8 @@ ### 3.7 Custom constraints If you have your own Symfony constraint, implement the matching constraint on -the JavaScript side too. The JavaScript constructor name should match the full -PHP class name with namespace separators removed. +the JavaScript side too. Register it in `Svaroh.constraints` under the full PHP +class name with the namespace separators removed. For example, a PHP constraint: @@ -51,7 +51,7 @@ requires this JavaScript constraint: ```html +``` + +`Svaroh` is the namespace of the library, as described in the README. +Constraints declared as a global function of the same name still work: + +```html + ``` + +This form is deprecated and support for it will be dropped in a future release, +so prefer the namespace for new code. Note that a global cannot replace a +constraint the bundle ships: the namespace is looked up first. diff --git a/src/Resources/doc/3_8.md b/src/Resources/doc/3_8.md index ae5ff32d..7701e832 100644 --- a/src/Resources/doc/3_8.md +++ b/src/Resources/doc/3_8.md @@ -4,11 +4,12 @@ If you use a custom view transformer, implement the matching transformer on the JavaScript side so validation receives the same normalized value. For a PHP view transformer such as `App\Form\DataTransformer\MyTransformer`, -create a JavaScript constructor with the namespace separators removed: +register a JavaScript constructor in `Svaroh.transformers` under the class name +with the namespace separators removed: ```html ``` + +`Svaroh` is the namespace of the library, as described in the README. +Transformers declared as a global function of the same name still work, but that +form is deprecated and support for it will be dropped in a future release. diff --git a/src/Resources/public/js/SvarohJsFormValidator.js b/src/Resources/public/js/SvarohJsFormValidator.js index 5dc6edc2..8e4adc08 100644 --- a/src/Resources/public/js/SvarohJsFormValidator.js +++ b/src/Resources/public/js/SvarohJsFormValidator.js @@ -1,3 +1,4 @@ +import Svaroh from './namespace.js'; import './constraints'; import './transformers'; @@ -1278,6 +1279,29 @@ var SvarohJsFormValidator = new function () { return value; }; + /** + * Resolves the constraint or the transformer the PHP side refers to by its + * class name with the namespace separators removed. + * + * The classes shipped by the bundle are registered in the Svaroh namespace. + * The ones an application defines for its own constraints and transformers + * are looked up in the global scope as before, see doc/3_7.md and + * doc/3_8.md, so that a global keeps working when the namespace holds no + * class of that name. + * + * @param {Object} registry + * @param {String} className + * + * @returns {Function|undefined} + */ + this.resolveClass = function (registry, className) { + if (Object.prototype.hasOwnProperty.call(registry, className)) { + return registry[className]; + } + + return window[className]; + }; + /** * @param {Object} list */ @@ -1285,10 +1309,11 @@ var SvarohJsFormValidator = new function () { var constraints = []; for (var name in list) { var className = name.replace(/\\/g, ''); - if (undefined !== window[className]) { + var constraintClass = this.resolveClass(Svaroh.constraints, className); + if (undefined !== constraintClass) { var i = list[name].length; while (i--) { - var constraint = new window[className](); + var constraint = new constraintClass(); for (var param in list[name][i]) { constraint[param] = list[name][i][param]; } @@ -1314,8 +1339,9 @@ var SvarohJsFormValidator = new function () { var i = list.length; while (i--) { var className = String(list[i]['name']).replace(/\\/g, ''); - if (undefined !== window[className]) { - var transformer = new window[className](); + var transformerClass = this.resolveClass(Svaroh.transformers, className); + if (undefined !== transformerClass) { + var transformer = new transformerClass(); for (var propName in list[i]) { transformer[propName] = list[i][propName]; } diff --git a/src/Resources/public/js/constraints/globals.test.js b/src/Resources/public/js/constraints/globals.test.js index d6c61dab..e6e26580 100644 --- a/src/Resources/public/js/constraints/globals.test.js +++ b/src/Resources/public/js/constraints/globals.test.js @@ -37,11 +37,22 @@ const constraintGlobals = [ 'SymfonyComponentValidatorConstraintsValid', ]; -// The validator instantiates constraints as window[className], so a constraint -// that is not exported to the global scope is silently skipped at runtime. +// The validator instantiates constraints as Svaroh.constraints[className], so a +// constraint that is not registered in the namespace is silently skipped at +// runtime. test.each(constraintGlobals)( - 'window.%s is registered', + 'Svaroh.constraints.%s is registered', + (name) => { + expect(typeof window.Svaroh.constraints[name]).toBe('function'); + }, +); + +// The global name of every constraint is kept as a deprecated alias: user code +// may define custom constraints and Callback handlers against it. +test.each(constraintGlobals)( + 'window.%s is a deprecated alias of the namespaced constraint', (name) => { expect(typeof window[name]).toBe('function'); + expect(window[name]).toBe(window.Svaroh.constraints[name]); }, ); diff --git a/src/Resources/public/js/constraints/index.js b/src/Resources/public/js/constraints/index.js index 4584b987..a9d2ba58 100644 --- a/src/Resources/public/js/constraints/index.js +++ b/src/Resources/public/js/constraints/index.js @@ -1,31 +1,76 @@ -import './Blank.js'; -import './Callback.js'; -import './Choice.js'; -import './Count.js'; -import './Date.js'; -import './DateTime.js'; -import './Email.js'; -import './EqualTo.js'; -import './File.js'; -import './GreaterThan.js'; -import './GreaterThanOrEqual.js'; -import './IdenticalTo.js'; -import './Ip.js'; -import './IsFalse.js'; -import './IsNull.js'; -import './IsTrue.js'; -import './Length.js'; -import './LessThan.js'; -import './LessThanOrEqual.js'; -import './Luhn.js'; -import './NotBlank.js'; -import './NotEqualTo.js'; -import './NotIdenticalTo.js'; -import './NotNull.js'; -import './Range.js'; -import './Regex.js'; -import './Time.js'; -import './Type.js'; -import './UniqueEntity.js'; -import './Url.js'; -import './Valid.js'; +import Svaroh from '../namespace.js'; + +import SymfonyComponentValidatorConstraintsBlank from './Blank.js'; +import SymfonyComponentValidatorConstraintsCallback from './Callback.js'; +import SymfonyComponentValidatorConstraintsChoice from './Choice.js'; +import SymfonyComponentValidatorConstraintsCount from './Count.js'; +import SymfonyComponentValidatorConstraintsDate from './Date.js'; +import SymfonyComponentValidatorConstraintsDateTime from './DateTime.js'; +import SymfonyComponentValidatorConstraintsEmail from './Email.js'; +import SymfonyComponentValidatorConstraintsEqualTo from './EqualTo.js'; +import SymfonyComponentValidatorConstraintsFile from './File.js'; +import SymfonyComponentValidatorConstraintsGreaterThan from './GreaterThan.js'; +import SymfonyComponentValidatorConstraintsGreaterThanOrEqual from './GreaterThanOrEqual.js'; +import SymfonyComponentValidatorConstraintsIdenticalTo from './IdenticalTo.js'; +import SymfonyComponentValidatorConstraintsIp from './Ip.js'; +import SymfonyComponentValidatorConstraintsIsFalse from './IsFalse.js'; +import SymfonyComponentValidatorConstraintsIsNull from './IsNull.js'; +import SymfonyComponentValidatorConstraintsIsTrue from './IsTrue.js'; +import SymfonyComponentValidatorConstraintsLength from './Length.js'; +import SymfonyComponentValidatorConstraintsLessThan from './LessThan.js'; +import SymfonyComponentValidatorConstraintsLessThanOrEqual from './LessThanOrEqual.js'; +import SymfonyComponentValidatorConstraintsLuhn from './Luhn.js'; +import SymfonyComponentValidatorConstraintsNotBlank from './NotBlank.js'; +import SymfonyComponentValidatorConstraintsNotEqualTo from './NotEqualTo.js'; +import SymfonyComponentValidatorConstraintsNotIdenticalTo from './NotIdenticalTo.js'; +import SymfonyComponentValidatorConstraintsNotNull from './NotNull.js'; +import SymfonyComponentValidatorConstraintsRange from './Range.js'; +import SymfonyComponentValidatorConstraintsRegex from './Regex.js'; +import SymfonyComponentValidatorConstraintsTime from './Time.js'; +import SymfonyComponentValidatorConstraintsType from './Type.js'; +import SvarohJsFormValidatorBundleFormConstraintUniqueEntity from './UniqueEntity.js'; +import SymfonyComponentValidatorConstraintsUrl from './Url.js'; +import SymfonyComponentValidatorConstraintsValid from './Valid.js'; + +// The constraints are keyed by the name the PHP side serialises them under: +// their class name with the namespace separators removed. Every module also +// still assigns that name on "window" as a deprecated alias. +Object.assign(Svaroh.constraints, { + SymfonyComponentValidatorConstraintsBlank, + SymfonyComponentValidatorConstraintsCallback, + SymfonyComponentValidatorConstraintsChoice, + SymfonyComponentValidatorConstraintsCount, + SymfonyComponentValidatorConstraintsDate, + SymfonyComponentValidatorConstraintsDateTime, + SymfonyComponentValidatorConstraintsEmail, + SymfonyComponentValidatorConstraintsEqualTo, + SymfonyComponentValidatorConstraintsFile, + SymfonyComponentValidatorConstraintsGreaterThan, + SymfonyComponentValidatorConstraintsGreaterThanOrEqual, + SymfonyComponentValidatorConstraintsIdenticalTo, + SymfonyComponentValidatorConstraintsIp, + SymfonyComponentValidatorConstraintsIsFalse, + SymfonyComponentValidatorConstraintsIsNull, + SymfonyComponentValidatorConstraintsIsTrue, + SymfonyComponentValidatorConstraintsLength, + SymfonyComponentValidatorConstraintsLessThan, + SymfonyComponentValidatorConstraintsLessThanOrEqual, + SymfonyComponentValidatorConstraintsLuhn, + SymfonyComponentValidatorConstraintsNotBlank, + SymfonyComponentValidatorConstraintsNotEqualTo, + SymfonyComponentValidatorConstraintsNotIdenticalTo, + SymfonyComponentValidatorConstraintsNotNull, + SymfonyComponentValidatorConstraintsRange, + SymfonyComponentValidatorConstraintsRegex, + SymfonyComponentValidatorConstraintsTime, + SymfonyComponentValidatorConstraintsType, + SvarohJsFormValidatorBundleFormConstraintUniqueEntity, + SymfonyComponentValidatorConstraintsUrl, + SymfonyComponentValidatorConstraintsValid, + + // The names the False, Null and True constraints had before Symfony 2.7 + // renamed them, they are still reachable as class aliases in PHP + SymfonyComponentValidatorConstraintsFalse: SymfonyComponentValidatorConstraintsIsFalse, + SymfonyComponentValidatorConstraintsNull: SymfonyComponentValidatorConstraintsIsNull, + SymfonyComponentValidatorConstraintsTrue: SymfonyComponentValidatorConstraintsIsTrue, +}); diff --git a/src/Resources/public/js/namespace.js b/src/Resources/public/js/namespace.js new file mode 100644 index 00000000..c33eb6e1 --- /dev/null +++ b/src/Resources/public/js/namespace.js @@ -0,0 +1,32 @@ +/** + * The namespace the library registers its constraints and transformers in. + * + * The PHP side serialises the class name of every constraint and of every view + * transformer into the form model, and the browser side instantiates them by + * that name with the namespace separators removed. Each of those names used to + * be a separate property of "window", which is what the "Add namespace in JS + * lib" issue objects to. + * + * The name mirrors the "Svaroh\JsFormValidatorBundle" PHP namespace and the + * "Svaroh" prefix the library already uses for its own globals. The object is + * merged into whatever is on "window.Svaroh" already, so an application that + * owns a "Svaroh" object of its own keeps it. + * + * An application registers its own constraint like this: + * + * Svaroh.constraints.AppValidatorConstraintsContainsAlphanumeric = function () { + * // ... + * }; + * + * Constraints and transformers that are only defined as globals keep working: + * the lookup falls back to the global scope, see + * SvarohJsFormValidator.resolveClass(). + */ +var Svaroh = window.Svaroh || {}; + +Svaroh.constraints = Svaroh.constraints || {}; +Svaroh.transformers = Svaroh.transformers || {}; + +window.Svaroh = Svaroh; + +export default Svaroh; diff --git a/src/Resources/public/js/namespace.test.js b/src/Resources/public/js/namespace.test.js new file mode 100644 index 00000000..9bbfa504 --- /dev/null +++ b/src/Resources/public/js/namespace.test.js @@ -0,0 +1,131 @@ +import './SvarohJsFormValidator'; + +const validator = window.SvarohJsFormValidator; + +describe('the Svaroh namespace', () => { + test('is the single global the library registers its classes in', () => { + expect(typeof window.Svaroh).toBe('object'); + expect(typeof window.Svaroh.constraints).toBe('object'); + expect(typeof window.Svaroh.transformers).toBe('object'); + }); + + test('a constraint is instantiated from the namespace, not from the global scope', () => { + const constraintClass = window.SymfonyComponentValidatorConstraintsNotBlank; + delete window.SymfonyComponentValidatorConstraintsNotBlank; + + try { + const constraints = validator.parseConstraints({ + 'Symfony\\Component\\Validator\\Constraints\\NotBlank': [ + { message: 'This value should not be blank.' }, + ], + }); + + expect(constraints).toHaveLength(1); + expect(constraints[0]).toBeInstanceOf(constraintClass); + expect(constraints[0].message).toBe('This value should not be blank.'); + } finally { + window.SymfonyComponentValidatorConstraintsNotBlank = constraintClass; + } + }); + + test('a transformer is instantiated from the namespace, not from the global scope', () => { + const chainClass = window.SymfonyComponentFormExtensionCoreDataTransformerDataTransformerChain; + const valueClass = window.SymfonyComponentFormExtensionCoreDataTransformerChoiceToValueTransformer; + delete window.SymfonyComponentFormExtensionCoreDataTransformerDataTransformerChain; + delete window.SymfonyComponentFormExtensionCoreDataTransformerChoiceToValueTransformer; + + try { + const transformers = validator.parseTransformers([ + { + name: 'Symfony\\Component\\Form\\Extension\\Core\\DataTransformer\\DataTransformerChain', + transformers: [ + { + name: 'Symfony\\Component\\Form\\Extension\\Core\\DataTransformer\\ChoiceToValueTransformer', + }, + ], + }, + ]); + + expect(transformers).toHaveLength(1); + expect(transformers[0]).toBeInstanceOf(chainClass); + expect(transformers[0].transformers).toHaveLength(1); + expect(transformers[0].transformers[0]).toBeInstanceOf(valueClass); + } finally { + window.SymfonyComponentFormExtensionCoreDataTransformerDataTransformerChain = chainClass; + window.SymfonyComponentFormExtensionCoreDataTransformerChoiceToValueTransformer = valueClass; + } + }); + + test('an application constraint defined in the global scope is still instantiated', () => { + window.AppValidatorConstraintsContainsAlphanumeric = function () { + this.message = ''; + + this.validate = function () { + return []; + }; + }; + + try { + const constraints = validator.parseConstraints({ + 'App\\Validator\\Constraints\\ContainsAlphanumeric': [ + { message: 'It can only contain letters or numbers.' }, + ], + }); + + expect(constraints).toHaveLength(1); + expect(constraints[0].message).toBe('It can only contain letters or numbers.'); + } finally { + delete window.AppValidatorConstraintsContainsAlphanumeric; + } + }); + + test('an application transformer defined in the global scope is still instantiated', () => { + window.AppFormDataTransformerMyTransformer = function () { + this.reverseTransform = function (value) { + return value; + }; + }; + + try { + const transformers = validator.parseTransformers([ + { name: 'App\\Form\\DataTransformer\\MyTransformer', extraOption: 'kept' }, + ]); + + expect(transformers).toHaveLength(1); + expect(transformers[0].extraOption).toBe('kept'); + } finally { + delete window.AppFormDataTransformerMyTransformer; + } + }); + + test('an application class registered in the namespace is instantiated', () => { + window.Svaroh.constraints.AppValidatorConstraintsNamespaced = function () { + this.message = ''; + + this.validate = function () { + return []; + }; + }; + + try { + const constraints = validator.parseConstraints({ + 'App\\Validator\\Constraints\\Namespaced': [{ message: 'Namespaced.' }], + }); + + expect(constraints).toHaveLength(1); + expect(constraints[0].message).toBe('Namespaced.'); + } finally { + delete window.Svaroh.constraints.AppValidatorConstraintsNamespaced; + } + }); + + test('a name the namespace and the global scope do not know is skipped', () => { + expect(validator.parseConstraints({ + 'App\\Validator\\Constraints\\Unknown': [{ message: 'Unknown.' }], + })).toStrictEqual([]); + + expect(validator.parseTransformers([ + { name: 'App\\Form\\DataTransformer\\Unknown' }, + ])).toStrictEqual([]); + }); +}); diff --git a/src/Resources/public/js/transformers/globals.test.js b/src/Resources/public/js/transformers/globals.test.js index c686eb20..5be4a043 100644 --- a/src/Resources/public/js/transformers/globals.test.js +++ b/src/Resources/public/js/transformers/globals.test.js @@ -18,12 +18,21 @@ const transformerGlobals = [ namespace + 'ValueToDuplicatesTransformer', ]; -// parseTransformers() instantiates a transformer as window[className] and skips -// it without a word when the global is undefined, which is how the localized -// number types went unvalidated in the first place. +// parseTransformers() instantiates a transformer as Svaroh.transformers[className] +// and skips it without a word when the namespace holds no such name, which is how +// the localized number types went unvalidated in the first place. test.each(transformerGlobals)( - 'window.%s is registered', + 'Svaroh.transformers.%s is registered', + (name) => { + expect(typeof window.Svaroh.transformers[name]).toBe('function'); + }, +); + +// The global name of every transformer is kept as a deprecated alias. +test.each(transformerGlobals)( + 'window.%s is a deprecated alias of the namespaced transformer', (name) => { expect(typeof window[name]).toBe('function'); + expect(window[name]).toBe(window.Svaroh.transformers[name]); }, ); diff --git a/src/Resources/public/js/transformers/index.js b/src/Resources/public/js/transformers/index.js index 81e33138..3b3ddae4 100644 --- a/src/Resources/public/js/transformers/index.js +++ b/src/Resources/public/js/transformers/index.js @@ -1,13 +1,34 @@ -import './ArrayToParts.js'; -import './BooleanToString.js'; -import './ChoicesToBooleanArray.js'; -import './ChoicesToValues.js'; -import './ChoiceToBooleanArray.js'; -import './ChoiceToValue.js'; -import './DataTransformerChain.js'; -import './DateTimeToArray.js'; -import './IntegerToLocalizedString.js'; -import './MoneyToLocalizedString.js'; -import './NumberToLocalizedString.js'; -import './PercentToLocalizedString.js'; -import './ValueToDuplicates.js'; \ No newline at end of file +import Svaroh from '../namespace.js'; + +import SymfonyComponentFormExtensionCoreDataTransformerArrayToPartsTransformer from './ArrayToParts.js'; +import SymfonyComponentFormExtensionCoreDataTransformerBooleanToStringTransformer from './BooleanToString.js'; +import SymfonyComponentFormExtensionCoreDataTransformerChoicesToBooleanArrayTransformer from './ChoicesToBooleanArray.js'; +import SymfonyComponentFormExtensionCoreDataTransformerChoicesToValuesTransformer from './ChoicesToValues.js'; +import SymfonyComponentFormExtensionCoreDataTransformerChoiceToBooleanArrayTransformer from './ChoiceToBooleanArray.js'; +import SymfonyComponentFormExtensionCoreDataTransformerChoiceToValueTransformer from './ChoiceToValue.js'; +import SymfonyComponentFormExtensionCoreDataTransformerDataTransformerChain from './DataTransformerChain.js'; +import SymfonyComponentFormExtensionCoreDataTransformerDateTimeToArrayTransformer from './DateTimeToArray.js'; +import SymfonyComponentFormExtensionCoreDataTransformerIntegerToLocalizedStringTransformer from './IntegerToLocalizedString.js'; +import SymfonyComponentFormExtensionCoreDataTransformerMoneyToLocalizedStringTransformer from './MoneyToLocalizedString.js'; +import SymfonyComponentFormExtensionCoreDataTransformerNumberToLocalizedStringTransformer from './NumberToLocalizedString.js'; +import SymfonyComponentFormExtensionCoreDataTransformerPercentToLocalizedStringTransformer from './PercentToLocalizedString.js'; +import SymfonyComponentFormExtensionCoreDataTransformerValueToDuplicatesTransformer from './ValueToDuplicates.js'; + +// The transformers are keyed by the name the PHP side serialises them under: +// their class name with the namespace separators removed. Every module also +// still assigns that name on "window" as a deprecated alias. +Object.assign(Svaroh.transformers, { + SymfonyComponentFormExtensionCoreDataTransformerArrayToPartsTransformer, + SymfonyComponentFormExtensionCoreDataTransformerBooleanToStringTransformer, + SymfonyComponentFormExtensionCoreDataTransformerChoicesToBooleanArrayTransformer, + SymfonyComponentFormExtensionCoreDataTransformerChoicesToValuesTransformer, + SymfonyComponentFormExtensionCoreDataTransformerChoiceToBooleanArrayTransformer, + SymfonyComponentFormExtensionCoreDataTransformerChoiceToValueTransformer, + SymfonyComponentFormExtensionCoreDataTransformerDataTransformerChain, + SymfonyComponentFormExtensionCoreDataTransformerDateTimeToArrayTransformer, + SymfonyComponentFormExtensionCoreDataTransformerIntegerToLocalizedStringTransformer, + SymfonyComponentFormExtensionCoreDataTransformerMoneyToLocalizedStringTransformer, + SymfonyComponentFormExtensionCoreDataTransformerNumberToLocalizedStringTransformer, + SymfonyComponentFormExtensionCoreDataTransformerPercentToLocalizedStringTransformer, + SymfonyComponentFormExtensionCoreDataTransformerValueToDuplicatesTransformer, +});