PageSourceSearch

https://customer.nationalgrid.co.uk/public/javascripts/angular.wpd.js

js nationalgrid.co.uk collected 2026-09-24 09:02:06 UTC 225,153 bytes, 5,160 lines download raw bytes

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. &#128103;)
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.