From 6a3625db66774f025011dd6024442f3f6e64f5f0 Mon Sep 17 00:00:00 2001
From: Ere Maijala <ere.maijala@helsinki.fi>
Date: Thu, 20 Mar 2014 15:37:46 +0200
Subject: [PATCH] Implemented support for request groups with holds in core
 code, Demo driver and Voyager drivers.

---
 config/vufind/VoyagerRestful.ini              |  39 +-
 languages/en.ini                              |   3 +
 languages/fi.ini                              |   3 +
 languages/sv.ini                              |   5 +-
 .../src/VuFind/Controller/AjaxController.php  |  57 ++
 .../src/VuFind/Controller/Plugin/Holds.php    |  44 +-
 .../VuFind/Controller/RecordController.php    |  47 +-
 module/VuFind/src/VuFind/ILS/Driver/Demo.php  | 148 +++--
 .../VuFind/src/VuFind/ILS/Driver/Voyager.php  |  15 +-
 .../src/VuFind/ILS/Driver/VoyagerRestful.php  | 529 +++++++++++++++---
 themes/blueprint/css/styles.css               |   2 +-
 themes/blueprint/js/hold.js                   |  41 ++
 .../templates/myresearch/holds.phtml          |   5 +
 themes/blueprint/templates/record/hold.phtml  |  61 +-
 .../templates/myresearch/holds.phtml          |   5 +
 themes/bootstrap/js/hold.js                   |  41 ++
 .../templates/myresearch/holds.phtml          |   5 +
 themes/bootstrap/templates/record/hold.phtml  |  88 ++-
 themes/jquerymobile/css/styles.css            |   9 +-
 themes/jquerymobile/js/hold.js                |  44 ++
 .../templates/myresearch/holds.phtml          |   7 +
 .../jquerymobile/templates/record/hold.phtml  |  61 +-
 22 files changed, 1078 insertions(+), 181 deletions(-)
 create mode 100644 themes/blueprint/js/hold.js
 create mode 100644 themes/bootstrap/js/hold.js
 create mode 100644 themes/jquerymobile/js/hold.js

diff --git a/config/vufind/VoyagerRestful.ini b/config/vufind/VoyagerRestful.ini
index a8264087fa3..d4c232ec7bf 100644
--- a/config/vufind/VoyagerRestful.ini
+++ b/config/vufind/VoyagerRestful.ini
@@ -91,8 +91,8 @@ HMACKeys = item_id:holdtype:level
 defaultRequiredDate = 0:1:0
 
 ; extraHoldFields - A colon-separated list used to display extra visible fields in the
-; place holds form. Supported values are "comments", "requiredByDate" and 
-; "pickUpLocation"  
+; place holds form. Supported values are "comments", "requiredByDate", 
+; "pickUpLocation" and "requestGroup"
 extraHoldFields = comments:requiredByDate:pickUpLocation
 
 ; A Pick Up Location Code used to pre-select the pick up location drop down list and
@@ -108,6 +108,41 @@ defaultPickUpLocation = ""
 ; link. Use "0" to check all items via ajax. Default is 15.
 holdCheckLimit = 15
 
+; A request group ID used to pre-select the request group drop down list and
+; provide a default option if others are not available. Must be one of the following:
+; 1) false (default) to indicate that the user always has to choose the group
+; 2) empty string to indicate that the first value is default
+; 3) correspond with one of the request group IDs returned by getRequestGroups().
+; This setting is only effective if requestGroup is specified in extraHoldFields.
+;defaultRequestGroup = ""
+
+; By default the request group list is sorted alphabetically. This setting can be
+; used to manually set the order by entering request group IDs as a colon-separated list.
+; This setting is only effective if requestGroup is specified in extraHoldFields.
+;requestGroupOrder = 33
+
+; By default the available pickup locations don't have to belong to the selected request group.
+; Uncomment this setting to limit pickup locations to the request group.
+; This setting is only effective if requestGroup is specified in extraHoldFields.
+;pickupLocationsInRequestGroup = true
+
+; By default a title hold can be placed even when there are no items. Uncomment this
+; to prevent holds if no items exist. If request groups are enabled, item existence is
+; checked only in the selected request group.
+;checkItemsExist = true
+
+; By default a title hold can be placed even when there are items available. Uncomment this
+; to prevent holds if items are available. If request groups are enabled, availability is
+; checked only in the selected request group.
+;checkItemsAvailable = true
+
+; A colon-separated list of request group ids for which the available item check is disabled.
+; If a listed request group is selected, the availability check is not made regardless of the
+; setting above.
+; This setting is only effective if requestGroup is specified in extraHoldFields.
+disableAvailabilityCheckForRequestGroups = "15:19:21:32"
+
+
 ; This section controls call slip behavior. To enable, uncomment (at minimum) the
 ; HMACKeys and extraFields settings below.
 [StorageRetrievalRequests]
diff --git a/languages/en.ini b/languages/en.ini
index b1e96b926f1..1db85d8f67c 100644
--- a/languages/en.ini
+++ b/languages/en.ini
@@ -371,12 +371,14 @@ hold_empty_selection = "No holds were selected"
 hold_error_blocked = "You do not have sufficient privileges to place a hold on this item"
 hold_error_fail = "Your request failed. Please contact the circulation desk for further assistance"
 hold_invalid_pickup = "An invalid pick up location was entered. Please try again"
+hold_invalid_request_group = "An invalid library was entered. Please try again"
 hold_login = "for hold and recall information" 
 hold_place = "Place Request"
 hold_place_fail_missing = "Your request failed. Some data was missing. Please contact the circulation desk for further assistance"
 hold_place_success = "Your request was successful"
 hold_profile_html = "For hold and recall information, please establish your <a href="%%url%%">Library Catalog Profile</a>."
 hold_queue_position = "Queue Position"
+hold_request_group = "Library"
 hold_required_by = "No longer required after"
 hold_success = "Your request was successful"
 Home = Home
@@ -695,6 +697,7 @@ Select your carrier = "Select your carrier"
 Selected = "Selected"
 select_page = "Select Page"
 select_pickup_location = "Select Pick Up Location"
+select_request_group = "Select Library"
 Send = Send
 Send us your feedback! = "Send us your feedback!"
 Sensor Image = "Sensor Image"
diff --git a/languages/fi.ini b/languages/fi.ini
index 1bfc223f3f1..e2961a42eb2 100644
--- a/languages/fi.ini
+++ b/languages/fi.ini
@@ -368,12 +368,14 @@ hold_empty_selection = "Yhtään varausta ei valittu"
 hold_error_blocked = "Varaaminen ei ole mahdollista, koska olet lainauskiellossa."
 hold_error_fail = "Pyyntö epäonnistui. Ota yhteyttä kirjaston asiakaspalveluun."
 hold_invalid_pickup = "Valittu noutopaikka on virheellinen. Yritä uudestaan."
+hold_invalid_request_group = "Valittu kirjasto on virheellinen. Yritä uudestaan."
 hold_login = "tehdäksesi varauspyynnön" 
 hold_place = "Tee varaus"
 hold_place_fail_missing = "Pyyntö epäonnistui puuttuvien tietojen vuoksi. Ota yhteyttä kirjaston asiakaspalveluun."
 hold_place_success = "Varauspyyntö onnistui"
 hold_profile_html = "Kirjaudu <a href="%%url%%">kirjastokortilla</a> nähdäksesi varaukset."
 hold_queue_position = "Sijainti jonossa"
+hold_request_group = "Kirjasto"
 hold_required_by = "Viimeinen voimassaolopäivä"
 hold_success = "Varauspyyntö onnistui"
 Home = "Koti"
@@ -697,6 +699,7 @@ Select your carrier = "Valitse operaattori"
 Selected = "Valittu"
 select_page = "Valitse sivu"
 select_pickup_location = "Valitse noutopaikka"
+select_request_group = "Valitse kirjasto"
 Send = "Lähetä"
 Send us your feedback! = "Lähetä meille palautetta"
 Sensor Image = "Kaukokartoituskuva"
diff --git a/languages/sv.ini b/languages/sv.ini
index 532ad8c09c3..b8df60b0d03 100644
--- a/languages/sv.ini
+++ b/languages/sv.ini
@@ -368,12 +368,14 @@ hold_empty_selection = "Inga utvalda reserveringar"
 hold_error_blocked = "Det är inte möjligt att placera en reservering eftersom du är i låneförbud."
 hold_error_fail = "Reservering misslyckades. Vänd dig till bibliotekets kundtjänst."
 hold_invalid_pickup = "Avhämtningsplats duger inte. Kolla och försök igen."
-hold_login = "för att reservera material"
+hold_invalid_request_group = "Biblioteket duger inte. Kolla och försök igen."
+hold_login = "för att reservera material" 
 hold_place = "reservera"
 hold_place_fail_missing = "Reservering misslyckades p.g.a. saknande uppgifter. Vänd dig till bibliotekets kundtjänst."
 hold_place_success = "Material har reserverats."
 hold_profile_html = "Logga in med din <a href="%%url%%">bibliotekskort</a> för att se reserveringar."
 hold_queue_position = "Läget i kön"
+hold_request_group = "Bibliotek"
 hold_required_by = "Sista dagen i kraft"
 hold_success = "Material har reserverats."
 Home = "Hem"
@@ -697,6 +699,7 @@ Select your carrier = "Välj teleoperatör"
 Selected = "Vald"
 select_page = "Välj sida"
 select_pickup_location = "Välj avhämtningsplats"
+select_request_group = "Välj bibliotek"
 Send = "Skicka"
 Send us your feedback! = "Skicka os din feedback"
 Sensor Image = "Fjärranalysbild"
diff --git a/module/VuFind/src/VuFind/Controller/AjaxController.php b/module/VuFind/src/VuFind/Controller/AjaxController.php
index ee6648e06d5..0091a6858ad 100644
--- a/module/VuFind/src/VuFind/Controller/AjaxController.php
+++ b/module/VuFind/src/VuFind/Controller/AjaxController.php
@@ -1427,6 +1427,63 @@ class AjaxController extends AbstractBase
         );
     }
 
