From: Bill Erickson Date: Thu, 10 Oct 2013 15:32:38 +0000 (-0400) Subject: AngularJS web-based staff client experiment X-Git-Url: https://old-git.evergreen-ils.org/?a=commitdiff_plain;h=f2d4c60ac007bad49b43dcb389d92e199cf5688d;p=working%2Frandom.git AngularJS web-based staff client experiment https://hostname/ng-staff/circ/patron/search Signed-off-by: Bill Erickson --- diff --git a/README.ng-staff b/README.ng-staff new file mode 100644 index 000000000..43886d487 --- /dev/null +++ b/README.ng-staff @@ -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 index 000000000..f88722640 --- /dev/null +++ b/ng-staff/.htaccess @@ -0,0 +1,9 @@ +# html5 pushstate (history) support: +Options -MultiViews + + RewriteEngine On + RewriteCond %{REQUEST_FILENAME} !-f + RewriteCond %{REQUEST_FILENAME} !-d + RewriteCond %{REQUEST_URI} !index + RewriteRule (.*) /ng-staff/index.html [L,DPI] + diff --git a/ng-staff/README b/ng-staff/README new file mode 100644 index 000000000..0a6662eec --- /dev/null +++ b/ng-staff/README @@ -0,0 +1,57 @@ +The app assumes the base web dir is /ng-staff/. To install elsewhere, +change the 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 index 000000000..f28cfdce6 --- /dev/null +++ b/ng-staff/app.js @@ -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 index 000000000..3ddebe87e --- /dev/null +++ b/ng-staff/circ/.htaccess @@ -0,0 +1,9 @@ +# html5 pushstate (history) support: +Options -MultiViews + + RewriteEngine On + RewriteCond %{REQUEST_FILENAME} !-f + RewriteCond %{REQUEST_FILENAME} !-d + RewriteCond %{REQUEST_URI} !index + RewriteRule (.*) /ng-staff/circ/index.html [L,DPI] + diff --git a/ng-staff/circ/patron/.htaccess b/ng-staff/circ/patron/.htaccess new file mode 100644 index 000000000..a8c389cc6 --- /dev/null +++ b/ng-staff/circ/patron/.htaccess @@ -0,0 +1,9 @@ +Options -MultiViews + + RewriteEngine On + RewriteCond %{REQUEST_FILENAME} !-f + RewriteCond %{REQUEST_FILENAME} !-d + RewriteCond %{REQUEST_URI} !index + RewriteRule (.*) /ng-staff/circ/patron/index.html [L,DPI] + + diff --git a/ng-staff/circ/patron/app.js b/ng-staff/circ/patron/app.js new file mode 100644 index 000000000..fa86cb362 --- /dev/null +++ b/ng-staff/circ/patron/app.js @@ -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 index 000000000..0fff7f361 --- /dev/null +++ b/ng-staff/circ/patron/bills.html @@ -0,0 +1 @@ +BILLS diff --git a/ng-staff/circ/patron/checkout.html b/ng-staff/circ/patron/checkout.html new file mode 100644 index 000000000..55b06d64b --- /dev/null +++ b/ng-staff/circ/patron/checkout.html @@ -0,0 +1,55 @@ + + +
+
+
+ + + + +
+
+ + + + + + + + + + + + + + + + + + + +
#BarcodeTitleAuthorDue DateStatus
{{circs.index}}{{circs.barcode}}{{circs.title}}{{circs.author}}{{circs.due_date | date}}{{circs.status}}
+ + +
diff --git a/ng-staff/circ/patron/checkout.js b/ng-staff/circ/patron/checkout.js new file mode 100644 index 000000000..63ff197d6 --- /dev/null +++ b/ng-staff/circ/patron/checkout.js @@ -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 index 000000000..00daeb87e --- /dev/null +++ b/ng-staff/circ/patron/edit.html @@ -0,0 +1 @@ +EDIT diff --git a/ng-staff/circ/patron/holds.html b/ng-staff/circ/patron/holds.html new file mode 100644 index 000000000..071a08cdd --- /dev/null +++ b/ng-staff/circ/patron/holds.html @@ -0,0 +1 @@ +HOLDS diff --git a/ng-staff/circ/patron/index.html b/ng-staff/circ/patron/index.html new file mode 100644 index 000000000..a4e3d5aca --- /dev/null +++ b/ng-staff/circ/patron/index.html @@ -0,0 +1,48 @@ + + + + Patron + + + + + + + +
+ + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ng-staff/circ/patron/items_out.html b/ng-staff/circ/patron/items_out.html new file mode 100644 index 000000000..4687d9b55 --- /dev/null +++ b/ng-staff/circ/patron/items_out.html @@ -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 index 000000000..b0632b00b --- /dev/null +++ b/ng-staff/circ/patron/messages.html @@ -0,0 +1 @@ +MESSAGES diff --git a/ng-staff/circ/patron/patron.html b/ng-staff/circ/patron/patron.html new file mode 100644 index 000000000..e149266b1 --- /dev/null +++ b/ng-staff/circ/patron/patron.html @@ -0,0 +1,41 @@ + +
+
+
+
+
+ +
+ +
+ +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/ng-staff/circ/patron/search.html b/ng-staff/circ/patron/search.html new file mode 100644 index 000000000..6778d05b8 --- /dev/null +++ b/ng-staff/circ/patron/search.html @@ -0,0 +1,98 @@ + + +
+ +
+
+
+ +
+
+
+ +
+
+
+
+
+ + + + + +
+
+
+
+ + + + + +
+
+
+
+ + + + + Include Inactive? +
+
+
+
+
+ +
+ + + + + + + + + + + + + + + + + + + + + +
#IDBarcodeLast NameFirst NameDoB
{{user.idx}}{{user.id}}{{user.card}}{{user.family_name}}{{user.first_given_name}}{{user.dob | date}}
+
+
+ diff --git a/ng-staff/circ/patron/search.js b/ng-staff/circ/patron/search.js new file mode 100644 index 000000000..a982f1a01 --- /dev/null +++ b/ng-staff/circ/patron/search.js @@ -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 index 000000000..9480b2f0d --- /dev/null +++ b/ng-staff/circ/patron/summary.html @@ -0,0 +1,89 @@ + +
+
+
+ Patron Summary +
+
+
+
+
{{full_name}}
+
+
+
Card
+
{{card}}
+
+
+
Profile
+
{{profile}}
+
+
+
Home Library
+
{{home_ou}}
+
+
+
Create Date
+
{{create_date | date}}
+
+
+
Expire Date
+
{{expire_date | date}}
+
+
+
Fines Owed
+
{{balance_owed | currency}}
+
+
+
Items Out
+
{{items_out}}
+
+
+
Items Overdue
+
{{items_overdue}}
+
+
+
Holds
+
{{holds}} / {{holds_ready}}
+
+
+ + + +
+
+
+
+ {{addr.address_type}} +
{{addr.street1}} {{addr.street2}}
+
{{addr.city}}, {{addr.state}} {{addr.post_code}}
+
+
+
+
+
diff --git a/ng-staff/circ/patron/summary.js b/ng-staff/circ/patron/summary.js new file mode 100644 index 000000000..759f6442e --- /dev/null +++ b/ng-staff/circ/patron/summary.js @@ -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 index 000000000..0a28ad190 --- /dev/null +++ b/ng-staff/index.html @@ -0,0 +1,47 @@ + + + + Evergreen + + + + + + + +
+ + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ng-staff/login.html b/ng-staff/login.html new file mode 100644 index 000000000..66b6134a7 --- /dev/null +++ b/ng-staff/login.html @@ -0,0 +1,52 @@ +
+
+
+
+
+ Sign In + +
+
+ +
+ +
+
+ +
+ +
+ +
+
+ +
+ +
+ +
+
+ +
+
+ + Login Failed +
+
+
+
+
+
+
+
diff --git a/ng-staff/navbar.html b/ng-staff/navbar.html new file mode 100644 index 000000000..5e53a6ae3 --- /dev/null +++ b/ng-staff/navbar.html @@ -0,0 +1,41 @@ + + + diff --git a/ng-staff/navbar.js b/ng-staff/navbar.js new file mode 100644 index 000000000..d7fd4d2e8 --- /dev/null +++ b/ng-staff/navbar.js @@ -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 index 000000000..bf053e6c3 --- /dev/null +++ b/ng-staff/services/auth.js @@ -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 index 000000000..5b11d008e --- /dev/null +++ b/ng-staff/services/env.js @@ -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 index 000000000..9d9832da9 --- /dev/null +++ b/ng-staff/services/idl.js @@ -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 index 000000000..b7cd337ba --- /dev/null +++ b/ng-staff/services/net.js @@ -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 index 000000000..adcb41642 --- /dev/null +++ b/ng-staff/services/startup.js @@ -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 index 000000000..ec9938d9e --- /dev/null +++ b/ng-staff/services/ui.js @@ -0,0 +1,45 @@ +/** + * UI tools and directives. + */ +angular.module('egUiMod', []) + + +/** + * + * $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)); + }) + } + }; +}]) + +// +// $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 index 000000000..1503448ad --- /dev/null +++ b/ng-staff/services/user.js @@ -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 index 000000000..ba5880553 --- /dev/null +++ b/ng-staff/splash.html @@ -0,0 +1,53 @@ +
+
+
+ +
+
+
+
+ +
+
+
+
Circulation & Patrons
+
+
+
+ + Check Out +
+
+
+
+ +
+
+
+
Item Search & Cataloging
+
+
+
+ + More Things +
+
+
+
+ +
+
+
+
Administration
+
+
+ +
+
+
+ +
+