AngularJS web-based staff client experiment collab/berick/web-staff-ui-angular
authorBill Erickson <berick@esilibrary.com>
Thu, 10 Oct 2013 15:32:38 +0000 (11:32 -0400)
committerBill Erickson <berick@esilibrary.com>
Tue, 15 Oct 2013 13:28:46 +0000 (09:28 -0400)
https://hostname/ng-staff/circ/patron/search

Signed-off-by: Bill Erickson <berick@esilibrary.com>
32 files changed:
README.ng-staff [new file with mode: 0644]
ng-staff/.htaccess [new file with mode: 0644]
ng-staff/README [new file with mode: 0644]
ng-staff/app.js [new file with mode: 0644]
ng-staff/circ/.htaccess [new file with mode: 0644]
ng-staff/circ/patron/.htaccess [new file with mode: 0644]
ng-staff/circ/patron/app.js [new file with mode: 0644]
ng-staff/circ/patron/bills.html [new file with mode: 0644]
ng-staff/circ/patron/checkout.html [new file with mode: 0644]
ng-staff/circ/patron/checkout.js [new file with mode: 0644]
ng-staff/circ/patron/edit.html [new file with mode: 0644]
ng-staff/circ/patron/holds.html [new file with mode: 0644]
ng-staff/circ/patron/index.html [new file with mode: 0644]
ng-staff/circ/patron/items_out.html [new file with mode: 0644]
ng-staff/circ/patron/messages.html [new file with mode: 0644]
ng-staff/circ/patron/patron.html [new file with mode: 0644]
ng-staff/circ/patron/search.html [new file with mode: 0644]
ng-staff/circ/patron/search.js [new file with mode: 0644]
ng-staff/circ/patron/summary.html [new file with mode: 0644]
ng-staff/circ/patron/summary.js [new file with mode: 0644]
ng-staff/index.html [new file with mode: 0644]
ng-staff/login.html [new file with mode: 0644]
ng-staff/navbar.html [new file with mode: 0644]
ng-staff/navbar.js [new file with mode: 0644]
ng-staff/services/auth.js [new file with mode: 0644]
ng-staff/services/env.js [new file with mode: 0644]
ng-staff/services/idl.js [new file with mode: 0644]
ng-staff/services/net.js [new file with mode: 0644]
ng-staff/services/startup.js [new file with mode: 0644]
ng-staff/services/ui.js [new file with mode: 0644]
ng-staff/services/user.js [new file with mode: 0644]
ng-staff/splash.html [new file with mode: 0644]

