Browser-Based Staff Client Development Log ========================================== 2013-11-20 Templates and Apache ------------------------------- When a path is requested from the server, e.g. /eg/staff/circ/patron/search, there are 2 different aspects of the file that are of intest to us: the content of the HTML file and the path used to retrieve the file. Page Content ~~~~~~~~~~~~ For Angular apps, the HTML page only needs to provide enough information for Angular to load the correct application -- a seed document. A seed document might look something like this: [source,html] ----------------------------------------------------------------------------- Evergreen Staff Patron
----------------------------------------------------------------------------- Note the body is a single div with an 'ng-view' tag. Building Pages from Seeds ~~~~~~~~~~~~~~~~~~~~~~~~~ With the above document we know the Angular application (egPatronApp), we have an Angular Controller (PatronCtrl -- not strictly required here). If we assume this page was fetched using the path '/eg/staff/circ/patron/search', then we have all we need to build the real page. The Angular App will contain a series of app-specific routes based on the URL path. Here, our (relative) path will be '/circ/patron/search', since the base path of the application is '/eg/staff/'. For our route configuration we have a chunk of code like this: [source,js] ----------------------------------------------------------------------------- $routeProvider.when('/circ/patron/search', { templateUrl: './circ/patron/t_search', controller: 'PatronSearchCtrl', resolve : resolver // more on resolvers later... }); ----------------------------------------------------------------------------- When the browser lands on '/eg/staff/circ/patron/search', Angular will locate the template file at './circ/patron/t_search' (the app-relative path to a Template Toolkit template), by performing an HTTP request and, once fetched, will drive the display of the Angular template within with the JS controller called PatronSearchCtrl and insert the content of the template into the body of the page at the location of the
div. **** 'Note': For speed, it's sometimes better to include Angular templates directly in the delivered document so that one less HTTP request is needed. More on that later. **** Fetching the Same Page at Different URLs ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ The egPatronApp might support a variety of different route-specific interfaces that are all driven by the same seed document. This means we have to tell Apache to always deliver the same file when we access files within a given range of URL paths. The secret to this is in Apache Rewrite configuration. For example: [source,conf] ----------------------------------------------------------------------------- Options -MultiViews RewriteEngine On RewriteCond %{PATH_INFO} !/staff/circ/patron/index RewriteCond %{PATH_INFO} !/staff/circ/patron/t_* RewriteRule .* /eg/staff/index [L,DPI] ----------------------------------------------------------------------------- In short, any URL path that does not map to the index file or to a file whose name starts with "t_" (more on 't_' below) will result in Apache rewriting the request to deliver the index file (i.e. our seed document). So, in our example, a request for '/eg/staff/circ/patron/search', will return the index file found at '/eg/staff/circ/patron/index', which maps on the server side to the Template Toolkit file at '/path/to/templates/staff/circ/patron/index.tt2'. Two complications arise from this approach. Help appreciated! ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ Simpler rewrite rules exist in the wild... ++++++++++++++++++++++++++++++++++++++++++ But, they do not take into account that we are fetching Template Toolkit-generated files instead of vanilla HTML. The rules I found online take the form of "if it's not a real file, return the index", but none of the files we fetch are real files, since they are internally mapped to Template Toolkit files. This is why I'm using the 't_' prefix. It makes the mapping trivial. I'm all ears for better solution. Configuration Explosion +++++++++++++++++++++++ This I see as a real problem, but one that certainly has a solution. The configuration chunk above is such that we need a new chunk for each top-level Angular app. This will quickly get out of hand. A single, dynamic configuration that can map elemenents of arbitrarily-nested paths (or possibly a small set with predefined path depths) to the correct index file would be ideal. UPDATE: 2013-12-12 Apache Config Repaired +++++++++++++++++++++++++++++++++++++++++ I finally got around to fixing the Apache configuration. It now supports arbitrary nesting via EGWeb.pm extension. [source,conf] ----------------------------------------------------------------------------- Options -MultiViews PerlSetVar OILSWebStopAtIndex "true" # ... ----------------------------------------------------------------------------- 2013-11-21 Angular $scope inheritance ------------------------------------- Consider the following document: [source,html] -----------------------------------------------------------------------------
Top: {{attr1}}
Sub: {{attr1}}
----------------------------------------------------------------------------- And the following code: [source,js] ----------------------------------------------------------------------------- .controller('TopCtrl', function($scope) { $scope.attr1 = 'top-attr' }) .controller('SubCtrl', function($scope) { }) ----------------------------------------------------------------------------- The output: [source,sh] ----------------------------------------------------------------------------- Top: top-attr Sub: top-attr ----------------------------------------------------------------------------- Now, if we apply a value in the child: [source,js] ----------------------------------------------------------------------------- .controller('SubCtrl', function($scope) { $scope.attr1 = 'sub-attr' }) ----------------------------------------------------------------------------- [source,sh] ----------------------------------------------------------------------------- Top: top-attr Sub: sub-attr ----------------------------------------------------------------------------- Setting a value in the child does not change the value in the parent. Scopes are inherited prototypically, which means attributes from a parent scope are copied into the child scope and the child's version of the attribute masks that of the parent. For both scopes to share a single value, either the parent needs to provide a setter function on the value: [source,js] ----------------------------------------------------------------------------- .controller('TopCtrl', function($scope) { $scope.attr1 = 'top-attr'; $scope.setAttr1 = function(val) { $scope.attr1 = val; } }) .controller('SubCtrl', function($scope) { $scope.setAttr1('sub-attr'); }) ----------------------------------------------------------------------------- Produces.. [source,sh] ----------------------------------------------------------------------------- Top: sub-attr Sub: sub-attr ----------------------------------------------------------------------------- Or the value in question needs to be stored within a structure. [source,html] -----------------------------------------------------------------------------
Top: {{attrs.attr1}}
Sub: {{attrs.attr1}}
----------------------------------------------------------------------------- [source,js] ----------------------------------------------------------------------------- .controller('TopCtrl', function($scope) { $scope.attrs = {attr1 : 'top-attr'} }) .controller('SubCtrl', function($scope) { $scope.attrs.attr1 = 'sub-attr' }) ----------------------------------------------------------------------------- Also produces.. [source,sh] ----------------------------------------------------------------------------- Top: sub-attr Sub: sub-attr ----------------------------------------------------------------------------- Since the child scope is not clobbering the 'attrs' attribute, both scopes share the value, which is a reference to a single object. This last is approach is the best for providing two-way binding across both scopes. For example: [source,html] -----------------------------------------------------------------------------
Top:
Sub:
----------------------------------------------------------------------------- With this, typing a value into the first input, will set the value for both scopes. For more, see https://github.com/angular/angular.js/wiki/Understanding-Scopes[Understanding-Scopes]. 2013-11-25 Prototype Install and Hacking ---------------------------------------- Installing the code ~~~~~~~~~~~~~~~~~~~ The code lives at http://git.evergreen-ils.org/?p=working/Evergreen.git;a=shortlog;h=refs/heads/collab/berick/web-staff-proto[working => collab/berick/web-staff-proto] **** The branch is a child of Evergreen master and will be kept up to date with master as development progresses. **** Install the code in the usual Evergreen manner -- No DB upgrades are required to date, but there are some IDL changes -- and apply the Apache configuration changes found in eg_vhost.conf: [source,conf] ----------------------------------------------------------------------------- # see notes above on how we can condense the configuration... # TODO: some of this is clearly redundant and needs to be rearranged Options -MultiViews RewriteEngine On RewriteCond %{PATH_INFO} !/staff/index RewriteCond %{PATH_INFO} !/staff/t_* RewriteRule .* /eg/staff/index [L,DPI] Header append Cache-Control "public" SetOutputFilter DEFLATE BrowserMatch ^Mozilla/4 gzip-only-text/html BrowserMatch ^Mozilla/4\.0[678] no-gzip BrowserMatch \bMSI[E] !no-gzip !gzip-only-text/html SetEnvIfNoCase Request_URI \.(?:gif|jpe?g|png)$ no-gzip dont-vary Header append Vary User-Agent env=!dont-vary Options -MultiViews RewriteEngine On RewriteCond %{PATH_INFO} !/staff/cat/bucket/record/index RewriteCond %{PATH_INFO} !/staff/cat/bucket/record/t_* RewriteRule .* /eg/staff/cat/bucket/record/index [L,DPI] Options -MultiViews RewriteEngine On RewriteCond %{PATH_INFO} !/staff/circ/patron/index RewriteCond %{PATH_INFO} !/staff/circ/patron/t_* RewriteRule .* /eg/staff/circ/patron/index [L,DPI] Header append Cache-Control "public" SetOutputFilter DEFLATE BrowserMatch ^Mozilla/4 gzip-only-text/html BrowserMatch ^Mozilla/4\.0[678] no-gzip BrowserMatch \bMSI[E] !no-gzip !gzip-only-text/html SetEnvIfNoCase Request_URI \.(?:gif|jpe?g|png)$ no-gzip dont-vary Header append Vary User-Agent env=!dont-vary ----------------------------------------------------------------------------- Once installed, point Chrome or Firefox at https://hostname/eg/staff/ to see the splash page. Hacking ~~~~~~~ Most of the code should seem familiar. The Template Toolkit templates behave as usual. For modifying scripts and Angular-loaded templates, I highly recommend Chrome's "Disable Cache (while dev tools is open)" option found under the Javascript console (dev tools) configuration menu (gear icon, bottom right) since Chrome aggressively caches the templates. 2013-11-26 Getting Started with Testing --------------------------------------- Today I wrote one unit test for a stub controller from https://github.com/angular/angular-seed[angular-seed]. Here's what I did. I'm still figuring this out, so bear with me... * On my desktop, I installed node.js and npm (node package manager) and the node plugin 'karma'. [source,sh] ----------------------------------------------------------------------------- % sudo apt-get install nodejs npm # node.js is installed at 'nodejs' -- npm, etc. assume 'node' % sudo ln -s /usr/bin/nodejs /usr/bin/node % sudo npm install -g karma ----------------------------------------------------------------------------- * Clone the angular-seed repository, which provides a stub test environment [source,sh] ----------------------------------------------------------------------------- % git clone https://github.com/angular/angular-seed ----------------------------------------------------------------------------- * Modify the angular-seed test script, which makes some odd assumptions about the location of binaries [source,diff] ----------------------------------------------------------------------------- diff --git a/scripts/test.sh b/scripts/test.sh index 972001f..f6db762 100755 --- a/scripts/test.sh +++ b/scripts/test.sh @@ -6,4 +6,5 @@ echo "" echo "Starting Karma Server (http://karma-runner.github.io)" echo "-------------------------------------------------------------------" -$BASE_DIR/../node_modules/karma/bin/karma start $BASE_DIR/../config/karma.conf.js $* +#$BASE_DIR/../node_modules/karma/bin/karma start $BASE_DIR/../config/karma.conf.js $* +karma start $BASE_DIR/../config/karma.conf.js $* ----------------------------------------------------------------------------- * Modify the stock controller and controller unit test to do something (very simple). [source,diff] ----------------------------------------------------------------------------- diff --git a/app/js/controllers.js b/app/js/controllers.js index cc9d305..679570d 100644 --- a/app/js/controllers.js +++ b/app/js/controllers.js @@ -3,9 +3,10 @@ /* Controllers */ angular.module('myApp.controllers', []). - controller('MyCtrl1', [function() { + controller('MyCtrl1', ['$scope', function($scope) { + $scope.foo = 'hello'; }]) diff --git a/test/unit/controllersSpec.js b/test/unit/controllersSpec.js index 23f6b09..ec741a6 100644 --- a/test/unit/controllersSpec.js +++ b/test/unit/controllersSpec.js @@ -3,11 +3,18 @@ /* jasmine specs for controllers go here */ describe('controllers', function(){ beforeEach(module('myApp.controllers')); + // create the scope, instantiate the controller + var ctrl, scope; + beforeEach(inject(function ($rootScope, $controller) { + scope = $rootScope.$new(); + ctrl = $controller('MyCtrl1', {$scope: scope}); + })); it('should ....', inject(function() { //spec body + expect(scope.foo).toEqual('hello'); })); ----------------------------------------------------------------------------- * Launched the test, which fires up Chrome and logs the output to the terminal [source,sh] ----------------------------------------------------------------------------- CHROME_BIN=/usr/bin/chromium-browser scripts/test.sh Starting Karma Server (http://karma-runner.github.io) INFO [karma]: Karma v0.10.5 server started at http://localhost:9876/ INFO [launcher]: Starting browser Chrome INFO [Chromium 30.0.1599 (Ubuntu)]: Connected on socket 344641UsyCbWCVxk_28H Chromium 30.0.1599 (Ubuntu): Executed 5 of 5 SUCCESS (0.612 secs / 0.088 secs) ----------------------------------------------------------------------------- All 5 tests succeeded, even my rigorous $scope.foo='hello' test. Next steps for testing will be to create an environment around the staff client code so we can write some real tests... 2013-12-02 Scopes, Services, and Routing ---------------------------------------- If you have two routes like so, [source,js] ----------------------------------------------------------------------------- $routeProvider.when('/circ/patron/1/items_out', {...}); $routeProvider.when('/circ/patron/1/holds', {...}); ----------------------------------------------------------------------------- You can access these two pages by changing your browser's URL or you can use links/actions in the page to jump between routes using pushstate routing. Pushsate routing allows you to change the browser URL and have the JS code respond to the change without actually reloading the web page. In short, it makes page switching faster. It does this in two main ways. 1. The browser does not have to fetch the page and scripts from the server (or cache) or re-parse and execute scripts that run at page load time. 2. If two pages share data, which is common for route-linked pages, then it's possible for the second page to access data retrieved by the first, without having to re-fetch it from the server. How do we manage routing and caching with angular scopes and services? Any time a path is loaded in Angular, regardless of how the path was accessed (routing or initial page load), all controllers within the page are re-initialized with an empty $scope. Naturally, this means that any data that was loaded into a $scope will no longer be available when the next route is accessed. To persist data across routes, it has to be managed outside of the controller. The Angular solution is to use a service. Generally speaking, Angular services provide re-usable chunks of logic that are scope and controller-agnostic. They are factory classes which produce singleton objects, instantiated once at page load, and persisted through all routes. Because of this, they double as a great place to store route-global data (i.e. data that should be cached between all of our known routes). Some examples, with lots of extra stuff left out: [source,js] ----------------------------------------------------------------------------- .controller('PatronItemsOut', function($scope) { fetchPatronFromServer(id).then( function(p) { $scope.patron = p }); }) .controller('PatronHolds', function($scope) { fetchPatronFromServer(id).then( function(p) { $scope.patron = p }); }) ----------------------------------------------------------------------------- Here we fetch the patron object from the server every time the PatronItemsOut or PatronHolds controllers are instantiated. Since these two controllers are managed within the same angular app, they are accessible via routing and can theoretically share date. Fetching the user every time is wasteful. Here's an example where we mange user retrieval with a service. [source,js] ----------------------------------------------------------------------------- .factory('patronService', function() { var service = { // only cache the last-accessed user. // caching all accessed users is a recipe for memory leaks current : null }; service.fetchPatron = function(id) { // on initial page load, service.current will be null if (service.current && service.current.id() == id) { // no need to fetch! } else { // fetch patron and set service.current = patron } } return service; }) .controller('PatronItemsOut', function($scope, patronService) { patronService.fetchPatron(id) $scope.patron = function() { return patronService.current } }) .controller('PatronHolds', function($scope, patronService) { patronService.fetchPatron(id) $scope.patron = function() { return patronService.current } }) ----------------------------------------------------------------------------- Now the patron object lives within our patronService Angular service. It will persist through page routes until we replace the value or reload the page. Note that we are now accessing our patron at $scope.patron() instead of $scope.patron, because $scope.patron() is guaranteed to always return the correct value for the current patron. Angular templates are perfectly happy to work with functions intead of attributes. [source,html] -----------------------------------------------------------------------------
Username: {{patron().usrname()}}
----------------------------------------------------------------------------- 2013-12-03 What About Workstations? Printing? ---------------------------------------------- Why is the workstation optional in the prototype login page? ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ I mentioned this briefly toward the bottom of my old "Hey, let's build a prototype!" http://yeti.esilibrary.com/dev/pub/EvergreenWebStaffClientPrototype.pdf[proposal]. I've heard the question again from a number of people, so I'll answer it here in a little more detail. Short answer first, yes, the plan is to continue requiring workstations for the browser-based client. The fact that they are not required in the prototype is the result of an unresolved technical hurdle. What's the problem, exactly? ~~~~~~~~~~~~~~~~~~~~~~~~~~~~ One of the main benefits of XULRunner is that it runs on your computer as a trusted application. XULRunner can open files on your computer, create raw network sockets, and it can talk directly to your printers. Web browsers, being tasked with executing all kinds of untrusted code, have a whole host of restrictions that prevent that level of access. This is a good thing for security, but it makes accomplishing some tasks understandably much more difficult. This lack of access in the browser may be the biggest challenge we face in developing a browser-based client. There are two classes of problems that we need to resolve: access to files and access to printers. File access is important for things like storing the workstation information in a way that is not easily deleted on accident and can be shared between more than one browser. We need the same level of control for offline transaction data as well. There are probably more... Modern browsers will let you store quite a bit of data (~5MB for localStorage), and in some browsers you can manually raise the storage limit, but they won't let you control where that data lives or allow other applications to access the data. They also make it fairly easy to delete the data en masse within the various debug consoles, which is obviously bad for files we want to stick around indefinitely. For printing, all you can do from the browser is say "print, please" and then the user is shown a print dialog. Different browsers have different settings for controlling whether the dialog appears, etc. but they are non-standard and still not as flexible as we need. What we need is a way for the web application to send print data to different printers based on the context of the action, without requiring the user to manually select the printer each time. What's the solution? ~~~~~~~~~~~~~~~~~~~~ There are probably multiple solutions. The most promising so far, which was discussed at the http://goo.gl/oKo5yt[Evergreen Hackaway] is that of a small service which runs on the client machine independent of the browser and performs privileged tasks that the browser cannot. It would likely operate as a small, local web service, accepting connections from clients (i.e. browsers) on the local machine. Those connections may take the form of Websocket connections or XMLHttpRequest via http://en.wikipedia.org/wiki/Cross-origin_resource_sharing[cross-origin resource sharing] (though, I'm not sure the latter approach would actually work). With this approach, we get to resolve all of the browser limitations with one, cross-platform, browser-agnostic add-on. Depending on how it's implemented, we may also get to leverage printing APIs which are more powerful than those found in XULRunner. This probably goes without saying, but such an add-on, whatever form it takes, will *not* be a *requirement* for using the staff client. One aspect of a browser-based client that I find most appealing is the ease of access from different devices and locations. If I were using a tablet, where the add-on is not (or conceivably cannot be) installed, I would still expect to be able to log in, possibly with a workstation (via bookmark/URL or typed in), possibly without, and perform, say, a patron lookup. If I don't need the extended features, I should not be required to have them installed. Finally, it needs a better name than "add-on" (Add-On 2.0!) and it needs developers/sponsors, and all that fun stuff. 2013-12-04 Promises in AngularJS -------------------------------- One of my favorite new-ish tools in the Javascript world are called "Promises". **** If a function cannot return a value or throw an exception without blocking, it can return a promise instead. A promise is an object that represents the return value or the thrown exception that the function may eventually provide. A promise can also be used as a proxy for a remote object to overcome latency. -- https://github.com/kriskowal/q **** Typically, with asynchronous network calls, the caller will make the call and pass in a callback function, which is invoked when the async call returns. This works well enough for single calls, but it does not scale well. Promises allow us to manage collections of asynchronous calls much more elegantly. Here's a quick example: [source,js] ----------------------------------------------------------------------------- // non-promise api call request(service, method, params, {oncomplete : callback}); // promise-based call request(service, method, params).then(callback); ----------------------------------------------------------------------------- At first, the difference seems trivial, but it becomes more pronounced as more requests are added. Batch Parallel Request Example ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ [source,js] ----------------------------------------------------------------------------- // non-promise batch // send three, parallel requests and track the // responses to see when all are done var expected = 3; var checkComplete = function() { if (--expected == 0) { // decrement w/ each response // all responses received, move on to other things } } request(s, m, p, function(r) { /*do stuff*/; checkComplete() }); request(s, m, p, function(r) { /*do stuff*/; checkComplete() }); request(s, m, p, function(r) { /*do stuff*/; checkComplete() }); // promise-based batch var promises = []; promises.push(request(s, m, p).then(function(r) {/*do stuff*/})); promises.push(request(s, m, p).then(function(r) {/*do stuff*/})); promises.push(request(s, m, p).then(function(r) {/*do stuff*/})); $q.all(promises).then( function() { // all responses received, move on to other things } ); // $q.all() takes a collection of promise objects and returns a new // promise which is resolved when all of the embedded promises are resolved ----------------------------------------------------------------------------- Not only is the code a little cleaner, but it flows in the same direction as execution. In other words, checkComplete(), which is the last-executed code in the non-promise example is defined before the requests are sent. This creates a spaghetti effect in the code, where you have to jump around to follow the logic. Not so in the promise example. It flows top to bottom. Attaching to Promises ~~~~~~~~~~~~~~~~~~~~~ Another neat trick you can do w/ promises is create a promise to represent a long-running task. When clients ask for the result of the task and the task is not yet complete, the promise can be returned to any number of clients. [source,js] ----------------------------------------------------------------------------- function longTask() { if (this.promise) { return this.promise; } else { this.promise = performAsyncTask(); return this.promise; } } // the first call kicks off the long-running process longTask().then(/*func*/); // subsequent calls from other locations simply pick up the existing promise // different location in the code longTask().then(/*func*/); // different location in the code longTask().then(/*func*/); // different location in the code longTask().then(/*func*/); // when longTask() finally completes, all promise handlers are run // Also, if longTask() was *already* complete, the promise handler // will be immediately run. ----------------------------------------------------------------------------- This structure is used to great effect in the prototype page initialization code. Independent controllers that all want to ensure the client has authenticated and retrieved necessary data will call the startup code, the first will kick it off, and the others will latch on the outstanding promise. As a final bonus to using promises within Angular, promise resolution causes another $digest() run in Angular, which causes templates to get updated. [source,js] ----------------------------------------------------------------------------- egNet.request( 'open-ils.actor', 'open-ils.actor.user.retrieve', egAuth.token(), userId ).then( function(user) { $scope.patron = patron; } ); ----------------------------------------------------------------------------- [source,html] -----------------------------------------------------------------------------
Username: {{patron.usrname()}}
----------------------------------------------------------------------------- Promises also support reject() and notify() handlers for failed requests and intermediate messages. In my egNet module, I'm leveraging notify() for streaming responses. [source,js] ----------------------------------------------------------------------------- egNet.request(service, method, params).then( function(finalResp) { console.log('all done') }, function() { console.error('request failed!') }, function(resp) { console.log('got a response ' + resp) } ); // The egNet module has additional examples and docs. ----------------------------------------------------------------------------- For the full technical rundown, see also http://docs.angularjs.org/api/ng.$q[Angular $q Docs]. 2013-12-06 Template Cornucopia and Dynamic Strings Experiment ------------------------------------------------------------- Using Angular on top of Template Toolkit gives us lots of options for managing templates. TT lets us INCLUDE (etc.) shared templates on the server. AngularJS lets us ng-include (etc.) both inline templates (i.e. delivered within the main document) and lazy-loaded templates, fetched as needed from the server. When to use each approach comes down to how each template is used in the application. Is it needed on page load? Is it needed on every page? Is it needed often, but not on page load? Is it needed rarely? You have to weigh the cost of adding the template contents into the main page body (does it add a lot of bytes?) to the cost of (potentially) having to fetch it over the network as a separate document. Some examples: Classic TT Template ~~~~~~~~~~~~~~~~~~~ In the prototype, I'm loading the navigation template (t_navbar.tt2) within the base document, i.e. the document used as the base template for all top-level index files. [source,sh] ----------------------------------------------------------------------------- [% INCLUDE "staff/t_navbar.tt2" %] ----------------------------------------------------------------------------- I'm doing this because the navbar is used on every page. It makes no sense to load the template as a separate file from the server, because that just increases the network overhead. Inline Angular Template ~~~~~~~~~~~~~~~~~~~~~~~ Angular templates, regardless of origin, must be represented as addressable chunks. To give an inline chunk of HTML an "address", it can be inserted into a ----------------------------------------------------------------------------- The address is simply the ID, so in this example, the modal dialog is invoked like so: [source,js] ----------------------------------------------------------------------------- $modal.open({templateUrl : 'uncat_alert_dialog', controller : [...]}); ----------------------------------------------------------------------------- **** $modal is an angular-ui-bootstrap service. It's not part of core AngularJS. **** Lazy-Loaded Angular Template ~~~~~~~~~~~~~~~~~~~~~~~~~~~~ These behave much like inline templates, except the template file is fetched from the server as needed. To change the inline template example above into a lazy-loaded template, drop the ----------------------------------------------------------------------------- And here's the confirm dialog code: [source,js] ----------------------------------------------------------------------------- egConfirmDialog.open( egCheckinStrings.COPY_ALERT_MSG_DIALOG_TITLE, evt.payload, // copy alert message text { copy_barcode : args.copy_barcode, ok : function() { // on confirm, redo checkout w/ override performCheckin(args, true) }, cancel : function() { // on cancel, push the event on the list // to show that it happened checkinSvc.checkins.items.push(evt); } } ); ----------------------------------------------------------------------------- You may wonder why I created these as services instead of directives. http://www.befundoo.com/blog/angularjs-popup-dialog/[These folks], who developed some similar code, do a pretty good job of explaining why a service is best in this context. (See "Why create the AngularJS Popup Dialog Service?"). More on Angular directives later. 2013-12-13 Unit Tests with AngularJS/Karma/Jasmine -------------------------------------------------- We now have 4 unit tests in the repository! For now, the files live under Open-ILS/tests/staffweb/. Running Unit Tests ~~~~~~~~~~~~~~~~~~~ These instructions replace my earlier instructions at link:web-staff-log.html#_2013_11_26_getting_started_with_testing[2013-11-26 Getting Started with Testing]. [source,sh] ----------------------------------------------------------------------------- # install node.js % sudo apt-get install nodejs npm # node.js is installed at 'nodejs' -- npm, etc. assume 'node' % sudo ln -s /usr/bin/nodejs /usr/bin/node # install karma test engine node plugin % sudo npm install -g karma # fetch a copy of angular / angular-ui-bootstrap % sudo apt-get install curl # if needed % cd Open-ILS/tests/staffweb/ % ./update-angular.sh 1.2.4 0.7.0 # run the tests % CHROME_BIN=chromium-browser scripts/test.sh ----------------------------------------------------------------------------- The output... [source,sh] ----------------------------------------------------------------------------- Starting Karma Server (http://karma-runner.github.io) \------------------------------------------------------------------- INFO [karma]: Karma v0.10.5 server started at http://localhost:9876/ INFO [launcher]: Starting browser Chrome INFO [Chromium 30.0.1599 (Ubuntu)]: Connected on socket XmJAj4CwHlta3NuSQwC9 Chromium 30.0.1599 (Ubuntu): Executed 4 of 4 SUCCESS (0.623 secs / 0.073 secs) \----------------------------------------------------------------------------- ----------------------------------------------------------------------------- Sample Tests ~~~~~~~~~~~~ [source,js] ----------------------------------------------------------------------------- /** patronSvc tests **/ describe('patronSvcTests', function() { it('patronSvc should start with empty lists', inject(function(patronSvc) { expect(patronSvc.patrons.count()).toEqual(0); })); it('patronSvc reset should clear data', inject(function(patronSvc) { patronSvc.checkout_overrides.a = 1; expect(Object.keys(patronSvc.checkout_overrides).length).toBe(1); patronSvc.resetPatronLists(); expect(Object.keys(patronSvc.checkout_overrides).length).toBe(0); expect(patronSvc.holds.items.length).toBe(0); })); }) ----------------------------------------------------------------------------- These tests are very basic. They ensure that the patronSvc service (defined in circ/patron/app.js) is properly initialized and that the resetPatronLists() function behaves as expected. Initial Reactions ~~~~~~~~~~~~~~~~~ These types of tests can be very useful for testing complex, client-side code. However, just like with Evergreen Perl/C unit tests, JS unit tests are not meant to be executed in a live environment. You can test code that does not require network IO (as above) and you have the option of creating mock data which is used in place of network-retrieved data. I believe the best long-term approach, however, will be full coverage testing with the live, end-to-end test structure, also supported by Angular. It requires more setup and I hope to have time to research it more fully soon. I say this because Evergreen has fairly complex data requirements (IDL required, data fetched through opensrf instead of bare XHR) and practically all of the prototype code uses network IO or assumes the presence of a variety of network-fetched data, which will be non-trivial to mock up and will only grow over time. Fingers crossed that it's not a beast to get running. More on this as the story develops.... 2013-12-13 #2 - Brief Update ---------------------------- * Last week the prototype acquired a locale selector. It uses the existing locale bits from EGWeb.pm, so it was easy to add. * The prototype is nearing completion! * Beware testing on small screens, as the CSS causes the screen to break and flow (e.g. for mobile devices) a little too aggressively right now. TODO. 2013-12-16 JS Minification -------------------------- Angular has some built-in magic which forces us to take special care when preparing for Javascript minification. For the details, see the "A Note On Minification" section of the http://docs.angularjs.org/tutorial/step_05[Angular Totorial Docs]. Briefly, there's a short (auto-magic) way and a long way to declare dependencies for services, controllers, etc. To use minificaiton, you have to use the long way. To make sure everything is working as expected, I've set up a test. Testing Minification ~~~~~~~~~~~~~~~~~~~~ * Download jar file from https://github.com/yui/yuicompressor/releases[YUICompressor Releases] * Create a staff.min.js bundle from commonly loaded files: [source,sh] ----------------------------------------------------------------------------- % cd /openils/var/web/js/ui/default/staff # note the files have to be combined in a specific order % cat /openils/var/web/js/dojo/opensrf/JSON_v1.js \ /openils/var/web/js/dojo/opensrf/opensrf.js \ /openils/var/web/js/dojo/opensrf/opensrf_xhr.js \ services/core.js \ services/idl.js \ services/event.js \ services/net.js \ services/auth.js \ services/pcrud.js \ services/env.js \ services/org.js \ services/startup.js \ services/ui.js \ navbar.js \ | java -jar yuicompressor-2.4.8.jar --type js -o staff.min.js ----------------------------------------------------------------------------- * modify /openils/var/templates/staff/t_base_js.tt2 ** comment out ----------------------------------------------------------------------------- * Reload the page. I'm happy to report all pages load successfully with (mostly) minimized JS. Next on the minification front would be baking minification into the release-building process. Future Topics... ---------------- * My (currently) preferred parent scope, child scope, service pattern * Displaying bib records in the prototype //// vim: ft=asciidoc ////