LP1655158 Patron Search by Date of Birth
authorblake <blake@mobiusconsortium.org>
Wed, 9 Aug 2017 14:28:41 +0000 (14:28 +0000)
committerChris Sharp <csharp@georgialibraries.org>
Mon, 18 Sep 2017 00:13:20 +0000 (20:13 -0400)
Adds three UI boxes to the WBSC "Show Extra" patron search. One for the year, month and day.
The javascript on the page is altered to deliver group "4" to the backend. Local javascript
strips out non-numeric user entered data. The backend is updated to handle the new group.
SQL is genereated using the DATE_PART postgres function.

1. Open the web based staff client and browse to the patron search UI.
2. Click the show more down arrow button. Notice the lack of birth date field.
3. Apply the patch, repeat step one. Notice the addition of birth date boxes.
4. Type 1975 into the birth year box and press enter. Notice search results.
5. Try searching for partial names and partial birthdates.
6. Try entering non-numeric data into the birth date boxes.
7. Try searching for patrons without including the dob. Try with only the dob. Try a mix.

Signed-off-by: blake <blake@mobiusconsortium.org>
Open-ILS/src/perlmods/lib/OpenILS/Application/Storage/Publisher/actor.pm
Open-ILS/src/templates/staff/circ/patron/t_search.tt2
Open-ILS/web/js/ui/default/staff/circ/patron/app.js

index a1e634d..9631f88 100644 (file)
@@ -698,21 +698,11 @@ sub patron_search {
        @usrv = map { "^" . _clean_regex_chars($$search{$_}{value}) } grep { ''.$$search{$_}{group} eq '0' } keys %$search;
     }
 
