diff --git a/Gemfile b/Gemfile index 7f4f5e9..5088d39 100644 --- a/Gemfile +++ b/Gemfile @@ -3,3 +3,5 @@ source 'https://rubygems.org' gemspec + +gem 'client_side_validations', github: 'MichalRemis/client_side_validations', branch: 'attach-many-JS-validations' diff --git a/client_side_validations-simple_form.gemspec b/client_side_validations-simple_form.gemspec index 20edf37..221168e 100644 --- a/client_side_validations-simple_form.gemspec +++ b/client_side_validations-simple_form.gemspec @@ -39,6 +39,7 @@ Gem::Specification.new do |spec| spec.add_development_dependency 'rubocop-performance', '~> 1.5' spec.add_development_dependency 'rubocop-rails', '~> 2.5' spec.add_development_dependency 'simplecov', '~> 0.18.5' + spec.add_development_dependency 'sqlite3', '~> 1.4' # For QUnit testing spec.add_development_dependency 'shotgun', '~> 0.9.2' diff --git a/dist/simple-form.bootstrap4.esm.js b/dist/simple-form.bootstrap4.esm.js index 5cf2108..1cc7dc9 100644 --- a/dist/simple-form.bootstrap4.esm.js +++ b/dist/simple-form.bootstrap4.esm.js @@ -7,16 +7,103 @@ import $ from 'jquery'; import ClientSideValidations from '@client-side-validations/client-side-validations'; -ClientSideValidations.formBuilders['SimpleForm::FormBuilder'] = { +function checkedInputsCount(element) { + var formSettings = element.closest('form[data-client-side-validations]').data('clientSideValidations'); + var wrapperClass = formSettings.html_settings.wrapper_class; + return element.closest(".".concat(wrapperClass.replace(/ /g, '.'))).find('input:checked').length; +} + +var originalLengthValidator = ClientSideValidations.validators.local.length; +var VALIDATIONS = { + is: function is(a, b) { + return a === parseInt(b, 10); + }, + minimum: function minimum(a, b) { + return a >= parseInt(b, 10); + }, + maximum: function maximum(a, b) { + return a <= parseInt(b, 10); + } +}; + +var runValidations = function runValidations(valueLength, options) { + for (var validation in VALIDATIONS) { + var validationOption = options[validation]; + var validationFunction = VALIDATIONS[validation]; + + if (validationOption && !validationFunction(valueLength, validationOption)) { + return options.messages[validation]; + } + } +}; + +ClientSideValidations.validators.local.length = function (element, options) { + if (element.attr('type') === 'checkbox') { + var count = checkedInputsCount(element); + + if (options.allow_blank && count === 0) { + return; + } + + return runValidations(count, options); + } else { + return originalLengthValidator(element, options); + } +}; + +var originalPresenceValidator = ClientSideValidations.validators.local.presence; + +ClientSideValidations.validators.local.presence = function (element, options) { + if (element.attr('type') === 'checkbox' || element.attr('type') === 'radio') { + if (checkedInputsCount(element) === 0) { + return options.message; + } + } else { + return originalPresenceValidator(element, options); + } +}; + +// It could be fixed in main CSV if radio_buttons validations are needed there and +// in that case we may removed it from here + +var originalInputEnabler = window.ClientSideValidations.enablers.input; + +window.ClientSideValidations.enablers.input = function (input) { + originalInputEnabler(input); + var $input = $(input); + var form = input.form; + var eventsToBind = window.ClientSideValidations.eventsToBind.input(form); + var wrapperClass = form.ClientSideValidations.settings.html_settings.wrapper_class; + + for (var eventName in eventsToBind) { + var eventFunction = eventsToBind[eventName]; + $input.filter(':radio').each(function () { + return $(this).attr('data-validate', true); + }).on(eventName, eventFunction); + } + + $input.filter(':radio').on('change.ClientSideValidations', function () { + $(this).isValid(form.ClientSideValidations.settings.validators); + }); // when we change radio/check mark also all sibling radios/checkboxes as changed to revalidate on submit + + $input.filter(':radio,:checkbox').on('change.ClientSideValidations', function () { + $(this).closest(".".concat(wrapperClass.replace(/ /g, '.'))).find(':radio,:checkbox').data('changed', true); + }); +}; + +var simpleFormFormBuilder = { add: function add(element, settings, message) { - this.wrapper(settings.wrapper).add.call(this, element, settings, message); + this.wrapper(this.wrapperName(element, settings)).add.call(this, element, settings, message); }, remove: function remove(element, settings) { - this.wrapper(settings.wrapper).remove.call(this, element, settings); + this.wrapper(this.wrapperName(element, settings)).remove.call(this, element, settings); }, wrapper: function wrapper(name) { return this.wrappers[name] || this.wrappers["default"]; }, + wrapperName: function wrapperName(element, settings) { + return element.data('clientSideValidationsWrapper') || settings.wrapper; + }, wrappers: { "default": { add: function add(element, settings, message) { @@ -42,6 +129,34 @@ ClientSideValidations.formBuilders['SimpleForm::FormBuilder'] = { element.removeClass('is-invalid'); errorElement.remove(); } + }, + vertical_collection: { + add: function add(element, settings, message) { + var wrapperElement = element.closest('.' + settings.wrapper_class.replace(/ /g, '.')); + var errorElement = wrapperElement.find(settings.error_tag + '.invalid-feedback'); + + if (!errorElement.length) { + errorElement = $('<' + settings.error_tag + '>', { + "class": 'invalid-feedback d-block', + text: message + }); + element.closest('.form-check').parent().children('.form-check:last').after(errorElement); + element.closest('.form-check').parent().children('.form-check:last').after(errorElement); + } + + wrapperElement.addClass(settings.wrapper_error_class); + wrapperElement.find('input:visible').addClass('is-invalid'); + errorElement.text(message); + }, + remove: function remove(element, settings) { + var wrapperElement = element.closest('.' + settings.wrapper_class.replace(/ /g, '.')); + var errorElement = wrapperElement.find(settings.error_tag + '.invalid-feedback'); + wrapperElement.removeClass(settings.wrapper_error_class); + errorElement.remove(); + wrapperElement.find('input:visible').removeClass('is-invalid'); + } } } }; +simpleFormFormBuilder.wrappers.horizontal_collection = simpleFormFormBuilder.wrappers.vertical_collection; +ClientSideValidations.formBuilders['SimpleForm::FormBuilder'] = simpleFormFormBuilder; diff --git a/dist/simple-form.bootstrap4.js b/dist/simple-form.bootstrap4.js index e5bd428..a5ae166 100644 --- a/dist/simple-form.bootstrap4.js +++ b/dist/simple-form.bootstrap4.js @@ -13,16 +13,103 @@ $ = $ && Object.prototype.hasOwnProperty.call($, 'default') ? $['default'] : $; ClientSideValidations = ClientSideValidations && Object.prototype.hasOwnProperty.call(ClientSideValidations, 'default') ? ClientSideValidations['default'] : ClientSideValidations; - ClientSideValidations.formBuilders['SimpleForm::FormBuilder'] = { + function checkedInputsCount(element) { + var formSettings = element.closest('form[data-client-side-validations]').data('clientSideValidations'); + var wrapperClass = formSettings.html_settings.wrapper_class; + return element.closest(".".concat(wrapperClass.replace(/ /g, '.'))).find('input:checked').length; + } + + var originalLengthValidator = ClientSideValidations.validators.local.length; + var VALIDATIONS = { + is: function is(a, b) { + return a === parseInt(b, 10); + }, + minimum: function minimum(a, b) { + return a >= parseInt(b, 10); + }, + maximum: function maximum(a, b) { + return a <= parseInt(b, 10); + } + }; + + var runValidations = function runValidations(valueLength, options) { + for (var validation in VALIDATIONS) { + var validationOption = options[validation]; + var validationFunction = VALIDATIONS[validation]; + + if (validationOption && !validationFunction(valueLength, validationOption)) { + return options.messages[validation]; + } + } + }; + + ClientSideValidations.validators.local.length = function (element, options) { + if (element.attr('type') === 'checkbox') { + var count = checkedInputsCount(element); + + if (options.allow_blank && count === 0) { + return; + } + + return runValidations(count, options); + } else { + return originalLengthValidator(element, options); + } + }; + + var originalPresenceValidator = ClientSideValidations.validators.local.presence; + + ClientSideValidations.validators.local.presence = function (element, options) { + if (element.attr('type') === 'checkbox' || element.attr('type') === 'radio') { + if (checkedInputsCount(element) === 0) { + return options.message; + } + } else { + return originalPresenceValidator(element, options); + } + }; + + // It could be fixed in main CSV if radio_buttons validations are needed there and + // in that case we may removed it from here + + var originalInputEnabler = window.ClientSideValidations.enablers.input; + + window.ClientSideValidations.enablers.input = function (input) { + originalInputEnabler(input); + var $input = $(input); + var form = input.form; + var eventsToBind = window.ClientSideValidations.eventsToBind.input(form); + var wrapperClass = form.ClientSideValidations.settings.html_settings.wrapper_class; + + for (var eventName in eventsToBind) { + var eventFunction = eventsToBind[eventName]; + $input.filter(':radio').each(function () { + return $(this).attr('data-validate', true); + }).on(eventName, eventFunction); + } + + $input.filter(':radio').on('change.ClientSideValidations', function () { + $(this).isValid(form.ClientSideValidations.settings.validators); + }); // when we change radio/check mark also all sibling radios/checkboxes as changed to revalidate on submit + + $input.filter(':radio,:checkbox').on('change.ClientSideValidations', function () { + $(this).closest(".".concat(wrapperClass.replace(/ /g, '.'))).find(':radio,:checkbox').data('changed', true); + }); + }; + + var simpleFormFormBuilder = { add: function add(element, settings, message) { - this.wrapper(settings.wrapper).add.call(this, element, settings, message); + this.wrapper(this.wrapperName(element, settings)).add.call(this, element, settings, message); }, remove: function remove(element, settings) { - this.wrapper(settings.wrapper).remove.call(this, element, settings); + this.wrapper(this.wrapperName(element, settings)).remove.call(this, element, settings); }, wrapper: function wrapper(name) { return this.wrappers[name] || this.wrappers["default"]; }, + wrapperName: function wrapperName(element, settings) { + return element.data('clientSideValidationsWrapper') || settings.wrapper; + }, wrappers: { "default": { add: function add(element, settings, message) { @@ -48,8 +135,36 @@ element.removeClass('is-invalid'); errorElement.remove(); } + }, + vertical_collection: { + add: function add(element, settings, message) { + var wrapperElement = element.closest('.' + settings.wrapper_class.replace(/ /g, '.')); + var errorElement = wrapperElement.find(settings.error_tag + '.invalid-feedback'); + + if (!errorElement.length) { + errorElement = $('<' + settings.error_tag + '>', { + "class": 'invalid-feedback d-block', + text: message + }); + element.closest('.form-check').parent().children('.form-check:last').after(errorElement); + element.closest('.form-check').parent().children('.form-check:last').after(errorElement); + } + + wrapperElement.addClass(settings.wrapper_error_class); + wrapperElement.find('input:visible').addClass('is-invalid'); + errorElement.text(message); + }, + remove: function remove(element, settings) { + var wrapperElement = element.closest('.' + settings.wrapper_class.replace(/ /g, '.')); + var errorElement = wrapperElement.find(settings.error_tag + '.invalid-feedback'); + wrapperElement.removeClass(settings.wrapper_error_class); + errorElement.remove(); + wrapperElement.find('input:visible').removeClass('is-invalid'); + } } } }; + simpleFormFormBuilder.wrappers.horizontal_collection = simpleFormFormBuilder.wrappers.vertical_collection; + ClientSideValidations.formBuilders['SimpleForm::FormBuilder'] = simpleFormFormBuilder; }))); diff --git a/dist/simple-form.esm.js b/dist/simple-form.esm.js index 22b06e5..089cc52 100644 --- a/dist/simple-form.esm.js +++ b/dist/simple-form.esm.js @@ -7,16 +7,103 @@ import $ from 'jquery'; import ClientSideValidations from '@client-side-validations/client-side-validations'; +function checkedInputsCount(element) { + var formSettings = element.closest('form[data-client-side-validations]').data('clientSideValidations'); + var wrapperClass = formSettings.html_settings.wrapper_class; + return element.closest(".".concat(wrapperClass.replace(/ /g, '.'))).find('input:checked').length; +} + +var originalLengthValidator = ClientSideValidations.validators.local.length; +var VALIDATIONS = { + is: function is(a, b) { + return a === parseInt(b, 10); + }, + minimum: function minimum(a, b) { + return a >= parseInt(b, 10); + }, + maximum: function maximum(a, b) { + return a <= parseInt(b, 10); + } +}; + +var runValidations = function runValidations(valueLength, options) { + for (var validation in VALIDATIONS) { + var validationOption = options[validation]; + var validationFunction = VALIDATIONS[validation]; + + if (validationOption && !validationFunction(valueLength, validationOption)) { + return options.messages[validation]; + } + } +}; + +ClientSideValidations.validators.local.length = function (element, options) { + if (element.attr('type') === 'checkbox') { + var count = checkedInputsCount(element); + + if (options.allow_blank && count === 0) { + return; + } + + return runValidations(count, options); + } else { + return originalLengthValidator(element, options); + } +}; + +var originalPresenceValidator = ClientSideValidations.validators.local.presence; + +ClientSideValidations.validators.local.presence = function (element, options) { + if (element.attr('type') === 'checkbox' || element.attr('type') === 'radio') { + if (checkedInputsCount(element) === 0) { + return options.message; + } + } else { + return originalPresenceValidator(element, options); + } +}; + +// It could be fixed in main CSV if radio_buttons validations are needed there and +// in that case we may removed it from here + +var originalInputEnabler = window.ClientSideValidations.enablers.input; + +window.ClientSideValidations.enablers.input = function (input) { + originalInputEnabler(input); + var $input = $(input); + var form = input.form; + var eventsToBind = window.ClientSideValidations.eventsToBind.input(form); + var wrapperClass = form.ClientSideValidations.settings.html_settings.wrapper_class; + + for (var eventName in eventsToBind) { + var eventFunction = eventsToBind[eventName]; + $input.filter(':radio').each(function () { + return $(this).attr('data-validate', true); + }).on(eventName, eventFunction); + } + + $input.filter(':radio').on('change.ClientSideValidations', function () { + $(this).isValid(form.ClientSideValidations.settings.validators); + }); // when we change radio/check mark also all sibling radios/checkboxes as changed to revalidate on submit + + $input.filter(':radio,:checkbox').on('change.ClientSideValidations', function () { + $(this).closest(".".concat(wrapperClass.replace(/ /g, '.'))).find(':radio,:checkbox').data('changed', true); + }); +}; + ClientSideValidations.formBuilders['SimpleForm::FormBuilder'] = { add: function add(element, settings, message) { - this.wrapper(settings.wrapper).add.call(this, element, settings, message); + this.wrapper(this.wrapperName(element, settings)).add.call(this, element, settings, message); }, remove: function remove(element, settings) { - this.wrapper(settings.wrapper).remove.call(this, element, settings); + this.wrapper(this.wrapperName(element, settings)).remove.call(this, element, settings); }, wrapper: function wrapper(name) { return this.wrappers[name] || this.wrappers["default"]; }, + wrapperName: function wrapperName(element, settings) { + return element.data('clientSideValidationsWrapper') || settings.wrapper; + }, wrappers: { "default": { add: function add(element, settings, message) { @@ -28,7 +115,12 @@ ClientSideValidations.formBuilders['SimpleForm::FormBuilder'] = { "class": settings.error_class, text: message }); - wrapper.append(errorElement); + + if (wrapper.hasClass('check_boxes') || wrapper.hasClass('radio_buttons')) { + element.closest('.checkbox,.radio').parent().children('.checkbox:last, .radio:last').after(errorElement); + } else { + wrapper.append(errorElement); + } } wrapper.addClass(settings.wrapper_error_class); diff --git a/dist/simple-form.js b/dist/simple-form.js index 0a24645..45ae5c8 100644 --- a/dist/simple-form.js +++ b/dist/simple-form.js @@ -13,16 +13,103 @@ $ = $ && Object.prototype.hasOwnProperty.call($, 'default') ? $['default'] : $; ClientSideValidations = ClientSideValidations && Object.prototype.hasOwnProperty.call(ClientSideValidations, 'default') ? ClientSideValidations['default'] : ClientSideValidations; + function checkedInputsCount(element) { + var formSettings = element.closest('form[data-client-side-validations]').data('clientSideValidations'); + var wrapperClass = formSettings.html_settings.wrapper_class; + return element.closest(".".concat(wrapperClass.replace(/ /g, '.'))).find('input:checked').length; + } + + var originalLengthValidator = ClientSideValidations.validators.local.length; + var VALIDATIONS = { + is: function is(a, b) { + return a === parseInt(b, 10); + }, + minimum: function minimum(a, b) { + return a >= parseInt(b, 10); + }, + maximum: function maximum(a, b) { + return a <= parseInt(b, 10); + } + }; + + var runValidations = function runValidations(valueLength, options) { + for (var validation in VALIDATIONS) { + var validationOption = options[validation]; + var validationFunction = VALIDATIONS[validation]; + + if (validationOption && !validationFunction(valueLength, validationOption)) { + return options.messages[validation]; + } + } + }; + + ClientSideValidations.validators.local.length = function (element, options) { + if (element.attr('type') === 'checkbox') { + var count = checkedInputsCount(element); + + if (options.allow_blank && count === 0) { + return; + } + + return runValidations(count, options); + } else { + return originalLengthValidator(element, options); + } + }; + + var originalPresenceValidator = ClientSideValidations.validators.local.presence; + + ClientSideValidations.validators.local.presence = function (element, options) { + if (element.attr('type') === 'checkbox' || element.attr('type') === 'radio') { + if (checkedInputsCount(element) === 0) { + return options.message; + } + } else { + return originalPresenceValidator(element, options); + } + }; + + // It could be fixed in main CSV if radio_buttons validations are needed there and + // in that case we may removed it from here + + var originalInputEnabler = window.ClientSideValidations.enablers.input; + + window.ClientSideValidations.enablers.input = function (input) { + originalInputEnabler(input); + var $input = $(input); + var form = input.form; + var eventsToBind = window.ClientSideValidations.eventsToBind.input(form); + var wrapperClass = form.ClientSideValidations.settings.html_settings.wrapper_class; + + for (var eventName in eventsToBind) { + var eventFunction = eventsToBind[eventName]; + $input.filter(':radio').each(function () { + return $(this).attr('data-validate', true); + }).on(eventName, eventFunction); + } + + $input.filter(':radio').on('change.ClientSideValidations', function () { + $(this).isValid(form.ClientSideValidations.settings.validators); + }); // when we change radio/check mark also all sibling radios/checkboxes as changed to revalidate on submit + + $input.filter(':radio,:checkbox').on('change.ClientSideValidations', function () { + $(this).closest(".".concat(wrapperClass.replace(/ /g, '.'))).find(':radio,:checkbox').data('changed', true); + }); + }; + ClientSideValidations.formBuilders['SimpleForm::FormBuilder'] = { add: function add(element, settings, message) { - this.wrapper(settings.wrapper).add.call(this, element, settings, message); + this.wrapper(this.wrapperName(element, settings)).add.call(this, element, settings, message); }, remove: function remove(element, settings) { - this.wrapper(settings.wrapper).remove.call(this, element, settings); + this.wrapper(this.wrapperName(element, settings)).remove.call(this, element, settings); }, wrapper: function wrapper(name) { return this.wrappers[name] || this.wrappers["default"]; }, + wrapperName: function wrapperName(element, settings) { + return element.data('clientSideValidationsWrapper') || settings.wrapper; + }, wrappers: { "default": { add: function add(element, settings, message) { @@ -34,7 +121,12 @@ "class": settings.error_class, text: message }); - wrapper.append(errorElement); + + if (wrapper.hasClass('check_boxes') || wrapper.hasClass('radio_buttons')) { + element.closest('.checkbox,.radio').parent().children('.checkbox:last, .radio:last').after(errorElement); + } else { + wrapper.append(errorElement); + } } wrapper.addClass(settings.wrapper_error_class); diff --git a/lib/client_side_validations/simple_form/form_builder.rb b/lib/client_side_validations/simple_form/form_builder.rb index 807622a..832694f 100644 --- a/lib/client_side_validations/simple_form/form_builder.rb +++ b/lib/client_side_validations/simple_form/form_builder.rb @@ -22,9 +22,21 @@ def input(attribute_name, options = {}, &block) options.delete(:validate) end + add_field_specific_wrapper_name_to_field_options(attribute_name, options, &block) + super(attribute_name, options, &block) end + # these methods don't call `super` in SimpleForm and therefore don't use overriden CSV FormBuilder methods + # and therefore aren't included in CSV validations hash.. we add them to the hash here + %i[collection_check_boxes collection_radio_buttons].each do |method_name| + define_method method_name do |method, collection, value_method, text_method, options = {}, html_options = {}, &block| # rubocop:disable Metrics/ParameterLists + build_validation_options method, html_options.merge(name: options[:name]) + add_field_specific_wrapper_name_to_field_options(method_name, options, &block) + super(method, collection, value_method, text_method, options, html_options, &block) + end + end + private def wrapper_error_component @@ -34,6 +46,14 @@ def wrapper_error_component wrapper.find(:full_error) end end + + def add_field_specific_wrapper_name_to_field_options(attribute_name, options, &block) + wrapper_name = options[:wrapper] || find_wrapper_mapping(find_input(attribute_name, options, &block).input_type) + return if wrapper_name.nil? + + options[:input_html] ||= {} + options[:input_html][:'data-client-side-validations-wrapper'] = wrapper_name + end end end end diff --git a/src/main.bootstrap4.js b/src/main.bootstrap4.js index dbb7e3f..4457c5c 100644 --- a/src/main.bootstrap4.js +++ b/src/main.bootstrap4.js @@ -1,22 +1,26 @@ import $ from 'jquery' import ClientSideValidations from '@client-side-validations/client-side-validations' +import './validator_overrides/index' -ClientSideValidations.formBuilders['SimpleForm::FormBuilder'] = { +const simpleFormFormBuilder = { add: function (element, settings, message) { - this.wrapper(settings.wrapper).add.call(this, element, settings, message) + this.wrapper(this.wrapperName(element, settings)).add.call(this, element, settings, message) }, remove: function (element, settings) { - this.wrapper(settings.wrapper).remove.call(this, element, settings) + this.wrapper(this.wrapperName(element, settings)).remove.call(this, element, settings) }, wrapper: function (name) { return this.wrappers[name] || this.wrappers.default }, + wrapperName: function (element, settings) { + return element.data('clientSideValidationsWrapper') || settings.wrapper + }, wrappers: { default: { add (element, settings, message) { const wrapperElement = element.parent() - let errorElement = wrapperElement.find(settings.error_tag + '.invalid-feedback') + var errorElement = wrapperElement.find(settings.error_tag + '.invalid-feedback') if (!errorElement.length) { errorElement = $('<' + settings.error_tag + '>', { class: 'invalid-feedback', text: message }) @@ -36,6 +40,35 @@ ClientSideValidations.formBuilders['SimpleForm::FormBuilder'] = { element.removeClass('is-invalid') errorElement.remove() } + }, + vertical_collection: { + add (element, settings, message) { + const wrapperElement = element.closest('.' + settings.wrapper_class.replace(/ /g, '.')) + var errorElement = wrapperElement.find(settings.error_tag + '.invalid-feedback') + + if (!errorElement.length) { + errorElement = $('<' + settings.error_tag + '>', { class: 'invalid-feedback d-block', text: message }) + element.closest('.form-check').parent().children('.form-check:last').after(errorElement) + element.closest('.form-check').parent().children('.form-check:last').after(errorElement) + } + + wrapperElement.addClass(settings.wrapper_error_class) + wrapperElement.find('input:visible').addClass('is-invalid') + errorElement.text(message) + }, + remove (element, settings) { + const wrapperElement = element.closest('.' + settings.wrapper_class.replace(/ /g, '.')) + const errorElement = wrapperElement.find(settings.error_tag + '.invalid-feedback') + + wrapperElement.removeClass(settings.wrapper_error_class) + errorElement.remove() + wrapperElement.find('input:visible').removeClass('is-invalid') + } + } } } + +simpleFormFormBuilder.wrappers.horizontal_collection = simpleFormFormBuilder.wrappers.vertical_collection + +ClientSideValidations.formBuilders['SimpleForm::FormBuilder'] = simpleFormFormBuilder diff --git a/src/main.js b/src/main.js index 0b27184..c0c5ee0 100644 --- a/src/main.js +++ b/src/main.js @@ -1,26 +1,35 @@ import $ from 'jquery' import ClientSideValidations from '@client-side-validations/client-side-validations' +import './validator_overrides/index' ClientSideValidations.formBuilders['SimpleForm::FormBuilder'] = { add: function (element, settings, message) { - this.wrapper(settings.wrapper).add.call(this, element, settings, message) + this.wrapper(this.wrapperName(element, settings)).add.call(this, element, settings, message) }, remove: function (element, settings) { - this.wrapper(settings.wrapper).remove.call(this, element, settings) + this.wrapper(this.wrapperName(element, settings)).remove.call(this, element, settings) }, wrapper: function (name) { return this.wrappers[name] || this.wrappers.default }, + wrapperName: function (element, settings) { + return element.data('clientSideValidationsWrapper') || settings.wrapper + }, wrappers: { default: { add (element, settings, message) { const wrapper = element.closest(settings.wrapper_tag + '.' + settings.wrapper_class.replace(/ /g, '.')) - let errorElement = wrapper.find(settings.error_tag + '.' + settings.error_class.replace(/ /g, '.')) + var errorElement = wrapper.find(settings.error_tag + '.' + settings.error_class.replace(/ /g, '.')) if (!errorElement.length) { errorElement = $('<' + settings.error_tag + '>', { class: settings.error_class, text: message }) - wrapper.append(errorElement) + + if (wrapper.hasClass('check_boxes') || wrapper.hasClass('radio_buttons')) { + element.closest('.checkbox,.radio').parent().children('.checkbox:last, .radio:last').after(errorElement) + } else { + wrapper.append(errorElement) + } } wrapper.addClass(settings.wrapper_error_class) diff --git a/src/validator_overrides/helper.js b/src/validator_overrides/helper.js new file mode 100644 index 0000000..0c4e958 --- /dev/null +++ b/src/validator_overrides/helper.js @@ -0,0 +1,7 @@ + +export function checkedInputsCount (element) { + const formSettings = element.closest('form[data-client-side-validations]').data('clientSideValidations') + const wrapperClass = formSettings.html_settings.wrapper_class + + return element.closest(`.${wrapperClass.replace(/ /g, '.')}`).find('input:checked').length +} diff --git a/src/validator_overrides/index.js b/src/validator_overrides/index.js new file mode 100644 index 0000000..28f8121 --- /dev/null +++ b/src/validator_overrides/index.js @@ -0,0 +1,34 @@ +import './length' +import './presence' +import $ from 'jquery' + +// main CSV enabler doesnt attach validation events to radio buttons.. I do it here +// It could be fixed in main CSV if radio_buttons validations are needed there and +// in that case we may removed it from here +const originalInputEnabler = window.ClientSideValidations.enablers.input + +window.ClientSideValidations.enablers.input = function (input) { + originalInputEnabler(input) + + const $input = $(input) + const form = input.form + const eventsToBind = window.ClientSideValidations.eventsToBind.input(form) + const wrapperClass = form.ClientSideValidations.settings.html_settings.wrapper_class + + for (const eventName in eventsToBind) { + const eventFunction = eventsToBind[eventName] + + $input.filter(':radio').each(function () { + return $(this).attr('data-validate', true) + }).on(eventName, eventFunction) + } + + $input.filter(':radio').on('change.ClientSideValidations', function () { + $(this).isValid(form.ClientSideValidations.settings.validators) + }) + + // when we change radio/check mark also all sibling radios/checkboxes as changed to revalidate on submit + $input.filter(':radio,:checkbox').on('change.ClientSideValidations', function () { + $(this).closest(`.${wrapperClass.replace(/ /g, '.')}`).find(':radio,:checkbox').data('changed', true) + }) +} diff --git a/src/validator_overrides/length.js b/src/validator_overrides/length.js new file mode 100644 index 0000000..d19ea72 --- /dev/null +++ b/src/validator_overrides/length.js @@ -0,0 +1,39 @@ +import ClientSideValidations from '@client-side-validations/client-side-validations' +import { checkedInputsCount } from './helper' + +const originalLengthValidator = ClientSideValidations.validators.local.length + +const VALIDATIONS = { + is: (a, b) => { + return (a === parseInt(b, 10)) + }, + minimum: (a, b) => { + return (a >= parseInt(b, 10)) + }, + maximum: (a, b) => { + return (a <= parseInt(b, 10)) + } +} + +const runValidations = (valueLength, options) => { + for (const validation in VALIDATIONS) { + const validationOption = options[validation] + const validationFunction = VALIDATIONS[validation] + + if (validationOption && !validationFunction(valueLength, validationOption)) { + return options.messages[validation] + } + } +} + +ClientSideValidations.validators.local.length = (element, options) => { + if (element.attr('type') === 'checkbox') { + const count = checkedInputsCount(element) + if (options.allow_blank && count === 0) { + return + } + return runValidations(count, options) + } else { + return originalLengthValidator(element, options) + } +} diff --git a/src/validator_overrides/presence.js b/src/validator_overrides/presence.js new file mode 100644 index 0000000..7f3675a --- /dev/null +++ b/src/validator_overrides/presence.js @@ -0,0 +1,14 @@ +import ClientSideValidations from '@client-side-validations/client-side-validations' +import { checkedInputsCount } from './helper' + +const originalPresenceValidator = ClientSideValidations.validators.local.presence + +ClientSideValidations.validators.local.presence = function (element, options) { + if (element.attr('type') === 'checkbox' || element.attr('type') === 'radio') { + if (checkedInputsCount(element) === 0) { + return options.message + } + } else { + return originalPresenceValidator(element, options) + } +} diff --git a/test/action_view/test_helper.rb b/test/action_view/test_helper.rb index 8d5457d..1048a0f 100644 --- a/test/action_view/test_helper.rb +++ b/test/action_view/test_helper.rb @@ -26,6 +26,7 @@ def _routes Routes.draw do resources :posts + resources :users end def default_url_options diff --git a/test/javascript/public/test/form_builders/validateSimpleForm.js b/test/javascript/public/test/form_builders/validateSimpleForm.js index a7d1c1b..7883471 100644 --- a/test/javascript/public/test/form_builders/validateSimpleForm.js +++ b/test/javascript/public/test/form_builders/validateSimpleForm.js @@ -20,7 +20,9 @@ QUnit.module('Validate SimpleForm', { wrapper: 'default' }, validators: { - 'user[name]': { presence: [{ message: 'must be present' }], format: [{ message: 'is invalid', 'with': { options: 'g', source: '\\d+' } }] } + 'user[name]': { presence: [{ message: 'must be present' }], format: [{ message: 'is invalid', 'with': { options: 'g', source: '\\d+' } }] }, + 'user[date_of_birth]': { presence: [{ message: 'must be present' }] }, + 'user[role_ids]': { presence: [{ message: 'must be present' }] } } } @@ -30,16 +32,66 @@ QUnit.module('Validate SimpleForm', { 'data-client-side-validations': JSON.stringify(dataCsv), method: 'post', id: 'new_user' - })) - .find('form') - .append($('
')) - .find('div') - .append($('', { - name: 'user[name]', - id: 'user_name', - type: 'text' - })) - .append($('')) + }) + .append($('
') + .append($('', { + name: 'user[name]', + id: 'user_name', + type: 'text' + })) + .append($('')) + ) + .append($('
') + .append($('', { + name: 'user[date_of_birth]', + id: 'date_of_birth', + type: 'text', + 'data-client-side-validations-wrapper': 'custom_date_wrapper' + })) + ) + .append($('
', { class: "input check_boxes required user_roles" }) + .append('') + .append('') + .append($('') + .append($('