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]
-----------------------------------------------------------------------------
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]
-----------------------------------------------------------------------------
-----------------------------------------------------------------------------
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]
-----------------------------------------------------------------------------
-----------------------------------------------------------------------------
[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]
-----------------------------------------------------------------------------
-----------------------------------------------------------------------------
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
////