+    /**
+     * Get pick up locations for a request group
+     *
+     * @return \Zend\Http\Response
+     */
+    protected function getRequestGroupPickupLocationsAjax()
+    {
+        $this->writeSession();  // avoid session write timing bug
+        $id = $this->params()->fromQuery('id');
+        $requestGroupId = $this->params()->fromQuery('requestGroupId');
+        if (!empty($id) && !empty($requestGroupId)) {
+            // check if user is logged in
+            $user = $this->getUser();
+            if (!$user) {
+                return $this->output(
+                    array(
+                        'status' => false,
+                        'msg' => $this->translate('You must be logged in first')
+                    ),
+                    self::STATUS_NEED_AUTH
+                );
+            }
+
+            try {
+                $catalog = $this->getILS();
+                $patron = $this->getAuthManager()->storedCatalogLogin();
+                if ($patron) {
+                    $details = array(
+                        'id' => $id,
+                        'requestGroupId' => $requestGroupId
+                    );
+                    $results = $catalog->getPickupLocations(
+                        $patron, $details
+                    );
+                    foreach ($results as &$result) {
+                        if (isset($result['locationDisplay'])) {
+                            $result['locationDisplay'] = $this->translate(
+                                'location_' . $result['locationDisplay'],
+                                array(),
+                                $result['locationDisplay']
+                            );
+                        }
+                    }
+                    return $this->output(
+                        array('locations' => $results), self::STATUS_OK
+                    );
+                }
+            } catch (\Exception $e) {
+                // Do nothing -- just fail through to the error message below.
+            }
+        }
+
+        return $this->output(
+            $this->translate('An error has occurred'), self::STATUS_ERROR
+        );
+    }
+
     /**
      * Convenience method for accessing results
      *
diff --git a/module/VuFind/src/VuFind/Controller/Plugin/Holds.php b/module/VuFind/src/VuFind/Controller/Plugin/Holds.php
index 3dfccde71b9..dfe3a1afda8 100644
--- a/module/VuFind/src/VuFind/Controller/Plugin/Holds.php
+++ b/module/VuFind/src/VuFind/Controller/Plugin/Holds.php
@@ -191,7 +191,7 @@ class Holds extends AbstractPlugin
                     );
                 }
             }
-            
+
             foreach ($details as $info) {
                 // If the user input contains a value not found in the session
                 // whitelist, something has been tampered with -- abort the process.
@@ -308,6 +308,48 @@ class Holds extends AbstractPlugin
          return false;
     }
 
+    /**
+     * Check if the user-provided request group is valid.
+     *
+     * @param string $requestGroupId  Id of user-specified request group
+     * @param array  $extraHoldFields Hold form fields enabled by
+     * configuration/driver
+     * @param array  $requestGroups   Request group list from driver
+     *
+     * @return bool
+     */
+    public function validateRequestGroupInput(
+        $requestGroupId, $extraHoldFields, $requestGroups
+    ) {
+        // Not having to care for pickUpLocation is equivalent to having a valid one.
+        if (!in_array('requestGroup', $extraHoldFields)) {
+            return true;
+        }
+
+        // Check the valid pickup locations for a match against user input:
+        return $this->validateRequestGroup($requestGroupId, $requestGroups);
+    }
+
+    /**
+     * Check if the provided request group is valid.
+     *
+     * @param string $requestGroupId Id of the request group to check
+     * @param array  $requestGroups  Request group list from driver
+     *
+     * @return bool
+     */
+    public function validateRequestGroup($requestGroupId, $requestGroups)
+    {
+        foreach ($requestGroups as $group) {
+            if ($requestGroupId == $group['id']) {
+                return true;
+            }
+        }
+
+        // If we got this far, something is wrong!
+         return false;
+    }
+
     /**
      * Getting a default required date based on hold settings.
      *
diff --git a/module/VuFind/src/VuFind/Controller/RecordController.php b/module/VuFind/src/VuFind/Controller/RecordController.php
index 2ec1fef4766..f2535b3f083 100644
--- a/module/VuFind/src/VuFind/Controller/RecordController.php
+++ b/module/VuFind/src/VuFind/Controller/RecordController.php
@@ -97,7 +97,7 @@ class RecordController extends AbstractRecord
     public function holdAction()
     {
         $driver = $this->loadRecord();
-        
+
         // If we're not supposed to be here, give up now!
         $catalog = $this->getILS();
         $checkHolds = $catalog->checkFunction("Holds", $driver->getUniqueID());
@@ -126,18 +126,28 @@ class RecordController extends AbstractRecord
 
         // Send various values to the view so we can build the form:
         $pickup = $catalog->getPickUpLocations($patron, $gatheredDetails);
+        $requestGroups = $catalog->checkCapability('getRequestGroups')
+            ? $catalog->getRequestGroups($driver->getUniqueID(), $patron)
+            : array();
         $extraHoldFields = isset($checkHolds['extraHoldFields'])
             ? explode(":", $checkHolds['extraHoldFields']) : array();
 
         // Process form submissions if necessary:
         if (!is_null($this->params()->fromPost('placeHold'))) {
-            // If the form contained a pickup location, make sure that
-            // the value has not been tampered with:
-            if (!$this->holds()->validatePickUpInput(
+            // If the form contained a pickup location or request group, make sure
+            // they are valid:
+            if (!$this->holds()->validateRequestGroupInput(
+                    $gatheredDetails['requestGroupId'],
+                    $extraHoldFields,
+                    $requestGroups)
+            ) {
+                $this->flashMessenger()->setNamespace('error')
+                    ->addMessage('hold_invalid_request_group');
+            } elseif (!$this->holds()->validatePickUpInput(
                 $gatheredDetails['pickUpLocation'], $extraHoldFields, $pickup
             )) {
                 $this->flashMessenger()->setNamespace('error')
-                    ->addMessage('error_inconsistent_parameters');
+                    ->addMessage('hold_invalid_pickup');
             } else {
                 // If we made it this far, we're ready to place the hold;
                 // if successful, we will redirect and can stop here.
@@ -184,6 +194,18 @@ class RecordController extends AbstractRecord
         } catch (\Exception $e) {
             $defaultPickup = false;
         }
+        try {
+            $defaultRequestGroup = empty($requestGroups)
+                ? false
+                : $catalog->getDefaultRequestGroup($patron, $gatheredDetails);
+        } catch (\Exception $e) {
+            $defaultRequestGroup = false;
+        }
+
+        $requestGroupNeeded = in_array('requestGroup', $extraHoldFields)
+            && !empty($requestGroups)
+            && (empty($gatheredDetails['level'])
+                || $gatheredDetails['level'] != 'copy');
 
         return $this->createViewModel(
             array(
@@ -192,7 +214,10 @@ class RecordController extends AbstractRecord
                 'defaultPickup' => $defaultPickup,
                 'homeLibrary' => $this->getUser()->home_library,
                 'extraHoldFields' => $extraHoldFields,
-                'defaultRequiredDate' => $defaultRequired
+                'defaultRequiredDate' => $defaultRequired,
+                'requestGroups' => $requestGroups,
+                'defaultRequestGroup' => $defaultRequestGroup,
+                'requestGroupNeeded' => $requestGroupNeeded
             )
         );
     }
@@ -311,11 +336,11 @@ class RecordController extends AbstractRecord
     public function illRequestAction()
     {
         $driver = $this->loadRecord();
-        
+
         // If we're not supposed to be here, give up now!
         $catalog = $this->getILS();
         $checkRequests = $catalog->checkFunction(
-            'ILLRequests', 
+            'ILLRequests',
             $driver->getUniqueID()
         );
         if (!$checkRequests) {
@@ -344,7 +369,7 @@ class RecordController extends AbstractRecord
         }
 
         // Send various values to the view so we can build the form:
-        
+
         $extraFields = isset($checkRequests['extraFields'])
             ? explode(":", $checkRequests['extraFields']) : array();
 
@@ -396,10 +421,10 @@ class RecordController extends AbstractRecord
         );
 
         // Get pickup locations. Note that these are independent of pickup library,
-        // and library specific locations must be retrieved when a library is 
+        // and library specific locations must be retrieved when a library is
         // selected.
         $pickupLocations = $catalog->getPickUpLocations($patron, $gatheredDetails);
-        
+
         return $this->createViewModel(
             array(
                 'gatheredDetails' => $gatheredDetails,
diff --git a/module/VuFind/src/VuFind/ILS/Driver/Demo.php b/module/VuFind/src/VuFind/ILS/Driver/Demo.php
index d93db3aa477..17960d69253 100644
--- a/module/VuFind/src/VuFind/ILS/Driver/Demo.php
+++ b/module/VuFind/src/VuFind/ILS/Driver/Demo.php
@@ -90,7 +90,7 @@ class Demo extends AbstractBase
      * @var bool
      */
     protected $ILLRequests = true;
-    
+
     /**
      * Date converter object
      *
@@ -132,7 +132,7 @@ class Demo extends AbstractBase
         if (isset($this->config['Catalog']['ILLRequests'])) {
             $this->ILLRequests = $this->config['Catalog']['ILLRequests'];
         }
-        
+
         // Establish a namespace in the session for persisting fake data (to save
         // on Solr hits):
         $this->session = new SessionContainer('DemoDriver');
@@ -256,10 +256,10 @@ class Demo extends AbstractBase
 
     /**
      * Generate a list of holds, storage retrieval requests or ILL requests.
-     * 
+     *
      * @param string $requestType Request type (Holds, StorageRetrievalRequests or
      * ILLRequests)
-     * 
+     *
      * @return ArrayObject List of requests
      */
     protected function createRequestList($requestType)
@@ -272,6 +272,8 @@ class Demo extends AbstractBase
         // loop.
         $this->prepSolr();
 
+        $requestGroups = $this->getRequestGroups(null, null);
+
         $list = new ArrayObject();
         for ($i = 0; $i < $items; $i++) {
             $location = $this->getFakeLoc(false);
@@ -283,7 +285,7 @@ class Demo extends AbstractBase
                 "item_id" => $i,
                 "reqnum" => $i
             );
