Angular-based staff catalog experiment WIP
authorBill Erickson <berickxx@gmail.com>
Thu, 26 Oct 2017 21:56:36 +0000 (17:56 -0400)
committerBill Erickson <berickxx@gmail.com>
Fri, 10 Nov 2017 14:58:09 +0000 (09:58 -0500)
Signed-off-by: Bill Erickson <berickxx@gmail.com>
12 files changed:
Open-ILS/examples/fm_IDL.xml
Open-ILS/src/perlmods/lib/OpenILS/Application/Search/Biblio.pm
Open-ILS/src/templates/staff/cat/staffcat/index.tt2 [new file with mode: 0644]
Open-ILS/src/templates/staff/cat/staffcat/search_form.tt2 [new file with mode: 0644]
Open-ILS/src/templates/staff/cat/staffcat/search_result_facets.tt2 [new file with mode: 0644]
Open-ILS/src/templates/staff/cat/staffcat/search_result_pagination.tt2 [new file with mode: 0644]
Open-ILS/src/templates/staff/cat/staffcat/search_result_record.tt2 [new file with mode: 0644]
Open-ILS/src/templates/staff/cat/staffcat/t_record.tt2 [new file with mode: 0644]
Open-ILS/src/templates/staff/cat/staffcat/t_search.tt2 [new file with mode: 0644]
Open-ILS/src/templates/staff/css/style.css.tt2
Open-ILS/src/templates/staff/navbar.tt2
Open-ILS/web/js/ui/default/staff/cat/staffcat/app.js [new file with mode: 0644]

index edd7054..0738f9c 100644 (file)
@@ -3157,7 +3157,7 @@ Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA
                        <link field="authority_links" reltype="has_many" key="bib" map="" class="abl"/>
                        <link field="subscriptions" reltype="has_many" key="record_entry" map="" class="ssub"/>
                        <link field="attrs" reltype="might_have" key="id" map="" class="mra"/>
-                       <link field="mattrs" reltype="might_have" key="id" map="" class="mraf"/>
+                       <link field="mattrs" reltype="has_many" key="id" map="" class="mraf"/>
                        <link field="source" reltype="has_a" key="id" map="" class="cbs"/>
                        <link field="display_entries" reltype="has_many" key="source" map="" class="mde"/>
                        <link field="flat_display_entries" reltype="has_many" key="source" map="" class="mfde"/>
index 06ec97d..5c9eafc 100644 (file)
@@ -2623,6 +2623,50 @@ sub copies_by_cn_label {
     return [ map { ($U->is_true($_->location->opac_visible) && $U->is_true($_->status->opac_visible)) ? ($_->id) : () } @$copies ];
 }
 
+__PACKAGE__->register_method(
+    method   => 'staffcat_bib_extras',
+    api_name => 'open-ils.search.biblio.catalog.extras.staff',
+    stream => 1,
+     # disable opensrf bundling/chunking for timely responses.
+    max_chunk_size => 0,
+    max_bundle_size => 0,
+    max_bundle_count => 0,
+    signaturn => {
+        desc => q/
+Collect various APIs needed for bib list display in the staff catalog into 
+a single API to streamline./
+    },
+);
+
+sub staffcat_bib_extras {
+    my ($self, $client, $auth, $org_id, $bre_ids) = @_;
+
+    # No auth checks are needed at time of writing.
+    # Reserving for possible future use of $auth.
+    my $e = new_editor(authtoken => $auth);
+
+    for my $bre_id (@$bre_ids) {
+
+        # Fire hold counts in parallel.  Response is collected below.
+        my $hold_req = OpenSRF::AppSession->create('open-ils.circ')
+            ->request('open-ils.circ.bre.holds.count', $bre_id);
+
+        my $copy_counts = $e->json_query({from =>
+            ['asset.record_copy_count', $org_id, $bre_id, 1]});
+
+        $copy_counts = [sort { $a->{depth} <=> $b->{depth} } @$copy_counts];
+
+        $client->respond({
+            id => $bre_id,
+            copy_counts => $copy_counts,
+            hold_count => $hold_req->gather(1)
+        });
+    }
+
+    return undef;
+}
+
+
 
 1;
 
