New pull list interface taking advantage of flattener for speed, simplified-hpl-squash
authorLebbeous Fogle-Weekley <lebbeous@esilibrary.com>
Sat, 31 Mar 2012 16:17:40 +0000 (12:17 -0400)
committerLebbeous Fogle-Weekley <lebbeous@esilibrary.com>
Fri, 20 Apr 2012 23:04:54 +0000 (19:04 -0400)
and advanced sorting.  For now, access it by the "Simplifed Pull List"
button along the bottom edge of the existing holds pull list interface
(but I think when/if this thing is widely accepted, it should replace
the existing interface outright).

With thanks to Mike Peters for testing an early version.  Now including
some updates requested by Thomas Berezansky.

[Column picker not quite right]

Signed-off-by: Lebbeous Fogle-Weekley <lebbeous@esilibrary.com>
16 files changed:
Open-ILS/examples/fm_IDL.xml
Open-ILS/src/perlmods/lib/OpenILS/Application/Flattener.pm
Open-ILS/src/perlmods/lib/OpenILS/Utils/Fieldmapper.pm
Open-ILS/src/perlmods/lib/OpenILS/WWW/FlatFielder.pm
Open-ILS/src/sql/Pg/090.schema.action.sql
Open-ILS/src/sql/Pg/500.view.cross-schema.sql
Open-ILS/src/sql/Pg/950.data.seed-values.sql
Open-ILS/src/sql/Pg/upgrade/XXXX.schema.simplified-hold-pull-list.sql [new file with mode: 0644]
Open-ILS/src/templates/circ/hold_pull_list.tt2 [new file with mode: 0644]
Open-ILS/web/js/dojo/openils/FlattenerStore.js
Open-ILS/web/js/dojo/openils/widget/FlattenerGrid.js
Open-ILS/web/js/dojo/openils/widget/GridColumnPicker.js
Open-ILS/web/opac/locale/en-US/lang.dtd
Open-ILS/xsl/FlatFielder2HTML.xsl
Open-ILS/xul/staff_client/server/patron/holds.js
Open-ILS/xul/staff_client/server/patron/holds_overlay.xul

index 9d9b30f..1ff2e35 100644 (file)
@@ -2298,7 +2298,7 @@ Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA
                        <field reporter:label="Notes" name="notes" oils_persist:virtual="true" reporter:datatype="link"/>
                        <field reporter:label="URI Maps" name="uri_maps" oils_persist:virtual="true" reporter:datatype="link"/>
                        <field reporter:label="URIs" name="uris" oils_persist:virtual="true" reporter:datatype="link"/>
-                       <field reporter:label="Sort Key" name="label_sortkey" reporter:datatype="text"/>
+                       <field reporter:label="Call Number Sort Key" name="label_sortkey" reporter:datatype="text"/>
                        <field reporter:label="Classification Scheme" name="label_class" reporter:datatype="link"/>
                        <field reporter:label="Prefix" name="prefix" reporter:datatype="link"/>
                        <field reporter:label="Suffix" name="suffix" reporter:datatype="link"/>
@@ -2644,7 +2644,7 @@ Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA
                        <link field="billing_location" reltype="has_a" key="id" map="" class="aou"/>
                </links>
        </class>
-       <class id="au" controller="open-ils.cstore" oils_obj:fieldmapper="actor::user" oils_persist:tablename="actor.usr" reporter:core="true" reporter:label="ILS User">
+       <class id="au" controller="open-ils.cstore open-ils.pcrud" oils_obj:fieldmapper="actor::user" oils_persist:tablename="actor.usr" reporter:core="true" reporter:label="ILS User">
                <fields oils_persist:primary="id" oils_persist:sequence="actor.usr_id_seq">
                        <field reporter:label="All Addresses" name="addresses" oils_persist:virtual="true" reporter:datatype="link"/>
                        <field reporter:label="All Library Cards" name="cards" oils_persist:virtual="true" reporter:datatype="link"/>
@@ -2742,6 +2742,11 @@ Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA
                        <link field="reservations" reltype="has_many" key="usr" map="" class="bresv"/>
                        <link field="usr_activity" reltype="has_many" key="usr" map="" class="auact"/>
                </links>
+               <permacrud xmlns="http://open-ils.org/spec/opensrf/IDL/permacrud/v1">
+                       <actions>
+                               <retrieve permission="VIEW_USER" context_field="home_ou" />
+                       </actions>
+               </permacrud>
        </class>
        <class id="cuat" controller="open-ils.cstore open-ils.pcrud" oils_obj:fieldmapper="config::usr_activity_type" oils_persist:tablename="config.usr_activity_type" reporter:label="User Activity Type">
                <fields oils_persist:primary="id" oils_persist:sequence="config.usr_activity_type_id_seq">
@@ -4766,7 +4771,7 @@ SELECT  usr,
             </actions>
         </permacrud>
        </class>
-       <!-- A note: Please update alhr when updating ahr -->
+       <!-- A note: Please update alhr and ahopl when updating ahr -->
        <class id="ahr" controller="open-ils.cstore" oils_obj:fieldmapper="action::hold_request" oils_persist:tablename="action.hold_request" reporter:core="true" reporter:label="Hold Request">
                <fields oils_persist:primary="id" oils_persist:sequence="action.hold_request_id_seq">
                        <field reporter:label="Status" name="status" oils_persist:virtual="true" />
@@ -4783,7 +4788,7 @@ SELECT  usr,
                        <field reporter:label="Hold ID" name="id" reporter:datatype="id" />
                        <field reporter:label="Notifications Phone Number" name="phone_notify" reporter:datatype="text"/>
                        <field reporter:label="Notifications SMS Number" name="sms_notify" reporter:datatype="text"/>
-                       <field reporter:label="Notifications SMS Carrier" name="sms_carrier" reporter:datatype="text"/>
+                       <field reporter:label="Notifications SMS Carrier" name="sms_carrier" reporter:datatype="link"/>
                        <field reporter:label="Pickup Library" name="pickup_lib" reporter:datatype="org_unit"/>
                        <field reporter:label="Last Targeting Date/Time" name="prev_check_time" reporter:datatype="timestamp"/>
                        <field reporter:label="Requesting Library" name="request_lib" reporter:datatype="org_unit"/>
@@ -4826,9 +4831,135 @@ SELECT  usr,
                        <link field="cancel_cause" reltype="might_have" key="id" map="" class="ahrcc"/>
                        <link field="notes" reltype="has_many" key="hold" map="" class="ahrn"/>
                        <link field="current_shelf_lib" reltype="has_a" key="id" map="" class="aou"/>
-                       <link field="sms_carrier" reltype="might_have" key="code" map="" class="csc"/>
+                       <link field="sms_carrier" reltype="has_a" key="id" map="" class="csc"/>
                </links>
        </class>