-            
+
             if ($i == 2 || rand()%5 == 1) {
                 // Mimic an ILL request
                 $currentItem["id"] = "ill_request_$i";
@@ -291,7 +293,7 @@ class Demo extends AbstractBase
                 $currentItem['institution_id'] = 'ill_institution';
                 $currentItem['institution_name'] = 'ILL Library';
                 $currentItem['institution_dbkey'] = 'ill_institution';
-            } else {    
+            } else {
                 if ($this->idsInMyResearch) {
                     $currentItem['id'] = $this->getRandomBibId();
                 } else {
@@ -306,31 +308,33 @@ class Demo extends AbstractBase
                 } else {
                     $currentItem['available'] = true;
                 }
+                $pos = rand(0, count($requestGroups) - 1);
+                $currentItem['requestGroup'] = $requestGroups[$pos]['name'];
             } else {
                 $status = rand()%5;
                 $currentItem['available'] = $status == 1;
                 $currentItem['canceled'] = $status == 2;
                 $currentItem['processed'] = ($status == 1 || rand(1, 3) == 3)
-                    ? date("j-M-y") 
+                    ? date("j-M-y")
                     : '';
                 if ($requestType == 'ILLRequests') {
                     $transit = rand()%2;
-                    if (!$currentItem['available'] 
+                    if (!$currentItem['available']
                         && !$currentItem['canceled']
                         && $transit == 1
                     ) {
-                        $currentItem['in_transit'] = $location;  
+                        $currentItem['in_transit'] = $location;
                     } else {
                         $currentItem['in_transit'] = false;
                     }
                 }
             }
-            
+
             $list->append($currentItem);
         }
-        return $list;        
+        return $list;
     }
-    
+
     /**
      * Get Status
      *
@@ -664,7 +668,7 @@ class Demo extends AbstractBase
         }
         return $this->session->storageRetrievalRequests;
     }
-    
+
     /**
      * Get Patron ILL Requests
      *
@@ -683,7 +687,7 @@ class Demo extends AbstractBase
         }
         return $this->session->ILLRequests;
     }
-    
+
     /**
      * Get Patron Transactions
      *
@@ -730,7 +734,7 @@ class Demo extends AbstractBase
 
                 // Renewal limit
                 $renewLimit = $renew + rand()%3;
-                
+
                 // Pending requests : 0,0,0,0,0,1,2,3,4,5
                 $req = rand()%10 - 5;
                 if ($req < 0) {
@@ -738,7 +742,7 @@ class Demo extends AbstractBase
                 }
 
                 if ($i == 2 || rand()%5 == 1) {
-                    // Mimic an ILL loan    
+                    // Mimic an ILL loan
                     $transList[] = array(
                         'duedate' => $due_date,
                         'dueStatus' => $dueStatus,
@@ -850,6 +854,55 @@ class Demo extends AbstractBase
         return $locations[0]['locationID'];
     }
 
+    /**
+     * Get Default Request Group
+     *
+     * Returns the default request group
+     *
+     * @param array $patron      Patron information returned by the patronLogin
+     * method.
+     * @param array $holdDetails Optional array, only passed in when getting a list
+     * in the context of placing a hold; contains most of the same values passed to
+     * placeHold, minus the patron data.  May be used to limit the request group options
+     * or may be ignored.
+     *
+     * @return false|string      The default request group for the patron.
+     * @SuppressWarnings(PHPMD.UnusedFormalParameter)
+     */
+    public function getDefaultRequestGroup($patron = false, $holdDetails = null)
+    {
+        if (rand(0, 1) == 1) {
+            return false;
+        }
+        $requestGroups = $this->getRequestGroups(0, 0);
+        return $requestGroups[0]['id'];
+    }
+
+    /**
+     * Get request groups
+     *
+     * @param integer $bibId  BIB ID
+     * @param array   $patron Patron information returned by the patronLogin
+     * method.
+     *
+     * @return array  False if request groups not in use or an array of
+     * associative arrays with id and name keys
+     * @SuppressWarnings(PHPMD.UnusedFormalParameter)
+     */
+    public function getRequestGroups($bibId = null, $patron = null)
+    {
+        return array(
+            array(
+                'id' => 1,
+                'name' => 'Main Library'
+            ),
+            array(
+                'id' => 2,
+                'name' => 'Branch Library'
+            )
+        );
+    }
+
     /**
      * Get Funds
      *
@@ -1182,7 +1235,7 @@ class Demo extends AbstractBase
     {
         return $checkOutDetails['item_id'];
     }
-    
+
     /**
      * Check if hold or recall available
      *
@@ -1202,7 +1255,7 @@ class Demo extends AbstractBase
         }
         return true;
     }
-    
+
     /**
      * Place Hold
      *
@@ -1256,6 +1309,13 @@ class Demo extends AbstractBase
             );
         }
 
+        $requestGroup = '';
+        foreach ($this->getRequestGroups(null, null) as $group) {
+            if ($group['id'] == $holdDetails['requestGroupId']) {
+                $requestGroup = $group['name'];
+                break;
+            }
+        }
         $this->session->holds->append(
             array(
                 "id"       => $holdDetails['id'],
@@ -1265,7 +1325,8 @@ class Demo extends AbstractBase
                 "reqnum"   => sprintf("%06d", $nextId),
                 "item_id" => $nextId,
                 "volume" => '',
-                "processed" => ''
+                "processed" => '',
+                "requestGroup" => $requestGroup
             )
         );
 
@@ -1291,7 +1352,7 @@ class Demo extends AbstractBase
         }
         return true;
     }
-    
+
     /**
      * Place a Storage Retrieval Request
      *
@@ -1326,9 +1387,9 @@ class Demo extends AbstractBase
         }
         $lastRequest = count($this->session->storageRetrievalRequests) - 1;
         $nextId = $lastRequest >= 0
-            ? $this->session->storageRetrievalRequests[$lastRequest]['item_id'] + 1 
+            ? $this->session->storageRetrievalRequests[$lastRequest]['item_id'] + 1
             : 0;
-        
+
         // Figure out appropriate expiration date:
         if (!isset($details['requiredBy'])
             || empty($details['requiredBy'])
@@ -1353,7 +1414,7 @@ class Demo extends AbstractBase
                 'sysMessage' => 'storage_retrieval_request_date_past'
             );
         }
-        
+
         $this->session->storageRetrievalRequests->append(
             array(
                 "id"       => $details['id'],
@@ -1388,7 +1449,7 @@ class Demo extends AbstractBase
         }
         return true;
     }
-    
+
     /**
      * Place ILL Request
      *
@@ -1423,9 +1484,9 @@ class Demo extends AbstractBase
         }
         $lastRequest = count($this->session->ILLRequests) - 1;
         $nextId = $lastRequest >= 0
-            ? $this->session->ILLRequests[$lastRequest]['item_id'] + 1 
+            ? $this->session->ILLRequests[$lastRequest]['item_id'] + 1
             : 0;
-        
+
         // Figure out appropriate expiration date:
         if (!isset($details['requiredBy'])
             || empty($details['requiredBy'])
@@ -1450,7 +1511,7 @@ class Demo extends AbstractBase
                 'sysMessage' => 'ill_request_date_past'
             );
         }
-        
+
         // Verify pickup library and location
         $pickupLocation = '';
         $pickupLocations = $this->getILLPickupLocations(
@@ -1470,7 +1531,7 @@ class Demo extends AbstractBase
                 'sysMessage' => 'ill_request_place_fail_missing'
             );
         }
-                
+
         $this->session->ILLRequests->append(
             array(
                 "id"       => $details['id'],
@@ -1485,7 +1546,7 @@ class Demo extends AbstractBase
 
         return array('success' => true);
     }
-    
+
     /**
      * Get ILL Pickup Libraries
      *
@@ -1494,7 +1555,7 @@ class Demo extends AbstractBase
      * @param string $id     Record ID
      * @param array  $patron Patron
      *
-     * @return bool|array False if request not allowed, or an array of associative 
+     * @return bool|array False if request not allowed, or an array of associative
      * arrays with libraries.
      * @SuppressWarnings(PHPMD.UnusedFormalParameter)
      */
@@ -1503,34 +1564,34 @@ class Demo extends AbstractBase
         if (!$this->ILLRequests) {
             return false;
         }
-        
+
         $details = array(
             array(
                 'id' => 1,
                 'name' => 'Main Library',
                 'isDefault' => true
-            ),                
+            ),
             array(
                 'id' => 2,
                 'name' => 'Branch Library',
                 'isDefault' => false
-            )                
+            )
         );
-        
+
         return $details;
     }
-    
+
     /**
      * Get ILL Pickup Locations
-     * 
-     * This is responsible for getting a list of possible pickup locations for a 
+     *
+     * This is responsible for getting a list of possible pickup locations for a
      * library
      *
      * @param string $id        Record ID
      * @param string $pickupLib Pickup library ID
      * @param array  $patron    Patron
      *
-     * @return boo|array False if request not allowed, or an array of  
+     * @return boo|array False if request not allowed, or an array of
      * locations.
      * @SuppressWarnings(PHPMD.UnusedFormalParameter)
      */
@@ -1543,7 +1604,7 @@ class Demo extends AbstractBase
                     'id' => 1,
                     'name' => 'Circulation Desk',
                     'isDefault' => true
-                ),                
+                ),
                 array(
                     'id' => 2,
                     'name' => 'Reference Desk',
@@ -1564,7 +1625,7 @@ class Demo extends AbstractBase
                 )
             );
         }