diff --git a/Open-ILS/src/templates/staff/cat/staffcat/index.tt2 b/Open-ILS/src/templates/staff/cat/staffcat/index.tt2
new file mode 100644 (file)
index 0000000..a24b1db
--- /dev/null
@@ -0,0 +1,25 @@
+[%
+  WRAPPER "staff/base.tt2";
+  ctx.page_title = l("Staff Catalog"); 
+  ctx.page_app = "egStaffCatApp";
+  ctx.page_ctrl = "StaffCatBaseCtrl";
+%]
+
+[% BLOCK APP_JS %]
+<script src="[% ctx.media_prefix %]/js/ui/default/staff/cat/services/record.js"></script>
+<script src="[% ctx.media_prefix %]/js/ui/default/staff/cat/staffcat/app.js"></script>
+<script>
+angular.module('egCoreMod').run(['egStrings', function(s) {
+}])
+</script>
+
+[% END %]
+
+<!-- search form is anchored to the top of the page -->
+[% INCLUDE 'staff/cat/staffcat/search_form.tt2' %]
+
+<!-- search results, record details, etc. -->
+<div ng-view></div>
+
+[% END %]
+
diff --git a/Open-ILS/src/templates/staff/cat/staffcat/search_form.tt2 b/Open-ILS/src/templates/staff/cat/staffcat/search_form.tt2
new file mode 100644 (file)
index 0000000..839424b
--- /dev/null
@@ -0,0 +1,216 @@
+<style>
+  /** TODO: clean up some inline styles */
+
+  #staffcat-search-form .eg-org-selector,
+  #staffcat-search-form .eg-org-selector button {
+    width: 100%;
+    text-align: left
+  }
+  .flex-cell {
+    /* override style.css padding:4px */
+    padding: 0px 0px 0px 8px;
+  }
+  .flex-row:first-child {
+    padding-left: 0px;
+  }
+</style>
+<div id='staffcat-search-form'>
+  <div class="row"
+    ng-repeat="q in context.search_args.query track by $index">
+    <div class="col-md-9 flex-row">
+      <div class="flex-cell">
+        <div ng-if="$index == 0">
+          <select class="form-control" ng-model="context.search_args.format">
+            <option value=''>[% l('All Formats') %]</option>
+            <option ng-repeat="fmt in context.ccvm_lists('search_format')"
+              value="{{fmt.code()}}">{{fmt.value()}}</option>
+          </select>
+        </div>
+        <div ng-if="$index > 0">
+          <select class="form-control"
+            ng-model="context.search_args.joiner[$index]">
+            <option value='&&'>[% l('And') %]</option>
+            <option value='||'>[% l('Or') %]</option>
+          </select>
+        </div>
+      </div>
+      <div class="flex-cell">
+        <select class="form-control" 
+          ng-model="context.search_args.type[$index]">
+          <option value='keyword'>[% l('Keyword') %]</option>
+          <option value='title'>[% l('Title') %]</option>
+          <option value='jtitle'>[% l('Journal Title') %]</option>
+          <option value='author'>[% l('Author') %]</option>
+          <option value='subject'>[% l('Subject') %]</option>
+          <option value='series'>[% l('Series') %]</option>
+          <!-- TODO: bookplate -->
+        </select>
+      </div>
+      <div class="flex-cell">
+        <select class="form-control" 
+          ng-model="context.search_args.match[$index]">
+          <option value='contains'>[% l('Contains') %]</option>
+          <option value='nocontains'>[% l('Does not contain') %]</option>
+          <option value='phrase'>[% l('Contains phrase') %]</option>
+          <option value='exact'>[% l('Matches exactly') %]</option>
+          <option value='starts'>[% l('Starts with') %]</option>
+        </select>
+      </div>
+      <div class="flex-cell flex-2">
+        <div class="form-group">
+          <input type="text" class="form-control"
+            focus-me="context.focus_query[$index]"
+            ng-model="context.search_args.query[$index]"
+            ng-keyup="context.check_enter($event)"
+            placeholder="[% l('Query...') %]"/>
+        </div>
+      </div>
+      <div class="flex-cell">
+        <button class="btn btn-sm btn-default" 
+          ng-click="context.add_search_row($index + 1)">
+          <span class="glyphicon glyphicon-plus"></span>
+        </button>
+        <button class="btn btn-sm btn-default"
+          ng-disabled="context.search_args.query.length < 2"
+          ng-click="context.del_search_row($index)">
+          <span class="glyphicon glyphicon-minus"></span>
+        </button>
+      </div>
+    </div>
+    <div class="col-md-3">
+      <div ng-if="$index == 0" class="pull-right">
+        <button class="btn btn-success" type="button"
+          ng-click="context.search_args.offset=0;context.search_by_form()">
+          [% l('Search') %]
+        </button>
+        <button class="btn btn-warning" type="button"
+          ng-click="context.reset_form()">
+          [% l('Clear Form') %]
+        </button>
+        <button class="btn btn-default" type="button"
+          ng-if="!context.show_adv_search"
+          ng-click="context.show_adv_search=true">
+          [% l('More Filters...') %]
+        </button>
+        <button class="btn btn-default" type="button"
+          ng-if="context.show_adv_search"
+          ng-click="context.show_adv_search=false">
+          [% l('Hide Filters') %]
+        </button>
+      </div>
+    </div>
+  </div>
+  <div class="row">
+    <div class="col-md-9 flex-row">
+      <div class="flex-cell">
+        <eg-org-selector nodefault 
+          selected="context.search_args.context_org">
+        </eg-org-selector>
+      </div>
+      <div class="flex-cell flex-3">
+        <select class="form-control" ng-model="context.search_args.sort">
+          <option value=''>[% l('Sort by Relevance') %]</option>
+          <optgroup label="[% l('Sort by Title') %]">
+            <option value='titlesort'>[% l('Title: A to Z') %]</option>
+            <option value='titlesort.descending'>[% l('Title: Z to A') %]</option>
+          </optgroup>
+          <optgroup label="[% l('Sort by Author') %]">
+            <option value='authorsort'>[% l('Author: A to Z') %]</option>
+            <option value='authorsort.descending'>[% l('Author: Z to A') %]</option>
+          </optgroup>
+          <optgroup label="[% l('Sort by Publication Date') %]">
+            <option value='pubdate'>[% l('Date: A to Z') %]</option>
+            <option value='pubdate.descending'>[% l('Date: Z to A') %]</option>
+          </optgroup>
+          <optgroup label="[% l('Sort by Popularity') %]">
+            <option value='popularity'>[% l('Most Popular') %]</option>
+            <option value='poprel'>[% l('Popularity Adjusted Relevance') %]</option>
+          </optgroup>
+        </select>
+      </div>
+      <div class="flex-cell flex-2">
+        <div class="checkbox">
+          <label>
+            <input type="checkbox" ng-model="context.search_args.available"/>
+            [% l('Limit to Available') %]
+          </label>
+        </div>
+      </div>
+      <div class="flex-cell flex-4">
+        <div class="checkbox">
+          <label>
+            <input type="checkbox" ng-model="context.search_args.global"/>
+            [% l('Show Results from All Libraries') %]
+          </label>
+        </div>
+      </div>
+      <!-- balance out the flow -->
+      <div class="flex-cell flex-2">
+        <div ng-if="context.search_state() == 'searching'">
+          <div class="progress">
+            <div class="progress-bar progress-bar-striped active"
+              role="progressbar" aria-valuenow="100" aria-valuemin="0"
+              aria-valuemax="100" style="width: 100%">
+              <span class="sr-only">[% l('Searching..') %]</span>
+            </div>
+          </div>
+        </div>
+      </div>
+    </div>
+  </div>
+
+  <div class="row pad-vert-min" ng-show="context.show_adv_search">
+    <div class="col-md-2">
+      <select class="form-control" ng-model="context.search_args.item_type" multiple="true">
+        <option value=''>[% l('All Item Types') %]</option>
+        <option ng-repeat="item_type in context.ccvm_lists('item_type')"
+          value="{{item_type.code()}}">{{item_type.value()}}</option>
+      </select>
+    </div>
+    <div class="col-md-2">
+      <select class="form-control" ng-model="context.search_args.item_form" multiple="true">
+        <option value=''>[% l('All Item Forms') %]</option>
+        <option ng-repeat="item_form in context.ccvm_lists('item_form')"
+          value="{{item_form.code()}}">{{item_form.value()}}</option>
+      </select>
+    </div>
+    <div class="col-md-2">
+      <select class="form-control" ng-model="context.search_args.item_lang" multiple="true">
+        <option value=''>[% l('All Languages') %]</option>
+        <option ng-repeat="item_lang in context.ccvm_lists('item_lang')"
+          value="{{item_lang.code()}}">{{item_lang.value()}}</option>
+      </select>
+    </div>
+    <div class="col-md-2">
+      <select class="form-control" ng-model="context.search_args.audience" multiple="true">
+        <option value=''>[% l('All Audiences') %]</option>
+        <option ng-repeat="audience in context.ccvm_lists('audience')"
+          value="{{audience.code()}}">{{audience.value()}}</option>
+      </select>
+    </div>
+  </div>
+  <div class="row pad-vert-min" ng-show="context.show_adv_search">
+    <div class="col-md-2">
+      <select class="form-control" ng-model="context.search_args.vr_format" multiple="true">
+        <option value=''>[% l('All Video Formats') %]</option>
+        <option ng-repeat="vr_format in context.ccvm_lists('vr_format')"
+          value="{{vr_format.code()}}">{{vr_format.value()}}</option>
+      </select>
+    </div>
+    <div class="col-md-2">
+      <select class="form-control" ng-model="context.search_args.bib_level" multiple="true">
+        <option value=''>[% l('All Bib Levels') %]</option>
+        <option ng-repeat="bib_level in context.ccvm_lists('bib_level')"
+          value="{{bib_level.code()}}">{{bib_level.value()}}</option>
+      </select>
+    </div>
+    <div class="col-md-2">
+      <select class="form-control" ng-model="context.search_args.lit_form" multiple="true">
+        <option value=''>[% l('All Literary Forms') %]</option>
+        <option ng-repeat="lit_form in context.ccvm_lists('lit_form')"
+          value="{{lit_form.code()}}">{{lit_form.value()}}</option>
+      </select>
+    </div>
+  </div>
+</div>
+
diff --git a/Open-ILS/src/templates/staff/cat/staffcat/search_result_facets.tt2 b/Open-ILS/src/templates/staff/cat/staffcat/search_result_facets.tt2
new file mode 100644 (file)
index 0000000..448f48a
--- /dev/null
@@ -0,0 +1,37 @@
+<style>
+  .facet-selected {
+    background-color: #DDD;
+  }
+</style>
+<div ng-repeat="facet_conf in context.facets.display">
+  <div ng-if="context.result().facet_data[facet_conf.facet_class]">
+    <div ng-repeat="name in facet_conf.facet_order">
+      <div class="row" 
+        ng-if="context.result().facet_data[facet_conf.facet_class][name]">
+        <div class="panel panel-info">
+          <div class="panel-heading">
+            <h3 class="panel-title">
+              {{context.result().facet_data[facet_conf.facet_class][name].cmf_label}}
+            </h3>
+          </div>
+          <div class="panel-body">
+            <div class="row" 
+              ng-class="{'facet-selected' :
+                context.facet_is_applied(facet_conf.facet_class, name, value.value)}"
+              ng-repeat="
+                value in context.result().facet_data[facet_conf.facet_class][name].value_list
+                |limitTo:context.facets.default_display_count">
+              <div class="col-md-10">
+                <a href 
+                  ng-click="context.apply_facet(facet_conf.facet_class, name, value.value)">
+                  {{value.value}}
+                </a>
+              </div>
+              <div class="col-md-2">{{value.count}}</div>
+            </div>
+          </div>
+        </div>
+      </div>
+    </div>
+  </div>
+</div>
diff --git a/Open-ILS/src/templates/staff/cat/staffcat/search_result_pagination.tt2 b/Open-ILS/src/templates/staff/cat/staffcat/search_result_pagination.tt2
new file mode 100644 (file)
index 0000000..c885d20
--- /dev/null
@@ -0,0 +1,24 @@
+<style>
+  /* Bootstrap default is 20px */
+  .pagination {margin: 10px 0px 10px 0px}
+</style>
+<ul class="pagination">
+  <li ng-class="{disabled : context.first_page()}">
+    <a href ng-click="context.go_to_page(context.current_page() - 1)"
+      aria-label="[% l('Previous') %]">
+      <span aria-hidden="true">&laquo;</span>
+    </a>
+  </li>
+  <li ng-repeat="page in context.page_list()" 
+    ng-class="{active : context.current_page() ==  page}">
+    <a href ng-click="context.go_to_page(page)">
+      {{page}} <span class="sr-only">(current)</span></a>
+  </li>
+  <li ng-class="{disabled : context.last_page()}">
+    <a href ng-click="context.go_to_page(context.current_page() + 1)"
+      aria-label="Next">
+      <span aria-hidden="true">&raquo;</span>
+    </a>
+  </li>
+</ul>
+
diff --git a/Open-ILS/src/templates/staff/cat/staffcat/search_result_record.tt2 b/Open-ILS/src/templates/staff/cat/staffcat/search_result_record.tt2
new file mode 100644 (file)
index 0000000..db9acb6
--- /dev/null
@@ -0,0 +1,113 @@
+<div class="col-md-12 well" style="margin-bottom:10px;padding:9px">
+  <div class="row">
+    <div class="col-md-1">
+      <a href="./cat/staffcat/record/{{record.bre.id()}}">
+        <img style="height:80px"
+          src="[% ctx.media_prefix %]/opac/extras/ac/jacket/small/r/{{record.bre.id()}}"/>
+      </a>
+    </div>
+    <div class="col-md-5">
+      <div class="row">
+        <div class="col-md-12 strong-text-1">
+          <!-- nbsp allows the column to take shape when no value exists -->
+          <a href="./cat/staffcat/record/{{record.bre.id()}}">
+            {{record.display_fields.title.value || '&nbsp;'}}
+          </a>
+        </div>
+      </div>
+      <div class="row pad-vert-min">
+        <div class="col-md-12">
+          <!-- nbsp allows the column to take shape when no value exists -->
+          <a href ng-click="context.search_author(record)" style="font-style:italic">
+            {{record.display_fields.author.value || '&nbsp;'}}
+          </a>
+        </div>
+      </div>
+      <div class="row pad-vert-min">
+        <div class="col-md-12">
+          <span>#{{$index + 1 + context.number(context.search_args.offset)}}</span>
+          <span>
+            <img class="pad-right-min" ng-cloak
+              src="[% ctx.media_prefix %]/images/format_icons/{{record.icon_attr.attr()}}/{{record.icon_attr.value()}}.png"/>
+            <span>{{context.icon_ccvm_map()[record.icon_attr.value()].value()}}</span>
+          </span>
+          <span style='pad-left'>{{record.display_fields.edition.value}}</span>
+          <span style='pad-left'>{{record.display_fields.pubdate.value}}</span>
+        </div>
+      </div>
+    </div>
+    <div class="col-md-2">
+      <div class="row" ng-class="{'pad-vert-min':$index > 0}" 
+        ng-repeat="copy_count in record.copy_counts">
+        <div class="col-md-12">
+          [% l(
+              '[_1] / [_2] items @ [_3]',
+              '{{copy_count.available}}',
+              '{{copy_count.visible}}',
+              '{{context.org_name(copy_count.org_unit)}}'
+            )
+          %]
+        </div>
+      </div>
+    </div>
+    <div class="col-md-4">
+      <div class="row">
+        <div class="col-md-4">
+          <div class="pull-right weak-text-1">
+            [% l('Holds: [_1]', '{{record.hold_count}}') %]
+          </div>
+        </div>
+        <div class="col-md-8">
+          <div class="pull-right weak-text-1">
+            [% l('Created [_1] by [_2]', 
+              '{{record.bre.create_date() | date:$root.egDateFormat}}',
+              '<a target="_self" 
+              href="./circ/patron/{{record.bre.creator().id()}}/checkout">{{record.bre.creator().usrname()}}</a>'
+            ) %]
+          </div>
+        </div>
+      </div>
+      <div class="row pad-vert-min">
+        <div class="col-md-4">
+          <div class="pull-right weak-text-1">
+            [% l('TCN: [_1]', '{{record.bre.tcn_value()}}') %]
+          </div>
+        </div>
+        <div class="col-md-8">
+          <div class="pull-right weak-text-1">
+            [% l('Edited [_1] by [_2]', 
+              '{{record.bre.edit_date() | date:$root.egDateFormat}}',
+              '<a target="_self" 
+              href="./circ/patron/{{record.bre.editor().id()}}/checkout">{{record.bre.editor().usrname()}}</a>'
+            ) %]
+          </div>
+        </div>
+      </div>
+      <div class="row pad-vert-min">
+        <div class="col-md-4">
+          <div class="pull-right weak-text-1">
+            [% l('ID: [_1]', '{{record.bre.id()}}') %]
+          </div>
+        </div>
+        <div class="col-md-8">
+          <div class="pull-right">
+            <span>
+              <button ng-click="context.place_hold(record)" 
+                class="btn btn-sm btn-success">
+                <span class="glyphicon glyphicon-ok"></span>
+                [% l('Place Hold') %]
+              </button>
+            </span>
+            <span>
+              <button ng-click="context.add_to_list(record)" 
+                class="btn btn-sm btn-info">
+                <span class="glyphicon glyphicon-list-alt"></span>
+                [% l('Add to List') %]
+              </button>
+            </span>
+          </div>
+        </div>
+      </div>
+    </div>
+  </div>
+</div>
diff --git a/Open-ILS/src/templates/staff/cat/staffcat/t_record.tt2 b/Open-ILS/src/templates/staff/cat/staffcat/t_record.tt2
new file mode 100644 (file)
index 0000000..7526eb8
--- /dev/null
@@ -0,0 +1,6 @@
+<h3>[% l('Record Details') %]</h4>
+<eg-record-summary record-id="context.record_id" 
+  record="context.summary_record"> </eg-record-summary>
+
+<hr/>
+<h4>Tabs and actions and stuff</h4>
diff --git a/Open-ILS/src/templates/staff/cat/staffcat/t_search.tt2 b/Open-ILS/src/templates/staff/cat/staffcat/t_search.tt2
new file mode 100644 (file)
index 0000000..b79fa72
--- /dev/null
@@ -0,0 +1,37 @@
+<div class="row pad-vert" 
+  ng-if="context.search_state() == 'complete' && context.result_count() == 0">
+  <div class="alert alert-warning" role="alert">
+    [% l('No result found') %]
+  </div>
+</div>
+
+<div id="staffcat-search-container"
+  ng-if="context.search_state() != 'pending' && context.result_count()">
+  <div class="row">
+    <div class="col-md-2" style="margin-top:10px"><!--match pagination margin-->
+      <h4>
+        [% l('Search Results [_1]', 
+          '<span class="badge">{{context.result_count()}}</span>') %]
+      </h4>
+    </div>
+    <div class="col-md-1">
+    </div>
+    <div class="col-md-9">
+      <div class="pull-right">
+        [% INCLUDE 'staff/cat/staffcat/search_result_pagination.tt2' %]
+      </div>
+    </div>
+  </div>
+  <div class="row">
+    <!-- scooch it left a little to create some space w/o pushing
+          it to overflow -->
+    <div class="col-md-2" style="margin-left:-5px;margin-right:5px">
+      [% INCLUDE 'staff/cat/staffcat/search_result_facets.tt2' %]
+    </div>
+    <div class="col-md-10">
+      <div class="row" ng-repeat="record in context.result().records">
+        [% INCLUDE 'staff/cat/staffcat/search_result_record.tt2' %]
+      </div>
+    </div>
+  </div>
+</div>
index 74cf439..cbc22de 100644 (file)
@@ -108,6 +108,7 @@ table.list tr.selected td { /* deprecated? */
 
 .pad-horiz {padding : 0px 10px 0px 10px; }
 .pad-vert {padding : 20px 0px 10px 0px;}
+.pad-vert-min {padding : 5px 0px 2px 0px;}
 .pad-left {padding-left: 10px;}
 .pad-right {padding-right: 10px;}
 .pad-right-min {padding-right: 5px;}
@@ -171,6 +172,9 @@ table.list tr.selected td { /* deprecated? */
   font-size: 140%;
   font-weight: bold;
 }
+.weak-text-1 {
+  font-size: 90%;
+}
 
 .currency-input {
   width: 8em;
index a6a65ad..d2a4db8 100644 (file)
             </a>
           </li>
           <li>
+            <a href="./cat/staffcat/search" target="_self">
+              <span class="glyphicon glyphicon-search"></span>
+              [% l('Staff Catalog (Experimental)') %]
+            </a>
+          </li>
+          <li>
             <a href="./cat/bucket/record/view" target="_self">
               <span class="glyphicon glyphicon-list-alt"></span>
               [% l('Record Buckets') %]
diff --git a/Open-ILS/web/js/ui/default/staff/cat/staffcat/app.js b/Open-ILS/web/js/ui/default/staff/cat/staffcat/app.js
new file mode 100644 (file)
index 0000000..e8058a5
--- /dev/null
@@ -0,0 +1,751 @@
+angular.module('egStaffCatApp', 
+    ['ngRoute', 'ui.bootstrap', 'egCoreMod', 'egUiMod'])
+
+.config(function($routeProvider, $locationProvider, $compileProvider) {
+    $locationProvider.html5Mode(true);
+    $compileProvider.aHrefSanitizationWhitelist(/^\s*(https?|blob):/); // grid export
+
+
+    var resolver = {delay : ['egCore','egStartup',
+        function(egCore, egStartup) { 
+
+            // Batch load universally required org unit settings
+            egCore.env.classLoaders.aous = function() {
+                // Settings are cached in egCore.org.settings(...)
+                return egCore.org.settings([
+                    'opac.default_search_location',
+                    // Add more catalog display settings here
+                ]);
+            }
+
+            egCore.env.loadClasses.push('aous');
+            return egStartup.go();
+        } 
+    ]};
+
+    $routeProvider.when('/cat/staffcat/search', {
+        templateUrl: './cat/staffcat/t_search',
+        controller: 'StaffCatSearchResultsCtrl',
+        resolve : resolver
+    });
+
+    $routeProvider.when('/cat/staffcat/record/:record_id', {
+        templateUrl: './cat/staffcat/t_record',
+        controller: 'StaffCatRecordCtrl',
+        resolve : resolver
+    });
+
+    $routeProvider.otherwise({redirectTo : '/cat/staffcat/search'});
+})
+
+.factory('staffCatSvc', [
+        '$q','egCore','egBibDisplay',
+function($q , egCore , egBibDisplay) {
+
+    var service = {
+        
+        search_state : 'pending',
+        context_org : null,
+        default_context_org : null,
+        search_result : null,
+        ccvm_list_keys : [
+            'item_type',
+            'item_form',
+            'item_lang',
+            'audience',
+            'audience_group', // TODO
+            'vr_format',
+            'bib_level',
+            'lit_form'
+        ],
+
+        // ctype => value-alphabetically sorted list of ccvms
+        ccvm_lists : {}, 
+        icon_ccvm_map : {},
+        cmf_map : {},
+
+        // Some data must be fetched on all pages, before any actions
+        // have occurred, but after startup is complete.  Bundle those
+        // here so page-specific controllers can be sure the data is
+        // loaded before kicking off any auto-load ations.
+        post_startup_init : function() {
+            return $q.all([
+                service.set_default_context_org(),
+                service.fetch_ccvms()
+            ])
+        },
+
+        /**
+         * Perform the search and collect data related to each bib record.
+         */
+        search : function(params) {
+            var deferred = $q.defer();
+
+            service.search_state = 'searching';
+            service.context_org = params.context_org;
+
+            var full_query = service.compile_search_query(params)
+
+            console.debug('search query: ' + full_query);
+
+            egCore.net.request(
+                'open-ils.search',
+                'open-ils.search.biblio.multiclass.query.staff', 
+                {limit : params.limit, offset : params.offset}, 
+                full_query, true
+
+            ).then(function(resp) {
+                console.debug(resp);
+
+                service.search_result = resp;
+                service.search_result.records = [];
+
+                // Build the record stub in order of ID response.
+                var bre_ids = [];
+                angular.forEach(resp.ids, function(bre_blob) {
+                    var bre_id = bre_blob[0];
+                    bre_ids.push(bre_id);
+
+                    // Create the result record on which all record-
+                    // specific data hangs.  Add stub data so the UI
+                    // will shuffle less as real data arrives.
+                    var record = {
+                        id : bre_id,
+                        hold_count : 0,
+                        copy_counts : []
+                    }
+
+                    angular.forEach(egCore.org.ancestors(
+                        service.context_org.id(), true).reverse(),
+                        function(org_id) {
+                            record.copy_counts.push({
+                                available : 0,
+                                visible : 0,
+                                org_unit : org_id
+                            });
+                        }
+                    );
+
+                    service.search_result.records.push(record);
+                });
+
+                if (bre_ids.length == 0) return;
+
+                // Collect display data.
+                // These each run 1 API, so OK to run them in parallel.
+                return $q.all([
+                    service.fetch_bres(bre_ids),
+                    service.fetch_facet_data(resp.facet_key),
+                    service.fetch_bib_extras(bre_ids)
+                ]);
+
+            }).then(function() {
+                service.search_state = 'complete';
+                deferred.resolve();
+            });
+
+            return deferred.promise;
+        },
+
+        fetch_bres : function(bre_ids) {
+            return egCore.pcrud.search('bre', {id : bre_ids}, {
+                flesh : 2, 
+                flesh_fields : {
+                    bre : 
+                        ['flat_display_entries','creator','editor','mattrs'],
+                },
+                // Avoid fetching the MARC blob.  
+                // Add other bre fields as needed.
+                select : {bre : 
+                    ['id','tcn_value','creator','editor','create_date','edit_date']
+                },
+
+            }).then(
+                function() { 
+                    service.search_state = 'records'; 
+                }, 
+                null, 
+                function(bre) {
+                    var record = service.get_result_record(bre.id());
+                    record.bre = bre;
+                    record.display_fields = 
+                        egBibDisplay.mfdeToMetaHash(bre.flat_display_entries())
+                    record.icon_attr = bre.mattrs().filter(
+                        function(a) {return a.attr() == 'icon_format'})[0];
+                }
+            );
+        },
+
+        /**
+         * Compiles one clause in a multi-clause boolean search
+         */
+        compile_one_query_set : function(params, idx) {
+            var query = params.query[idx];
+            var joiner = params.joiner[idx];
+            var match = params.match[idx];
+            var type = params.type[idx];
+
+            var str = '';
+            if (!query) return str;
+
+            if (idx > 0) str += ' ' + joiner;
+
+            str += ' (';
+            if (type) str += type + ':';
+
+            function strip_quotes(query) {return query.replace(/"/g, ''); }
+            function strip_anchors(query) {return query.replace(/[\^\$]/g, ''); }
+            function add_quotes(query) {
+                if (query.match(/ /))
+                    return '"' + query + '"' 
+                return query;
+            };
+
+            switch(match) {
+                case 'phrase':
+                    query = add_quotes(strip_quotes(query));
+                    break;
+                case 'nocontains':
+                    query = '-' + add_quotes(strip_quotes(query));
+                    break;
+                case 'exact':
+                    query = '^' + strip_anchors(query) + '$';
+                    break;
+                case 'starts':
+                    query = add_quotes('^' + strip_anchors(strip_quotes(query)));
+                    break;
+            }
+
+            return str + query + ')';
+        },
+
+        /**
+         * Turn the form parameters into a single query string
+         */
+        compile_search_query : function(params) {
+            var str = '';
+
+            var qcount = params.query.length;
+            if (qcount > 1) str += '(';
+            angular.forEach(params.query, function(q, idx) {
+                str += service.compile_one_query_set(params, idx);
+            });
+            if (qcount > 1) str += ')';
+
+            if (params.format) {
+                str += ' format(' + params.format + ')';
+            }
+
+            str += ' site(' + service.context_org.shortname() + ')';
+
+            if (params.available) str += ' #available';
+            if (params.global) {
+                str += ' depth(' + 
+                    egCore.org.root().ou_type().depth() + ')';
+            }
+
+            if (params.sort) {
+                // e.g. title, title.descending
+                var parts = params.sort.split(/\./);
+                str += ' sort(' + parts[0] + ')';
+                if (parts[1]) str += ' #descending';
+            }
+
+            angular.forEach(service.ccvm_list_keys, function(field) {
+                if (params[field]) { // comma-separated string
+                    str += ' ' + field + '(' + params[field] + ')';
+                }
+            });
+
+            angular.forEach(params.facets, function(facet) {
+                str += ' ' + facet.cls + '|' 
+                    + facet.name + '[' + facet.value + ']';
+            });
+
+            return str;
+        },
+
+        fetch_bib_extras : function(bre_ids) {
+            return egCore.net.request(
+                'open-ils.search',
+                'open-ils.search.biblio.catalog.extras.staff',
+                egCore.auth.token(), service.context_org.id(), bre_ids
+            ).then(null, null, function(bib_info) {
+                var record = service.get_result_record(bib_info.id);
+                // null if a new search was started in the meantime.
+                if (!record) return;
+                record.copy_counts = bib_info.copy_counts;
+                record.hold_count = bib_info.hold_count;
+            });
+        },
+
+        get_result_record : function(bre_id) {
+            // Old-timey for loop so we can exit early
+            var recs = service.search_result.records;
+            for (var idx = 0; idx < recs.length; idx++) {
+                if (recs[idx].id == bre_id)
+                    return recs[idx];
+            }
+        },
+
+        fetch_facet_data : function(facet_key) {
+            return egCore.net.request(
+                'open-ils.search',
+                'open-ils.search.facet_cache.retrieve', facet_key
+            ).then(function(facets) {
+                // Translate the facet data into something a little
+                // more digestable by the template.
+                var facet_data = {};
+                angular.forEach(facets, function(facet_hash, cmf_id) {
+                    var cmf_data = [];
+                    var cmf = service.cmf_map[cmf_id];
+
+                    angular.forEach(facet_hash, function(count, value) {
+                        cmf_data.push({value : value, count : count});
+                    });
+
+                    if (!facet_data[cmf.field_class()])
+                        facet_data[cmf.field_class()] = {};
+
+                    facet_data[cmf.field_class()][cmf.name()] = {
+                        cmf_label : cmf.label(),
+                        value_list : cmf_data.sort(function(a, b) { 
+                            if (a.count > b.count) return -1;
+                            if (a.count < b.count) return 1;
+                            // secondary alpha sort on display value
+                            return a.value < b.value ? -1 : 1;
+                        })
+                    };
+                });
+
+                service.search_result.facet_data = facet_data;
+            });
+        },
+
+        fetch_ccvms : function() {
+            // May already be cached
+            if (service.ccvm_lists.search_format) return $q.when();
+
+            return egCore.pcrud.search('ccvm', 
+                {ctype : service.ccvm_list_keys.concat(
+                    ['search_format','icon_format'])}, 
+                {}, {atomic : true}
+            ).then(function(list) {
+                angular.forEach(list, function(ccvm) {
+                    if (ccvm.ctype() == 'icon_format') {
+                        service.icon_ccvm_map[ccvm.code()] = ccvm;
+                    } else {
+                        if (!service.ccvm_lists[ccvm.ctype()])
+                            service.ccvm_lists[ccvm.ctype()] = [];
+                        service.ccvm_lists[ccvm.ctype()].push(ccvm);
+                    }
+                });
+
+                // CCVM lists are all sorted alphabetically by "value" (label).
+                angular.forEach(
+                    service.ccvm_list_keys.concat(['search_format']), 
+                    function(key) {
+                        if (!service.ccvm_lists[key]) 
+                            service.ccvm_lists[key] = [];
+                        service.ccvm_lists[key] = service.ccvm_lists[key].sort(
+                            function(a, b) {
+                                return a.value() < b.value() ? -1 : 1 
+                            }
+                        );
+                    }
+                );
+            });
+        },
+
+        fetch_cmfs : function() {
+            // At the moment, we only need facet CMFs.  
+            // May need other later.
+            if (Object.keys(service.cmf_map).length) return $q.when();
+            return egCore.pcrud.search('cmf', 
+                {facet_field : 't'}, {}, {atomic : true})
+            .then(function(cmfs) {
+                angular.forEach(cmfs, function(cmf) {
+                    service.cmf_map[cmf.id()] = cmf;
+                });
+            });
+        },
+
+        set_default_context_org : function() {
+            // This setting is load-cached during startup.
+            if (service.default_context_org) return $q.when();
+
+            return egCore.org.settings('opac.default_search_location')
+            .then(function (setting) {
+                var def = setting['opac.default_search_location'];
+                service.default_context_org = 
+                    def ? egCore.org.get(def) : egCore.org.root()
+            })
+        },
+    };
+
+    return service;
+}])
+
+/** Top-level controller for the catalog app.
+ *  Tracks scope data needed by all child controllers, including 
+ *  (primarily) the search form.
+ *  This controller runs on page load before startup has completed.
+ */
+.controller('StaffCatBaseCtrl',
+       ['$scope','$q','$window','$location','$timeout','egCore','staffCatSvc',
+        'egBibDisplay',
+function($scope , $q , $window , $location , $timeout , egCore , staffCatSvc,
+         egBibDisplay) {
+
+    var scs = staffCatSvc; // For tidiness
+    var ctx = $scope.context = {
+        search_args : {limit : 15}, // see reset_form()
+        focus_query : [true],
+        icon_ccvm_map : function() {return scs.icon_ccvm_map},
+        ccvm_lists : function(type) {
+            return (scs.ccvm_lists && scs.ccvm_lists[type]) 
+                ? scs.ccvm_lists[type] : [];
+        },
+        result : function() {return scs.search_result},
+        search_state : function() {return scs.search_state},
+        org_name : function(org_id) {return egCore.org.get(org_id).shortname()},
+        result_count : function() {
+            return scs.search_result ? scs.search_result.count : 0;
+        },
+        reset_form : reset_form,
+        search_by_form : search_by_form,
+        search_by_url : search_by_url,
+        check_enter : function($event) {
+            if ($event.keyCode == 13) {
+                ctx.search_args.offset = 0;
+                ctx.search_by_form();
+            }
+        },
+        first_page : function() { return page_info('first') },
+        last_page : function() { return page_info('last') },
+        current_page : function() { return page_info('current') },
+        page_count : function() { return page_info('count') },
+        page_list : function() { return page_info('list') },
+        go_to_page : function(page) {
+            ctx.search_args.offset = (ctx.search_args.limit * (page - 1));
+            search_by_form();
+        },
+        // Useful for inline addition, avoid string concat.
+        number : function(n) { return Number(n || 0) }
+    };
+
+    ctx.add_search_row = function(index) {
+        ctx.search_args.query.splice(index, 0, '');
+        ctx.search_args.type.splice(index, 0, 'keyword');
+        ctx.search_args.joiner.splice(index, 0, '&&');
+        ctx.search_args.match.splice(index, 0, 'contains');
+    }
+
+    ctx.del_search_row = function(index) {
+        ctx.search_args.query.splice(index, 1);
+        ctx.search_args.type.splice(index, 1);
+        ctx.search_args.joiner.splice(index, 1);
+        ctx.search_args.match.splice(index, 1);
+    }
+
+    function reset_form() {
+        ctx.search_args.offset = 0,
+        ctx.search_args.format = '',
+        ctx.search_args.sort = '',
+        ctx.search_args.query  = [''];
+        ctx.search_args.type   = ['keyword'];
+        ctx.search_args.match  = ['contains'];
+        ctx.search_args.joiner = [''];
+        ctx.search_args.facets = [];
+        ctx.search_args.available = false;
+        ctx.search_args.global = false;
+
+        // Default to empty string values so sane defaults can be 
+        // applied in the UI for various CCVM-based widgets.
+        angular.forEach(scs.ccvm_list_keys, function(key) {
+            ctx.search_args[key] = [''];
+        });
+    }
+
+    function page_info(which) {
+        var active = Boolean(scs.search_result);
+        switch(which) {
+            case 'first': 
+                return active && ctx.search_args.offset == 0;
+            case 'last': 
+                return active && page_info('current') == page_info('count');
+            case 'current':
+                return !active ? 0 :
+                    Math.floor(ctx.search_args.offset / ctx.search_args.limit) + 1
+            case 'count':
+                if (!active) return 0;
+                var pages = scs.search_result.count / ctx.search_args.limit;
+                if (Math.floor(pages) < pages) 
+                    pages = Math.floor(pages) + 1;
+                return pages;
+            case 'list':
+                var list = [];
+                for(var i = 1; i <= page_info('count'); i++)
+                    list.push(i);
+                return list;
+        }
+    }
+
+    ctx.place_hold = function(result) {
+        alert('Place hold for ID ' + result.bre.id());
+    }
+
+    ctx.add_to_list = function(result) {
+        alert('Add to list for ID: ' + result.bre.id());
+    }
+
+    ctx.search_author = function(result) {
+        reset_form();
+        ctx.search_args.type = ['author'];
+        ctx.search_args.query = [result.display_fields.author.value];
+        search_by_form();
+    }
+
+    /**
+     * Add a new facet to the set of facets used for filtering.
+     * If the facet is already applied, remove it.
+     */
+    ctx.apply_facet = function(cls, name, value) {
+        if (ctx.facet_is_applied(cls, name, value)) {
+            ctx.search_args.facets = ctx.search_args.facets.filter(
+                function(f) {
+                    return !(
+                        f.cls == cls && 
+                        f.name == name && 
+                        f.value == value
+                    );
+                }
+            );
+        } else {
+            ctx.search_args.facets.push({
+                cls : cls,
+                name : name,
+                value : value
+            });
+        }
+
+        ctx.search_args.offset = 0;
+        search_by_form();
+    }
+
+    ctx.facet_is_applied = function(cls, name, value) {
+        return ctx.search_args.facets.filter(function(f) {
+            return (
+                f.cls == cls && 
+                f.name == name && 
+                f.value == value
+            );
+        })[0];
+    }
+
+     // Migrate form data into the URL, the activate the new URL.
+    function search_by_form() {
+
+        // Avoid propagating search args to the URL if the search 
+        // will ultimately fail on an empty query.
+        if (!ctx.search_args.query[0]) return;
+
+        propagate_form_to_url();
+    }
+
+    // Encode search parameters from all form values into the URL.
+    // Remove previously encoded values that have been cleared from
+    // the search form since page load.
+    function propagate_form_to_url() {
+
+        // Copy form data to URL
+        var url_search = $location.search();
+
+        // Propagate scalar values
+        // Avoid poluting the URL with unset values.
+        angular.forEach(
+            ['limit','offset','format','sort','available','global'],
+            function(field) { 
+                if (ctx.search_args[field]) {
+                    url_search[field] = ctx.search_args[field]; 
+                } else {
+                    delete url_search[field];
+                }
+            }
+        );
+
+        url_search.query  = [],                                           
+        url_search.type   = [],                                           
+        url_search.joiner = [];                                           
+        url_search.match  = [];    
+
+        angular.forEach(ctx.search_args.query, function(q, idx) {
+            if (!ctx.search_args.query[idx]) return;
+            angular.forEach(
+                ['query', 'type','joiner','match'], 
+                function(field) {
+                    url_search[field][idx] = ctx.search_args[field][idx];
+                }
+            );
+        });
+
+        // Encode and propagate array-based values
+        // Avoid propagating list values that have no user selection.
+        angular.forEach(scs.ccvm_list_keys, function(field) {
+            if (ctx.search_args[field][0] != '') {
+                url_search[field] = ctx.search_args[field].join(',');
+            } else {
+                delete url_search[field];
+            }
+        });
+
+        // Facets are encoded as a multi-param value.
+        // Each value is a JSON encoded blob of class, name, and value
+        url_search.facet = [];
+        angular.forEach(ctx.search_args.facets, function(facet) {
+            url_search.facet.push(JSON.stringify(facet));
+        });
+
+        // Handle special case values
+        if (ctx.search_args.context_org.id() != scs.default_context_org.id()) {
+            url_search.org = ctx.search_args.context_org.id();
+        } else {
+            delete url_search.org;
+        }
+
+        $location.path('/cat/staffcat/search').search(url_search);
+    }
+
+    // Reset the form to its default state, then copy values from
+    // the URL into the form.
+    function propagate_url_to_form() {
+        reset_form();
+
+        var url_search = $location.search();
+
+        // Propagate scalar values
+        angular.forEach(
+            ['limit','offset','format','sort','available','global'],
+            function(field) {
+                if (url_search[field] != undefined) {
+                    ctx.search_args[field] = url_search[field];
+                }
+            }
+        );
+
+        if (typeof url_search.query == 'string') {
+            // If there is only one query set, each parameter
+            // will be represented as a string instead of an array.
+            url_search.query = [url_search.query];
+            url_search.type = [url_search.type];
+            url_search.joiner = [url_search.joiner];
+            url_search.match = [url_search.match];
+        }
+        
+        angular.forEach(url_search.query, function(q, idx) {
+            ctx.search_args.query[idx] = url_search.query[idx];
+            ctx.search_args.type[idx] = url_search.type[idx];
+            ctx.search_args.match[idx] = url_search.match[idx];
+            ctx.search_args.joiner[idx] = url_search.joiner[idx];
+        });
+
+        // Decode and propagate array-based values
+        angular.forEach(scs.ccvm_list_keys, function(field) {
+            if (url_search[field]) {
+                ctx.search_args[field] = url_search[field].split(/,/);
+
+                // Any of these fields means advanced search filters
+                // are active.  Make them visible.
+                ctx.show_adv_search = true;
+            }
+        });
+
+        // if there's only a single value it will be a string.
+        if (typeof url_search.facet == 'string')
+            url_search.facet = [url_search.facet];
+
+        angular.forEach(url_search.facet, function(facet) {
+            console.log('parsing: ' + facet);
+            ctx.search_args.facets.push(JSON.parse(facet));
+        });
+
+        // Handle special-case values
+        if (url_search.org)
+            ctx.search_args.context_org = egCore.org.get(url_search.org);
+
+    }
+
+    // All searches are ultimately search-by-URL.  Migrate URL data into
+    // the form then activate the current parameters by running a new
+    // search or loading a new page of results.
+    function search_by_url() {
+
+        propagate_url_to_form();
+
+        // Searches rerun controllers but do not reload the page.
+        // Force the browser back to the top of the page w/ each search.
+        $window.scrollTo(0, 0);
+
+        // Finally exit if there is no search to execute.  Do this after
+        // any other form data has been propagated so the URL and form
+        // will be in sync.
+        if (!ctx.search_args.query[0]) return;
+
+        var params = angular.copy(ctx.search_args);
+        angular.forEach(scs.ccvm_list_keys, function(key) {
+            params[key] = params[key].join(','); // Stringify
+        });
+
+        return staffCatSvc.search(params);
+    }
+
+}])
+
+/** Controller for main search page */
+.controller('StaffCatSearchResultsCtrl',
+       ['$scope','$q','$window','$location','egCore','staffCatSvc',
+function($scope , $q , $window , $location , egCore , staffCatSvc) {
+
+    staffCatSvc.post_startup_init().then(
+        function() { 
+            // will be overridden by URL params when present
+            $scope.context.search_args.context_org 
+                = staffCatSvc.default_context_org;
+
+            // currently we only need CMF's for facets on the results page.
+            staffCatSvc.fetch_cmfs()
+            .then($scope.context.search_by_url);
+        }
+    );
+
+    $scope.context.facets = {
+        display : [
+            {facet_class : 'author',  facet_order : ['personal', 'corporate']},
+            {facet_class : 'subject', facet_order : ['topic']},
+            {facet_class : 'identifier', facet_order : ['genre']},
+            {facet_class : 'series',  facet_order : ['seriestitle']},
+            {facet_class : 'subject', facet_order : ['name', 'geographic']}
+        ],
+        default_display_count : 5
+    }
+
+}])
+
+/** Controller for bib detail page */
+.controller('StaffCatRecordCtrl',
+       ['$scope','$q','$window','$location','$routeParams','egCore','staffCatSvc',
+function($scope , $q , $window , $location , $routeParams , egCore , staffCatSvc) {
+
+    $scope.context.record_id = $routeParams.record_id;
+
+    staffCatSvc.post_startup_init().then(
+        function() { 
+            // TODO:
+            // will be overridden by URL params when present
+            $scope.context.search_args.context_org = 
+                staffCatSvc.default_context_org;
+        }
+    );
+}])
+
+