Angular web staff - initial import
authorBill Erickson <berick@esilibrary.com>
Thu, 24 Oct 2013 18:39:33 +0000 (14:39 -0400)
committerBill Erickson <berick@esilibrary.com>
Thu, 24 Oct 2013 18:39:35 +0000 (14:39 -0400)
 * TT-ifying the HTML files
 * Initial Apache rewrite configuration to support routes

Signed-off-by: Bill Erickson <berick@esilibrary.com>
19 files changed:
Open-ILS/examples/apache/eg_vhost.conf.in
Open-ILS/examples/apache_24/eg_vhost.conf.in
Open-ILS/src/templates/staff/README [new file with mode: 0644]
Open-ILS/src/templates/staff/index.tt2 [new file with mode: 0644]
Open-ILS/src/templates/staff/t_base_js.tt2 [new file with mode: 0644]
Open-ILS/src/templates/staff/t_login.tt2 [new file with mode: 0644]
Open-ILS/src/templates/staff/t_navbar.tt2 [new file with mode: 0644]
Open-ILS/src/templates/staff/t_splash.tt2 [new file with mode: 0644]
Open-ILS/web/js/ui/default/staff/app.js [new file with mode: 0644]
Open-ILS/web/js/ui/default/staff/navbar.js [new file with mode: 0644]
Open-ILS/web/js/ui/default/staff/services/auth.js [new file with mode: 0644]
Open-ILS/web/js/ui/default/staff/services/env.js [new file with mode: 0644]
Open-ILS/web/js/ui/default/staff/services/idl.js [new file with mode: 0644]
Open-ILS/web/js/ui/default/staff/services/net.js [new file with mode: 0644]
Open-ILS/web/js/ui/default/staff/services/org.js [new file with mode: 0644]
Open-ILS/web/js/ui/default/staff/services/pcrud.js [new file with mode: 0644]
Open-ILS/web/js/ui/default/staff/services/startup.js [new file with mode: 0644]
Open-ILS/web/js/ui/default/staff/services/ui.js [new file with mode: 0644]
Open-ILS/web/js/ui/default/staff/services/user.js [new file with mode: 0644]

index 1de2212..c39c981 100644 (file)
@@ -787,7 +787,22 @@ RewriteRule ^/openurl$ ${openurl:%1} [NE,PT]
     </IfModule>
 </Location>
 
+# TODO: as is, each sub-app will require a new Location.  
+# need to investigate ways to avoid that.
+<Location /eg/staff/>
+    Options -MultiViews
+    # any reuest that does not map to a template file
+    # is redirected to the index.  This allows us to
+    # map multiple routes to the same application.
+    RewriteEngine On
+    RewriteCond %{REQUEST_URI} !t_*
+    RewriteCond %{REQUEST_URI} !index
+    RewriteRule (.*) /eg/staff/index [L,DPI]
+</Location>
+
 # Uncomment the following to force SSL for everything. Note that this defeats caching
 # and you will suffer a performance hit.
 #RewriteCond %{HTTPS} off
 #RewriteRule ^ https://%{HTTP_HOST}%{REQUEST_URI} [NE,R,L]
+
+
index f530f29..8d70ab3 100644 (file)
@@ -799,6 +799,20 @@ RewriteRule ^/openurl$ ${openurl:%1} [NE,PT]
     </IfModule>
 </Location>
 
+# TODO: as is, each sub-app will require a new Location.  
+# need to investigate ways to avoid that.
+<Location /eg/staff/>
+    Options -MultiViews
+    # any reuest that does not map to a template file
+    # is redirected to the index.  This allows us to
+    # map multiple routes to the same application.
+    RewriteEngine On
+    RewriteCond %{REQUEST_URI} !t_*
+    RewriteCond %{REQUEST_URI} !index
+    RewriteRule (.*) /eg/staff/index [L,DPI]
+</Location>
+
+
 # Uncomment the following to force SSL for everything. Note that this defeats caching
 # and you will suffer a performance hit.
 #RewriteCond %{HTTPS} off