-        return array();                
+        return array();
     }
 
     /**
@@ -1624,7 +1685,7 @@ class Demo extends AbstractBase
     {
         return $details['reqnum'];
     }
-    
+
     /**
      * Public Function which specifies renew, hold and cancel settings.
      *
@@ -1637,7 +1698,8 @@ class Demo extends AbstractBase
         if ($function == 'Holds') {
             return array(
                 'HMACKeys' => 'id',
-                'extraHoldFields' => 'comments:pickUpLocation:requiredByDate',
+                'extraHoldFields' =>
+                    'comments:requestGroup:pickUpLocation:requiredByDate',
                 'defaultRequiredDate' => 'driver:0:2:0',
             );
         }
@@ -1655,7 +1717,7 @@ class Demo extends AbstractBase
             return array(
                 'enabled' => true,
                 'HMACKeys' => 'number',
-                'extraFields' => 
+                'extraFields' =>
                     'comments:pickUpLibrary:pickUpLibraryLocation:requiredByDate',
                 'defaultRequiredDate' => '0:1:0',
                 'helpText' => 'This is an ILL request help text'
diff --git a/module/VuFind/src/VuFind/ILS/Driver/Voyager.php b/module/VuFind/src/VuFind/ILS/Driver/Voyager.php
index 31ce5176c32..97649bf5d78 100644
--- a/module/VuFind/src/VuFind/ILS/Driver/Voyager.php
+++ b/module/VuFind/src/VuFind/ILS/Driver/Voyager.php
@@ -1155,7 +1155,7 @@ class Voyager extends AbstractBase
         } else {
             $sql .= "lower(PATRON.{$login_field}) = :login";
         }
-        
+
         try {
             $bindLogin = strtolower(utf8_decode($login));
             $bindBarcode = strtolower(utf8_decode($barcode));
@@ -1480,7 +1480,8 @@ class Voyager extends AbstractBase
             "MFHD_ITEM.ITEM_ENUM",
             "MFHD_ITEM.YEAR",
             "BIB_TEXT.TITLE_BRIEF",
-            "BIB_TEXT.TITLE"
+            "BIB_TEXT.TITLE",
+            "REQUEST_GROUP.GROUP_NAME as REQUEST_GROUP_NAME"
         );
 
         // From
@@ -1488,7 +1489,9 @@ class Voyager extends AbstractBase
             $this->dbName.".HOLD_RECALL",
             $this->dbName.".HOLD_RECALL_ITEMS",
             $this->dbName.".MFHD_ITEM",
-            $this->dbName.".BIB_TEXT"
+            $this->dbName.".BIB_TEXT",
+            $this->dbName.".VOYAGER_DATABASES",
+            $this->dbName.".REQUEST_GROUP"
         );
 
         // Where
@@ -1498,7 +1501,10 @@ class Voyager extends AbstractBase
             "HOLD_RECALL_ITEMS.ITEM_ID = MFHD_ITEM.ITEM_ID(+)",
             "(HOLD_RECALL_ITEMS.HOLD_RECALL_STATUS IS NULL OR " .
             "HOLD_RECALL_ITEMS.HOLD_RECALL_STATUS < 3)",
-            "BIB_TEXT.BIB_ID = HOLD_RECALL.BIB_ID"
+            "BIB_TEXT.BIB_ID = HOLD_RECALL.BIB_ID",
+            "(HOLD_RECALL.HOLDING_DB_ID IS NULL OR (HOLD_RECALL.HOLDING_DB_ID = " .
+            "VOYAGER_DATABASES.DB_ID AND VOYAGER_DATABASES.DB_CODE = 'LOCAL'))",
+            "HOLD_RECALL.REQUEST_GROUP_ID = REQUEST_GROUP.GROUP_ID(+)"
         );
 
         // Bind
@@ -1546,6 +1552,7 @@ class Voyager extends AbstractBase
             'id' => $sqlRow['BIB_ID'],
             'type' => $sqlRow['HOLD_RECALL_TYPE'],
             'location' => $sqlRow['PICKUP_LOCATION'],
+            'requestGroup' => $sqlRow['REQUEST_GROUP_NAME'],
             'expire' => $expireDate,
             'create' => $createDate,
             'position' => $sqlRow['QUEUE_POSITION'],
diff --git a/module/VuFind/src/VuFind/ILS/Driver/VoyagerRestful.php b/module/VuFind/src/VuFind/ILS/Driver/VoyagerRestful.php
index 791a24c8e84..cdbf7f8b935 100644
--- a/module/VuFind/src/VuFind/ILS/Driver/VoyagerRestful.php
+++ b/module/VuFind/src/VuFind/ILS/Driver/VoyagerRestful.php
@@ -147,6 +147,42 @@ class VoyagerRestful extends Voyager implements \VuFindHttp\HttpServiceAwareInte
      */
     protected $cookies = false;
 