+       <class id="ahopl" controller="open-ils.cstore open-ils.pcrud" oils_obj:fieldmapper="action::hold_on_pull_list" reporter:label="Hold On Pull List" oils_persist:readonly="true">
+               <oils_persist:source_definition><![CDATA[
+               SELECT
+                       ahr.*,
+                       COALESCE(acplo.position, 999) AS
+                               copy_location_order_position,
+                       CASE WHEN au.alias IS NOT NULL THEN
+                               au.alias
+                       ELSE
+                               REGEXP_REPLACE(ARRAY_TO_STRING(ARRAY[
+                                       COALESCE(au.family_name, ''),
+                                       COALESCE(au.suffix, ''),
+                                       ', ',
+                                       COALESCE(au.prefix, ''),
+                                       COALESCE(au.first_given_name, ''),
+                                       COALESCE(au.second_given_name, '')
+                               ], ' '), E'\\s+,', ',')
+                       END AS usr_display_name,
+                       TRIM(acnp.label || ' ' || acn.label || ' ' || acns.label)
+                               AS call_number_label,
+                       siss.label AS issuance_label,
+                       ahqa.queue_position,
+                       ahqa.total_holds,
+                       ahawt.avg_wait_time,
+                       ahawt.num_potentials,
+                       action.estimate_wait_time_for_hold(
+                               ahawt.avg_wait_time,
+                               ahqa.queue_position::INTEGER,
+                               au.home_ou
+                       ) AS estimated_wait_time,
+                       (ahr.usr <> ahr.requestor) AS is_staff_hold
+               FROM action.hold_request ahr
+               JOIN action.hold_avg_wait_time ahawt ON (ahawt.hold = ahr.id)
+               JOIN action.hold_queue_approximation ahqa
+                       ON (ahqa.this_hold=ahr.id AND ahqa.other_hold=ahr.id)
+               JOIN asset.copy acp ON (acp.id = ahr.current_copy)
+               JOIN asset.call_number acn ON (acp.call_number = acn.id)
+               JOIN asset.call_number_prefix acnp ON (acn.prefix = acnp.id)
+               JOIN asset.call_number_suffix acns ON (acn.suffix = acns.id)
+               JOIN actor.usr au ON (au.id = ahr.usr)
+               LEFT JOIN serial.issuance siss
+                       ON (ahr.hold_type = 'I' AND siss.id = ahr.target)
+               LEFT JOIN asset.copy_location_order acplo
+                       ON (acp.location = acplo.location AND
+                               acp.circ_lib = acplo.org)
+               WHERE
+                       ahr.capture_time IS NULL AND
+                       ahr.cancel_time IS NULL AND
+                       (ahr.expire_time is NULL OR ahr.expire_time > NOW())
+               ]]></oils_persist:source_definition>
+               <fields oils_persist:primary="id">
+                       <field reporter:label="Status" name="status" oils_persist:virtual="true" />
+                       <field reporter:label="Transit" name="transit" oils_persist:virtual="true" />
+                       <field reporter:label="Capture Date/Time" name="capture_time" reporter:datatype="timestamp"/>
+                       <field reporter:label="Currently Targeted Copy" name="current_copy" />
+                       <field reporter:label="Notify by Email?" name="email_notify" reporter:datatype="bool"/>
+                       <field reporter:label="Hold Expire Date/Time" name="expire_time" reporter:datatype="timestamp"/>
+                       <field reporter:label="Fulfilling Library" name="fulfillment_lib" reporter:datatype="org_unit"/>
+                       <field reporter:label="Fulfilling Staff" name="fulfillment_staff" />
+                       <field reporter:label="Fulfillment Date/Time" name="fulfillment_time" reporter:datatype="timestamp"/>
+                       <field reporter:label="Hold Type" name="hold_type" reporter:datatype="text"/>
+                       <field reporter:label="Holdable Formats (for M-type hold)" name="holdable_formats" reporter:datatype="text"/>
+                       <field reporter:label="Hold ID" name="id" reporter:datatype="id" />
+                       <field reporter:label="Notifications Phone Number" name="phone_notify" reporter:datatype="text"/>
+                       <field reporter:label="Notifications SMS Number" name="sms_notify" reporter:datatype="text"/>
+                       <field reporter:label="Notifications SMS Carrier" name="sms_carrier" reporter:datatype="link"/>
+                       <field reporter:label="Pickup Library" name="pickup_lib" reporter:datatype="org_unit"/>
+                       <field reporter:label="Last Targeting Date/Time" name="prev_check_time" reporter:datatype="timestamp"/>
+                       <field reporter:label="Requesting Library" name="request_lib" reporter:datatype="org_unit"/>
+                       <field reporter:label="Request Date/Time" name="request_time" reporter:datatype="timestamp"/>
+                       <field reporter:label="Requesting User" name="requestor" reporter:datatype="link"/>
+                       <field reporter:label="Item Selection Depth" name="selection_depth" />
+                       <field reporter:label="Selection Locus" name="selection_ou" reporter:datatype="org_unit"/>
+                       <field reporter:label="Target Object ID" name="target" reporter:datatype="link"/>
+                       <field reporter:label="Hold User" name="usr" reporter:datatype="link"/>
+                       <field reporter:label="Hold Cancel Date/Time" name="cancel_time" reporter:datatype="timestamp"/>
+                       <field reporter:label="Notify Time" name="notify_time" oils_persist:virtual="true" reporter:datatype="timestamp"/>
+                       <field reporter:label="Notify Count" name="notify_count" oils_persist:virtual="true" reporter:datatype="int" />
+                       <field reporter:label="Notifications" name="notifications" oils_persist:virtual="true" reporter:datatype="link"/>
+                       <field reporter:label="Bib Record link" name="bib_rec" oils_persist:virtual="true" reporter:datatype="link"/>
+                       <field reporter:label="Eligible Copies" name="eligible_copies" oils_persist:virtual="true" reporter:datatype="link"/>
+                       <field reporter:label="Currently Frozen" name="frozen" reporter:datatype="bool"/>
+                       <field reporter:label="Thaw Date (if frozen)" name="thaw_date" reporter:datatype="timestamp"/>
+                       <field reporter:label="Shelf Time" name="shelf_time" reporter:datatype="timestamp"/>
+                       <field reporter:label="Cancelation cause" name="cancel_cause" reporter:datatype="link" />
+                       <field reporter:label="Cancelation note" name="cancel_note" reporter:datatype="text" />
+                       <field reporter:label="Top of Queue" name="cut_in_line" reporter:datatype="bool" />
+                       <field reporter:label="Is Mint Condition" name="mint_condition" reporter:datatype="bool" />
+                       <field reporter:label="Shelf Expire Time" name="shelf_expire_time" reporter:datatype="timestamp"/>
+                       <field reporter:label="Notes" name="notes" reporter:datatype="link" oils_persist:virtual="true"/>
+                       <field reporter:label="Current Shelf Lib" name="current_shelf_lib" reporter:datatype="org_unit"/>
+                       <field reporter:label="Copy Location Sort Order" name="copy_location_order_position" reporter:datatype="int" />
+                       <field reporter:label="User Display Name" name="usr_display_name" reporter:datatype="text" />
+                       <field reporter:label="Call Number Label" name="call_number_label" reporter:datatype="text" />
+                       <field reporter:label="Issuance Label" name="issuance_label" reporter:datatype="text" />
+                       <field reporter:label="Queue Position" name="queue_position" reporter:datatype="int" />
+                       <field reporter:label="Total Holds in Queue" name="total_holds" reporter:datatype="int" />
+                       <field reporter:label="Average Wait Time Per Hold" name="avg_wait_time" reporter:datatype="interval" />
+                       <field reporter:label="Number of Potential Copies" name="num_potentials" reporter:datatype="int" />
+                       <field reporter:label="Estimated Wait Time" name="estimated_wait_time" reporter:datatype="interval" />
+                       <field reporter:label="Is Staff Hold?" name="is_staff_hold" reporter:datatype="bool" />
+               </fields>
+               <links>
+                       <link field="fulfillment_lib" reltype="has_a" key="id" map="" class="aou"/>
+                       <link field="fulfillment_staff" reltype="has_a" key="id" map="" class="au"/>
+                       <link field="pickup_lib" reltype="has_a" key="id" map="" class="aou"/>
+                       <link field="selection_ou" reltype="has_a" key="id" map="" class="aou"/>
+                       <link field="requestor" reltype="has_a" key="id" map="" class="au"/>
+                       <link field="current_copy" reltype="has_a" key="id" map="" class="acp"/>
+                       <link field="usr" reltype="has_a" key="id" map="" class="au"/>
+                       <link field="request_lib" reltype="has_a" key="id" map="" class="aou"/>
+                       <link field="transit" reltype="might_have" key="hold" map="" class="ahtc"/>
+                       <link field="notifications" reltype="has_many" key="hold" map="" class="ahn"/>
+                       <link field="eligible_copies" reltype="has_many" key="hold" map="target_copy" class="ahcm"/>
+                       <link field="bib_rec" reltype="might_have" key="id" map="" class="rhrr"/>
+                       <link field="cancel_cause" reltype="might_have" key="id" map="" class="ahrcc"/>
+                       <link field="notes" reltype="has_many" key="hold" map="" class="ahrn"/>
+                       <link field="current_shelf_lib" reltype="has_a" key="id" map="" class="aou"/>
+                       <link field="sms_carrier" reltype="has_a" key="id" map="" class="csc"/>
+               </links>
+               <permacrud xmlns="http://open-ils.org/spec/opensrf/IDL/permacrud/v1">
+                       <actions>
+                               <retrieve permission="VIEW_HOLD" context_field="pickup_lib" />
+                       </actions>
+               </permacrud>
+       </class>
        <class id="alhr" controller="open-ils.cstore" oils_obj:fieldmapper="action::last_hold_request" reporter:label="Last Captured Hold Request" oils_persist:readonly="true">
                <oils_persist:source_definition>
                        SELECT ahr.* FROM action.hold_request ahr JOIN (SELECT current_copy, MAX(capture_time) AS capture_time FROM action.hold_request WHERE capture_time IS NOT NULL GROUP BY current_copy)x USING (current_copy, capture_time)
@@ -5070,7 +5201,7 @@ SELECT  usr,
                        <link field="entries" reltype="has_many" key="stat_cat" map="" class="asce"/>
                </links>
        </class>
-       <class id="ac" controller="open-ils.cstore" oils_obj:fieldmapper="actor::card" oils_persist:tablename="actor.card" reporter:label="Library Card">
+       <class id="ac" controller="open-ils.cstore open-ils.pcrud" oils_obj:fieldmapper="actor::card" oils_persist:tablename="actor.card" reporter:label="Library Card">
                <fields oils_persist:primary="id" oils_persist:sequence="actor.card_id_seq">
                        <field reporter:label="IsActive?" name="active" reporter:datatype="bool"/>
                        <field reporter:label="Barcode" name="barcode" reporter:datatype="text"/>
@@ -5080,6 +5211,13 @@ SELECT  usr,
                <links>
                        <link field="usr" reltype="has_a" key="id" map="" class="au"/>
                </links>
+               <permacrud xmlns="http://open-ils.org/spec/opensrf/IDL/permacrud/v1">
+                       <actions>
+                               <retrieve permission="VIEW_USER">
+                                       <context link="usr" field="home_ou" />
+                               </retrieve>
+                       </actions>
+               </permacrud>
        </class>
     <class id="actscsf" controller="open-ils.cstore open-ils.pcrud" oils_obj:fieldmapper="actor::stat_cat_sip_fields" oils_persist:tablename="actor.stat_cat_sip_fields" reporter:label="SIP Statistical Category Field Identifier">
         <fields oils_persist:primary="field">
@@ -7831,7 +7969,7 @@ SELECT  usr,
                        <link field="folder" reltype="has_a" key="id" map="" class="rof"/>
                </links>
        </class>
-       <class id="rmsr" controller="open-ils.reporter-store open-ils.cstore" oils_obj:fieldmapper="reporter::materialized_simple_record" oils_persist:tablename="reporter.materialized_simple_record" reporter:label="Fast Simple Record Extracts">
+       <class id="rmsr" controller="open-ils.reporter-store open-ils.cstore open-ils.pcrud" oils_obj:fieldmapper="reporter::materialized_simple_record" oils_persist:tablename="reporter.materialized_simple_record" reporter:label="Fast Simple Record Extracts">
                <fields oils_persist:primary="id">
                        <field reporter:label="Record ID" name="id" reporter:datatype="id" />
                        <field reporter:label="Fingerprint" name="fingerprint" reporter:datatype="text"/>
