|
| 1 | +/** |
| 2 | + * Collapse WET-BOEW / jQuery Validate errors for Webform checkbox *groups* so: |
| 3 | + * - "at least one checked" is enforced via require_from_group |
| 4 | + * - only ONE inline error is rendered for the whole set |
| 5 | + * - only ONE item appears in the WET error summary |
| 6 | + * - checking ANY checkbox in the set clears the group error |
| 7 | + * |
| 8 | + * How it works (high level): |
| 9 | + * 1) We wait until WET's validator is initialized on the form. |
| 10 | + * 2) We discover checkbox groups by looking for inputs named like base[option] |
| 11 | + * that also have data-rule-require_from_group (added in PHP preprocess). |
| 12 | + * 3) We register jQuery Validate "groups" for each base so the set acts as one field. |
| 13 | + * 4) We only *place* an inline error label for the FIRST checkbox of each base. |
| 14 | + * (The others are skipped to avoid duplicate labels.) |
| 15 | + * 5) We de-duplicate the validator's error list in invalidHandler so WET's top |
| 16 | + * summary only contains one entry per base. |
| 17 | + * 6) We prevent submission when the validator reports invalid, covering |
| 18 | + * normal submit + Enter key + direct submit-button clicks. |
| 19 | + */ |
| 20 | + |
| 21 | +(function ($, Drupal, once) { |
| 22 | + Drupal.behaviors.wetGroupOneMessage = { |
| 23 | + attach(context) { |
| 24 | + // Bind this behavior once per element with .wb-frmvld. |
| 25 | + const hosts = once('wetGroupOneMessage', '.wb-frmvld', context); |
| 26 | + |
| 27 | + hosts.forEach((host) => { |
| 28 | + const $host = $(host); |
| 29 | + |
| 30 | + // Find the <form> that is being validated. |
| 31 | + // In WxT it’s usually a child of .wb-frmvld. |
| 32 | + const $form = $host.is('form') ? $host : $host.find('form').first(); |
| 33 | + if (!$form.length) return; |
| 34 | + |
| 35 | + /** |
| 36 | + * Given a name like "select_from_the_following[2]" return the base "select_from_the_following". |
| 37 | + * Returns null for non-array names. |
| 38 | + */ |
| 39 | + const baseOf = (name) => (name || '').match(/^([^\[]+)\[.+\]$/)?.[1] || null; |
| 40 | + |
| 41 | + /** |
| 42 | + * Discover checkbox groups on the form. |
| 43 | + * We only consider checkboxes that: |
| 44 | + * - have a name like base[option] |
| 45 | + * - and have data-rule-require_from_group (added server-side) |
| 46 | + * |
| 47 | + * Returns a map: { baseName: [ "base[1]", "base[2]", ... ] } |
| 48 | + */ |
| 49 | + function discoverGroups() { |
| 50 | + const map = {}; |
| 51 | + $form.find('input[type=checkbox][name*="["][data-rule-require_from_group]').each(function () { |
| 52 | + const base = baseOf(this.name); |
| 53 | + if (!base) return; |
| 54 | + (map[base] ||= []).push(this.name); |
| 55 | + }); |
| 56 | + return map; |
| 57 | + } |
| 58 | + |
| 59 | + /** |
| 60 | + * Wire our grouping + dedup logic into the existing validator instance. |
| 61 | + * @param {Object} v - jQuery Validate instance stored on the form by WET. |
| 62 | + */ |
| 63 | + function wire(v) { |
| 64 | + const groups = discoverGroups(); |
| 65 | + |
| 66 | + // Make jQuery Validate prefer element.id over name (fixes accented name issues) |
| 67 | + v.idOrName = function (el) { |
| 68 | + return el.id || (el.name ? el.name.replace(/[^\w\-]+/g, '_') : ''); |
| 69 | + }; |
| 70 | + |
| 71 | + // 1) Register one jQuery Validate "group" per checkbox base. |
| 72 | + // This makes jQuery Validate treat multiple field names as one logical group |
| 73 | + // when deciding which single label/entry to render. |
| 74 | + v.settings.groups = v.settings.groups || {}; |
| 75 | + Object.keys(groups).forEach((base) => { |
| 76 | + // Names must be space-separated for JQV "groups". |
| 77 | + v.settings.groups[base] = groups[base].join(' '); |
| 78 | + }); |
| 79 | + |
| 80 | + // Precompute the FIRST field *id* of each base so we can place a single inline label. |
| 81 | + const firstIdOf = {}; |
| 82 | + Object.keys(groups).forEach((base) => { |
| 83 | + const firstName = groups[base][0]; |
| 84 | + const $el = $form.find('[name="' + CSS.escape(firstName) + '"]').first(); |
| 85 | + firstIdOf[base] = $el.attr('id') || ''; |
| 86 | + }); |
| 87 | + |
| 88 | + // Shadow a local baseOf so inner functions have it in scope (performance/readability). |
| 89 | + const baseOf = (name) => (name || '').match(/^([^\[]+)\[.+\]$/)?.[1] || null; |
| 90 | + |
| 91 | + // 2) Override errorPlacement to only place the inline label for the FIRST checkbox. |
| 92 | + // This avoids multiple inline labels stacked under the group. |
| 93 | + const origPlace = v.settings.errorPlacement || function (error, element) { error.insertAfter(element); }; |
| 94 | + v.settings.errorPlacement = function (error, element) { |
| 95 | + const name = element.attr('name'); |
| 96 | + const base = baseOf(name); |
| 97 | + |
| 98 | + // If this is part of a checkbox base and it's NOT the first item, |
| 99 | + // skip placing the label entirely (the "first" item will get it). |
| 100 | + if (base) { |
| 101 | + const id = element.attr('id') || ''; |
| 102 | + // Only place for the FIRST id in the group, skip others to avoid duplicates. |
| 103 | + if (firstIdOf[base] && id !== firstIdOf[base]) return; |
| 104 | + } |
| 105 | + return origPlace(error, element); |
| 106 | + }; |
| 107 | + |
| 108 | + // 3) Override invalidHandler to de-duplicate BEFORE WET composes its summary. |
| 109 | + // We reduce validator.errorList/errorMap to contain only the first failing |
| 110 | + // element of each base, so the top summary has one entry per group. |
| 111 | + const origInvalid = v.settings.invalidHandler || $.noop; |
| 112 | + v.settings.invalidHandler = function (formEl, validator) { |
| 113 | + const seen = new Set(); |
| 114 | + const list = []; |
| 115 | + const map = {}; |
| 116 | + |
| 117 | + validator.errorList.forEach((item) => { |
| 118 | + const el = item.element; |
| 119 | + const nm = el && el.name; |
| 120 | + const id = el && el.id; |
| 121 | + const base = baseOf(nm) || nm; |
| 122 | + const dedupeKey = base + '::' + (id || ''); |
| 123 | + |
| 124 | + if (!seen.has(dedupeKey)) { |
| 125 | + seen.add(dedupeKey); |
| 126 | + list.push(item); |
| 127 | + if (nm) map[nm] = item.message; |
| 128 | + } |
| 129 | + }); |
| 130 | + |
| 131 | + // Replace the validator's error structures with our reduced versions. |
| 132 | + validator.errorList = list; |
| 133 | + validator.errorMap = map; |
| 134 | + |
| 135 | + // Clean up any duplicate labels that may exist (caused earlier by accented names) |
| 136 | + const seenLabelFor = {}; |
| 137 | + $(formEl).find('label.error[for]').each(function () { |
| 138 | + const f = $(this).attr('for'); |
| 139 | + if (seenLabelFor[f]) $(this).remove(); |
| 140 | + else seenLabelFor[f] = true; |
| 141 | + }); |
| 142 | + |
| 143 | + // Allow any original invalidHandler (including WET's) to run with the reduced list. |
| 144 | + return origInvalid.call(this, formEl, validator); |
| 145 | + }; |
| 146 | + } |
| 147 | + |
| 148 | + /** |
| 149 | + * Try to find the jQuery Validate instance created by WET for this form. |
| 150 | + * If found, wire our grouping/dedup logic into it. |
| 151 | + * Returns true if the validator exists and was wired. |
| 152 | + */ |
| 153 | + function tryWire() { |
| 154 | + const v = $form.data('validator') || $host.data('validator'); |
| 155 | + if (v) wire(v); |
| 156 | + return !!v; |
| 157 | + } |
| 158 | + |
| 159 | + // If WET's validator is already present, wire immediately. |
| 160 | + if (tryWire()) return; |
| 161 | + |
| 162 | + // Otherwise, wait for WET to finish attaching the validator, then wire. |
| 163 | + // 'wb-ready.wb-frmvld' fires when the plugin is ready on this wrapper. |
| 164 | + $host.on('wb-ready.wb-frmvld', tryWire); |
| 165 | + |
| 166 | + // ---- Submission guards -------------------------------------------------- |
| 167 | + // Prevent the form from submitting when invalid (covers: |
| 168 | + // - clicking the submit button |
| 169 | + // - pressing Enter in a field |
| 170 | + // - any custom handler that falls back to form.submit()) |
| 171 | + // |
| 172 | + // We *evaluate* validity using $form.valid()/v.checkForm() and block the |
| 173 | + // event if invalid. We also focus the first invalid field for accessibility. |
| 174 | + |
| 175 | + // Guard the native form submit event. |
| 176 | + $form.on('submit.wetGroupOneMessage', function (e) { |
| 177 | + const v = $form.data('validator') || $host.data('validator'); |
| 178 | + // If no validator, let normal processing happen. |
| 179 | + if (!v) return; |
| 180 | + |
| 181 | + // valid() calls checkForm() and sets up error display without submitting. |
| 182 | + const ok = $form.valid ? $form.valid() : v.checkForm(); |
| 183 | + if (!ok) { |
| 184 | + v.focusInvalid && v.focusInvalid(); |
| 185 | + e.preventDefault(); |
| 186 | + e.stopImmediatePropagation(); |
| 187 | + e.stopPropagation(); |
| 188 | + return false; |
| 189 | + } |
| 190 | + }); |
| 191 | + |
| 192 | + // Guard submit button clicks as well to catch flows |
| 193 | + // where other handlers might try to submit programmatically. |
| 194 | + $form.find('button[type=submit], input[type=submit]').on('click.wetGroupOneMessage', function (e) { |
| 195 | + const v = $form.data('validator') || $host.data('validator'); |
| 196 | + if (!v) return; |
| 197 | + const ok = $form.valid ? $form.valid() : v.checkForm(); |
| 198 | + if (!ok) { |
| 199 | + v.focusInvalid && v.focusInvalid(); |
| 200 | + e.preventDefault(); |
| 201 | + e.stopImmediatePropagation(); |
| 202 | + e.stopPropagation(); |
| 203 | + return false; |
| 204 | + } |
| 205 | + }); |
| 206 | + }); |
| 207 | + } |
| 208 | + }; |
| 209 | +})(jQuery, Drupal, once); |
0 commit comments