1/** 2 * @author keiranc 3 * global angularjs wpd module 4 */ 5var wpd = angular.module('wpd', []); 6 7wpd.config([ 8 '$locationProvider', 9 function($locationProvider) { 10 $locationProvider.html5Mode(true); 11 $locationProvider.hashPrefix('!'); 12 } 13]); 14 15 16/** 17 * Angular service that **should** be used for reporting any metrics stuff 18 * @author keiranc 19 * 20 * GB - The google analytics element of this has never worked - ! DO NOT USE TO LOG GOOGLE ANALYTICS EVENTS ! 21 * Only use for "internal" metrics logging. 22 */ 23wpd.service('Analytics', [ 24 '$http', 25 '$log', 26 '$window', 27 function($http, $log, $window) { 28 // Google Analytics may not be loaded, so use angular.noop in this case (no-ga mode) 29 this.ga = $window.ga || angular.noop; 30 31 this.metricsUrl = '/__metrics/log'; 32 33 // Utility method to ensure the GA event is sent - see https://developers.google.com/analytics/devguides/collection/analyticsjs/sending-hits#hitcallback 34 function createFunctionWithTimeout(callback, opt_timeout) { 35 var called = false; 36 function fn() { 37 if (!called) { 38 called = true; 39 callback(); 40 } 41 } 42 setTimeout(fn, opt_timeout || 1000); 43 return fn; 44 } 45 46 /** 47 * Sends a custom event to google analytics 48 * @param {String} category typically the object that was interacted with 49 * @param {String} action the type of interaction 50 * @param {String} label useful for categorising events 51 * @param {Number} value a numeric value associated with the event (integer) 52 */ 53 this.logEvent = function(category, action, label, value) { 54 if (!category || !action) { 55 $log.error( 56 'Analytics.logEvent must have at least a valid category and action parameter' 57 ); 58 return; 59 } 60 61 var _this = this; 62 63 //!!!GB - As far as I can tell this has never worked 64 //We will just treat this module as an "internal" logger and deal with google analytics seperately. 65 //this.ga('send', category, action, label, value); 66 67 // send to metrics 68 var url = $window.location.href; 69 70 var payload = { 71 category: category, 72 action: action, 73 URL: url, 74 authenticityToken: CORE.AT 75 }; 76 77 if (angular.isDefined(label) && label != null) { 78 payload.label = label; 79 } 80 81 if (angular.isDefined(value) && value != null) { 82 payload.value = value; 83 } 84 85 if (navigator.sendBeacon) { 86 var payloadFormData = new FormData(); 87 for (var key in payload) { 88 if (payload.hasOwnProperty(key)) { 89 payloadFormData.append(key, payload[key]); 90 } 91 } 92 navigator.sendBeacon(_this.metricsUrl, payloadFormData); 93 } else { 94 $http.post(_this.metricsUrl, payload); 95 } 96 return this; 97 }; 98 99 /** 100 * Sends a page view event to google analytics for the url 101 * @param {String} url url of the page 102 * @returns {Analytics} this 103 */ 104 this.logPageView = function(url) { 105 this.ga('send', { 106 hitType: 'pageview', 107 page: url 108 }); 109 return this; 110 }; 111 112 /** 113 * Sends a page view event to google analytics for the current page 114 * @returns {Analytics} this 115 */ 116 this.logCurrentPageView = function() { 117 var currentUrl = $window.location.href; 118 this.logPageView(currentUrl); 119 return this; 120 }; 121 } 122]); 123 124wpd.controller('alertDismissableCTRL', [ 125 '$element', 126 '$scope', 127 function($element, $scope) { 128 $scope.close = function() { 129 angular.element($element).fadeOut(); 130 }; 131 } 132]); 133 134wpd.controller('youtubeCTRL', [ 135 '$scope', 136 '$window', 137 function($scope, $window) { 138 // DEBUG && console.log(angular.element('.yt_holder')); 139 } 140]); 141 142 143 //GBDEVELOPMENT - my new directive to check dates 144/** 145 * check if the date is valid 146 */ 147wpd.directive('invalidDate', [ 148 function() { 149 return { 150 require: 'ngModel', 151 restrict: 'A', 152 link: function(scope, element, attrs, ngModel) { 153 ngModel.$validators.invalidDate = function(value) { 154 if(value != undefined){ 155 return (true); //all dates are invalid 156 }else{ 157 return false; 158 } 159 }; 160 } 161 }; 162 } 163]); 164 165// For Ability Net refinement of [WSME-586] 166/** 167* Use on a structure in which role=tablist, and it has role=tab children. 168* Will set things up so that an Enter or Space press will trigger click() for each tab child. 169* Will set up movement between tabs using left and right arrow keys. 170* TODO: should be namespaced 171* 172* @author noahm 173*/ 174// For Ability Net refinement of [WSME-586] 175/* Use on a structure in which role=tablist, and it has role=tab children. 176 * Will set things up so that an Enter or Space press will trigger click() for each tab child. 177 * Will set up movement between tabs using left and right arrow keys. 178*/ 179wpd.directive('accessibleTabs', ['accessibilityKeyCodes', '$timeout', 180 function(accessibilityKeyCodes, $timeout) { 181 return { 182 restrict: 'AE', 183// scope: { 184// initialSelectedTabIndex: '<?' 185// }, 186 /** 187 * Note that this directive has no explicit template (TODO - but is this worth it?) 188 * Interesting: https://stackoverflow.com/questions/20878830/angularjs-isolated-scope-for-directives-without-own-template 189 * and: https://stackoverflow.com/questions/36211733/angular-directive-with-no-template-assign-ng-click-function-from-directive 190 */ 191 link: function($scope, $element, $attrs, $ctrl, $transcludeFn) { 192 193 $scope.selectedTabIndex = parseInt($attrs.initialSelectedTabIndex) || 0; 194 195 /** 196 * Given as a function, despite its simplicity, to allow people overriding the ng-click to have a slightly easier time setting the tab 197 * 198 * @param {number|string} tabIndex - the tab to set as the currently showing tab 199 * @return {undefined} 200 */ 201 $scope.selectTab = function(tabIndex) { 202 $scope.selectedTabIndex = parseInt(tabIndex) || 0; 203 }; 204 205 /** 206 * @param {number|string} tabIndex - the tab to check whether we should be able to show or not 207 * @return {boolean} 208 */ 209 $scope.showTabPanel = function(tabIndex) { 210 return $scope.selectedTabIndex === parseInt(tabIndex); 211 }; 212 213 $timeout(function() { 214 var $tabChildren = $element.find('[role="tab"]'); 215 216 $tabChildren.each(function(idx, tabChild) { 217 tabChild = angular.element(tabChild); 218 219 /** 220 * So this is how it should work: you can focus on each tab sequentially by navigating through them with the left and right arrow keys. 221 * Changing focus does not actually 'click' the tab to change the tab panel, pressing enter or space will though. 222 */ 223 tabChild.on('keyup', function(event) { 224 if (event.keyCode === accessibilityKeyCodes.ENTE
224R || event.keyCode === accessibilityKeyCodes.SPACE) { 225 tabChild.click(); 226 } 227 else if (event.keyCode === accessibilityKeyCodes.LEFT_ARROW) { 228 if (idx === 0) { //wrap to last 229 $tabChildren.last().focus(); 230 } 231 else { 232 $tabChildren[idx-1].focus(); 233 } 234 } 235 else if (event.keyCode === accessibilityKeyCodes.RIGHT_ARROW) { 236 if (idx === $tabChildren.length - 1) { //wrap to first 237 $tabChildren.first().focus(); 238 } 239 else { 240 $tabChildren[idx+1].focus(); 241 } 242 } 243 }); 244 }); 245 }); 246 } 247 } 248 } 249]); 250 251/** 252* TODO: make this the only version of accessibleTabs and ditch the other one. 253* Can't address this now because short on time and the change to use an isolate scope means that HTML bits will need 254* to be updated! 255* 256* @author noahm 257*/ 258wpd.directive('wpdutilsAccessibleTabs', ['accessibilityKeyCodes', '$timeout', 259 function(accessibilityKeyCodes, $timeout) { 260 return { 261 restrict: 'AE', 262 scope: { 263 initialSelectedTabIndex: '<?wpdutilsInitialSelectedTabIndex', 264 deferSetup: '<?wpdutilsAccessibleTabsDeferSetup', 265 tabChildren: '=?wpdutilsAccessibleTabsTabChildren', 266 watchTabChildren: '<?wpdutilsAccessibleTabsWatchTabChildren', 267 onSetupFn: '&?wpdutilsAccessibleTabsOnSetupFn', 268 onTearDownFn: '&?wpdutilsAccessibleTabsOnTearDownFn', 269 disableDirective: '<?wpdutilsAccessibleTabsDisableDirective' 270 }, 271 /** 272 * Note that this directive has no explicit template (TODO - but is this worth it?) 273 * Interesting: https://stackoverflow.com/questions/20878830/angularjs-isolated-scope-for-directives-without-own-template 274 * and: https://stackoverflow.com/questions/36211733/angular-directive-with-no-template-assign-ng-click-function-from-directive 275 */ 276 link: function($scope, $element, $attrs, $ctrl, $transcludeFn) { 277 const DEBUG = false; 278 const EVENT_NAMESPACE = 'wpdutilsAccessibleTabs'; 279 280 $scope.selectedTabIndex = $scope.initialSelectedTabIndex || parseInt($attrs.initialSelectedTab) || 0; 281 282 /** 283 * Given as a function, despite its simplicity, to allow people overriding the ng-click to have a slightly easier time setting the tab 284 * 285 * @param {number|string} tabIndex - the tab to set as the currently showing tab 286 * @return {undefined} 287 */ 288 $scope.selectTab = function(tabIndex) { 289 $scope.selectedTabIndex = parseInt(tabIndex) || 0; 290 }; 291 292 /** 293 * @param {number|string} tabIndex - the tab to check whether we should be able to show or not 294 * @return {boolean} 295 */ 296 $scope.showTabPanel = function(tabIndex) { 297 return $scope.selectedTabIndex === parseInt(tabIndex); 298 }; 299 300 /** 301 * As the name implies, will only attach a particular handler for an event once, no matter how many times 302 * it's told to attach that handler to the specified event. 303 * 304 * @param {jQuery} $targetElem - the element in the DOM triggering the named event 305 * @param {string} eventName - the name of the event being triggered 306 * @param {string} eventNamespace - the namespace of the event being triggered 307 * @param {function} handler - the handler function to provide as a callback 308 * @returns {undefined} 309 */ 310 var attachHandlerOnce = function($targetElem, eventName, eventNamespace, handler) { 311 var han = $._data($targetElem.get(0), "events"); 312 if (!han || !han[eventName] || han[eventName].map(function(a) {return a.handler.name + '.' + a.namespace}).indexOf(handler.name + '.' + eventNamespace) < 0) { 313 let finalEventName = eventName + (eventNamespace ? '.' + eventNamespace : ''); 314 $targetElem.on(finalEventName, handler); 315 } 316 }; 317 318 /** 319 * So this is how it should work: you can focus on each tab sequentially by navigating through them with the left and right arrow keys. 320 * Changing focus does not actually 'click' the tab to change the tab panel, pressing enter or space will though. 321 */ 322 var _tabKeyupEvent = function(event, tabChild, idx, $tabChildren) { 323 if (event.keyCode === accessibilityKeyCodes.ENTE
323R || event.keyCode === accessibilityKeyCodes.SPACE) { 324 // `event.currentTarget` should == tabChild anyway 325 tabChild.click(); 326 } 327 else if (event.keyCode === accessibilityKeyCodes.LEFT_ARROW) { 328 if (idx === 0) { // wrap to last 329 $tabChildren.last().focus(); 330 } 331 else { 332 $tabChildren[idx-1].focus(); 333 } 334 } 335 else if (event.keyCode === accessibilityKeyCodes.RIGHT_ARROW) { 336 if (idx === $tabChildren.length - 1) { // wrap to first 337 $tabChildren.first().focus(); 338 } 339 else { 340 $tabChildren[idx+1].focus(); 341 } 342 } 343 }; 344 345 $scope.setupTabBehaviour = function(callbackFn, ignoreDefaultCallbackFn) { 346 DEBUG && console.log($scope.tabChildren); 347 348 if ($scope.tabChildren) { 349 $scope.tabChildren.each(function(idx, tabChild) { 350 tabChild = angular.element(tabChild); 351 attachHandlerOnce( 352 tabChild, 353 'keyup', 354 `${EVENT_NAMESPACE}__tabKeyupEvent`, 355 (event) => _tabKeyupEvent(event, tabChild, idx, $scope.tabChildren) 356 ); 357 }); 358 } 359 360 callbackFn && callbackFn(); 361 362 if (!ignoreDefaultCallbackFn) { 363 $scope.onSetupFn && $scope.onSetupFn($scope, context); 364 } 365 }; 366 367 $scope.tearDownTabBehaviour = function(callbackFn, ignoreDefaultCallbackFn) { 368 DEBUG && console.log($scope.tabChildren); 369 370 if ($scope.tabChildren) { 371 $scope.tabChildren.each(function(idx, tabChild) { 372 tabChild = angular.element(tabChild); 373 tabChild.off(`keyup.${EVENT_NAMESPACE}__tabKeyupEvent`); 374 }); 375 } 376 377 callbackFn && callbackFn(); 378 379 if (!ignoreDefaultCallbackFn) { 380 $scope.onTearDownFn && $scope.onTearDownFn($scope, context); 381 } 382 }; 383 384 $scope.initUndefinedTabChildren = function() { 385 if ($scope.tabChildren === undefined) { 386 $scope.tabChildren = $element.find('[role="tab"]'); 387 } 388 }; 389 390 $scope.$on('wpdutilsAccessibleTabs__setup', (event, data) => { 391 if ($scope.disableDirective) return; 392 $scope.setupTabBehaviour(data?.callbackFn, data?.ignoreDefaultCallbackFn); 393 }); 394 395 $scope.$on('wpdutilsAccessibleTabs__tearDown', (event, data) => { 396 $scope.tearDownTabBehaviour(data?.callbackFn, data?.ignoreDefaultCallbackFn); 397 }); 398// 399// if ($scope.watchDisableDirective) { 400// $scope.$watch('disableDirective', function(newVal, oldVal) { 401// if (newVal) $scope.tearDownTabBehaviour(); 402// else $scope.setupTabBehaviour(); 403// }); 404// } 405
406 // only set this watcher up if we were told to 407 if ($scope.watchTabChildren) { 408 $scope.$watchCollection('tabChildren', function(newVal, oldVal) { 409 // both newVal and oldVal will be null on initialization for watchCollection. 410 if (newVal !== oldVal) { 411 if ($scope.disableDirective) $scope.tearDownTabBehaviour(); 412 else $scope.setupTabBehaviour(); 413 } 414 }); 415 } 416 417 // initialize 418 if (!($scope.deferSetup || $scope.disableDirective)) { 419 $timeout(() => { 420 $scope.initUndefinedTabChildren(); 421 $scope.setupTabBehaviour(); 422 }); 423 } 424 } 425 } 426 } 427]); 428 429/** 430 * WPD site wide datePicker directive 431 * for options see https://xdsoft.net/jqplugins/datetimepicker/ 432 * @author keiranc 433 */ 434wpd.directive('datePicker', [ 435 function() { 436 return { 437 require: 'ngModel', 438 scope: { 439 formatFn: '&', // custom formatter 440 parseFn: '&', // custom parser 441 options: '=' // xdsoft datetimepicker options 442 }, 443 link: function(scope, element, attrs, ngModelController) { 444 const DEBUG = false; 445 // Object.values polyfill for IE 9+ 446 if (!Object.values) { 447 Object.values = function(o) { 448 return Object.keys(o).map(function(k) { 449 return o[k]; 450 }); 451 }; 452 } 453 454 455 // NOTE: this application of datetimepicker on the jQuery object is different to $.fn.datetimepicker! 456 // This only appears in jquery.datetimepicker.full.js, not the standard jquery.datetimepicker.js that comes with CORE, 457 // so make sure you have used #{wpd.utils.loadDatePicker /} in your template somewhere. 458 $.datetimepicker.setDateFormatter('moment'); 459 460 var format = 'DD/MM/YYYY'; 461 462 if (scope.options && scope.options.format) { 463 format = scope.options.format; 464 } 465 466 var hasParseFn = !!attrs.parseFn; 467 var hasFormatFn = !!attrs.formatFn; 468 469 // format the date from the model value to a value to display in the input box 470 ngModelController.$formatters.push(function(modelValue) { 471 if (modelValue == null) { 472 return null; 473 } 474 if (hasFormatFn) { 475 return scope.formatFn({ 476 modelValue: modelValue, 477 format: format 478 }); 479 } 480 var date = moment(modelValue); 481 482 return date.isValid() ? date.format(format) : ''; 483 }); 484 485 // format the date from the input box to a value to store in the model 486 ngModelController.$parsers.push(function(viewValue) { 487 if (viewValue == null) { 488 return null; 489 } 490 if (hasParseFn) { 491 return scope.parseFn({ 492 viewValue: viewValue, 493 format: format 494 }); 495 } 496 var date = moment(viewValue, format); 497 return date.isValid() ? date.toDate() : null; 498 }); 499 500//GBDEVELOPMENT - Switched off validateOnBlur as that was the built in validation changing the invalid date to "today" 501 angular.element(element).datetimepicker( 502 angular.extend( 503 { 504 format: format, 505 timepicker: false, 506 autoHide: true, 507 validateOnBlur: false, 508 509 // remove scroll date change 510 scrollInput: false, 511 }, 512 scope.options || {} 513 ) 514 ); 515 516//GBDEVELOPMENT - My new onblur that I don't think is a great solution. 517// angular.element(element).on('blur', function() { 518// DEBUG && console.log(element); 519// var dateToCheck = angular.element(element).val();
520// var valid = true; 521// if (dateToCheck == 'f') valid = false; 522// if (!valid) { 523// element.after('<span class="error" id="' + element.ID + 'clientError' + '">You have entered an invalid date</span>'); 524// } 525// else 526// { 527// var toDel = document.getElementById(element.ID + 'clientError'); 528// toDel.parentNode.removeChild(toDel); 529// } 530// }); 531 } 532 }; 533 } 534]); 535 536wpd.directive('trackEvent', ['Analytics', function(Analytics) { 537 return { 538 scope: { 539 trackEvent: '@?', 540 category: '@', 541 action: '@', 542 label: '@?', 543 value: '@?' 544 }, 545 link: function(scope, element, attrs) { 546 if (scope.category && scope.action) { 547 angular.element(element).on(scope.trackEvent || 'click', function() { 548 Analytics.logEvent(scope.category, scope.action, scope.label || attrs.href || '', parseInt(scope.value, 10) || null); 549 }); 550 } 551 } 552 } 553}]); 554 555wpd.factory('scrollTo', [ 556 '$q', 557 '$timeout', 558 function($q, $timeout) { 559 return function(selector, offset, duration) { 560 return $q(function(resolve) { 561 $timeout(function() { 562 var element = angular.element(selector); 563 if (element && element.offset()) { 564 angular.element('html, body').animate( 565 { 566 scrollTop: 567 element.offset().top - 568 (offset ? offset : 50) - 569 (window.fixedHeaderOffset || 0) 570 }, 571 duration, 572 'swing', 573 resolve 574 ); 575 } 576 }); 577 }); 578 }; 579 } 580]); 581 582wpd.controller('BlocksCtrl', [ 583 '$scope', 584 function($scope) { 585 $scope.selected = 1; 586 587 $scope.select = function(index) { 588 $scope.selected = parseInt(index, 10); 589 }; 590 } 591]); 592 593/** 594 * Angular service for adding accessibility functionality when opening a pop up with vex 595 * @author petert 596 */ 597wpd.service('vexAccessibility', [ 598 '$timeout', 599 function($timeout) { 600 601 602 /** 603 * Hides elements from screen readers that are in the background and indexes elements created by vex so screen readers can access them. 604 * @param {List} elements, classes, ids to hide from the screen reader 605 * @param {List} classes and ids to index 606 * @param {String} element to give focus to 607 */ 608 this.openVex = function(elementsToHide, elementsToIndex, elementToFocus) { 609 var e; 610 var tag; 611 for(e = 0; e < elementsToHide.length; e++){ 612 tag = angular.element(document).find(elementsToHide[e]); 613 tag.attr('aria-hidden',true); 614 tag.attr('tabindex',-1); 615 } 616 $timeout(function() { 617 var e; 618 var tag; 619 620 // make the vex-close button accessible and respond to enter keypresses. 621 $('.vex-content').each(function(idx, elem) { 622 if ($(this).find('.vex-close').length) { 623 $(this).prepend( 624 $(this).find('.vex-close') 625 .attr('tabindex',0).attr('role','button').attr('aria-label','Close dialog') 626 .addClass('focusable-element') 627 .on('keyup', 628 function(event) { 629 if (event.keyCode === 13) { 630 event.preventDefault(); 631 this.click(); 632 } 633 } 634 ) 635 ); 636 } 637 638 }); 639 640 for(e = 0; e < elementsToIndex.length; e++){ 641 tag = angular.element(document).find(elementsToIndex[e]); 642 tag.attr('tabindex',0); 643 } 644 angular.element(document).find(elementToFocus).focus(); 645 }); 646 }; 647 648 /** 649 * Unhides elements that were hidden with openVex 650 * @param {List} elements, classes, ids to unhide from the screen reader 651 * @param {String} element to give focus to 652 */ 653 this.closeVex = function(elementsToAdd, elementToFocus) { 654 var e; 655 var tag; 656 $timeout(function() { 657 for(e = 0; e < elementsToAdd.length; e++){ 658 tag = angular.element(document).find(elementsToAdd[e]); 659 tag.attr('aria-hidden',false); 660 tag.attr('tabindex',0); 661 }
662 angular.element(document)?.find(elementToFocus)?.focus()?.get(0)?.scrollIntoView( 663 { 664 behavior: "auto", 665 block: "center", 666 inline: "nearest" 667 }); 668 }); 669 }; 670 } 671]); 672 673/** 674* A service that will configure a vex dialog object's settings such that it can render an ngTemplate as its view. 675* 676* @author noahm 677*/ 678wpd.service('vexNgTemplate', [ 679 '$templateRequest', '$templateCache', '$timeout', '$compile', 680 function($templateRequest, $templateCache, $timeout, $compile) { 681 682 var service = this; 683 684 // Stolen from https://www.w3resource.com/javascript-exercises/javascript-math-exercise-23.php 685 var _createUUID = function() { 686 var dt = new Date().getTime(); 687 var uuid = 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) { 688 var r = (dt + Math.random()*16)%16 | 0; 689 dt = Math.floor(dt/16); 690 return (c=='x' ? r :(r&0x3|0x8)).toString(16); 691 }); 692 return uuid; 693 }; 694 695 /** 696 * @param {String} rawHTML 697 * @param {Object} existingSettings 698 * @param {Object} templateScope 699 * @param {boolean} removeVexForm 700 * @param {String|null} compileTargetId 701 * 702 * @return {boolean|Object} 703 */ 704 service.setRawTemplate = function(rawHTML, existingSettings, templateScope, removeVexForm, compileTargetId) { 705 var vexSettings = existingSettings || {}; 706 707 if (rawHTML) { 708 var targetId = compileTargetId || _createUUID(); 709 vexSettings.input = '<div id="' + targetId + '">' + rawHTML + '</div>'; 710 var currentAfterOpenFn = vexSettings.afterOpen || angular.noop; 711 712 vexSettings.afterOpen = function() { 713 // find the DOM node of the content we added into vex (our vexSettings.input). You want this to have a unique ID so if you supply the compileTargetId make sure it is. 714 var $vexHtml = angular.element('#' + targetId); 715 716 // Vex popups wrap their content in a form element, which means any form element within your template will not function. 717 // If you choose to remove the vex form, then think about the repercussions on the vex buttons (if you're using them) 718 if (removeVexForm) { 719 var $vexForm = $vexHtml.closest('form.vex-dialog-form'); 720 721 if ($vexForm) { 722 $vexForm.parent().append($vexForm.children()); 723 $vexForm.remove(); 724 } 725 } 726 727 // compile the template using the supplied templateScope, so it can access vm or whatever, despite no longer being within vm's root DOM node 728 $compile($vexHtml)(templateScope); 729 730 // execute the original afterOpen function 731 currentAfterOpenFn(); 732 }; 733 } 734 else { 735 console.error('Could not apply the HTML template'); 736 return false; 737 } 738 739 // return the new augmented settings 740 return existingSettings; 741 }; 742 743 /** 744 * @asynchronous 745 * 746 * @param {String} ngTemplateName 747 * @param {Object} existingSettings 748 * @param {Object} templateScope 749 * @param {boolean} removeVexForm 750 * @param {String|null} compileTargetId 751 * 752 * @return {Promise} - a Promise that will resolve to an object of Vex settings ready to do with as you wish 753 */ 754 service.setNgTemplate = function(ngTemplateName, existingSettings, templateScope, removeVexForm, compileTargetId) { 755 return new Promise(function(resolve, reject) { 756 // look in templateCache first 757 var existingTemplate = $templateCache.get(ngTemplateName); 758 759 if (existingTemplate) { 760 var setTemplateResult = service.setRawTemplate(existingTemplate, existingSettings, templateScope, removeVexForm, compileTargetId); 761 762 if (setTemplateResult) { 763 // return the augmented settings 764 resolve(setTemplateResult); 765 } 766 else { 767 // return the original settings 768 console.error('Could not fetch template for ' + ngTemplateName); 769 reject(existingSettings); 770 } 771 } 772 else { 773 $templateRequest(ngTemplateName).then( 774 function(html) { 775 var setTemplateResult = service.setRawTemplate(html, existingSettings, templateScope, removeVexForm, compileTargetId); 776 777 if (setTemplateResult) { 778 // return the augmented settings 779 resolve(setTemplateResult); 780 } 781 else { 782 // return the original settings 783 console.error('Could not fetch template for ' + ngTemplateName); 784 reject(existingSettings); 785 } 786 } 787 ); 788 } 789 }); 790 }; 791 } 792]); 793 794 795/** 796* Little directive to help CMS users discern areas of a template which on the frontend would normally be visually mutually exclusive, but have been 797* made visible all the time in the CMS in order to edit stuff within them like slots. 798* Will outline the directive annotated element with randomly coloured dashed border, and supply a label for the area at the top. 799* 800* @author noahm 801*/ 802wpd.directive('cmsShowBounds', function() { 803 return { 804 restrict: 'A', 805 scope: { 806 cmsBoundLabel: '@' 807 }, 808 link: function($scope, $elem, $attrs) { 809 if ($attrs.cmsShowBounds === 'true') { 810 var randomColor = 'hsl(' + Math.floor(Math.random() * 360) + ', 70%, 50%)'; // percentages are for saturation and lightness 811 812 $($elem).css({outline: ('2px dashed ' + randomColor), borderRadius: '3px', outlineOffset: '6px'}); 813 814 if ($scope.cmsBoundLabel) { 815 var $label = $('<span>' + $scope.cmsBoundLabel + '</span>'); 816 $label.css({fontSize: '0.8em', color: randomColor, display: 'inline-block', margin: '18px 0px 12px'}); 817 $($elem).before($label); 818 } 819 } 820 } 821 } 822}); 823 824/** 825* TODO: think about creating a super class rather than mixin? 826*/ 827wpd.service('wpdutilsCustomInputMixinService', ['$compile', function($compile) { 828 /** 829 * A quick setup function that instantiates watches between a custom input directive's inner FormController 830 * and syncs the status of that FormController to the status of the NgModelController of the input's ngModel 831 * 832 * @param {Scope} $scope - the $scope of the directive this mixin is being applied to 833 * @param {string} formCtrlName - the name of the inner form of the directive that this mixin is being applied to 834 * @param {NgModelController} ngModelCtrl - the ngModel controller attached to the directive that this mixin is being applied to 835 * @param {string} directiveKey - the name of the directive so that uit may be identifiable within ngModelCtrl's $error object 836 * 837 * @return {object} - an object possessing the watcher cancel functions 838 */ 839 this.initStatusSyncers = function($scope, formCtrlName, ngModelCtrl, directiveKey) { 840 // sync touch/dirty/valid states of our inner form with the outer ngControl 841 var cancelFormValidWatch = $scope.$watch(() => $scope[formCtrlName].$valid, function() { 842 ngModelCtrl.$setValidity(directiveKey, $scope[formCtrlName].$valid); 843 }); 844 845 var cancelFormDirtyWatch = $scope.$watch(() => $scope[formCtrlName].$dirty, function() { 846 $scope[formCtrlName].$dirty ? ngModelCtrl.$setDirty() : ngModelCtrl.$setPristine(); 847 }); 848 849 // a form doesn't have a $touched field (though feels like it should do). we need to check each of our controls 850 var cancelFormDirtyWatch = $scope.$watch(() =>
850 $scope[formCtrlName].$$controls.find((control) => control.$touched), function(newVal, oldVal) { 851 newVal ? ngModelCtrl.$setTouched() : ngModelCtrl.$setUntouched(); 852 }); 853 854 return { 855 cancelFormValidWatch: cancelFormValidWatch, 856 cancelFormDirtyWatch: cancelFormDirtyWatch, 857 cancelFormDirtyWatch: cancelFormDirtyWatch 858 } 859 }; 860 861 /** 862 * Inject a loading spinner into the given parent element, that activates on the given Angular condition 863 * @param {object} $scope - the scope for the condition, typically $scope 864 * @param {element} parent - the element to add the wrapper class to, and to add the spinner overlay to, typically $elem.parent() 865 * @param {string} condition - the condition to use for showing the spinner, e.g. 'loading' or 'vm.loading' 866 */ 867 this.injectLoadingSpinner = function($scope, parent, condition = 'loading') { 868 // Bail if the parent already has a matching spinner 869 if (parent.find('.universal-loading-spinner[data-ng-show="' + condition + '"]').length > 0) { 870 return; 871 } 872 // Add the spinner to the parent and give it the wrapper class 873 const spinner = $compile('<div class="universal-loading-spinner" data-ng-show="' + condition + '"></div>')($scope); 874 parent.prepend(spinner); 875 parent.addClass('universal-loading-spinner-wrapper'); 876 } 877}]); 878 879// TODO: these parsers need a complement "unparser" in order to take an existing value from an external model and return it to a format that the input can respond to 880// not sure if a formatter will cut it? 881 882/** 883* A simple little parser that returns the boolean interpretation of a value. 884* In the case of a string, all values except 'true', 'y' or 'yes' will count as false. 885* 886* @author noahm 887*/ 888wpd.directive('wpdutilsAsBoolean', [function() { 889 return { 890 require: 'ngModel', 891 restrict: 'A', 892 link: function($scope, $elem, $attrs, ctrl) { 893 var parse = function(value) { 894 if (typeof value === 'string') { 895 let v = value.toLowerCase(); 896 return (v === 'true' || v === 'y' || v === 'yes'); 897 } 898 899 return !!value; 900 }; 901 902 ctrl.$parsers.push(parse); 903 } 904 } 905}]); 906 907/** 908* Might seem unnecessary, but certain inputs (like checkboxes) don't necessarily return what's in their value attribute 909* 910* @author noahm 911*/ 912wpd.directive('wpdutilsAsValueAttr', [function() { 913 return { 914 require: 'ngModel', 915 restrict: 'A', 916 link: function($scope, $elem, $attrs, ctrl) { 917 // this is of no use if we are on an element that has no value attribute. 918 if (!($elem[0] instanceof HTMLInputElement)) return; 919 920 var parse = function(value) { 921 if (value) { 922 return $elem.attr('value'); 923 } 924 925 return null; 926 }; 927 928 ctrl.$parsers.push(parse); 929 } 930 } 931}]); 932 933/** 934* Since angularJS-eqsue '{{}}' strings get sanitized out of WYSIWYG content in the CMS, use '#{}' instead. 935* If an element has this directive attached to it, any instances of '#{}' will be changed to '{{}}' bindings 936* 937* @directive 938* @author noahm 939*/ 940wpd.directive('wpdutilsCompilable', [function() { 941 return { 942 restrict: 'AC', 943 scope: false, 944 compile: function(tElem, tAttrs) { 945 const SEARCH_REGEX = /#\{(.*?)\}/gi; 946 947 tElem.html(function(idx, oldHTML) { 948 return oldHTML.replaceAll(SEARCH_REGEX, '{{ $1 }}'); 949 }); 950 } 951 } 952}]); 953 954wpd.directive('wpdutilsHref', ['$window','$location', function($window, $location) { 955 return { 956 scope: { 957 preserveQueryString: '<?', 958 href: '@?wpdutilsHref' 959 }, 960 link: function(scope, iElem, iAttrs) { 961 const MATCH_REGEX = /^([^?#]*)?(?<query>\?.*)?(?<hash>#.*)?$/gmi; 962 scope.preserveQueryString = scope.preserveQueryString !== undefined ? scope.preserveQueryString : true; 963 964 // preference for an existing href attribute over one given to us, as this may be an href 965 // created by something like ngHref 966 let href = iAttrs.href || scope.href; 967 968 const replaceFn = function() { 969 let href = iAttrs.href || scope.href; 970 let curSearchParams = $window.location.search; 971 let newHref = href.replace(MATCH_REGEX, '$1' + curSearchParams + '$<hash>'); 972 973 if (newHref.startsWith('?')) { 974 newHref = $window.location.pathname + newHref; 975 } 976 iElem.attr('href', newHref); 977 }; 978 979 if (!scope.preserveQueryString) { 980 iElem.attr('href', href); 981 } 982 else { 983 replaceFn(); 984 985 // TODO: watch query string and update href automatically? 986 } 987 } 988 } 989}]) 990 991/** 992* TODO: Map option will not behave as you expect with the ng-model definition on the checkboxes! map['key'] is wrong, you need to use map.set('key') 993* 994* 995*/ 996wpd.directive('wpdutilsCheckboxNgCollectionField', ['wpdutilsCustomInputMixinService', function(customInputMixin) { 997 return { 998 require: ['ngModel'], 999 restrict: 'AE', 1000 scope: { 1001 readonly: '<', 1002 asType: '<', // ('array'|'map'|undefined|null) 1003 fieldId: '<', 1004 disabled: '<', 1005 required: '<' 1006 }, 1007 templateUrl: 'wpdutilsCheckboxNgCollectionField.html',
1008 link: function($scope, $elem, $attrs, ctrl) { 1009 const DIRECTIVE_KEY = 'wpdutilsCheckboxNgCollectionField'; 1010 const INNER_FORM_NAME = 'checkboxCollectionForm'; 1011 1012 // TODO: not sure why in this instance ctrl is an array, even though it has just one member 1013 var ngModelCtrl = Array.isArray(ctrl) ? ctrl[0] : ctrl; 1014 1015 var updatedViaNgChange = false; 1016 1017 var unsetFormData = function() { 1018 $scope.formData = { 1019 checkboxCollection: ($scope.asType === 'array' ? [] : $scope.asType === 'map' ? new Map() : {}) 1020 }; 1021 }; 1022 unsetFormData(); 1023 1024 /** 1025 * Updates the passed in ngModel with the checkboxCollectionForm formData. 1026 * Will only perform the update if all of checkboxCollectionForm's fields are valid. 1027 * 1028 * @return {undefined} 1029 */ 1030 $scope.updateNgModel = function() { 1031 updatedViaNgChange = true; 1032 1033 if ($scope[INNER_FORM_NAME].$valid) { 1034 ngModelCtrl.$setViewValue($scope.formData.checkboxCollection); 1035 } 1036 1037 if ($scope.required) checkValidity(); 1038 }; 1039 1040 1041 // TODO: This should be its own validator directive 1042 function checkValidity() { 1043 var validateArray = []; 1044 if ($scope?.formData?.checkboxCollection?.length > 0) { 1045 $scope.formData.checkboxCollection.forEach(item => { 1046 if (item) { 1047 validateArray.push(item); 1048 } 1049 }); 1050 } 1051 if (validateArray.length <= 0) { 1052 ngModelCtrl.$setValidity("required-empty", false); 1053 } 1054 else { 1055 ngModelCtrl.$setValidity("required-empty", true); 1056 } 1057 } 1058 1059 1060 var updateCheckedProperties = function() { 1061 // TODO: you need to "recheck" the checkboxes which have values that are found in our internal model 1062 }; 1063 1064 // TODO: needs more thorough testing 1065 var updateInternalModel = function(value) { 1066 if (value === undefined || value === null) { 1067 unsetFormData(); 1068 } 1069 else if (Array.isArray(value)) { 1070 switch ($scope.asType) { 1071 case 'map': 1072 let m = new Map(); 1073 value.forEach((elem) => m.set(elem, elem)); // TODO: think about how a user would actually like a map/object to be utilised 1074 $scope.formData.checkboxCollection = m; 1075 break; 1076 case 'array': 1077 // NOTE: this is a shallow copy! 1078 $scope.formData.checkboxCollection = angular.copy(value); 1079 break; 1080 default: 1081 let o = {}; 1082 value.forEach((elem) => o[elem] = elem); 1083 $scope.formData.checkboxCollection = o; 1084 break; 1085 } 1086 } 1087 else if (value instanceof Map) { 1088 switch ($scope.asType) { 1089 case 'array': 1090 let a = []; 1091 for (const [k,v] of value) a.push(v); 1092 $scope.formData.checkboxCollection = a; 1093 break; 1094 case 'map': 1095 // NOTE: this is a shallow copy! 1096 $scope.formData.checkboxCollection = angular.copy(value); 1097 break; 1098 default: 1099 let o = {}; 1100 for (const [k,v] of value) o[k] = v; 1101 $scope.formData.checkboxCollection = o; 1102 break; 1103 } 1104 } 1105 else if (typeof value === 'object') { 1106 switch ($scope.asType) { 1107 case 'array': 1108 let a = []; 1109 Object.entries(value).forEach(([k,v]) => a.push(v)); 1110 $scope.formData.checkboxCollection = a; 1111 break; 1112 case 'map': 1113 let m = new Map(); 1114 Object.entries(value).forEach(([k,v]) =>
1114 m.set(k, v)); 1115 break; 1116 default: 1117 // NOTE: this is a shallow copy! 1118 $scope.formData.checkboxCollection = angular.copy(value); 1119 break; 1120 } 1121 } 1122 }; 1123 1124 var cancelWatch = $scope.$watch(() => ngModelCtrl.$modelValue, function(newVal, oldVal) { 1125 if (!updatedViaNgChange) updateInternalModel(newVal); 1126 updatedViaNgChange = false; 1127 if ($scope.required) checkValidity(); 1128 }); 1129 1130 var statusSyncerCancelFns = customInputMixin.initStatusSyncers($scope, INNER_FORM_NAME, ngModelCtrl, DIRECTIVE_KEY); 1131 1132 } 1133 } 1134}]); 1135 1136/** 1137* Hooks together a "button" functional element with a checkbox or radio input and passes on a click on the button to a click on the input 1138* 1139* @param {string} inputId - the id attribute of the input to link to 1140* @param {string|function} checkedClass - a string or string output expression which represents a CSS class to apply to the "button" when the input is in a checked state 1141* @param {function} extraClickFn - a callback to execute when the input is toggled. It will be supplied the input and the "button" element 1142* 1143* @author noahm 1144*/ 1145wpd.directive('wpdutilsInputToggleButton', [function() { 1146 return { 1147 restrict: 'A', 1148 scope: { 1149 inputId: '<', 1150 checkedClass: '@', 1151 extraClickFn: '&' 1152 }, 1153 link: function($scope, $elem, $attrs) { 1154 if (!$scope.inputId) return; 1155 1156 const $input = angular.element('#' + $scope.inputId.replace('#', '')); 1157 1158 if ($input && $input.length) { 1159 let t = $input.attr('type'); 1160 1161 if (t === 'checkbox' || t === 'radio') { 1162 $scope.toggleInput = function() { 1163 $input.click(); 1164 $scope.extraClickFn && $scope.extraClickFn($input, $elem); 1165 }; 1166 1167 // apply/remove the checkedClass from the button based on the checked property of its input 1168 $scope.$watch(() => $input.prop('checked'), function() { 1169 $input.prop('checked') ? $elem.addClass($scope.checkedClass) : $elem.removeClass($scope.checkedClass); 1170 }); 1171 1172 $elem.on('click', $scope.toggleInput); 1173 } 1174 } 1175 else { 1176 console.error('A checkbox or radio input with ID ' + $scope.inputId + ' couldn\'t be found'); 1177 } 1178 } 1179 } 1180}]); 1181 1182/** 1183* Template: /app/views/ng_templates/wpd/utils/mpan-field.html 1184* Style: ??? 1185* 1186* @author noahm 1187*/ 1188wpd.directive('wpdutilsMpanField', ['$timeout', 'wpdutilsCustomInputMixinService', function($timeout, customInputMixin) { 1189 return { 1190 require: 'ngModel', 1191 restrict: 'AE', 1192 scope: { 1193 isReadonly: '<', 1194 asType: '<', // ('string'|'number'|undefined|null) 1195 fieldId: '<', 1196 isDisabled: '<', 1197 isRequired: '<' 1198 }, 1199 templateUrl: 'wpdutilsMpanField.html', 1200 link: function($scope, $elem, $attrs, ctrl) { 1201 const DIRECTIVE_KEY = 'wpdutilsMpanField'; 1202 const INNER_FORM_NAME = 'mpanForm'; 1203 1204 var updatedViaNgChange = false; 1205 1206 if ($scope.asType !== 'string' && $scope.asType !== 'number') { 1207 $scope.asType = null; 1208 } 1209 1210 // we store the mpan value internally as an object, regardless of the asString setting 1211 var unsetFormData = function() { 1212 $scope.formData = { 1213 distributorId: null, 1214 meterPointId: null, 1215 checkDigit: null 1216 }; 1217 } 1218 unsetFormData(); 1219 1220 /** 1221 * @return {String} 1222 */ 1223 var formDataToString = function() { 1224 return $scope.formData.distributorId + $scope.formData.meterPointId + $scope.formData.checkDigit; 1225 }; 1226 1227 /** 1228 * Updates the passed in ngModel with the mpanForm formData. 1229 * Will only perform the update if all of mpanForm's fields are valid. 1230 * 1231 * @return {undefined} 1232 */ 1233 $scope.updateNgModel = function() { 1234 updatedViaNgChange = true; 1235 1236 if ($scope[INNER_FORM_NAME].$valid) { 1237 switch ($scope.asType) { 1238 case 'string': 1239 ctrl.$setViewValue(formDataToString()); 1240 break; 1241 case 'number': 1242 // NOTE: if casting to a number, no leading 0s will be preserved! 1243 ctrl.$setViewValue(parseInt(formDataToString())); 1244 break; 1245 default: 1246 ctrl.$setViewValue($scope.formData); 1247 } 1248 } 1249 else { 1250 ctrl.$setViewValue(undefined); 1251 } 1252 }; 1253 1254 var updateInternalModel = function(value) { 1255 if (value === undefined || value === null) { 1256 unsetFormData(); 1257 } 1258 else if (typeof value === 'object') { 1259 $scope.formData = { 1260 distributorId: value.distributorId, 1261 meterPointId: value.meterPointId, 1262 checkDigit: value.checkDigit 1263 }; 1264 } 1265 else if (typeof value === 'number' || typeof value === 'string') { 1266 var x = ''; 1267 1268 // transform into string 1269 if (typeof value === 'number') x += value; 1270 else x = value; 1271
1272 // try to split string into the individual digits 1273 try { 1274 $scope.formData = { 1275 distributorId: x.substring(0,2), 1276 meterPointId: x.substring(2,10), 1277 checkDigit: x.substring(10,13), 1278 }; 1279 } 1280 catch (e) { 1281 console.error(e); 1282 } 1283 } 1284 }; 1285 1286 /** 1287 * Interesting in that watching 'ctrl.$modelValue' doesn't seem to fire when you change the underlying model, but watching () => ctrl.$modelValue does. 1288 * You can also watch $attrs['ngModel'] which is a reference to the object being written to, and not the ngModelController itself (ctrl). 1289 * 1290 * In this case, we're wanting to look for a programmatic external update to the ngModel 1291 */ 1292 var cancelWatch = $scope.$watch(() => ctrl.$modelValue, function(newVal, oldVal) { 1293 // an ngChange call that we've internally set up will execute before this watch fires. We don't want to waste time writing back an identical value 1294 // to our internal model if the change to $modelValue came from us. 1295 if (!updatedViaNgChange) { 1296 updateInternalModel(newVal); 1297 } 1298 1299 updatedViaNgChange = false; 1300 }); 1301 1302 var statusSyncerCancelFns = customInputMixin.initStatusSyncers($scope, INNER_FORM_NAME, ctrl, DIRECTIVE_KEY); 1303 } 1304 } 1305}]); 1306 1307/** 1308* Template: /app/views/ng_templates/wpd/utils/numeric-unit-field.html 1309* Style: ??? 1310* 1311* Motivation behind this directive is introducing built-in automatic conversion of the value when units change (set up a standalone angular service for this) 1312* 1313* @author noahm 1314*/ 1315wpd.directive('wpdutilsNumericUnitField', ['wpdutilsCustomInputMixinService', function(customInputMixin) { 1316 return { 1317 require: 'ngModel', 1318 restrict: 'AE', 1319 transclude: true, 1320 scope: { 1321 isReadonly: '<', // {boolean} 1322 asType: '<', // {string} ('string'|undefined|null) 1323 fieldId: '<', // {string} 1324 isDisabled: '<', // {boolean} 1325 isRequired: '<', // {boolean} 1326 units: '<', // {(string|object)[]} 1327 minValue: '<', // {number} 1328 maxValue: '<', // {number} 1329 defaultUnit: '<' // {string} 1330 }, 1331 templateUrl: 'wpdutilsNumericUnitField.html', 1332 link: function($scope, $elem, $attrs, ctrl) { 1333 const DIRECTIVE_KEY = 'wpdutilsNumericUnitField'; 1334 const INNER_FORM_NAME = 'numericUnitForm'; 1335 1336 var updatedViaNgChange = false; 1337 1338 if ($scope.asType !== 'string') { 1339 $scope.asType = null; 1340 } 1341 1342 // if the supplied defaultUnit isn't in the units array, take the first element of the units array instead. 1343 var defaultUnit = null; // $scope.units?.includes($scope.defaultUnit) ? $scope.defaultUnit : ($scope.units?.length ? $scope.units[0] : null); 1344 1345 $scope.unitStrings = []; 1346 $scope.unitValidators = {}; 1347 1348 var _extractUnitValidator = function(validator, unitKey, validatorKey) { 1349 if (!$scope.unitValidators[unitKey]) $scope.unitValidators[unitKey] = {}; 1350 // treat all validation objects as functions to be executed. 1351 $scope.unitValidators[unitKey][validatorKey] = typeof validator === 'function' ? validator : () => { return validator; }; 1352 }; 1353 1354 var _extractUnitData = function(unitObj, unitKey) { 1355 if (typeof unitObj === 'string') { 1356 $scope.unitStrings.push(unitObj); 1357 if (!defaultUnit) defaultUnit = unitObj; 1358 } 1359 else if (typeof unitObj === 'object') { 1360 $scope.unitStrings.push(unitKey); 1361 $scope.unitValidators[unitKey] = {}; 1362 if (!defaultUnit) defaultUnit = unitKey; 1363 1364 if (unitObj['validators']) { 1365 if (Array.isArray(unitObj['validators'])) { 1366 unitObj['validators'].forEach(function(elem, validatorKey) { 1367 _extractUnitValidator(elem, unitKey, validatorKey); 1368 }); 1369 } 1370 else { 1371 for (const validatorKey in unitObj['validators']) { 1372 _extractUnitValidator(unitObj['validators'][validatorKey], unitKey, validatorKey); 1373 } 1374 } 1375 } 1376 } 1377 }; 1378 1379 if ($scope.units) { 1380 if (Array.isArray($scope.units)) {
1381 $scope.units.forEach(_extractUnitData); 1382 } 1383 else { // assume to be an object 1384 for (const unitKey in $scope.units) { 1385 _extractUnitData($scope.units[unitKey], unitKey) 1386 } 1387 } 1388 } 1389 1390 // we store the mpan value internally as an object, regardless of the asString setting 1391 var unsetFormData = function() { 1392 $scope.formData = { 1393 value: null, 1394 unit: defaultUnit 1395 }; 1396 } 1397 unsetFormData(); 1398 1399 /** 1400 * @return {String} 1401 */ 1402 var formDataToString = function() { 1403 return $scope.formData.value + $scope.formData.unit; 1404 }; 1405 1406 /** 1407 * Updates the passed in ngModel with the numericUnitForm formData. 1408 * Will only perform the update if all of numericUnitForm's fields are valid. 1409 * 1410 * @return {undefined} 1411 */ 1412 $scope.updateNgModel = function() { 1413 updatedViaNgChange = true; 1414 1415 if ($scope[INNER_FORM_NAME].$valid) { 1416 switch ($scope.asType) { 1417 case 'string': 1418 ctrl.$setViewValue(formDataToString()); 1419 break; 1420 default: 1421 ctrl.$setViewValue($scope.formData); 1422 } 1423 } 1424 else { 1425 ctrl.$setViewValue(undefined); 1426 } 1427 }; 1428 1429 var updateInternalModel = function(value) { 1430 if (value === undefined || value === null) { 1431 unsetFormData(); 1432 } 1433 else if (typeof value === 'object') { 1434 $scope.formData = { 1435 value: ctrl.$modelValue.value, 1436 unit: ctrl.$modelValue.unit 1437 }; 1438 } 1439 else if (typeof value === 'number') { 1440 $scope.formData.value = ctrl.$modelValue; 1441 } 1442 else if (typeof value === 'string') { 1443 // given a string that appears to be a representation of a numerical value plus a unit, try to split them 1444 // e.g. 5.65ppm => 5.65 and 'ppm' 1445 1446 const regex = /(?<value>\d*\.?\d*)(?<unit>.*)/gm; 1447 const results = regex.exec(ctrl.$modelValue); 1448 1449 $scope.formData = { 1450 value: results.groups.value, 1451 unit: results.groups.unit?.trim() 1452 }; 1453 } 1454 }; 1455 1456 var cancelWatch = $scope.$watch(() => ctrl.$modelValue, function(newVal, oldVal) { 1457 // an ngChange call that we've internally set up will execute before this watch fires. We don't want to waste time writing back an identical value 1458 // to our internal model if the change to $modelValue came from us. 1459 if (!updatedViaNgChange) { 1460 updateInternalModel(newVal); 1461 } 1462 1463 updatedViaNgChange = false; 1464 }); 1465 1466 var statusSyncerCancelFns = customInputMixin.initStatusSyncers($scope, INNER_FORM_NAME, ctrl, DIRECTIVE_KEY); 1467 } 1468 } 1469}]); 1470 1471/** 1472* Template: /app/views/ng_templates/wpd/utils/telephone-with-type-field.html 1473* Style: ??? 1474* 1475* @author noahm 1476*/ 1477wpd.directive('wpdutilsTelephoneWithTypeField', ['wpdutilsCustomInputMixinService', '$compile', '$parse', function(customInputMixin, $compile, $parse) { 1478 return { 1479 require: 'ngModel', 1480 restrict: 'AE', 1481 scope: { 1482 isReadonly: '<', // {boolean} 1483 asType: '<', // {string} ('string'|undefined|null) 1484 fieldId: '<', // {string} 1485 fieldName: '<', // {string} 1486 isDisabled: '<', // {boolean} 1487 isRequired: '<', // {boolean} 1488 telephoneTypes: '<', // {Object[]} 1489 placeholder: '<', // {string} 1490 }, 1491 templateUrl: 'wpdutilsTelephoneWithTypeField.html',
1492 compile: function($elem, $attrs) { 1493 // Add the validator attribute prior to compile 1494 1495 // This function modifies the template itself, not the instance, 1496 // so if this isn't the first use of this directive, we need to clean it up 1497 const telephoneField = $elem.find('input[type="tel"]'); 1498 telephoneField.removeAttr('wpdutils-is-telephone-mobile wpdutils-is-telephone-tight wpdutils-is-telephone'); 1499 1500 // Which validator to use (telephone-number-validator attribute)? 1501 // Because we're pre-compile, the $attrs haven't been parsed yet :( 1502 let validatorAttr = $parse($attrs.telephoneNumberValidator)(); 1503 if (validatorAttr) { 1504 // Add the data- prefix if necessary 1505 if (!validatorAttr.startsWith('data-')) { 1506 validatorAttr = 'data-' + validatorAttr; 1507 } 1508 } 1509 else { 1510 // Default to the standard telephone validator 1511 validatorAttr = 'wpdutils-is-telephone'; 1512 } 1513 1514 // Add the selected validator to the telephone number field 1515 telephoneField.attr(validatorAttr, ''); 1516 1517 1518 // Standard post-link function 1519 return function($scope, $elem, $attrs, ctrl) { 1520 const DIRECTIVE_KEY = 'wpdutilsTelephoneWithTypeField'; 1521 const INNER_FORM_NAME = 'telephoneWithTypeForm'; 1522 1523 var updatedViaNgChange = false; 1524 1525 const defaultType = $scope.telephoneTypes?.length ? $scope.telephoneTypes[0]?.value : null; 1526 1527 // we store the value internally as an object, regardless of the asString setting 1528 var unsetFormData = function() { 1529 $scope.formData = { 1530 telephone: null, 1531 type: defaultType 1532 }; 1533 } 1534 unsetFormData(); 1535 1536 if ($scope?.$parent?.vm?.formData?.hasOwnProperty($scope.fieldName)) { 1537 $scope.formData = $scope?.$parent?.vm?.formData[$scope.fieldName]; 1538 } 1539 1540 /** 1541 * Updates the passed in ngModel with the telephoneWithTypeForm formData. 1542 * Will only perform the update if all of telephoneWithTypeForm's fields are valid. 1543 * 1544 * @return {undefined} 1545 */ 1546 $scope.updateNgModel = function() { 1547 updatedViaNgChange = true; 1548 1549 if ($scope[INNER_FORM_NAME].$valid) { 1550 ctrl.$setViewValue($scope.formData); 1551 } 1552 else { 1553 ctrl.$setViewValue(undefined); 1554 } 1555 }; 1556 1557 var updateInternalModel = function(value) { 1558 if (value === undefined || value === null) { 1559 unsetFormData(); 1560 } 1561 else if (typeof value === 'object') { 1562 $scope.formData = { 1563 telephone: value.telephone, 1564 type: value.type 1565 }; 1566 } 1567 else if (typeof value === 'number' || typeof value === 'string') { 1568 $scope.formData.telephone = value + ''; 1569 } 1570 } 1571 1572 var cancelWatch = $scope.$watch(() => ctrl.$modelValue, function(newVal, oldVal) { 1573 if (!updatedViaNgChange) updateInternalModel(newVal); 1574 updatedViaNgChange = false; 1575 }); 1576 1577 var statusSyncerCancelFns = customInputMixin.initStatusSyncers($scope, INNER_FORM_NAME, ctrl, DIRECTIVE_KEY); 1578 }; 1579 } 1580 }; 1581}]); 1582 1583 1584// =========================================================================================== 1585// ------------------------------ Form validation directives --------------------------------- 1586// =========================================================================================== 1587 1588/** 1589 * @return {boolean} - true if viewValue is a postcode that exists within the shared data service (has a dno). 1590 */ 1591wpd.directive('registeredPostcodeCheck', ['$requester', '$q', function($requester, $q) { 1592 var json = $requester({ 1593 registeredPostcodeCheck: 'WPDUtilsFront.registeredPostcode_JSON' 1594 });
1595 1596 return { 1597 require: 'ngModel', 1598 link: function($scope, $elem, $attrs, ctrl) { 1599 const DIRECTIVE_KEY = 'registeredPostcodeCheck'; 1600 1601 if (!ctrl.customErrorMessages) ctrl.customErrorMessages = {}; 1602 1603 // Set up the custom error message for this ngModel on failure of this validator 1604 ctrl.customErrorMessages[DIRECTIVE_KEY] = null; 1605 1606 ctrl.$asyncValidators[DIRECTIVE_KEY] = function(modelValue, viewValue) { 1607 return $q(function(resolve, reject) { 1608 if (ctrl.$isEmpty(modelValue)) { 1609 // consider empty models to be valid 1610 ctrl.customErrorMessages[DIRECTIVE_KEY] = null; 1611 resolve(); 1612 } 1613 1614 if (viewValue) { 1615 json.registeredPostcodeCheck({postcode: viewValue}, function(response) { 1616 if (response && response.areaStatus) { 1617 ctrl.customErrorMessages[DIRECTIVE_KEY] = null; 1618 resolve(); 1619 } 1620 else { 1621 ctrl.customErrorMessages[DIRECTIVE_KEY] = "Sorry, this postcode isn't registered."; 1622 reject(); 1623 } 1624 }); 1625 } 1626 else { 1627 ctrl.customErrorMessages[DIRECTIVE_KEY] = "Please enter a valid postcode"; 1628 reject(); 1629 } 1630 }); 1631 }; 1632 } 1633 }; 1634}]); 1635 1636 1637 1638// Author: @ianbe 1639wpd.directive('wpdutilsCheckboxGroupField', 1640 ['wpdutilsCustomInputMixinService', '$timeout', function(customInputMixin, $timeout) { 1641 return { 1642 require: 'ngModel', 1643 restrict: 'AE', 1644 scope: { 1645 isReadonly: '<', // {boolean} 1646 fieldId: '<', // {string} 1647 fieldName: '<', // {string} 1648 disabled: '<', // {boolean} 1649 required: '<', // {boolean} 1650 placeholder: '<', // {string} 1651 wrapperCssClasses: '@', // {string} 1652 1653 options: '<', // {Object[]} 1654 1655 type: '<', // {string} 1656 1657 // Indexed types only 1658 trueForChecked: '<', // {boolean} 1659 1660 // Ignored if type is 'array' 1661 falseForUnchecked: '<', // {boolean} // Takes priority over nullForUnchecked 1662 nullForUnchecked: '<', // {boolean} 1663 }, 1664 templateUrl: 'wpdutilsCheckboxGroupField.html', 1665 link: function($scope, $elem, $attrs, ctrl) { 1666 var i; 1667 1668 const DIRECTIVE_KEY = 'wpdUtilsCheckboxField'; 1669 const INNER_FORM_NAME = 'checkboxCollectionForm'; 1670 1671 var COLLECTION_TYPE = 'indexObject'; 1672 var TRUE_FOR_CHECKED = false; 1673 var FALSE_VALUE = undefined; 1674 1675 const ngModelCtrl = Array.isArray(ctrl) ? ctrl[0] : ctrl; 1676 1677 // this version of the internal model is just going to be the object as built by the vanilla behaviour (similar to indexObject, but with undefined/null) 1678 // the ngmodel with our desired structure will be built separately from it 1679 $scope.formData = { 1680 checkboxCollection: {} 1681 }; 1682 1683 switch ($scope.type) { 1684 case 'array': 1685 // Array type only contains checked values, from top to bottom. Indexes are lost but there is no empty space. 1686 // Good for storing a simple list of selected options. 1687 // Example: [ "VALUE2", "VALUE4" ] 1688 COLLECTION_TYPE = 'array'; 1689 break; 1690 case 'object': 1691 // Object type uses the option value as the key and the checked state (true/false) as the value. 1692 // This allows you to treat each option almost as its own separate boolean variable. 1693 // If falseForUnchecked or nullForUnchecked are set, all boxes will be present, otherwise only checked boxes will be present. 1694 // Example: { "VALUE1": FALSE_VALUE, "VALUE2": true, "VALUE3": FALSE_VALUE, "VALUE4": true } 1695 COLLECTION_TYPE = 'object'; 1696 break; 1697 case 'indexArray': 1698 // IndexArray type is almost identical to IndexObject type, but it uses an array instead of an object. 1699 // Example: [ 0: undefined, 1: FALSE_VALUE, 2: "VALUE2", 3: FALSE_VALUE, 4: "VALUE4" ] 1700 COLLECTION_TYPE = 'indexArray'; 1701 break; 1702 default: 1703 // indexObject (default): 1704 // IndexObject is similar to vanilla checkbox behaviour. The index is the key, and checked boxes will have their option value as the value. 1705 // falseForUnchecked or nullForUnchecked are available as de
1705scribed above. 1706 // trueForChecked will set the value to true instead of the option value. 1707 // Example: { 1: FALSE_VALUE, 2: "VALUE2", 3: FALSE_VALUE, 4: "VALUE4" } 1708 break; 1709 } 1710 1711 if (COLLECTION_TYPE !== 'array') { 1712 if ($scope.falseForUnchecked) { 1713 FALSE_VALUE = false; 1714 } 1715 else if ($scope.nullForUnchecked) { 1716 FALSE_VALUE = null; 1717 } 1718 1719 if (COLLECTION_TYPE !== 'object') { 1720 TRUE_FOR_CHECKED = ($scope.trueForChecked) ? true : false; 1721 } 1722 } 1723 1724 1725 1726 function checkValidity() { 1727 const validCount = Object.values($scope?.formData?.checkboxCollection).filter(o => typeof o === 'string').length; 1728 ngModelCtrl.$setValidity("required-empty", validCount > 0); 1729 } 1730 1731 1732 var directiveCausedUpdate = false; 1733 $scope.updateNgModel = function() { 1734 if ($scope[INNER_FORM_NAME].$valid) { 1735 directiveCausedUpdate = true; 1736 let viewValue = generateViewModel($scope.formData.checkboxCollection); 1737 ngModelCtrl.$setViewValue(viewValue); 1738 } 1739 1740 if ($scope.required) checkValidity(); 1741 }; 1742 1743 var generateViewModel = function(input) { 1744 let output; 1745 if (COLLECTION_TYPE === 'array') { 1746 output = Object.values(input).filter(val => typeof val === 'string'); 1747 } 1748 else if (COLLECTION_TYPE === 'object') { 1749 output = {}; 1750 if (FALSE_VALUE !== undefined) { 1751 for (const k of $scope.options) { 1752 output[k.value] = FALSE_VALUE; 1753 } 1754 } 1755 Object.entries(input).forEach(([k, v]) => { 1756 if (typeof v === 'string') { 1757 output[v] = true; 1758 } 1759 }); 1760 } 1761 else if (['indexArray', 'indexObject'].includes(COLLECTION_TYPE)) { 1762 output = (COLLECTION_TYPE === 'indexArray') ? [] : {}; 1763 if (FALSE_VALUE !== undefined) { 1764 for (i = 1; i <= $scope.options.length; i++) { 1765 output[i] = FALSE_VALUE; 1766 } 1767 } 1768 Object.entries(input).forEach(([k, v]) => { 1769 let value; 1770 if (typeof v === 'string') { 1771 value = TRUE_FOR_CHECKED ? true : v; 1772 } 1773 else value = FALSE_VALUE; 1774 output[k] = value; 1775 }); 1776 } 1777 return output; 1778 } 1779 1780 1781 var fieldsByIndex = []; 1782 var fieldsByValue = {}; 1783 i = 1; 1784 for (let o of $scope.options) { 1785 fieldsByIndex[i] = o.value; 1786 fieldsByValue[o.value] = i; 1787 i++; 1788 } 1789 1790 1791 var generateInternalModel = function(input) { 1792 let output = {}; 1793 1794 if (COLLECTION_TYPE === 'indexObject') { 1795 Object.entries(input).forEach(([k, v]) => { 1796 if (TRUE_FOR_CHECKED) { 1797 if (v === true) { 1798 output[k] = fieldsByIndex[k]; 1799 }; 1800 } 1801 else { 1802 output[k] = v; 1803 } 1804 }); 1805 } 1806 else if (COLLECTION_TYPE === 'indexArray') { 1807 input.forEach((v, k) => { 1808 if (TRUE_FOR_CHECKED) { 1809 if (v === true) { 1810 output[k] = fieldsByIndex[k]; 1811 } 1812 } 1813 else { 1814 output[k] = v; 1815 } 1816 }); 1817 } 1818 else if (COLLECTION_TYPE === 'object') { 1819 Object.entries(input).forEach(([k, v]) => { 1820 if (v === true) { 1821 output[fieldsByValue[k]] = k; 1822 } 1823 }); 1824 } 1825 else if (COLLECTION_TYPE === 'array') { 1826 input.forEach((v, k) => { 1827 output[fieldsByValue[v]] = v; 1828 }); 1829 } 1830 return output; 1831 } 1832 1833 var statusSyncerCancelFns = customInputMixin.initStatusSyncers($scope, INNER_FORM_NAME, ngModelCtrl, DIRECTIVE_KEY); 1834 1835 1836 // Wait a digest cycle to link to existing form data if present, and then rebuild the internal model from it 1837 $timeout(function() { 1838 var existingData = ngModelCtrl.$viewValue; 1839 if (existingData) { 1840 $scope.formData.checkboxCollection = generateInternalModel(existingData); 1841 } 1842 }); 1843 1844 } 1845 } 1846 }] 1847) 1848 1849/** 1850 * Uses the WIMS API's postcodeLookup endpoint to get address data for a given postcode, and populates the given fields 1851 * 1852 * @author ianbe 1853 */ 1854wpd.directive('wpdutilsAddressLookup', 1855 ['wpdutilsCustomInputMixinService', 'wpdutilsAddressLookupService', '$timeout', '$interpolate', function(customInputMixin, AddressLookupService, $timeout, $interpolate) { 1856 return { 1857 require: 'ngModel', 1858 restrict: 'AE', 1859 scope: { 1860 fieldId: '<', // {string} - the ID of the address lookup field as a whole 1861 fieldName: '<', // {string:null} - if provided, the field name within the parent form to use as the object c
1861ontaining the final address data 1862 // if not provided, the address data will be bound to the parent directly 1863 formName: '<', // {string:null} - if provided, the name of the inner form containing the search section 1864 // if not provided, "wpdutilsAddressLookup_" followed by a UUID will be used instead 1865 1866 required: '<', // {boolean:false} - whether the address is required 1867 // in practice, the main address fields are outside of this form and have their own validation, 1868 // so this is already satisfied if the address section is shown 1869 inArea: '<', // {boolean:false} - whether the postcode needs to satisfy postcodeAreaCheck validation 1870 label: '<', // {string} - the label to display above the postcode search field 1871 loqate: '<', // {string} - the handle of the API key preference to use for the address lookup service 1872 maxLengthLine1: '<', // {number} - the maximum length of the address line 1 field, only used if the address line 1 field is not provided by the API 1873 maxLengthLine2: '<', // {number} - the maximum length of the address line 2 field, only used if the address line 1 field is not provided by the API 1874 }, 1875 templateUrl: 'wpdutilsAddressLookup.html', 1876 transclude: true, 1877 link: function($scope, $elem, $attrs, ctrl, transclude) { 1878 1879 transclude($scope.$parent, function(clone) { 1880 $elem.find('[data-wpdutilsaddressfield-address-fields]').prepend(clone); 1881 }); 1882 1883 const DIRECTIVE_KEY = 'wpdutilsAddressLookup'; 1884 1885 //const INNER_FORM_NAME = $scope.formName ?? DIRECTIVE_KEY + '_' + crypto.randomUUID().replace('-', ''); 1886 const INNER_FORM_NAME = DIRECTIVE_KEY; 1887 // $scope.innerFormName = INNER_FORM_NAME; 1888 1889 const FIELDS = ['addressLine1', 'addressLine2', 'town', 'county', 'postcode']; 1890 1891 $scope.required = ($scope.required == true); 1892 $scope.inArea = ($scope.inArea == true); 1893 $scope.label = $scope.label ?? 'Address'; 1894 1895 // Interpolate the fieldId in case it contains variables 1896 $scope.fieldId = $interpolate($scope.fieldId)($scope.$parent); 1897 1898 var updatedViaNgChange = false; 1899 1900 1901 const getAddressField = function(handle) { 1902 const field = $elem.find('[data-wpdutilsaddresslookup-address=' + handle + ']'); 1903 return field ? field.controller('ngModel') : null; 1904 } 1905 1906 // Setup the loquate address lookup service 1907 const ADDRESS_LOOKUP_SERVICE = new AddressLookupService($scope.loqate); 1908 1909 // Populate the select list from a postcode search 1910 $scope.addressLookupList = null; 1911 $scope.currentlySearching = false; 1912 $scope.searchPostcode = function() { 1913 let lookupPostcode; 1914 var postcodeValid = false; 1915 1916 var postcodeSearchField = $scope[INNER_FORM_NAME].postcodeSearch; 1917 1918 if (postcodeSearchField?.$valid) 1919 { 1920 lookupPostcode = postcodeSearchField.$viewValue; 1921 postcodeValid = Object.keys(postcodeSearchField.$error)?.length === 0; 1922 } 1923 1924 if (postcodeValid && (lookupPostcode?.length >= 4)) { 1925 $scope.currentlySearching = true; 1926 ADDRESS_LOOKUP_SERVICE.addressFind(lookupPostcode).then( 1927 function(response) { 1928 $scope.currentlySearching = false; 1929 if (response?.result?.items) { 1930 $scope.addressLookupList = response.result.items; 1931 for (let item of $scope.addressLookupList) { 1932 item.label = `${item.text}, ${item.description}`; 1933 } 1934 } 1935 else { 1936 _noResultFromAddressLookup(); 1937 } 1938 }, 1939 _noResultFromAddressLookup.bind(null) 1940 ); 1941 1942 } 1943 else { 1944 $scope.addressLookupList = []; 1945 } 1946 1947 checkValidity(); 1948 }; 1949 1950 const _noResultFromAddressLookup = function() { 1951 $scope.currentlySearching = false; 1952 $scope.addressLookupList = []; 1953 } 1954 1955 // Control the visibility of the manual site address fields 1956 $scope.showAddressSection = false; 1957 $scope.setShowAddressSection = function(bool) 1958 { 1959 $timeout(function() { 1960 $scope.showAddressSection = bool; 1961 checkValidity(); 1962 1963 // If the address section is shown, don't care about validation on the (invisible) postcode search 1964 if (bool) { 1965 $scope[INNER_FORM_NAME].postcodeSearch.$setValidity('isPostcode', true); 1966 } 1967 }); 1968 } 1969 1970 $scope.currentlyGettingAddress = false; 1971 $scope.setAddress = function() { 1972 const barcode = $scope[INNER_FORM_NAME].selectedAddress.$modelValue?.id; 1973 1974 if (barcode !== null) { 1975 $scope.currentlyGettingAddress = true; 1976 ADDRESS_LOOKUP_SERVICE.addressRetrieve(barcode).then( 1977 function(response) { 1978 $scope.currentlyGettingAddress = false; 1979 if (response?.result?.address) { 1980 const address = response.result.address; 1981 1982 // If the address line 1 (and 2) is not provided by the API, we need to construct it ourselves 1983 // Adapted from Lloyd's PCR lookup 1984 if (!address.line1) { 1985 // Filter out any empty fields 1986 const fieldList = [address.company, address.subBuilding, address.buildingName, address.buildingNumber, address.street] 1987 .filter((field) => field != null && field != ''); 1988 // If exactly two fields are present, just use them as line 1 and line 2 1989 if (fieldList.length == 2) { 1990 [address.line1, address.line2] = fieldList; 1991 } 1992 // Otherwise, join the non-empty fields with a comma 1993 else { 1994 const separator = ', '; 1995 let line1 = fieldList.join(separator); 1996 // If too long for the field, trim down to last comma and move the rest to line 2 1997 const line1MaxLength = $scope.maxLengthLine1 || 255; 1998 const line2MaxLength = $scope.maxLengthLine2 || 255; 1999 if (line1.length > line1MaxLength) { 2000 const lastCommaAt = line1.lastIndexOf(separator, line1MaxLength); 2001 let line2 = line1.substring(lastCommaAt + separator.length); 2002 line1 = line1.substring(0, lastCommaAt); 2003 if (line2.length > line2MaxLength) { 2004 line2 = line2.substring(0, line2MaxLength); 2005 } 2006 address.line2 = line2; 2007 } 2008 address.line1 = (line1.length > 0) ? line1 : 'No first line of address found.'; 2009 } 2010 } 2011 2012 // sometimes county is not provided by the API (e.g. London addresses), so add a placeholder if required 2013 if ((address?.county === "") && (getAddressField('county').$$attr.required === true)) { 2014 address.county = '-'; 2015 } 2016 2017 $scope.setShowAddressSection(true); 2018 2019 const fields = { 2020 addressLine1: address.line1, 2021 addressLine2: address.line2, 2022 town: address.city, 2023 county: address.county, 2024 postcode: address.postcode, 2025 }; 2026 for (const [field, value] of Object.entries(fields)) { 2027 const addressField = getAddressField(field); 2028 if (addressField) { 2029 addressField.$setViewValue(value); 2030 addressField.$setTouched(); 2031 addressField.$render(); 2032 } 2033 } 2034 2035 // Set the lookup field in case we come back 2036 const postcodeSearchField = $scope[INNER_FORM_NAME].postcodeSearch; 2037 const previousPostcodeValue = postcodeSearchField.$viewValue; 2038 postcodeSearchField.$setViewValue(address.postcode); 2039 if (postcodeSearchField.$invalid) { 2040 postcodeSearchField.$setViewValue(previousPostcodeValue); 2041 } 2042 postcodeSearchField.$render(); 2043 2044 } 2045 }, 2046 function() { 2047 $scope.currentlyGettingAddress = false; 2048 $scope.setShowAddressSection(true); 2049 } 2050 ); 2051 } 2052 2053 checkValidity(); 2054 }; 2055 2056 // Repopulate the internal model with the existing data if it exists 2057 if ($scope?.$parent?.vm?.formData) { 2058 // If the fieldName is a dot notation path or array notation path, split it and reduce to get the value 2059 // - filter(Boolean) is just a truthiness test equivalent to !! to get rid of empty strings from the split 2060 let repopulated = false; 2061 let existingData = $scope.$parent.vm.formData; 2062 if ($scope.fieldName) { 2063 existingData = $scope.fieldName.split(/\.|\[|\]/).filter(
2063Boolean).reduce((acc, key) => acc && acc[key], existingData); 2064 } 2065 for (const field of FIELDS) { 2066 const addressField = getAddressField(field); 2067 if (addressField) { 2068 // Get the last part of the field name 2069 const existingName = addressField.$name.split('.').pop(); 2070 if (existingData && existingData.hasOwnProperty(existingName)) { 2071 addressField.$setViewValue(existingData[existingName]); 2072 addressField.$render(); 2073 repopulated = true; 2074 } 2075 } 2076 } 2077 if (repopulated) { 2078 $scope.setShowAddressSection(true); 2079 } 2080 } 2081 2082 const checkValidity = function() { 2083 if ($scope.required) { 2084 let ok = true; 2085 2086 // The lookup is valid if the address section is shown 2087 if (!$scope.showAddressSection) { 2088 ok = false; 2089 } 2090 2091 $scope[INNER_FORM_NAME].$setValidity('required', ok); 2092 } 2093 } 2094 2095 $scope[INNER_FORM_NAME].customErrorMessages = { 2096 required: "Required", 2097 }; 2098 2099 // Set the initial valid state 2100 $timeout(() => { 2101 const statusSyncerCancelFns = customInputMixin.initStatusSyncers($scope, INNER_FORM_NAME, ctrl, DIRECTIVE_KEY); 2102 checkValidity(); 2103 }); 2104 } 2105 }; 2106}]); 2107 2108 2109/** 2110 * Uses the WIMS API's postcodeLookup endpoint to get address data for a given postcode, and populates the given fields 2111 * Special version :) 2112 * 2113 * @author ianbe, michals 2114 */ 2115wpd.directive('wpdutilsAddressLookupWims', 2116 ['wpdutilsCustomInputMixinService', 'wpdutilsAddressLookupService', '$timeout', '$interpolate',
2117 function(customInputMixin, AddressLookupService, $timeout, $interpolate) { 2118 return { 2119 require: 'ngModel', 2120 restrict: 'AE', 2121 scope: { 2122 fieldId: '<', // {string} - ID of the address lookup field as a whole 2123 fieldName: '<', // {string|null} - field name within the parent form for the address data object; 2124 // if omitted, address data binds to the parent directly 2125 formName: '<', // {string|null} - name of the inner form containing the search section; 2126 // if omitted, defaults to "wpdutilsAddressLookup_<UUID>" 2127 required: '<', // {boolean:false} - whether an address is required 2128 inArea: '<', // {boolean:false} - whether the postcode must pass postcodeAreaCheck validation 2129 label: '<', // {string} - label displayed above the postcode search field 2130 loqate: '<', // {string} - API key preference handle for the address lookup service 2131 maxLengthLine1: '<', // {number} - max length of address line 1 (used when the API omits it) 2132 maxLengthLine2: '<', // {number} - max length of address line 2 (used when the API omits it) 2133 lockAddressFields: '<', // {boolean:false} - lock address fields after a successful WIMS lookup 2134 allowWimsLookup: '<', 2135 allowLoqateLookup: '<', 2136 allowIdnoLookup: '<' 2137 }, 2138 templateUrl: 'wpdutilsAddressLookupWIMS.html', 2139 transclude: true, 2140 link: function($scope, $elem, $attrs, ctrl, transclude) { 2141 2142 // ─── Constants ──────────────────────────────────────────────────────────── 2143 2144 const DEBUG = false; 2145 const DIRECTIVE_KEY = 'wpdutilsAddressLookupWIMS'; 2146 const INNER_FORM = DIRECTIVE_KEY; // alias for $scope[INNER_FORM] 2147 const ADDRESS_FIELDS = ['addressLine1', 'addressLine2', 'town', 'county', 'postcode']; 2148 2149 const ADDRESS_SOURCE = { 2150 MANUAL: 'MANUAL', // Manual entry or Loqate fallback 2151 LOQATE: 'LOQATE', // Loqate lookup (editable, same as MANUAL in practice) 2152 WIMS: 'WIMS', // WIMS lookup - allows MPAN retrieval later 2153 }; 2154 2155 // ─── Scope defaults ─────────────────────────────────────────────────────── 2156 2157 $scope.addressSourceEnum = ADDRESS_SOURCE; 2158 $scope.label = $scope.label ?? 'Address'; 2159 $scope.required = $scope.required == true; 2160 $scope.lockAddressFields = $scope.lockAddressFields == true; 2161 $scope.allowWimsLookup = $scope.allowWimsLookup != false; 2162 $scope.allowLoqateLookup = $scope.allowLoqateLookup != false; 2163 $scope.inArea = $scope.inArea == true; 2164 $scope.fieldId = $interpolate($scope.fieldId)($scope.$parent); 2165 $scope.allowIdnoLookup = $scope.allowIdnoLookup != false; 2166 2167 $scope.addressSource = null; 2168 $scope.addressLookupList = null; 2169 $scope.showAddressSection = false; 2170 $scope.currentlySearching = false; 2171 $scope.currentlyGettingAddress = false; 2172 $scope.postcodeAreaCheckResult = null; 2173 2174 const ADDRESS_LOOKUP_SERVICE = new AddressLookupService($scope.loqate); 2175 2176 transclude($scope.$parent, function(clone) { 2177 $elem.find('[data-wpdutilsaddressfield-address-fields]').prepend(clone); 2178 }); 2179 2180 // ─── Helpers ────────────────────────────────────────────────────────────── 2181 2182 const resetData = function(keepPostcode = false) { 2183 const savedValue = keepPostcode 2184 ? $scope[INNER_FORM]?.postcodeSearch?.$viewValue 2185 : null; 2186 // Clear address fields, unlock, and reset validation state 2187 ADDRESS_FIELDS.forEach(function(field) { 2188 const addressField = getAddressField(field); 2189 if (!addressField) return; 2190 addressField.$setViewValue(null); 2191 addressField.$setPristine(); 2192 addressField.$setUntouched(); 2193 addressField.$render(); 2194 addressField.$$element.prop('disabled', false); 2195 addressField.$$element.removeAttr('disabled'); 2196 }); 2197
2198 // Clear postcode search field 2199 const postcodeSearch = $scope[INNER_FORM]?.postcodeSearch; 2200 if (postcodeSearch) { 2201 postcodeSearch.$setViewValue(null); 2202 postcodeSearch.$setPristine(); 2203 postcodeSearch.$setUntouched(); 2204 postcodeSearch.$render(); 2205 } 2206 2207 // Wipe parent form data for this field 2208 const formData = $scope?.$parent?.vm?.formData; 2209 if (formData && $scope.fieldName) { 2210 formData[$scope.fieldName] = null; 2211 formData[$scope.fieldName + 'AddressSource'] = null; 2212 formData[$scope.fieldName + 'MPAN'] = null; 2213 } 2214 2215 // Reset all directive state 2216 $scope.addressSource = null; 2217 $scope.addressLookupList = null; 2218 $scope.showAddressSection = false; 2219 $scope.currentlySearching = false; 2220 $scope.currentlyGettingAddress = false; 2221 $scope.postcodeAreaCheckResult = null; 2222 2223 checkValidity(); 2224 2225 if (savedValue) { 2226 const postcodeSearch = $scope[INNER_FORM]?.postcodeSearch; 2227 postcodeSearch.$setViewValue(savedValue); 2228 postcodeSearch.$render(); 2229 } 2230 }; 2231 2232 $scope.resetData = resetData; 2233 2234 /** Returns the ngModel controller for a named address field, or null. */ 2235 const getAddressField = function(handle) { 2236 const field = $elem.find('[data-wpdutilsaddresslookupwims-address=' + handle + ']'); 2237 return field ? field.controller('ngModel') : null; 2238 }; 2239 2240 /** Returns the current postcode search value. */ 2241 const getPostcodeValue = function() { 2242 return cleanPostcode($scope[INNER_FORM]?.postcodeSearch?.$viewValue); 2243 }; 2244 2245 /** Returns true only if the postcode field is valid and long enough to search. */ 2246 const isPostcodeValid = function(postcode) { 2247 const field = $scope[INNER_FORM]?.postcodeSearch; 2248 return field?.$valid 2249 && Object.keys(field.$error ?? {}).length === 0 2250 && postcode?.length >= 4; 2251 }; 2252 2253 /** Re-runs form validity so that the required error reflects the current state. */ 2254 const checkValidity = function() { 2255 if ($scope.required) { 2256 let ok = true; 2257 if(!$scope.showAddressSection) { 2258 ok = false; 2259 } 2260 $scope[INNER_FORM]?.$setValidity('required', ok); 2261 } 2262 }; 2263 2264 // ─── Field locking ──────────────────────────────────────────────────────── 2265 2266 /** 2267 * Returns true when address fields should be disabled. 2268 */ 2269 $scope.addressFieldsLocked = function() { 2270 return $scope.showAddressSection 2271 && $scope.lockAddressFields 2272 && $scope.addressSource === ADDRESS_SOURCE.WIMS; 2273 }; 2274 2275 /** Applies or removes the disabled attribute on all address fields to match the lock state. */ 2276 const applyFieldLocking = function() { 2277 const locked = $scope.addressFieldsLocked(); 2278 ADDRESS_FIELDS.forEach(function(field) { 2279 const addressField = getAddressField(field); 2280 if (!addressField) return; 2281 addressField.$$element.prop('disabled', locked); 2282 if (!locked) addressField.$$element.removeAttr('disabled'); 2283 }); 2284 }; 2285 2286 // ─── Address section visibility ─────────────────────────────────────────── 2287 2288 /** Programmatic show/hide - also broadcast via $on('showAddressSection') below. */ 2289 $scope.setShowAddressSection = function(show) { 2290 $timeout(function() { 2291 $scope.showAddressSection = show; 2292 checkValidity(); 2293 2294 if (show) { 2295 // Suppress postcode-search validation while address fields are visible 2296 $scope[INNER_FORM].postcodeSearch.$setValidity('isPostcode', true); 2297 applyFieldLocking(); 2298 return; 2299 } 2300 2301 // ── Hiding: clear fields, reset state ──────────────────────────── 2302 $scope.$parent.vm.formData[$scope.fieldName + 'AddressSource'] = null; 2303 $scope.$parent.vm.formData[$scope.fieldName + 'MPAN'] = null; 2304 2305 ADDRESS_FIELDS.forEach(function(field) { 2306 const addressField = getAddressField(field); 2307 if (!addressField) return; 2308 addressField.$setViewValue(null); 2309 addressField.$setPristine(); 2310 addressField.$setUntouched(); 2311 addressField.$render(); 2312 // Always unlock when hiding 2313 addressField.$$element.prop('disabled', false); 2314 addressField.$$element.removeAttr('disabled'); 2315 }); 2316 2317 // Restore postcode into both the address field and the search f
2317ield 2318 if ($scope[INNER_FORM]?.postcodeSearch?.$viewValue) { 2319 const addressPostcodeField = getAddressField('postcode'); 2320 if (addressPostcodeField) { 2321 addressPostcodeField.$setViewValue(getPostcodeValue()); 2322 addressPostcodeField.$render(); 2323 } 2324 const postcodeSearch = $scope[INNER_FORM]?.postcodeSearch; 2325 if (postcodeSearch) { 2326 postcodeSearch.$setViewValue(getPostcodeValue()); 2327 postcodeSearch.$render(); 2328 } 2329 } 2330 2331 $scope.addressLookupList = null; 2332 $scope.postcodeAreaCheckResult = null; 2333 }); 2334 }; 2335 2336 $scope.$on('showAddressSection', function(event, bool) { 2337 if (!bool) { 2338 resetData(); 2339 return; 2340 } 2341 $scope.setShowAddressSection(bool); 2342 }); 2343 2344 $scope.setShowAddressSectionManual = function() { 2345 $scope.addressSource = ADDRESS_SOURCE.MANUAL; 2346 _persistAddressMeta(); 2347 $scope.setShowAddressSection(true); 2348 }; 2349 2350 // ─── Postcode search ────────────────────────────────────────────────────── 2351 2352 $scope.searchPostcode = function() { 2353 resetData(true); 2354 $scope.currentlySearching = true; 2355 2356 const lookupPostcode = getPostcodeValue(); 2357 if (!isPostcodeValid(lookupPostcode)) { 2358 DEBUG && console.log(DIRECTIVE_KEY + ': searchPostcode: postcode invalid, not searching'); 2359 $scope.addressLookupList = []; 2360 $scope.currentlySearching = false; 2361 checkValidity(); 2362 return; 2363 } 2364 2365 ADDRESS_LOOKUP_SERVICE.postcodeLookup(lookupPostcode) 2366 .then(response => _handleWimsResponse(response, lookupPostcode, $scope.allowWimsLookup)) 2367 .then(response => _handleLoqateResponse(response, lookupPostcode, $scope.allowLoqateLookup)) 2368 .catch(err => _handleLookupError(err)) 2369 .finally(() => _finaliseLookup()); 2370 }; 2371 2372 // ─── Lookup response handlers ───────────────────────────────────────────── 2373 2374 function cleanPostcode(value) { 2375 if(!value) return value; 2376 const formatted = (value + '') 2377 .replace(/\s/g, '') 2378 .toUpperCase(); 2379 return formatted.substring(0,formatted.length - 3) + ' ' + formatted.substring(formatted.length - 3); 2380 } 2381 2382 function _handleWimsResponse(response, lookupPostcode, allowLookup) { 2383 if (!allowLookup) { 2384 DEBUG && console.log(DIRECTIVE_KEY + ': skipping WIMS, falling through to Loqate'); 2385 return ADDRESS_LOOKUP_SERVICE.addressFind(lookupPostcode); 2386 } 2387 2388 DEBUG && console.log(DIRECTIVE_KEY + ': WIMS response:', response); 2389 2390 if ((response.inArea === false && response.success) && $scope.inArea) { 2391 DEBUG && console.log(DIRECTIVE_KEY + ': WIMS out of area'); 2392 _outOfAreaResult(response); 2393 return; 2394 } 2395 2396 if (response?.results) { 2397 $scope.postcodeAreaCheckResult = null; 2398 $scope.addressLookupList = response.results.map(_mapWimsResult); 2399 $scope.addressSource = ADDRESS_SOURCE.WIMS; 2400 DEBUG && console.log(DIRECTIVE_KEY + ': WIMS mapped results:', $scope.addressLookupList); 2401 } else { 2402 $scope.addressSource = ADDRESS_SOURCE.LOQATE; 2403 DEBUG && console.log(DIRECTIVE_KEY + ': no WIMS results, falling through to Loqate'); 2404 } 2405 2406 if (!$scope.addressLookupList?.length) { 2407 DEBUG && console.log(DIRECTIVE_KEY + ': starting Loqate lookup'); 2408 return ADDRESS_LOOKUP_SERVICE.addressFind(lookupPostcode); 2409 } 2410 } 2411 2412 function _handleLoqateResponse(response,lookupPostcode, allowLookup) { 2413 if (!allowLookup) { 2414 DEBUG && console.log(DIRECTIVE_KEY + ': skipping Loqate'); 2415 return; 2416 } 2417 if (!response) { 2418 DEBUG && console.log(DIRECTIVE_KEY + ': no Loqate response, ending'); 2419 return; 2420 } 2421 2422 DEBUG && console.log(DIRECTIVE_KEY + ': Loqate response:', response); 2423 2424 if (response?.result?.items) { 2425 $scope.postcodeAreaCheckResult = null; 2426 $scope.addressLookupList = response.result.items 2427 .map(_mapLoqateResult) 2428 .filter(item => { 2429 const label = item.label.replace(/\s/g, '').toUpperCase(); 2430 return label.includes(lookupPostcode.replace(/\s/g, '').toUpperCase()); 2431 }); 2432 if($scope.addressLookupList.length > 0) { 2433 $scope.addressSource = ADDRESS_SOURCE.LOQATE; 2434 DEBUG && console.log(DIRECTIVE_KEY + ': Loqate mapped results:', $scope.addressLookupList); 2435 } else { 2436 $scope.addressSource = ADDRESS_SOURCE.MANUAL; 2437 DEBUG && console.log(DIRECTIVE_KEY + ': Loqate mapped no matching results, falling through to manaul'); 2438 _noResults(); 2439 } 2440 } else { 2441 $scope.addressSource = ADDRESS_SOURCE.MANUAL; 2442 DEBUG && console.log(DIRECTIVE_KEY + ': Loqate no results, falling through to manual'); 2443 _noResults(); 2444 } 2445 } 2446 2447 function _handleLookupError(err) { 2448 $scope.addressSource = ADDRESS_SOURCE.MANUAL; 2449 console.error(DIRECTIVE_KEY + ':' + getPostcodeValue() + ':address lookup error:', err); 2450 _noResults(); 2451 } 2452 2453 function _finaliseLookup() { 2454 DEBUG && console.log(DIRECTIVE_KEY + ':' + getPostcodeValue() + ':lookup done - list:', $scope.addressLookupList, '| source:', $scope.addressSource); 2455 2456 $scope.addressLookupList = $scope.addressLookupList.slice().sort(function(a, b) { 2457 var strA = a.addressLine2 || a.text || a.addressLine1 || a.label || ''; 2458 var strB = b.addressLine2 || b.text || b.addressLine1 || b.label || ''; 2459 2460 // Pull out the last number in the string to use as house number 2461 function houseNum(str) { 2462 if (!str) return Infinity; 2463 if (/^\d+$/.test(str)) return parseInt(str, 10); 2464 var nums = str.match(/\b(\d+)(?:-\d+)?\b/g); 2465 return nums ? parseInt(nums[nums.length - 1], 10) : Infinity; 2466 } 2467 2468 // Pull out 'street name' 2469 // FLAT 1 COOL HOUSE STREET -> COOL HOUSE STREET 2470 // HOUSE COOL HOUSE STREET - > HOUSE COOL HOUSE STREET 2471 function street(str) { 2472 if (!str) return ''; 2473 var m = str.match(/^(.*?\b\d+[\w-]*\b)\s+(.+
2473)$/); 2474 return m ? m[2].trim().toUpperCase() : str.trim().toUpperCase(); 2475 } 2476 2477 return street(strA).localeCompare(street(strB)) || 2478 houseNum(a.addressLine1 || strA) - houseNum(b.addressLine1 || strB); 2479 }); 2480 2481 $scope.currentlySearching = false; 2482 checkValidity(); 2483 } 2484 2485 function _outOfAreaResult(data) { 2486 DEBUG && console.log(DIRECTIVE_KEY + ':' + getPostcodeValue() + ':out of area result:', data); 2487 _noResults(); 2488 } 2489 2490 function _noResults() { 2491 $scope.currentlySearching = false; 2492 $scope.addressLookupList = []; 2493 _persistAddressMeta(); 2494 $scope.setShowAddressSectionManual(); 2495 } 2496 2497 // ─── Result mapping ─────────────────────────────────────────────────────── 2498 2499 function _mapWimsResult(item) { 2500 const lines = Object.keys(item) 2501 .filter(k => k.indexOf('addressline') === 0) 2502 .sort() 2503 .map(k => item[k]); 2504 const county = ('addressline9' in item) ? lines.pop() : ''; 2505 const town = lines.pop(); 2506 const addressLine1 = lines.shift(); 2507 const addressLine2 = lines.length ? lines.join(', ') : ''; 2508 return { 2509 id: item.TempID, 2510 county, 2511 town, 2512 addressLine1, 2513 addressLine2, 2514 label: [addressLine1, addressLine2, town, county].filter(Boolean).join(', '), 2515 }; 2516 } 2517 2518 function _mapLoqateResult(item) { 2519 return { ...item, label: [item.text, item.description].filter(Boolean).join(', ') }; 2520 } 2521 2522 // ─── Set address fields from selection ──────────────────────────────────── 2523 2524 /** 2525 * Writes values into address fields and syncs the postcode search field. 2526 * If the postcode would make the search field invalid, the previous value is restored. 2527 */ 2528 const _applyFieldValues = function(fields) { 2529 for (const [field, value] of Object.entries(fields)) { 2530 const addressField = getAddressField(field); 2531 const maxLength = parseInt(addressField.$$element.attr('maxlength'), 10) || 255; 2532 if (addressField) { 2533 addressField.$setViewValue(value.substring(0,maxLength)); 2534 addressField.$setTouched(); 2535 addressField.$render(); 2536 } 2537 } 2538 2539 const postcodeSearch = $scope[INNER_FORM].postcodeSearch; 2540 const previousPostcode = postcodeSearch.$viewValue; 2541 postcodeSearch.$setViewValue(fields.postcode); 2542 if (postcodeSearch.$invalid) { 2543 DEBUG && console.log(DIRECTIVE_KEY + ': postcode search invalid after update, reverting'); 2544 postcodeSearch.$setViewValue(previousPostcode); 2545 } 2546 postcodeSearch.$render(); 2547 }; 2548 2549 /** Persists address metadata (source, id) to the parent form data. */ 2550 const _persistAddressMeta = function(extraData) { 2551 const formData = $scope?.$parent?.vm?.formData; 2552 const fieldName = $scope.fieldName; 2553 if (formData && fieldName) { 2554 if (!formData[fieldName]) { 2555 formData[fieldName] = {}; 2556 } 2557 formData[fieldName].addressSource = $scope.addressSource; 2558 if (extraData) { 2559 Object.assign(formData[fieldName], extraData); 2560 } 2561 } else { 2562 console.error(DIRECTIVE_KEY + ': failed - missing formData or fieldName'); 2563 } 2564 }; 2565 2566 $scope.setAddress = function() { 2567 DEBUG && console.log(DIRECTIVE_KEY + ': setAddress, source =', $scope.addressSource); 2568 2569 if ($scope.addressSource === ADDRESS_SOURCE.WIMS) { 2570 _setAddressFromWims(); 2571 } else if ($scope.addressSource === ADDRESS_SOURCE.LOQATE) { 2572 _setAddressFromLoqate(); 2573 } 2574 2575 // Always persist the source, even for LOQATE (meta only - fields set async above) 2576 _persistAddressMeta(); 2577 checkValidity(); 2578 }; 2579 2580 function _setAddressFromWims() { 2581 const selected = $scope.addressLookupList.find( 2582 item => item.id === $scope[INNER_FORM].selectedAddress.$modelValue?.id 2583 ); 2584 if (!selected) return; 2585 DEBUG && console.log(DIRECTIVE_KEY + ': WIMS selected:', selected); 2586 2587 $scope.currentlyGettingAddress = true; 2588 $scope.postcodeAreaCheckResult = null; 2589 2590 if (!$scope.allowIdnoLookup) { 2591 // No IDNO check needed (e.g. correspondence address) - skip straight to populating 2592 $scope.currentlyGettingAddress = false; 2593 _populateWimsFields(selected); 2594 return; 2595 } 2596 2597 ADDRESS_LOOKUP_SERVICE.checkMPANForIDNO(selected.id, getPostcodeValue()) 2598 .then(response => { 2599 $scope.currentlyGettingAddress = false; 2600 console.log(response); 2601 if (!response?.wpd && response.success) { 2602 // Address is outside WPD area - show DNO info and block progression 2603 $scope.addressLookupList = null; 2604 $scope.postcodeAreaCheckResult = { 2605 manual: true, 2606 message: "Your postcode is maintained by an Independent Network Operator (iDNO)", 2607 dnos: response?.success ? [{ 2608 name: (response.companyName || response.dno || '').trim(), 2609 dno: (response.dno || '').trim(), 2610 telephone: (response.companyPhoneNumber || '').trim(), 2611 isWPD: '0', 2612 url: null, 2613 prefixNo: response.prefixNo 2614 }] : [] 2615 }; 2616 const postcodeEl = $elem.find('[data-postcode-area-check]'); 2617 if (postcodeEl.length) { 2618 const postcodeNgModelCtrl = postcodeEl.controller('ngModel'); 2619 if (postcodeNgModelCtrl) { 2620 postcodeNgModelCtrl.customErrorMessages['postcodeAreaCheck'] = $scope.postcodeAreaCheckResult.message; 2621 postcodeNgModelCtrl.$setValidity('postcodeAreaCheck', false); 2622 postcodeNgModelCtrl.$setTouched(); 2623 } 2624 } 2625 return; // stop here, don't populate fields 2626 } 2627 // In area - proceed to populate 2628 _populateWimsFields(selected); 2629 }) 2630 // If the lookup for iDNO fails, just populate the fields as if it succeeded (we are permissive here) 2631 .catch(function(err) {console.error("iDNO check error", err); _populateWimsFields(selected)}); 2632 } 2633 2634 function _populateWimsFields(selected) { 2635 const fields = { 2636 addressLine1: selected.addressLine1, 2637 addressLine2: selected.addressLine2, 2638 town: selected.town, 2639 county: selected.county, 2640 postcode: selected.postcode, 2641 }; 2642 if (!fields.county && getAddressField('county')?.$$attr.required === true) fields.county = '-'; 2643 if (!fields.postcode && getAddressField('postcode')?.$$attr.required === true) { 2644 fields.postcode = $scope[INNER_FORM].postcodeSearch.$viewValue.toUpperCase(); 2645 } 2646 // Clear any previous out-of-area error state on the postcode field 2647 const postcodeEl = $elem.find('[data-postcode-area-check]'); 2648 if (postcodeEl.length) { 2649 const postcodeNgModelCtrl = postcodeEl.controller('ngModel'); 2650 if (postcodeNgModelCtrl) { 2651 postcodeNgModelCtrl.customErrorMessages['postcodeAreaCheck'] = null; 2652 postcodeNgModelCtrl.$setValidity('postcodeAreaCheck', true); 2653 } 2654 } 2655 $scope.setShowAddressSection(true); 2656 $scope.postcodeAreaCheckResult = null; 2657 _applyFieldValues(fields); 2658 _persistAddressMeta(selected.id ? { id: selected.id } : { id: null });
2659 $timeout(applyFieldLocking); 2660 } 2661 2662 $scope.$watch( 2663 function() { return ctrl.$modelValue; }, 2664 function(newVal, oldVal) { 2665 if (newVal !== oldVal && $scope.postcodeAreaCheckResult?.manual) { 2666 $scope.postcodeAreaCheckResult = null; 2667 } 2668 } 2669 ); 2670 2671 function _setAddressFromLoqate() { 2672 const barcode = $scope[INNER_FORM].selectedAddress.$modelValue?.id; 2673 if (barcode === null) return; 2674 2675 $scope.currentlyGettingAddress = true; 2676 DEBUG && console.log(DIRECTIVE_KEY + ': doing Loqate retrieve for', barcode); 2677 2678 ADDRESS_LOOKUP_SERVICE.addressRetrieve(barcode).then( 2679 function(response) { 2680 $scope.currentlyGettingAddress = false; 2681 DEBUG && console.log(DIRECTIVE_KEY + ': Loqate retrieve response:', response); 2682 2683 if (!response?.result?.address) return; 2684 const address = response.result.address; 2685 2686 // Build line1 (and optionally line2) when the API doesn't provide them 2687 if (!address.line1) { 2688 _buildLoqateAddressLines(address); 2689 } 2690 2691 // Fill placeholder for required county when the API returns empty string 2692 if (address?.county === '' && getAddressField('county').$$attr.required === true) { 2693 address.county = '-'; 2694 } 2695 2696 $scope.setShowAddressSection(true); 2697 $scope.postcodeAreaCheckResult = null; 2698 2699 _applyFieldValues({ 2700 addressLine1: address.line1, 2701 addressLine2: address.line2, 2702 town: address.city, 2703 county: address.county, 2704 postcode: address.postcode, 2705 }); 2706 2707 _persistAddressMeta(); 2708 2709 // Loqate addresses are never locked - no applyFieldLocking call here 2710 }, 2711 function() { 2712 $scope.currentlyGettingAddress = false; 2713 $scope.setShowAddressSection(true); 2714 } 2715 ); 2716 } 2717 2718 /** 2719 * Mutates `address` to populate line1/line2 from sub-fields when the API omits them. 2720 * Adapted from Lloyd's PCR lookup. 2721 */ 2722 function _buildLoqateAddressLines(address) { 2723 const line1MaxLength = $scope.maxLengthLine1 || 255; 2724 const line2MaxLength = $scope.maxLengthLine2 || 255; 2725 const separator = ', '; 2726 2727 const parts = [address.company, address.subBuilding, address.buildingName, address.buildingNumber, address.street] 2728 .filter(f => f != null && f !== ''); 2729 2730 if (parts.length === 2) { 2731 [address.line1, address.line2] = parts; 2732 return; 2733 } 2734 2735 let line1 = parts.join(separator); 2736 2737 if (line1.length > line1MaxLength) { 2738 const splitAt = line1.lastIndexOf(separator, line1MaxLength); 2739 let line2 = line1.substring(splitAt + separator.length); 2740 line1 = line1.substring(0, splitAt); 2741 if (line2.length > line2MaxLength) line2 = line2.substring(0, line2MaxLength); 2742 address.line2 = line2; 2743 } 2744 2745 address.line1 = line1.length > 0 ? line1 : 'No first line of address found.'; 2746 } 2747 2748 // ─── Pre-populate from existing form data ───────────────────────────────── 2749 2750 (function repopulateFromExistingData() { 2751 if (!$scope?.$parent?.vm?.formData) return; 2752 2753 DEBUG && console.log(DIRECTIVE_KEY + ': repopulating address fields from existing form data'); 2754 2755 let existingData = $scope.$parent.vm.formData; 2756 if ($scope.fieldName) { 2757 existingData = $scope.fieldName 2758 .split(/\.|\[|\]/) 2759 .filter(Boolean) 2760 .reduce((acc, key) => acc && acc[key], existingData); 2761 } 2762 2763 let repopulated = false; 2764 ADDRESS_FIELDS.forEach(function(field) { 2765 const addressField = getAddressField(field); 2766 if (!addressField) return; 2767 const existingKey = addressField.$name.split('.').pop(); 2768 if (existingData?.hasOwnProperty(existingKey)) { 2769 addressField.$setViewValue(existingData[existingKey]); 2770 addressField.$render(); 2771 repopulated = true; 2772 } 2773 }); 2774 2775 if (repopulated) $scope.setShowAddressSection(true); 2776 })(); 2777 2778 // ─── Initialisation ─────────────────────────────────────────────────────── 2779 2780 $scope[INNER_FORM].customErrorMessages = { required: 'Required' }; 2781 2782 $timeout(function() { 2783 customInputMixin.initStatusSyncers($scope, INNER_FORM, ctrl, DIRECTIVE_KEY); 2784 // Repopulate address source 2785 if ($scope?.$parent?.vm?.formData) { 2786 const data = $scope.fieldName 2787 ? $scope.$parent.vm.formData[$scope.fieldName] 2788 : $scope.$parent.vm.formData; 2789 if (data?.addressSource) { 2790 $scope.addressSource = data.addressSource; 2791 } 2792 } 2793 $scope.$watch( 2794 () => $scope[INNER_FORM]?.postcodeSearch?.postcodeAreaCheckResult, 2795 (newVal) => { $scope.postcodeAreaCheckResult = newVal; }, 2796 true 2797 ); 2798 checkValidity(); 2799 applyFieldLocking(); 2800 }); 2801 } 2802 }; 2803}]); 2804 2805 2806 2807/** 2808 * Template: /app/views/ng_templates/wpd/utils/upload-field.html 2809 * Style: ??? 2810 * 2811 * Bringing the uploadField into 2025, so the controller doesn't need to handle the actual upload behaviour 2812 * 2813 * @author ianbe 2814 */ 2815wpd.directive('wpdutilsUploadField', 2816 [ 'wpdutilsCustomInputMixinService', '$asset_upload', '$q', function(customInputMixin, $asset_upload, $q) { 2817 return { 2818 require: 'ngModel', 2819 restrict: 'AE', 2820 scope: { 2821 isReadonly: '<', // {boolean} 2822 asType: '<', // {string} ('string'|undefined|null) 2823 fieldId: '<', // {string} 2824 fieldName: '<', // {string} 2825 isDisabled: '<', // {boolean} 2826 isRequired: '<', // {boolean} 2827 placeholder: '<', // {string} 2828 allowedTypes: '<', // {Object[]} 2829 singleFile: '<', // {boolean} 2830 minFiles: '<', 2831 maxFiles: '<', 2832 multiSelection: '<', // {boolean} 2833 privateAsset: '<', // {boolean} 2834 hideMaxFiles: '<', 2835 2836 onStart: '=', // {function} 2837 onProgress: '=', // {function} 2838 onSuccess: '=', // {function} 2839 onInvalid: '=', // {function} 2840 2841 buttonLabel: '<', // {string} 2842 deleteButtonLabel: '<', // {string|boolean} 2843 // TODO unimplemented 2844 deleteButtonClass: '<', 2845 browseButtonClass: '<', 2846 }, 2847 templateUrl: 'wpdutilsUploadField.html',
2848 link: function($scope, $elem, $attrs, ctrl) { 2849 const DIRECTIVE_KEY = 'wpdutilsUploadField'; 2850 const INNER_FORM_NAME = $scope.fieldName.replace('.', '_') + '_' + DIRECTIVE_KEY; 2851 2852 $scope.singleFile = ($scope.singleFile == true); 2853 $scope.maxFiles = $scope.singleFile ? 1 : ($scope.maxFiles ?? 5); 2854 $scope.multiSelection = $scope.singleFile ? false : ($scope.multiSelection == true); 2855 $scope.isRequired = ($scope.isRequired == true); 2856 2857 $scope.browseButtonId = $scope.fieldId + "__browse-button"; 2858 $scope.deleteSectionId = $scope.fieldId + "__delete"; 2859 2860 if ($scope.deleteButtonLabel === true) { 2861 $scope.deleteButtonLabel = 'Remove'; 2862 } 2863 2864 const filesOverMaximum = []; 2865 2866 var unsetFormData = function() { 2867 $scope.formData = []; 2868 filesOverMaximum.length = 0; 2869 }; 2870 unsetFormData(); 2871 2872 // Repopulate the internal model with the existing data if it exists 2873 if ($scope?.$parent?.vm?.formData) { 2874 // If the fieldName is a dot notation path or array notation path, split it and reduce to get the value 2875 // - filter(Boolean) is just a truthiness test equivalent to !! to get rid of empty strings from the split 2876 // - not sure how this works if the target value is falsish, so be careful if you want to apply this to another field type 2877 const existingData = $scope.fieldName.split(/\.|\[|\]/).filter(Boolean).reduce((acc, key) => acc && acc[key], $scope.$parent.vm.formData); 2878 if (existingData !== undefined) { 2879 if ($scope.singleFile) { 2880 $scope.formData.push(existingData); 2881 } else { 2882 $scope.formData.push(...existingData); 2883 } 2884 } 2885 } 2886 2887 $scope.uploading = false; 2888 2889 var uploadOnSuccess = function(files) { 2890 if ($scope.onSuccess) { 2891 $scope.onSuccess.call(this, files, _uploadOnSuccess); 2892 } 2893 else { 2894 _uploadOnSuccess(files); 2895 } 2896 }; 2897 2898 var _uploadOnSuccess = function(files) { 2899 filesOverMaximum.length = 0; 2900 2901 if (!files) return; 2902 2903 for (var i in files) { 2904 if ($scope.formData.length < $scope.maxFiles) { 2905 filesOverMaximum.push(files[i]); 2906 } 2907 else { 2908 $scope.filesOverMaximum.push(files[i]); 2909 } 2910 } 2911 2912 if (filesOverMaximum.length) { 2913 // TODO trigger the over-max message 2914 } 2915 2916 $scope.updateNgModel(); 2917 ctrl.$validate(); 2918 2919 uploadOnFinally(); 2920 } 2921 2922 var _uploadOnProgress = function(progress) { 2923 }; 2924 2925 $scope.previousUploadPercentage = 0; 2926 $scope.uploadPercentage = 0; 2927 var uploadOnProgress = function(progress) { 2928 if (progress?.total) { 2929 $scope.previousUploadPercentage = $scope.uploadPercentage; 2930 $scope.uploadPercentage = progress.total.percent; 2931 } 2932 2933 if ($scope.onProgress) { 2934 $scope.onProgress.call(this, progress, _uploadOnProgress); 2935 } 2936 else { 2937 _uploadOnProgress(progress); 2938 } 2939 }; 2940 2941 var _uploadOnInvalid = function(data, error) { 2942 let message = "There was a problem uploading your file"; 2943 const messageExtra = error.response ?? error.message; 2944 message += (messageExtra) ? ":\n\n" + messageExtra : "."; 2945 window.alert(message); 2946 }; 2947
2948 var uploadOnInvalid = function(data, error) { 2949 if ($scope.onInvalid) { 2950 $scope.onInvalid.call(this, data, error, _uploadOnInvalid); 2951 } 2952 else { 2953 _uploadOnInvalid(data, error); 2954 } 2955 2956 uploadOnFinally(); 2957 } 2958 2959 var _uploadOnStart = function(files) { 2960 2961 }; 2962 2963 var uploadOnStart = function(files) { 2964 $scope.uploading = true; 2965 $scope.previousUploadPercentage = 0; 2966 $scope.uploadPercentage = 0; 2967 2968 if ($scope.onStart) { 2969 $scope.onStart.call(this, files, _uploadOnStart); 2970 } 2971 else { 2972 _uploadOnStart(files); 2973 } 2974 } 2975 2976 var uploadOnFinally = function() { 2977 $scope.uploading = false; 2978 } 2979 2980 $scope.removeFile = function(index) { 2981 $scope.formData.splice(index, 1); 2982 $scope.updateNgModel(); 2983 ctrl.$validate(); 2984 }; 2985 2986 var numberOfValidFiles = function() { 2987 const value = $scope.formData; 2988 if (typeof value != 'object') { 2989 return false; 2990 } 2991 return value.filter(o => o.result == 'success').length; 2992 } 2993 2994 ctrl.$validators.required = function(modelValue, viewValue) { 2995 if (!$scope.isRequired) return true; 2996 2997 if ((ctrl.$isEmpty(viewValue)) || (typeof viewValue != 'object')) { 2998 return false; 2999 } 3000 3001 if ($scope.singleFile) { 3002 if (viewValue.result != 'success') { 3003 return false; 3004 } 3005 } 3006 else if (!viewValue.some(o => o.result == 'success')) { 3007 return false; 3008 } 3009 3010 return true; 3011 }; 3012 3013 ctrl.$validators.min = function(modelValue, viewValue) { 3014 if (($scope.minFiles == null) || ($scope.minFiles < 1)) return true; 3015 return (numberOfValidFiles() >= $scope.minFiles); 3016 } 3017 3018 ctrl.$validators.max = function(modelValue, viewValue) { 3019 if (($scope.maxFiles == null) || ($scope.maxFiles < 1)) return true; 3020 return (numberOfValidFiles() <= $scope.maxFiles); 3021 } 3022 3023 var updatedViaNgChange = false; 3024 $scope.updateNgModel = function() { 3025 updatedViaNgChange = true; 3026 3027 if ($scope[INNER_FORM_NAME].$valid) { 3028 ctrl.$setViewValue($scope.singleFile ? ($scope.formData[0] ?? null) : $scope.formData); 3029 } 3030 else { 3031 ctrl.$setViewValue(undefined); 3032 } 3033 }; 3034 3035 var cancelWatch = $scope.$watch(() => ctrl.$modelValue, function(newVal, oldVal) { 3036 updatedViaNgChange = false; 3037 }); 3038 3039 var statusSyncerCancelFns = customInputMixin.initStatusSyncers($scope, INNER_FORM_NAME, ctrl, DIRECTIVE_KEY); 3040 3041 var options = { 3042 browse_button: $scope.browseButtonId, 3043 drop_element: $scope.browseButtonId, 3044 types: $scope.allowedTypes ?? ['image', 'document'], 3045 action: 'CoreAssetFrontUploader.upload_JSON', 3046 multi_selection: $scope.multiSelection ?? false, 3047 paramsFn: function () { 3048 return { 3049 privateAsset: $scope.privateAsset ?? false 3050 }; 3051 } 3052 }; 3053 $scope.asset_upload = $asset_upload(options, $scope); 3054 $scope.asset_upload.onOK(uploadOnSuccess); 3055 $scope.asset_upload.onProgress(uploadOnProgress);
3056 $scope.asset_upload.onInvalid(uploadOnInvalid); 3057 $scope.asset_upload.onStart(uploadOnStart); 3058 3059 } 3060 3061 }; 3062 }]); 3063 3064 3065 3066// =========================================================================================== 3067// ------------------------------ Form validation directives --------------------------------- 3068// =========================================================================================== 3069 3070wpd.service('wpdValidationService', ['$requester', '$q', function($requester, $q) { 3071 3072 this.isPostcode = function(value) { 3073 const PATTERN = /^\s*([a-z]{1,2}\d(\d|[a-z])?)\s*(\d[a-z]{2})\s*$/gi; 3074 return PATTERN.test(value); 3075 }; 3076}]); 3077 3078/** 3079 * @return {boolean} - true if the supplied {string} viewValue is a postcode within WPD's area of operation. This validator is asynchronous. 3080 * 3081 * @author noahm 3082 */ 3083wpd.directive('postcodeAreaCheck', ['wpdutilsRequester', '$q', 'wpdutilsCustomInputMixinService', function(wpdutilsRequester, $q, customInputMixin) { 3084 return { 3085 require: 'ngModel', 3086 link: function($scope, $elem, $attrs, ctrl) { 3087 const json = wpdutilsRequester({ 3088 areaCheck: 'WPDUtilsFront.wimsPostcodeLookup_JSON' 3089 }); 3090 const DEBUG = false; 3091 const DIRECTIVE_KEY = 'postcodeAreaCheck'; 3092 3093 const loadingSpinnerProperty = setupLoadingSpinner($scope, $elem, DIRECTIVE_KEY, customInputMixin); 3094 3095 if (!ctrl.customErrorMessages) ctrl.customErrorMessages = {}; 3096 if (!ctrl.postcodeAreaCheckResult) ctrl.postcodeAreaCheckResult = {}; 3097 3098 $scope[loadingSpinnerProperty] = false; 3099 3100 // Set up the custom error message for this ngModel on failure of this validator 3101 ctrl.customErrorMessages[DIRECTIVE_KEY] = null; 3102 ctrl.postcodeAreaCheckResult = null; 3103 3104 ctrl.$asyncValidators[DIRECTIVE_KEY] = function(modelValue, viewValue) { 3105 return $q(function(resolve, reject) { 3106 3107 if ($scope.postcodeAreaCheckResult?.manual == true) { 3108 console.log("postcode area check not triggering as we're going manual mode"); 3109 resolve(); 3110 return; 3111 } 3112 3113 if (ctrl.$isEmpty(modelValue) || $scope.$eval($attrs.ngDisabled)) { 3114 // consider empty models to be valid 3115 ctrl.customErrorMessages[DIRECTIVE_KEY] = null; 3116 ctrl.postcodeAreaCheckResult = null; 3117 resolve(); 3118 } 3119 3120 if (viewValue) { 3121 const request = json.areaCheck({postcode: viewValue}, function(response) { 3122 DEBUG && console.log("postcodeAreaCheck response:", response); 3123 // Success here means 'is this a postcode that we successfully found' during lookup 3124 const isSuccess = response?.success === true; 3125 const isValid = response?.postcodeValid === true; 3126 const inArea = response?.inArea === true; 3127 if (isSuccess && isValid && inArea) { 3128 // valid + in area 3129 ctrl.customErrorMessages[DIRECTIVE_KEY] = null; 3130 ctrl.postcodeAreaCheckResult = null; 3131 $scope.postcodeAreaCheckResult = null; 3132 resolve(); 3133 } 3134 else if (isSuccess && isValid && !inArea) { 3135 // valid postcode, but outside area 3136 ctrl.customErrorMessages[DIRECTIVE_KEY] = "Postcode " + viewValue + " is out of area"; 3137 ctrl.postcodeAreaCheckResult = { 3138 message: ctrl.customErrorMessages[DIRECTIVE_KEY], 3139 dnos: response?.dnos 3140 }; 3141 $scope.postcodeAreaCheckResult = ctrl.postcodeAreaCheckResult; 3142 ctrl.$setTouched(); 3143 reject(); 3144 } 3145 else { 3146 // invalid / unrecognised postcode 3147 ctrl.customErrorMessages[DIRECTIVE_KEY] = "Please enter a valid postcode"; 3148 ctrl.postcodeAreaCheckResult = { 3149 message: ctrl.customErrorMessages[DIRECTIVE_KEY], 3150 dnos: response?.dnos 3151 }; 3152 $scope.postcodeAreaCheckResult = ctrl.postcodeAreaCheckResult; 3153 ctrl.$setTouched(); 3154 reject(); 3155 } 3156 return { cache: true }; 3157 }); 3158 request.setLoader({ 3159 load: () => { $scope[loadingSpinnerProperty] = true; }, 3160 clear: () => { $scope[loadingSpinnerProperty] = false; } 3161 }) 3162 } 3163 else { 3164 ctrl.customErrorMessages[DIRECTIVE_KEY] = "Please enter a valid postcode"; 3165 reject(); 3166 } 3167 }); 3168 }; 3169 } 3170 }; 3171}]); 3172 3173/** 3174* @return {boolean} - true if the supplied {number} viewValue is greater than the supplied 'greaterThan' attribute value, or if the underlying model value is unassigned 3175* 3176* @author noahm 3177*/ 3178wpd.directive('greaterThan', function() { 3179 return { 3180 require: 'ngModel', 3181 restrict: 'A', 3182 link: function($scope, $elem, $attrs, ctrl) { 3183 var DIRECTIVE_KEY = 'greaterThan'; 3184 3185 if (!ctrl.customErrorMessages) ctrl.customErrorMessages = {}; 3186 3187 // Set up the custom error message for this ngModel on failure of this validator 3188 ctrl.customErrorMessages[DIRECTIVE_KEY] = null; 3189 3190 ctrl.$validators[DIRECTIVE_KEY] = function(modelValue, viewValue) { 3191 if (ctrl.$isEmpty(modelValue)) { 3192 // consider empty models to be valid 3193 ctrl.customErrorMessages[DIRECTIVE_KEY] = null; 3194 return true; 3195 } 3196 3197 // grab value supplied to attribute to compare. 3198 var greaterThanWhat = Number.parseFloat($attrs[DIRECTIVE_KEY]); 3199 3200 if (viewValue > greaterThanWhat) { 3201 ctrl.customErrorMessages[DIRECTIVE_KEY] = null; 3202 return true; 3203 } 3204 3205 ctrl.customErrorMessages[DIRECTIVE_KEY] = "Must be greater than " + greaterThanWhat; 3206 return false; 3207 }; 3208 } 3209 }; 3210}); 3211 3212/** 3213 * @return {boolean} - true if the supplied {number} viewValue is less than the supplied 'lessThan' attribute value, or if the underlying model value is unassigned 3214 * 3215 * @author noahm 3216 */ 3217wpd.directive('lessThan', function() { 3218 return { 3219 require: 'ngModel', 3220 restrict: 'A', 3221 link: function($scope, $elem, $attrs, ctrl) { 3222 var DIRECTIVE_KEY = 'lessThan'; 3223 3224 if (!ctrl.customErrorMessages) ctrl.customErrorMessages = {}; 3225 3226 // Set up the custom error message for this ngModel on failure of this validator 3227 ctrl.customErrorMessages[DIRECTIVE_KEY] = null; 3228 3229 ctrl.$validators[DIRECTIVE_KEY] = function(modelValue, viewValue) { 3230 if (ctrl.$isEmpty(modelValue)) { 3231 // consider empty models to be valid 3232 ctrl.customErrorMessages[DIRECTIVE_KEY] = null; 3233 return true; 3234 } 3235 3236 // grab value supplied to attribute to compare. 3237 var lessThanWhat = Number.parseFloat($attrs[DIRECTIVE_KEY]); 3238 3239 if (viewValue < lessThanWhat) { 3240 ctrl.customErrorMessages[DIRECTIVE_KEY] = null; 3241 return true; 3242 } 3243 3244 ctrl.customErrorMessages[DIRECTIVE_KEY] = "Must be less than " + lessThanWhat; 3245 return false; 3246 }; 3247 } 3248 }; 3249}); 3250 3251/** 3252 * @return {boolean} - true if the supplied {number} viewValue is an integer 3253 * 3254 * @author noahm 3255 */ 3256wpd.directive('isInteger', function() { 3257 return { 3258 require: 'ngModel', 3259 restrict: 'A', 3260 link: function($scope, $elem, $attrs, ctrl) { 3261 var DIRECTIVE_KEY = 'isInteger';
3262 //var NUMBER_REGEX = /^-?[0-9]+\.?[0-9]*$/g; // general number 3263 var NUMBER_REGEX = /^-?[0-9]+$/g; 3264 3265 if (!ctrl.customErrorMessages) ctrl.customErrorMessages = {}; 3266 3267 // Set up the custom error message for this ngModel on failure of this validator 3268 ctrl.customErrorMessages[DIRECTIVE_KEY] = null; 3269 3270 ctrl.$validators[DIRECTIVE_KEY] = function(modelValue, viewValue) { 3271 // consider empty models to be valid 3272 if (ctrl.$isEmpty(modelValue) || viewValue.match(NUMBER_REGEX)) { 3273 ctrl.customErrorMessages[DIRECTIVE_KEY] = null; 3274 return true; 3275 } 3276 3277 ctrl.customErrorMessages[DIRECTIVE_KEY] = "Value must be an integer"; 3278 return false; 3279 }; 3280 } 3281 }; 3282}); 3283 3284 3285wpd.directive('isValidDate', function() { 3286 return { 3287 require: 'ngModel', 3288 restrict: 'A', 3289 link: function($scope, $elem, $attrs, ctrl) { 3290 var DIRECTIVE_KEY = 'isValidDate'; 3291 var format = "DD/MM/YYYY"; 3292 3293 ctrl.$validators[DIRECTIVE_KEY] = function(modelValue, viewValue) { 3294 3295 // EMPTY MODEL - consider empty models to be valid - drop out of the check 3296 if (ctrl.$isEmpty(modelValue)) { 3297 return true; 3298 } 3299 3300 // ELSE - Continue with rest of valid date checks 3301 //Check if the date is in a valid format 3302 //Do we meet the correct pattern? 3303 var datePatternRegex = RegExp('\\d{2}\\/\\d{2}\\/\\d{4}'); //Only tests for integers and slashes (xx/xx/xxxx) - not date format 3304 valid = datePatternRegex.test(viewValue) && viewValue.length <= 10; 3305 if (valid) 3306 { 3307 //Now we know it's the correct format, is it a valid date? 3308 var date = moment(viewValue, format); 3309 returnVal = date.isValid() ? date.valueOf() : viewValue; //NOTE: [WSME-577] - changed ternary false bit from null to viewValue to stop it totally clearing. 3310 valid = date.isValid(); 3311 } 3312 return valid; 3313 } 3314 } 3315 } 3316}) 3317 3318wpd.directive('isValidPastDate', function() { 3319 return { 3320 require: 'ngModel', 3321 restrict: 'A', 3322 link: function($scope, $elem, $attrs, ctrl) { 3323 var DIRECTIVE_KEY = 'isValidPastDate'; 3324 var format = "DD/MM/YYYY"; 3325 3326 ctrl.$validators[DIRECTIVE_KEY] = function(modelValue, viewValue) { 3327 3328 // EMPTY MODEL - consider empty models to be valid - drop out of the check 3329 if (ctrl.$isEmpty(modelValue)) { 3330 return true; 3331 } 3332 3333 // ELSE - Continue with rest of valid date checks 3334 //Check if the date is in a valid format 3335 //Do we meet the correct pattern? 3336 var datePatternRegex = RegExp('\\d{2}\\/\\d{2}\\/\\d{4}'); //Only tests for integers and slashes (xx/xx/xxxx) - not date format 3337 valid = datePatternRegex.test(viewValue) && viewValue.length <= 10; 3338 if (valid) 3339 { 3340 //Now we know it's the correct format, is it a valid date? 3341 var date = moment(viewValue, format); 3342 returnVal = date.isValid() ? date.valueOf() : viewValue; //NOTE: [WSME-577] - changed ternary false bit from null to viewValue to stop it totally clearing. 3343 valid = date.isValid(); 3344 } 3345 3346 if (valid) 3347 { 3348 var now = new Date(); 3349 var viewDate = moment(viewValue, format).toDate(); 3350 3351 //If date is in past we're okay 3352 valid = (viewDate <= now); 3353 } 3354 3355 return valid; 3356 } 3357 } 3358 } 3359}) 3360 3361/** 3362* @return {boolean} - true if the supplied {date} is a valid date in the correct format and in the future 3363* 3364* @author kerianc, noahm 3365*/ 3366wpd.directive('isValidFutureDate', function() { 3367 return { 3368 require: 'ngModel', 3369 restrict: 'A', 3370 link: function($scope, $elem, $attrs, ctrl) { 3371 var DIRECTIVE_KEY = 'isValidFutureDate'; 3372 var format = "DD/MM/YYYY"; 3373 3374 if (!ctrl.customErrorMessages) ctrl.customErrorMessages = {}; 3375 3376 // Set up the custom error message for this ngModel on failure of this validator 3377 ctrl.customErrorMessages[DIRECTIVE_KEY] = null; 3378 3379 ctrl.$validators[DIRECTIVE_KEY] = function(modelValue, viewValue) { 3380 3381 // EMPTY MODEL - consider empty models to be valid - drop out of the check 3382 if (ctrl.$isEmpty(modelValue)) { 3383 ctrl.customErrorMessages[DIRECTIVE_KEY] = null; 3384 return true; 3385 } 3386 3387 // ELSE - Continue with rest of valid date checks 3388 3389 var errMessage = null;
3390 var valid = false; 3391 3392 //Check if the date is in a valid format 3393 //Do we meet the correct pattern? 3394 var datePatternRegex = RegExp('\\d{2}\\/\\d{2}\\/\\d{4}'); //Only tests for integers and slashes (xx/xx/xxxx) - not date format 3395 valid = datePatternRegex.test(viewValue); 3396 if (valid) 3397 { 3398 //Now we know it's the correct format, is it a valid date? 3399 var date = moment(viewValue, format); 3400 returnVal = date.isValid() ? date.valueOf() : viewValue; //NOTE: [WSME-577] - changed ternary false bit from null to viewValue to stop it totally clearing. 3401 valid = date.isValid(); 3402 } 3403 if (!valid) 3404 { 3405 errMessage = "Date must be in format DD/MM/YYYY"; 3406 } 3407 3408 //Now we have a valid date - is it in the future? 3409 if (valid) 3410 { 3411 var now = new Date(); 3412 var viewDate = moment(viewValue, format).toDate(); 3413 3414 //If date is in future we're okay 3415 valid = (viewDate > now); 3416 3417 if (!valid) 3418 { 3419 errMessage = "Date must be in the future"; 3420 } 3421 } 3422 3423 ctrl.customErrorMessages[DIRECTIVE_KEY] = errMessage; 3424 return valid; 3425 }; 3426 3427 } 3428 }; 3429}); 3430 3431/** 3432 * @return {boolean} - true if the field's date is before the supplied date-before value 3433 * 3434 * @author ianbe (adapted from noahm) 3435 */ 3436wpd.directive('dateBefore', function() { 3437 return { 3438 require: 'ngModel', 3439 restrict: 'A', 3440 link: function($scope, $elem, $attrs, ctrl) { 3441 if (!ctrl.customErrorMessages) ctrl.customErrorMessages = {}; 3442 3443 // Set up the custom error message for this ngModel on failure of this validator 3444 ctrl.customErrorMessages.dateBefore = null; 3445 3446 var errMsgFormat = "Must be before $1"; // IE doesn't support template literals so this will have to do 3447 3448 ctrl.$validators.dateBefore = function(modelValue, viewValue) { 3449 3450 if (ctrl.$isEmpty(modelValue)) { 3451 // consider empty models to be valid 3452 ctrl.customErrorMessages.dateBefore = null; 3453 return true; 3454 } 3455 3456 // grab value supplied to attribute to compare. 3457 var comparisonDate = parseFloat($attrs.dateBefore); 3458 var date, result; 3459 3460 // unset any unit smaller than day, since that's only as granular as we're getting with our comparison 3461 date = new Date(modelValue); 3462 date.setHours(0); 3463 date.setMinutes(0); 3464 date.setSeconds(0); 3465 date.setMilliseconds(0); 3466 3467 compare = new Date(comparisonDate); 3468 compare.setHours(0); 3469 compare.setMinutes(0); 3470 compare.setSeconds(0); 3471 compare.setMilliseconds(0); 3472 3473 result = date.valueOf() < compare.valueOf(); 3474 3475 // Set error message if necessary 3476 ctrl.customErrorMessages.dateBefore = !result ? 3477 errMsgFormat.replace('$1', compare.toLocaleDateString('en-GB')) : 3478 null; 3479 return result; 3480 3481 }; 3482 } 3483 } 3484}); 3485 3486/** 3487 * @return {boolean} - true if the field's date is after the supplied date-before value 3488 * 3489 * @author ianbe (adapted from noahm) 3490 */ 3491wpd.directive('dateAfter', function() { 3492 return { 3493 require: 'ngModel', 3494 restrict: 'A', 3495 link: function($scope, $elem, $attrs, ctrl) { 3496 if (!ctrl.customErrorMessages) ctrl.customErrorMessages = {}; 3497 3498 // Set up the custom error message for this ngModel on failure of this validator 3499 ctrl.customErrorMessages.dateAfter = null; 3500 3501 var errMsgFormat = "Must be after $1"; // IE doesn't support template literals so this will have to do 3502 3503 ctrl.$validators.dateAfter = function(modelValue, viewValue) { 3504 3505 if (ctrl.$isEmpty(modelValue)) { 3506 // consider empty models to be valid 3507 ctrl.customErrorMessages.dateAfter = null; 3508 return true; 3509 } 3510 3511 // grab value supplied to attribute to compare. 3512 var comparisonDate = parseFloat($attrs.dateAfter); 3513 var date, result; 3514 3515 // unset any unit smaller than day, since that's only as granular as we're getting with our comparison 3516 date = new Date(modelValue); 3517 date.setHours(0); 3518 date.setMinutes(0); 3519 date.setSeconds(0); 3520 date.setMilliseconds(0); 3521 3522 compare = new Date(comparisonDate); 3523 compare.setHours(0); 3524 compare.setMinutes(0); 3525 compare.setSeconds(0); 3526 compare.setMilliseconds(0); 3527 3528 result = date.valueOf() > compare.valueOf(); 3529 3530 // Set error message if necessary 3531 ctrl.customErrorMessages.dateAfter = !result ? 3532 errMsgFormat.replace('$1', compare.toLocaleDateString('en-GB')) : 3533 null; 3534 return result; 3535 3536 }; 3537 } 3538 } 3539}); 3540 3541/** 3542 * @return {boolean} - true if the supplied {Date} viewValue is between now and now + X years (both inclusive, to the granularity of day), or if the underlying model is unassigned 3543 * 3544 * @author noahm 3545 */ 3546wpd.directive('dateWithinXYearsOfNow', function() { 3547 return { 3548 require: 'ngModel', 3549 restrict: 'A', 3550 link: function($scope, $elem, $attrs, ctrl) { 3551 if (!ctrl.customErrorMessages) ctrl.customErrorMessages = {}; 3552 3553 // Set up the custom error message for this ngModel on failure of this validator 3554 ctrl.customErrorMessages.dateWithinXYearsOfNow = null; 3555 3556 var errMsgFormat = "Must be between today's date ($1) and $2 years from now ($3)"; // IE doesn't support template literals so this will have to do 3557 3558 ctrl.$validators.dateWithinXYearsOfNow = function(modelValue, viewValue) { 3559 3560 if (ctrl.$isEmpty(modelValue)) { 3561 // consider empty models to be valid 3562 ctrl.customErrorMessages.dateWithinXYearsOfNow = null; 3563 return true; 3564 } 3565 3566 // grab value supplied to attribute to compare. 3567 var xYears = parseFloat($attrs.dateWithinXYearsOfNow);
3568 var date, now, xYearsFromNow, result; 3569 3570 // if we have access to moment.js, use that. 3571 if (typeof moment === 'function') { 3572 date = moment(modelValue); 3573 now = moment(); 3574 xYearsFromNow = now.clone().add(xYears, 'years'); 3575 3576 // https://momentjs.com/docs/#/query/is-between/ 3577 // Note the 'day' granularity, and the inclusivity string '[]' 3578 result = date.isBetween(now, xYearsFromNow, 'day', '[]'); 3579 3580 // Set error message if necessary 3581 ctrl.customErrorMessages.dateWithinXYearsOfNow = !result ? 3582 errMsgFormat.replace('$1', now.format('DD/MM/YYYY')).replace('$2', xYears).replace('$3', xYearsFromNow.format('DD/MM/YYYY')) : 3583 null; 3584 return result; 3585 } 3586 else { 3587 // unset any unit smaller than day, since that's only as granular as we're getting with our comparison 3588 date = new Date(modelValue); 3589 date.setHours(0); 3590 date.setMinutes(0); 3591 date.setSeconds(0); 3592 date.setMilliseconds(0); 3593 3594 now = new Date(Date.now()); 3595 now.setHours(0); 3596 now.setMinutes(0); 3597 now.setSeconds(0); 3598 now.setMilliseconds(0); 3599 3600 xYearsFromNow = new Date(new Date(now).setFullYear(now.getFullYear() + xYears)); 3601 3602 result = date.valueOf() >= now.valueOf() && date.valueOf() <= xYearsFromNow.valueOf(); 3603 3604 // Set error message if necessary 3605 ctrl.customErrorMessages.dateWithinXYearsOfNow = !result ? 3606 errMsgFormat.replace('$1', now.toLocaleDateString('en-GB')).replace('$2', xYears).replace('$3', xYearsFromNow.toLocaleDateString('en-GB')) : 3607 null; 3608 return result; 3609 } 3610 }; 3611 } 3612 }; 3613}); 3614 3615/** 3616 * @return {boolean} - Do as is required check only if the passed in value is true 3617 * 3618 * @author grahamb 3619 */ 3620wpd.directive('conditionallyRequired', function() { 3621 return { 3622 require: 'ngModel', 3623 restrict: 'A', 3624 link: function($scope, $elem, $attrs, ctrl) { 3625 var DIRECTIVE_KEY = 'conditionallyRequired'; 3626 3627 if (!ctrl.customErrorMessages) ctrl.customErrorMessages = {}; 3628 3629 // Set up the custom error message for this ngModel on failure of this validator 3630 ctrl.customErrorMessages[DIRECTIVE_KEY] = null; 3631 3632 ctrl.$validators[DIRECTIVE_KEY] = function(modelValue, viewValue) { 3633 3634 var isRequired = ($attrs[DIRECTIVE_KEY]); 3635 3636 if (isRequired) 3637 { 3638 //IF empty then fail 3639 if (ctrl.$isEmpty(modelValue)) { 3640 ctrl.customErrorMessages[DIRECTIVE_KEY] = "Required"; 3641 return false; 3642 } 3643 else 3644 { 3645 //else Okay 3646 ctrl.customErrorMessages[DIRECTIVE_KEY] = null; 3647 return true; 3648 } 3649 } 3650 else 3651 { 3652 ctrl.customErrorMessages[DIRECTIVE_KEY] = null; 3653 return true; 3654 } 3655 }; 3656 } 3657 }; 3658}); 3659 3660/** 3661* TODO: wpdUtils service has isValidPostcode... 3662* @return {boolean} - true if the supplied {String} viewValue matches the format for a UK postcode 3663* 3664* @author noahm 3665*/ 3666wpd.directive('isPostcode', ['wpdValidationService', '$timeout', function(wpdValidationService, $timeout) { 3667 return { 3668 require: 'ngModel', 3669 restrict: 'A', 3670 link: function($scope, $elem, $attrs, ctrl) { 3671 var DIRECTIVE_KEY = 'isPostcode'; 3672 3673 if (!ctrl.customErrorMessages) ctrl.customErrorMessages = {}; 3674 3675 // Set up the custom error message for this ngModel on failure of this validator 3676 ctrl.customErrorMessages[DIRECTIVE_KEY] = null; 3677 3678 function cleanPostcode(value) { 3679 if (!value) return value; 3680 const stripped = (value + '').replace(/\s/g, '').toUpperCase(); 3681 if (stripped.length <= 3) return stripped; 3682 return stripped.slice(0, -3) + ' ' + stripped.slice(-3); 3683 } 3684 3685 ctrl.$parsers.push(function(viewValue) { 3686 return cleanPostcode(viewValue); 3687 }); 3688 3689 $scope.$watch( 3690 () => ctrl.$modelValue, 3691 function(newVal, oldVal) { 3692 if (newVal && newVal !== oldVal) { 3693 const formatted = cleanPostcode(newVal); 3694 if (formatted !== ctrl.$viewValue) { 3695 ctrl.$viewValue = formatted; 3696 ctrl.$render(); 3697 } 3698 } 3699 } 3700 ); 3701 3702 ctrl.$validators[DIRECTIVE_KEY] = function(modelValue, viewValue) { 3703 if (ctrl.$isEmpty(modelValue) || $scope.$eval($attrs.ngDisabled)) { 3704 // consider empty models to be valid 3705 ctrl.customErrorMessages[DIRECTIVE_KEY] = null; 3706 ctrl.postcodeAreaCheckResult = null; 3707 return true; 3708 } 3709 3710 let result = wpdValidationService.isPostcode(viewValue); 3711 ctrl.customErrorMessages[DIRECTIVE_KEY] = result ? null : "Must be a valid UK postcode"; 3712 ctrl.postcodeAreaCheckResult = null; 3713 return result; 3714 }; 3715 } 3716 }; 3717}]); 3718 3719/** 3720* @return {boolean} - true if the supplied {String|number} viewValue is in a telephone format 3721* Treat the following sample variants as valid: 3722* 123 456 789 3723* 123456789 3724* +44 123456789 3725* (+44) 123456789 3726* (0)123 456 789 3727* 3728* @author noahm 3729*/ 3730wpd.directive('wpdutilsIsTelephone', ['wpdValidationService', function (wpdValidationService) { 3731 return { 3732 require: 'ngModel', 3733 restrict: 'A', 3734 link: function ($scope, $elem, $attrs, ctrl) { 3735 3736 const DEBUG = false; 3737 const DIRECTIVE_KEY = 'wpdutilsIsTelephone'; 3738 const TEL_REGEX = /^(\+|\(\+?\d+\))?\d{3,15}$/; 3739 if (!ctrl.customErrorMessages) { ctrl.customErrorMessages = {}; } 3740 ctrl.customErrorMessages[DIRECTIVE_KEY] = null; 3741 3742 ctrl.$parsers.push(function (viewValue) { 3743 DEBUG && console.log(DIRECTIVE_KEY + ":parsing '" + viewValue + "'"); 3744 if (!viewValue) return viewValue; 3745 3746 let cleaned = (viewValue + '').replace(/\s/g, ''); 3747 return cleaned; 3748 }); 3749 3750 ctrl.$validators[DIRECTIVE_KEY] = function (modelValue) { 3751 DEBUG && console.log(DIRECTIVE_KEY + ":validating '" + modelValue + "'"); 3752 if (ctrl.$isEmpty(modelValue)) { 3753 ctrl.customErrorMessages[DIRECTIVE_KEY] = null; 3754 return true; 3755 } 3756 let isValid = TEL_REGEX.test(modelValue); 3757 ctrl.customErrorMessages[DIRECTIVE_KEY] = isValid ? null : "Must be a valid telephone number"; 3758 return isValid; 3759 }; 3760 3761 ctrl.$formatters.push(function (modelValue) { 3762 return modelValue; 3763 }); 3764 } 3765 }; 3766}]); 3767 3768/** 3769* @return {boolean} - true if the supplied {String|number} viewValue is in a telephone format 3770* Treat the following sample variants as valid: 3771* 01234567890 3772* 07712345678 3773* 02081234567 3774* 01908765432 3775* 01619876543
3776* Treat the following sample variants as invalid: 3777* 1234567890 (missing leading 0) 3778* 012345678901 (too many characters) 3779* 0123a567890 (contains non-numeric characters) 3780* 0123-567-890 (contains dashes) 3781* +441234567890 (contains country code) 3782* 3783* Nged requested as it matches their internal validation rules. 3784* Ours might be better but it would let through phone numbers their system would reject 3785* This validation differs from the telephone annotation used on the back end 3786 * @author noahm, konstanting, grahamb 3787 * -KG tightened up NMs original directive 3788 * -Moved to utils by GB 3789*/ 3790wpd.directive('wpdutilsIsTelephoneTight', ['wpdValidationService', function(wpdValidationService) { 3791 return { 3792 require: 'ngModel', 3793 restrict: 'A', 3794 link: function($scope, $elem, $attrs, ctrl) { 3795 const DIRECTIVE_KEY = 'wpdutilsIsTelephoneTight'; 3796 const TEL_REGEX = /^0\d{10}$/g; 3797 3798 if (!ctrl.customErrorMessages) ctrl.customErrorMessages = {}; 3799 3800 // Set up the custom error message for this ngModel on failure of this validator 3801 ctrl.customErrorMessages[DIRECTIVE_KEY] = null; 3802 3803 ctrl.$validators[DIRECTIVE_KEY] = function(modelValue, viewValue) { 3804 if (ctrl.$isEmpty(modelValue)) { 3805 // consider empty models to be valid 3806 ctrl.customErrorMessages[DIRECTIVE_KEY] = null; 3807 return true; 3808 } 3809 3810 let result = false; 3811 3812 if (viewValue && (typeof viewValue === 'string' || typeof viewValue === 'number')) { 3813 let testValue = viewValue + ''; // convert to string 3814 testValue = testValue.replaceAll(/\s/g, ''); // remove any whitespace-like characters for easier testing 3815 result = testValue.match(TEL_REGEX); 3816 } 3817 ctrl.customErrorMessages[DIRECTIVE_KEY] = result ? null : "Must be a valid telephone number"; 3818 return result; 3819 }; 3820 } 3821 }; 3822}]); 3823 3824/** 3825 * @return {boolean} - true if the supplied {String|number} viewValue is in a telephone format 3826 * 3827 * This is a more specific yet accomodating version of wpdutilsIsTelephoneTight. 3828 * wpdutilsIsTelephoneTight doesn't make allowances for some landlines that can have 10 digits. 3829 * Built for more stringent validation rules for the PCR Form Contact Details step, rules based off feedback in NGEDSME-728 3830 * Any mobile number (assuming UK) must start with 07, and be 11 digits long. 3831 * Any other number must be either 10 or 11 digits long, and start with 0. 3832 * Non-UK phone numbers may not be valid in this format, and any international numbers with country codes will not be valid at all. 3833 * 3834 * Treat the following sample variants as valid: 3835 * 07855548895 3836 * 02147825748 3837 * 01915887744 3838 * 0169772584 (one of the valid 10-digit landline area codes '016977' - there are 12 in total, but we will only validate them length-wise, not checking explicity for the correct area codes) 3839 * Treat the following sample variants as invalid: 3840 * 1234567890 (missing leading 0) 3841 * 012345678901 (too many characters) 3842 * 0123a567890 (contains non-numeric characters) 3843 * 0123-567-890 (contains dashes) 3844 * 0712345678 (not enough digits for starting with 07) 3845 * 012345678 (not enough digits for any number) 3846 * +447851122114 (contains country code, too long) 3847 * 3848 * @author lloydc 3849 */ 3850wpd.directive('wpdutilsIsTelephoneMobile', ['wpdValidationService', function(wpdValidationService) { 3851 return { 3852 require: 'ngModel', 3853 restrict: 'A', 3854 link: function($scope, $elem, $attrs, ctrl) { 3855 const DIRECTIVE_KEY = 'wpdutilsIsTelephoneMobile'; 3856 const TEL_REGEX = /^0(7\d{9}|[1-689]\d{8,9})$/g; // Allows 10 or 11 digit numbers starting with 0 3857 3858 if (!ctrl.customErrorMessages) ctrl.customErrorMessages = {}; 3859 3860 // Set up the custom error message for this ngModel on failure of this validator 3861 ctrl.customErrorMessages[DIRECTIVE_KEY] = null; 3862 3863 ctrl.$validators[DIRECTIVE_KEY] = function(modelValue, viewValue) { 3864 if (ctrl.$isEmpty(modelValue)) { 3865 // consider empty models to be valid 3866 ctrl.customErrorMessages[DIRECTIVE_KEY] = null; 3867 return true; 3868 } 3869 3870 let result = false; 3871 3872 if (viewValue && (typeof viewValue === 'string' || typeof viewValue === 'number')) { 3873 let testValue = viewValue + ''; // convert to string 3874 testValue = testValue.replaceAll(/\s/g, ''); // remove any whitespace-like characters for easier testing 3875 result = testValue.match(TEL_REGEX); 3876 } 3877 ctrl.customErrorMessages[DIRECTIVE_KEY] = result ? null : "Must be a valid UK telephone number (11 digits for mobile, 10 or 11 for landline)"; 3878 return result; 3879 }; 3880 } 3881 }; 3882}]); 3883 3884/** 3885 * Validates that the input is a valid UK telephone number using Loqate's telephone validation API. 3886 * 3887 * Performs an initial regex check before calling the API - the number must start with 0 and 3888 * be either 10 or 11 digits long. 3889 * 3890 * If the API response is credible and the number is valid, the cleaned value is written back to the model. 3891 * 3892 * @author GrahamB 3893 * @author BradleyM 3894 */ 3895wpd.directive('wpdutilsIsTelUkLoqate', [ 3896 '$requester', 3897 'wpdutilsCustomInputMixinService', 3898 'wpdutilsLoqateValidationService', 3899 function($requester, customInputMixin, loqateValidation) { 3900 3901 const DEBUG = false; 3902 const DIRECTIVE_KEY = 'wpdutilsIsTelUkLoqate'; 3903 const VALIDATION_TIMEOUT_MS = 10000; 3904 const TEL_REGEX = /^0(7\d{9}|[1-689]\d{8,9})$/; // Allows 10 or 11 digit numbers starting with 0 3905 3906 return { 3907 require: 'ngModel', 3908 restrict: 'A', 3909 link: function($scope, $elem, $attrs, ctrl) { 3910 const json = $requester({ 3911 telCheck: 'WPDUtilsFront.validateUKTelephoneLoqate_JSON' 3912 }); 3913 3914 DEBUG && console.log(`[${DIRECTIVE_KEY}] Initialised for element: ${$elem.attr('id')}`); 3915 3916 // Coerce to string and strip whitespace 3917 ctrl.$parsers.push(function(viewValue) { 3918 if (!viewValue) return viewValue; 3919 return (viewValue + '').replace(/\s/g, ''); 3920 }); 3921 3922 function telRegexCheck(val) { 3923 return !!val.match(TEL_REGEX); 3924 } 3925 3926 const loadingSpinnerProperty = setupLoadingSpinner($scope, $elem, DIRECTIVE_KEY, customInputMixin); 3927 3928 function buildTelApiCall(json, apiKeyHandle) { 3929 return function({ viewValue, resolve, reject, setError, markDefinitive, onNetworkFailure }) { 3930 json.telCheck({ telephoneNumber: viewValue, apiKeyHandle }, function(response) { 3931 const item = response?.result?.items?.[0]; 3932 3933 if (!response?.success || !item) { 3934 setError(null); 3935 resolve(); 3936 return; 3937 } 3938 3939 markDefinitive(); 3940 3941 if (item.IsCredible) { 3942 if (item.IsValid?.toUpperCase() === "NO") { 3943 setError(`Telephone number ${viewValue} is not a valid UK telephone number`); 3944 reject(); 3945 } else { 3946 ctrl.$setViewValue(viewValue); 3947 ctrl.$render(); 3948 setError(null); 3949 resolve(); 3950 } 3951 } else { 3952 // Response not credible - default to accepting the number 3953 setError(null); 3954 resolve(); 3955 } 3956 }
3956).onFailure(onNetworkFailure); 3957 }; 3958 } 3959 3960 ctrl.$asyncValidators[DIRECTIVE_KEY] = loqateValidation.createValidator({ 3961 validatorType: DIRECTIVE_KEY, 3962 ctrl, $elem, $scope, 3963 customInputMixin, json, 3964 apiKeyHandle: $attrs[DIRECTIVE_KEY], 3965 regexCheck: telRegexCheck, 3966 regexFailMessage: "Must be a valid UK telephone number", 3967 emptyMessage: "Please enter a valid UK telephone number", 3968 timeoutMs: VALIDATION_TIMEOUT_MS, 3969 debug: DEBUG, 3970 buildApiCall: buildTelApiCall, 3971 onLoadStart: function() { $scope[loadingSpinnerProperty] = true; }, 3972 onLoadEnd: function() { $scope[loadingSpinnerProperty] = false; }, 3973 onDestroy: function(cleanupFn) { $scope.$on('$destroy', cleanupFn); } 3974 }); 3975 } 3976 }; 3977}]); 3978 3979/** 3980 * Validates that the input is a valid email address using Loqate's email validation API. 3981 * 3982 * Performs an initial regex check before calling the API - the email must have a valid 3983 * format including a TLD after the @ symbol. If the API response marks the address as 3984 * invalid, the error is written back to the ngModel controller. 3985 * 3986 * @author GrahamB 3987 * @author BradleyM 3988 */ 3989wpd.directive('wpdutilsIsEmailLoqate', [ 3990 '$requester', 3991 'wpdutilsCustomInputMixinService', 3992 'wpdutilsLoqateValidationService', 3993 function($requester, customInputMixin, loqateValidation) { 3994 3995 const DEBUG = false; 3996 const DIRECTIVE_KEY = 'wpdutilsIsEmailLoqate'; 3997 const VALIDATION_TIMEOUT_MS = 10000; 3998 const EMAIL_REGEX = /^[\w.!#$%&'*+/=?^`{|}~-]+@[a-z\d](?:[a-z\d-]{0,61}[a-z\d])?(?:\.[a-z\d](?:[a-z\d-]{0,61}[a-z\d])?)+$/i; 3999 4000 return { 4001 require: 'ngModel', 4002 restrict: 'A', 4003 link: function($scope, $elem, $attrs, ctrl) { 4004 const json = $requester({ 4005 emailCheck: 'WPDUtilsFront.validateEmailLoqate_JSON' 4006 }); 4007 4008 DEBUG && console.log(`[${DIRECTIVE_KEY}] Initialised for element: ${$elem.attr('id')}`); 4009 4010 // Angular adds its own email validator on type="email" inputs. 4011 // Remove it so Loqate is the sole email validity authority. 4012 // Safe on re-link: delete on a non-existent property is a no-op. 4013 if ($attrs.type === 'email' && ctrl && ctrl.$validators) { 4014 delete ctrl.$validators.email; 4015 } 4016 4017 // Coerce to string and strip whitespace 4018 ctrl.$parsers.push(function(viewValue) { 4019 if (!viewValue) return viewValue; 4020 return (viewValue + '').trim(); 4021 }); 4022 4023 const loadingSpinnerProperty = setupLoadingSpinner($scope, $elem, DIRECTIVE_KEY, customInputMixin); 4024 4025 function buildEmailApiCall(json, apiKeyHandle) { 4026 return function({ viewValue, resolve, reject, setError, markDefinitive, onNetworkFailure }) { 4027 json.emailCheck({ emailAddress: viewValue, apiKeyHandle }, function(response) { 4028 const item = response?.result?.items?.[0]; 4029 4030 if (!response?.success || !item) { 4031 setError(null); 4032 resolve(); 4033 return; 4034 } 4035 4036 markDefinitive(); 4037 4038 if (item.ResponseCode?.toUpperCase() === "INVALID") { 4039 setError(`Email address ${viewValue} is not a valid email address`); 4040 reject(); 4041 } else { 4042 setError(null); 4043 resolve(); 4044 } 4045 }).onFailure(onNetworkFailure); 4046 }; 4047 } 4048 4049 function emailRegexCheck(val) { 4050 return typeof val === 'string' && !!val.match(EMAIL_REGEX); 4051 } 4052 4053 ctrl.$asyncValidators[DIRECTIVE_KEY] = loqateValidation.createValidator({ 4054 validatorType: DIRECTIVE_KEY, 4055 ctrl, 4056 $elem, 4057 $scope, 4058 customInputMixin, 4059 json, 4060 apiKeyHandle: $attrs[DIRECTIVE_KEY], 4061 regexCheck: emailRegexCheck, 4062 regexFailMessage: "Must be a valid email address", 4063 emptyMessage: "Please enter a valid email address", 4064 timeoutMs: VALIDATION_TIMEOUT_MS, 4065 debug: DEBUG, 4066 buildApiCall: buildEmailApiCall, 4067 onLoadStart: function() { $scope[loadingSpinnerProperty] = true; }, 4068 onLoadEnd: function() { $scope[loadingSpinnerProperty] = false; }, 4069 onDestroy: function(cleanupFn) { $scope.$on('$destroy', cleanupFn); } 4070 }); 4071 } 4072 }; 4073}]); 4074 4075/** 4076* @return {boolean} - true if the supplied String viewValue is in an email format
4077* Ensures that an email has a TLD after the @ symbol 4078* @author lloydc 4079*/ 4080wpd.directive('wpdutilsIsEmail', ['wpdValidationService', function(wpdValidationService) { 4081 return { 4082 require: 'ngModel', 4083 restrict: 'A', 4084 link: function($scope, $elem, $attrs, ctrl) { 4085 const DIRECTIVE_KEY = 'wpdutilsIsEmail'; 4086 const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/g; // Accepts "[email protected]" but not "test@test" 4087 4088 if (!ctrl.customErrorMessages) ctrl.customErrorMessages = {}; 4089 4090 // Set up the custom error message for this ngModel on failure of this validator 4091 ctrl.customErrorMessages[DIRECTIVE_KEY] = null; 4092 4093 ctrl.$validators[DIRECTIVE_KEY] = function(modelValue, viewValue) { 4094 if (ctrl.$isEmpty(modelValue)) { 4095 // consider empty models to be valid 4096 ctrl.customErrorMessages[DIRECTIVE_KEY] = null; 4097 return true; 4098 } 4099 4100 let result = false; 4101 4102 if (viewValue && typeof viewValue === 'string') { 4103 result = viewValue.match(EMAIL_REGEX); 4104 } 4105 4106 ctrl.customErrorMessages[DIRECTIVE_KEY] = result ? null : "Must be a valid email in the format '[email protected]'"; 4107 return result; 4108 }; 4109 } 4110 }; 4111}]); 4112 4113/** 4114 * Adaptation of wpdutilsIsTelephone directive, using a JavaScript compatible 4115 * version of the regex in CoreTelephone's isValid method. 4116 * 4117 * This should be used in conjunction with the @Telephone annotation, as 4118 * CoreTelephone's isValid method is used when validating attributes with this 4119 * annotation on form submission. 4120 * 4121 * @return {boolean} - true if the supplied {String|number} viewValue is in a 4122 * telephone format 4123 * 4124 * Treat the following sample variants as valid: 4125 * 123 456 789 4126 * 123456789 4127 * +44 123456789 4128 * +44123456789 4129 * (020) 1234 1234 4130 * 4131 * @author bradleym, noahm (wpdutilsIsTelephone) 4132 */ 4133wpd.directive('wpdutilsIsCoreTelephone', ['wpdValidationService', function(wpdValidationService) { 4134 return { 4135 require: 'ngModel', 4136 restrict: 'A', 4137 link: function($scope, $elem, $attrs, ctrl) { 4138 const DIRECTIVE_KEY = 'wpdutilsIsCoreTelephone'; 4139 const TEL_REGEX = /^([\\+][0-9]{1,3})?([ \\.\\-])?([\\(]{1}[0-9]{2,6}[\\)])?([0-9 \\.\\\\-\\\/]{3,20})((x|ext|extension)[ ]?[0-9]{1,4})?$/g; 4140 // Adapted from CoreTelephone's isValid method 4141 // Original: ^([\\+][0-9]{1,3})?([ \\.\\-])?([\\(]{1}[0-9]{2,6}[\\)])?([0-9 \\.\\-/]{3,20})((x|ext|extension)[ ]?[0-9]{1,4})?$ 4142 4143 if (!ctrl.customErrorMessages) ctrl.customErrorMessages = {}; 4144 4145 // Set up the custom error message for this ngModel on failure of this validator 4146 ctrl.customErrorMessages[DIRECTIVE_KEY] = null; 4147 4148 ctrl.$validators[DIRECTIVE_KEY] = function(modelValue, viewValue) { 4149 if (ctrl.$isEmpty(modelValue)) { 4150 // consider empty models to be valid 4151 ctrl.customErrorMessages[DIRECTIVE_KEY] = null; 4152 return true; 4153 } 4154 4155 let result = false; 4156 4157 if (viewValue && (typeof viewValue === 'string' || typeof viewValue === 'number')) { 4158 let testValue = viewValue + ''; // convert to string 4159 testValue = testValue.replaceAll(/\s/g, ''); // remove any whitespace-like characters for easier testing 4160 result = testValue.match(TEL_REGEX); 4161 } 4162 4163 ctrl.customErrorMessages[DIRECTIVE_KEY] = result ? null : "Must be a valid telephone number"; 4164 return result; 4165 }; 4166 } 4167 }; 4168}]); 4169 4170 4171wpd.directive('wpdutilsIsPowercutsTelephone', ['wpdValidationService', function(wpdValidationService) { 4172 return { 4173 require: 'ngModel', 4174 restrict: 'A', 4175 link: function($scope, $elem, $attrs, ctrl) { 4176 const DIRECTIVE_KEY = 'wpdutilsIsCoreTelephone'; 4177 const TEL_REGEX = /^([\\+][0-9]{1,3})?([ \\.\\-])?([\\(]{1}[0-9]{2,6}[\\)])?([0-9 \\.\\\\-\\\/]{3,20})((x|ext|extension)[ ]?[0-9]{1,4})?$/g; 4178 // Adapted from CoreTelephone's isValid method 4179 // Original: ^([\\+][0-9]{1,3})?([ \\.\\-])?([\\(]{1}[0-9]{2,6}[\\)])?([0-9 \\.\\-/]{3,20})((x|ext|extension)[ ]?[0-9]{1,4})?$ 4180 4181 if (!ctrl.customErrorMessages) ctrl.customErrorMessages = {}; 4182 4183 // Set up the custom error message for this ngModel on failure of this validator 4184 ctrl.customErrorMessages[DIRECTIVE_KEY] = null; 4185 4186 ctrl.$validators[DIRECTIVE_KEY] = function(modelValue, viewValue) { 4187 if (ctrl.$isEmpty(modelValue)) { 4188 // consider empty models to be valid 4189 ctrl.customErrorMessages[DIRECTIVE_KEY] = null; 4190 return true; 4191 } 4192 4193 let result = false; 4194 4195 if (viewValue && (typeof viewValue === 'string' || typeof viewValue === 'number')) { 4196 let testValue = viewValue + ''; // convert to string 4197 testValue = testValue.replaceAll(/\s/g, ''); // remove any whitespace-like characters for easier testing 4198 result = testValue.match(TEL_REGEX); 4199 } 4200 4201 ctrl.customErrorMessages[DIRECTIVE_KEY] = result ? null : "Must be a valid telephone number"; 4202 return result; 4203 }; 4204 } 4205 }; 4206}]); 4207 4208/** 4209* @return {boolean} - true if the supplied {String} is prefixed with NGED area codes
4210* Does not check if the MPAN number if valid, only if it is in area. 4211* 4212* @author grahamb 4213*/ 4214wpd.directive('wpdutilsIsNgedMpan', ['wpdValidationService', function(wpdValidationService) { 4215 return { 4216 require: 'ngModel', 4217 restrict: 'A', 4218 link: function($scope, $elem, $attrs, ctrl) { 4219 const DIRECTIVE_KEY = 'wpdutilsIsNgedMpan'; 4220 4221 if (!ctrl.customErrorMessages) ctrl.customErrorMessages = {}; 4222 4223 // Set up the custom error message for this ngModel on failure of this validator 4224 ctrl.customErrorMessages[DIRECTIVE_KEY] = null; 4225 4226 ctrl.$validators[DIRECTIVE_KEY] = function(modelValue, viewValue) { 4227 if (ctrl.$isEmpty(modelValue) || modelValue.toString().length < 2) { 4228 // consider empty models to be valid 4229 ctrl.customErrorMessages[DIRECTIVE_KEY] = null; 4230 return true; 4231 } 4232 4233 if (!Number.isInteger(Number(modelValue))) { 4234 ctrl.customErrorMessages[DIRECTIVE_KEY] = "Must contain only numeric digits"; 4235 return false; 4236 } 4237 4238 var checkValue = modelValue.toString().substr(0, 2); 4239 var hasRightStart = (checkValue == "21" || checkValue == "22" || checkValue == "11" || checkValue == "14"); 4240 4241 ctrl.customErrorMessages[DIRECTIVE_KEY] = (hasRightStart) ? null : "This MPAN does not belong to National Grid. Cannot proceed with application."; 4242 return hasRightStart; 4243 }; 4244 } 4245 }; 4246 } 4247]); 4248 4249 4250/** 4251* Form directive that passes through the value of the parent form's stepSubmitted property 4252* TODO maybe consider making this part of wpdutilsCustomInputMixinService instead (or as well?) 4253* 4254* @author ianbe 4255* @since 2025-06-27 4256*/ 4257wpd.directive('wpdutilsParentFormStatus', [function() { 4258 return { 4259 restrict: 'A', 4260 require: 'form', 4261 link: function($scope, $elem, $attrs, $ctrls) { 4262 const thisForm = $scope[$attrs.ngForm]; 4263 if (thisForm && thisForm.$$parentForm) { 4264 Object.defineProperty(thisForm, 'stepSubmitted', { 4265 get: function() { 4266 return thisForm.$$parentForm?.stepSubmitted; 4267 } 4268 }); 4269 } 4270 } 4271 }; 4272}]); 4273 4274 4275/** 4276 * Directive to validate the maximum length of a string, after encoding it in the manner of the backend 4277 * 4278 * @author ianbe 4279 * @since 2025-07-02 4280 */ 4281wpd.directive('wpdutilsMaxLength', ['wpdutilsRequester', function(wpdutilsRequester) { 4282 4283 // static object to hold the aliases for emojis, so that they can be shared across multiple instances of this directive 4284 const ALIASES = { state: 'uninitialised', aliases: {} }; 4285 4286 return { 4287 restrict: 'A', 4288 require: 'ngModel', 4289 link: function($scope, $elem, $attrs, $ctrl) { 4290 const DIRECTIVE_KEY = 'wpdutilsMaxLength'; 4291 4292 const FITZPATRICK_TYPES = { 4293 "\uD83C\uDFFB": "type_1_2", 4294 "\uD83C\uDFFC": "type_3", 4295 "\uD83C\uDFFD": "type_4", 4296 "\uD83C\uDFFE": "type_5", 4297 "\uD83C\uDFFF": "type_6" 4298 }; 4299 4300 // Modes - these match the CoreEmojiSanitiser behaviours for the corresponding modes 4301 // - parse (default): Create HTML entities for emojis, strip Fitzpatrick modifiers (e.g. 👧) 4302 // - alias: Create aliases for emojis, use aliases for Fitzpatrick modifiers if provided (e.g. :person: or :person|type_3:) 4303 // TODO: Not fully implemented yet - emoji aliases are currently mapped to their code points, not their aliases 4304 // - strip: Strip all emojis and Fitzpatrick modifiers, leaving no content 4305 const max = parseInt($attrs[DIRECTIVE_KEY], 10); 4306 let emojiMode = $attrs[DIRECTIVE_KEY + 'EmojiMode'] || 'parse'; // default to parse mode if not specified 4307 4308 if (!['parse', 'alias', 'strip'].includes(emojiMode)) { 4309 emojiMode = 'parse'; // default to parse mode 4310 } 4311 4312 if (emojiMode === 'alias') { 4313 // Set the shared aliases asynchronously if this is the first directive to request them 4314 if (['uninitialised', 'failed'].includes(ALIASES.state)) { 4315 ALIASES.state = 'loading'; 4316 const json = wpdutilsRequester({ 4317 getEmojiAliases: 'WPDUtilsFront.getEmojiAliases_JSON' 4318 }); 4319 const request = json.getEmojiAliases({}, function(response) { 4320 if (response && response.success) { 4321 ALIASES.state = 'success'; 4322 ALIASES.aliases = response.aliases; 4323 } 4324 }); 4325 request.onFailure(function() { 4326 // Mark aliases as failed so a later directive can try again 4327 ALIASES.state = 'failed'; 4328 }) 4329 } 4330 } 4331 4332 4333 $ctrl.customErrorMessages = $ctrl.customErrorMessages || {}; 4334 4335 4336 function stripFitzpatrick(str) { 4337 return str.replace(/\p{Emoji_Modifier}/ug, ''); 4338 } 4339 4340 function encodeEmoji(str) { 4341 switch (emojiMode) { 4342 case 'alias': 4343 str = str.replace(/(\p{Extended_Pictographic})(\p{Emoji_Modifier})?/ug, (matches, emoji, modifier) => { 4344 const emojiStr = ALIASES.aliases[emoji] || 'unknown'; 4345 if (modifier === undefined) { 4346 return `:${emojiStr}:`; 4347 } 4348 else { 4349 const modifierStr = FITZPATRICK_TYPES[modifier]; 4350 return `:${emojiStr}|${modifierStr}:`; 4351 } 4352 }); 4353 break; 4354 case 'strip': 4355 str = stripFitzpatrick(str); 4356 str = str.replace(/\p{Extended_Pictographic}/ug, ''); 4357 break; 4358 case 'parse': 4359 default: 4360 str = stripFitzpatrick(str); 4361 str = str.replace(/\p{Extended_Pictographic}/ug, emoji => '&#' + emoji.codePointAt(0) + ';'); 4362 break; 4363 } 4364 4365 return str; 4366 } 4367 4368 4369 $ctrl.$validators[DIRECTIVE_KEY] = function(modelValue) { 4370 if (!modelValue) return true; 4371 4372 const encoded = encodeEmoji(modelValue); 4373 const charCount = encoded.length; 4374 4375 // DEBUG && console.log(`"${modelValue}" has ${charCount} characters after encoding - ${encoded}`); 4376 4377 if (charCount <= max) { 4378 return true; 4379 } 4380 else { 4381 $ctrl.customErrorMessages[DIRECTIVE_KEY] = "Must not be longer than " + max + " characters"; 4382 if (modelValue < encoded) { 4383 $ctrl.customErrorMessages[DIRECTIVE_KEY] += ". Note that emoji use more than one character."; 4384 } 4385 return false; 4386 } 4387 }; 4388 } 4389 }; 4390}]); 4391 4392/** 4393 * Use this when you have a fieldset of checkboxes that are grouped together 4394 * and you want to make sure at least one checkbox in the group is checked. 4395 * 4396 * Add the directive to the fieldset element, and supply it with the "group name" 4397 * checkbox-group-required="claimReasons" 4398 * 4399 * All the input boxes need to have the data-group attribute set to the same group name: 4400 * <input type="checkbox" data-group="claimReasons" ... /> 4401 * 4402 * @return {boolean} - true if at least one checkbox in the group is checked 4403 * 4404 * @author grahamb 4405 */ 4406wpd.directive('checkboxGroupRequired', function() { 4407 return { 4408 restrict: 'A', 4409 require: '^form', 4410 link: function($scope, $elem, $attrs, ctrl) { 4411 const groupName = $attrs.checkboxGroupRequired; 4412 const DIRECTIVE_KEY = 'checkboxGroupRequired_' + groupName; // Unique per group 4413 4414 if (!ctrl.customErrorMessages) ctrl.customErrorMessages = {}; 4415 4416 // Set up the custom error message for this form on failure of this validator 4417 ctrl.customErrorMessages[DIRECTIVE_KEY] = null; 4418 4419 ctrl.$validators[DIRECTIVE_KEY] = function() { 4420 const checkboxes = $elem.querySelectorAll('input[type="checkbox"][data-group="' + groupName + '"]'); 4421 return Array.from(checkboxes).some(cb => cb.checked); 4422 }; 4423 4424 // Watch for changes 4425 $scope.$watch(function() { 4426 const checkboxes = $elem.querySelectorAll('input[type="checkbox"][data-group="' + groupName + '"]'); 4427 return Array.from(checkboxes).map(cb => cb.checked).join(','); 4428 }, function() { 4429 ctrl.$validate(); 4430 }); 4431 } 4432 }; 4433}); 4434 4435 4436 4437// =========================================================================================== 4438// ----------------------------------- Helpful filters --------------------------------------- 4439// =========================================================================================== 4440 4441/** 4442* Return a string representation of a number, formatted to a particular locale's rules 4443* See: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Number/toLocaleStr
4443ing 4444* 4445* For the Java version, see {module:Utils} /app/wpd/utils/NumberHelper#numberLocaleString 4446* 4447* @param {number|string} input - the number to format, or a string parsable to a number 4448* @param {string} locale - the locale code to use, i.e. 'en-ie'. If left blank, will use the system locale. 4449* @param {object} options - a map of optional arguments to pass in which influence the returned string. See the Mozilla link for examples. 4450* @return {string} 4451* 4452* @author noahm 4453*/ 4454wpd.filter('numberLocaleString', function() { 4455 const NAME = 'numberLocaleString'; 4456 4457 return function(input, locale, options) { 4458 let workingInput = input; 4459 4460 if (typeof workingInput === 'string') { 4461 try { 4462 workingInput = parseFloat(workingInput); 4463 } 4464 catch (e) { 4465 console.warn(`${NAME} could not parse a Number from the supplied string value "${input}"`); 4466 } 4467 } 4468 4469 if (typeof workingInput !== 'number') { 4470 console.warn(`${NAME} cannot work on anything other than a Number type`); 4471 } 4472 else { 4473 workingInput = workingInput.toLocaleString(locale, options); 4474 } 4475 4476 return workingInput; 4477 } 4478}); 4479 4480/** 4481* Return a string representation of a number padded with some amount of leading 0s. 4482* 4483* @param {number} input 4484* @param {number} padCount 4485* @return {string} 4486* 4487* @author noahm 4488*/ 4489wpd.filter('zeroPad', function() { 4490 return function(input, padCount) { 4491 input += ''; 4492 if (!input || !padCount || typeof padCount !== 'number') return input; 4493 return input.padStart(padCount, '0'); 4494 }; 4495}); 4496 4497// Stolen from angular.stormmode.js/angular.powercuts-reporting.js 4498// It's okay for this to take precedence over the other two since they're all exactly the same function 4499// TODO: remove the ordinal filters in those two files - let them use this one. 4500wpd.filter('ordinal', function() { 4501 return function(input) { 4502 if (!input && input !== 0) return ''; 4503 var s = ['th', 'st', 'nd', 'rd'], 4504 v = input % 100; 4505 return input + (s[(v - 20) % 10] || s[v] || s[0]); 4506 }; 4507}); 4508 4509/** 4510* An augmentation of the normal angularjs date filter 4511* 4512* @return {string} - the given date in the specified angularjs date filter format, but with the ability to have an ordinal applied 4513* to any of the returned numerical portions, using '$ORD'. 4514* e.g. 'EEEE d$ORD MMMM, yyyy' => 'Thursday 13th May, 2022' 4515* 4516* @author noahm 4517*/ 4518wpd.filter('datePlus', ['$filter', function($filter) { 4519 var ordinalMarker = '$ORD'; 4520 4521 return function(date, format, timezone) { 4522 var ret = $filter('date')(date, format, timezone); 4523 4524 if (ret && ret.indexOf(ordinalMarker) >= 0) { 4525 ret = ret.replaceAll(new RegExp('(\\d{1,2})\\'+ordinalMarker, 'ig'), function(match, p1) { 4526 return $filter('ordinal')(Number.parseInt(p1)); 4527 }); 4528 } 4529 4530 return ret; 4531 }; 4532}]); 4533 4534/** 4535* A filter that attempts to capitalize words in a string in a title-like format, such as: 4536* "Structural evaluations of houses in Gateshead" => "Structural Evaluations of Houses in Gateshead" 4537* 4538* @author noahm 4539*/ 4540wpd.filter('smartCapitalize', function() { 4541 // Feel free to add to this list if you spot more examples 4542 var noCapitalize = ['of', 'the', 'do', 'with', 'to', 'from', 'by', 'upon', 'under', 'a', 'in', 'out', 'and', 'at', 'above', 'below']; 4543 4544 // TODO: give option to provide noCapitalize works that _should_ still be capitalized. 4545 return function(input) { 4546 if (!input) return ''; 4547 4548 var output = input.toLowerCase().replaceAll(/\b([^\s\r\n\t]*)\b/gi, function(match) { 4549 if (noCapitalize.indexOf(match) < 0) { 4550 match = match.charAt(0).toUpperCase() + match.slice(1); 4551 } 4552 4553 // TODO: you still need to capitalize noCapitalize words if they are the start of a sentence! 4554 4555 return match; 4556 }); 4557 4558 // we still want to capitalize the very first letter, no matter what it is 4559 return output.charAt(0).toUpperCase() + output.slice(1); 4560 }; 4561}); 4562 4563/** 4564* A filter that takes in a string containing substitution tokens, and swaps those tokens out with the relevant string which maps to it 4565* in the replaceMap. The input string may look like this: 4566* "My $vehicle is $color" 4567* and the map may look like this: 4568* { 4569* vehicle: 'motorbike', 4570* color: 'red' 4571* } 4572* 4573* @author noahm 4574*/ 4575wpd.filter('stringSubstitute', function() { 4576 return function(inputString, replaceMap) { 4577 if (!inputString) return ''; 4578 if (!replaceMap) return inputString; 4579 4580 return inputString.replaceAll(/\$[a-bA-B0-9_]+/gi, function(match) { 4581 match = match.slice(1); // remove the $ at the start 4582 return replaceMap[match] !== undefined ? replaceMap[match] : match; 4583 }); 4584 }; 4585}); 4586 4587/** 4588* A filter which returns the given input if it is neither null or undefined, else returns the supplied `alternativeValue` 4589* 4590* @author noahm 4591*/ 4592wpd.filter('wpdutilsIfExistsOr', function() { 4593 return function(input, alternativeValue) { 4594 return input !== null && input !== undefined ? input : alternativeValue; 4595 } 4596}); 4597 4598 4599// =========================================================================================== 4600// ------------------------------ General Utility Services ----------------------------------- 4601// =========================================================================================== 4602 4603/** 4604* Dumping ground of helpful math functions 4605* 4606* @author noahm 4607*/ 4608wpd.service('wpdutilsMathUtilities', [ 4609 function() { 4610 /** 4611 * Stolen from Powercuts 4612 * Rounds a number to a precision 4613 * @author keiranc 4614 * @param {Number} value the value to round 4615 * @param {Number} precision the number of decimal places 4616 * @returns {Number} the rounded value 4617 */ 4618 this.roundWithPrecision = function(value, precision) { 4619 var multiplier = Math.pow(10, precision || 0); 4620 return Math.round(value * multiplier) / multiplier; 4621 }; 4622 4623 /** 4624 * Stolen from Powercuts 4625 * Converts latitude to radians 4626 * @author keiranc 4627 * @param {Number} lat latitude 4628 * @returns {Number} the amount in radians 4629 */ 4630 this.lat2rad = function(lat) { 4631 var sin = Math.sin((lat * Math.PI) / 180); 4632 var radX2 = Math.log((1 + sin) / (1 - sin)) / 2; 4633 return Math.max(Math.min(radX2, Math.PI), -Math.PI) / 2; 4634 }; 4635 } 4636]); 4637 4638/** 4639* Dumping ground of helpful miscellaneous functions 4640* 4641* @author noahm 4642*/ 4643wpd.service('wpdutilsMiscUtilities', [ 4644 function() { 4645 /** 4646 * Stolen from the Powercuts site 4647 * 4648 * finds an object by property value and returns said object, 4649 * or null if there is no object, or if initial objects was null 4650 * 4651 * @author keiranc 4652 * @param {String} property the property to filter on 4653 * @param {Object} value the value to filter on 4654 * @param {Array} objects the objects to filter 4655 * @returns object with property matching value in objects 4656 */ 4657 this.getBy = function(property, value, objects) { 4658 if (!objects) { 4659 return null; 4660 } 4661 var _object = objects.filter(function(object) { 4662 return object[property] === value; 4663 }); 4664 return _object.length ? _object[0] : null; 4665 }; 4666 4667 4668 /** 4669 * Returns a simple key-value store (mostly for factories to use to share data between instances) 4670 * @author ianbe 4671 * @since 2025-07-30 4672 */ 4673 this.newKeyValueStore = () => function() { 4674 const store = {}; 4675 4676 /** 4677 * Check whether a value exists in the store. 4678 * @param {string} key Cache key 4679 * @returns {boolean} True if the store has a value at that key, false otherwise 4680 */ 4681 this.has = function(key) { 4682 return store.hasOwnProperty(key); 4683 }; 4684 4685 /** 4686 * Get a value from the store, or if not found, set it to the return value of a callback function and return it. 4687 * @param {string} key Cache key 4688 * @param {boolean|function} setDefault Determines the default value to set if the key is not found in the store. 4689 * If true, an empty object will be set at the key. 4690 * If a function, its return value will be set at the key. 4691 * Functions are highly recommended to return a reference type such as an object, if you want to share data without having to re-fetch it. 4692 * 4693 * @returns {*} The value from the store or the result of the callback function 4694 */ 4695 this.get = function(key, setDefault = null) { 4696 if (setDefault && !this.has(key)) { 4697 if (setDefault === true) { 4698 store[key] = {}; 4699 } 4700 else if (typeof setDefault === 'function') { 4701 store[key] = setDefault(); 4702 } 4703 } 4704 return store[key]; 4705 }; 4706 4707 /** 4708 * Set a value in the store. 4709 * Note that if the key already contains a reference type, any existing references will not be updated to the new value. 4710 * @param {string} key Cache key 4711 * @param {*} value The value to store at the key 4712 */ 4713 this.set = function(key, value) { 4714 store[key] = value; 4715 } 4716 }; 4717 4718 } 4719]) 4720 4721 4722 4723/** 4724 * Wrapper for the $requester service to add extra functionality: 4725 * 4726 * Check if a request in the requester - isLoading() - or a specific route - isLoading(route) - is currently in progress. 4727 * 4728 * Caching. If the success callback returns an object with a truthy `cache` property, the response will be cached 4729 * and returned on subsequent calls with the same parameters. Use for read-only requests where the response is expected to be static. 4730 * 4731 * Should be interchangeable with the $requester syntax, so, sorry, you still gotta call onFailure separately! 4732 * Also $requester doesn't use promises, so we won't either. 4733 * 4734 * @author ianbe 4735 * @created 2025-06-24 4736 */ 4737wpd.factory('wpdutilsRequester', ['$requester', function($requester) { 4738 4739 /* Store for the optional request cache */ 4740 const requestCache = {}; 4741 4742 /** 4743 * Looks in the request cache for a stored response to the given route and parameters. 4744 * @param {string} route The $requester route, for the cache key 4745 * @param {object} params The request parameters, for the cache key 4746 * @returns the response object if found, otherwise undefined 4747 */ 4748 const getCache = function(route, params) { 4749 const paramsKey = JSON.stringify(params); 4750 if (!requestCache[route]) { 4751 return undefined; 4752 } 4753 return requestCache[route][paramsKey]; 4754 } 4755 4756 /** 4757 * Stores a response in the request cache for the given route and parameters. 4758 * @param {string} route The $requester route, for the cache key 4759 * @param {object} params The request parameters, for the cache key 4760 * @param {object} value The response object to store 4761 */ 4762 const setCache = function(route, params, value) { 4763 const paramsKey = JSON.stringify(params); 4764 if (!requestCache[route]) { 4765 requestCache[route] = {}; 4766 } 4767 requestCache[route][paramsKey] = value; 4768 } 4769 4770 /** 4771 * Clears the request cache, optionally only for the given route and/or parameters. 4772 * @param {string|null} route The $requester route to clear from the cache, or null to clear the whole cache 4773 * @param {object|null} params The request parameters within the (required) route to clear from the cache, or null to clear the whole route 4774 */ 4775 const clearCache = function(route = null, params = null) { 4776 if (!route) { 4777 requestCache = {}; 4778 return; 4779 } 4780 if (params) { 4781 const paramsKey = JSON.stringify(params); 4782 delete requestCache[route][paramsKey]; 4783 } 4784 else { 4785 delete requestCache[route]; 4786 } 4787 } 4788 4789 return function(routes, ...args) { 4790 // Original requester object 4791 var requester = $requester(routes, ...args); 4792 4793 // Tracker for loading state 4794 var loading = 0; 4795 var loadingRoutes = {}; 4796 4797 // Wrapper that will be returned to the caller 4798 var wrapper = {}; 4799 4800 /** 4801 * Outward-facing function to check if a request is currently in progress. 4802 * @returns {boolean} - true if a request is currently in progress, false otherwise 4803 */ 4804 wrapper.isLoading = function(route = null) { 4805 if (route) { 4806 return (loadingRoutes[route] && loadingRoutes[route] > 0); 4807 } 4808 else { 4809 return (loading > 0); 4810 } 4811 }; 4812 4813 /** 4814 * Outward-facing function to reset the loading state. 4815 */ 4816 wrapper.unblock = function() { 4817 loading = 0; 4818 } 4819 4820 /** 4821 * Internal function to increment the loading counter at the start of a request. 4822 * @param {string|null} route The request route to use as a key 4823 */ 4824 const incrementLoading = function(route = null) { 4825 loading = Math.max(1, loading + 1); 4826 if (route) { 4827 loadingRoutes[route] = Math.max(1, (loadingRoutes[route] ?? 0) + 1); 4828 } 4829 } 4830 4831 /** 4832 * Internal function to decrement the loading counter at the end of a request. 4833 * @param {string|null} route The request route to use as a key 4834 */ 4835 const decrementLoading = function(route = null) { 4836 loading = Math.max(0, loading - 1); 4837 if (route) { 4838 loadingRoutes[route] = Math.max(0, (loadingRoutes[route] ?? 0) - 1); 4839 } 4840 } 4841 4842 // Wrap each route's call function to add the extra functionality
4843 Object.keys(routes).forEach(function(key) { 4844 const original = requester[key]; 4845 4846 /** 4847 * wpdutilsRequester wrapper around the $requester function to call a route 4848 * @param {object} params Set of HTTP parameters to send with the request 4849 * @param {function} successFn Success callback function that can optionally return { cache: true } to cache the response 4850 * @param {boolean} background 4851 * @param {object} opts 4852 */ 4853 wrapper[key] = function(params, successFn, background, opts = {}) { 4854 // Try to find a cached response, and bail out with it if found 4855 const cachedResponse = getCache(key, params); 4856 if (cachedResponse !== undefined) { 4857 successFn(cachedResponse); 4858 return { 4859 onSuccess: function(fn) {}, 4860 onFailure: function(fn) {}, 4861 setLoader: function(obj) {}, 4862 } 4863 } 4864 4865 // Increment the loading counter 4866 incrementLoading(key); 4867 4868 // Call original function 4869 const result = original(params, successFn, background); 4870 4871 // Modify the other functions for loading state changes 4872 const originalOnSuccess = result.onSuccess; 4873 4874 /** 4875 * wpdutilsRequester wrapper for the $requester onSuccess function 4876 * @param {function} successFn Success callback function that can optionally return { cache: true } to cache the response 4877 */ 4878 result.onSuccess = function(successFn) { 4879 originalOnSuccess(function(...args) { 4880 const successFnResult = successFn(...args); 4881 if ((successFnResult instanceof Object) && (successFnResult.cache)) { 4882 // If the success function returns an object with a trueish cache property, store the response 4883 delete params.authenticityToken; 4884 setCache(key, params, args[0]); 4885 } 4886 decrementLoading(key); 4887 }); 4888 }; 4889 result.onSuccess(successFn); 4890 4891 const originalOnFailure = result.onFailure; 4892 4893 /** 4894 * wpdutilsRequester wrapper for the $requester onFailure function 4895 * @param {function} failureFn Failure callback function 4896 */ 4897 result.onFailure = function(failureFn) { 4898 originalOnFailure(function(...args) { 4899 failureFn(...args); 4900 decrementLoading(key); 4901 }); 4902 } 4903 4904 // Set an empty failure function to ensure that the loading state is decremented, even if not set 4905 result.onFailure(() => {}); 4906 4907 return result; 4908 }; 4909 }); 4910 4911 // Return a proxy that returns wrapper properties if they exist, and falls back to the original requester if not 4912 return new Proxy({}, { 4913 get(_, property) { 4914 return (wrapper[property] !== undefined) ? wrapper[property] : requester[property]; 4915 } 4916 }); 4917 }; 4918}]); 4919 4920wpd.service('wpdutilsLoqateValidationService', [ 4921 '$q', 4922 '$timeout', 4923 '$window', 4924 function($q, $timeout, $window) { 4925 // Loqate *requires* we don't re-use stored results over 30 days old, though recommends we check for fresh 4926 // results every hour or so. Below is a practical TTL that meets both this requirement & recommendation. 4927 const LOQATE_RESULT_TTL_MS = 60 * 60 * 1000; 4928 4929 const memoisedValidationResults = new Map(); // Memoised results 4930 const pendingValidationRequests = new Map(); // In-flight requests 4931 4932 /** 4933 * @returns unique string key for a given validator type & input value. 4934 */ 4935 function validationKey(validatorType, viewValue) { 4936 return JSON.stringify([validatorType, viewValue]); 4937 } 4938 4939 function getPreviousResult(key) { 4940 const result = memoisedValidationResults.get(key); 4941 if (!result) return null; 4942 4943 // Remove entry if it's exceeded our TTL - we need to do a fresh Loqate call 4944 if (Date.now() - result.settledTime >
4944 LOQATE_RESULT_TTL_MS) { 4945 memoisedValidationResults.delete(key); 4946 return null; 4947 } 4948 return result.promise; 4949 } 4950 4951 /** 4952 * Sets up all shared directive infrastructure and returns a ready to assign $asyncValidators function. 4953 * 4954 * @param {object} options 4955 * @param {string} options.validatorType - Unique key for this validator (DIRECTIVE_KEY) 4956 * @param {object} options.ctrl - ngModel controller 4957 * @param {object} options.$elem - jQuery-wrapped element 4958 * @param {Function} options.onLoadStart - Called when a validation request begins 4959 * @param {Function} options.onLoadEnd - Called when a validation request completes 4960 * @param {Function} options.onDestroy - Accepts a cleanup function and registers it for execution on directive 4961 * destroy 4962 * @param {object} options.customInputMixin - wpdutilsCustomInputMixinService 4963 * @param {object} options.json - $requester result with setLoader 4964 * @param {Function} options.regexCheck - Pre-flight check run before the API call - return false to reject the 4965 * value immediately 4966 * @param {string} options.regexFailMessage - Error message shown on regex failure 4967 * @param {string} options.emptyMessage - Error message shown when value is empty 4968 * @param {number} options.timeoutMs - Milliseconds before a pending validation request is abandoned 4969 * @param {Function} options.buildApiCall - Constructs the API call function for this validator, given the json 4970 * requester and API key handle 4971 * @param {string} options.apiKeyHandle - API key handle from directive attribute 4972 * @param {boolean} options.debug - Enable debug logging 4973 */ 4974 this.createValidator = function(options) { 4975 if (!options || !options.validatorType || !options.ctrl || !options.$elem || 4976 !options.customInputMixin || !options.json || !options.regexCheck || !options.buildApiCall) { 4977 throw new Error('wpdutilsLoqateValidationService.createValidator: missing required options'); 4978 } 4979 4980 const { 4981 validatorType, ctrl, $elem, customInputMixin, json, regexCheck, regexFailMessage, emptyMessage, 4982 timeoutMs, buildApiCall, apiKeyHandle, debug 4983 } = options; 4984 4985 json.setLoader({ 4986 load: options.onLoadStart, 4987 clear: options.onLoadEnd 4988 }); 4989 4990 /** 4991 * If Loqate gave us a definitive result, memoise a lightweight already-settled promise rather than the 4992 * original - to avoid retaining the full validation closure in memory - and store a settledTime. 4993 * 4994 * @param {*} resolved - whether the promise was resolved (true) or rejected (false) 4995 * @param {*} lookupKey - key to store the validation result under 4996 * @param {*} isLoqateResultDefinitive - whether the result from loqate was definitive 4997 */ 4998 function memoiseDefinitiveResult(resolved, lookupKey, isLoqateResultDefinitive) { 4999 if (isLoqateResultDefinitive) { 5000 memoisedValidationResults.set(lookupKey, { 5001 promise: resolved ? $q.resolve() : $q.reject(), 5002 // We need to know when we performed this lookup, see LOQATE_RESULT_TTL_MS 5003 settledTime: Date.now() 5004 }); 5005 } 5006 } 5007 5008 function setError(msg) { 5009 ctrl.customErrorMessages[validatorType] = msg; 5010 } 5011 5012 // Error message initialisation 5013 if (!ctrl.customErrorMessages) ctrl.customErrorMessages = {}; 5014 ctrl.customErrorMessages[validatorType] = null; 5015 5016 const apiCall = buildApiCall(json, apiKeyHandle); 5017 5018 // Creating cleanup function and passing to options onDestroy fn. 5019 function cleanup() {
5020 const keyPrefix = JSON.stringify([validatorType, '']).slice(0, -3); 5021 pendingValidationRequests.forEach(function(promise, key) { 5022 if (key.startsWith(keyPrefix)) pendingValidationRequests.delete(key); 5023 }); 5024 } 5025 options.onDestroy(cleanup); 5026 5027 return function $asyncValidator(modelValue, viewValue) { 5028 5029 if (ctrl.$isEmpty(modelValue)) { 5030 setError(null); // (Remove error) 5031 return $q.resolve(); 5032 } 5033 5034 const lookupKey = validationKey(validatorType, viewValue); 5035 5036 // If we have a pending promise for the given viewValue, return that promise instead of creating a 5037 // new one. 5038 const pendingRequest = pendingValidationRequests.get(lookupKey); 5039 if (pendingRequest) { 5040 debug && console.log(`[${validatorType}] Skipping - ${viewValue} already pending`); 5041 return pendingRequest; 5042 } 5043 5044 // If we've already validated this viewValue - with a definitive yes/no from Loqate - return the 5045 // associated promise instead of creating a new one. 5046 const previousResult = getPreviousResult(lookupKey); 5047 if (previousResult) { 5048 debug && console.log(`[${validatorType}] Skipping - ${viewValue} already validated`); 5049 return previousResult; 5050 } 5051 5052 // Perform supplied regex check on value 5053 if (!regexCheck(viewValue)) { 5054 debug && console.log(`[${validatorType}] Failed regex check for: ${viewValue}`); 5055 setError(regexFailMessage); 5056 return $q.reject(); 5057 } 5058 5059 let isLoqateResultDefinitive = false; 5060 5061 const validationPromise = $q(function(origResolve, origReject) { 5062 5063 let settled = false; 5064 let validationTimeout; 5065 5066 // Helper wrappers to allow pre/post logic before resolving/rejecting 5067 function resolve() { 5068 // Place any pre-resolve logic here 5069 if (settled) return; 5070 settled = true; 5071 $timeout.cancel(validationTimeout); 5072 $elem.removeAttr('disabled'); 5073 origResolve(); 5074 } 5075 5076 function reject() { 5077 // Place any pre-reject logic here 5078 if (settled) return; 5079 settled = true; 5080 $timeout.cancel(validationTimeout); 5081 $elem.removeAttr('disabled'); 5082 origReject(); 5083 } 5084 5085 // Disable the element while validation is in progress 5086 $elem.attr('disabled', 'disabled'); 5087 5088 // Prevent hung validation requests from leaving the validator pending indefinitely. 5089 validationTimeout = $timeout(function() { 5090 debug && console.warn(`[${validatorType}] Validation timed out for: ${viewValue}`); 5091 options.onLoadEnd(); 5092 setError(null); 5093 resolve(); 5094 }, timeoutMs); 5095 5096 apiCall({ 5097 viewValue, 5098 resolve, 5099 reject, 5100 setError, 5101 markDefinitive: function() { 5102 isLoqateResultDefinitive = true; 5103 }, 5104 onNetworkFailure: function() { 5105 // Loader clear not called on this path so need to do so ourselves 5106 options.onLoadEnd(); 5107 5108 if (false === $window.navigator.onLine) { 5109 // The user is offline, according to their browser 5110 setError("You appear to be offline. Please check your internet connection."); 5111 reject(); 5112 } else { 5113 // The user isn't offline, so something else has gone wrong. 5114 // We'll have to let this telephone number through. 5115 debug && console.warn(`[${validatorType}] Request failed for: ${viewValue}`); 5116 setError(null); 5117 resolve(); 5118 } 5119 } 5120 }); 5121 }); 5122 5123 // Guard duplicate in-flight requests for the same value 5124 pendingValidationRequests.set(lookupKey, validationPromise); 5125 5126 validationPromise.finally(function() { 5127 pendingValidationRequests.delete(lookupKey); // Remove viewValue from pending map - now settled 5128 }); 5129 5130 validationPromise.then( 5131 function() { memoiseDefinitiveResult(true, lookupKey, isLoqateResultDefinitive); }, 5132 function() { memoiseDefinitiveResult(false, lookupKey, isLoqateResultDefinitive); } 5133 ); 5134 5135 return validationPromise; 5136 }; 5137 }; 5138 } 5139]); 5140 5141/** 5142 * Sets up the loading spinner for a validation directive. 5143 * Injects the spinner into the directive's parent element, initialises it to hidden, 5144 * and returns the scope property name. 5145 * 5146 * @param {object} $scope - The directive's scope 5147 * @param {object} $elem - jQuery-wrapped directive element 5148 * @param {string} directiveKey - The directive's unique key 5149 * @param {object} customInputMixin - wpdutilsCustomInputMixinService 5150 * @returns {string} The scope property name used to control spinner visibility 5151 */ 5152function setupLoadingSpinner($scope, $elem, directiveKey, customInputMixin) { 5153 // The replaceAll needs to be here as stray '-' chars mess up the selection for the universal loading spinner 5154 // Selection is also too broad without $elem.attr('id') 5155 const loadingSpinnerProperty = (directiveKey + 'Loading_' + $elem.attr('id')).replaceAll('-', ''); 5156 // Inject a loading spinner into the parent element 5157 customInputMixin.injectLoadingSpinner($scope, $elem.parent(), loadingSpinnerProperty); 5158 $scope[loadingSpinnerProperty] = false; 5159 return loadingSpinnerProperty; 5160}
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.