--- /dev/null
+
+1. ln -s /path/to/ng-staff /openils/var/web/ng-staff
+
+2. See ng-staff/README for more
+
+
--- /dev/null
+# 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>
--- /dev/null
+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)
+
+
--- /dev/null
+/**
+ * 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');
+ }
+]);
+
--- /dev/null
+# 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>
--- /dev/null
+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>
+
--- /dev/null
+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 + '"');
+ }
+ );
+}]);
+
+
--- /dev/null
+<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>
--- /dev/null
+// 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();
+ }
+ }
+ }
+ );
+ }
+}]);
+
+
--- /dev/null
+<!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>
--- /dev/null
+<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>
--- /dev/null
+<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 ⇩</a>
+ <a class="btn" ng-show="display.more_fields"
+ ng-click="display.more_fields = false">Fewer Options ⇧</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>
--- /dev/null
+// 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() : '';
+ };
+}]);
+
--- /dev/null
+<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>
--- /dev/null
+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);
+ }
+ );
+ }
+}]);
--- /dev/null
+<!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>
--- /dev/null
+<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>
--- /dev/null
+<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>
+
+
--- /dev/null
+/**
+ * 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
+ }
+ );
+}
--- /dev/null
+/* 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;
+}]);
+
--- /dev/null
+/*
+ * 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;
+}]);
+
+
+
--- /dev/null
+/**
+ * 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;
+}]);
+
--- /dev/null
+/**
+ * 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;
+ }
+ };
+});
--- /dev/null
+/**
+ * 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;
+ }
+ };
+}]);
+
--- /dev/null
+/**
+ * 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));
+ })
+ }
+ };
+}]);
--- /dev/null
+/** 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;
+}]);
+
--- /dev/null
+<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 & 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 & 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>