@@ -7849,6 +7987,11 @@ SELECT  usr,
                <links>
                        <link field="biblio_record" reltype="might_have" key="id" map="" class="bre"/>
                </links>
+               <permacrud xmlns="http://open-ils.org/spec/opensrf/IDL/permacrud/v1">
+                       <actions>
+                               <retrieve />
+                       </actions>
+               </permacrud>
        </class>
        <class id="rssr" controller="open-ils.reporter-store" oils_obj:fieldmapper="reporter::super_simple_record" oils_persist:tablename="reporter.super_simple_record" reporter:label="Simple Record Extracts">
                <fields oils_persist:primary="id">
index a6c77d3..ff232a9 100644 (file)
@@ -89,12 +89,14 @@ sub _flattened_search_single_join_clause {
             my $new_join;
             if ($reltype eq "has_a") {
                 $new_join = {
+                    type => "left",
                     class => $hint,
                     fkey => $piece,
                     field => $field
                 };
             } elsif ($reltype eq "has_many" or $reltype eq "might_have") {
                 $new_join = {
+                    type => "left",
                     class => $hint,
                     fkey => $last_ident,
                     field => $field
index df00277..eddb944 100644 (file)
@@ -139,11 +139,13 @@ sub load_links {
                        my $reltype = get_attribute( $attribute_list, 'reltype' );
                        my $key     = get_attribute( $attribute_list, 'key' );
                        my $class   = get_attribute( $attribute_list, 'class' );
+                       my $map     = get_attribute( $attribute_list, 'map' );
 
                        $$fieldmap{$fm}{links}{ $field } =
                                { class   => $class,
                                  reltype => $reltype,
                                  key     => $key,
+                                 map     => $map
                                };
                }
        }
index 0e5fc98..b80c9e8 100644 (file)
@@ -58,8 +58,7 @@ my $_output_handler_dispatch = {
         "prio" => 0,
         "code" => sub {
             $_[0]->content_type("text/html; charset=utf-8");
-            print html_ish_output( @_, 'FlatFielder2HTML.xsl' );
-            return Apache2::Const::OK;
+            return html_ish_output( @_, 'FlatFielder2HTML.xsl' );
         }
     },
     "application/xml" => {
@@ -115,15 +114,29 @@ sub data_to_xml {
     $fs->setAttribute("FS_key", $args->{key}) if $args->{key};
     $dom->setDocumentElement($fs);
 
+    my @columns;
+    my %column_labels;
+    if (@{$args->{columns}}) {
+        @columns = @{$args->{columns}};
+        if (@{$args->{labels}}) {
+            my @labels = @{$args->{labels}};
+            $column_labels{$columns[$_]} = $labels[$_] for (0..$#labels);
+        }
+    }
+
     my $rownum = 1;
     for my $i (@{$$args{data}}) {
         my $item = $dom->createElement("row");
         $item->setAttribute('ordinal', $rownum);
         $rownum++;
-        for my $k (keys %$i) {
+        @columns = keys %$i unless @columns;
+        for my $k (@columns) {
             my $val = $dom->createElement('column');
-            $val->setAttribute('name', $k);
-            $val->appendText($i->{$k});
+            my $datum = $i->{$k};
+            $datum = join(" ", @$datum) if ref $datum eq 'ARRAY';
+
+            $val->setAttribute('name', $column_labels{$k} || $k);
+            $val->appendText($datum);
             $item->addChild($val);
         }
         $fs->addChild($item);
@@ -214,6 +227,8 @@ sub handler {
     $args{key} = $cgi->param('key');
     $args{id_field} = $cgi->param('identifier');
     $args{label_field} = $cgi->param('label');
+    $args{columns} = [ $cgi->param('columns') ];
+    $args{labels} = [ $cgi->param('labels') ];
 
     my $fielder = OpenSRF::AppSession->create('open-ils.fielder');
     if ($args{map}) {
index 1af6e89..d72ea6d 100644 (file)
@@ -972,5 +972,56 @@ query-based fieldsets.
 Returns NULL if successful, or an error message if not.
 $$;
 
+-- For a given hold, get avg wait time of copies on potentials list and
+-- the number of copies on potentials list.  Estimated wait time can be
+-- calculated from these results based on queue position.
+CREATE VIEW action.hold_avg_wait_time AS
+    SELECT
+        SUM(num_potentials * avg_wait_time) / SUM(num_potentials) AS avg_wait_time,
+        SUM(num_potentials) AS num_potentials,
+        hold
+    FROM (
+        SELECT
+            COUNT(acp.id) AS num_potentials,
+            ahcm.hold,
+            COALESCE(ccm.avg_wait_time, (SELECT MAX(value) FROM (
+                SELECT value::INTERVAL FROM actor.org_unit_ancestor_setting('circ.holds.default_estimated_wait_interval', au.home_ou)
+                    UNION
+                SELECT '0 seconds'::INTERVAL
+            ) ous)) AS avg_wait_time
+        FROM action.hold_copy_map ahcm
+        JOIN action.hold_request ahr ON (ahr.id = ahcm.hold)
+        JOIN actor.usr au ON (au.id = ahr.usr)
+        JOIN asset.copy acp ON (acp.id = ahcm.target_copy)
+        LEFT JOIN config.circ_modifier ccm ON (ccm.code = acp.circ_modifier)
+        GROUP BY 2, 3, au.home_ou
+    ) x
+    GROUP by 3;
+
+
+CREATE OR REPLACE FUNCTION action.estimate_wait_time_for_hold(
+    avg_wait_time   INTERVAL,
+    queue_position  INT,
+    home_ou         INT
+) RETURNS INTERVAL AS $$
+SELECT
+    CASE WHEN min_wait > estimated_wait_time THEN
+        min_wait
+    ELSE
+        estimated_wait_time
+    END
+FROM (
+    SELECT
+        (SELECT MAX(value) FROM (
+            SELECT value::INTERVAL
+                FROM actor.org_unit_ancestor_setting(
+                    'circ.holds.min_estimated_wait_interval', $3
+                )
+            UNION
+            SELECT '0 seconds'::INTERVAL
+        ) ous) AS min_wait,
+        $1 * $2 AS estimated_wait_time
+) x
+$$ LANGUAGE SQL;
 
 COMMIT;
index f49defc..2162ddc 100644 (file)
@@ -60,6 +60,82 @@ CREATE TABLE config.idl_field_doc (
 );
 CREATE UNIQUE INDEX idl_field_doc_identity ON config.idl_field_doc (fm_class,field,owner);
 
+-- Begin hold queue approximation (the first view is cross-schema and the
+-- rest depends on that).
 
-COMMIT;
+--  All hold queue things in Evergreen have always been approximations.  There
+--  is no actual holds queue. That's not how Evergreen holds work.
+
+--  This approximation is faster than the one it aims to replace.  It's Mike
+--  Rylander's idea and mostly his implementation.
+
+-- redundant with reporter.hold_request_record for now
+-- needs attention someday re M-type holds.
+CREATE OR REPLACE VIEW action.hold_request_record AS
+SELECT id,
+       target,
+       hold_type,
+       CASE
+               WHEN hold_type = 'T'
+                       THEN target
+               WHEN hold_type = 'I'
+                       THEN (SELECT ssub.record_entry FROM serial.subscription ssub JOIN serial.issuance si ON (si.subscription = ssub.id) WHERE si.id = ahr.target)
+               WHEN hold_type = 'V'
+                       THEN (SELECT cn.record FROM asset.call_number cn WHERE cn.id = ahr.target)
+               WHEN hold_type IN ('C','R','F')
+                       THEN (SELECT cn.record FROM asset.call_number cn JOIN asset.copy cp ON (cn.id = cp.call_number) WHERE cp.id = ahr.target)
+               WHEN hold_type = 'M'
+                       THEN (SELECT mr.master_record FROM metabib.metarecord mr WHERE mr.id = ahr.target)
+        WHEN hold_type = 'P'
+            THEN (SELECT bmp.record FROM biblio.monograph_part bmp WHERE bmp.id = ahr.target)
+       END AS bib_record
+  FROM action.hold_request ahr;
+
+-- a mat-view, trigger maintained (when the target changes), that maps bibs to holds of any type
+CREATE TABLE action.materialized_hold_record_map AS
+    SELECT id, bib_record FROM action.hold_request_record; 
+CREATE INDEX hr_pkey_idx ON action.materialized_hold_record_map (id);
+CREATE INDEX hr_rec_idx ON action.materialized_hold_record_map (bib_record);
+CREATE OR REPLACE FUNCTION action.materialize_hold_record_map()
+RETURNS TRIGGER AS $$
+BEGIN
+    IF TG_OP = 'INSERT' OR (
+        NEW.target <> OLD.target OR NEW.hold_type <> OLD.hold_type
+    ) THEN
+        DELETE FROM action.materialized_hold_record_map WHERE id = NEW.id;
+        INSERT INTO action.materialized_hold_record_map (id, bib_record)
+            SELECT id, bib_record
+            FROM action.hold_request_record
+            WHERE id = NEW.id;
+    END IF;
+
+    RETURN NEW;
+END;
+$$ LANGUAGE PLPGSQL;
 
+CREATE TRIGGER materialize_hold_record_map
+    AFTER INSERT OR UPDATE
+    ON action.hold_request
+    FOR EACH ROW EXECUTE PROCEDURE action.materialize_hold_record_map();
+
+-- To join this usefully, join where
+-- this_hold = <your hold id> AND other_hold = <your hold id>
+CREATE VIEW action.hold_queue_approximation AS
+SELECT  h1.id AS this_hold,
+        h2.id AS other_hold,
+        ROW_NUMBER() OVER (
+            PARTITION BY h1.id
+            ORDER BY
+                COALESCE(h2.cut_in_line, FALSE) DESC,
+                h2.request_time
+        ) AS queue_position,
+        COUNT(*) OVER (PARTITION BY h1.id) AS total_holds
+  FROM  action.hold_request h1
+        JOIN action.materialized_hold_record_map r1 USING (id)
+        JOIN action.materialized_hold_record_map r2 USING (bib_record)
+        JOIN action.hold_request h2 ON (h2.id = r2.id) ;
+
+-- End hold queue approximation
+
+COMMIT;
index d53ae14..cb07918 100644 (file)
@@ -10140,6 +10140,26 @@ INSERT INTO config.usr_setting_type (name,grp,opac_visible,label,description,dat
     ),
     'string'
 );
+
+INSERT INTO config.usr_setting_type (name,grp,opac_visible,label,description,datatype) VALUES (
+    'ui.grid_columns.circ.hold_pull_list',
+    'gui',
+    FALSE,
+    oils_i18n_gettext(
+        'ui.grid_columns.circ.hold_pull_list',
+        'Hold Pull List',
+        'cust',
+        'label'
+    ),
+    oils_i18n_gettext(
+        'ui.grid_columns.circ.hold_pull_list',
+        'Hold Pull List Saved Column Settings',
+        'cust',
+        'description'
+    ),
+    'string'
+);
+
 SELECT setval( 'config.sms_carrier_id_seq', 1000 );
 INSERT INTO config.sms_carrier VALUES
 
diff --git a/Open-ILS/src/sql/Pg/upgrade/XXXX.schema.simplified-hold-pull-list.sql b/Open-ILS/src/sql/Pg/upgrade/XXXX.schema.simplified-hold-pull-list.sql
new file mode 100644 (file)
index 0000000..98e017b
--- /dev/null
@@ -0,0 +1,152 @@
+BEGIN;
+
+SELECT evergreen.upgrade_deps_block_check('XXXX', :eg_version);
+
+-- Begin hold queue approximation
+
+--  All hold queue things in Evergreen have always been approximations.  There
+--  is no actual holds queue. That's not how Evergreen holds work.
+
+--  This approximation is faster than the one it aims to replace.  It's Mike
+--  Rylander's idea and more or less his implementation.
+
+
+CREATE OR REPLACE VIEW action.hold_request_record AS
+SELECT id,
+       target,
+       hold_type,
+       CASE
+               WHEN hold_type = 'T'
+                       THEN target
+               WHEN hold_type = 'I'
+                       THEN (SELECT ssub.record_entry FROM serial.subscription ssub JOIN serial.issuance si ON (si.subscription = ssub.id) WHERE si.id = ahr.target)
+               WHEN hold_type = 'V'
+                       THEN (SELECT cn.record FROM asset.call_number cn WHERE cn.id = ahr.target)
+               WHEN hold_type IN ('C','R','F')
+                       THEN (SELECT cn.record FROM asset.call_number cn JOIN asset.copy cp ON (cn.id = cp.call_number) WHERE cp.id = ahr.target)
+               WHEN hold_type = 'M'
+                       THEN (SELECT mr.master_record FROM metabib.metarecord mr WHERE mr.id = ahr.target)
+        WHEN hold_type = 'P'
+            THEN (SELECT bmp.record FROM biblio.monograph_part bmp WHERE bmp.id = ahr.target)
+       END AS bib_record
+  FROM action.hold_request ahr;
+
+-- a mat-view, trigger maintained (when the target changes), that maps bibs to holds of any type
+CREATE TABLE action.materialized_hold_record_map AS
+    SELECT id, bib_record FROM action.hold_request_record; 
+CREATE INDEX hr_pkey_idx ON action.materialized_hold_record_map (id);
+CREATE INDEX hr_rec_idx ON action.materialized_hold_record_map (bib_record);
+CREATE OR REPLACE FUNCTION action.materialize_hold_record_map()
+RETURNS TRIGGER AS $$
+BEGIN
+    IF TG_OP = 'INSERT' OR (
+        NEW.target <> OLD.target OR NEW.hold_type <> OLD.hold_type
+    ) THEN
+        DELETE FROM action.materialized_hold_record_map WHERE id = NEW.id;
+        INSERT INTO action.materialized_hold_record_map (id, bib_record)
+            SELECT id, bib_record
+            FROM action.hold_request_record
+            WHERE id = NEW.id;
+    END IF;
+
+    RETURN NEW;
+END;
+$$ LANGUAGE PLPGSQL;
+
+CREATE TRIGGER materialize_hold_record_map
+    AFTER INSERT OR UPDATE
+    ON action.hold_request
+    FOR EACH ROW EXECUTE PROCEDURE action.materialize_hold_record_map();
+
+-- To join this usefully, join where
+-- this_hold = <your hold id> AND other_hold = <your hold id>
+CREATE VIEW action.hold_queue_approximation AS
+SELECT  h1.id AS this_hold,
+        h2.id AS other_hold,
+        ROW_NUMBER() OVER (
+            PARTITION BY h1.id
+            ORDER BY
+                COALESCE(h2.cut_in_line, FALSE) DESC,
+                h2.request_time
+        ) AS queue_position,
+        COUNT(*) OVER (PARTITION BY h1.id) AS total_holds
+  FROM  action.hold_request h1
+        JOIN action.materialized_hold_record_map r1 USING (id)
+        JOIN action.materialized_hold_record_map r2 USING (bib_record)
+        JOIN action.hold_request h2 ON (h2.id = r2.id) ;
+
+-- End hold queue approximation
+
+-- For a given hold, get avg wait time of copies on potentials list and
+-- the number of copies on potentials list.  Estimated wait time can be
+-- calculated from these results based on queue position.
+CREATE VIEW action.hold_avg_wait_time AS
+    SELECT
+        SUM(num_potentials * avg_wait_time) / SUM(num_potentials) AS avg_wait_time,
+        SUM(num_potentials) AS num_potentials,
+        hold
+    FROM (
+        SELECT
+            COUNT(acp.id) AS num_potentials,
+            ahcm.hold,
+            COALESCE(ccm.avg_wait_time, (SELECT MAX(value) FROM (
+                SELECT value::INTERVAL FROM actor.org_unit_ancestor_setting('circ.holds.default_estimated_wait_interval', au.home_ou)
+                    UNION
+                SELECT '0 seconds'::INTERVAL
+            ) ous)) AS avg_wait_time
+        FROM action.hold_copy_map ahcm
+        JOIN action.hold_request ahr ON (ahr.id = ahcm.hold)
+        JOIN actor.usr au ON (au.id = ahr.usr)
+        JOIN asset.copy acp ON (acp.id = ahcm.target_copy)
+        LEFT JOIN config.circ_modifier ccm ON (ccm.code = acp.circ_modifier)
+        GROUP BY 2, 3, au.home_ou
+    ) x
+    GROUP by 3;
+
+
+CREATE OR REPLACE FUNCTION action.estimate_wait_time_for_hold(
+    avg_wait_time   INTERVAL,
+    queue_position  INT,
+    home_ou         INT
+) RETURNS INTERVAL AS $$
+SELECT
+    CASE WHEN min_wait > estimated_wait_time THEN
+        min_wait
+    ELSE
+        estimated_wait_time
+    END
+FROM (
+    SELECT
+        (SELECT MAX(value) FROM (
+            SELECT value::INTERVAL
+                FROM actor.org_unit_ancestor_setting(
+                    'circ.holds.min_estimated_wait_interval', $3
+                )
+            UNION
+            SELECT '0 seconds'::INTERVAL
+        ) ous) AS min_wait,
+        $1 * $2 AS estimated_wait_time
+) x
+$$ LANGUAGE SQL;
+
+INSERT INTO config.usr_setting_type (name,grp,opac_visible,label,description,datatype) VALUES (
+    'ui.grid_columns.circ.hold_pull_list',
+    'gui',
+    FALSE,
+    oils_i18n_gettext(
+        'ui.grid_columns.circ.hold_pull_list',
+        'Hold Pull List',
+        'cust',
+        'label'
+    ),
+    oils_i18n_gettext(
+        'ui.grid_columns.circ.hold_pull_list',
+        'Hold Pull List Saved Column Settings',
+        'cust',
+        'description'
+    ),
+    'string'
+);
+
+COMMIT;
diff --git a/Open-ILS/src/templates/circ/hold_pull_list.tt2 b/Open-ILS/src/templates/circ/hold_pull_list.tt2
new file mode 100644 (file)
index 0000000..3e9cccf
--- /dev/null
@@ -0,0 +1,97 @@
+[% WRAPPER base.tt2 %]
+[% ctx.page_title = 'Hold Pull List' %]
+<script type="text/javascript">
+    dojo.require("dijit.form.Button");
+    dojo.require("openils.widget.OrgUnitFilteringSelect");
+    dojo.require("openils.widget.FlattenerGrid");
+
+    var map_extras = {
+        "copy_circ_lib": {
+            "path": "current_copy.circ_lib",
+            "filter": true
+        },
+        "call_number_sort_key": {
+            "path": "current_copy.call_number.label_sortkey",
+            "sort" :true
+        }
+    };
+
+    function set_grid_query_from_org_selector() {
+        grid.query = {
+            "copy_circ_lib": org_selector.attr("value")
+        };
+        grid.refresh();
+    }
+
+    function prepare_org_selector(perm) {
+        new openils.User().buildPermOrgSelector(
+            perm, org_selector, null,
+            function() {
+                dojo.connect(
+                    org_selector, "onChange", set_grid_query_from_org_selector
+                );
+                set_grid_query_from_org_selector();
+            }
+        );
+    }
+
+    openils.Util.addOnLoad(
+        function() {
+            prepare_org_selector("VIEW_HOLD");
+        }
+    );
+
+</script>
+<div dojoType="dijit.layout.ContentPane" layoutAlign="client">
+    <div dojoType="dijit.layout.ContentPane"
+         layoutAlign="top" class="oils-header-panel">
+        <div>Hold Pull List</div>
+        <div>
+            <button dojoType="dijit.form.Button"
+                onClick="grid.print();">Print Pull List</button>
+        </div>
+    </div>
+    <div class="oils-acq-basic-roomy">
+        <label for="org_selector">Show the pull list for:</label>
+        <select
+            id="org_selector" jsId="org_selector"
+            dojoType="openils.widget.OrgUnitFilteringSelect"
+            searchAttr="name" labelAttr="name">
+        </select>
+    </div>
+    <table
+        jsid="grid"
+        dojoType="openils.widget.FlattenerGrid"
+        columnPersistKey='"circ.hold_pull_list"'
+        autoHeight="10"
+        editOnEnter="false"
+        hideSelector="true"
+        autoCoreFields="true"
+        autoFieldFields="['current_copy','current_copy.call_number.record.simple_record']"
+        editStyle="pane"
+        showLoadFilter="true"
+        fmClass="'ahopl'"
+        defaultSort="['copy_location_sort_order','call_number_sort_key']"
+        mapExtras="map_extras"
+        sortFieldReMap="{call_number_label: 'call_number_sort_key'}"
+        fetchLock="true"
+        query="{}">
+        <thead>
+            <tr>
+                <th field="shelving_loc" fpath="current_copy.location.name" ffilter="true">Shelving Location</th>
+                <th field="call_number_label" fpath="call_number_label"></th>
+                <th field="author" fpath="current_copy.call_number.record.simple_record.author">Author</th>
+                <th field="title" fpath="current_copy.call_number.record.simple_record.title">Title</th>
+                <th field="barcode" fpath="current_copy.barcode"></th>
+                <th field="parts" fpath="current_copy.parts.label" fsort="false">Parts</th>
+                <th field="notes" fpath="notes.body" fsort="false" _visible="false">Hold Notes</th>
+                <th field="patron_barcode" fpath="usr.card.barcode" _visible="false">Patron Barcode</th>
+                <th field="request_lib_name" fpath="request_lib.name" _visible="false">Request Library</th>
+                <th field="request_lib_shortname" fpath="request_lib.shortname" _visible="false">Request Library (Shortname)</th>
+                <th field="selection_ou" fpath="selection_ou.shortname" _visible="false">Selection Locus</th>
+                <th field="sms_carrier_name" fpath="sms_carrier.name" _visible="false">SMS Carrier</th>
+            </tr>
+        </thead>
+    </table>
+</div>
+[% END %]
index e5cbdd7..700d3f2 100644 (file)
@@ -29,6 +29,7 @@ if (!dojo._hasResource["openils.FlattenerStore"]) {
         "offset": 0,
         "baseSort": null,
         "defaultSort": null,
+        "sortFieldReMap": null,
 
         "constructor": function(/* object */ args) {
             dojo.mixin(this, args);
@@ -51,7 +52,33 @@ if (!dojo._hasResource["openils.FlattenerStore"]) {
             );
         },
 
-        "_prepare_flattener_params": function(req) {
+        "_remap_sort": function(prepared_sort) {
+            if (this.sortFieldReMap) {
+                return prepared_sort.map(
+                    dojo.hitch(
+                        this, function(exp) {
+                            if (typeof exp == "object") {
+                                var key;
+                                for (key in exp)
+                                    break;
+                                var newkey = (key in this.sortFieldReMap) ?
+                                    this.sortFieldReMap[key] : key;
+                                var o = {};
+                                o[newkey] = exp[key];
+                                return o;
+                            } else {
+                                return (exp in this.sortFieldReMap) ?
+                                    this.sortFieldReMap[exp] : exp;
+                            }
+                        }
+                    )
+                );
+            } else {
+                return prepared_sort;
+            }
+        },
+
+        "_build_flattener_params": function(req) {
             var params = {
                 "hint": this.fmClass,
                 "ses": openils.User.authtoken
@@ -65,31 +92,38 @@ if (!dojo._hasResource["openils.FlattenerStore"]) {
 
                 params.where = dojo.toJson(where);
             } else {
-                var limit = (!isNaN(req.count) && req.count != Infinity) ?
-                    req.count : this.limit;
-                var offset = (!isNaN(req.start) && req.start != Infinity) ?
-                    req.start : this.offset;
-
-                dojo.mixin(
-                    params, {
-                        "where": dojo.toJson(req.query),
-                        "slo": dojo.toJson({
-                            "sort": this._prepare_sort(req.sort),
-                            "limit": limit,
-                            "offset": offset
-                        })
-                    }
-                );
+                params.where =  dojo.toJson(req.query);
+
+                var slo = {
+                    "sort": this._remap_sort(this._prepare_sort(req.sort))
+                };
+
+                if (!req.queryOptions.all) {
+                    slo.limit =
+                        (!isNaN(req.count) && req.count != Infinity) ?
+                            req.count : this.limit;
+
+                    slo.offset =
+                        (!isNaN(req.start) && req.start != Infinity) ?
+                            req.start : this.offset;
+                }
+
+                if (req.queryOptions.columns)
+                    params.columns = req.queryOptions.columns;
+                if (req.queryOptions.labels)
+                    params.labels = req.queryOptions.labels;
+
+                params.slo = dojo.toJson(slo);
             }
 
-            if (this.mapKey) { /* XXX TODO, get a map key */
+            if (this.mapKey) {
                 params.key = this.mapKey;
             } else {
                 params.map = dojo.toJson(this.mapClause);
             }
 
-            for (var key in params)
-                console.debug("flattener param " + key + " -> " + params[key]);
+//            for (var key in params)
+//                console.debug("flattener param " + key + " -> " + params[key]);
 
             return params;
         },
@@ -114,6 +148,94 @@ if (!dojo._hasResource["openils.FlattenerStore"]) {
             );
         },
 
+        "_on_http_error": function(response, ioArgs, req, retry_method) {
+            if (response.status == 402) {   /* 'Payment Required' stands
+                                               in for cache miss */
+                if (this._retried_map_key_already) {
+                    var e = new FlattenerStoreError(
+                        "Server won't cache flattener map?"
+                    );
+                    if (typeof req.onError == "function")
+                        req.onError.call(callback_scope, e);
+                    else
+                        throw e;
+                } else {
+                    this._retried_map_key_already = true;
+                    delete this.mapKey;
+                    if (retry_method)
+                        return this[retry_method](req);
+                }
+            }
+        },
+
+        "_fetch_prepare": function(req) {
+            req.queryOptions = req.queryOptions || {};
+            req.abort = function() { console.warn("[unimplemented] abort()"); };
+
+            if (!this.mapKey)
+                this._get_map_key();
+
+            return this._build_flattener_params(req);
+        },
+
+        "_fetch_execute": function(params,handle_as,mime_type,onload,onerror) {
+            dojo.xhrPost({
+                "url": this._flattener_url,
+                "content": params,
+                "handleAs": handle_as,
+                "sync": false,
+                "preventCache": true,
+                "headers": {"Accept": mime_type},
+                "load": onload,
+                "error": onerror
+            });
+        },
+
+        /* *** Nonstandard but public API - Please think hard about doing
+         * things the Dojo Way whenever possible before extending the API
+         * here. *** */
+
+        /* fetchToPrint() acts like a lot like fetch(), but doesn't call
+         * onBegin or onComplete.  */
+        "fetchToPrint": function(req) {
+            var callback_scope = req.scope || dojo.global;
+            var post_params;
+
+            try {
+                post_params = this._fetch_prepare(req);
+            } catch (E) {
+                if (typeof req.onError == "function")
+                    req.onError.call(callback_scope, E);
+                else
+                    throw E;
+            }
+
+            var process_fetch_all = dojo.hitch(
+                this, function(text) {
+                    this._retried_map_key_already = false;
+
+                    if (typeof req.onComplete == "function")
+                        req.onComplete.call(callback_scope, text, req);
+                }
+            );
+
+            var process_error = dojo.hitch(
+                this, function(response, ioArgs) {
+                    this._on_http_error(response, ioArgs, req, "fetchToPrint");
+                }
+            );
+
+            this._fetch_execute(
+                post_params,
+                "text",
+                "text/html",
+                process_fetch_all,
+                process_error
+            );
+
+            return req;
+        },
+
         /* *** Begin dojo.data.api.Read methods *** */
 
         "getValue": function(
@@ -223,35 +345,18 @@ if (!dojo._hasResource["openils.FlattenerStore"]) {
             //      onItem   a callback that takes each item as we get it
             //      onComplete  a callback that takes the list of items
             //                      after they're all fetched
-            //
-            //  The onError callback is ignored for now (haven't thought
-            //  of anything useful to do with it yet).
-            //
-            //  The Read API also charges this method with adding an abort
-            //  callback to the *req* object for the caller's use, but
-            //  the one we provide does nothing but issue an alert().
 
-            //console.log("fetch(" + dojo.toJson(req) + ")");
             var self = this;
             var callback_scope = req.scope || dojo.global;
-
-            if (!this.mapKey) {
-                try {
-                    this._get_map_key();
-                } catch (E) {
-                    if (req.onError)
-                        req.onError.call(callback_scope, E);
-                    else
-                        throw E;
-                }
-            }
-
-            var post_params = this._prepare_flattener_params(req);
-
-            if (!post_params) {
-                if (typeof req.onComplete == "function")
-                    req.onComplete.call(callback_scope, [], req);
-                return;
+            var post_params;
+
+            try {
+                post_params = this._fetch_prepare(req);
+            } catch (E) {
+                if (typeof req.onError == "function")
+                    req.onError.call(callback_scope, E);
+                else
+                    throw E;
             }
 
             var process_fetch = function(obj, when) {
@@ -296,41 +401,21 @@ if (!dojo._hasResource["openils.FlattenerStore"]) {
                     req.onComplete.call(callback_scope, obj, req);
             };
 
-            req.abort = function() {
-                throw new FlattenerStoreError(
-                    "The 'abort' operation is not supported"
-                );
-            };
+            var process_error = dojo.hitch(
+                this, function(response, ioArgs) {
+                    this._on_http_error(response, ioArgs, req, "fetch");
+                }
+            );
 
             var fetch_time = this._last_fetch = (new Date().getTime());
 
-            dojo.xhrPost({
-                "url": this._flattener_url,
-                "content": post_params,
-                "handleAs": "json",
-                "sync": false,
-                "preventCache": true,
-                "headers": {"Accept": "application/json"},
-                "load": function(obj) { process_fetch(obj, fetch_time); },
-                "error": function(response, ioArgs) {
-                    if (response.status == 402) {   /* 'Payment Required' stands
-                                                       in for cache miss */
-                        if (self._retried_map_key_already) {
-                            var e = new FlattenerStoreError(
-                                "Server won't cache flattener map?"
-                            );
-                            if (typeof req.onError == "function")
-                                req.onError.call(callback_scope, e);
-                            else
-                                throw e;
-                        } else {
-                            self._retried_map_key_already = true;
-                            delete self.mapKey;
-                            return self.fetch(req);
-                        }
-                    }
-                }
-            });
+            this._fetch_execute(
+                post_params,
+                "json",
+                "application/json",
+                function(obj) { process_fetch(obj, fetch_time); },
+                process_error
+            );
 
             return req;
         },
@@ -368,7 +453,15 @@ if (!dojo._hasResource["openils.FlattenerStore"]) {
                 return;
             }
 
-            var post_params = this._prepare_flattener_params(keywordArgs);
+            var post_params;
+            try {
+                post_params = this._fetch_prepare(keywordArgs);
+            } catch (E) {
+                if (typeof keywordArgs.onError == "function")
+                    keywordArgs.onError.call(callback_scope, E);
+                else
+                    throw E;
+            }
 
             var process_fetch_one = dojo.hitch(
                 this, function(obj, when) {
@@ -404,17 +497,23 @@ if (!dojo._hasResource["openils.FlattenerStore"]) {
                 }
             );
 
+            var process_error = dojo.hitch(
+                this, function(response, ioArgs) {
+                    this._on_http_error(
+                        response, ioArgs, keywordArgs, "fetchItemByIdentity"
+                    );
+                }
+            );
+
             var fetch_time = this._last_fetch = (new Date().getTime());
 
-            dojo.xhrPost({
-                "url": this._flattener_url,
-                "content": post_params,
-                "handleAs": "json",
-                "sync": false,
-                "preventCache": true,
-                "headers": {"Accept": "application/json"},
-                "load": function(obj){ process_fetch_one(obj, fetch_time); }
-            });
+            this._fetch_execute(
+                post_params,
+                "json",
+                "application/json",
+                function(obj) { process_fetch_one(obj, fetch_time); },
+                process_error
+            );
         },
 
         /* dojo.data.api.Write - only very partially implemented, because
index a18cd4a..2be4d48 100644 (file)
@@ -16,7 +16,10 @@ if (!dojo._hasResource["openils.widget.FlattenerGrid"]) {
              * FlattenerGrid in their own right */
             "columnReordering": true,
             "columnPersistKey": null,
+            "autoCoreFields": false,
+            "autoFieldFields": null,
             "showLoadFilter": false,    /* use FlattenerFilterDialog */
+            "fetchLock": false,
 
             /* These potential constructor arguments maybe useful to
              * FlattenerGrid in their own right, and are passed to
@@ -24,6 +27,7 @@ if (!dojo._hasResource["openils.widget.FlattenerGrid"]) {
             "fmClass": null,
             "fmIdentifier": null,
             "mapExtras": null,
+            "sortFieldReMap": null,
             "defaultSort": null,  /* whatever any part of the UI says will
                                      /replace/ this */
             "baseSort": null,     /* will contains what the columnpicker
@@ -53,12 +57,12 @@ if (!dojo._hasResource["openils.widget.FlattenerGrid"]) {
                 /* These are the fields defined in thead -> tr -> [th,th,...].
                  * For purposes of building the map, where each field has
                  * three boolean attributes "display", "sort" and "filter",
-                 * assume "display" and "sort" are always true for these.
+                 * assume "display" is always true for these.
                  * That doesn't mean that at the UI level we can't hide a
                  * column later.
                  *
                  * If you need extra fields in the map for which display
-                 * or sort should *not* be true, use mapExtras.
+                 * should *not* be true, use mapExtras.
                  */
                 dojo.forEach(
                     fields, function(field) {
@@ -68,7 +72,7 @@ if (!dojo._hasResource["openils.widget.FlattenerGrid"]) {
                         map[field.field] = {
                             "display": true,
                             "filter": (field.ffilter || false),
-                            "sort": true,
+                            "sort": field.fsort,
                             "path": field.fpath || field.field
                         };
                         /* The following attribute is not for the flattener
@@ -133,78 +137,96 @@ if (!dojo._hasResource["openils.widget.FlattenerGrid"]) {
                 return clean;
             },
 
-            /* The FlattenerStore doesn't need this, but it has at least two
-             * uses: 1) FlattenerFilterDialog, 2) setting column header labels
-             * to IDL defaults.
-             *
-             * To call these 'Terminii' can be misleading. In certain
-             * (actually probably common) cases, they won't really be the last
-             * field in a path, but the next-to-last. Read on. */
-            "_calculateMapTerminii": function() {
-                function _fm_is_selector_for_class(hint, field) {
-                    var cl = fieldmapper.IDL.fmclasses[hint];
+            /* Given the hint of a class to start at, follow path to the end
+             * and return information on the last field.  */
+            "_followPathToEnd": function(hint, path, allow_selector_backoff) {
+                function _fm_is_selector_for_class(h, field) {
+                    var cl = fieldmapper.IDL.fmclasses[h];
                     return (cl.field_map[cl.pkey].selector == field);
                 }
 
-                function _follow_to_end(hint, path) {
-                    var last_field, last_hint;
-                    var orig_path = dojo.clone(path);
-                    var field;
-
-                    while (field = path.shift()) {
-                        /* XXX this assumes we have the whole IDL loaded. I
-                         * guess we could teach this to work by loading classes
-                         * on demand when we don't have the whole IDL loaded. */
-                        var field_def =
-                            fieldmapper.IDL.fmclasses[hint].field_map[field];
-
-                        if (field_def["class"] && path.length) {
-                            last_field = field;
-                            last_hint = hint;
-
-                            hint = field_def["class"];
-                        } else if (path.length) {
-                            /* There are more fields left but we can't follow
-                             * the chain via IDL any further. */
-                            throw new Error(
-                                "_calculateMapTerminii can't parse path " +
-                                orig_path + " (at " + field + ")"
-                            );
-                        } else {
-                            break;  /* keeps field defined after loop */
-                        }
+                var last_field, last_hint;
+                var orig_path = dojo.clone(path);
+                var field, field_def;
+
+                while (field = path.shift()) {
+                    /* XXX this assumes we have the whole IDL loaded. I
+                     * guess we could teach this to work by loading classes
+                     * on demand when we don't have the whole IDL loaded. */
+                    field_def =
+                        fieldmapper.IDL.fmclasses[hint].field_map[field];
+
+                    if (!field_def) {
+                        /* This can be ok in some cases. Columns following
+                         * IDL paths involving links with a nonempty "map"
+                         * attribute can be used for display only (no
+                         * sort, no filter). */
+                        console.info(
+                            "Lost our way in IDL at hint " + hint +
+                            ", field " + field + "; may be ok"
+                        );
+                        return null;
                     }
 
-                    var datatype = field_def.datatype;
-                    var indirect = false;
-                    /* Back off the last field in the path if it's a selector
-                     * for its class, because the preceding field will be
-                     * a better thing to hand to AutoFieldWidget.
-                     */
-                    if (orig_path.length > 1 &&
-                            _fm_is_selector_for_class(hint, field)) {
-                        hint = last_hint;
-                        field = last_field;
-                        datatype = "link";
-                        indirect = true;
+                    if (field_def["class"]) {
+                        last_field = field;
+                        last_hint = hint;
+
+                        hint = field_def["class"];
+                    } else if (path.length) {
+                        /* There are more fields left but we can't follow
+                         * the chain via IDL any further. */
+                        throw new Error(
+                            "_calculateMapTerminii can't parse path " +
+                            orig_path + " (at " + field + ")"
+                        );
                     }
+                }
 
-                    return {
-                        "fmClass": hint,
-                        "name": field,
-                        "label": field_def.label,
-                        "datatype": datatype,
-                        "indirect": indirect
-                    };
+                var datatype = field_def.datatype;
+                var indirect = false;
+                /* If allowed, back off the last field in the path if it's a
+                 * selector for its class, because the preceding field will be
+                 * a better thing to hand to AutoFieldWidget.
+                 */
+                if (orig_path.length > 1 && allow_selector_backoff &&
+                        _fm_is_selector_for_class(hint, field_def.name)) {
+                    hint = last_hint;
+                    field = last_field;
+                    datatype = "link";
+                    indirect = true;
+                } else {
+                    field = field_def.name;
                 }
 
+                return {
+                    "fmClass": hint,
+                    "name": field,
+                    "label": field_def.label,
+                    "datatype": datatype,
+                    "indirect": indirect
+                };
+            },
+
+            /* The FlattenerStore doesn't need this, but it has at least two
+             * uses: 1) FlattenerFilterDialog, 2) setting column header labels
+             * to IDL defaults.
+             *
+             * To call these 'Terminii' can be misleading. In certain
+             * (actually probably common) cases, they won't really be the last
+             * field in a path, but the next-to-last. Read on. */
+            "_calculateMapTerminii": function() {
                 this.mapTerminii = [];
                 for (var column in this.mapClause) {
+                    var end = this._followPathToEnd(
+                        this.fmClass,
+                        this.mapClause[column].path.split(/\./),
+                        true /* allow selector backoff */
+                    );
+                    if (!end)
+                        continue;
                     var terminus = dojo.mixin(
-                        _follow_to_end(
-                            this.fmClass,
-                            this.mapClause[column].path.split(/\./)
-                        ), {
+                        end, {
                             "simple_name": column,
                             "isfilter": this.mapClause[column].filter
                         }
@@ -217,8 +239,7 @@ if (!dojo._hasResource["openils.widget.FlattenerGrid"]) {
             },
 
             "_supplementHeaderNames": function() {
-                /* You'd be surprised how rarely this make sense in Flattener
-                 * use cases, but if we didn't give a particular header cell
+                /* If we didn't give a particular header cell
                  * (<th>) a display name (the innerHTML of that <th>), then
                  * use the IDL to provide the label of the terminus of the
                  * flattener path for that column. It may be better than using
@@ -237,6 +258,122 @@ if (!dojo._hasResource["openils.widget.FlattenerGrid"]) {
                 );
             },
 
+            "_columnOrderingAndLabels": function() {
+                var labels = [];
+                var columns = [];
+
+                this.views.views[0].structure.cells[0].forEach(
+                    function(c) {
+                        if (!c.field.match(/^\+/)) {
+                            labels.push(c.name);
+                            columns.push(c.field);
+                        }
+                    }
+                );
+
+                return {"labels": labels, "columns": columns};
+            },
+
+            "_getAutoFieldFields": function(fmclass) {
+                return dojo.clone(
+                    fieldmapper.IDL.fmclasses[fmclass].fields)
+                .filter(
+                    function(field) {
+                        return !field.virtual && field.datatype != "link";
+                    }
+                ).sort(
+                    function(a, b) { return a.label > b.label ? 1 : -1; }
+                );
+            },
+
+            /* Take our core class (this.fmClass) and add table columns for
+             * any field we don't already have covered by actual hard-coded
+             * <th> columns. */
+            "_addAutoCoreFields": function() {
+                var cell_list = this.structure[0].cells[0];
+                var fields = dojo.clone(
+                    fieldmapper.IDL.fmclasses[this.fmClass].fields
+                ).sort(
+                    function(a, b) { return a.label > b.label ? 1 : -1; }
+                );
+
+                dojo.forEach(
+                    fields, function(f) {
+                        if (f.datatype == "link" || f.virtual)
+                            return;
+
+                        if (cell_list.filter(
+                            function(c) {
+                                if (!c.fpath) return false;
+                                return c.fpath.split(/\./)[0] == f.name;
+                            }
+                        ).length)
+                            return;
+
+                        cell_list.push({
+                            "field": f.name,
+                            "name": f.label,
+                            "fsort": true,
+                            "_visible": false
+                        });
+                    }
+                );
+            },
+
+            "_addAutoFieldFields": function(paths) {
+                var self = this;
+                var n = 0;
+
+                dojo.forEach(
+                    paths, function(path) {
+                        /* The beginning is the end. */
+                        var beginning = self._followPathToEnd(
+                            self.fmClass, path.split(/\./), false
+                        );
+                        if (!beginning) {
+                            return;
+                        } else {
+                            console.log(dojo.toJson(beginning));
+                            dojo.forEach(
+                                self._getAutoFieldFields(beginning.fmClass),
+                                function(field) {
+                                    var would_be_path =
+                                        path + "." + field.name;
+                                    var wbp_re =
+                                        new RegExp("^" + would_be_path);
+                                    if (!self.structure[0].cells[0].filter(
+                                        function(c) {
+                                            return c.fpath &&
+                                                c.fpath.match(wbp_re);
+                                        }
+                                    ).length) {
+                                        self.structure[0].cells[0].push({
+                                            "field": "AUTO_" + beginning.name +
+                                                "_" + field.name,
+                                            "name": beginning.label + " - " +
+                                                field.label,
+                                            "fsort": true,
+                                            "fpath": would_be_path,
+                                            "_visible": false
+                                        });
+                                    }
+                                }
+                            );
+                        }
+                    }
+                );
+            },
+
+            "_addAutoFields": function() {
+                if (this.autoCoreFields)
+                    this._addAutoCoreFields();
+
+                if (dojo.isArray(this.autoFieldFields))
+                    this._addAutoFieldFields(this.autoFieldFields);
+
+                this.setStructure(this.structure);
+            },
+
             "constructor": function(args) {
                 dojo.mixin(this, args);
 
@@ -245,9 +382,11 @@ if (!dojo._hasResource["openils.widget.FlattenerGrid"]) {
             },
 
             "startup": function() {
-
                 /* Save original query for further filtering later */
                 this._baseQuery = dojo.clone(this.query);
+
+                this._addAutoFields();
+
                 this._startupGridHelperColumns();
 
                 if (!this.columnPicker) {
@@ -271,6 +410,17 @@ if (!dojo._hasResource["openils.widget.FlattenerGrid"]) {
                 this.inherited(arguments);
             },
 
+            "canSort": function(idx, skip_structure /* API abuse */) {
+                var initial = this.inherited(arguments);
+
+                /* idx is one-based instead of zero-based for a reason. */
+                var view_idx = Math.abs(idx) - 1;
+                return initial && (
+                    skip_structure ||
+                        this.views.views[0].structure.cells[0][view_idx].fsort
+                );
+            },
+
             /*  Maps ColumnPicker sort fields to the correct format.
                 If no sort fields specified, falls back to defaultSort */
             "_mapCPSortFields": function(sortFields) {
@@ -287,20 +437,25 @@ if (!dojo._hasResource["openils.widget.FlattenerGrid"]) {
 
             "_finishStartup": function(sortFields) {
 
-                this.setStore(
+                this._setStore( /* Seriously, let's leave this as _setStore. */
                     new openils.FlattenerStore({
                         "fmClass": this.fmClass,
                         "fmIdentifier": this.fmIdentifier,
                         "mapClause": (this.mapClause ||
                             this._cleanMapForStore(this._generateMap())),
                         "baseSort": this.baseSort,
-                        "defaultSort": this._mapCPSortFields(sortFields)
+                        "defaultSort": this._mapCPSortFields(sortFields),
+                        "sortFieldReMap": this.sortFieldReMap
+
                     }), this.query
                 );
 
                 // pick up any column label changes
                 this.columnPicker.reloadStructure();
 
+                if (!this.fetchLock)
+                    this._refresh(true);
+
                 this._showing_create_pane = false;
 
                 this.overrideEditWidgets = {};
@@ -352,6 +507,18 @@ if (!dojo._hasResource["openils.widget.FlattenerGrid"]) {
                 }
             },
 
+            "refresh": function() {
+                this.fetchLock = false;
+                this._refresh(/* isRender */ true);
+            },
+
+            "_fetch": function() {
+                if (this.fetchLock)
+                    return;
+                else
+                    return this.inherited(arguments);
+            },
+
             /* ******** below are methods mostly copied but
              * slightly changed from AutoGrid ******** */
 
@@ -662,6 +829,26 @@ if (!dojo._hasResource["openils.widget.FlattenerGrid"]) {
                         );
                     }
                 );
+            },
+
+            /* Print the same data that the Flattener is feeding to the
+             * grid, sorted the same way too. remove limit and offset (i.e.,
+             * print it all. */
+            "print": function() {
+                var coal = this._columnOrderingAndLabels();
+                var req = {
+                    "query": this.query,
+                    "queryOptions": {
+                        "all": true,
+                        "columns": coal.columns,
+                        "labels": coal.labels
+                    },
+                    "onComplete": function(text) {
+                        openils.Util.printHtmlString(text);
+                    }
+                };
+
+                this.store.fetchToPrint(req);
             }
         }
     );
@@ -683,6 +870,15 @@ if (!dojo._hasResource["openils.widget.FlattenerGrid"]) {
                         cellDef[a] = value;
                 }
             );
+
+            /* fsort and _visible are different. Assume true unless defined. */
+            dojo.forEach(
+                ["fsort", "_visible"], function(a) {
+                    var val = dojo.attr(node, a);
+                    cellDef[a] = (typeof val == "undefined" || val === null) ?
+                        true : dojo.fromJson(val);
+                }
+            );
         };
     })();
 
index 9cc367d..d0c86db 100644 (file)
@@ -63,8 +63,9 @@ if(!dojo._hasResource["openils.widget.GridColumnPicker"]) {
          *  This is necessary if external forces alter the structure. 
          */
         reloadStructure : function() {
-            this.structure = this.grid.structure;
             this.cells = this.structure[0].cells[0].slice();
+            this.pruneInvisibleFields();
+            this.structure = this.grid.structure;
             this.grid.setStructure(this.structure);
         },
 
@@ -114,7 +115,7 @@ if(!dojo._hasResource["openils.widget.GridColumnPicker"]) {
                 "<th width='23%'>Auto Width</th><th width='23%'>Sort Priority</th></tr></thead>" +
                 "<tbody />"});
 
-            var tDiv = dojo.create('div', {style : 'height:400px; overflow-y:auto;'});
+            var tDiv = dojo.create('div', {style : 'min-height: 400px;'});
             tDiv.appendChild(table);
 
             var bDiv = dojo.create('div', {style : 'text-align:right; width:100%;',
@@ -211,16 +212,24 @@ if(!dojo._hasResource["openils.widget.GridColumnPicker"]) {
                 else
                     this.dialogTable.appendChild(tr);
 
-                if ( this.grid.canSort(i+1) ) { // column index is 1-based
-
-                    // must be added after its parent node is inserted into the DOM.
-                    var ns = new dijit.form.NumberSpinner(
-                        {   constraints : {places : 0}, 
-                            value : cell._sort || 0,
-                            style : 'width:4em',
-                            name : 'sort',
-                        }, ipt3
-                    );
+                if (this.grid.canSort(
+                    i + 1,  /* column index is 1-based */
+                    true    /* skip structure test (API abuse) */
+                )) { 
+
+                    /* Ugly kludge. When using with FlattenerGrid the
+                     * conditional is needed. Shouldn't hurt usage with
+                     * AutoGrid. */
+                    if (typeof cell.fsort == "undefined" || cell.fsort) {
+                        // must be added after its parent node is inserted into the DOM.
+                        var ns = new dijit.form.NumberSpinner(
+                            {   constraints : {places : 0}, 
+                                value : cell._sort || 0,
+                                style : 'width:4em',
+                                name : 'sort',
+                            }, ipt3
+                        );
+                    }
                 }
             }
         },
@@ -366,6 +375,18 @@ if(!dojo._hasResource["openils.widget.GridColumnPicker"]) {
             this.grid.update();
         },
 
+        // *only* call this when no usr setting tells us what columns
+        // are visible or not.
+        pruneInvisibleFields : function() {
+            this.structure[0].cells[0] = dojo.filter(
+                this.structure[0].cells[0],
+                dojo.hitch(this, function(c) {
+                    // keep true or undef, lose false
+                    return typeof c._visible == "undefined" || c._visible;
+                })
+            );
+        },
+
         load : function() {
             var _this = this;
 
index e9385dd..17823fc 100644 (file)
 <!ENTITY staff.patron.holds_overlay.print_full_pull_list.accesskey "u">
 <!ENTITY staff.patron.holds_overlay.print_alt_pull_list.label "Print Full Pull List (Alternate strategy)">
 <!ENTITY staff.patron.holds_overlay.print_alt_pull_list.accesskey "y">
+<!ENTITY staff.patron.holds_overlay.simplified_pull_list.label "Simplified Pull List Interface">
+<!ENTITY staff.patron.holds_overlay.simplified_pull_list.accesskey "i">
 <!ENTITY staff.patron.holds_overlay.place_hold.label "Place Hold">
 <!ENTITY staff.patron.holds_overlay.place_hold.accesskey "H">
 <!ENTITY staff.patron.holds_overlay.show_cancelled_holds.label "Show Cancelled Holds">
index c988ba8..eaa7e37 100644 (file)
@@ -9,6 +9,13 @@
     <html>
         <head>
             <meta http-equiv="Content-Type" content="text/html" charset="utf-8"/>
+            <style type="text/css">
+                /* This CSS controls whether data printed from an interface
+                based on FlattenerGrid has visible table cell borders. */
+
+                table { border-collapse: collapse; }
+                td, th { border: 1px solid black; }
+            </style>
         </head>
         <body>
             <table>
index cb45955..f289260 100644 (file)
@@ -389,6 +389,44 @@ patron.holds.prototype = {
                             }
                         }
                     ],
+                    'cmd_simplified_pull_list' : [
+                        ['command'],
+                        function() {
+                            try {
+                                var content_params = {
+                                    "session": ses(),
+                                    "authtime": ses("authtime"),
+                                    "no_xulG": false,
+                                    "show_nav_buttons": true,
+                                    "show_print_button": true
+                                };
+                                ["url_prefix", "new_tab", "set_tab",
+                                    "close_tab", "new_patron_tab",
+                                    "set_patron_tab", "volume_item_creator",
+                                    "get_new_session",
+                                    "holdings_maintenance_tab", "set_tab_name",
+                                    "open_chrome_window", "url_prefix",
+                                    "network_meter", "page_meter",
+                                    "set_statusbar", "set_help_context"
+                                ].forEach(function(k) {
+                                    content_params[k] = xulG[k];
+                                });
+
+                                var loc = urls.XUL_BROWSER + "?url=" + window.escape(
+                                    xulG.url_prefix("/eg/circ/hold_pull_list").replace("http:","https:")
+                                );
+                                xulG.new_tab(
+                                    loc, {
+                                        "tab_name": "Simplified Pull List", /* XXX i18n */
+                                        "browser": false,
+                                        "show_print_button": false
+                                    }, content_params
+                                );
+                            } catch (E) {
+                                g.error.sdump("D_ERROR", E);
+                            }
+                        }
+                    ],
                     'cmd_holds_print' : [
                         ['command'],
                         function() {
@@ -1489,6 +1527,7 @@ patron.holds.prototype = {
         var x_expired_checkbox = document.getElementById('expired_checkbox');
         var x_print_full_pull_list = document.getElementById('print_full_btn');
         var x_print_full_pull_list_alt = document.getElementById('print_alt_btn');
+        var x_simplified_pull_list = document.getElementById('simplified_pull_list_btn');
         switch(obj.hold_interface_type) {
             case 'shelf':
                 obj.render_lib_menus({'pickup_lib':true});
@@ -1497,6 +1536,7 @@ patron.holds.prototype = {
                 if (x_lib_menu_placeholder) x_lib_menu_placeholder.hidden = false;
                 if (x_clear_shelf_widgets) x_clear_shelf_widgets.hidden = false;
                 if (x_print_full_pull_list_alt) x_print_full_pull_list_alt.hidden = true;
+                if (x_simplified_pull_list) x_simplified_pull_list.hidden = true;
             break;
             case 'pull' :
                 if (x_fetch_more) x_fetch_more.hidden = false;
@@ -1504,6 +1544,7 @@ patron.holds.prototype = {
                 if (x_print_full_pull_list_alt) x_print_full_pull_list_alt.hidden = false;
                 if (x_lib_type_menu) x_lib_type_menu.hidden = true;
                 if (x_lib_menu_placeholder) x_lib_menu_placeholder.hidden = true;
+                if (x_simplified_pull_list) x_simplified_pull_list.hidden = false;
             break;
             case 'record' :
                 obj.render_lib_menus({'pickup_lib':true,'request_lib':true});
@@ -1511,6 +1552,7 @@ patron.holds.prototype = {
                 if (x_lib_type_menu) x_lib_type_menu.hidden = false;
                 if (x_print_full_pull_list_alt) x_print_full_pull_list_alt.hidden = true;
                 if (x_lib_menu_placeholder) x_lib_menu_placeholder.hidden = false;
+                if (x_simplified_pull_list) x_simplified_pull_list.hidden = true;
             break;
             default:
                 if (obj.controller.view.cmd_search_opac) obj.controller.view.cmd_search_opac.setAttribute('hidden', false);
@@ -1519,6 +1561,7 @@ patron.holds.prototype = {
                 if (x_lib_menu_placeholder) x_lib_menu_placeholder.hidden = true;
                 if (x_show_cancelled_deck) x_show_cancelled_deck.hidden = false;
                 if (x_print_full_pull_list_alt) x_print_full_pull_list_alt.hidden = true;
+                if (x_simplified_pull_list) x_simplified_pull_list.hidden = true;
             break;
         }
         setTimeout( // We do this because render_lib_menus above creates and appends a DOM node, but until this thread exits, it doesn't really happen
index 1e05002..2326cdf 100644 (file)
@@ -20,6 +20,7 @@
         <command id="cmd_holds_print" />
         <command id="cmd_holds_print_full" />
         <command id="cmd_holds_print_alt" />
+        <command id="cmd_simplified_pull_list" />
         <command id="cmd_show_catalog" />
         <command id="cmd_retrieve_patron" />
         <command id="cmd_holds_edit_desire_mint_condition" />
         <button id="holds_print" label="&staff.patron.holds_overlay.print.label;" command="cmd_holds_print" accesskey="&staff.patron.holds_overlay.print.accesskey;" />
         <button id="print_full_btn" hidden="true" label="&staff.patron.holds_overlay.print_full_pull_list.label;" command="cmd_holds_print_full" accesskey="&staff.patron.holds_overlay.print_full_pull_list.accesskey;" />
         <button id="print_alt_btn" hidden="true" label="&staff.patron.holds_overlay.print_alt_pull_list.label;" command="cmd_holds_print_alt" accesskey="&staff.patron.holds_overlay.print_alt_pull_list.accesskey;" />
+        <button id="simplified_pull_list_btn" hidden="true" label="&staff.patron.holds_overlay.simplified_pull_list.label;" command="cmd_simplified_pull_list" accesskey="&staff.patron.holds_overlay.simplified_pull_list.accesskey;" />
         <spacer flex="1"/>
     </hbox>