-    while (($key, $value) = each (%$search)) {
-        if($$search{$key}{group} eq '4') {
-            my $tval = $key;
-            $tval =~ s/dob_//g;
-            my $right = "RIGHT('0'|| ";
-            my $end = ", 2)";
-            $end = $right = '' if lc $tval eq 'year';
-            $dob .= $right."CAST(DATE_PART('$tval', dob) AS text)$end ~ ? AND ";
-        }
-    }
-    # Trim the last " AND "
-    $dob = substr($dob,0,-4);
+    $dob = join ' AND ', map { ("CAST (DATE_PART('" . ( s/year//g ? 'year' : ( s/month//g ? 'month' : 'day' ) ) . "', dob) AS text) ~ ?") } grep { ''.$$search{$_}{group} eq '4' } keys %$search;
     @dobv = map { _clean_regex_chars($$search{$_}{value}) } grep { ''.$$search{$_}{group} eq '4' } keys %$search;
+
     $usr .= ' AND ' if ( $usr && $dob );
-    $usr .= $dob if $dob; # $dob not in-line above in case $usr doesn't have any search vals (only searched for dob)
+    $usr .= $dob if $dob;
     push(@usrv, @dobv) if @dobv;
 
     my $addr = join ' AND ', map { "evergreen.lowercase(CAST($_ AS text)) ~ ?" } grep { ''.$$search{$_}{group} eq '1' } keys %$search;
index c67291a..16e9189 100644 (file)
@@ -1,4 +1,166 @@
-[% INCLUDE 'staff/share/t_patron_search_form.tt2' %]
+<!-- TODO: inputs need sr-only labels
+   <label class="sr-only" for="input-id">label</label>
+-->
+
+<div class="row" id="patron-search-form-row">
+  <div class="col-md-11">
+    <form ng-submit="search(searchArgs)" id="patron-search-form" 
+        role="form" class="form-horizontal">
+
+      <div class="form-group">
+
+        <div class="col-md-2">
+          <input type="text" class="form-control" 
+            focus-me="focusMe"
+            ng-model="searchArgs.family_name" placeholder="[% l('Last Name') %]"/>
+        </div>
+
+        <div class="col-md-2">
+          <input type="text" class="form-control" 
+            ng-model="searchArgs.first_given_name" placeholder="[% l('First Name') %]"/>
+        </div>
+
+        <div class="col-md-2">
+          <input type="text" class="form-control" 
+            ng-model="searchArgs.second_given_name" placeholder="[% l('Middle Name') %]"/>
+        </div>
+
+        <div class="col-md-2" ng-mouseover="setLastFormElement()">
+          <input type="submit" class="btn btn-primary" value="[% l('Search') %]"/>
+        </div>
+
+        <div class="col-md-2" ng-mouseover="setLastFormElement()">
+          <input type="reset" class="btn btn-primary" ng-click="clearForm()" 
+            value="[% l('Clear Form') %]"/>
+        </div>
+
+        <div class="col-md-2">
+          <button class="btn btn-default" ng-click="applyShowExtras($event, true)" 
+            ng-mouseover="setLastFormElement()"
+            title="[% l('Show More Fields') %]" ng-show="!showExtras">
+            <span class="glyphicon glyphicon-circle-arrow-down"></span>
+          </button>
+          <button class="btn btn-default" ng-click="applyShowExtras($event, false)" 
+            ng-mouseover="setLastFormElement()"
+            title="[% l('Show Fewer Fields') %]" ng-show="showExtras">
+            <span class="glyphicon glyphicon-circle-arrow-up"></span>
+          </button>
+        </div>
+      </div>
+
+      <div class="form-group" ng-show="showExtras">
+        <div class="col-md-2">
+          <input type="text" class="form-control" ng-model="searchArgs.card" 
+            placeholder="[% l('Barcode') %]"/>
+        </div>
+        <div class="col-md-2">
+          <input type="text" class="form-control" 
+            ng-model="searchArgs.alias" placeholder="[% l('Alias') %]"/>
+        </div>
+        <div class="col-md-2">
+          <input type="text" class="form-control" 
+            ng-model="searchArgs.usrname" placeholder="[% l('Username') %]"/>
+        </div>
+        <div class="col-md-2">
+          <input type="text" class="form-control" 
+            ng-model="searchArgs.email" placeholder="[% l('Email') %]"/>
+        </div>
+        <div class="col-md-2">
+          <input type="text" class="form-control" 
+            ng-model="searchArgs.ident" placeholder="[% l('Identification') %]"/>
+        </div>
+      </div>
+
+      <div class="form-group" ng-show="showExtras">
+        <div class="col-md-2">
+          <input type="text" class="form-control" 
+            ng-model="searchArgs.id" placeholder="[% l('Database ID') %]"/>
+        </div>
+        <div class="col-md-2">
+          <input type="text" class="form-control" 
+            ng-model="searchArgs.phone" placeholder="[% l('Phone') %]"/>
+        </div>
+        <div class="col-md-2">
+          <input type="text" class="form-control" 
+            ng-model="searchArgs.street1" placeholder="[% l('Street 1') %]"/>
+        </div>
+        <div class="col-md-2">
+          <input type="text" class="form-control" 
+            ng-model="searchArgs.street2" placeholder="[% l('Street 2') %]"/>
+        </div>
+        <div class="col-md-2">
+          <input type="text" class="form-control" 
+            ng-model="searchArgs.city" placeholder="[% l('City') %]"/>
+        </div>
+      </div>
+
+      <div class="form-group" ng-show="showExtras">
+        <div class="col-md-2">
+          <input type="text" class="form-control" ng-model="searchArgs.state" 
+            placeholder="[% l('State') %]" title="[% l('State') %]"/>
+        </div>
+
+        <div class="col-md-2">
+          <input type="text" class="form-control" ng-model="searchArgs.post_code" 
+            placeholder="[% l('Post Code') %]" title="[% l('Post Code') %]"/>
+        </div>
+
+        <div class="col-md-2">
+          <!--
+          <input type="text" class="form-control"  
+            placeholder="[% l('Profile Group') %]"
+            ng-model="searchArgs.profile"
+            typeahead="grp as grp.name for grp in profiles | filter:$viewValue" 
+            typeahead-editable="false" />
+            -->
+
+            <div class="btn-group patron-search-selector" uib-dropdown>
+              <button type="button" class="btn btn-default" uib-dropdown-toggle>
+                <span style="padding-right: 5px;">{{searchArgs.profile.name() || "[% l('Profile Group') %]"}}</span>
+                <span class="caret"></span>
+              </button>
+              <ul uib-dropdown-menu>
+                <li ng-repeat="grp in profiles">
+                  <a href
+                    style="padding-left: {{pgt_depth(grp) * 10 + 5}}px"
+                    ng-click="searchArgs.profile = grp">{{grp.name()}}</a>
+                </li>
+              </ul>
+            </div>
+        </div>
+
+        <div class="col-md-2">
+          <eg-org-selector label="[% l('Home Library') %]" 
+            selected="searchArgs.home_ou" sticky-setting="eg.circ.patron.search.ou">
+          </eg-org-selector>
+        </div>
+
+        <div class="col-md-2">
+          <div class="checkbox">
+            <label>
+              <input type="checkbox" ng-model="searchArgs.inactive"/>
+              [% l('Include Inactive?') %]
+            </label>
+          </div>
+        </div>
+      </div>
+      <div class="form-group" ng-show="showExtras">
+        <div class="col-md-2">
+            <input type="text" class="form-control" ng-model="searchArgs.dob_year"
+            placeholder="[% l('DOB Year') %]" title="[% l('DOB Year') %]"/>
+        </div>
+        <div class="col-md-2">
+            <input type="text" class="form-control" ng-model="searchArgs.dob_month"
+            placeholder="[% l('DOB Month') %]" title="[% l('DOB Month') %]"/>
+        </div>
+        <div class="col-md-2">
+            <input type="text" class="form-control" ng-model="searchArgs.dob_day"
+            placeholder="[% l('DOB Day') %]" title="[% l('DOB Day') %]"/>
+        </div>
+      </div>
+    </form>
+  </div>
+</div>
 
 <br/>
 <div class="row">
index e7d6430..219aad7 100644 (file)
@@ -611,9 +611,226 @@ function($scope,  $q,  $routeParams,  $timeout,  $window,  $location,  egCore ,
         true
     );
 
-    $scope.need_one_selected = function() {
-        var items = $scope.gridControls.selectedItems();
-        return (items.length > 0) ? false : true;
+        } else {
+            patronSvc.search_barcode = $scope.searchArgs.card;
+            
+            var search = compileSearch($scope.searchArgs);
+            if (Object.keys(search) == 0) return $q.when();
+
+            var home_ou = search.home_ou;
+            delete search.home_ou;
+            var inactive = search.inactive;
+            delete search.inactive;
+
+            fullSearch = {
+                search : search,
+                sort : compileSort(),
+                inactive : inactive,
+                home_ou : home_ou,
+            };
+        }
+
+        fullSearch.count = count;
+        fullSearch.offset = offset;
+
+        if (patronSvc.lastSearch) {
+            // search repeated, return the cached results
+            if (angular.equals(fullSearch, patronSvc.lastSearch)) {
+                console.log('patron search returning ' + 
+                    patronSvc.patrons.length + ' cached results');
+                
+                // notify has to happen after returning the promise
+                $timeout(
+                    function() {
+                        angular.forEach(patronSvc.patrons, function(user) {
+                            deferred.notify(user);
+                        });
+                        deferred.resolve();
+                    }
+                );
+                return deferred.promise;
+            }
+        }
+
+        patronSvc.lastSearch = fullSearch;
+
+        if (fullSearch.search.id) {
+            // search by user id performs a direct ID lookup
+            var userId = fullSearch.search.id.value;
+            $timeout(
+                function() {
+                    egUser.get(userId).then(function(user) {
+                        patronSvc.localFlesh(user);
+                        patronSvc.patrons = [user];
+                        deferred.notify(user);
+                        deferred.resolve();
+                    });
+                }
+            );
+            return deferred.promise;
+        }
+
+        if (!Object.keys(fullSearch.search).length) {
+            // Empty searches are rejected by the server.  Avoid 
+            // running the the empty search that runs on page load. 
+            return $q.when();
+        }
+
+        egProgressDialog.open(); // Indeterminate
+
+        patronSvc.patrons = [];
+        var which_sound = 'success';
+        egCore.net.request(
+            'open-ils.actor',
+            'open-ils.actor.patron.search.advanced.fleshed',
+            egCore.auth.token(), 
+            fullSearch.search, 
+            fullSearch.count,
+            fullSearch.sort,
+            fullSearch.inactive,
+            fullSearch.home_ou,
+            egUser.defaultFleshFields,
+            fullSearch.offset
+
+        ).then(
+            function() {
+                deferred.resolve();
+            },
+            function() { // onerror
+                which_sound = 'error';
+            },
+            function(user) {
+                // hide progress bar as soon as the first result appears.
+                egProgressDialog.close();
+                patronSvc.localFlesh(user); // inline
+                patronSvc.patrons.push(user);
+                deferred.notify(user);
+            }
+        )['finally'](function() { // close on 0-hits or error
+            if (which_sound == 'success' && patronSvc.patrons.length == 0) {
+                which_sound = 'warning';
+            }
+            egCore.audio.play(which_sound + '.patron.by_search');
+            egProgressDialog.close();
+        });
+
+        return deferred.promise;
+    };
+
+    $scope.patronSearchGridProvider = provider;
+
+    // determine the tree depth of the profile group
+    $scope.pgt_depth = function(grp) {
+        var d = 0;
+        while (grp = egCore.env.pgt.map[grp.parent()]) d++;
+        return d;
+    }
+
+    $scope.clearForm = function () {
+        $scope.searchArgs={};
+        if (lastFormElement) lastFormElement.focus();
+    }
+
+    $scope.applyShowExtras = function($event, bool) {
+        if (bool) {
+            $scope.showExtras = true;
+            egCore.hatch.setItem('eg.circ.patron.search.show_extras', true);
+        } else {
+            $scope.showExtras = false;
+            egCore.hatch.removeItem('eg.circ.patron.search.show_extras');
+        }
+        if (lastFormElement) lastFormElement.focus();
+        $event.preventDefault();
+    }
+
+    egCore.hatch.getItem('eg.circ.patron.search.show_extras')
+    .then(function(val) {$scope.showExtras = val});
+
+    // map form arguments into search params
+    function compileSearch(args) {
+        var search = {};
+        angular.forEach(args, function(val, key) {
+            if (!val) return;
+            if (key == 'profile' && args.profile) {
+                search.profile = {value : args.profile.id(), group : 0};
+            } else if (key == 'home_ou' && args.home_ou) {
+                search.home_ou = args.home_ou.id(); // passed separately
+            } else if (key == 'inactive') {
+                search.inactive = val;
+            } else {
+                search[key] = {value : val, group : 0};
+            }
+            if (key.match(/phone|ident/)) {
+                search[key].group = 2;
+            } else {
+                if (key.match(/street|city|state|post_code/)) {
+                    search[key].group = 1;
+                } else if (key == 'card') {
+                    search[key].group = 3
+                } else if (key.match(/dob_/)) {
+                    // DOB should always be numeric
+                    search[key].value = search[key].value.replace(/\D/g,'');
+                    if (search[key].value.length == 0) {
+                        delete search[key];
+                    }
+                    else {
+                        search[key].group = 4;
+                    }
+                }
+            }
+        });
+
+        return search;
+    }
+
+    function compileSort() {
+
+        if (!provider.sort.length) {
+            return [ // default
+                "family_name ASC",
+                "first_given_name ASC",
+                "second_given_name ASC",
+                "dob DESC"
+            ];
+        }
+
+        var sort = [];
+        angular.forEach(
+            provider.sort,
+            function(sortdef) {
+                if (angular.isObject(sortdef)) {
+                    var name = Object.keys(sortdef)[0];
+                    var dir = sortdef[name];
+                    sort.push(name + ' ' + dir);
+                } else {
+                    sort.push(sortdef);
+                }
+            }
+        );
+
+        return sort;
+    }
+
+    $scope.setLastFormElement = function() {
+        lastFormElement = $document[0].activeElement;
+    }
+
+    // search form submit action; tells the results grid to
+    // refresh itself.
+    $scope.search = function(args) { // args === $scope.searchArgs
+        if (args && Object.keys(args).length) 
+            $scope.gridControls.refresh();
+        if (lastFormElement) lastFormElement.focus();
+    }
+
+    // TODO: move this into the (forthcoming) grid row activate action
+    $scope.onPatronDblClick = function($event, user) {
+        $location.path('/circ/patron/' + user.id() + '/checkout');
+    }
+
+    if (patronSvc.urlSearch) {
+        // force the grid to load the url-based search on page load
+        provider.refresh();
     }
     $scope.need_two_selected = function() {
         var items = $scope.gridControls.selectedItems();