diff --git a/README.ng-staff b/README.ng-staff
new file mode 100644 (file)
index 0000000..43886d4
--- /dev/null
@@ -0,0 +1,6 @@
+
+1.  ln -s /path/to/ng-staff /openils/var/web/ng-staff
+
+2. See ng-staff/README for more
+
+
diff --git a/ng-staff/.htaccess b/ng-staff/.htaccess
new file mode 100644 (file)
index 0000000..f887226
--- /dev/null
@@ -0,0 +1,9 @@
+# html5 pushstate (history) support:
+Options -MultiViews
+<ifModule mod_rewrite.c>
+    RewriteEngine On
+    RewriteCond %{REQUEST_FILENAME} !-f
+    RewriteCond %{REQUEST_FILENAME} !-d
+    RewriteCond %{REQUEST_URI} !index
+    RewriteRule (.*) /ng-staff/index.html [L,DPI]
+</ifModule>
diff --git a/ng-staff/README b/ng-staff/README
new file mode 100644 (file)
index 0000000..0a6662e
--- /dev/null
@@ -0,0 +1,57 @@
+The app assumes the base web dir is /ng-staff/. To install elsewhere,
+change the <base> element of each of the index.html files and the
+paths in the .htaccess files.
+
+In a TT enviornment, most of the template repetition could be avoided.
+
+LAYOUT:
+
+/.htaccess         -- rewrite rules for html5 pushstate support
+/index.html        -- base template for home app (login, splash page)
+/app.js            -- module JS for home app (routes, etc.)
+/*.*               -- supporting templates / JS for home app
+/services/         -- shared angular services
+/circ/patron/      -- patron "app" directory
+/circ/patron/.htaccess  -- rewrite rules for html5 pushstate support
+/circ/patron/index.html -- base template for patron app
+/circ/patron/app.js     -- patron app module JS (routes, etc.)
+/circ/patron/*.*   -- supporting templates and JS for patron app
+
+TODO:
+
+* condense some of the core services/* into one egCoreServices module
+  for shorter imports -- the ones we /always/ use.
+
+* i18n example / plugin 
+
+* tests
+
+* sample keyboard shortcuts
+
+* breadcrumbs?
+
+* minification process
+
+* much more
+
+TODO (bigger picture / bigger challenges):
+
+* http://angular-ui.github.io/
+
+* If we stick w/ Bootstrap 
+  * http://mgcrea.github.io/angular-strap/ 
+    -- angular directives for bootstrap integratoin
+    -- may be some crossover with below
+  * http://angular-ui.github.io/bootstrap/ 
+    -- replaces jquery/bootstrap.js
+
+* automagic grid handling -- what's the frequency, Kenneth?
+    -- column selection
+    -- column sorting
+    -- scrolling / paging
+
+* auto widgets
+
+* Typeahead (see also Bootstrap below)
+
+
diff --git a/ng-staff/app.js b/ng-staff/app.js
new file mode 100644 (file)
index 0000000..f28cfdc
--- /dev/null
@@ -0,0 +1,87 @@
+/**
+ * App to drive the base page. 
+ * Login Form
+ * Splash Page
+ */
+
+angular.module('egHome', ['ngRoute', 'egStartupMod', 'egAuthMod', 'egUiMod'])
+
+.config(function($routeProvider, $locationProvider) {
+
+    /**
+     * Route resolvers allow us to run async commands
+     * before the page controller is instantiated.
+     */
+    var resolver = {delay : function(egStartup) {return egStartup.go()}};
+
+    $routeProvider.when('/login', {
+        templateUrl: './login.html',
+        controller: 'LoginCtrl',
+        resolve : {delay : function(egStartup, egAuth) {
+            // hack for now to kill the base ses cookie where sub-path
+            // apps were unable to remove it.  See note at the top of 
+            // services/auth.js about angular cookies and paths.
+            egAuth.logout();
+            return egStartup.go();
+        }}
+    });
+
+    // default page 
+    $routeProvider.otherwise({
+        templateUrl : './splash.html',
+        controller : 'SplashCtrl',
+        resolve : resolver
+    });
+
+    // HTML5 pushstate support
+    $locationProvider.html5Mode(true);
+})
+
+/**
+ * Login controller.  
+ * Reads the login form and submits the login request
+ */
+.controller('LoginCtrl', 
+    /* inject services into our controller.  Spelling them
+     * out like this allows the auto-magic injector to work
+     * even if the code has been minified */
+    ['$scope', '$location', '$window', 'egAuth',
+    function($scope, $location, $window, egAuth) {
+        $scope.focusMe = true;
+
+        // for now, workstations may be passed in via URL param
+        $scope.args = {workstation : $location.search().ws};
+
+        $scope.login = function(args) {
+            args.type = 'staff';
+            $scope.loginFailed = false;
+
+            egAuth.login(args).then(
+                function() { 
+                    // after login, send the user back to the originally
+                    // requested page or, if none, the home page.
+                    // TODO: this is a little hinky because it causes 2 
+                    // redirects if no route_to is defined.  Improve.
+                    $window.location.href = 
+                        $location.search().route_to || 
+                        $location.path('/').absUrl()
+                },
+                function() {
+                    $scope.args.password = '';
+                    $scope.loginFailed = true;
+                    $scope.focusMe = true;
+                }
+            );
+        }
+    }
+])
+
+/**
+ * Splash page dynamic content.
+ */
+.controller('SplashCtrl', ['$scope',
+    function($scope) {
+        console.log('SplashCtrl');
+    }
+]);
+
diff --git a/ng-staff/circ/.htaccess b/ng-staff/circ/.htaccess
new file mode 100644 (file)
index 0000000..3ddebe8
--- /dev/null
@@ -0,0 +1,9 @@
+# html5 pushstate (history) support:
+Options -MultiViews
+<ifModule mod_rewrite.c>
+    RewriteEngine On
+    RewriteCond %{REQUEST_FILENAME} !-f
+    RewriteCond %{REQUEST_FILENAME} !-d
+    RewriteCond %{REQUEST_URI} !index
+    RewriteRule (.*) /ng-staff/circ/index.html [L,DPI]
+</ifModule>
diff --git a/ng-staff/circ/patron/.htaccess b/ng-staff/circ/patron/.htaccess
new file mode 100644 (file)
index 0000000..a8c389c
--- /dev/null
@@ -0,0 +1,9 @@
+Options -MultiViews
+<ifModule mod_rewrite.c>
+    RewriteEngine On
+    RewriteCond %{REQUEST_FILENAME} !-f
+    RewriteCond %{REQUEST_FILENAME} !-d
+    RewriteCond %{REQUEST_URI} !index
+    RewriteRule (.*) /ng-staff/circ/patron/index.html [L,DPI]
+</ifModule>
+
diff --git a/ng-staff/circ/patron/app.js b/ng-staff/circ/patron/app.js
new file mode 100644 (file)
index 0000000..fa86cb3
--- /dev/null
@@ -0,0 +1,95 @@
+angular.module('egPatron', 
+['ngRoute', 'egNetMod', 'egAuthMod', 'egStartupMod', 'egUserMod', 'egUiMod'])
+
+.config(function($routeProvider, $locationProvider) {
+
+    // The route-specified controller will not get instantiated 
+    // until the promise returned by this function is resolved 
+    var resolver = {delay :
+        function(egStartup) {return egStartup.go()}
+    };
+
+    $routeProvider.when('/circ/patron/:id/checkout', {
+        templateUrl: './circ/patron/patron.html',
+        controller: 'PatronCtrl',
+        resolve : resolver
+    });
+
+    $routeProvider.when('/circ/patron/:id/items_out', {
+        templateUrl: './circ/patron/patron.html',
+        controller: 'PatronCtrl',
+        resolve : resolver
+    });
+
+    $routeProvider.when('/circ/patron/:id/holds', {
+        templateUrl: './circ/patron/patron.html',
+        controller: 'PatronCtrl',
+        resolve : resolver
+    });
+
+    $routeProvider.when('/circ/patron/:id/bills', {
+        templateUrl: './circ/patron/patron.html',
+        controller: 'PatronCtrl',
+        resolve : resolver
+    });
+
+    $routeProvider.when('/circ/patron/:id/messages', {
+        templateUrl: './circ/patron/patron.html',
+        controller: 'PatronCtrl',
+        resolve : resolver
+    });
+
+    $routeProvider.when('/circ/patron/:id/edit', {
+        templateUrl: './circ/patron/patron.html',
+        controller: 'PatronCtrl',
+        resolve : resolver
+    });
+    
+    // default route
+    $routeProvider.otherwise({
+        templateUrl: './circ/patron/search.html',
+        controller: 'PatronSearchCtrl',
+        resolve : resolver
+    });
+
+    $locationProvider.html5Mode(true);
+})
+
+/**
+ * Controller which drives the tabbed patron UI. This controller is
+ * used for all tabbed pages, with individual pages loading their own
+ * templates / controllers as needed.
+ */
+.controller('PatronCtrl', 
+['$scope', '$location', '$rootScope', '$timeout', '$routeParams', 'egUser',
+
+function ($scope, $location, $rootScope, $timeout, $routeParams, egUser) {
+
+    $scope.id = $routeParams.id;
+
+    if (!$scope.id) {
+        console.error("PatronCtrl called with no patron id");
+        return;
+    }
+
+    mytab = $location.path().replace(/.*\/(.*)$/, '$1');
+    $scope['tab_' + mytab] = true;
+
+    // every tab displays the patron summary. fetch the user, then tell
+    // the summary controller to draw itself.
+    egUser.get($scope.id).then(
+        function(user) { 
+            // fire the summary event after a timeout since the summary
+            // controller will not have been instantiated yet if the
+            // patron we are fetching is from cache (i.e. no async)
+            $timeout(function() {
+                $rootScope.$broadcast('drawPatronSummary', {user : user})
+            }, 1);
+        },
+        function(evt) { 
+            console.error('could not retrieve user "' + $scope.id + '"');
+        }
+    );
+}]);
+
+
diff --git a/ng-staff/circ/patron/bills.html b/ng-staff/circ/patron/bills.html
new file mode 100644 (file)
index 0000000..0fff7f3
--- /dev/null
@@ -0,0 +1 @@
+BILLS
diff --git a/ng-staff/circ/patron/checkout.html b/ng-staff/circ/patron/checkout.html
new file mode 100644 (file)
index 0000000..55b06d6
--- /dev/null
@@ -0,0 +1,55 @@
+<style>
+    .pad-horiz {padding : 0px 10px 0px 10px; }
+    .pad-vert {padding : 10px 0px 10px 0px;}
+    #patron-checkout-barcode { width: 20em; }
+</style>
+
+<div ng-controller="PatronCheckoutCtrl">
+  <div class="container pad-vert" style='text-align:center'>
+    <form ng-submit="checkout(args)">
+      <!-- focus-me : see services/ui.js -->
+      <input focus-me="focusMe" ng-model="args.barcode" type="text"/> 
+      <span class="pad-horiz"></span>
+      <select ng-model="args.type">
+        <option value='barcode'>Barcode</option>
+        <option value=''>...</option>
+        <!-- TODO: non-cat circs
+        <option value=''>Newspaper</option>
+        -->
+      </select>
+    </form>
+  </div>
+  <table class="table table-striped table-hover">
+    <thead><tr>
+      <th>#</th>
+      <th>Barcode</th>
+      <th>Title</th>
+      <th>Author</th>
+      <th>Due Date</th>
+      <th>Status</th>
+    </tr></thead>
+    <tbody>
+      <tr ng-repeat="circs in checkouts">
+        <td>{{circs.index}}</td>
+        <td>{{circs.barcode}}</td>
+        <td>{{circs.title}}</td>
+        <td>{{circs.author}}</td>
+        <td>{{circs.due_date | date}}</td>
+        <td>{{circs.status}}</td>
+      </tr>
+    </tbody>
+  </table>
+
+  <nav class="navbar navbar-default navbar-fixed-bottom" role="navigation">
+    <ul class="nav navbar-nav navbar-right">
+      <li class="dropdown">
+        <a href="javascript:;" class="dropdown-toggle" 
+          data-toggle="dropdown">List Actions<b class="caret"></b></a>
+        <ul class="dropdown-menu">
+          <li><a href="">Action One</a></li>
+          <li><a href="">Action Two</a></li>
+        </ul>
+      </li>
+    </ul>
+  </nav>
+</div>
diff --git a/ng-staff/circ/patron/checkout.js b/ng-staff/circ/patron/checkout.js
new file mode 100644 (file)
index 0000000..63ff197
--- /dev/null
@@ -0,0 +1,60 @@
+// link a controller to an existing module
+
+angular.module('egPatron')
+.controller('PatronCheckoutCtrl', 
+    ['$scope', '$routeParams', 'egNet', 'egAuth', 'egUser',
+
+function($scope, $routeParams, egNet, egAuth, egUser) {
+
+    // if this controller is instantiated, it means our
+    // template is in view.  tell the focus-me directive
+    // within our template to wake up.
+    $scope.focusMe = true;
+
+    $scope.checkouts = []; 
+    $scope.index = 1;
+    $scope.args = {type : 'barcode'}; // default to barcode checkouts
+
+    $scope.checkout = function(args) {
+        var index = $scope.index++;
+        var barcode = $scope.args.barcode;
+
+        $scope.args.barcode = '';
+        var display_args = {
+            index : index,
+            barcode : barcode
+        };
+
+        // plop the display for this checkout into the 
+        // list of checkouts so that rendering can begin 
+        // before we get a response, which ensures the
+        // final results are in the correct order.
+        $scope.checkouts.unshift(display_args);
+
+        // egNet.request() returns a promise.  returning that
+        // back to the caller of $scope.checkout means
+        // when the promise is later resolved, a $digest() run
+        // will occurr automatically and any updates to our $scope
+        // from within our promise handler will be applied in the UI.
+        return egNet.request(
+            'open-ils.circ', 'open-ils.circ.checkout.full', egAuth.token(), 
+            {patron_id : $routeParams.id, copy_barcode : barcode}).then(
+            function(evt) {
+                if (angular.isArray(evt)) evt = evt[0];
+                display_args.status = evt.textcode
+                var payload = evt.payload;
+                if (payload) {
+                    if (payload.circ) 
+                        display_args.due_date = payload.circ.due_date();
+                    if (payload.record) { 
+                        // *sigh* mvr...  display attrs, anyone?
+                        display_args.title = payload.record.title();
+                        display_args.author = payload.record.author();
+                    }
+                }
+            }                                                                      
+        );
+    }
+}]);
+
+
diff --git a/ng-staff/circ/patron/edit.html b/ng-staff/circ/patron/edit.html
new file mode 100644 (file)
index 0000000..00daeb8
--- /dev/null
@@ -0,0 +1 @@
+EDIT
diff --git a/ng-staff/circ/patron/holds.html b/ng-staff/circ/patron/holds.html
new file mode 100644 (file)
index 0000000..071a08c
--- /dev/null
@@ -0,0 +1 @@
+HOLDS
diff --git a/ng-staff/circ/patron/index.html b/ng-staff/circ/patron/index.html
new file mode 100644 (file)
index 0000000..a4e3d5a
--- /dev/null
@@ -0,0 +1,48 @@
+<!doctype html>
+<html ng-app="egPatron" lang="en">
+  <head>
+    <title>Patron</title>
+    <base href="/ng-staff/" />
+    <meta name="viewport" content="width=device-width, initial-scale=1.0">
+    <link rel="stylesheet" href="//netdna.bootstrapcdn.com/bootstrap/3.0.0/css/bootstrap.min.css" />
+  </head>
+  <body>
+
+    <!-- bootstrap navbar -->
+    <div ng-include="'./navbar.html'"></div>
+
+    <!-- main body of the page -->
+    <div ng-view></div>
+  </body>
+
+  <!-- bootstrap JS -->
+  <script src="//ajax.googleapis.com/ajax/libs/jquery/1.10.2/jquery.min.js"></script>
+  <script src="//netdna.bootstrapcdn.com/bootstrap/3.0.0/js/bootstrap.min.js"></script>
+
+  <!-- angular -->
+  <script src="//ajax.googleapis.com/ajax/libs/angularjs/1.2.0-rc.2/angular.min.js"></script>
+  <script src="//ajax.googleapis.com/ajax/libs/angularjs/1.2.0-rc.2/angular-route.min.js"></script>
+  <script src="//ajax.googleapis.com/ajax/libs/angularjs/1.2.0-rc.2/angular-cookies.min.js"></script>
+
+  <!-- IDL / opensrf (network) -->
+  <script src="/IDL2js"></script>
+  <script src="/js/dojo/opensrf/JSON_v1.js"></script>
+  <script src="/js/dojo/opensrf/opensrf.js"></script>
+  <script src="/js/dojo/opensrf/opensrf_xhr.js"></script>
+
+  <!-- angular-driven shared services -->
+  <script src="services/idl.js"></script>
+  <script src="services/net.js"></script>
+  <script src="services/auth.js"></script>
+  <script src="services/env.js"></script>
+  <script src="services/startup.js"></script>
+  <script src="services/user.js"></script>
+  <script src="services/ui.js"></script>
+
+  <!-- angular-driven controllers -->
+  <script src="navbar.js"></script>
+  <script src="circ/patron/app.js"></script>
+  <script src="circ/patron/search.js"></script>
+  <script src="circ/patron/summary.js"></script>
+  <script src="circ/patron/checkout.js"></script>
+</html>
diff --git a/ng-staff/circ/patron/items_out.html b/ng-staff/circ/patron/items_out.html
new file mode 100644 (file)
index 0000000..4687d9b
--- /dev/null
@@ -0,0 +1 @@
+ITEMS OUT
diff --git a/ng-staff/circ/patron/messages.html b/ng-staff/circ/patron/messages.html
new file mode 100644 (file)
index 0000000..b0632b0
--- /dev/null
@@ -0,0 +1 @@
+MESSAGES
diff --git a/ng-staff/circ/patron/patron.html b/ng-staff/circ/patron/patron.html
new file mode 100644 (file)
index 0000000..e149266
--- /dev/null
@@ -0,0 +1,41 @@
+<style>
+    #patron-tabs { margin-top: 20px; } 
+</style>
+<div class="row">
+  <div class="col-lg-3">
+    <div ng-include="'./circ/patron/summary.html'"></div>
+  </div>
+  <div class="col-lg-9">
+    <ul class="nav nav-tabs">
+      <li ng-class="{active : tab_checkout}"><a href="./circ/patron/{{id}}/checkout">Checkout</a></li>
+      <li ng-class="{active : tab_items_out}"><a href="./circ/patron/{{id}}/items_out">Items Out</a></li>
+      <li ng-class="{active : tab_holds}"><a href="./circ/patron/{{id}}/holds">Holds</a></li>
+      <li ng-class="{active : tab_bills}"><a href="./circ/patron/{{id}}/bills">Bills</a></li>
+      <li ng-class="{active : tab_messages}"><a href="./circ/patron/{{id}}/messages">Messages</a></li>
+      <li ng-class="{active : tab_edit}"><a href="./circ/patron/{{id}}/edit">Edit</a></li>
+    </ul>
+    <div class="tab-content" id="patron-tabs">
+      <!-- ng-class: apply the class "active" 
+           if the tab_checkout variable is truthy -->
+      <div class="tab-pane" ng-class="{active : tab_checkout}">
+        <!-- only load the template for each tab when the tab is active -->
+        <div ng-if="tab_checkout" ng-include="'./circ/patron/checkout.html'"></div>
+      </div>
+      <div class="tab-pane" ng-class="{active : tab_items_out}">
+        <div ng-if="tab_items_out" ng-include="'./circ/patron/items_out.html'"></div>
+      </div>
+      <div class="tab-pane" ng-class="{active : tab_holds}">
+        <div ng-if="tab_holds" ng-include="'./circ/patron/holds.html'"></div>
+      </div>
+      <div class="tab-pane" ng-class="{active : tab_bills}">
+        <div ng-if="tab_bills" ng-include="'./circ/patron/bills.html'"></div>
+      </div>
+      <div class="tab-pane" ng-class="{active : tab_messages}">
+        <div ng-if="tab_messages" ng-include="'./circ/patron/messages.html'"></div>
+      </div>
+      <div class="tab-pane" ng-class="{active : tab_edit}">
+        <div ng-if="tab_edit" ng-include="'./circ/patron/edit.html'"></div>
+      </div>
+    </div>
+  </div>
+</div>
diff --git a/ng-staff/circ/patron/search.html b/ng-staff/circ/patron/search.html
new file mode 100644 (file)
index 0000000..6778d05
--- /dev/null
@@ -0,0 +1,98 @@
+<style>
+    .row-pad-bottom { padding-bottom: 5px; }
+    select,input[type="text"],input[type="button"],
+      input[type="submit"],input[type="reset"],button { width: 10em }
+</style>
+
+<div class="row">
+
+  <div class="col-lg-3">
+    <div ng-include="'./circ/patron/summary.html'"></div>
+  </div>
+
+  <div class="col-lg-9">
+    <form ng-submit="search(args)">
+      <div class="row">
+        <div class="col-lg-12">
+          <input type="text" ng-model="args.card" placeholder="Barcode" focus-me="focusMe"/>
+          <input type="text" ng-model="args.family_name" placeholder="Last Name"/>
+          <input type="text" ng-model="args.first_given_name" placeholder="First Name"/>
+          <input type="submit" value="Search"/>
+          <input type="reset" value="Clear Form"/>
+          <a class="btn" ng-hide="display.more_fields" 
+            ng-click="display.more_fields = true">More Options &#x21e9;</a>
+          <a class="btn" ng-show="display.more_fields" 
+            ng-click="display.more_fields = false">Fewer Options &#x21e7;</a>
+        </div>
+      </div>
+      <div class="row row-pad-bottom" ng-model="display" ng-show="display.more_fields">
+        <div class="col-lg-12">
+          <div class="row">
+            <div class="col-lg-12">
+              <input type="text" ng-model="args.second_given_name" placeholder="Middle Name"/>
+              <input type="text" ng-model="args.alias" placeholder="Alias"/>
+              <input type="text" ng-model="args.usrname" placeholder="Username"/>
+              <input type="text" ng-model="args.email" placeholder="Email"/>
+              <input type="text" ng-model="args.ident" placeholder="Identification"/>
+            </div>
+          </div>
+          <div class="row">
+            <div class="col-lg-12">
+              <input type="text" ng-model="args.id" placeholder="Database ID"/>
+              <input type="text" ng-model="args.phone" placeholder="Phone"/>
+              <input type="text" ng-model="args.street1" placeholder="Street 1"/>
+              <input type="text" ng-model="args.street2" placeholder="Street 2"/>
+              <input type="text" ng-model="args.city" placeholder="City"/>
+            </div>
+          </div>
+          <div class="row">
+            <div class="col-lg-12">
+              <input type="text" ng-model="args.state" placeholder="State"/>
+              <input type="text" ng-model="args.post_code" placeholder="Post Code"/>
+              <select ng-mode="args.profile">
+                <option value="" selected="selected" disabled='disabled'>-- Profile --</option>
+                <option>...</option>
+              </select>
+              <select ng-mode="args.home_ou">
+                <option value="" selected="selected" disabled='disabled'>-- Home Library --</option>
+                <option>...</option>
+              </select>
+              Include Inactive? <input type="checkbox" ng-model="args.inactive"/>
+            </div>
+          </div>
+        </div>
+      </div>
+    </form>
+
+    <br/>
+    <table class="table table-striped table-hover">
+      <thead>
+        <tr>
+          <th>#</th>
+          <th>ID</th>
+          <th>Barcode</th>
+          <th>Last Name</th>
+          <th>First Name</th>
+          <th>DoB</th>
+        </tr>
+      </thead>
+      <tbody ng-repeat="user in searchResults">
+        <tr ng-click="handleResultClick(user)" 
+            ng-dblclick="handleResultDblClick(user)">
+          <td>{{user.idx}}</td>
+          <td>{{user.id}}</td>
+          <td>{{user.card}}</td>
+          <td>{{user.family_name}}</td>
+          <td>{{user.first_given_name}}</td>
+          <td>{{user.dob | date}}</td>
+        </tr>
+      </tbody>
+    </table>
+  </div>
+</div>
+<nav class="navbar navbar-default navbar-fixed-bottom" role="navigation">
+  <div class="navbar-right" style="margin-right: 20px;">
+    <i>Click a row to view a patron.</i>
+    <i>Double-click to open patron</i>
+  </div>
+</nav>
diff --git a/ng-staff/circ/patron/search.js b/ng-staff/circ/patron/search.js
new file mode 100644 (file)
index 0000000..a982f1a
--- /dev/null
@@ -0,0 +1,122 @@
+// TODO: patron search server API which supports 
+// streaming and server-side paging
+
+// TODO: translate patron search into CGI params for deep linking?
+
+/**
+ * Search results cache.  Useful for tracking the last run search.
+ * When navigating back to the search page (through routing,
+ * not a new page load) the user can see the results of the last
+ * search without having to reexecute.
+ */
+angular.module('egPatron').factory('egPatronSearchCache', [
+    '$cacheFactory', function($cacheFactory) {
+        return $cacheFactory('egPatronSearchCache', {});
+    }
+])
+
+.controller('PatronSearchCtrl', 
+    ['$scope', '$rootScope', '$location', 
+        'egPatronSearchCache', 'egNet', 'egAuth', 'egUser',
+
+function($scope, $rootScope, $location, 
+    egPatronSearchCache, egNet, egAuth, egUser) {
+
+    var self = this;
+    $scope.focusMe = true;
+    $scope.limit = 50;
+    $scope.offset = 0;
+    $scope.searchResults = egPatronSearchCache.get('last') || [];
+
+    // single-click on a row opens the summary for the selected patron
+    $scope.handleResultClick = function(user) {
+        $rootScope.$broadcast('drawPatronSummary', {user : user.obj})
+    }
+
+    // double-click on a row opens the checkout page for the selected patron
+    $scope.handleResultDblClick = function(user) {
+        $location.path('/circ/patron/' + user.id + '/checkout');
+    }
+
+    $scope.search = function(args) { 
+        if (!args || Object.keys(args).length == 0) return;
+        if (args.id) {
+            self.displayResultSet([args.id]);
+        } else {
+            self.sendSearch(args);
+        }
+    };
+
+    this.compile = function(search) {
+        var args = {};
+
+        // map the form arguments into search params
+        angular.forEach(search, function(val, key) {
+            if (!val) return;
+            args[key] = {value : val, group : 0};
+
+            if (key.match(/phone|ident/)) {
+                args[key].group = 2;
+            } else {
+                if (key.match(/street|city|state|post_code/)) {
+                    args[key].group = 1;
+                } else {
+                    if (key == 'card')
+                        args[key].group = 3;
+                }
+            }
+        });
+
+        return args;
+    };
+
+    this.sendSearch = function(args) {
+        args = this.compile(args);
+
+        // clear the results before the async call so the UI can clear
+        $scope.searchResults = [];
+
+        egNet.request(
+            'open-ils.actor',
+            'open-ils.actor.patron.search.advanced',
+            egAuth.token(), args, 100 /* limit */,
+            [   /* sort */
+                "family_name ASC",
+                "first_given_name ASC",
+                "second_given_name ASC",
+                "dob DESC"
+            ],
+            null, /* OU filter */
+            args.inactive
+
+        ).then(function(ids) {
+            //self.displayResultSet(ids.slice(0,self.limit));
+            self.displayResultSet(ids);
+        });
+    };
+
+    this.displayResultSet = function(userIds) {
+        $scope.searchResults = [];
+        egPatronSearchCache.put('last', $scope.searchResults);
+
+        angular.forEach(userIds, function(id, idx) {
+            // ensure the correct display order by tracking the IDs first
+            $scope.searchResults.push({id : id, idx : idx}); 
+            egUser.get(id).then(
+                function(user) {self.displayUser(user)}
+            );
+        });
+    };
+
+    this.displayUser = function(user) {
+        var blob = $scope.searchResults.filter(
+            function(u) { return u.id == user.id() })[0];
+
+        blob.obj = user;
+        blob.family_name = user.family_name(),
+        blob.first_given_name = user.first_given_name(),
+        blob.dob = user.dob()
+        blob.card = user.card() ? user.card().barcode() : '';
+    };
+}]);
+
diff --git a/ng-staff/circ/patron/summary.html b/ng-staff/circ/patron/summary.html
new file mode 100644 (file)
index 0000000..9480b2f
--- /dev/null
@@ -0,0 +1,89 @@
+<style>
+  /** style to make a grid look like a striped table */
+  #patron-summary-grid div.row {margin-bottom: 10px; padding: 3px;}
+  #patron-summary-grid div.row:nth-child(odd) {background-color: rgb(249, 249, 249);}
+
+  /* there are bootstrap tyles for error, warning, etc., 
+    but the ones I'm finding aren't quite cutting it..*/
+  .alert {color: red; font-weight:bold}
+</style>
+<div ng-controller="PatronSummaryCtrl">
+  <div class="row" ng-hide="full_name">
+    <div class="col-lg-12">
+      <i>Patron Summary</i>
+    </div>
+  </div>
+  <div ng-show="full_name" id="patron-summary-grid">
+    <div class="row">
+      <div class="col-lg-12"><b>{{full_name}}</b></div>
+    </div>
+    <div class="row">
+      <div class="col-lg-5">Card</div>
+      <div class="col-lg-7">{{card}}</div>
+    </div>
+    <div class="row">
+      <div class="col-lg-5">Profile</div>
+      <div class="col-lg-7">{{profile}}</div>
+    </div>
+    <div class="row">
+      <div class="col-lg-5">Home Library</div>
+      <div class="col-lg-7">{{home_ou}}</div>
+    </div>
+    <div class="row">
+      <div class="col-lg-5">Create Date</div>
+      <div class="col-lg-7">{{create_date | date}}</div>
+    </div>
+    <div class="row">
+      <div class="col-lg-5">Expire Date</div>
+      <div class="col-lg-7">{{expire_date | date}}</div>
+    </div>
+    <div class="row" ng-class="{alert : balance_owed}">
+      <div class="col-lg-5">Fines Owed</div>
+      <div class="col-lg-7">{{balance_owed | currency}}</div>
+    </div>
+    <div class="row">
+      <div class="col-lg-5">Items Out</div>
+      <div class="col-lg-7">{{items_out}}</div>
+    </div>
+    <div class="row" ng-class="{alert : items_overdue}">
+      <div class="col-lg-5">Items Overdue</div>
+      <div class="col-lg-7">{{items_overdue}}</div>
+    </div>
+    <div class="row">
+      <div class="col-lg-5">Holds</div>
+      <div class="col-lg-7">{{holds}} / {{holds_ready}}</div>
+    </div>
+  </div>
+
+  <!-- Table version of the above.
+  <table id="patron-summary-table" class="table table-striped">
+    <tbody ng-hide="full_name">
+      <tr><td colspan='2'><i>Patron Summary</i></tr>
+    </tbody>
+    <tbody ng-show="full_name">
+      <tr><td colspan='2'><b>{{full_name}}</b></tr>
+      <tr><td>Card</td><td>{{card}}</td></tr>
+      <tr><td>Profile</td><td>{{profile}}</td></tr>
+      <tr><td>Home Library</td><td>{{home_ou}}</td></tr>
+      <tr><td>Create Date</td><td>{{create_date | date}}</td></tr>
+      <tr><td>Expire Date</td><td>{{expire_date | date}}</td></tr>
+      <tr><td>Fines Owed</td><td>{{balance_owed}}</td></tr>
+      <tr><td>Items Out</td><td>{{items_out}}</td></tr>
+      <tr><td>Items Overdue</td><td>{{items_overdue}}</td></tr>
+      <tr><td>Holds</td><td>{{holds}} / {{holds_ready}}</td></tr>
+    </tbody>
+  </table>
+  -->
+
+  <div class="row" ng-repeat="addr in addresses">
+    <div class="panel">
+      <div class="panel-body">
+        <fieldset>
+          <legend>{{addr.address_type}}</legend>
+          <div>{{addr.street1}} {{addr.street2}}</div>
+          <div>{{addr.city}}, {{addr.state}} {{addr.post_code}}</div>
+        </fieldset>
+      </div>
+    </div>
+  </div>
+</div>
diff --git a/ng-staff/circ/patron/summary.js b/ng-staff/circ/patron/summary.js
new file mode 100644 (file)
index 0000000..759f644
--- /dev/null
@@ -0,0 +1,126 @@
+angular.module('egPatron')
+
+.factory('egPatronSummaryCache', [
+    '$cacheFactory', function($cacheFactory) {
+        return $cacheFactory('egPatronSummaryCache', {});
+    }
+])
+
+.controller('PatronSummaryCtrl', 
+['$scope', '$window', '$cacheFactory', 'egNet', 
+    'egEnv', 'egAuth', 'egPatronSummaryCache',
+
+function($scope, $window, $cacheFactory, 
+    egNet, egEnv, egAuth, egPatronSummaryCache) {
+    var self = this;
+
+    // summary is not drawn directly, but rather as a 
+    // result of other controllers fetching users
+    $scope.$on('drawPatronSummary', 
+        function(evt, args) {self.draw(args.user)}
+    );
+
+    // render the patron summary view
+    this.draw = function(user) {
+        var display = egPatronSummaryCache.get('display');
+
+        if (display && display.id == user.id()) {
+            // drawing the same user we drew last time
+            // populate values from cache into the new
+            // scope and we're done.
+            for (var k in display) $scope[k] = display[k];
+            return;
+        } else {
+            // new patron, new display values object
+            // replace the previously cached display with the new one
+            display = {id : user.id()};
+            egPatronSummaryCache.put('display', display);
+        }
+
+        /**
+         * Each controller is instantiated anew with each page load,
+         * including pushstate-routed pages.  Any time a value is added
+         * to our scope, we want to cache it for future re-draws of
+         * the summary page, to avoid patron data re-fetching.
+         */
+        function scopeCache(key, val) {
+            if (val !== undefined)
+                $scope[key] = display[key] = val;
+            return display[key];
+        }
+
+        scopeCache('full_name', 
+            (user.first_given_name() || '') + ' ' +
+            (user.second_given_name() || '') + ' ' + 
+            (user.family_name() || '')
+        );
+        scopeCache('card', user.card() ? user.card().barcode() : '');
+        scopeCache('profile', egEnv.get('pgt').map[user.profile()].name());
+        scopeCache('home_ou', egEnv.get('aou').map[Number(user.home_ou())].shortname());
+        scopeCache('create_date', user.create_date());
+        scopeCache('expire_date', user.expire_date()); 
+        scopeCache('addresses', []);
+        scopeCache('items_out', '');
+        scopeCache('overdue', '');
+        scopeCache('balance_owed', '');
+        scopeCache('holds', '');
+        scopeCache('holds_ready', '');
+
+        angular.forEach(['mailing', 'billing'], function(type) {
+            var addr = user[type + '_address']();
+            if (!addr) return;
+
+            // don't repeat addresses
+            if (scopeCache('addresses').filter(
+                function(a) {return a.id == addr.id()})[0])
+                return;
+
+            scopeCache('addresses').push({
+                id : addr.id(),
+                address_type : addr.address_type(),
+                street1 : addr.street1(),
+                street2 : addr.street2(),
+                city : addr.city(),
+                state : addr.state(),
+                post_code : addr.post_code(),
+            });
+        });
+
+        // items out summary
+        egNet.request(
+            'open-ils.actor',
+            'open-ils.actor.user.checked_out.count.authoritative',
+            egAuth.token(), user.id()
+        ).then(
+            function(blob) {
+                scopeCache('items_out', blob.out + blob.overdue);
+                scopeCache('items_overdue', blob.overdue);
+            }
+        );
+
+        // fines summary
+        egNet.request(
+            'open-ils.actor',
+            'open-ils.actor.user.fines.summary.authoritative',
+            egAuth.token(), user.id()
+
+        ).then(
+            function(summary) {
+                var owed = (summary) ? summary.balance_owed() : 0;
+                scopeCache('balance_owed', owed);
+            }
+        );
+
+        // holds summary
+        egNet.request(
+            'open-ils.actor',
+            'open-ils.actor.user.hold_requests.count.authoritative',
+            egAuth.token(), user.id()
+        ).then(
+            function(summary) {
+                scopeCache('holds', (summary) ? summary.total : 0);
+                scopeCache('holds_ready', (summary) ? summary.ready : 0);
+            }
+        );
+    }
+}]);
diff --git a/ng-staff/index.html b/ng-staff/index.html
new file mode 100644 (file)
index 0000000..0a28ad1
--- /dev/null
@@ -0,0 +1,47 @@
+<!doctype html>
+<html ng-app="egHome" lang="en">
+  <head>
+    <title>Evergreen</title>
+    <base href="/ng-staff/" />
+    <meta name="viewport" content="width=device-width, initial-scale=1.0">
+    <link rel="stylesheet" href="//netdna.bootstrapcdn.com/bootstrap/3.0.0/css/bootstrap.min.css" />
+  </head>
+  <body>
+
+    <!-- bootstrap navbar -->
+    <div ng-include="'./navbar.html'"></div>
+
+    <!-- main body of the page -->
+    <div ng-view></div>
+  </body>
+
+  <!-- bootstrap JS -->
+  <script src="//ajax.googleapis.com/ajax/libs/jquery/1.10.2/jquery.min.js"></script>
+  <script src="//netdna.bootstrapcdn.com/bootstrap/3.0.0/js/bootstrap.min.js"></script>
+
+  <!-- angular -->
+  <script src="//ajax.googleapis.com/ajax/libs/angularjs/1.2.0-rc.2/angular.min.js"></script>
+  <script src="//ajax.googleapis.com/ajax/libs/angularjs/1.2.0-rc.2/angular-route.min.js"></script>
+  <script src="//ajax.googleapis.com/ajax/libs/angularjs/1.2.0-rc.2/angular-cookies.min.js"></script>
+
+  <!-- IDL / opensrf (network) -->
+  <script src="/IDL2js"></script>
+  <script src="/js/dojo/opensrf/JSON_v1.js"></script>
+  <script src="/js/dojo/opensrf/opensrf.js"></script>
+  <script src="/js/dojo/opensrf/opensrf_xhr.js"></script>
+
+  <!-- needed for login -->
+  <script src="/js/dojo/opensrf/md5.js"></script>
+
+  <!-- angular-driven shared services -->
+  <script src="services/idl.js"></script>
+  <script src="services/net.js"></script>
+  <script src="services/auth.js"></script>
+  <script src="services/env.js"></script>
+  <script src="services/startup.js"></script>
+  <script src="services/ui.js"></script>
+
+  <!-- angular-driven controllers -->
+  <script src="app.js"></script>
+  <script src="navbar.js"></script>
+</html>
diff --git a/ng-staff/login.html b/ng-staff/login.html
new file mode 100644 (file)
index 0000000..66b6134
--- /dev/null
@@ -0,0 +1,52 @@
+<div class="container">
+  <div class="row">
+    <div class="col-lg-3"></div><!-- offset? -->
+      <div class="col-lg-6">
+        <fieldset>
+          <legend>Sign In</legend>
+          <!-- 
+            login() hangs off the page $scope.
+            Values entered by the user are put into 'args', 
+            which is is autovivicated if needed.
+            The input IDs are there to match the labels.  
+            They are not referenced in the Login controller.
+          -->
+          <form ng-submit="login(args)">
+            <div class="form-group row">
+              <label class="col-lg-4 control-label" for="login-username">Username</label>
+              <div class="col-lg-8">
+                <input type="text" id="login-username" class="form-control" 
+                  focus-me="focusMe" select-me="focusMe"
+                  placeholder="Username" ng-model="args.username"/>
+              </div>
+            </div>
+
+            <div class="form-group row">
+              <label class="col-lg-4 control-label" for="login-password">Password</label>
+              <div class="col-lg-8">
+                <input type="password" id="login-password" class="form-control"
+                  placeholder="Password" ng-model="args.password"/>
+              </div>
+            </div>
+
+            <div class="form-group row">
+              <label class="col-lg-4 control-label" for="login-workstation">Workstation</label>
+              <div class="col-lg-8">
+                <input type="text" id="login-workstation" class="form-control"
+                  placeHolder="Optional.  Also try ?ws=<name>"
+                  ng-model="args.workstation"/>
+              </div>
+            </div>
+
+            <div class="form-group row">
+              <div class="col-lg-12">
+                <button type="submit" class="btn">Sign in</button>
+                <span ng-show="loginFailed">Login Failed</span>
+              </div>
+            </div>
+          </form>
+        </fieldset>
+      </div>
+    <div class="col-lg-3"></div><!-- offset? -->
+  </div>
+</div>
diff --git a/ng-staff/navbar.html b/ng-staff/navbar.html
new file mode 100644 (file)
index 0000000..5e53a6a
--- /dev/null
@@ -0,0 +1,41 @@
+<div class="navbar navbar-default navbar-static-top" ng-controller="NavCtrl">
+  <div class="navbar-collapse collapse">
+    <ul class="nav navbar-nav">
+      <li class="dropdown">
+        <a href="javascript:;" class="dropdown-toggle" 
+          data-toggle="dropdown">Circulation<b class="caret"></b></a>
+        <ul class="dropdown-menu">
+          <!-- note the use of target="_self" - this tells angular to
+              treat the href has a new page and not a intra-page route -->
+          <li><a href="./circ/patron/search" target="_self">Patron Search</a></li>
+          <li><a href="javascript:;">Stuff 2</a></li>
+          <li><a href="javascript:;">Stuff 3</a></li>
+          <li class="divider"></li>
+          <li class="dropdown-header">A Sub Menu</li>
+          <li><a href="javascript:;">Other Stuff</a></li>
+        </ul>
+      </li>
+      <li class="dropdown">
+        <a href="javascript:;" class="dropdown-toggle" 
+          data-toggle="dropdown">Thing 2<b class="caret"></b></a>
+        <ul class="dropdown-menu">
+          <li><a href="javascript:;">Stuff 1</a></li>
+          <li><a href="javascript:;">Stuff 2</a></li>
+          <li><a href="javascript:;">Stuff 3</a></li>
+          <li><a href="javascript:;">...</a></li>
+          <li class="divider"></li>
+          <li class="dropdown-header">A Sub Menu</li>
+          <li><a href="javascript:;">Other Stuff</a></li>
+       </ul>
+      </li>
+    </ul>
+    <ul class="nav navbar-nav navbar-right">
+      <!-- ng-cloak tells angular to hide unresolved page variables.
+          When a value is populated, it will un-hide -->
+      <li><a ng-cloak>{{username}}</a></li>
+      <li><a href="./login" ng-click="logout()" target="_self">Log Out</a></li>
+    </ul>
+  </div>
+</div>
+
+
diff --git a/ng-staff/navbar.js b/ng-staff/navbar.js
new file mode 100644 (file)
index 0000000..d7fd4d2
--- /dev/null
@@ -0,0 +1,38 @@
+/**
+ * Free-floating controller which can be used by any app
+ */
+function NavCtrl($scope, egStartup, egAuth) {
+
+    $scope.logout = function() {
+        egAuth.logout();
+        return true;
+    };
+
+    /**
+     * Two important things happening here.
+     *
+     * 1. Since this is a standalone controller, which may execute at
+     * any time during page load, we have no gaurantee that needed
+     * startup actions, session retrieval being the main one, have taken
+     * place yet. So we kick off the startup chain ourselves and run
+     * actions when it's done. Note this does not mean startup runs
+     * multiple times. If it's already started, we just pick up the
+     * existing startup promise.
+     *
+     * 2. Since we are returning a promise, whose success handler
+     * updates a value within our scope, we do not have to manually
+     * call $scope.$apply() in the handler to update the DOM.. When 
+     * the promise is resolved, another $digest() loop will pick up 
+     * our changes to $scope.username and apply them to the UI.
+     */
+    return egStartup.go().then(
+        function() {
+
+            // login page will not have a cached
+            if (!egAuth.user()) return;
+
+            $scope.username = egAuth.user().usrname();
+            // TODO: add workstation
+        }
+    );
+}
diff --git a/ng-staff/services/auth.js b/ng-staff/services/auth.js
new file mode 100644 (file)
index 0000000..bf053e6
--- /dev/null
@@ -0,0 +1,102 @@
+/* Auth manager
+ *
+ * Angular cookies are still fairly primitive.  
+ * In particular, you can't set the path.
+ * https://github.com/angular/angular.js/issues/1786
+ */
+
+angular.module('egAuthMod', ['ngCookies', 'egNetMod'])
+
+.constant('EG_AUTH_COOKIE', 'ses')
+
+// auth cache
+.factory('egAuthCache', 
+    ['$cacheFactory', function($cacheFactory) {
+    return $cacheFactory('egAuthCache', {});
+}])
+
+
+.factory('egAuth', 
+['$q', '$cookies', '$timeout', '$location', 
+    '$window', 'egAuthCache', 'egNet', 'EG_AUTH_COOKIE', 
+function($q, $cookies, $timeout, $location, 
+    $window, egAuthCache, egNet, EG_AUTH_COOKIE) {
+
+    var service = {
+        user : function() {
+            return egAuthCache.get('user');
+        },
+        token : function() {
+            // no need to cache, since auth lives in a cookie
+            return $cookies[EG_AUTH_COOKIE];
+        },
+        workstation : function() { // TODO
+            return egAuthCache.get('workstation');
+        }
+    };
+
+    /* Returns a promise, which is resolved if valid
+     * authtoken is found, otherwise rejected */
+    service.testAuthToken = function() {
+        var deferred = $q.defer();
+        var token = service.token();
+
+        if (token) {
+            egNet.request(
+                'open-ils.auth',
+                'open-ils.auth.session.retrieve', token).then(
+                function(user) {
+                    if (user && user.classname) {
+                        egAuthCache.put('user', user);
+                        deferred.resolve();
+                    } else {
+                        delete $cookies[EG_AUTH_COOKIE]; 
+                        deferred.reject();
+                    }
+                }
+            );
+
+        } else {
+            deferred.reject();
+        }
+
+        return deferred.promise;
+    };
+
+    /**
+     * Returns a promise, which is resolved on successful 
+     * login and rejected on failed login.
+     */
+    service.login = function(args) {
+        var deferred = $q.defer();
+        egNet.request(
+            'open-ils.auth',
+            'open-ils.auth.authenticate.init', args.username).then(
+            function(seed) {
+                args.password = hex_md5(seed + hex_md5(args.password))
+                egNet.request(
+                    'open-ils.auth',
+                    'open-ils.auth.authenticate.complete', args).then(
+                    function(evt) {
+                        if (evt.textcode == 'SUCCESS') {
+                            $cookies[EG_AUTH_COOKIE] = evt.payload.authtoken;
+                            deferred.resolve();
+                        } else {
+                            console.error('login failed ' + js2JSON(evt));
+                            deferred.reject();
+                        }
+                    }
+                )
+            }
+        );
+
+        return deferred.promise;
+    };
+
+    service.logout = function() {
+        delete $cookies[EG_AUTH_COOKIE];
+    };
+
+    return service;
+}]);
+
diff --git a/ng-staff/services/env.js b/ng-staff/services/env.js
new file mode 100644 (file)
index 0000000..5b11d00
--- /dev/null
@@ -0,0 +1,103 @@
+/* 
+ * Data that we always want to load at startup goes here.
+ * Requests are sents as a swarm of async calls.  As each
+ * returns, a pending-calls counter is decremented.  Once
+ * it reaches zero, the promise returned by load() / 
+ * loadAll() is resolved.
+ */
+
+angular.module('egEnvMod', ['egNetMod'])
+
+// env cache
+.factory('egEnvCache', ['$cacheFactory', 
+function($cacheFactory) {
+    return $cacheFactory('egEnvCache', {});
+}])
+
+// env fetcher
+.factory('egEnv', ['$q', 'egEnvCache', 'egNet', 'egAuth', 
+function($q, egEnvCache, egNet, egAuth) { 
+
+    var service = {
+        get : function(class_) {
+            return egEnvCache.get(class_);
+        }
+    };
+
+    service.onload = function() {
+        if (--this.in_flight == 0) 
+            this.deferred.resolve();
+    };
+
+    /* returns a promise, loads all of the specified classes */
+    service.load = function(classes) {
+        if (!classes) classes = Object.keys(this.loaders);
+        this.deferred = $q.defer();
+        this.in_flight = classes.length;
+        angular.forEach(classes, function(cls) {
+            service.loaders[cls]().then(function(){service.onload()});
+        });
+        return this.deferred.promise;
+    };
+
+    /** given a tree-shaped collection, captures the tree and
+     *  flattens the tree for absorption.
+     */
+    service.absorbTree = function(tree, class_) {
+        var list = [];
+        function squash(node) {
+            list.push(node);
+            angular.forEach(node.children(), squash);
+        }
+        squash(tree);
+        var blob = service.absorbList(list, class_);
+        blob.tree = tree;
+    };
+
+    /** caches the object list both as the list and an id => object map */
+    service.absorbList = function(list, class_) {
+        var blob = {list : list, map : {}};
+        angular.forEach(list, function(item) {blob.map[item.id()] = item});
+        egEnvCache.put(class_, blob);
+        return blob;
+    };
+
+    /* Classes (by hint) to load, their loading routines,
+     * and their result mungers */
+
+    service.loaders = {
+
+        aou : function() {
+            return egNet.request('open-ils.pcrud', 
+                'open-ils.pcrud.search.aou', egAuth.token(), 
+                {parent_ou : null}, 
+                {   flesh : -1, 
+                    flesh_fields : {aou : ['children', 'ou_type']}
+                }
+            ).then(function(tree) {
+                service.absorbTree(tree, 'aou')
+            });
+        },
+
+        pgt : function() {
+            return egNet.request('open-ils.pcrud',
+                'open-ils.pcrud.search.pgt', egAuth.token(), 
+                {parent : null}, 
+                {   flesh : -1,
+                    flesh_fields : {pgt : ['children']}
+                }
+            ).then(
+                function(tree) {
+                    service.absorbTree(tree, 'pgt')
+                }
+            );
+        }
+
+        // org unit settings, blah, blah
+    }
+
+    return service;
+}]);
+
+
+
diff --git a/ng-staff/services/idl.js b/ng-staff/services/idl.js
new file mode 100644 (file)
index 0000000..9d9832d
--- /dev/null
@@ -0,0 +1,60 @@
+/**
+ * IDL parser
+ * usage:
+ *  var aou = new egIDL.aou();
+ *  var fullIDL = egIDL.classes;
+ *
+ *  IDL TODO:
+ *
+ * 1. selector field only appears once per class.  We could save
+ *    a lot of IDL (network) space storing it only once at the 
+ *    class level.
+ * 2. we don't need to store array_position in /IDL2js since it
+ *    can be derived at parse time.  Ditto saving space.
+ */
+angular.module('egIDLMod', [])
+
+.factory('egIDL', ['$window', function($window) {
+
+    var service = {};
+
+    service.parseIDL = function() {
+        console.log('egIDL.parseIDL()');
+
+        // retain a copy of the full IDL within the service
+        service.classes = $window._preload_fieldmapper_IDL;
+
+        // original, global reference no longer needed
+        $window._preload_fieldmapper_IDL = null;
+
+        /**
+         * Creates the class constructor and getter/setter
+         * methods for each IDL class.
+         */
+        function mkclass(cls, fields) {
+
+            service[cls] = function(seed) {
+                this.a = seed || [];
+                this.classname = cls;
+                this._isfieldmapper = true;
+            }
+
+            /** creates the getter/setter methods for each field */
+            angular.forEach(fields, function(field, idx) {
+                service[cls].prototype[fields[idx].name] = function(n) {
+                    if (arguments.length==1) this.a[idx] = n;
+                    return this.a[idx];
+                }
+            });
+
+            // global class constructors required for JSON_v1.js
+            $window[cls] = service[cls]; 
+        }
+
+        for (var cls in service.classes) 
+            mkclass(cls, service.classes[cls].fields);
+    };
+
+    return service;
+}]);
+
diff --git a/ng-staff/services/net.js b/ng-staff/services/net.js
new file mode 100644 (file)
index 0000000..b7cd337
--- /dev/null
@@ -0,0 +1,51 @@
+/**
+ * Promise wrapper for OpenSRF network calls.
+ * http://docs.angularjs.org/api/ng.$q
+ *
+ * promise.notify() is called with each streamed response.
+ *
+ * promise.resolve() is called when the request is complete 
+ * and passes as its value the response received from the 
+ * last call to onresponse().
+ *
+ * Example: Call with one response and no error checking:
+ *
+ * egNet.request(service, method, param1, param2).then(
+ *      function(data) { console.log(data) });
+ *
+ * Example: capture streaming responses, error checking
+ *
+ * egNet.request(service, method, param1, param2).then(
+ *      function(data) { console.log('all done') },
+ *      function(err)  { console.log('error: ' + err) },
+ *      functoin(data) { console.log('received stream response ' + data) }
+ *  );
+ */
+
+angular.module('egNetMod', [])
+.factory('egNet', function($q) {
+
+    return {
+        request : function(service, method) {
+            var last;
+            var deferred = $q.defer();
+            new OpenSRF.ClientSession(service).request({
+                async  : true,
+                method : method,
+                params : Array.prototype.slice.call(arguments, 2),
+                oncomplete : function() {
+                    deferred.resolve(last ? last.content() : null);
+                },
+                onresponse : function(r) {
+                    if (last = r.recv())
+                        deferred.notify(last.content());
+                },
+                onerror : function(msg) {
+                    deferred.reject(msg);
+                }
+            }).send();
+
+            return deferred.promise;
+        }
+    };
+});
diff --git a/ng-staff/services/startup.js b/ng-staff/services/startup.js
new file mode 100644 (file)
index 0000000..adcb416
--- /dev/null
@@ -0,0 +1,77 @@
+/**
+ * Coordinates all startup routines and consolidates them into
+ * a single startup promise.  Startup can be launched from multiple
+ * controllers, etc., but only one startup routine will be run.
+ *
+ * If no valid authtoken is found, startup will exit early and 
+ * change the page href to the login page.  Otherwise, the global
+ * promise returned by startup.go() will be resolved after all
+ * async data is arrived.
+ */
+
+angular.module('egStartupMod', ['egIDLMod', 'egAuthMod', 'egEnvMod'])
+
+.factory('egStartupCache', 
+    ['$cacheFactory', function($cacheFactory) {
+    return $cacheFactory('egStartupCache', {number : 1});
+}])
+
+
+.factory('egStartup', 
+    ['$q', '$rootScope', '$timeout', '$location', '$window', 
+    'egStartupCache', 'egIDL', 'egAuth', 'egEnv',
+function(
+        $q, $rootScope, $timeout, $location, $window, 
+        egStartupCache, egIDL, egAuth, egEnv) {
+
+    return {
+        go : function (args) {
+            args = args || {};
+            
+            if (egStartupCache.get('promise')) {
+                // startup is done, return our promise
+                return egStartupCache.get('promise').promise;
+            } 
+
+            // create a new promise and fire off startup
+            var deferred = $q.defer();
+            egStartupCache.put('promise', deferred);
+
+            // IDL parsing is sync.  No promises required
+            egIDL.parseIDL();
+            egAuth.testAuthToken().then(
+
+                // testAuthToken resolved
+                function() { 
+                    egEnv.load(args.load_classes).then(
+                        function() { deferred.resolve() }, 
+                        function() { 
+                            deferred.reject('egEnv did not resolve')
+                        }
+                    );
+                },
+
+                // testAuthToken rejected
+                function() { 
+                    console.log('egAuth found no valid authtoken');
+                    if ($location.path() == '/login') {
+                        console.log('egStartup resolving without authtoken on /login');
+                        deferred.resolve();
+                    } else {
+                        // TODO: this is a little hinky because it causes 2 redirects.
+                        // the first is the oh-so-convenient call to $location.path(),
+                        // the second is the final href change.
+                        $window.location.href = $location
+                            .path('/login')
+                            .search({route_to : 
+                                $window.location.pathname + $window.location.search})
+                            .absUrl();
+                    }
+                }
+            );
+
+            return deferred.promise;
+        }
+    };
+}]);
+
diff --git a/ng-staff/services/ui.js b/ng-staff/services/ui.js
new file mode 100644 (file)
index 0000000..ec9938d
--- /dev/null
@@ -0,0 +1,45 @@
+/**
+  * UI tools and directives.
+  */
+angular.module('egUiMod', [])
+
+
+/**
+ * <input focus-me="iAmOpen"/>
+ * $scope.iAmOpen = true;
+ */
+.directive('focusMe', 
+['$timeout', '$parse', 
+function($timeout, $parse) {
+    return {
+        link: function(scope, element, attrs) {
+            var model = $parse(attrs.focusMe);
+            scope.$watch(model, function(value) {
+                if(value === true) 
+                    $timeout(function() {element[0].focus()});
+            });
+            element.bind('blur', function() {
+                scope.$apply(model.assign(scope, false));
+            })
+        }
+    };
+}])
+
+// <input select-me="iWantToBeSelected"/>
+// $scope.iWantToBeSelected = true;
+.directive('selectMe', 
+['$timeout', '$parse', 
+function($timeout, $parse) {
+    return {
+        link: function(scope, element, attrs) {
+            var model = $parse(attrs.focusMe);
+            scope.$watch(model, function(value) {
+                if(value === true) 
+                    $timeout(function() {element[0].select()});
+            });
+            element.bind('blur', function() {
+                scope.$apply(model.assign(scope, false));
+            })
+        }
+    };
+}]);
diff --git a/ng-staff/services/user.js b/ng-staff/services/user.js
new file mode 100644 (file)
index 0000000..1503448
--- /dev/null
@@ -0,0 +1,47 @@
+/** Service for fetching fleshed user objects.
+  * The last user retrieved is kept in the local cache */
+
+angular.module('egUserMod', ['egNetMod', 'egAuthMod'])
+
+.factory('egUserCache', 
+    ['$cacheFactory', function($cacheFactory) {
+    return $cacheFactory('egUserCache', {number : 1});
+}])
+
+
+.factory('egUser', 
+['$q', '$timeout', 'egNet', 'egAuth', 'egUserCache', 
+function($q, $timeout, egNet, egAuth, egUserCache) {
+
+    var service = {};
+    service.get = function(userId) {
+        var deferred = $q.defer();
+
+        var last = egUserCache.get('last');
+        if (last && last.id() == userId) {
+            return $q.when(last);
+
+        } else {
+
+            egNet.request(
+                'open-ils.actor',
+                'open-ils.actor.user.fleshed.retrieve',
+                egAuth.token(), userId).then(
+                function(user) {
+                    if (user && user.classname == 'au') {
+                        egUserCache.put('last', user);
+                        deferred.resolve(user);
+                    } else {
+                        egUserCache.remove('last');
+                        deferred.reject(user);
+                    }
+                }
+            );
+        }
+
+        return deferred.promise;
+    };
+
+    return service;
+}]);
+
diff --git a/ng-staff/splash.html b/ng-staff/splash.html
new file mode 100644 (file)
index 0000000..ba58805
--- /dev/null
@@ -0,0 +1,53 @@
+<div class="container">
+  <div class="row">
+    <div class="col-lg-12 text-center">
+      <img src="/xul/server/skin/media/images/portal/logo.png"/>
+    </div>
+  </div>
+  <br/>
+  <div class="row">
+
+    <div class="col-lg-4">
+      <div class="panel panel-success">
+        <div class="panel-heading">
+          <div class="panel-title text-center">Circulation &amp; Patrons</div>
+        </div>
+        <div class="panel-body">
+          <div>
+            <img src="/xul/server/skin/media/images/portal/forward.png"/>
+            <a target="_self" href="./circ/patron/search">Check Out</a>
+          </div>
+        </div>
+      </div>
+    </div>
+
+    <div class="col-lg-4">
+      <div class="panel panel-success">
+        <div class="panel-heading">
+          <div class="panel-title text-center">Item Search &amp; Cataloging</div>
+        </div>
+        <div class="panel-body">
+          <div>
+            <img src="/xul/server/skin/media/images/portal/bucket.png"/>
+            <a target="_self" href="./circ/patron/search">More Things</a>
+          </div>
+        </div>
+      </div>
+    </div>
+
+    <div class="col-lg-4">
+      <div class="panel panel-success">
+        <div class="panel-heading">
+          <div class="panel-title text-center">Administration</div>
+        </div>
+        <div class="panel-body">
+          <div>
+            <img src="/xul/server/skin/media/images/portal/helpdesk.png"/>
+            <a target="_self" href="./circ/patron/search">All the Things</a>
+          </div>
+        </div>
+      </div>
+    </div>
+
+  </div>
+</div>