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, +});