1'use strict'; 2 3/** 4 * HobConditional â Moteur de logique conditionnelle pour HOB FormChat Pro 5 * Evalue les regles en temps reel au fur et a mesure que les valeurs du formulaire changent. 6 * 7 * Structure d'une regle (stockee dans config_json.conditional_rules) : 8 * { 9 * id: "rule_abc123", 10 * label: "Description de la regle", 11 * enabled: true, 12 * match: "all"|"any", // "all" = ET logique, "any" = OU logique 13 * conditions: [{ field_id, operator, value }], 14 * actions: [{ type, target, value?, source? }] 15 * } 16 * 17 * Fonctionne cote front-end (formulaires publics) et dans l'apercu du builder. 18 */ 19const HobConditional = { 20 21 /** Reference au formulaire courant */ 22 _formEl: null, 23 24 /** Regles actives */ 25 _rules: [], 26 27 /** Handler d'evenements (conserve pour pouvoir le retirer) */ 28 _handler: null, 29 30 /** 31 * Normalise une règle au format v2 (groups[]). 32 * Compatible avec l'ancien format (conditions[] flat + match). 33 */ 34 normalizeRule: function (rule) { 35 if (!rule) return rule; 36 // Convert old format to new: wrap flat conditions in a single group 37 if (rule.conditions && !rule.groups) { 38 rule.groups = [{ 39 operator: rule.match === 'any' ? 'OR' : 'AND', 40 conditions: rule.conditions 41 }]; 42 rule.group_operator = rule.match === 'any' ? 'OR' : 'AND'; 43 } 44 // Ensure then is an array 45 if (rule.actions && !Array.isArray(rule.actions)) { 46 rule.actions = [rule.actions]; 47 } 48 // Default trigger 49 if (!rule.trigger) rule.trigger = 'on_change'; 50 if (rule.enabled === undefined) rule.enabled = true; 51 return rule; 52 }, 53 54 normalizeRules: function (rules) { 55 var self = this; 56 return (rules || []).map(function (r) { return self.normalizeRule(r); }); 57 }, 58 59 /* ==================================================================== 60 * INITIALISATION / DESTRUCTION 61 * ==================================================================== */ 62 63 /** 64 * Initialise le moteur conditionnel sur un formulaire. 65 * 1. Masque les champs cibles d'actions "show" (etat par defaut = masque) 66 * 2. Ecoute les evenements input/change 67 * 3. Execute une premiere evaluation 68 * 69 * @param {HTMLElement} formEl Element <form> ou conteneur 70 * @param {Array} rules Tableau de regles conditionnelles 71 */ 72 init: function (formEl, rules) { 73 if (!formEl || !Array.isArray(rules)) return; 74 75 this._formEl = formEl; 76 this._rules = rules; 77 this._rules = this.normalizeRules(rules); 78 rules = this._rules; 79 80 // Group rules by trigger 81 var byTrigger = { on_load: [], on_change: [], on_next_page: [], on_submit_unlock: [] };
82 rules.forEach(function (rule) { 83 var t = rule.trigger || 'on_change'; 84 if (byTrigger[t]) byTrigger[t].push(rule); 85 else byTrigger.on_change.push(rule); 86 }); 87 this._byTrigger = byTrigger; 88 89 // Combine on_load + on_change for initial default-hide of "show" targets 90 var initRules = byTrigger.on_load.concat(byTrigger.on_change); 91 initRules.forEach(function (rule) { 92 if (!rule.enabled) return; 93 (rule.actions || []).forEach(function (action) { 94 if (action.type === 'show') { 95 var target = HobConditional.getTarget(action.target); 96 if (target) { 97 target.style.display = 'none'; 98 target.setAttribute('data-hidden', '1'); 99 } 100 } 101 }); 102 }); 103 104 // TRIGGER on_load: evaluate once immediately 105 if (byTrigger.on_load.length) { 106 this.evaluateForm(formEl, byTrigger.on_load); 107 } 108 109 // TRIGGER on_change: bind input/change events 110 var changeRules = byTrigger.on_change; 111 this._handler = function () { 112 HobConditional.evaluateForm(formEl, changeRules); 113 // Also re-evaluate submit unlock on every change 114 if (byTrigger.on_submit_unlock.length) { 115 HobConditional._evaluateSubmitUnlock(formEl, byTrigger.on_submit_unlock); 116 } 117 }; 118 formEl.addEventListener('input', this._handler); 119 formEl.addEventListener('change', this._handler); 120 121 // First evaluation of on_change rules 122 this.evaluateForm(formEl, changeRules); 123 124 // TRIGGER on_next_page: install guard on Next buttons 125 if (byTrigger.on_next_page.length) { 126 this._installNextPageGuard(formEl, byTrigger.on_next_page); 127 } 128 129 // TRIGGER on_submit_unlock: lock submit by default, evaluate 130 if (byTrigger.on_submit_unlock.length) { 131 this._lockSubmit(formEl); 132 this._evaluateSubmitUnlock(formEl, byTrigger.on_submit_unlock); 133 } 134 }, 135 136 /** 137 * Detruit les ecouteurs et nettoie les references. 138 */ 139 destroy: function () { 140 if (this._formEl && this._handler) { 141 this._formEl.removeEventListener('input', this._handler); 142 this._formEl.removeEventListener('change', this._handler); 143 } 144 this._formEl = null; 145 this._rules = []; 146 this._handler = null; 147 }, 148 149 /* ==================================================================== 150 * EVALUATION PRINCIPALE 151 * ==================================================================== */ 152 153 /** 154 * Construit l'etat du formulaire puis evalue toutes les regles. 155 * 156 * @param {HTMLElement} formEl Conteneur du formulaire 157 * @param {Array} rules Regles a evaluer 158 */ 159 evaluateForm: function (formEl, rules) { 160 if (!formEl) return; 161 162 var state = {}; 163 var wrappers = formEl.querySelectorAll('[data-field-id]'); 164 165 wrappers.forEach(function (wrapper) { 166 var fieldId = wrapper.getAttribute('data-field-id'); 167 var inputs = wrapper.querySelectorAll('input, select, textarea'); 168 169 if (!inputs.length) { 170 state[fieldId] = ''; 171 return; 172 } 173 174 var first = inputs[0]; 175 176 // Checkbox : tableau des valeurs cochees 177 if (first.type === 'checkbox') { 178 var checked = []; 179 inputs.forEach(function (cb) { 180 if (cb.checked) checked.push(cb.value); 181 }); 182 state[fieldId] = checked; 183 return; 184 } 185 186 // Radio : valeur selectionnee ou chaine vide 187 if (first.type === 'radio') { 188 var selected = ''; 189 inputs.forEach(function (r) { 190 if (r.checked) selected = r.value; 191 }); 192 state[fieldId] = selected; 193 return; 194 } 195 196 // Autres (text, select, textarea, etc.) 197 state[fieldId] = first.value || ''; 198 }); 199 200 this.evaluate(state, rules); 201 this.resolvePiping(formEl, state); 202 }, 203 204 /** 205 * Evalue chaque regle active et applique ou annule les actions. 206 * 207 * @param {Object} formState Cle = field_id, Valeur = valeur courante 208 * @param {Array} rules Regles conditionnelles 209 */ 210 evaluate: function (formState, rules) { 211 if (!Array.isArray(rules)) return; 212 var self = this; 213 214 // Two-pass: collect final action per target, then apply. 215 // Matched rules take priority; if no rule matches, last revert wins. 216 var finalActions = {}; 217
218 rules.forEach(function (rule) { 219 if (!rule || !rule.enabled) return; 220 221 var matched = false; 222 223 // NEW: grouped conditions (v2 format) 224 if (rule.groups && Array.isArray(rule.groups)) { 225 var outerOp = (rule.group_operator || 'AND').toUpperCase(); 226 var groupResults = rule.groups.map(function (g) { 227 return self._evaluateGroup(g, formState); 228 }); 229 matched = outerOp === 'OR' 230 ? groupResults.some(Boolean) 231 : groupResults.every(Boolean); 232 } else { 233 // OLD: flat conditions with match:"all"|"any" 234 var conditions = rule.conditions || []; 235 if (rule.match === 'any') { 236 matched = conditions.some(function (cond) { 237 return self.testCondition(cond, formState); 238 }); 239 } else { 240 matched = conditions.every(function (cond) { 241 return self.testCondition(cond, formState); 242 }); 243 } 244 } 245 246 // Record intended action per target â matched rules always win 247 (rule.actions || []).forEach(function (action) { 248 var key = (action.target || '') + '::' + (action.type || ''); 249 if (matched) { 250 finalActions[key] = { action: action, matched: true }; 251 } else if (!finalActions[key] || !finalActions[key].matched) { 252 finalActions[key] = { action: action, matched: false }; 253 } 254 }); 255 }); 256 257 // Apply resolved decisions 258 var keys = Object.keys(finalActions); 259 for (var i = 0; i < keys.length; i++) { 260 var entry = finalActions[keys[i]]; 261 if (entry.matched) { 262 self.applyAction(entry.action, formState); 263 } else { 264 self.revertAction(entry.action); 265 } 266 } 267 }, 268 269 /* ==================================================================== 270 * TEST D'UNE CONDITION 271 * ==================================================================== */ 272 273 /** 274 * Teste une condition unique contre l'etat du formulaire. 275 * 276 * @param {Object} cond { field_id, operator, value } 277 * @param {Object} state Etat courant du formulaire 278 * @return {boolean} 279 */ 280 testCondition: function (cond, state) { 281 if (!cond || !cond.field_id) return false; 282 283 var fieldVal = state[cond.field_id]; 284 var expected = cond.value; 285 286 // Normaliser : null/undefined â chaine vide 287 if (fieldVal === null || fieldVal === undefined) fieldVal = ''; 288 var strVal = Array.isArray(fieldVal) ? fieldVal.join(',') : String(fieldVal).trim(); 289 290 switch (cond.operator) { 291 292 /* --- Egalite / Inegalite --- */ 293 case 'equals': 294 return strVal === String(expected || ''); 295 296 case 'not_equals': 297 return strVal !== String(expected || ''); 298 299 /* --- Contenu --- */ 300 case 'contains': 301 return strVal.indexOf(String(expected || '')) !== -1; 302 303 case 'not_contains': 304 return strVal.indexOf(String(expected || '')) === -1; 305 306 case 'starts_with': 307 return strVal.indexOf(String(expected || '')) === 0; 308 309 case 'ends_with': { 310 var suffix = String(expected || ''); 311 return suffix.length > 0 && strVal.slice(-suffix.length) === suffix; 312 } 313 314 /* --- Vide / Non-vide --- */ 315 case 'is_empty': 316 return strVal.trim() === '' || (Array.isArray(fieldVal) && fieldVal.length === 0); 317 318 case 'is_not_empty': 319 return strVal.trim() !== '' && !(Array.isArray(fieldVal) && fieldVal.length === 0); 320 321 /* --- Comparaisons numeriques / dates --- */ 322 case 'greater_than': 323 return this._compare(fieldVal, expected) > 0; 324 325 case 'less_than': 326 return this._compare(fieldVal, expected) < 0; 327 328 case 'greater_or_equal': 329 return this._compare(fieldVal, expected) >= 0; 330 331 case 'less_or_equal': 332 return this._compare(fieldVal, expected) <= 0; 333 334 /* --- Plage (between) --- */ 335 case 'between': { 336 var range = this._parseRange(expected); 337 if (!range) return false; 338 var num = parseFloat(strVal); 339 return !isNaN(num) && num >= range[0] && num <= range[1]; 340 } 341 342 /* --- Listes --- */ 343 case 'in_list': { 344 var list = this._toArray(expected); 345 return list.indexOf(strVal) !== -1; 346 } 347 348 case 'not_in_list': { 349 var listN = this._toArray(expected); 350 return listN.indexOf(strVal) === -1; 351 } 352 353 /* --- Regex --- */ 354 case 'matches_regex': 355 try { 356 return new RegExp(expected).test(strVal); 357 } catch (e) { 358 return false; 359 } 360 361 /* --- Cases a cocher (booleen) --- */ 362 case 'checked': 363 return this._isTruthy(fieldVal); 364 365 case 'not_checked': 366 return !this._isTruthy(fieldVal); 367 368 /* --- Multi-checkbox : contient au moins un / tous --- */ 369 case 'contains_any': { 370 var arrField = Array.isArray(fieldVal) ? fieldVal : [strVal]; 371 var arrExp = this._toArray(expected); 372 return arrExp.some(function (v) { return arrField.indexOf(v) !== -1; }); 373 } 374 375 case 'contains_all': { 376 var arrF = Array.isArray(fieldVal) ? fieldVal : [strVal]; 377 var arrE = this._toArray(expected); 378 return arrE.every(function (v) { return arrF.indexOf(v) !== -1; }); 379 } 380 381 default: 382 return false; 383 } 384 }, 385 386 /* ====================================================================
387 * APPLICATION / ANNULATION DES ACTIONS 388 * ==================================================================== */ 389 390 /** 391 * Applique une action sur le DOM. 392 * 393 * @param {Object} action { type, target, value?, source? } 394 * @param {Object} state Etat courant du formulaire 395 */ 396 applyAction: function (action, state) { 397 if (!action || !action.type) return; 398 399 var target = this.getTarget(action.target); 400 var inputs; 401 402 switch (action.type) { 403 404 /* --- Visibilite --- */ 405 case 'show': 406 if (target) { 407 target.style.display = ''; 408 target.removeAttribute('data-hidden'); 409 target.classList.remove('hob-fc-field-hidden'); 410 inputs = target.querySelectorAll('input, select, textarea'); 411 inputs.forEach(function (el) { el.disabled = false; }); 412 } 413 break; 414 415 case 'hide': 416 if (target) { 417 target.style.display = 'none'; 418 target.setAttribute('data-hidden', '1'); 419 target.classList.add('hob-fc-field-hidden'); 420 inputs = target.querySelectorAll('input, select, textarea'); 421 inputs.forEach(function (el) { 422 el.disabled = true; 423 el.removeAttribute('required'); 424 }); 425 } 426 break; 427 428 /* --- Obligatoire --- */ 429 case 'require': 430 if (target) { 431 inputs = target.querySelectorAll('input, select, textarea'); 432 inputs.forEach(function (el) { el.setAttribute('required', 'required'); }); 433 // Add visual star indicator 434 var label = target.querySelector('.hob-fc-label, .hob-fc-field-label'); 435 if (label && !label.querySelector('.hob-fc-req-star')) { 436 var star = document.createElement('span'); 437 star.className = 'hob-fc-req-star'; 438 star.textContent = ' *'; 439 star.style.color = '#DC2626'; 440 label.appendChild(star); 441 } 442 } 443 break; 444 445 case 'unrequire': 446 if (target) { 447 inputs = target.querySelectorAll('input, select, textarea'); 448 inputs.forEach(function (el) { el.removeAttribute('required'); }); 449 } 450 break; 451 452 /* --- Actif / Desactive --- */ 453 case 'enable': 454 if (target) { 455 inputs = target.querySelectorAll('input, select, textarea, button'); 456 inputs.forEach(function (el) { el.removeAttribute('disabled'); }); 457 } 458 break; 459 460 case 'disable': 461 if (target) { 462 inputs = target.querySelectorAll('input, select, textarea, button'); 463 inputs.forEach(function (el) { el.setAttribute('disabled', 'disabled'); }); 464 } 465 break; 466 467 /* --- Valeurs --- */ 468 case 'set_value': 469 if (target) { 470 var resolved = this.resolveValue(action.value || '', state); 471 inputs = target.querySelectorAll('input, select, textarea'); 472 inputs.forEach(function (el) { 473 el.value = resolved; 474 el.dispatchEvent(new Event('input', { bubbles: true })); 475 }); 476 } 477 break; 478 479 case 'clear_value': 480 if (target) { 481 inputs = target.querySelectorAll('input, select, textarea'); 482 inputs.forEach(function (el) { 483 if (el.type === 'checkbox' || el.type === 'radio') { 484 el.checked = false; 485 } else { 486 el.value = ''; 487 } 488 }); 489 } 490 break; 491 492 /* --- Calcul --- */ 493 case 'calculate': 494 if (target) { 495 target.dispatchEvent(new CustomEvent('hob:recalculate', { bubbles: true })); 496 } 497 break; 498 499 /* --- Classes CSS --- */ 500 case 'add_class': 501 if (target && action.value) { 502 target.classList.add(action.value); 503 } 504 break; 505 506 case 'remove_class': 507 if (target && action.value) { 508 target.classList.remove(action.value); 509 } 510 break; 511 512 /* --- Navigation multi-pages --- */ 513 case 'jump_to_page': 514 if (window.hobMultipage && typeof window.hobMultipage.goTo === 'function') { 515 window.hobMultipage.goTo(parseInt(action.value, 10)); 516 } 517 break; 518 519 case 'skip_to_page': 520 if (window.hobMultipage && typeof window.hobMultipage.goTo === 'function') { 521 if (window.hobMultipage.setSkip) { 522 window.hobMultipage.setSkip(true); 523 } 524 window.hobMultipage.goTo(parseInt(action.value, 10)); 525 } 526 break; 527 528 /* --- Notification AJAX --- */ 529 case 'send_notification': 530 if (typeof jQuery !== 'undefined' && typeof hobFcAjax !== 'undefined') { 531 jQuery.post(hobFcAjax.ajaxUrl, { 532 action: 'hob_fc_send_notification', 533 nonce: hobFcAjax.nonce || '', 534 target: action.target, 535 value: action.value || '', 536 _source: 'conditional' 537 }); 538 } 539 break; 540 541 /* --- Redirection --- */ 542 case 'redirect': 543 if (action.value) { 544 window.location.href = action.value; 545 } 546 break; 547 548 /* --- Peupler les options d'un select --- */ 549 case 'populate_options': 550 this.populateOptions(action.target, action.value, state); 551 break; 552
553 /* --- Copier la valeur d'un champ vers un autre --- */ 554 case 'copy_value': { 555 var sourceVal = (action.source && state[action.source] !== undefined) 556 ? state[action.source] 557 : ''; 558 var destTarget = this.getTarget(action.target); 559 if (destTarget) { 560 var destInputs = destTarget.querySelectorAll('input, select, textarea'); 561 var val = Array.isArray(sourceVal) ? sourceVal.join(', ') : String(sourceVal); 562 destInputs.forEach(function (el) { 563 el.value = val; 564 el.dispatchEvent(new Event('input', { bubbles: true })); 565 }); 566 } 567 break; 568 } 569 570 /* --- Message en ligne --- */ 571 case 'show_message': { 572 var msgEl = document.getElementById('hob-cond-msg-' + action.target); 573 if (msgEl) { 574 msgEl.style.display = ''; 575 if (action.value) msgEl.textContent = action.value; 576 } 577 break; 578 } 579 580 /* --- Validation personnalisee AJAX --- */ 581 case 'validate_custom': 582 if (typeof jQuery !== 'undefined' && typeof hobFcAjax !== 'undefined') { 583 jQuery.post(hobFcAjax.ajaxUrl, { 584 action: 'hob_fc_validate_custom', 585 nonce: hobFcAjax.nonce || '', 586 field_id: action.target, 587 value: state[action.target] || '', 588 rule: action.value || '' 589 }); 590 } 591 break; 592 593 /* --- Soumission du formulaire --- */ 594 case 'submit_form': 595 if (this._formEl) { 596 this._formEl.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true })); 597 } 598 break; 599 600 /* --- Bouton Envoyer --- */ 601 case 'hide_submit': { 602 var submitWrap = (HobConditional._formEl || document).querySelector('.hob-fc-submit-wrap, [data-submit-wrap]'); 603 if (submitWrap) { 604 submitWrap.style.display = 'none'; 605 var btn = submitWrap.querySelector('button[type="submit"], input[type="submit"]'); 606 if (btn) btn.disabled = true; 607 } 608 break; 609 } 610 611 case 'show_submit': { 612 var submitWrap2 = (HobConditional._formEl || document).querySelector('.hob-fc-submit-wrap, [data-submit-wrap]'); 613 if (submitWrap2) { 614 submitWrap2.style.display = ''; 615 var btn2 = submitWrap2.querySelector('button[type="submit"], input[type="submit"]'); 616 if (btn2) btn2.disabled = false; 617 } 618 break; 619 } 620 621 default: 622 break; 623 } 624 }, 625 626 /** 627 * Annule une action (uniquement show/hide sont reversibles automatiquement). 628 * 629 * @param {Object} action Action a annuler 630 */ 631 revertAction: function (action) { 632 if (!action || !action.type) return; 633 634 var target = this.getTarget(action.target); 635 if (!target) return; 636 637 var inputs; 638 639 switch (action.type) { 640 case 'show': 641 // L'inverse de show = masquer 642 target.style.display = 'none'; 643 target.setAttribute('data-hidden', '1'); 644 target.classList.add('hob-fc-field-hidden'); 645 inputs = target.querySelectorAll('input, select, textarea'); 646 inputs.forEach(function (el) { 647 el.disabled = true; 648 el.removeAttribute('required'); 649 }); 650 break; 651 652 case 'hide': 653 // L'inverse de hide = afficher 654 target.style.display = ''; 655 target.removeAttribute('data-hidden'); 656 target.classList.remove('hob-fc-field-hidden'); 657 inputs = target.querySelectorAll('input, select, textarea');
658 inputs.forEach(function (el) { el.disabled = false; }); 659 break; 660 661 case 'require': 662 // Revert: unrequire 663 if (target) { 664 var inputs = target.querySelectorAll('input, select, textarea'); 665 inputs.forEach(function (el) { el.removeAttribute('required'); }); 666 var star = target.querySelector('.hob-fc-req-star'); 667 if (star) star.remove(); 668 } 669 break; 670 671 case 'unrequire': 672 // Revert: restore require (only if field was originally required) 673 break; 674 675 case 'hide_submit': { 676 var sw = (HobConditional._formEl || document).querySelector('.hob-fc-submit-wrap, [data-submit-wrap]'); 677 if (sw) { 678 sw.style.display = ''; 679 var b = sw.querySelector('button[type="submit"], input[type="submit"]'); 680 if (b) b.disabled = false; 681 } 682 break; 683 } 684 685 case 'show_submit': { 686 // Revert show_submit = hide submit 687 var sw2 = (HobConditional._formEl || document).querySelector('.hob-fc-submit-wrap, [data-submit-wrap]'); 688 if (sw2) { 689 sw2.style.display = 'none'; 690 var b2 = sw2.querySelector('button[type="submit"], input[type="submit"]'); 691 if (b2) b2.disabled = true; 692 } 693 break; 694 } 695 696 // Les autres actions ne sont pas automatiquement reversibles 697 default: 698 break; 699 } 700 }, 701 702 /* ==================================================================== 703 * UTILITAIRES 704 * ==================================================================== */ 705 706 /** 707 * Evalue un groupe de conditions. 708 * @param {Object} group { operator: "AND"|"OR", conditions: [...] } 709 * @param {Object} state Etat du formulaire 710 * @return {boolean} 711 */ 712 _evaluateGroup: function (group, state) { 713 var self = this; 714 var op = (group.operator || 'AND').toUpperCase(); 715 var conditions = group.conditions || []; 716 var results = conditions.map(function (cond) { 717 return self.testCondition(cond, state); 718 }); 719 return op === 'OR' 720 ? results.some(Boolean) 721 : results.every(Boolean); 722 }, 723 724 /** 725 * Résout le piping {field:xxx} dans les labels et textes du formulaire. 726 * @param {HTMLElement} formEl 727 * @param {Object} state Etat courant du formulaire 728 */ 729 resolvePiping: function (formEl, state) { 730 if (!formEl) return; 731 formEl.querySelectorAll('[data-pipe-template]').forEach(function (el) { 732 el.textContent = el.getAttribute('data-pipe-template').replace( 733 /\{field:([^}]+)\}/g, 734 function (match, fieldId) { 735 var val = state[fieldId]; 736 if (val === undefined || val === null) return ''; 737 return Array.isArray(val) ? val.join(', ') : String(val); 738 } 739 ); 740 }); 741 }, 742 743 /** 744 * Retourne l'element wrapper d'un champ par son data-field-id. 745 * 746 * @param {string} id Identifiant du champ 747 * @return {HTMLElement|null} 748 */ 749 getTarget: function (id) { 750 if (!id) return null; 751 return document.querySelector('[data-field-id="' + id + '"]'); 752 }, 753 754 /** 755 * Remplace les references {field_id} dans un template par les valeurs du state. 756 * 757 * @param {string} template Chaine contenant des placeholders {field_id} 758 * @param {Object} state Etat courant du formulaire 759 * @return {string} 760 */ 761 resolveValue: function (template, state) { 762 if (!template || typeof template !== 'string') return template || ''; 763 764 return template.replace(/\{([^}]+)\}/g, function (match, fieldId) { 765 var val = state[fieldId]; 766 if (val === undefined || val === null) return ''; 767 return Array.isArray(val) ? val.join(', ') : String(val); 768 }); 769 }, 770 771 /** 772 * Peuple les options d'un select a partir d'un tableau ou d'une URL. 773 * Si sourceData est un tableau, les options sont ajoutees directement. 774 * Si c'est une URL (chaine), on fait un fetch JSON. 775 * 776 * Format attendu du JSON : [{ value: "...", label: "..." }, ...] 777 * 778 * @param {string} targetFieldId ID du champ select cible 779 * @param {Array|string} sourceData Tableau d'options ou URL 780 * @param {Object} state Etat courant (pour resoudre d'eventuels placeholders) 781 */ 782 populateOptions: function (targetFieldId, sourceData, state) { 783 var self = this; 784 var target = this.getTarget(targetFieldId); 785 if (!target) return; 786 787 var select = target.querySelector('select'); 788 if (!select) return; 789 790 /** 791 * Reconstruit les <option> du select. 792 * @param {Array} options [{ value, label }, ...] 793 */ 794 var rebuild = function (options) { 795 // Conserver le placeholder eventuel 796 var placeholder = select.querySelector('option[value=""]'); 797 select.innerHTML = ''; 798 if (placeholder) select.appendChild(placeholder); 799
800 (options || []).forEach(function (opt) { 801 var o = document.createElement('option'); 802 o.value = opt.value !== undefined ? opt.value : opt; 803 o.textContent = opt.label || opt.text || o.value; 804 select.appendChild(o); 805 }); 806 }; 807 808 // Tableau fourni directement 809 if (Array.isArray(sourceData)) { 810 rebuild(sourceData); 811 return; 812 } 813 814 // URL a interroger en asynchrone 815 if (typeof sourceData === 'string' && sourceData.length > 0) { 816 var url = self.resolveValue(sourceData, state || {}); 817 818 fetch(url, { credentials: 'same-origin' }) 819 .then(function (resp) { 820 if (!resp.ok) throw new Error('Erreur HTTP ' + resp.status); 821 return resp.json(); 822 }) 823 .then(function (data) { 824 rebuild(Array.isArray(data) ? data : []); 825 }) 826 .catch(function (err) { 827 // En cas d'erreur, on vide les options 828 rebuild([]); 829 if (typeof console !== 'undefined') { 830 console.warn('[HobConditional] populateOptions â echec du fetch :', err); 831 } 832 }); 833 } 834 }, 835 836 /* ==================================================================== 837 * METHODES INTERNES (PRIVEES PAR CONVENTION) 838 * ==================================================================== */ 839 840 /** 841 * Compare deux valeurs (numeriques ou dates). 842 * Retourne un nombre negatif, zero ou positif (similaire a strcmp). 843 * 844 * @param {*} a Valeur du champ 845 * @param {*} b Valeur de reference 846 * @return {number} 847 */ 848 _compare: function (a, b) { 849 var na = parseFloat(a); 850 var nb = parseFloat(b); 851 852 // Si les deux sont des nombres valides, comparaison numerique 853 if (!isNaN(na) && !isNaN(nb)) return na - nb; 854 855 // Tenter une comparaison de dates 856 var da = new Date(a); 857 var db = new Date(b); 858 if (!isNaN(da.getTime()) && !isNaN(db.getTime())) return da.getTime() - db.getTime(); 859 860 // Comparaison par defaut en chaines 861 return String(a || '').localeCompare(String(b || '')); 862 }, 863 864 /** 865 * Parse une plage "min,max" ou [min, max]. 866 * 867 * @param {*} val Valeur brute 868 * @return {Array|null} [min, max] ou null 869 */ 870 _parseRange: function (val) { 871 if (Array.isArray(val) && val.length >= 2) { 872 return [parseFloat(val[0]), parseFloat(val[1])]; 873 } 874 if (typeof val === 'string') { 875 var parts = val.split(','); 876 if (parts.length >= 2) { 877 var min = parseFloat(parts[0].trim()); 878 var max = parseFloat(parts[1].trim()); 879 if (!isNaN(min) && !isNaN(max)) return [min, max]; 880 } 881 } 882 return null; 883 }, 884 885 /**
886 * Convertit une valeur en tableau (accepte tableau ou chaine separee par des virgules). 887 * 888 * @param {*} val 889 * @return {Array} 890 */ 891 _toArray: function (val) { 892 if (Array.isArray(val)) return val; 893 if (typeof val === 'string') { 894 return val.split(',').map(function (s) { return s.trim(); }); 895 } 896 return []; 897 }, 898 899 /** 900 * Verifie si une valeur est "cochee" / truthy. 901 * Faux pour : "", "0", "false", false, null, undefined, tableau vide. 902 * 903 * @param {*} val 904 * @return {boolean} 905 */ 906 _isTruthy: function (val) { 907 if (val === null || val === undefined) return false; 908 if (Array.isArray(val)) return val.length > 0; 909 var s = String(val).trim().toLowerCase(); 910 return s !== '' && s !== '0' && s !== 'false'; 911 }, 912 913 /* ==================================================================== 914 * TRIGGER on_next_page 915 * ==================================================================== */ 916 917 _installNextPageGuard: function (formEl, rules) { 918 var self = this; 919 var nextBtns = formEl.querySelectorAll('.hob-fc-next-btn, .hob-fc-obo__next'); 920 nextBtns.forEach(function (btn) { 921 btn.addEventListener('click', function (e) { 922 var state = {}; 923 self._collectState(formEl, state); 924 var failures = []; 925
926 rules.forEach(function (rule) { 927 if (!rule.enabled) return; 928 var matched = self._evaluateConditions(rule, state); 929 (rule.actions || []).forEach(function (action) { 930 if (action.type === 'block_next' && matched) { 931 failures.push(action.message || rule.label || 'Condition non remplie.'); 932 } 933 }); 934 }); 935 936 if (failures.length) { 937 e.preventDefault(); 938 e.stopImmediatePropagation(); 939 var msgZone = btn.parentNode.querySelector('.hob-fc-next-block-msg'); 940 if (!msgZone) { 941 msgZone = document.createElement('div'); 942 msgZone.className = 'hob-fc-next-block-msg'; 943 btn.insertAdjacentElement('afterend', msgZone); 944 } 945 msgZone.innerHTML = failures.map(function (m) { 946 return '<p>' + m + '</p>'; 947 }).join(''); 948 msgZone.style.display = 'block'; 949 btn.classList.add('hob-fc-btn-blocked'); 950 setTimeout(function () { btn.classList.remove('hob-fc-btn-blocked'); }, 600); 951 msgZone.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); 952 } else { 953 var existing = btn.parentNode.querySelector('.hob-fc-next-block-msg'); 954 if (existing) { existing.style.display = 'none'; existing.innerHTML = ''; } 955 } 956 }, true); // capture phase to fire before default handler 957 }); 958 }, 959 960 /* ==================================================================== 961 * TRIGGER on_submit_unlock 962 * ==================================================================== */ 963 964 _lockSubmit: function (formEl) { 965 var btn = formEl.querySelector('button[type=submit], input[type=submit], .hob-fc-submit-btn'); 966 if (!btn) return; 967 btn.disabled = true; 968 btn.classList.add('hob-fc-submit-locked'); 969 btn.dataset.originalText = btn.dataset.originalText || btn.textContent; 970 }, 971 972 _unlockSubmit: function (formEl) { 973 var btn = formEl.querySelector('button[type=submit], input[type=submit], .hob-fc-submit-btn'); 974 if (!btn) return; 975 btn.disabled = false; 976 btn.classList.remove('hob-fc-submit-locked'); 977 if (btn.dataset.originalText) btn.textContent = btn.dataset.originalText; 978 var msgZone = formEl.querySelector('.hob-fc-submit-unlock-msg'); 979 if (msgZone) { msgZone.style.display = 'none'; msgZone.innerHTML = ''; } 980 }, 981 982 _evaluateSubmitUnlock: function (formEl, rules) { 983 if (!rules || !rules.length) return; 984 var self = this; 985 var state = {}; 986 self._collectState(formEl, state); 987 var unmetMsgs = []; 988
989 rules.forEach(function (rule) { 990 if (!rule.enabled) return; 991 var matched = self._evaluateConditions(rule, state); 992 (rule.actions || []).forEach(function (action) { 993 if (action.type !== 'unlock_submit') return; 994 if (!matched) { 995 unmetMsgs.push(action.message || rule.label || 'Condition requise.'); 996 } 997 }); 998 }); 999 1000 var btn = formEl.querySelector('button[type=submit], input[type=submit], .hob-fc-submit-btn'); 1001 if (!btn) return; 1002 var msgZone = formEl.querySelector('.hob-fc-submit-unlock-msg'); 1003 if (!msgZone) { 1004 msgZone = document.createElement('ul'); 1005 msgZone.className = 'hob-fc-submit-unlock-msg'; 1006 msgZone.style.display = 'none'; 1007 btn.insertAdjacentElement('beforebegin', msgZone); 1008 } 1009 1010 if (unmetMsgs.length) { 1011 self._lockSubmit(formEl); 1012 msgZone.innerHTML = unmetMsgs.map(function (m) { 1013 return '<li class="hob-fc-unlock-item"><span class="hob-fc-unlock-icon">â</span> ' + m + '</li>'; 1014 }).join(''); 1015 msgZone.style.display = 'block'; 1016 } else { 1017 self._unlockSubmit(formEl); 1018 } 1019 }, 1020 1021 /* ==================================================================== 1022 * HELPERS â shared state collection & condition evaluation 1023 * ==================================================================== */ 1024 1025 _collectState: function (formEl, state) { 1026 var wrappers = formEl.querySelectorAll('[data-field-id]'); 1027 wrappers.forEach(function (wrapper) { 1028 var fieldId = wrapper.getAttribute('data-field-id'); 1029 if (state.hasOwnProperty(fieldId)) return; // skip duplicates 1030 var inputs = wrapper.querySelectorAll('input, select, textarea'); 1031 if (!inputs.length) { state[fieldId] = ''; return; } 1032 var first = inputs[0]; 1033 if (first.type === 'checkbox') { 1034 var checked = []; 1035 inputs.forEach(function (cb) { if (cb.checked) checked.push(cb.value); }); 1036 state[fieldId] = checked; 1037 } else if (first.type === 'radio') { 1038 var sel = ''; 1039 inputs.forEach(function (r) { if (r.checked) sel = r.value; }); 1040 state[fieldId] = sel; 1041 } else { 1042 state[fieldId] = first.value || ''; 1043 } 1044 }); 1045 }, 1046 1047 _evaluateConditions: function (rule, state) { 1048 var self = this; 1049 var matched = false; 1050 if (rule.groups && Array.isArray(rule.groups)) { 1051 var outerOp = (rule.group_operator || 'AND').toUpperCase(); 1052 var groupResults = rule.groups.map(function (g) { 1053 return self._evaluateGroup(g, state); 1054 }); 1055 matched = outerOp === 'OR' ? groupResults.some(Boolean) : groupResults.every(Boolean); 1056 } else if (rule.conditions) { 1057 var conditions = rule.conditions; 1058 if (rule.match === 'any') { 1059 matched = conditions.some(function (c) { return self.testCondition(c, state); }); 1060 } else { 1061 matched = conditions.every(function (c) { return self.testCondition(c, state); }); 1062 } 1063 } 1064 return matched; 1065 } 1066}; 1067 1068/* Exposition globale */ 1069window.HobConditional = HobConditional;
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.