diff --git a/Open-ILS/src/templates/staff/README b/Open-ILS/src/templates/staff/README
new file mode 100644 (file)
index 0000000..9206301
--- /dev/null
@@ -0,0 +1,6 @@
+AnguarJS/Web Staff Client
+=========================
+
+ * TT templates loaded via JS routes must be preceded with t_* (or similar), 
+   otherwise apache will serve the template at that path instead of the 
+   index file since the path maps to a real template.
diff --git a/Open-ILS/src/templates/staff/index.tt2 b/Open-ILS/src/templates/staff/index.tt2
new file mode 100644 (file)
index 0000000..143e164
--- /dev/null
@@ -0,0 +1,24 @@
+<!doctype html>
+<html ng-app="egHome" lang="[% ctx.locale %]">
+  <head>
+    <title>[% l('Evergreen Staff') %]</title>
+    <base href="/eg/staff/" />
+    <meta name="viewport" content="width=device-width, initial-scale=1.0">
+    <!-- TODO: remote hosted CSS should be hosted locally instead -->
+    <link rel="stylesheet" href="//netdna.bootstrapcdn.com/bootstrap/3.0.0/css/bootstrap.min.css" />
+  </head>
+  <body>
+    [% 
+      # load the navbar server-side to speed up initial page display
+      INCLUDE "staff/t_navbar.tt2"; 
+    %]
+
+    <!-- angular route-specific view -->
+    <div ng-view></div> 
+  </body>
+  [% INCLUDE "staff/t_base_js.tt2" %]
+
+  <!-- angular-driven controllers -->
+  <script src="[% ctx.media_prefix %]/js/ui/default/staff/app.js"></script>
+  <script src="[% ctx.media_prefix %]/js/ui/default/staff/navbar.js"></script>
+</html>
diff --git a/Open-ILS/src/templates/staff/t_base_js.tt2 b/Open-ILS/src/templates/staff/t_base_js.tt2
new file mode 100644 (file)
index 0000000..de516d7
--- /dev/null
@@ -0,0 +1,30 @@
+<!-- TODO: remotely hosted JS should be hosted locally -->
+<!-- TODO: combine and minify JS -->
+
+<!-- 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.3/angular.min.js"></script>
+<script src="//ajax.googleapis.com/ajax/libs/angularjs/1.2.0-rc.3/angular-route.min.js"></script>
+<script src="//ajax.googleapis.com/ajax/libs/angularjs/1.2.0-rc.3/angular-cookies.min.js"></script>
+
+<!-- IDL / opensrf (network) -->
+<script src="/IDL2js"></script>
+<script src="[% ctx.media_prefix %]/js/dojo/opensrf/JSON_v1.js"></script>
+<script src="[% ctx.media_prefix %]/js/dojo/opensrf/opensrf.js"></script>
+<script src="[% ctx.media_prefix %]/js/dojo/opensrf/opensrf_xhr.js"></script>
+
+<!-- needed for login -->
+<script src="[% ctx.media_prefix %]/js/dojo/opensrf/md5.js"></script>
+
+<!-- angular-driven shared services -->
+<script src="[% ctx.media_prefix %]/js/ui/default/staff/services/idl.js"></script>
+<script src="[% ctx.media_prefix %]/js/ui/default/staff/services/net.js"></script>
+<script src="[% ctx.media_prefix %]/js/ui/default/staff/services/auth.js"></script>
+<script src="[% ctx.media_prefix %]/js/ui/default/staff/services/pcrud.js"></script>
+<script src="[% ctx.media_prefix %]/js/ui/default/staff/services/env.js"></script>
+<script src="[% ctx.media_prefix %]/js/ui/default/staff/services/startup.js"></script>
+<script src="[% ctx.media_prefix %]/js/ui/default/staff/services/ui.js"></script>
+
diff --git a/Open-ILS/src/templates/staff/t_login.tt2 b/Open-ILS/src/templates/staff/t_login.tt2
new file mode 100644 (file)
index 0000000..506e3eb
--- /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>[% l('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">[% l('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">[% l('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">[% l('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">[% l('Sign in') %]</button>
+                <span ng-show="loginFailed">[% l('Login Failed') %]</span>
+              </div>
+            </div>
+          </form>
+        </fieldset>
+      </div>
+    <div class="col-lg-3"></div><!-- offset? -->
+  </div>
+</div>
diff --git a/Open-ILS/src/templates/staff/t_navbar.tt2 b/Open-ILS/src/templates/staff/t_navbar.tt2
new file mode 100644 (file)
index 0000000..7ef2aca
--- /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">[% l('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">[% l('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">[% l('Log Out') %]</a></li>
+    </ul>
+  </div>
+</div>
+
+
diff --git a/Open-ILS/src/templates/staff/t_splash.tt2 b/Open-ILS/src/templates/staff/t_splash.tt2
new file mode 100644 (file)
index 0000000..113c55f
--- /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">[% l('Circulation and 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">[% l('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">[% l('Item Search and 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">[% l('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>
diff --git a/Open-ILS/web/js/ui/default/staff/app.js b/Open-ILS/web/js/ui/default/staff/app.js
new file mode 100644 (file)
index 0000000..332dfa0
--- /dev/null
@@ -0,0 +1,88 @@
+/**
+ * App to drive the base page. 
+ * Login Form
+ * Splash Page
+ */
+
+angular.module('egHome', ['ngRoute', 'egStartupMod', 'egAuthMod', 'egUiMod'])
+
+.config(function($routeProvider, $locationProvider) {
+    console.log('config');
+
+    /**
+     * 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: './t_login',
+        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 : './t_splash',
+        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/Open-ILS/web/js/ui/default/staff/navbar.js b/Open-ILS/web/js/ui/default/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/Open-ILS/web/js/ui/default/staff/services/auth.js b/Open-ILS/web/js/ui/default/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/Open-ILS/web/js/ui/default/staff/services/env.js b/Open-ILS/web/js/ui/default/staff/services/env.js
new file mode 100644 (file)
index 0000000..e79b520
--- /dev/null
@@ -0,0 +1,95 @@
+/* 
+ * 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', 'egPCRUDMod'])
+
+// env cache
+.factory('egEnvCache', ['$cacheFactory', 
+function($cacheFactory) {
+    return $cacheFactory('egEnvCache', {});
+}])
+
+// env fetcher
+.factory('egEnv', 
+    ['$q', 'egEnvCache', 'egNet', 'egAuth', 'egPCRUD',
+function($q, egEnvCache, egNet, egAuth, egPCRUD) { 
+
+    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 egPCRUD.search('aou', {parent_ou : null}, 
+                {flesh : -1, flesh_fields : {aou : ['children', 'ou_type']}}
+            ).then(
+                function(tree) {service.absorbTree(tree, 'aou')}
+            );
+        },
+
+        // TODO: make me optional -- not all UIs need the PGT
+        pgt : function() {
+            return egPCRUD.search('pgt', {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/Open-ILS/web/js/ui/default/staff/services/idl.js b/Open-ILS/web/js/ui/default/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/Open-ILS/web/js/ui/default/staff/services/net.js b/Open-ILS/web/js/ui/default/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/Open-ILS/web/js/ui/default/staff/services/org.js b/Open-ILS/web/js/ui/default/staff/services/org.js
new file mode 100644 (file)
index 0000000..3941a51
--- /dev/null
@@ -0,0 +1,46 @@
+angular.module('egOrgMod', ['egEnvMod', 'egPCRUDMod'])
+
+.factory('egOrg', ['egEnv', 'egAuth', 'egPCRUD',
+function(egEnv, egAuth, egPCRUD) { 
+
+    var service = {};
+
+    service.get = function(node_or_id) {
+        if (typeof node_or_id == 'object')
+            return node_or_id;
+        return egEnv.get('aou').map[node_or_id];
+    };
+
+    service.list = function() {
+        return egEnv.get('aou').list;
+    };
+
+    service.ancestors = function(node_or_id) {
+        var node = service.get(node_or_id);
+        if (!node) return [];
+        var nodes = [node];
+        while( (node = service.get(node.parent_ou())))
+            nodes.push(node);
+        return nodes;
+    };
+
+    service.descendants = function(node_or_id) {
+        var node = service.get(node_or_id);
+        if (!node) return [];
+        var nodes = [];
+        function descend(n) {
+            nodes.push(n);
+            angular.forEach(n.children(), descend);
+        }
+        descend(node);
+        return nodes;
+    }
+
+    service.fullPath = function(node_or_id) {
+        return service.ancestors(node_or_id).concat(
+          service.descendants(node_or_id).slice(1));
+    }
+
+    return service;
+}]);
diff --git a/Open-ILS/web/js/ui/default/staff/services/pcrud.js b/Open-ILS/web/js/ui/default/staff/services/pcrud.js
new file mode 100644 (file)
index 0000000..774f252
--- /dev/null
@@ -0,0 +1,288 @@
+/**
+ * PCRUD client.
+ *
+ * Factory for PCRUDContext objects with pass-through service-level API.
+ *
+ * For most types of communication, where the client expects to make a
+ * single request which egPCRUD manages internally, use the service-
+ * level API.
+ *
+ * All service-level APIs (except connect()) return a promise, whose
+ * notfiy() channels individual responses (think: onresponse) and 
+ * whose resolve() channels the last received response (think: 
+ * oncomplete), consistent with egNet.request().  If only one response
+ * is expected (e.g. retrieve(), or .atomic searches), notify() 
+ * handlers are not required.
+ *
+ * egPCRUD.retrieve('aou', 1)
+ * .then(function(org) { console.log(org.shortname()) });
+ *
+ * egPCRUD.search('aou', {id : [1,2,3]})
+ * .then(function(orgs) { console.log(orgs.length) } );
+ *
+ * egPCRUD.search('aou', {id : {'!=' : null}}, {limit : 10})
+ * .then(...);
+ *
+ * For requests where the caller needs to manually connect and make
+ * individual API calls, the service.connect() call will create and
+ * pass a PCRUDContext object as the argument to the connect promise 
+ * resolver.  The PCRUDContext object can be used to make subsequent 
+ * pcrud calls directly.
+ *
+ * egPCRUD.connnect().then(
+ *   function(ctx) {
+ *     ctx.retrieve('aou', 1).then(
+ *       function(org) {
+ *         console.log(org.id());
+ *         ctx.disconnect();
+ *       }
+ *     )
+ *   }
+ * );
+ */
+angular.module('egPCRUDMod', [])
+
+// env fetcher
+.factory('egPCRUD', ['$q', 'egAuth', 'egIDL', function($q, egAuth, egIDL) { 
+    
+    var service = {};
+
+    // create service-level pass through functions 
+    // for one-off PCRUDContext actions.
+    angular.forEach(['connect', 'retrieve', 'retrieveAll', 
+        'search', 'create', 'update', 'remove', 'apply'],
+        function(action) {
+            service[action] = function() {
+                var ctx = new PCRUDContext();
+                return ctx[action].apply(ctx, arguments);
+            }
+        }
+    );
+
+    /*
+     * Since services are singleton objectss, we need an internal 
+     * class to manage individual PCRUD conversations.  
+     */
+    var PCRUDContextIdent = 0; // useful for debug logging
+    function PCRUDContext() {
+        var self = this;
+        this.xact_close_mode = 'rollback';
+        this.ident = PCRUDContextIdent++;
+        this.session = new OpenSRF.ClientSession('open-ils.pcrud');
+
+        this.toString = function() {
+            return '[PCRUDContext ' + this.ident + ']';
+        };
+
+        this.log = function(msg) {
+            console.debug(this + ': ' + msg);
+        };
+
+        this.err = function(msg) {
+            console.error(this + ': ' + msg);
+        };
+
+        this.connect = function() {
+            this.log('connect');
+            var deferred = $q.defer();
+            this.session.connect({onconnect : 
+                function() {deferred.resolve(self)}});
+            return deferred.promise;
+        };
+
+        this.disconnect = function() {
+            this.log('disconnect');
+            this.session.disconnect();
+        };
+
+        this.retrieve = function(fm_class, pkey, pcrud_ops) {
+            return this._dispatch(
+                'open-ils.pcrud.retrieve.' + fm_class,
+                [egAuth.token(), pkey, pcrud_ops]
+            );
+        };
+
+        this.retrieveAll = function(fm_class, pcrud_ops, req_ops) {
+            var search = {};
+            search[egIDL.classes[fm_class].pkey] = {'!=' : null};
+            return this.search(fm_class, search, pcrud_ops, req_ops);
+        };
+
+        this.search = function (fm_class, search, pcrud_ops, req_ops) {
+            req_ops = req_ops || {};
+
+            var return_type = req_ops.idlist ? 'id_list' : 'search';
+            var method = 'open-ils.pcrud.' + return_type + '.' + fm_class;
+
+            if (req_ops.atomic) method += '.atomic';
+
+            return this._dispatch(method, 
+                [egAuth.token(), search, pcrud_ops]);
+        };
+
+        this.create = function(list) {return this.CUD('create', list)};
+        this.update = function(list) {return this.CUD('update', list)};
+        this.remove = function(list) {return this.CUD('delete', list)};
+        this.apply  = function(list) {return this.CUD('apply',  list)};
+
+        this.xactClose = function() {
+            return this._send_request(
+                'open-ils.pcrud.transaction.' + this.xact_close_mode,
+                [egAuth.token()]
+            );
+        };
+
+        this.xactBegin = function() {
+            return this._send_request(
+                'open-ils.pcrud.transaction.begin',
+                [egAuth.token()]
+            );
+        };
+
+        this._dispatch = function(method, params) {
+            if (this.authoritative) {
+                return this._wrap_xact(
+                    function() {
+                        return self._send_request(method, params);
+                    }
+                );
+            } else {
+                return this._send_request(method, params)
+            }
+        };
+
+
+        // => connect
+        // => xact_begin 
+        // => action
+        // => xact_close(commit/rollback) 
+        // => disconnect
+        // Returns a promise
+        // main_func should return a promise
+        this._wrap_xact = function(main_func) {
+            var deferred = $q.defer();
+
+            // 1. connect
+            this.connect().then(function() {
+
+            // 2. start the transaction
+            self.xactBegin().then(function() {
+
+            // 3. execute the main body 
+            main_func().then(
+                // main body complete
+                function(lastResp) {  
+
+                    // 4. close the transaction
+                    self.xactClose().then(function() {
+                        // 5. disconnect
+                        self.disconnect();
+                        // 6. all done
+                        deferred.resolve(lastResp);
+                    });
+                },
+
+                // main body error handler
+                function() {}, 
+
+                // main body notify() handler
+                function(data) {deferred.notify(data)}
+            );
+
+            })}); // close 'em all up.
+
+            return deferred.promise;
+        };
+
+        this._send_request = function(method, params) {
+            this.log('_send_request(' + method + ')');
+            var deferred = $q.defer();
+            var lastResp;
+            this.session.request({
+                method : method,
+                params : params,
+                onresponse : function(r) {
+                    var resp = r.recv();
+                    if (resp && (lastResp = resp.content())) {
+                        deferred.notify(lastResp);
+                    } else {
+                        // pcrud requests should always return something
+                        self.err(method + " returned no response");
+                    }
+                },
+                oncomplete : function() {
+                    deferred.resolve(lastResp);
+                },
+                onerror : function(e) {
+                    self.err(method + " failed " + e);
+                    deferred.reject(e);
+                }
+            }).send();
+
+            return deferred.promise;
+        };
+
+        this.CUD = function (action, list) {
+            this.log('CUD(): ' + action);
+
+            this.cud_idx = 0;
+            this.cud_action = action;
+            this.xact_close_mode = 'commit';
+            this.cud_list = list;
+            this.cud_deferred = $q.defer();
+
+            if (!angular.isArray(list) || list.classname)
+                this.cud_list = [list];
+
+            return this._wrap_xact(
+                function() {
+                    self._CUD_next_request();
+                    return self.cud_deferred.promise;
+                }
+            );
+        }
+
+        /**
+         * Loops through the list of objects to update and sends
+         * them one at a time to the server for processing.  Once
+         * all are done, the cud_deferred promise is resolved.
+         */
+        this._CUD_next_request = function() {
+
+            if (this.cud_idx >= this.cud_list.length) {
+                this.cud_deferred.resolve(this.cud_last);
+                return;
+            }
+
+            var action = this.cud_action;
+            var fm_obj = this.cud_list[this.cud_idx++];
+
+            if (action == 'auto') {
+                if (fm_obj.ischanged()) action = 'update';
+                if (fm_obj.isnew())     action = 'create';
+                if (fm_obj.isdeleted()) action = 'delete';
+
+                if (action == 'auto') {
+                    // object does not need updating; move along
+                    this._CUD_next_request();
+                }
+            }
+
+            this._send_request(
+                'open-ils.pcrud.' + action + '.' + fm_obj.classname,
+                [egAuth.token(), fm_obj]).then(
+                function(data) {
+                    // update actions return one response.
+                    // no notify() handler needed.
+                    self.cud_last = data;
+                    self.cud_deferred.notify(data);
+                    self._CUD_next_request();
+                }
+            );
+           
+        };
+    }
+
+    return service;
+}]);
+
diff --git a/Open-ILS/web/js/ui/default/staff/services/startup.js b/Open-ILS/web/js/ui/default/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/Open-ILS/web/js/ui/default/staff/services/ui.js b/Open-ILS/web/js/ui/default/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/Open-ILS/web/js/ui/default/staff/services/user.js b/Open-ILS/web/js/ui/default/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;
+}]);
+