+    /**
+     * Whether request groups are enabled
+     *
+     * @var bool
+     */
+    protected $requestGroupsEnabled;
+
+    /**
+     * Default request group
+     *
+     * @var bool|string
+     */
+    protected $defaultRequestGroup;
+
+    /**
+     * Whether pickup location must belong to the request group
+     *
+     * @var bool
+     */
+    protected $pickupLocationsInRequestGroup;
+
+    /**
+     * Whether to check that items exist when placing a hold or recall request
+     *
+     * @var bool
+     */
+    protected $checkItemsExist;
+
+    /**
+     * Whether to check that items are not available when placing a hold or recall
+     * request
+     *
+     * @var bool
+     */
+    protected $checkItemsNotAvailable;
+
     /**
      * Constructor
      *
@@ -210,6 +246,30 @@ class VoyagerRestful extends Voyager implements \VuFindHttp\HttpServiceAwareInte
             = isset($this->config['CallSlips']['callSlipCheckLimit'])
             ? $this->config['CallSlips']['callSlipCheckLimit'] : "15";
 
+        $this->requestGroupsEnabled
+            = isset($this->config['Holds']['extraHoldFields'])
+            && in_array(
+                'requestGroup',
+                explode(':', $this->config['Holds']['extraHoldFields'])
+            );
+        $this->defaultRequestGroup
+            = isset($this->config['Holds']['defaultRequestGroup'])
+            ? $this->config['Holds']['defaultRequestGroup'] : false;
+        if ($this->defaultRequestGroup === 'user-selected') {
+            $this->defaultRequestGroup = false;
+        }
+        $this->pickupLocationsInRequestGroup
+            = isset($this->config['Holds']['pickupLocationsInRequestGroup'])
+            ? $this->config['Holds']['pickupLocationsInRequestGroup'] : false;
+
+        $this->checkItemsExist
+            = isset($this->config['Holds']['checkItemsExist'])
+            ? $this->config['Holds']['checkItemsExist'] : false;
+        $this->checkItemsNotAvailable
+            = isset($this->config['Holds']['checkItemsNotAvailable'])
+            ? $this->config['Holds']['checkItemsNotAvailable'] : false;
+
+
         // Establish a namespace in the session for persisting cached data
         $this->session = new SessionContainer('VoyagerRestful_' . $this->dbName);
     }
@@ -229,6 +289,19 @@ class VoyagerRestful extends Voyager implements \VuFindHttp\HttpServiceAwareInte
         } else {
             $functionConfig = false;
         }
+
+        // Make sure request group selection is displayed if request groups are
+        // enabled
+        if ($function == 'Holds' && $this->requestGroupsEnabled
+            && strpos($functionConfig['extraHoldFields'], 'requestGroup') === false
+        ) {
+            if (!empty($functionConfig['extraHoldFields'])) {
+                $functionConfig['extraHoldFields'] .= ':requestGroup';
+            } else {
+                $functionConfig['extraHoldFields'] = 'requestGroup';
+            }
+        }
+
         return $functionConfig;
     }
 
@@ -513,6 +586,14 @@ class VoyagerRestful extends Voyager implements \VuFindHttp\HttpServiceAwareInte
                 return false;
             }
         }
+
+        if ('title' == $level && $this->requestGroupsEnabled) {
+            // Verify that there are valid request groups
+            if (!$this->getRequestGroups($id, $patron)) {
+                return false;
+            }
+        }
+
         return true;
     }
 
@@ -610,6 +691,7 @@ class VoyagerRestful extends Voyager implements \VuFindHttp\HttpServiceAwareInte
      */
     public function getPickUpLocations($patron = false, $holdDetails = null)
     {
+        $params = array();
         if ($this->ws_pickUpLocations) {
             foreach ($this->ws_pickUpLocations as $code => $library) {
                 $pickResponse[] = array(
@@ -618,16 +700,32 @@ class VoyagerRestful extends Voyager implements \VuFindHttp\HttpServiceAwareInte
                 );
             }
         } else {
-            $sql = "SELECT CIRC_POLICY_LOCS.LOCATION_ID as location_id, " .
-                "NVL(LOCATION.LOCATION_DISPLAY_NAME, LOCATION.LOCATION_NAME) " .
-                "as location_name from " .
-                $this->dbName . ".CIRC_POLICY_LOCS, $this->dbName.LOCATION " .
-                "where CIRC_POLICY_LOCS.PICKUP_LOCATION = 'Y' ".
-                "and CIRC_POLICY_LOCS.LOCATION_ID = LOCATION.LOCATION_ID";
+            if ($this->requestGroupsEnabled
+                && $this->pickupLocationsInRequestGroup
+                && !empty($holdDetails['requestGroupId'])
+            ) {
+                $sql = "SELECT CIRC_POLICY_LOCS.LOCATION_ID as location_id, " .
+                    "NVL(LOCATION.LOCATION_DISPLAY_NAME, LOCATION.LOCATION_NAME) " .
+                    "as location_name from " .
+                    $this->dbName . ".CIRC_POLICY_LOCS, $this->dbName.LOCATION, " .
+                    "$this->dbName.REQUEST_GROUP_LOCATION rgl " .
+                    "where CIRC_POLICY_LOCS.PICKUP_LOCATION = 'Y' ".
+                    "and CIRC_POLICY_LOCS.LOCATION_ID = LOCATION.LOCATION_ID " .
+                    "and rgl.GROUP_ID=:requestGroupId " .
+                    "and rgl.LOCATION_ID = LOCATION.LOCATION_ID";
+                $params['requestGroupId'] = $holdDetails['requestGroupId'];
+            } else {
+                $sql = "SELECT CIRC_POLICY_LOCS.LOCATION_ID as location_id, " .
+                    "NVL(LOCATION.LOCATION_DISPLAY_NAME, LOCATION.LOCATION_NAME) " .
+                    "as location_name from " .
+                    $this->dbName . ".CIRC_POLICY_LOCS, $this->dbName.LOCATION " .
+                    "where CIRC_POLICY_LOCS.PICKUP_LOCATION = 'Y' ".
+                    "and CIRC_POLICY_LOCS.LOCATION_ID = LOCATION.LOCATION_ID";
+            }
 
             try {
                 $sqlStmt = $this->db->prepare($sql);
-                $sqlStmt->execute();
+                $sqlStmt->execute($params);
             } catch (PDOException $e) {
                 throw new ILSException($e->getMessage());
             }
@@ -655,7 +753,8 @@ class VoyagerRestful extends Voyager implements \VuFindHttp\HttpServiceAwareInte
      * placeHold, minus the patron data.  May be used to limit the pickup options
      * or may be ignored.
      *
-     * @return string       The default pickup location for the patron.
+     * @return false|string      The default pickup location for the patron or false
+     * if the user has to choose.
      * @SuppressWarnings(PHPMD.UnusedFormalParameter)
      */
     public function getDefaultPickUpLocation($patron = false, $holdDetails = null)
@@ -663,7 +762,245 @@ class VoyagerRestful extends Voyager implements \VuFindHttp\HttpServiceAwareInte
         return $this->defaultPickUpLocation;
     }
 
-     /**
+    /**
+     * Get Default Request Group
+     *
+     * Returns the default request group set in VoyagerRestful.ini
+     *
+     * @param array $patron      Patron information returned by the patronLogin
+     * method.
+     * @param array $holdDetails Optional array, only passed in when getting a list
+     * in the context of placing a hold; contains most of the same values passed to
+     * placeHold, minus the patron data.  May be used to limit the request group
+     * options or may be ignored.
+     *
+     * @return false|string      The default request group for the patron or false if
+     * the user has to choose.
+     * @SuppressWarnings(PHPMD.UnusedFormalParameter)
+     */
+    public function getDefaultRequestGroup($patron = false, $holdDetails = null)
+    {
+        return $this->defaultRequestGroup;
+    }
+
+    /**
+     * Sort function for sorting request groups
+     *
+     * @param array $a Request group
+     * @param array $b Request group
+     *
+     * @return number
+     */
+    protected function requestGroupSortFunction($a, $b)
+    {
+        $requestGroupOrder = isset($this->config['Holds']['requestGroupOrder'])
+            ? explode(':', $this->config['Holds']['requestGroupOrder'])
+            : array();
+        $requestGroupOrder = array_flip($requestGroupOrder);
+        if (isset($requestGroupOrder[$a['id']])) {
+            if (isset($requestGroupOrder[$b['id']])) {
+                return $requestGroupOrder[$a['id']] - $requestGroupOrder[$b['id']];
+            }
+            return -1;
+        }
+        if (isset($requestGroupOrder[$b['id']])) {
+            return 1;
+        }
+        return strcasecmp($a['name'], $b['name']);
+    }
+
+    /**
+     * Get request groups
+     *
+     * @param integer $bibId  BIB ID
+     * @param array   $patron Patron information returned by the patronLogin
+     * method.
+     *
+     * @return array  False if request groups not in use or an array of
+     * associative arrays with id and name keys
+     */
+    public function getRequestGroups($bibId, $patron)
+    {
+        if (!$this->requestGroupsEnabled) {
+            return false;
+        }
+
+        if ($this->checkItemsExist) {
+            // First get hold information for the list of items Voyager
+            // thinks are holdable
+            $request = $this->determineHoldType($patron['id'], $bibId);
+            if ($request != 'hold' && $result != 'recall') {
+                return false;
+            }
+
+            $hierarchy = array();
+
+            // Build Hierarchy
+            $hierarchy['record'] = $bibId;
+            $hierarchy[$request] = false;
+
+            // Add Required Params
+            $params = array(
+                "patron" => $patron['id'],
+                "patron_homedb" => $this->ws_patronHomeUbId,
+                "view" => "full"
+            );
+
+            $results = $this->makeRequest($hierarchy, $params, "GET", false);
+
+            if ($results === false) {
+                throw new ILSException('Could not fetch hold information');
+            }
+
+            $items = array();
+            foreach ($results->hold as $hold) {
+                foreach ($hold->items->item as $item) {
+                    $items[(string)$item->item_id] = 1;
+                }
+            }
+        }
+
+        // Find request groups (with items if item check is enabled)
+        if ($this->checkItemsExist) {
+            $sqlExpressions = array(
+                'rg.GROUP_ID',
+                'rg.GROUP_NAME',
+                'bi.ITEM_ID'
+            );
+
+            $sqlFrom = array(
+                "$this->dbName.BIB_ITEM bi",
+                "$this->dbName.MFHD_ITEM mi",
+                "$this->dbName.MFHD_MASTER mm",
+                "$this->dbName.REQUEST_GROUP rg",
+                "$this->dbName.REQUEST_GROUP_LOCATION rgl",
+            );
+
+            $sqlWhere = array(
+                'bi.BIB_ID=:bibId',
+                'mi.ITEM_ID=bi.ITEM_ID',
+                'mm.MFHD_ID=mi.MFHD_ID',
+                'rgl.LOCATION_ID=mm.LOCATION_ID',
+                'rg.GROUP_ID=rgl.GROUP_ID'
+            );
+
+            $sqlBind = array(
+                'bibId' => $bibId
+            );
+        } else {
+            $sqlExpressions = array(
+                'rg.GROUP_ID',
+                'rg.GROUP_NAME',
+            );
+
+            $sqlFrom = array(
+                "$this->dbName.REQUEST_GROUP rg",
+                "$this->dbName.REQUEST_GROUP_LOCATION rgl"
+            );
+
+            $sqlWhere = array(
+                'rg.GROUP_ID=rgl.GROUP_ID'
+            );
+
+            $sqlBind = array(
+            );
+
+            if ($this->pickupLocationsInRequestGroup) {
+                // Limit to request groups that have valid pickup locations
+                $sqlFrom[] = "$this->dbName.REQUEST_GROUP_LOCATION rgl";
+                $sqlFrom[] = "$this->dbName.CIRC_POLICY_LOCS cpl";
+
+                $sqlWhere[] = "rgl.GROUP_ID=rg.GROUP_ID";
+                $sqlWhere[] = "cpl.LOCATION_ID=rgl.LOCATION_ID";
+                $sqlWhere[] = "cpl.PICKUP_LOCATION='Y'";
+            }
+        }
+
+        if ($this->checkItemsNotAvailable) {
+
+            // Build inner query first
+            $subExpressions = array(
+                'sub_rgl.GROUP_ID',
+                'sub_i.ITEM_ID',
+                'max(sub_ist.ITEM_STATUS) as STATUS'
+            );
+
+            $subFrom = array(
+                "$this->dbName.ITEM_STATUS sub_ist",
+                "$this->dbName.BIB_ITEM sub_bi",
+                "$this->dbName.ITEM sub_i",
+                "$this->dbName.REQUEST_GROUP_LOCATION sub_rgl",
+                "$this->dbName.MFHD_ITEM sub_mi",
+                "$this->dbName.MFHD_MASTER sub_mm"
+            );
+
+            $subWhere = array(
+                'sub_bi.BIB_ID=:subBibId',
+                'sub_i.ITEM_ID=sub_bi.ITEM_ID',
+                'sub_ist.ITEM_ID=sub_i.ITEM_ID',
+                'sub_mi.ITEM_ID=sub_i.ITEM_ID',
+                'sub_mm.MFHD_ID=sub_mi.MFHD_ID',
+                'sub_rgl.LOCATION_ID=sub_mm.LOCATION_ID'
+            );
+
+            $subGroup = array(
+                'sub_rgl.GROUP_ID',
+                'sub_i.ITEM_ID'
+            );
+
+            $sqlBind['subBibId'] = $bibId;
+
+            $subArray = array(
+                'expressions' => $subExpressions,
+                'from' => $subFrom,
+                'where' => $subWhere,
+                'group' => $subGroup,
+                'bind' => array()
+            );
+
+            $subSql = $this->buildSqlFromArray($subArray);
+
+            $sqlWhere[] = "not exists (select status.GROUP_ID from " .
+                "({$subSql['string']}) status where status.status=1 " .
+                "and status.GROUP_ID = rgl.GROUP_ID)";
+        }
+
+        $sqlArray = array(
+            'expressions' => $sqlExpressions,
+            'from' => $sqlFrom,
+            'where' => $sqlWhere,
+            'bind' => $sqlBind
+        );
+
+        $sql = $this->buildSqlFromArray($sqlArray);
+
+        try {
+            $this->debugSQL(__FUNCTION__, $sql['string'], $sql['bind']);
+            $sqlStmt = $this->db->prepare($sql['string']);
+            $sqlStmt->execute($sql['bind']);
+        } catch (PDOException $e) {
+            return new PEAR_Error($e->getMessage());
+        }
+
+        $groups = array();
+        while ($row = $sqlStmt->fetch(PDO::FETCH_ASSOC)) {
+            if (!$this->checkItemsExist || isset($items[$row['ITEM_ID']])) {
+                $groups[$row['GROUP_ID']] = utf8_encode($row['GROUP_NAME']);
+            }
+        }
+
+        $results = array();
+        foreach ($groups as $groupId => $groupName) {
+            $results[] = array('id' => $groupId, 'name' => $groupName);
+        }
+
+        // Sort request groups
+        usort($results, array($this, 'requestGroupSortFunction'));
+
+        return $results;
+    }
+
+    /**
      * Make Request
      *
      * Makes a request to the Voyager Restful API
@@ -1080,88 +1417,93 @@ EOT;
     /**
      * Make Item Requests
      *
-     * Places a Hold or Recall for a particular item
+     * Places a Hold or Recall for a particular title or item
      *
-     * @param string $patronId    The user's Patron ID
-     * @param string $request     The request type (hold or recall)
-     * @param string $level       The request level (title or copy)
-     * @param array  $requestData An array of data to submit with the request,
-     * may include comment, lastInterestDate and pickUpLocation
-     * @param string $bibId       An item's Bib ID
-     * @param string $itemId      An item's Item ID (optional)
+     * @param string $patron      Patron information from patronLogin
+     * @param string $type        The request type (hold or recall)
+     * @param array  $requestData An array of parameters to submit with the request
      *
      * @return array             An array of data from the attempted request
      * including success, status and a System Message (if available)
      */
-    protected function makeItemRequests($patronId, $request, $level,
-        $requestData, $bibId, $itemId = false
+    protected function makeItemRequests($patron, $type, $requestData
     ) {
-        $response = array('success' => false, 'status' =>"hold_error_fail");
-
-        if (!empty($bibId) && !empty($patronId) && !empty($requestData)
-            && !empty($request)
+        if (empty($patron) || empty($requestData) || empty($requestData['bibId'])
+            || empty($type)
         ) {
-            $hierarchy = array();
-
-            // Build Hierarchy
-            $hierarchy['record'] = $bibId;
-
-            if ($itemId) {
-                $hierarchy['items'] = $itemId;
-            }
-
-            $hierarchy[$request] = false;
-
-            // Add Required Params
-            $params = array(
-                "patron" => $patronId,
-                "patron_homedb" => $this->ws_patronHomeUbId,
-                "view" => "full"
-            );
-
-            if ("title" == $level) {
-                $xmlParameter = ("recall" == $request)
-                    ? "recall-title-parameters" : "hold-title-parameters";
-                $request = $request . "-title";
-            } else {
-                $xmlParameter = ("recall" == $request)
-                    ? "recall-parameters" : "hold-request-parameters";
-            }
+            return array('success' => false, 'status' =>"hold_error_fail");
+        }
 
-            $xml[$xmlParameter] = array(
-                "pickup-location" => $requestData['pickupLocation'],
-                "last-interest-date" => $requestData['lastInterestDate'],
-                "comment" => $requestData['comment'],
-                "dbkey" => $this->ws_dbKey
-            );
+        // Build request
+        $patronId = htmlspecialchars($patron['id'], ENT_COMPAT, 'UTF-8');
+        $lastname = htmlspecialchars($patron['lastname'], ENT_COMPAT, 'UTF-8');
+        $barcode = htmlspecialchars($patron['cat_username'], ENT_COMPAT, 'UTF-8');
+        $localUbId = htmlspecialchars($this->ws_patronHomeUbId, ENT_COMPAT, 'UTF-8');
+        $type = strtoupper($type);
+        $cval = 'anyCopy';
+        if (isset($requestData['itemId'])) {
+            $cval = 'thisCopy';
+        } elseif (isset($requestData['requestGroupId'])) {
+            $cval = 'anyCopyAt';
+        }
 
-            // Generate XML
-            $requestXML = $this->buildBasicXML($xml);
+        // Build request
+        $xml =  <<<EOT
+<?xml version="1.0" encoding="UTF-8"?>
+<ser:serviceParameters xmlns:ser="http://www.endinfosys.com/Voyager/serviceParameters">
+  <ser:parameters>
+    <ser:parameter key="bibDbCode">
+      <ser:value>LOCAL</ser:value>
+    </ser:parameter>
+    <ser:parameter key="requestCode">
+      <ser:value>$type</ser:value>
+    </ser:parameter>
+    <ser:parameter key="requestSiteId">
+      <ser:value>$localUbId</ser:value>
+    </ser:parameter>
+    <ser:parameter key="CVAL">
+      <ser:value>$cval</ser:value>
+    </ser:parameter>
 
-            // Get Data
-            $result = $this->makeRequest($hierarchy, $params, "PUT", $requestXML);
+EOT;
+        foreach ($requestData as $key => $value) {
+            $value = htmlspecialchars($value, ENT_COMPAT, 'UTF-8');
+            $xml .= <<<EOT
+    <ser:parameter key="$key">
+      <ser:value>$value</ser:value>
+    </ser:parameter>
 
-            if ($result) {
-                // Process
-                $result = $result->children();
-                $node = "reply-text";
-                $reply = (string)$result->$node;
+EOT;
+        }
+        $xml .= <<<EOT
+  </ser:parameters>
+  <ser:patronIdentifier lastName="$lastname" patronHomeUbId="$localUbId" patronId="$patronId">
+    <ser:authFactor type="B">$barcode</ser:authFactor>
+  </ser:patronIdentifier>
+</ser:serviceParameters>
+EOT;
 
-                $responseNode = "create-".$request;
-                $note = (isset($result->$responseNode))
-                    ? trim((string)$result->$responseNode->note) : false;
+        $response = $this->makeRequest(array('SendPatronRequestService' => false), array(), 'POST', $xml);
 
-                // Valid Response
-                if ($reply == "ok" && $note == "Your request was successful.") {
-                    $response['success'] = true;
-                    $response['status'] = "hold_success";
-                } else {
-                    // Failed
-                    $response['sysMessage'] = $note;
-                }
+        if ($response === false) {
+            return $this->holdError('hold_error_system');
+        }
+        // Process
+        $response->registerXPathNamespace('ser', 'http://www.endinfosys.com/Voyager/serviceParameters');
+        $response->registerXPathNamespace('req', 'http://www.endinfosys.com/Voyager/requests');
+        foreach ($response->xpath('//ser:message') as $message) {
+            if ($message->attributes()->type == 'success') {
+                return array(
+                    'success' => true,
+                    'status' => 'hold_request_success'
+                );
+            }
+            if ($message->attributes()->type == 'system') {
+                return $this->holdError('hold_error_system');
             }
         }
-        return $response;
+
+        return $this->holdError('hold_error_blocked');
     }
 
     /**
@@ -1241,7 +1583,7 @@ EOT;
         $bibId = $holdDetails['id'];
 
         // Request was initiated before patron was logged in -
-        //Let's determine Hold Type now
+        // Let's determine Hold Type now
         if ($type == "auto") {
             $type = $this->determineHoldType($patron['id'], $bibId, $itemId);
             if (!$type || $type == "block") {
@@ -1280,22 +1622,31 @@ EOT;
             return $this->holdError("hold_invalid_pickup");
         }
 
+        if ($this->requestGroupsEnabled && !$itemId
+            && empty($holdDetails['requestGroupId'])
+        ) {
+            return $this->holdError('hold_invalid_request_group');
+        }
+
         // Build Request Data
         $requestData = array(
-            'pickupLocation' => $pickUpLocation,
-            'lastInterestDate' => $lastInterestDate,
-            'comment' => $comment
+            'bibId' => $bibId,
+            'PICK' => $pickUpLocation,
+            'REQNNA' => $lastInterestDate,
+            'REQCOMMENTS' => $comment
         );
+        if ($level == 'copy' && $itemId) {
+            $requestData['itemId'] = $itemId;
+        } elseif (isset($holdDetails['requestGroupId'])) {
+            $requestData['requestGroupId'] = $holdDetails['requestGroupId'];
+        }
 
-        if ($this->checkItemRequests($patron['id'], $type, $bibId, $itemId)) {
-            // Attempt Request
-            $result = $this->makeItemRequests(
-                $patron['id'], $type, $level, $requestData, $bibId, $itemId
-            );
-            if ($result) {
-                return $result;
-            }
+        // Attempt Request
+        $result = $this->makeItemRequests($patron, $type, $requestData);
+        if ($result) {
+            return $result;
         }
+
         return $this->holdError("hold_error_blocked");
     }
 
@@ -2289,7 +2640,7 @@ EOT;
                 'sysMessage' => 'ill_request_place_fail_missing'
             );
         }
-        
+
         // Attempt Request
         $xml =  <<<EOT
 <?xml version="1.0" encoding="UTF-8"?>
diff --git a/themes/blueprint/css/styles.css b/themes/blueprint/css/styles.css
index c1d15a3ba3d..a0f8da98f98 100644
--- a/themes/blueprint/css/styles.css
+++ b/themes/blueprint/css/styles.css
@@ -91,7 +91,7 @@ div.dialogLoading {
     height:100%;
 }
 
-.ajax_availability, .ajax_hold_availability, .ajax_storage_retrieval_request_availability, .ajax_ill_request_availability, .ajax_ill_request_loading {
+.ajax_availability, .ajax_hold_availability, .ajax_storage_retrieval_request_availability, .ajax_ill_request_availability, .ajax_ill_request_loading, .ajax_hold_request_loading {
     background: url(../images/ajax_loading.gif) no-repeat left top;
     padding:0 .5em .5em 20px;
 }
diff --git a/themes/blueprint/js/hold.js b/themes/blueprint/js/hold.js
new file mode 100644
index 00000000000..33d785d3cbd
--- /dev/null
+++ b/themes/blueprint/js/hold.js
@@ -0,0 +1,41 @@
+function setUpHoldRequestForm(recordId) {
+  $('#requestGroupId').change(function() {
+    var $emptyOption = $("#pickUpLocation option[value='']");
+    $("#pickUpLocation option[value!='']").remove();
+    if ($('#requestGroupId').val() === '') {
+        $('#pickUpLocation').attr('disabled', 'disabled');
+        return;
+    }
+    $('#pickUpLocationLabel').addClass("ajax_hold_request_loading");
+    var params = {
+      method: 'getRequestGroupPickupLocations',
+      id: recordId,
+      requestGroupId: $('#requestGroupId').val()              
+    };
+    $.ajax({
+      data: params,
+      dataType: 'json',
+      cache: false,
+      url: path + '/AJAX/JSON',
+      success: function(response) {
+        if (response.status == 'OK') {
+          var defaultValue = $('#pickUpLocation').data('default');
+          $.each(response.data.locations, function() {
+            var option = $('<option></option>').attr('value', this.locationID).text(this.locationDisplay);
+            if (this.locationID == defaultValue || (defaultValue == '' && this.isDefault && $emptyOption.length == 0)) {
+              option.attr('selected', 'selected');
+            }
+            $('#pickUpLocation').append(option);
+          });
+        }
+        $('#pickUpLocationLabel').removeClass("ajax_hold_request_loading");
+        $('#pickUpLocation').removeAttr('disabled');
+      },
+      fail: function() {
+        $('#pickUpLocationLabel').removeClass("ajax_hold_request_loading");
+        $('#pickUpLocation').removeAttr('disabled');
+      }
+    });   
+  });
+  $('#requestGroupId').change();
+}
diff --git a/themes/blueprint/templates/myresearch/holds.phtml b/themes/blueprint/templates/myresearch/holds.phtml
index 9be0cdb6ac8..4207757b4b6 100644
--- a/themes/blueprint/templates/myresearch/holds.phtml
+++ b/themes/blueprint/templates/myresearch/holds.phtml
@@ -94,6 +94,11 @@
               <br />
             <? endif; ?>
 
+            <? if (!empty($ilsDetails['requestGroup'])): ?>
+              <strong><?=$this->transEsc('hold_request_group') ?>:</strong> <?=$this->transEsc('location_' . $ilsDetails['requestGroup'], array(), $ilsDetails['requestGroup'])?>
+              <br />
+            <? endif; ?>
+
             <? /* Depending on the ILS driver, the "location" value may be a string or an ID; figure out the best
                value to display... */ ?>
             <? $pickupDisplay = ''; ?>
diff --git a/themes/blueprint/templates/record/hold.phtml b/themes/blueprint/templates/record/hold.phtml
index 0d5ec0b35d6..90d8c0c1c32 100644
--- a/themes/blueprint/templates/record/hold.phtml
+++ b/themes/blueprint/templates/record/hold.phtml
@@ -1,4 +1,7 @@
 <?
+    // Set up hold script:
+    $this->headScript()->appendFile("hold.js");
+
     // Set page title.
     $this->headTitle($this->translate('request_place_text') . ': ' . $this->driver->getBreadcrumb());
 
@@ -26,18 +29,54 @@
       </div>
     <? endif; ?>
 
-    <? if (in_array("pickUpLocation", $this->extraHoldFields)): ?>
+    <? if ($this->requestGroupNeeded): ?>
       <div>
-      <? if (count($this->pickup) > 1): ?>
         <?
-          if (isset($this->gatheredDetails['pickUpLocation']) && $this->gatheredDetails['pickUpLocation'] !== "") {
-              $selected = $this->gatheredDetails['pickUpLocation'];
-          } elseif (isset($this->homeLibrary) && $this->homeLibrary !== "") {
-              $selected = $this->homeLibrary;
+          if (isset($this->gatheredDetails['requestGroupId']) && $this->gatheredDetails['requestGroupId'] !== "") {
+              $selected = $this->gatheredDetails['requestGroupId'];
           } else {
-              $selected = $this->defaultPickup;
+              $selected = $this->defaultRequestGroup;
           }
-        ?>
+       ?>
+        <strong><?=$this->transEsc("hold_request_group")?>:</strong>
+        <select id="requestGroupId" name="gatheredDetails[requestGroupId]">
+        <? if ($selected === false): ?>
+          <option value="" selected="selected">
+            <?=$this->transEsc('select_request_group')?>
+          </option>
+        <? endif; ?>
+        <? foreach ($this->requestGroups as $group): ?>
+          <option value="<?=$this->escapeHtml($group['id'])?>"<?=($selected == $group['id']) ? ' selected="selected"' : ''?>>
+            <?=$this->escapeHtml($group['name'])?>
+          </option>
+        <? endforeach; ?>
+        </select>
+      </div>
+    <? endif; ?>
+
+    <? if (in_array("pickUpLocation", $this->extraHoldFields)): ?>
+      <?
+        if (isset($this->gatheredDetails['pickUpLocation']) && $this->gatheredDetails['pickUpLocation'] !== "") {
+            $selected = $this->gatheredDetails['pickUpLocation'];
+        } elseif (isset($this->homeLibrary) && $this->homeLibrary !== "") {
+            $selected = $this->homeLibrary;
+        } else {
+            $selected = $this->defaultPickup;
+        }
+      ?>
+      <div>
+      <? if ($this->requestGroupNeeded): ?>
+        <span id="pickUpLocationLabel"><strong><?=$this->transEsc("pick_up_location")?>:
+          <noscript> (<?=$this->transEsc("Please enable JavaScript.")?>)</noscript>
+        </strong></span>
+        <select id="pickUpLocation" name="gatheredDetails[pickUpLocation]" data-default="<?=$this->escapeHtml($selected)?>">
+          <? if ($selected === false): ?>
+          <option value="" selected="selected">
+            <?=$this->transEsc('select_pickup_location')?>
+          </option>
+          <? endif; ?>
+        </select>
+      <? elseif (count($this->pickup) > 1): ?>
         <strong><?=$this->transEsc("pick_up_location")?>:</strong><br/>
         <select name="gatheredDetails[pickUpLocation]">
         <? if ($selected === false): ?>
@@ -62,3 +101,9 @@
   </form>
 
 </div>
+
+<script type="text/javascript">
+$(document).ready(function(){
+    setUpHoldRequestForm('<?=$this->escapeHtml($this->driver->getUniqueId()) ?>');
+});
+</script>
diff --git a/themes/bootprint/templates/myresearch/holds.phtml b/themes/bootprint/templates/myresearch/holds.phtml
index f971db65f7c..6c4d7f12614 100644
--- a/themes/bootprint/templates/myresearch/holds.phtml
+++ b/themes/bootprint/templates/myresearch/holds.phtml
@@ -104,6 +104,11 @@
             <br />
           <? endif; ?>
 
+          <? if (!empty($ilsDetails['requestGroup'])): ?>
+            <strong><?=$this->transEsc('hold_request_group') ?>:</strong> <?=$this->transEsc('location_' . $ilsDetails['requestGroup'], array(), $ilsDetails['requestGroup'])?>
+            <br />
+          <? endif; ?>
+
           <? /* Depending on the ILS driver, the "location" value may be a string or an ID; figure out the best
              value to display... */ ?>
           <? $pickupDisplay = ''; ?>
diff --git a/themes/bootstrap/js/hold.js b/themes/bootstrap/js/hold.js
new file mode 100644
index 00000000000..70a0fbdd153
--- /dev/null
+++ b/themes/bootstrap/js/hold.js
@@ -0,0 +1,41 @@
+function setUpHoldRequestForm(recordId) {
+  $('#requestGroupId').change(function() {
+    var $emptyOption = $("#pickUpLocation option[value='']");
+    $("#pickUpLocation option[value!='']").remove();
+    if ($('#requestGroupId').val() === '') {
+        $('#pickUpLocation').attr('disabled', 'disabled');
+        return;
+    }
+    $('#pickUpLocationLabel i').addClass("icon-spinner icon-spin");
+    var params = {
+      method: 'getRequestGroupPickupLocations',
+      id: recordId,
+      requestGroupId: $('#requestGroupId').val()              
+    };
+    $.ajax({
+      data: params,
+      dataType: 'json',
+      cache: false,
+      url: path + '/AJAX/JSON',
+      success: function(response) {
+        if (response.status == 'OK') {
+          var defaultValue = $('#pickUpLocation').data('default');
+          $.each(response.data.locations, function() {
+            var option = $('<option></option>').attr('value', this.locationID).text(this.locationDisplay);
+            if (this.locationID == defaultValue || (defaultValue == '' && this.isDefault && $emptyOption.length == 0)) {
+              option.attr('selected', 'selected');
+            }
+            $('#pickUpLocation').append(option);
+          });
+        }
+        $('#pickUpLocationLabel i').removeClass("icon-spinner icon-spin");
+        $('#pickUpLocation').removeAttr('disabled');
+      },
+      fail: function() {
+        $('#pickUpLocationLabel i').removeClass("icon-spinner icon-spin");
+        $('#pickUpLocation').removeAttr('disabled');
+      }
+    });   
+  });
+  $('#requestGroupId').change();
+}
diff --git a/themes/bootstrap/templates/myresearch/holds.phtml b/themes/bootstrap/templates/myresearch/holds.phtml
index c37a9d5184a..5ccc7178aa5 100644
--- a/themes/bootstrap/templates/myresearch/holds.phtml
+++ b/themes/bootstrap/templates/myresearch/holds.phtml
@@ -104,6 +104,11 @@
             <br />
           <? endif; ?>
 
+          <? if (!empty($ilsDetails['requestGroup'])): ?>
+            <strong><?=$this->transEsc('hold_request_group') ?>:</strong> <?=$this->transEsc('location_' . $ilsDetails['requestGroup'], array(), $ilsDetails['requestGroup'])?>
+            <br />
+          <? endif; ?>
+
           <? /* Depending on the ILS driver, the "location" value may be a string or an ID; figure out the best
              value to display... */ ?>
           <? $pickupDisplay = ''; ?>
diff --git a/themes/bootstrap/templates/record/hold.phtml b/themes/bootstrap/templates/record/hold.phtml
index 131362674bb..c90edbf6a74 100644
--- a/themes/bootstrap/templates/record/hold.phtml
+++ b/themes/bootstrap/templates/record/hold.phtml
@@ -11,6 +11,7 @@
 <?=$this->flashmessages()?>
 <div class="hold-form">
   <form action="" class="form-horizontal" method="post" name="placeHold">
+
     <? if (in_array("comments", $this->extraHoldFields)): ?>
       <div class="control-group">
         <label class="control-label"><?=$this->transEsc("Comments")?>:</label>
@@ -30,21 +31,69 @@
       </div>
     <? endif; ?>
 
+    <? $showRequestGroups = in_array("requestGroup", $this->extraHoldFields)
+        && (empty($this->gatheredDetails['level'])
+            || $this->gatheredDetails['level'] != 'copy');
+    ?>
+    <? if ($this->requestGroupNeeded): ?>
+      <div class="control-group">
+        <?
+          if (isset($this->gatheredDetails['requestGroupId']) && $this->gatheredDetails['requestGroupId'] !== "") {
+              $selected = $this->gatheredDetails['requestGroupId'];
+          } else {
+              $selected = $this->defaultRequestGroup;
+          }
+       ?>
+        <label class="control-label"><?=$this->transEsc("hold_request_group")?>:</label>
+        <div class="controls">
+          <select id="requestGroupId" name="gatheredDetails[requestGroupId]">
+          <? if ($selected === false): ?>
+            <option value="" selected="selected">
+              <?=$this->transEsc('select_request_group')?>
+            </option>
+          <? endif; ?>
+          <? foreach ($this->requestGroups as $group): ?>
+            <option value="<?=$this->escapeHtml($group['id'])?>"<?=($selected == $group['id']) ? ' selected="selected"' : ''?>>
+              <?=$this->escapeHtml($group['name'])?>
+            </option>
+          <? endforeach; ?>
+          </select>
+        </div>
+      </div>
+    <? endif; ?>
+
     <? if (in_array("pickUpLocation", $this->extraHoldFields)): ?>
-      <? if (count($this->pickup) > 1): ?>
+      <?
+        if (isset($this->gatheredDetails['pickUpLocation']) && $this->gatheredDetails['pickUpLocation'] !== "") {
+            $selected = $this->gatheredDetails['pickUpLocation'];
+        } elseif (isset($this->homeLibrary) && $this->homeLibrary !== "") {
+            $selected = $this->homeLibrary;
+        } else {
+            $selected = $this->defaultPickup;
+        }
+      ?>
+      <? if ($this->requestGroupNeeded): ?>
+        <div class="control-group">
+          <label id="pickUpLocationLabel" class="control-label"><i></i>&nbsp;<?=$this->transEsc("pick_up_location")?>:
+          <? if (in_array("requestGroup", $this->extraHoldFields)): ?>
+            <noscript> (<?=$this->transEsc("Please enable JavaScript.")?>)</noscript>
+          <? endif; ?>
+          </label>
+          <div class="controls">
+            <select id="pickUpLocation" name="gatheredDetails[pickUpLocation]" data-default="<?=$this->escapeHtml($selected)?>">
+              <? if ($selected === false): ?>
+              <option value="" selected="selected">
+                <?=$this->transEsc('select_pickup_location')?>
+              </option>
+              <? endif; ?>
+            </select>
+          </div>
+        </div>
+      <? elseif (count($this->pickup) > 1): ?>
         <div class="control-group">
-          <?
-            if (isset($this->gatheredDetails['pickUpLocation']) && $this->gatheredDetails['pickUpLocation'] !== "") {
-                $selected = $this->gatheredDetails['pickUpLocation'];
-            } elseif (isset($this->homeLibrary) && $this->homeLibrary !== "") {
-                $selected = $this->homeLibrary;
-            } else {
-                $selected = $this->defaultPickup;
-            }
-          ?>
           <label class="control-label"><?=$this->transEsc("pick_up_location")?>:</label>
           <div class="controls">
-            <select name="gatheredDetails[pickUpLocation]">
+            <select id="pickUpLocation" name="gatheredDetails[pickUpLocation]">
             <? if ($selected === false): ?>
               <option value="" selected="selected">
                 <?=$this->transEsc('select_pickup_location')?>
@@ -69,3 +118,20 @@
     </div>
   </form>
 </div>
+
+<?
+    // Set up hold script; we do this inline instead of in the header for lightbox compatibility:
+    $this->inlineScript()->appendFile('hold.js');
+
+    $js = <<<JS
+        if ($.isReady) {
+            setUpHoldRequestForm("{$this->escapeHtml($this->driver->getUniqueId())}");
+        } else {
+            $(document).ready(function(){
+                setUpHoldRequestForm("{$this->escapeHtml($this->driver->getUniqueId())}");
+            });
+        }
+JS;
+
+    echo $this->inlineScript()->appendScript($js);
+?>
diff --git a/themes/jquerymobile/css/styles.css b/themes/jquerymobile/css/styles.css
index a86d3ce6838..18d5439e0fa 100644
--- a/themes/jquerymobile/css/styles.css
+++ b/themes/jquerymobile/css/styles.css
@@ -51,7 +51,7 @@ ul.comments .ui-li-aside {
 ul.comments p {
     white-space: normal;
     margin-top: .3em;
-    font-size: 14px;;
+    font-size: 14px;
 }
 ul.comments p.posted-by {
     font-size: 12px;
@@ -64,7 +64,7 @@ ul.history .ui-icon-plus {
 }
 ul.history p {
     white-space: normal;
-    font-size: 12px;;
+    font-size: 12px;
 }
 .result {
     white-space: normal !important;
@@ -204,6 +204,11 @@ div.footer-text {
     color:#ff890f;
     padding-left:18px
 }
+.ajax_hold_request_loading {
+    background: url(../images/loading.gif) no-repeat left top;
+    padding:0 .5em .5em 20px;
+}
+
 .error, .alert, .info {
   text-align:center;
   padding:10px 0;
diff --git a/themes/jquerymobile/js/hold.js b/themes/jquerymobile/js/hold.js
new file mode 100644
index 00000000000..ae84317ff62
--- /dev/null
+++ b/themes/jquerymobile/js/hold.js
@@ -0,0 +1,44 @@
+function setUpHoldRequestForm(recordId) {
+  $('#requestGroupId').change(function() {
+    var $emptyOption = $("#pickUpLocation option[value='']");
+    $("#pickUpLocation option[value!='']").remove();
+    try {
+        $("#pickUpLocation").selectmenu("refresh", true);
+    } catch (e) {}
+    if ($('#requestGroupId').val() === '') {
+        return;
+    }
+    $('#pickUpLocationLabel').addClass("ajax_hold_request_loading");
+    var params = {
+      method: 'getRequestGroupPickupLocations',
+      id: recordId,
+      requestGroupId: $('#requestGroupId').val()              
+    };
+    $.ajax({
+      data: params,
+      dataType: 'json',
+      cache: false,
+      url: path + '/AJAX/JSON',
+      success: function(response) {
+        if (response.status == 'OK') {
+          var defaultValue = $('#pickUpLocation').data('default');
+          $.each(response.data.locations, function() {
+            var option = $('<option></option>').attr('value', this.locationID).text(this.locationDisplay);
+            if (this.locationID == defaultValue || (defaultValue == '' && this.isDefault && $emptyOption.length == 0)) {
+              option.attr('selected', 'selected');
+            }
+            $('#pickUpLocation').append(option);
+          });
+          try {
+              $("#pickUpLocation").selectmenu("refresh", true);
+          } catch (e) {}
+        }
+        $('#pickUpLocationLabel').removeClass("ajax_hold_request_loading");
+      },
+      fail: function() {
+        $('#pickUpLocationLabel').removeClass("ajax_hold_request_loading");
+      }
+    });   
+  });
+  $('#requestGroupId').change();
+}
diff --git a/themes/jquerymobile/templates/myresearch/holds.phtml b/themes/jquerymobile/templates/myresearch/holds.phtml
index ed0533e61d2..7c07672843d 100644
--- a/themes/jquerymobile/templates/myresearch/holds.phtml
+++ b/themes/jquerymobile/templates/myresearch/holds.phtml
@@ -56,6 +56,13 @@
               <p><strong><?=$this->transEsc('Year of Publication')?>:</strong> <?=$this->escapeHtml($ilsDetails['publication_year'])?></p>
             <? endif; ?>
 
+            <? if (!empty($ilsDetails['requestGroup'])): ?>
+              <p>
+                <strong><?=$this->transEsc('hold_request_group') ?>:</strong>
+                <?=$this->transEsc('location_' . $ilsDetails['requestGroup'], array(), $ilsDetails['requestGroup'])?>
+              </p>
+            <? endif; ?>
+
             <? /* Depending on the ILS driver, the "location" value may be a string or an ID; figure out the best
                value to display... */ ?>
             <? $pickupDisplay = ''; ?>
diff --git a/themes/jquerymobile/templates/record/hold.phtml b/themes/jquerymobile/templates/record/hold.phtml
index b2e248d0b23..04bef4b36b4 100644
--- a/themes/jquerymobile/templates/record/hold.phtml
+++ b/themes/jquerymobile/templates/record/hold.phtml
@@ -1,4 +1,7 @@
 <?
+    // Set up hold script:
+    $this->headScript()->appendFile("hold.js");
+
     // Set page title.
     $this->headTitle($this->translate('request_place_text') . ': ' . $this->driver->getBreadcrumb());
 ?>
@@ -25,18 +28,54 @@
           </div>
         <? endif; ?>
 
-        <? if (in_array("pickUpLocation", $this->extraHoldFields)): ?>
+        <? if ($this->requestGroupNeeded): ?>
           <div>
-          <? if (count($this->pickup) > 1): ?>
             <?
-              if (isset($this->gatheredDetails['pickUpLocation']) && $this->gatheredDetails['pickUpLocation'] !== "") {
-                  $selected = $this->gatheredDetails['pickUpLocation'];
-              } elseif (isset($this->homeLibrary) && $this->homeLibrary !== "") {
-                  $selected = $this->homeLibrary;
+              if (isset($this->gatheredDetails['requestGroupId']) && $this->gatheredDetails['requestGroupId'] !== "") {
+                  $selected = $this->gatheredDetails['requestGroupId'];
               } else {
-                  $selected = $this->defaultPickup;
+                  $selected = $this->defaultRequestGroup;
               }
-            ?>
+           ?>
+            <strong><?=$this->transEsc("hold_request_group")?>:</strong>
+            <select id="requestGroupId" name="gatheredDetails[requestGroupId]">
+            <? if ($selected === false): ?>
+              <option value="" selected="selected">
+                <?=$this->transEsc('select_request_group')?>
+              </option>
+            <? endif; ?>
+            <? foreach ($this->requestGroups as $group): ?>
+              <option value="<?=$this->escapeHtml($group['id'])?>"<?=($selected == $group['id']) ? ' selected="selected"' : ''?>>
+                <?=$this->transEsc('location_' . $group['name'], array(), $group['name'])?>
+              </option>
+            <? endforeach; ?>
+            </select>
+          </div>
+        <? endif; ?>
+
+        <? if (in_array("pickUpLocation", $this->extraHoldFields)): ?>
+          <?
+            if (isset($this->gatheredDetails['pickUpLocation']) && $this->gatheredDetails['pickUpLocation'] !== "") {
+                $selected = $this->gatheredDetails['pickUpLocation'];
+            } elseif (isset($this->homeLibrary) && $this->homeLibrary !== "") {
+                $selected = $this->homeLibrary;
+            } else {
+                $selected = $this->defaultPickup;
+            }
+          ?>
+          <? if ($this->requestGroupNeeded): ?>
+          <div>
+            <span id="pickUpLocationLabel"><strong><?=$this->transEsc("pick_up_location")?>:
+              <noscript> (<?=$this->transEsc("Please enable JavaScript.")?>)</noscript>
+            </strong></span>
+            <select id="pickUpLocation" name="gatheredDetails[pickUpLocation]" data-default="<?=$this->escapeHtml($selected)?>">
+              <? if ($selected === false): ?>
+              <option value="" selected="selected">
+                <?=$this->transEsc('select_pickup_location')?>
+              </option>
+              <? endif; ?>
+            </select>
+          <? elseif (count($this->pickup) > 1): ?>
             <strong><?=$this->transEsc("pick_up_location")?>:</strong><br/>
             <select name="gatheredDetails[pickUpLocation]">
             <? if ($selected === false): ?>
@@ -64,3 +103,9 @@
   </div>
   <?=$this->mobileMenu()->footer()?>
 </div>
+
+<script type="text/javascript">
+$(document).bind("pageinit", function(){
+    setUpHoldRequestForm('<?=$this->escapeHtml($this->driver->getUniqueId()) ?>');
+});
+</script>
-- 
GitLab