LP#626157 Angular5 base app + some services, UI's, etc.
authorBill Erickson <berickxx@gmail.com>
Sun, 19 Nov 2017 00:51:20 +0000 (19:51 -0500)
committerBill Erickson <berickxx@gmail.com>
Wed, 2 May 2018 15:16:08 +0000 (11:16 -0400)
Signed-off-by: Bill Erickson <berickxx@gmail.com>
151 files changed:
Open-ILS/src/eg2/.angular-cli.json [new file with mode: 0644]
Open-ILS/src/eg2/.editorconfig [new file with mode: 0644]
Open-ILS/src/eg2/.gitignore [new file with mode: 0644]
Open-ILS/src/eg2/README.adoc [new file with mode: 0644]
Open-ILS/src/eg2/e2e/app.e2e-spec.ts [new file with mode: 0644]
Open-ILS/src/eg2/e2e/app.po.ts [new file with mode: 0644]
Open-ILS/src/eg2/e2e/tsconfig.e2e.json [new file with mode: 0644]
Open-ILS/src/eg2/karma.conf.js [new file with mode: 0644]
Open-ILS/src/eg2/package.json [new file with mode: 0644]
Open-ILS/src/eg2/protractor.conf.js [new file with mode: 0644]
Open-ILS/src/eg2/src/app/app.component.ts [new file with mode: 0644]
Open-ILS/src/eg2/src/app/app.module.ts [new file with mode: 0644]
Open-ILS/src/eg2/src/app/common.module.ts [new file with mode: 0644]
Open-ILS/src/eg2/src/app/core/README [new file with mode: 0644]
Open-ILS/src/eg2/src/app/core/auth.service.ts [new file with mode: 0644]
Open-ILS/src/eg2/src/app/core/event.service.ts [new file with mode: 0644]
Open-ILS/src/eg2/src/app/core/idl.service.ts [new file with mode: 0644]
Open-ILS/src/eg2/src/app/core/net.service.ts [new file with mode: 0644]
Open-ILS/src/eg2/src/app/core/org.service.ts [new file with mode: 0644]
Open-ILS/src/eg2/src/app/core/pcrud.service.ts [new file with mode: 0644]
Open-ILS/src/eg2/src/app/core/perm.service.ts [new file with mode: 0644]
Open-ILS/src/eg2/src/app/core/store.service.ts [new file with mode: 0644]
Open-ILS/src/eg2/src/app/migration.module.ts [new file with mode: 0644]
Open-ILS/src/eg2/src/app/resolver.service.ts [new file with mode: 0644]
Open-ILS/src/eg2/src/app/routing.module.ts [new file with mode: 0644]
Open-ILS/src/eg2/src/app/share/README [new file with mode: 0644]
Open-ILS/src/eg2/src/app/share/accesskey/accesskey-info.component.html [new file with mode: 0644]
Open-ILS/src/eg2/src/app/share/accesskey/accesskey-info.component.ts [new file with mode: 0644]
Open-ILS/src/eg2/src/app/share/accesskey/accesskey.directive.ts [new file with mode: 0644]
Open-ILS/src/eg2/src/app/share/accesskey/accesskey.service.ts [new file with mode: 0644]
Open-ILS/src/eg2/src/app/share/catalog/catalog-url.service.ts [new file with mode: 0644]
Open-ILS/src/eg2/src/app/share/catalog/catalog.service.ts [new file with mode: 0644]
Open-ILS/src/eg2/src/app/share/catalog/search-context.ts [new file with mode: 0644]
Open-ILS/src/eg2/src/app/share/catalog/unapi.service.ts [new file with mode: 0644]
Open-ILS/src/eg2/src/app/share/dialog/confirm.component.html [new file with mode: 0644]
Open-ILS/src/eg2/src/app/share/dialog/confirm.component.ts [new file with mode: 0644]
Open-ILS/src/eg2/src/app/share/dialog/dialog.component.ts [new file with mode: 0644]
Open-ILS/src/eg2/src/app/share/dialog/progress.component.css [new file with mode: 0644]
Open-ILS/src/eg2/src/app/share/dialog/progress.component.html [new file with mode: 0644]
Open-ILS/src/eg2/src/app/share/dialog/progress.component.ts [new file with mode: 0644]
Open-ILS/src/eg2/src/app/share/dialog/prompt.component.html [new file with mode: 0644]
Open-ILS/src/eg2/src/app/share/dialog/prompt.component.ts [new file with mode: 0644]
Open-ILS/src/eg2/src/app/share/fm-editor/fm-editor.component.html [new file with mode: 0644]
Open-ILS/src/eg2/src/app/share/fm-editor/fm-editor.component.ts [new file with mode: 0644]
Open-ILS/src/eg2/src/app/share/grid/grid-body.component.html [new file with mode: 0644]
Open-ILS/src/eg2/src/app/share/grid/grid-body.component.ts [new file with mode: 0644]
Open-ILS/src/eg2/src/app/share/grid/grid-column.component.ts [new file with mode: 0644]
Open-ILS/src/eg2/src/app/share/grid/grid-data-source.ts [new file with mode: 0644]
Open-ILS/src/eg2/src/app/share/grid/grid-header.component.html [new file with mode: 0644]
Open-ILS/src/eg2/src/app/share/grid/grid-header.component.ts [new file with mode: 0644]
Open-ILS/src/eg2/src/app/share/grid/grid-toolbar.component.html [new file with mode: 0644]
Open-ILS/src/eg2/src/app/share/grid/grid-toolbar.component.ts [new file with mode: 0644]
Open-ILS/src/eg2/src/app/share/grid/grid.component.css [new file with mode: 0644]
Open-ILS/src/eg2/src/app/share/grid/grid.component.html [new file with mode: 0644]
Open-ILS/src/eg2/src/app/share/grid/grid.component.ts [new file with mode: 0644]
Open-ILS/src/eg2/src/app/share/grid/grid.module.ts [new file with mode: 0644]
Open-ILS/src/eg2/src/app/share/grid/grid.service.ts [new file with mode: 0644]
Open-ILS/src/eg2/src/app/share/org-select/org-select.component.html [new file with mode: 0644]
Open-ILS/src/eg2/src/app/share/org-select/org-select.component.ts [new file with mode: 0644]
Open-ILS/src/eg2/src/app/share/string/string.component.ts [new file with mode: 0644]
Open-ILS/src/eg2/src/app/share/string/string.service.ts [new file with mode: 0644]
Open-ILS/src/eg2/src/app/share/toast/toast.component.css [new file with mode: 0644]
Open-ILS/src/eg2/src/app/share/toast/toast.component.html [new file with mode: 0644]
Open-ILS/src/eg2/src/app/share/toast/toast.component.ts [new file with mode: 0644]
Open-ILS/src/eg2/src/app/share/toast/toast.service.ts [new file with mode: 0644]
Open-ILS/src/eg2/src/app/share/util/audio.service.ts [new file with mode: 0644]
Open-ILS/src/eg2/src/app/share/util/pager.ts [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/admin/routing.module.ts [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/admin/workstation/routing.module.ts [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/admin/workstation/workstations/routing.module.ts [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/admin/workstation/workstations/workstations.component.html [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/admin/workstation/workstations/workstations.component.ts [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/admin/workstation/workstations/workstations.module.ts [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/catalog/catalog.component.html [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/catalog/catalog.component.ts [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/catalog/catalog.module.ts [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/catalog/catalog.service.ts [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/catalog/record/copies.component.html [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/catalog/record/copies.component.ts [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/catalog/record/pagination.component.html [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/catalog/record/pagination.component.ts [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/catalog/record/record.component.html [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/catalog/record/record.component.ts [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/catalog/resolver.service.ts [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/catalog/result/facets.component.html [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/catalog/result/facets.component.ts [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/catalog/result/pagination.component.css [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/catalog/result/pagination.component.html [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/catalog/result/pagination.component.ts [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/catalog/result/record.component.html [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/catalog/result/record.component.ts [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/catalog/result/results.component.html [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/catalog/result/results.component.ts [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/catalog/routing.module.ts [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/catalog/search-form.component.css [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/catalog/search-form.component.html [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/catalog/search-form.component.ts [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/circ/patron/bcsearch/bcsearch.component.html [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/circ/patron/bcsearch/bcsearch.component.ts [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/circ/patron/bcsearch/bcsearch.module.ts [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/circ/patron/bcsearch/routing.module.ts [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/circ/patron/routing.module.ts [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/circ/routing.module.ts [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/common.module.ts [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/login.component.html [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/login.component.ts [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/nav.component.css [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/nav.component.html [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/nav.component.ts [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/resolver.service.ts [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/routing.module.ts [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/sandbox/README [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/sandbox/routing.module.ts [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/sandbox/sandbox.component.html [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/sandbox/sandbox.component.ts [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/sandbox/sandbox.module.ts [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/share/README [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/share/bib-summary/bib-summary.component.html [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/share/bib-summary/bib-summary.component.ts [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/share/op-change/op-change.component.html [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/share/op-change/op-change.component.ts [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/share/staff-banner.component.ts [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/splash.component.html [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/splash.component.ts [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/staff.component.css [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/staff.component.html [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/staff.component.ts [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/staff.module.ts [new file with mode: 0644]
Open-ILS/src/eg2/src/app/welcome.component.html [new file with mode: 0644]
Open-ILS/src/eg2/src/app/welcome.component.ts [new file with mode: 0644]
Open-ILS/src/eg2/src/assets/.gitkeep [new file with mode: 0644]
Open-ILS/src/eg2/src/environments/environment.prod.ts [new file with mode: 0644]
Open-ILS/src/eg2/src/environments/environment.ts [new file with mode: 0644]
Open-ILS/src/eg2/src/favicon.ico [new file with mode: 0644]
Open-ILS/src/eg2/src/index.html [new file with mode: 0644]
Open-ILS/src/eg2/src/main.ts [new file with mode: 0644]
Open-ILS/src/eg2/src/polyfills.ts [new file with mode: 0644]
Open-ILS/src/eg2/src/styles.css [new file with mode: 0644]
Open-ILS/src/eg2/src/test.ts [new file with mode: 0644]
Open-ILS/src/eg2/src/tsconfig.app.json [new file with mode: 0644]
Open-ILS/src/eg2/src/tsconfig.spec.json [new file with mode: 0644]
Open-ILS/src/eg2/src/typings.d.ts [new file with mode: 0644]
Open-ILS/src/eg2/tsconfig.json [new file with mode: 0644]
Open-ILS/src/eg2/tslint.json [new file with mode: 0644]
Open-ILS/src/perlmods/lib/OpenILS/Application/Search/Biblio.pm
Open-ILS/src/templates/staff/ang2_js.tt2 [new file with mode: 0644]
Open-ILS/src/templates/staff/base.tt2
Open-ILS/src/templates/staff/circ/checkin/index.tt2
Open-ILS/src/templates/staff/navbar.tt2
Open-ILS/web/js/ui/default/staff/circ/checkin/app.js
Open-ILS/web/js/ui/default/staff/services/hatch.js

diff --git a/Open-ILS/src/eg2/.angular-cli.json b/Open-ILS/src/eg2/.angular-cli.json
new file mode 100644 (file)
index 0000000..56fbfef
--- /dev/null
@@ -0,0 +1,62 @@
+{
+  "$schema": "./node_modules/@angular/cli/lib/config/schema.json",
+  "project": {
+    "name": "eg"
+  },
+  "apps": [
+    {
+      "name": "eg",
+      "root": "src",
+      "outDir": "dist",
+      "assets": [
+        "assets",
+        "favicon.ico"
+      ],
+      "index": "index.html",
+      "main": "main.ts",
+      "polyfills": "polyfills.ts",
+      "test": "test.ts",
+      "tsconfig": "tsconfig.app.json",
+      "testTsconfig": "tsconfig.spec.json",
+      "prefix": "app",
+      "styles": [
+        "styles.css"
+      ],
+      "scripts": [],
+      "environmentSource": "environments/environment.ts",
+      "environments": {
+        "dev": "environments/environment.ts",
+        "prod": "environments/environment.prod.ts"
+      }
+    }
+
+  ],
+  "e2e": {
+    "protractor": {
+      "config": "./protractor.conf.js"
+    }
+  },
+  "lint": [
+    {
+      "project": "src/tsconfig.app.json",
+      "exclude": "**/node_modules/**"
+    },
+    {
+      "project": "src/tsconfig.spec.json",
+      "exclude": "**/node_modules/**"
+    },
+    {
+      "project": "e2e/tsconfig.e2e.json",
+      "exclude": "**/node_modules/**"
+    }
+  ],
+  "test": {
+    "karma": {
+      "config": "./karma.conf.js"
+    }
+  },
+  "defaults": {
+    "styleExt": "css",
+    "component": {}
+  }
+}
diff --git a/Open-ILS/src/eg2/.editorconfig b/Open-ILS/src/eg2/.editorconfig
new file mode 100644 (file)
index 0000000..6e87a00
--- /dev/null
@@ -0,0 +1,13 @@
+# Editor configuration, see http://editorconfig.org
+root = true
+
+[*]
+charset = utf-8
+indent_style = space
+indent_size = 2
+insert_final_newline = true
+trim_trailing_whitespace = true
+
+[*.md]
+max_line_length = off
+trim_trailing_whitespace = false
diff --git a/Open-ILS/src/eg2/.gitignore b/Open-ILS/src/eg2/.gitignore
new file mode 100644 (file)
index 0000000..54bfd20
--- /dev/null
@@ -0,0 +1,42 @@
+# See http://help.github.com/ignore-files/ for more about ignoring files.
+
+# compiled output
+/dist
+/tmp
+/out-tsc
+
+# dependencies
+/node_modules
+
+# IDEs and editors
+/.idea
+.project
+.classpath
+.c9/
+*.launch
+.settings/
+*.sublime-workspace
+
+# IDE - VSCode
+.vscode/*
+!.vscode/settings.json
+!.vscode/tasks.json
+!.vscode/launch.json
+!.vscode/extensions.json
+
+# misc
+/.sass-cache
+/connect.lock
+/coverage
+/libpeerconnection.log
+npm-debug.log
+testem.log
+/typings
+
+# e2e
+/e2e/*.js
+/e2e/*.map
+
+# System Files
+.DS_Store
+Thumbs.db
diff --git a/Open-ILS/src/eg2/README.adoc b/Open-ILS/src/eg2/README.adoc
new file mode 100644 (file)
index 0000000..2e4f209
--- /dev/null
@@ -0,0 +1,24 @@
+= EG Angular2 App =
+
+=== Apache Configuration ===
+
+[source,conf]
+---------------------------------------------------------------------
+<Directory "/openils/var/web/eg2">
+    FallbackResource /eg2/index.html
+</Directory>
+---------------------------------------------------------------------
+
+=== Transpile + Deploy in --watch mode for Dev ===
+
+[source,sh]
+---------------------------------------------------------------------
+ng build --aot --app eg --deploy-url /eg2/ --base-href /eg2/ --output-path  ../../web/eg2/  --watch
+---------------------------------------------------------------------
+
+=== Link build files into place for ease of dev
+
+[source,sh]
+---------------------------------------------------------------------
+sudo -u opensrf ln -s /PATH/TO/Evergreen/Open-ILS/web/eg2 /openils/var/web/eg2
+---------------------------------------------------------------------
diff --git a/Open-ILS/src/eg2/e2e/app.e2e-spec.ts b/Open-ILS/src/eg2/e2e/app.e2e-spec.ts
new file mode 100644 (file)
index 0000000..c2a69a8
--- /dev/null
@@ -0,0 +1,14 @@
+import { AppPage } from './app.po';
+
+describe('eg App', () => {
+  let page: AppPage;
+
+  beforeEach(() => {
+    page = new AppPage();
+  });
+
+  it('should display welcome message', () => {
+    page.navigateTo();
+    expect(page.getParagraphText()).toEqual('Welcome to app!');
+  });
+});
diff --git a/Open-ILS/src/eg2/e2e/app.po.ts b/Open-ILS/src/eg2/e2e/app.po.ts
new file mode 100644 (file)
index 0000000..82ea75b
--- /dev/null
@@ -0,0 +1,11 @@
+import { browser, by, element } from 'protractor';
+
+export class AppPage {
+  navigateTo() {
+    return browser.get('/');
+  }
+
+  getParagraphText() {
+    return element(by.css('app-root h1')).getText();
+  }
+}
diff --git a/Open-ILS/src/eg2/e2e/tsconfig.e2e.json b/Open-ILS/src/eg2/e2e/tsconfig.e2e.json
new file mode 100644 (file)
index 0000000..1d9e5ed
--- /dev/null
@@ -0,0 +1,14 @@
+{
+  "extends": "../tsconfig.json",
+  "compilerOptions": {
+    "outDir": "../out-tsc/e2e",
+    "baseUrl": "./",
+    "module": "commonjs",
+    "target": "es5",
+    "types": [
+      "jasmine",
+      "jasminewd2",
+      "node"
+    ]
+  }
+}
diff --git a/Open-ILS/src/eg2/karma.conf.js b/Open-ILS/src/eg2/karma.conf.js
new file mode 100644 (file)
index 0000000..af139fa
--- /dev/null
@@ -0,0 +1,33 @@
+// Karma configuration file, see link for more information
+// https://karma-runner.github.io/1.0/config/configuration-file.html
+
+module.exports = function (config) {
+  config.set({
+    basePath: '',
+    frameworks: ['jasmine', '@angular/cli'],
+    plugins: [
+      require('karma-jasmine'),
+      require('karma-chrome-launcher'),
+      require('karma-jasmine-html-reporter'),
+      require('karma-coverage-istanbul-reporter'),
+      require('@angular/cli/plugins/karma')
+    ],
+    client:{
+      clearContext: false // leave Jasmine Spec Runner output visible in browser
+    },
+    coverageIstanbulReporter: {
+      reports: [ 'html', 'lcovonly' ],
+      fixWebpackSourcePaths: true
+    },
+    angularCli: {
+      environment: 'dev'
+    },
+    reporters: ['progress', 'kjhtml'],
+    port: 9876,
+    colors: true,
+    logLevel: config.LOG_INFO,
+    autoWatch: true,
+    browsers: ['Chrome'],
+    singleRun: false
+  });
+};
diff --git a/Open-ILS/src/eg2/package.json b/Open-ILS/src/eg2/package.json
new file mode 100644 (file)
index 0000000..88366df
--- /dev/null
@@ -0,0 +1,55 @@
+{
+  "name": "eg",
+  "version": "0.0.0",
+  "license": "MIT",
+  "scripts": {
+    "ng": "ng",
+    "start": "ng serve",
+    "build": "ng build",
+    "test": "ng test",
+    "lint": "ng lint",
+    "e2e": "ng e2e"
+  },
+  "private": true,
+  "dependencies": {
+    "@angular/animations": "5.2.9",
+    "@angular/common": "5.2.9",
+    "@angular/compiler": "5.2.9",
+    "@angular/core": "5.2.9",
+    "@angular/forms": "5.2.9",
+    "@angular/http": "5.2.9",
+    "@angular/platform-browser": "5.2.9",
+    "@angular/platform-browser-dynamic": "5.2.9",
+    "@angular/router": "5.2.9",
+    "@angular/upgrade": "5.2.9",
+    "@ng-bootstrap/ng-bootstrap": "^1.0.0-beta.5",
+    "core-js": "^2.4.1",
+    "jquery": "^3.2.1",
+    "ngx-cookie": "^2.0.1",
+    "rxjs": "^5.5.8",
+    "zone.js": "^0.8.25"
+  },
+  "devDependencies": {
+    "@angular/cli": "1.7.3",
+    "@angular/compiler-cli": "5.2.9",
+    "@angular/language-service": "5.2.9",
+    "@types/jasmine": "~2.5.53",
+    "@types/jasminewd2": "~2.0.2",
+    "@types/jquery": "^3.2.16",
+    "@types/node": "~6.0.60",
+    "@types/xml2js": "^0.4.2",
+    "codelyzer": "~3.2.0",
+    "jasmine-core": "~2.6.2",
+    "jasmine-spec-reporter": "~4.1.0",
+    "karma": "~1.7.0",
+    "karma-chrome-launcher": "~2.1.1",
+    "karma-cli": "~1.0.1",
+    "karma-coverage-istanbul-reporter": "^1.2.1",
+    "karma-jasmine": "~1.1.0",
+    "karma-jasmine-html-reporter": "^0.2.2",
+    "protractor": "~5.1.2",
+    "ts-node": "~3.2.0",
+    "tslint": "~5.7.0",
+    "typescript": "2.6.2"
+  }
+}
diff --git a/Open-ILS/src/eg2/protractor.conf.js b/Open-ILS/src/eg2/protractor.conf.js
new file mode 100644 (file)
index 0000000..7ee3b5e
--- /dev/null
@@ -0,0 +1,28 @@
+// Protractor configuration file, see link for more information
+// https://github.com/angular/protractor/blob/master/lib/config.ts
+
+const { SpecReporter } = require('jasmine-spec-reporter');
+
+exports.config = {
+  allScriptsTimeout: 11000,
+  specs: [
+    './e2e/**/*.e2e-spec.ts'
+  ],
+  capabilities: {
+    'browserName': 'chrome'
+  },
+  directConnect: true,
+  baseUrl: 'http://localhost:4200/',
+  framework: 'jasmine',
+  jasmineNodeOpts: {
+    showColors: true,
+    defaultTimeoutInterval: 30000,
+    print: function() {}
+  },
+  onPrepare() {
+    require('ts-node').register({
+      project: 'e2e/tsconfig.e2e.json'
+    });
+    jasmine.getEnv().addReporter(new SpecReporter({ spec: { displayStacktrace: true } }));
+  }
+};
diff --git a/Open-ILS/src/eg2/src/app/app.component.ts b/Open-ILS/src/eg2/src/app/app.component.ts
new file mode 100644 (file)
index 0000000..d049f7a
--- /dev/null
@@ -0,0 +1,11 @@
+import {Component} from '@angular/core';
+
+@Component({
+  selector: 'eg-root',
+  template: '<router-outlet></router-outlet>'
+})
+
+export class EgBaseComponent {
+}
+
+
diff --git a/Open-ILS/src/eg2/src/app/app.module.ts b/Open-ILS/src/eg2/src/app/app.module.ts
new file mode 100644 (file)
index 0000000..8998b01
--- /dev/null
@@ -0,0 +1,34 @@
+/**
+ * EgBaseModule is the shared starting point for all apps.  It provides
+ * the root route and core services, and a simple welcome page for users
+ * that end up here accidentally.
+ */
+import {BrowserModule} from '@angular/platform-browser';
+import {NgModule} from '@angular/core';
+import {NgbModule} from '@ng-bootstrap/ng-bootstrap'; // ng-bootstrap
+import {CookieModule} from 'ngx-cookie'; // import CookieMonster
+
+import {EgCommonModule} from './common.module';
+import {EgBaseComponent} from './app.component';
+import {EgBaseRoutingModule} from './routing.module';
+import {WelcomeComponent} from './welcome.component';
+
+// Import and 'provide' globally required services.
+@NgModule({
+  declarations: [
+    EgBaseComponent,
+    WelcomeComponent
+  ],
+  imports: [
+    EgCommonModule.forRoot(),
+    EgBaseRoutingModule,
+    BrowserModule,
+    NgbModule.forRoot(),
+    CookieModule.forRoot()
+  ],
+  exports: [],
+  bootstrap: [EgBaseComponent]
+})
+
+export class EgBaseModule {}
+    
diff --git a/Open-ILS/src/eg2/src/app/common.module.ts b/Open-ILS/src/eg2/src/app/common.module.ts
new file mode 100644 (file)
index 0000000..8cc94c7
--- /dev/null
@@ -0,0 +1,54 @@
+/**
+ * Modules, services, and components used by all apps.
+ */ 
+import {CommonModule} from '@angular/common';
+import {NgModule, ModuleWithProviders} from '@angular/core';
+import {FormsModule} from '@angular/forms';
+import {NgbModule} from '@ng-bootstrap/ng-bootstrap';
+
+import {EgEventService} from '@eg/core/event.service';
+import {EgStoreService} from '@eg/core/store.service';
+import {EgIdlService} from '@eg/core/idl.service';
+import {EgNetService} from '@eg/core/net.service';
+import {EgAuthService} from '@eg/core/auth.service';
+import {EgPermService} from '@eg/core/perm.service';
+import {EgPcrudService} from '@eg/core/pcrud.service';
+import {EgOrgService} from '@eg/core/org.service';
+import {EgAudioService} from '@eg/share/util/audio.service';
+
+@NgModule({
+  declarations: [
+  ],
+  imports: [
+    CommonModule,
+    FormsModule,
+    NgbModule
+  ],
+  exports: [
+    CommonModule,
+    NgbModule,
+    FormsModule
+  ]
+})
+
+export class EgCommonModule {
+    /** forRoot() lets us define services that should only be
+     * instantiated once for all loaded routes */
+    static forRoot(): ModuleWithProviders {
+        return {
+            ngModule: EgCommonModule,
+            providers: [
+                EgEventService,
+                EgStoreService,
+                EgIdlService,
+                EgNetService,
+                EgAuthService,
+                EgPermService,
+                EgPcrudService,
+                EgOrgService,
+                EgAudioService
+            ]
+        };
+    }
+}
+
diff --git a/Open-ILS/src/eg2/src/app/core/README b/Open-ILS/src/eg2/src/app/core/README
new file mode 100644 (file)
index 0000000..3cf0ec4
--- /dev/null
@@ -0,0 +1,9 @@
+Core Angular services and assocated types/classes.
+
+Core services are imported and exported by the base module and 
+automatically added as dependencies to ALL applications.
+
+1. Only add services here that are universally required.
+2. Avoid URL path navigation in the core services as paths will vary 
+   by application.
+
diff --git a/Open-ILS/src/eg2/src/app/core/auth.service.ts b/Open-ILS/src/eg2/src/app/core/auth.service.ts
new file mode 100644 (file)
index 0000000..84de51a
--- /dev/null
@@ -0,0 +1,300 @@
+import {Injectable, EventEmitter} from '@angular/core';
+import {EgNetService} from './net.service';
+import {EgEventService, EgEvent} from './event.service';
+import {EgIdlService, EgIdlObject} from './idl.service';
+import {EgStoreService} from './store.service';
+
+// Not universally available.
+declare var BroadcastChannel;
+
+// Models a login instance.
+class EgAuthUser {
+    user:        EgIdlObject; // actor.usr (au) object
+    workstation: string; // workstation name
+    token:       string;
+    authtime:    number;
+
+    constructor(token: string, authtime: number, workstation?: string) {
+        this.token = token;
+        this.workstation = workstation;
+        this.authtime = authtime;
+    }
+}
+
+// Params required for calling the login() method.
+interface EgAuthLoginArgs {
+    username: string,
+    password: string,
+    type: string,
+    workstation?: string
+}
+
+export enum EgAuthWsState {
+    PENDING,
+    NOT_USED,
+    NOT_FOUND_SERVER,
+    NOT_FOUND_LOCAL,
+    VALID
+};
+
+@Injectable()
+export class EgAuthService {
+
+    private authChannel: any;
+
+    private activeUser: EgAuthUser = null;
+
+    workstationState: EgAuthWsState = EgAuthWsState.PENDING;
+
+    // Used by auth-checking resolvers
+    redirectUrl: string;
+
+    // reference to active auth validity setTimeout handler.
+    pollTimeout: any;
+
+    constructor(
+        private egEvt: EgEventService,
+        private net: EgNetService,
+        private store: EgStoreService
+    ) {
+
+        console.log("egAuth constructor()");
+
+        // BroadcastChannel is not yet defined in PhantomJS
+        this.authChannel = BroadcastChannel ? 
+            new BroadcastChannel('eg.auth') : {};
+    }
+
+    // Returns true if we are currently in op-change mode.
+    opChangeIsActive(): boolean {
+        return Boolean(this.store.getLoginSessionItem('eg.auth.time.oc'));
+    }
+
+    // - Accessor functions always refer to the active user.
+
+    user(): EgIdlObject { 
+        return this.activeUser ? this.activeUser.user : null;
+    };
+
+    // Workstation name.
+    workstation(): string { 
+        return this.activeUser ? this.activeUser.workstation : null;
+    };
+
+    token(): string { 
+        return this.activeUser ? this.activeUser.token : null;
+    };
+
+    authtime(): number { 
+        return this.activeUser ? this.activeUser.authtime : 0;
+    };
+
+    // NOTE: EgNetService emits an event if the auth session has expired.
+    // This only rejects when no authtoken is found.
+    testAuthToken(): Promise<any> {
+
+        if (!this.activeUser) {
+            // Only necessary on new page loads.  During op-change, 
+            // for example, we already have an activeUser.
+            this.activeUser = new EgAuthUser(
+                this.store.getLoginSessionItem('eg.auth.token'),
+                this.store.getLoginSessionItem('eg.auth.time')
+            );
+        }
+
+        if (!this.token()) return Promise.reject('no authtoken');
+
+        return this.net.request(
+            'open-ils.auth',
+            'open-ils.auth.session.retrieve', this.token()).toPromise()
+        .then(user => {
+            // EgNetService interceps NO_SESSION events.
+            // We can only get here if the session is valid.
+            this.activeUser.user = user;
+            this.listenForLogout();
+            this.sessionPoll();
+        });
+    }
+
+    login(args: EgAuthLoginArgs, isOpChange?: boolean): Promise<void> {
+        return this.net.request('open-ils.auth', 'open-ils.auth.login', args)
+        .toPromise().then(res => {
+            return this.handleLoginResponse(
+                args, this.egEvt.parse(res), isOpChange)
+        })
+    }
+
+    handleLoginResponse(
+        args: EgAuthLoginArgs, evt: EgEvent, isOpChange: boolean): Promise<void> {
+
+        switch (evt.textcode) {
+            case 'SUCCESS':
+                return this.handleLoginOk(args, evt, isOpChange);
+
+            case 'WORKSTATION_NOT_FOUND':
+                console.error(`No such workstation "${args.workstation}"`);
+                this.workstationState = EgAuthWsState.NOT_FOUND_SERVER;
+                delete args.workstation;
+                return this.login(args, isOpChange);
+
+            default:
+                console.error(`Login returned unexpected event: ${evt}`);
+                return Promise.reject('login failed');
+        }
+    }
+
+    // Stash the login data
+    handleLoginOk(args: EgAuthLoginArgs, evt: EgEvent, isOpChange: boolean): Promise<void> {
+
+        if (isOpChange) {
+            this.store.setLoginSessionItem('eg.auth.token.oc', this.token());
+            this.store.setLoginSessionItem('eg.auth.time.oc', this.authtime());
+        }
+
+        this.activeUser = new EgAuthUser(
+            evt.payload.authtoken,
+            evt.payload.authtime,
+            args.workstation
+        );
+
+        this.store.setLoginSessionItem('eg.auth.token', this.token());
+        this.store.setLoginSessionItem('eg.auth.time', this.authtime());
+
+        return Promise.resolve();
+    }
+
+    undoOpChange(): Promise<any> {
+        if (this.opChangeIsActive()) {
+            this.deleteSession();
+            this.activeUser = new EgAuthUser(
+                this.store.getLoginSessionItem('eg.auth.token.oc'),
+                this.store.getLoginSessionItem('eg.auth.time.oc'),
+                this.activeUser.workstation
+            );
+            this.store.removeLoginSessionItem('eg.auth.token.oc');                
+            this.store.removeLoginSessionItem('eg.auth.time.oc');                 
+            this.store.setLoginSessionItem('eg.auth.token', this.token());
+            this.store.setLoginSessionItem('eg.auth.time', this.authtime());
+        }
+        // Re-fetch the user.
+        return this.testAuthToken();
+    }
+
+    /**
+     * Listen for logout events initiated by other browser tabs.
+     */
+    listenForLogout(): void {
+        if (this.authChannel.onmessage) return;
+
+        this.authChannel.onmessage = (e) => {
+            console.debug(
+                `received eg.auth broadcast ${JSON.stringify(e.data)}`);
+
+            if (e.data.action == 'logout') {
+                // Logout will be handled by the originating tab.
+                // We just need to clear tab-local memory.
+                this.cleanup();
+                this.net.authExpired$.emit({viaExternal: true});
+            }
+        }
+    }
+
+    /**
+     * Force-check the validity of the authtoken on occasion. 
+     * This allows us to redirect an idle staff client back to the login
+     * page after the session times out.  Otherwise, the UI would stay
+     * open with potentially sensitive data visible.
+     * TODO: What is the practical difference (for a browser) between 
+     * checking auth validity and the ui.general.idle_timeout setting?
+     * Does that setting serve a purpose in a browser environment?
+     */
+    sessionPoll(): void {
+
+        // add a 5 second delay to give the token plenty of time
+        // to expire on the server.
+        let pollTime = this.authtime() * 1000 + 5000;
+
+        this.pollTimeout = setTimeout(() => {
+            this.net.request(
+                'open-ils.auth',
+                'open-ils.auth.session.retrieve',
+                this.token(),
+                0, // return extra auth details, unneeded here.
+                1  // avoid extending the auth timeout
+
+            // EgNetService intercepts NO_SESSION events.
+            // If the promise resolves, the session is valid.
+            ).toPromise().then(user => this.sessionPoll())
+
+        }, pollTime);
+    }
+
+
+    // Resolves if login workstation matches a workstation known to this 
+    // browser instance.  No attempt is made to see if the workstation
+    // is present on the server.  That happens at login time.
+    verifyWorkstation(): Promise<void> {
+
+        if (!this.user()) {
+            this.workstationState = EgAuthWsState.PENDING;
+            return Promise.reject('Cannot verify workstation without user');
+        }
+
+        if (!this.user().wsid()) {
+            this.workstationState = EgAuthWsState.NOT_USED;
+            return Promise.reject('User has no workstation ID to verify');
+        }
+
+        return new Promise((resolve, reject) => {
+            this.store.getItem('eg.workstation.all')
+            .then(workstations => {
+
+                if (workstations) {
+                    let ws = workstations.filter(
+                        w => {return w.id == this.user().wsid()})[0];
+
+                    if (ws) {
+                        this.activeUser.workstation = ws.name;
+                        this.workstationState = EgAuthWsState.VALID;
+                        return resolve();
+                    }
+                }
+
+                this.workstationState = EgAuthWsState.NOT_FOUND_LOCAL;
+                reject();
+            });
+        });
+    }
+
+    deleteSession(): void {
+        if (this.token()) {
+            this.net.request(
+                'open-ils.auth',
+                'open-ils.auth.session.delete', this.token())
+            .subscribe(x => console.debug('logged out'))
+        }
+    }
+
+    // Tell all listening browser tabs that it's time to logout.
+    // This should only be invoked by one tab.
+    broadcastLogout(): void {
+        console.debug('Notifying tabs of imminent auth token removal');
+        this.authChannel.postMessage({action : 'logout'});
+    }
+
+    // Remove/reset session data
+    cleanup(): void {
+        this.activeUser = null;
+        if (this.pollTimeout) {
+            clearTimeout(this.pollTimeout);
+            this.pollTimeout = null;
+        }
+    }
+
+    // Invalidate server auth token and clean up.
+    logout(): void {
+        this.deleteSession();
+        this.store.clearLoginSessionItems();                                  
+        this.cleanup();
+    }
+}
diff --git a/Open-ILS/src/eg2/src/app/core/event.service.ts b/Open-ILS/src/eg2/src/app/core/event.service.ts
new file mode 100644 (file)
index 0000000..33e3f84
--- /dev/null
@@ -0,0 +1,53 @@
+import {Injectable} from '@angular/core';
+
+export class EgEvent {
+    code        : number;
+    textcode    : string;
+    payload     : any;
+    desc        : string;
+    debug       : string;
+    note        : string;
+    servertime  : string;
+    ilsperm     : string;
+    ilspermloc  : number;
+    success     : Boolean = false;
+
+    toString(): string {
+        let s = `Event: ${this.code}:${this.textcode} -> ${this.desc}`;
+        if (this.ilsperm)
+            s += `  ${this.ilsperm}@${this.ilspermloc}`;
+        if (this.note)
+            s += `\n${this.note}`;
+        return s;
+    }
+}
+
+@Injectable()
+export class EgEventService {
+
+    /**
+     * Returns an EgEvent if 'thing' is an event, null otherwise.
+     */
+    parse(thing: any): EgEvent {
+
+        // All events have a textcode
+        if (thing && typeof thing == 'object' && 'textcode' in thing) {
+
+            let evt = new EgEvent();
+
+            ['textcode','payload','desc','note','servertime','ilsperm']
+                .forEach(field => { evt[field] = thing[field]; });
+
+            evt.debug = thing.stacktrace;
+            evt.code = +(thing.ilsevent || -1);
+            evt.ilspermloc = +(thing.ilspermloc || -1);
+            evt.success = thing.textcode == 'SUCCESS';
+
+            return evt;
+        }
+
+        return null;
+    }
+}
+
+
diff --git a/Open-ILS/src/eg2/src/app/core/idl.service.ts b/Open-ILS/src/eg2/src/app/core/idl.service.ts
new file mode 100644 (file)
index 0000000..f105140
--- /dev/null
@@ -0,0 +1,120 @@
+import {Injectable} from '@angular/core';
+
+// Added globally by /IDL2js
+declare var _preload_fieldmapper_IDL: Object;
+
+/**
+ * Every IDL object class implements this interface.
+ */
+export interface EgIdlObject {
+    a: any[];
+    classname: string;
+    _isfieldmapper: boolean;
+    // Dynamically appended functions from the IDL.
+    [fields: string]: any;
+}
+
+@Injectable()
+export class EgIdlService {
+
+    classes = {}; // IDL class metadata
+    constructors = {}; // IDL instance generators
+
+    /**
+     * Create a new IDL object instance.
+     */
+    create(cls: string, seed?:any[]): EgIdlObject {
+        if (this.constructors[cls])
+            return new this.constructors[cls](seed);
+        throw new Error(`No such IDL class ${cls}`);
+    }
+
+    parseIdl(): void {
+
+        try {
+            this.classes = _preload_fieldmapper_IDL;
+        } catch (E) {
+            console.error('IDL (IDL2js) not found.  Is the system running?');
+            return;
+        }
+
+        /**
+         * Creates the class constructor and getter/setter
+         * methods for each IDL class.
+         */
+        let mkclass = (cls, fields) => {
+            this.classes[cls].classname = cls;
+
+            // This dance lets us encode each IDL object with the
+            // EgIdlObject interface.  Useful for adding type restrictions
+            // where desired for functions, etc.
+            let generator:any = ((): EgIdlObject => {
+
+                var x:any = function(seed) {
+                    this.a = seed || [];
+                    this.classname = cls;
+                    this._isfieldmapper = true;
+                };
+
+                fields.forEach(function(field, idx) {
+                    x.prototype[field.name] = function(n) {
+                        if (arguments.length==1) this.a[idx] = n;
+                        return this.a[idx];
+                    }
+                });
+
+                return x;
+            });
+
+            this.constructors[cls] = generator();
+
+            // global class constructors required for JSON_v1.js
+            // TODO: polluting the window namespace w/ every IDL class 
+            // is less than ideal.
+            window[cls] = this.constructors[cls]; 
+        }
+
+        for (var cls in this.classes) 
+            mkclass(cls, this.classes[cls].fields);
+    }
+
+    // Makes a deep copy of an EgIdlObject's / structures containing
+    // EgIdlObject's.  Note we don't use JSON cross-walk because our
+    // JSON lib does not handle circular references.
+    // @depth specifies the maximum number of steps through EgIdlObject'
+    // we will traverse.
+    clone(source: any, depth?: number): any {
+        if (depth === undefined) depth = 100;
+
+        var result;
+        if (typeof source == 'undefined' || source === null) {
+            return source;
+
+        } else if (source._isfieldmapper) {
+            // same depth because we're still cloning this same object
+            result = this.create(source.classname, this.clone(source.a, depth));
+
+        } else {
+            if(Array.isArray(source)) {
+                result = [];
+            } else if(typeof source === 'object') { // source is not null
+                result = {};
+            } else {
+                return source; // primitive
+            }
+
+            for (var j in source) {
+                if (source[j] === null || typeof source[j] == 'undefined') {
+                    result[j] = source[j];
+                } else if(source[j]._isfieldmapper) {
+                    if (depth) result[j] = this.clone(source[j], depth - 1);
+                } else {
+                    result[j] = this.clone(source[j], depth);
+                }
+            }
+        }
+
+        return result;
+    }
+}
+
diff --git a/Open-ILS/src/eg2/src/app/core/net.service.ts b/Open-ILS/src/eg2/src/app/core/net.service.ts
new file mode 100644 (file)
index 0000000..bcedfc7
--- /dev/null
@@ -0,0 +1,183 @@
+/**
+ * 
+ * constructor(private net : EgNetService) {
+ *   ...
+ *   this.net.request(service, method, param1 [, param2, ...])
+ *     .subscribe(
+ *       (res) => console.log('received one resopnse: ' + res),
+ *       (err) => console.error('recived request error: ' + err),
+ *       ()    => console.log('request complete')
+ *     )
+ *   );
+ *   ...
+ *
+ *  // Example translating a net request into a promise.
+ *  this.net.request(service, method, param1)
+ *  .toPromise().then(result => console.log(result));
+ *
+ * }
+ *
+ * Each response is relayed via Observable.next().  The interface is 
+ * the same for streaming and atomic requests.
+ */
+import {Injectable, EventEmitter} from '@angular/core';
+import {Observable, Observer} from 'rxjs/Rx';
+import {EgEventService, EgEvent} from './event.service';
+
+// Global vars from opensrf.js
+// These are availavble at runtime, but are not exported.
+declare var OpenSRF, OSRF_TRANSPORT_TYPE_WS;
+
+export class EgNetRequest {
+    service    : string;
+    method     : string;
+    params     : any[];
+    observer   : Observer<any>;
+    superseded : boolean = false;
+    // If set, this will be used instead of a one-off OpenSRF.ClientSession.
+    session?   : any;
+    // True if we're using a single-use local session
+    localSession: boolean = true;
+
+    // Last EgEvent encountered by this request.
+    // Most callers will not need to import EgEvent since the parsed
+    // event will be available here.
+    evt: EgEvent;
+
+    constructor(service: string, method: string, params: any[], session?: any) {
+        this.service = service;
+        this.method = method;
+        this.params = params;
+        if (session) {
+            this.session = session;
+            this.localSession = false;
+        } else {
+            this.session = new OpenSRF.ClientSession(service);
+        }
+    }
+}
+
+export interface EgAuthExpiredEvent {
+    // request is set when the auth expiration was determined as a 
+    // by-product of making an API call.
+    request?: EgNetRequest;
+
+    // True if this environment (e.g. browser tab) was notified of the
+    // expired auth token from an external source (e.g. another browser tab).
+    viaExternal?: boolean;
+}
+
+@Injectable()
+export class EgNetService {
+
+    permFailed$: EventEmitter<EgNetRequest>;
+    authExpired$: EventEmitter<EgAuthExpiredEvent>;
+
+    // If true, permission failures are emitted via permFailed$ 
+    // and the active request is marked as superseded.
+    permFailedHasHandler: Boolean = false;
+
+    constructor(
+        private egEvt: EgEventService
+    ) { 
+        this.permFailed$ = new EventEmitter<EgNetRequest>();
+        this.authExpired$ = new EventEmitter<EgAuthExpiredEvent>();
+    }
+
+    // Standard request call -- Variadic params version
+    request(service: string, method: string, ...params: any[]): Observable<any> {
+        return this.requestWithParamList(service, method, params);
+    }
+
+    // Array params version
+    requestWithParamList(service: string, 
+        method: string, params: any[]): Observable<any> {
+        return this.requestCompiled(
+            new EgNetRequest(service, method, params));
+    }
+
+    // Request with pre-compiled EgNetRequest
+    requestCompiled(request: EgNetRequest): Observable<any> {
+        return Observable.create(
+            observer => {
+                request.observer = observer;
+                this.sendCompiledRequest(request);
+            }
+        );
+    }
+
+    // Send the compiled request to the server via WebSockets
+    sendCompiledRequest(request: EgNetRequest): void {
+        OpenSRF.Session.transport = OSRF_TRANSPORT_TYPE_WS;
+        console.debug(`EgNet: request ${request.method}`);
+
+        request.session.request({
+            async  : true, // WS only operates in async mode
+            method : request.method,
+            params : request.params,
+            oncomplete : () => {
+
+                // TODO: teach opensrf.js to call cleanup() inside
+                // disconnect() and teach EgPcrud to call cleanup() 
+                // as needed to avoid long-lived session data bloat.
+                if (request.localSession)
+                    request.session.cleanup();
+
+                // A superseded request will be complete()'ed by the 
+                // superseder at a later time.
+                if (!request.superseded)
+                    request.observer.complete();
+            },
+            onresponse : r => {
+                this.dispatchResponse(request, r.recv().content());
+            },
+            onerror : errmsg => {
+                let msg = `${request.method} failed! See server logs. ${errmsg}`;
+                console.error(msg);
+                request.observer.error(msg);
+            },
+            onmethoderror : (req, statCode, statMsg) => { 
+                let msg = 
+                    `${request.method} failed! stat=${statCode} msg=${statMsg}`;
+                console.error(msg);
+
+                if (request.service == 'open-ils.pcrud' && statCode == 401) {
+                    // 401 is the PCRUD equivalent of a NO_SESSION event
+                    this.authExpired$.emit({request: request});
+                }
+
+                request.observer.error(msg);
+            }
+
+        }).send();
+    }
+
+    // Relay response object to the caller for typical/successful
+    // responses.  Applies special handling to response events that
+    // require global attention.
+    private dispatchResponse(request, response): void {
+        request.evt = this.egEvt.parse(response);
+
+        if (request.evt) {
+            switch(request.evt.textcode) {
+
+                case 'NO_SESSION':
+                    console.debug(`EgNet emitting event: ${request.evt}`);
+                    request.observer.error(request.evt.toString());
+                    this.authExpired$.emit({request: request});
+                    return;
+
+                case 'PERM_FAILURE':
+                    if (this.permFailedHasHandler) {
+                        console.debug(`EgNet emitting event: ${request.evt}`);
+                        request.superseded = true;
+                        this.permFailed$.emit(request);
+                        return;
+                    }
+            } 
+        }
+
+        // Pass the response to the caller.
+        request.observer.next(response);
+    };
+}
diff --git a/Open-ILS/src/eg2/src/app/core/org.service.ts b/Open-ILS/src/eg2/src/app/core/org.service.ts
new file mode 100644 (file)
index 0000000..b2ad250
--- /dev/null
@@ -0,0 +1,267 @@
+import {Injectable} from '@angular/core';
+import {Observable} from 'rxjs/Rx';
+import {EgIdlObject, EgIdlService} from './idl.service';
+import {EgNetService} from './net.service';
+import {EgAuthService} from './auth.service';
+import {EgPcrudService} from './pcrud.service';
+
+type EgOrgNodeOrId = number | EgIdlObject;
+
+interface OrgFilter {
+    canHaveUsers?: boolean;
+    canHaveVolumes?: boolean;
+    opacVisible?: boolean;
+    inList?: number[];
+    notInList?: number[];
+}
+
+interface OrgSettingsBatch {
+    [key: string]: any;
+}
+
+@Injectable()
+export class EgOrgService {
+
+    private orgList: EgIdlObject[] = [];
+    private orgTree: EgIdlObject; // root node + children
+    private orgMap: {[id:number] : EgIdlObject} = {};
+    private settingsCache: OrgSettingsBatch = {};
+
+    constructor(
+        private net: EgNetService,
+        private auth: EgAuthService,
+        private pcrud: EgPcrudService
+    ) {}
+
+    get(nodeOrId: EgOrgNodeOrId): EgIdlObject {
+        if (typeof nodeOrId == 'object')
+            return nodeOrId;
+        return this.orgMap[nodeOrId];
+    }
+
+    list(): EgIdlObject[] {
+        return this.orgList;
+    }
+
+    /**
+     * Returns a list of org units that match the selected criteria.
+     * All filters must match for an org to be included in the result set.
+     * Unset filter options are ignored.
+     */
+    filterList(filter: OrgFilter, asId?: boolean): any[] {
+        let list = [];
+        this.list().forEach(org => {
+
+            let chu = filter.canHaveUsers;
+            if (chu && !this.canHaveUsers(org)) return;
+            if (chu === false && this.canHaveUsers(org)) return;
+
+            let chv = filter.canHaveVolumes;
+            if (chv && !this.canHaveVolumes(org)) return;
+            if (chv === false && this.canHaveVolumes(org)) return;
+
+            let ov = filter.opacVisible;
+            if (ov && !this.opacVisible(org)) return;
+            if (ov === false && this.opacVisible(org)) return;
+
+            if (filter.inList && filter.inList.indexOf(org.id()) == -1) 
+                return;
+
+            if (filter.notInList && filter.notInList.indexOf(org.id()) > -1) 
+                return;
+
+            // All filter tests passed.  Add it to the list
+            list.push(asId ? org.id() : org);
+        });
+
+        return list;
+    }
+
+    tree(): EgIdlObject {
+        return this.orgTree;
+    }
+
+    // get the root OU
+    root(): EgIdlObject {
+        return this.orgList[0];
+    }
+
+    // list of org_unit objects or IDs for ancestors + me
+    ancestors(nodeOrId: EgOrgNodeOrId, asId?: boolean): any[] {
+        let node = this.get(nodeOrId);
+        if (!node) return [];
+        let nodes = [node];
+        while( (node = this.get(node.parent_ou())))
+            nodes.push(node);
+        if (asId) return nodes.map(n => n.id());
+        return nodes;
+    }
+
+    // tests that a node can have users
+    canHaveUsers(nodeOrId): boolean {
+           return this
+            .get(nodeOrId)
+            .ou_type()
+            .can_have_users() == 't';
+    }
+
+    // tests that a node can have volumes
+    canHaveVolumes(nodeOrId): boolean {
+        return this
+            .get(nodeOrId)
+            .ou_type()
+            .can_have_vols() == 't';
+    }
+
+    opacVisible(nodeOrId): boolean {
+        return this.get(nodeOrId).opac_visible() == 't';
+    }
+
+    // list of org_unit objects  or IDs for me + descendants
+    descendants(nodeOrId: EgOrgNodeOrId, asId?: boolean): any[] {
+        let node = this.get(nodeOrId);
+        if (!node) return [];
+        let nodes = [];
+        function descend(n) {
+            nodes.push(n);
+            n.children().forEach(descend);
+        }
+        descend(node);
+        if (asId) 
+            return nodes.map(function(n){return n.id()});
+        return nodes;
+    }
+
+    // list of org_unit objects or IDs for ancestors + me + descendants
+    fullPath(nodeOrId: EgOrgNodeOrId, asId?: boolean): any[] {
+        let list = this.ancestors(nodeOrId, false).concat(
+          this.descendants(nodeOrId, false).slice(1));
+        if (asId) 
+            return list.map(function(n){return n.id()});
+        return list;
+    }
+
+    sortTree(sortField?: string, node?: EgIdlObject): void {
+        if (!sortField) sortField = 'shortname';
+        if (!node) node = this.orgTree;
+        node.children(
+            node.children.sort((a, b) => {
+                return a[sortField]() < b[sortField]() ? -1 : 1
+            })
+        );
+        node.children.forEach(n => this.sortTree(n));
+    }
+    
+    absorbTree(node?: EgIdlObject): void {
+        if (!node) {
+            node = this.orgTree;
+            this.orgMap = {};
+            this.orgList = [];
+        }
+        this.orgMap[node.id()] = node;
+        this.orgList.push(node);
+        node.children().forEach(c => this.absorbTree(c));
+    }
+
+    /**
+     * Grabs all of the org units from the server, chops them up into
+     * various shapes, then returns an "all done" promise.
+     */
+    fetchOrgs(): Promise<void> {
+        return this.pcrud.search('aou', {parent_ou : null},
+            {flesh : -1, flesh_fields : {aou : ['children', 'ou_type']}},
+            {anonymous : true}
+        ).toPromise().then(tree => {
+            // ingest tree, etc.
+            this.orgTree = tree;
+            this.absorbTree();
+        });
+    }
+
+    /** 
+     * Populate 'target' with settings from cache where available.
+     * Return the list of settings /not/ pulled from cache.
+     */ 
+    private settingsFromCache(names: string[], target: any) {
+        let cacheKeys = Object.keys(this.settingsCache);
+
+        cacheKeys.forEach(key => {
+            let matchIdx = names.indexOf(key);
+            if (matchIdx > -1) {
+                target[key] = this.settingsCache[key];
+                names.splice(matchIdx, 1);
+            }
+        });
+
+        return names;
+    }
+
+    /**
+     * Fetch org settings from the network.
+     * 'auth' is null for anonymous lookup.
+     */
+    private settingsFromNet(orgId: number, 
+        names: string[], auth?: string): Promise<any> {
+
+        let settings = {};
+        return new Promise((resolve, reject) => {
+            this.net.request(
+                'open-ils.actor',
+                'open-ils.actor.ou_setting.ancestor_default.batch',
+                orgId, names, auth
+            ).subscribe(
+                blob => {
+                    Object.keys(blob).forEach(key => {
+                        let val = blob[key]; // null or hash
+                        settings[key] = val ? val.value : null;
+                    });
+                    resolve(settings);
+                },
+                err => reject(err)
+            );
+        });
+    }
+
+
+    /**
+     * 
+     */
+    settings(names: string[], 
+        orgId?: number, anonymous?: boolean): Promise<OrgSettingsBatch> {
+
+        let settings = {};
+        let auth: string = null;
+        let useCache: boolean = false;
+
+        if (this.auth.user()) {
+            if (orgId) {
+                useCache = orgId == this.auth.user().ws_ou();
+            } else {
+                orgId = this.auth.user().ws_ou();
+                useCache = true;
+            }
+
+            // avoid passing auth token when anonymous is requested.
+            if (!anonymous) auth = this.auth.token();
+
+        } else if (!anonymous) {
+            return Promise.reject(
+                'Use "anonymous" To retrieve org settings without an authtoken');
+        }
+
+        if (useCache) names = this.settingsFromCache(names, settings);
+
+        // All requested settings found in cache (or name list is empty)
+        if (names.length == 0) return Promise.resolve(settings);
+
+        return this.settingsFromNet(orgId, names, auth)
+        .then(settings => {
+            if (useCache) {
+                Object.keys(settings).forEach(key => {
+                    this.settingsCache[key] = settings[key];
+                });
+            }
+            return settings;
+        });
+    }
+}
diff --git a/Open-ILS/src/eg2/src/app/core/pcrud.service.ts b/Open-ILS/src/eg2/src/app/core/pcrud.service.ts
new file mode 100644 (file)
index 0000000..dddf209
--- /dev/null
@@ -0,0 +1,303 @@
+import {Injectable} from '@angular/core';
+import {Observable, Observer} from 'rxjs/Rx';
+import {EgIdlService, EgIdlObject} from './idl.service';
+import {EgNetService, EgNetRequest} from './net.service';
+import {EgAuthService} from './auth.service';
+
+// Externally defined.  Used here for debugging.
+declare var js2JSON: (jsThing:any) => string;
+declare var OpenSRF: any; // creating sessions
+
+interface EgPcrudReqOps {
+    authoritative?: boolean;
+    anonymous?: boolean;
+    idlist?: boolean;
+    atomic?: boolean;
+}
+
+// For for documentation purposes.
+type EgPcrudResponse = any;
+
+export class EgPcrudContext {
+
+    static verboseLogging: boolean = true; // 
+    static identGenerator: number = 0; // for debug logging
+
+    private ident: number;
+    private authoritative: boolean;
+    private xactCloseMode: string;
+    private cudIdx: number;
+    private cudAction: string;
+    private cudLast: EgPcrudResponse;
+    private cudList: EgIdlObject[];
+
+    private idl: EgIdlService;
+    private net: EgNetService;
+    private auth: EgAuthService;
+
+    // Tracks nested CUD actions 
+    cudObserver: Observer<EgPcrudResponse>;
+
+    session: any; // OpenSRF.ClientSession
+
+    constructor( // passed in by parent service -- not injected
+        egIdl: EgIdlService,
+        egNet: EgNetService,
+        egAuth: EgAuthService
+    ) {
+        this.idl = egIdl;
+        this.net = egNet;
+        this.auth = egAuth;
+        this.xactCloseMode = 'rollback';
+        this.ident = EgPcrudContext.identGenerator++;
+        this.session = new OpenSRF.ClientSession('open-ils.pcrud');
+    }
+
+    toString(): string {
+        return '[PCRUDContext ' + this.ident + ']';
+    }
+
+    log(msg: string): void {
+        if (EgPcrudContext.verboseLogging)
+            console.debug(this + ': ' + msg);
+    }
+
+    err(msg: string): void {
+        console.error(this + ': ' + msg);
+    }
+
+    token(reqOps?: EgPcrudReqOps): string {
+        return (reqOps && reqOps.anonymous) ?
+            'ANONYMOUS' : this.auth.token();
+    }
+
+    connect(): Promise<EgPcrudContext> {
+        this.log('connect');
+        return new Promise( (resolve, reject) => {
+            this.session.connect({
+                onconnect : () => { resolve(this); }
+            });
+        })
+    }
+
+    disconnect(): void {
+        this.log('disconnect');
+        this.session.disconnect();
+    }
+
+    retrieve(fmClass: string, pkey: Number | string, 
+            pcrudOps?: any, reqOps?: EgPcrudReqOps): Observable<EgPcrudResponse> {
+        if (!reqOps) reqOps = {};
+        this.authoritative = reqOps.authoritative || false;
+        return this.dispatch(
+            `open-ils.pcrud.retrieve.${fmClass}`, 
+             [this.token(reqOps), pkey, pcrudOps]);
+    }
+
+    retrieveAll(fmClass: string, pcrudOps?: any, 
+            reqOps?: EgPcrudReqOps): Observable<EgPcrudResponse> {
+        let search = {};
+        search[this.idl.classes[fmClass].pkey] = {'!=' : null};
+        return this.search(fmClass, search, pcrudOps, reqOps);
+    }
+
+    search(fmClass: string, search: any, 
+            pcrudOps?: any, reqOps?: EgPcrudReqOps): Observable<EgPcrudResponse> {
+        reqOps = reqOps || {};
+        this.authoritative = reqOps.authoritative || false;
+
+        let returnType = reqOps.idlist ? 'id_list' : 'search';
+        let method = `open-ils.pcrud.${returnType}.${fmClass}`;
+
+        if (reqOps.atomic) method += '.atomic';
+
+        return this.dispatch(method, [this.token(reqOps), search, pcrudOps]);
+    }
+
+    create(list: EgIdlObject | EgIdlObject[]): Observable<EgPcrudResponse> {
+        return this.cud('create', list)
+    }
+    update(list: EgIdlObject | EgIdlObject[]): Observable<EgPcrudResponse> {
+        return this.cud('update', list)
+    }
+    remove(list: EgIdlObject | EgIdlObject[]): Observable<EgPcrudResponse> {
+        return this.cud('delete', list)
+    }
+    autoApply(list: EgIdlObject | EgIdlObject[]): Observable<EgPcrudResponse> { // RENAMED
+        return this.cud('auto',   list)
+    }
+
+    xactClose(): Observable<EgPcrudResponse> {
+        return this.sendRequest(
+            'open-ils.pcrud.transaction.' + this.xactCloseMode,
+            [this.token()]
+        );
+    };
+
+    xactBegin(): Observable<EgPcrudResponse> {
+        return this.sendRequest(
+            'open-ils.pcrud.transaction.begin', [this.token()]
+        );
+    };
+
+    private dispatch(method: string, params: any[]): Observable<EgPcrudResponse> {
+        if (this.authoritative) {
+            return this.wrapXact(() => {
+                return this.sendRequest(method, params);
+            });
+        } else {
+            return this.sendRequest(method, params)
+        }
+    };
+
+
+    // => connect
+    // => xact_begin 
+    // => action
+    // => xact_close(commit/rollback) 
+    // => disconnect
+    wrapXact(mainFunc: () => Observable<EgPcrudResponse>): Observable<EgPcrudResponse> {
+        return Observable.create(observer => {
+
+            // 1. connect
+            this.connect()
+
+            // 2. start the transaction
+            .then(() => {return this.xactBegin().toPromise()})
+
+            // 3. execute the main body 
+            .then(() => {
+
+                mainFunc().subscribe(
+                    res => observer.next(res),
+                    err => observer.error(err),
+                    ()  => {
+                        this.xactClose().toPromise().then(() => {
+                            // 5. disconnect
+                            this.disconnect();
+                            // 6. all done
+                            observer.complete();
+                        });
+                    }
+                );
+            })
+        });
+    };
+
+    private sendRequest(method: string, 
+            params: any[]): Observable<EgPcrudResponse> {
+
+        this.log(`sendRequest(${method})`);
+
+        return this.net.requestCompiled(
+            new EgNetRequest(
+                'open-ils.pcrud', method, params, this.session)
+        );
+    }
+
+    private cud(action: string, 
+        list: EgIdlObject | EgIdlObject[]): Observable<EgPcrudResponse> {
+        this.cudList = [].concat(list); // value or array
+
+        this.log(`CUD(): ${action}`);
+
+        this.cudIdx = 0;
+        this.cudAction = action;
+        this.xactCloseMode = 'commit';
+
+        return this.wrapXact(() => {
+            return Observable.create(observer => {
+                this.cudObserver = observer;
+                this.nextCudRequest();
+            });
+        });
+    }
+
+    /**
+     * Loops through the list of objects to update and sends
+     * them one at a time to the server for processing.  Once
+     * all are done, the cudObserver is resolved.
+     */
+    nextCudRequest(): void {
+        if (this.cudIdx >= this.cudList.length) {
+            this.cudObserver.complete();
+            return;
+        }
+
+        let action = this.cudAction;
+        let fmObj = this.cudList[this.cudIdx++];
+
+        if (action == 'auto') {
+            if (fmObj.ischanged()) action = 'update';
+            if (fmObj.isnew())     action = 'create';
+            if (fmObj.isdeleted()) action = 'delete';
+
+            if (action == 'auto') {
+                // object does not need updating; move along
+                this.nextCudRequest();
+            }
+        }
+
+        this.sendRequest(
+            `open-ils.pcrud.${action}.${fmObj.classname}`,
+            [this.token(), fmObj]
+        ).subscribe(
+            res => this.cudObserver.next(res),
+            err => this.cudObserver.error(err),
+            ()  => this.nextCudRequest()
+        );
+    };
+}
+
+@Injectable()
+export class EgPcrudService {
+
+    constructor(
+        private idl: EgIdlService,
+        private net: EgNetService,
+        private auth: EgAuthService
+    ) {}
+
+    // Pass-thru functions for one-off PCRUD calls
+
+    connect(): Promise<EgPcrudContext> {
+        return this.newContext().connect();
+    }
+
+    newContext(): EgPcrudContext {
+        return new EgPcrudContext(this.idl, this.net, this.auth);
+    }
+
+    retrieve(fmClass: string, pkey: Number | string, 
+        pcrudOps?: any, reqOps?: EgPcrudReqOps): Observable<EgPcrudResponse> {
+        return this.newContext().retrieve(fmClass, pkey, pcrudOps, reqOps);
+    }
+
+    retrieveAll(fmClass: string, pcrudOps?: any, 
+        reqOps?: EgPcrudReqOps): Observable<EgPcrudResponse> {
+        return this.newContext().retrieveAll(fmClass, pcrudOps, reqOps);
+    }
+
+    search(fmClass: string, search: any, 
+        pcrudOps?: any, reqOps?: EgPcrudReqOps): Observable<EgPcrudResponse> {
+        return this.newContext().search(fmClass, search, pcrudOps, reqOps);
+    }
+
+    create(list: EgIdlObject | EgIdlObject[]): Observable<EgPcrudResponse> {
+        return this.newContext().create(list);
+    }
+
+    update(list: EgIdlObject | EgIdlObject[]): Observable<EgPcrudResponse> {
+        return this.newContext().update(list);
+    }
+
+    remove(list: EgIdlObject | EgIdlObject[]): Observable<EgPcrudResponse> {
+        return this.newContext().remove(list);
+    }
+
+    autoApply(list: EgIdlObject | EgIdlObject[]): Observable<EgPcrudResponse> { 
+        return this.newContext().autoApply(list);
+    }
+}
+
+
diff --git a/Open-ILS/src/eg2/src/app/core/perm.service.ts b/Open-ILS/src/eg2/src/app/core/perm.service.ts
new file mode 100644 (file)
index 0000000..2e535d1
--- /dev/null
@@ -0,0 +1,58 @@
+import {Injectable} from '@angular/core';
+import {EgNetService} from './net.service';
+import {EgOrgService} from './org.service';
+import {EgAuthService} from './auth.service';
+
+interface HasPermAtResult {
+    [permName: string]: any[]; // org IDs or org unit objects
+}
+
+interface HasPermHereResult {
+    [permName: string]: boolean;
+}
+
+@Injectable()
+export class EgPermService {
+
+    constructor(
+        private net: EgNetService,
+        private org: EgOrgService,
+        private auth: EgAuthService,
+    ) {}
+
+    // workstation not required.
+    hasWorkPermAt(permNames: string[], asId?: boolean): Promise<HasPermAtResult> {
+        return this.net.request(
+            'open-ils.actor',
+            'open-ils.actor.user.has_work_perm_at.batch',
+            this.auth.token(), permNames
+        ).toPromise().then(resp => {
+            var answer: HasPermAtResult = {};
+            permNames.forEach(perm => {
+                var orgs = [];
+                resp[perm].forEach(oneOrg => {
+                    orgs = orgs.concat(this.org.descendants(oneOrg, asId));
+                });
+                answer[perm] = orgs;
+            });
+
+            return answer;
+        });
+    }
+
+    // workstation required
+    hasWorkPermHere(permNames: string[]): Promise<HasPermHereResult> {
+        let wsId: number = +this.auth.user().wsid(); 
+
+        if (!wsId) 
+            return Promise.reject('hasWorkPermHere requires a workstation');
+
+        return this.hasWorkPermAt(permNames, true).then(resp => {
+            let answer: HasPermHereResult = {};
+            Object.keys(resp).forEach(perm => {
+                answer[perm] = resp[perm].indexOf(wsId) > -1;
+            });
+            return answer;
+        });
+    }
+}
diff --git a/Open-ILS/src/eg2/src/app/core/store.service.ts b/Open-ILS/src/eg2/src/app/core/store.service.ts
new file mode 100644 (file)
index 0000000..218ad83
--- /dev/null
@@ -0,0 +1,117 @@
+/**
+ * Store and retrieve data from various sources.
+ */
+import {Injectable} from '@angular/core';
+import {Observable} from 'rxjs/Rx';
+import {CookieService} from 'ngx-cookie';
+
+@Injectable()
+export class EgStoreService {
+    
+    // Base path for cookie-based storage.
+    // Useful for limiting cookies to subsections of the application.
+    // Store cookies globally by default.
+    // Note cookies shared with /eg/staff must be stored at "/"
+    loginSessionBasePath: string = '/';
+
+    // Set of keys whose values should disappear at logout.
+    loginSessionKeys: string[] = [
+        'eg.auth.token',
+        'eg.auth.time',
+        'eg.auth.token.oc',
+        'eg.auth.time.oc'
+    ];
+
+    constructor(private cookieService: CookieService) {}
+
+    private parseJson(valJson: string): any {
+        if (valJson == null || valJson == '') return null;
+        try {
+            return JSON.parse(valJson);
+        } catch(E) { 
+            console.error(`Failure to parse JSON: ${E} => ${valJson}`);
+            return null;
+        }
+    }
+
+    /**
+     * Add a an app-local login session key
+     */
+    addLoginSessionKey(key: string): void {
+        this.loginSessionKeys.push(key);
+    }
+
+    setItem(key: string, val: any, isJson?: Boolean): Promise<void> {
+        // TODO: route keys appropriately
+        this.setLocalItem(key, val, false);
+        return Promise.resolve();
+    }
+
+    setLocalItem(key: string, val: any, isJson?: Boolean): void {
+        if (!isJson) val = JSON.stringify(val);
+        window.localStorage.setItem(key, val);
+    }
+
+    setServerItem(key: string, val: any): Promise<void> {
+        return Promise.resolve();
+    }
+
+    setSessionItem(key: string, val: any, isJson?: Boolean): void {
+        if (!isJson) val = JSON.stringify(val);
+        window.sessionStorage.setItem(key, val);
+    }
+
+    setLoginSessionItem(key: string, val: any, isJson?:Boolean): void {
+        if (!isJson) val = JSON.stringify(val);
+        this.cookieService.put(key, val, {path : this.loginSessionBasePath});
+    }
+
+    getItem(key: string): Promise<any> {
+        // TODO: route keys appropriately
+        return Promise.resolve(this.getLocalItem(key));
+    }
+
+    getLocalItem(key: string): any {
+        return this.parseJson(window.localStorage.getItem(key));
+    }
+
+    getServerItem(key: string): Promise<any> {
+        return Promise.resolve();
+    }
+
+    getSessionItem(key: string): any {
+        return this.parseJson(window.sessionStorage.getItem(key));
+    }
+
+    getLoginSessionItem(key: string): any {
+        return this.parseJson(this.cookieService.get(key));
+    }
+
+    removeItem(key: string): Promise<any> {
+        // TODO: route keys appropriately
+        return Promise.resolve(this.removeLocalItem(key));
+    }
+
+    removeLocalItem(key: string): void {
+        window.localStorage.removeItem(key);
+    }
+
+    removeServerItem(key: string): Promise<void> {
+        return Promise.resolve();
+    }
+
+    removeSessionItem(key: string): void {
+        window.sessionStorage.removeItem(key);
+    }
+
+    removeLoginSessionItem(key: string): void {
+        this.cookieService.remove(key, {path : this.loginSessionBasePath});
+    }
+
+    clearLoginSessionItems(): void {
+        this.loginSessionKeys.forEach(
+            key => this.removeLoginSessionItem(key)
+        );
+    }
+}
+
diff --git a/Open-ILS/src/eg2/src/app/migration.module.ts b/Open-ILS/src/eg2/src/app/migration.module.ts
new file mode 100644 (file)
index 0000000..5c878b5
--- /dev/null
@@ -0,0 +1,92 @@
+/**
+ * EgMigrationModule
+ *
+ * This module has no internal components or routing.  It's just a 
+ * pass-through for AngularJS.
+ *
+ * 1. Loads the ang1 => ang2 upgrade components.
+ * 2. Downgrades and injects shared ang2 services and components for use
+ *    by ang1.
+ * 3. Bootstraps ang1.
+ */
+import {BrowserModule} from '@angular/platform-browser';
+import {NgModule} from '@angular/core';
+import {NgbModule} from '@ng-bootstrap/ng-bootstrap'; // ng-bootstrap
+import {CookieModule} from 'ngx-cookie'; // import CookieMonster
+import {UpgradeModule, downgradeInjectable, downgradeComponent} 
+    from '@angular/upgrade/static';
+
+// Replacement for egStrings.setPageTitle()
+import {Title} from '@angular/platform-browser';
+
+// Import service handles so we can downgrade them.
+import {EgCommonModule} from './common.module';
+import {EgEventService} from '@eg/core/event.service';
+import {EgStoreService} from '@eg/core/store.service';
+import {EgIdlService} from '@eg/core/idl.service';
+import {EgNetService} from '@eg/core/net.service';
+import {EgAuthService} from '@eg/core/auth.service';
+import {EgPermService} from '@eg/core/perm.service';
+import {EgPcrudService} from '@eg/core/pcrud.service';
+import {EgOrgService} from '@eg/core/org.service';
+
+// Downgraded components
+//import {EgDialogComponent} from '@eg/share/dialog/dialog.component';
+//import {EgConfirmDialogComponent} from '@eg/share/dialog/confirm.component';
+
+declare var angular: any;
+
+@NgModule({
+  imports: [
+    UpgradeModule,
+    BrowserModule,
+    NgbModule.forRoot(),
+    CookieModule.forRoot(),
+    EgCommonModule.forRoot()
+  ],
+  declarations: [
+    //EgDialogComponent,
+    //EgConfirmDialogComponent
+  ],
+  entryComponents: [
+    //EgDialogComponent,
+    //EgConfirmDialogComponent
+  ]
+})
+
+export class EgMigrationModule {
+    
+    constructor(private upgrade: UpgradeModule) {}
+
+    ngDoBootstrap() {
+        let myWin: any = window; // hush compiler warnings
+
+        if (!myWin.ang1PageApp) {
+            console.error('NO PAGE APP DEFINED');
+            return;
+        }
+
+        console.log(`Ang2 loading Ang1 app ${myWin.ang1PageApp}`);
+
+        angular.module(myWin.ang1PageApp)
+            .factory('eg2Event', downgradeInjectable(EgEventService))
+            .factory('eg2Store', downgradeInjectable(EgStoreService))
+            .factory('eg2Idl',   downgradeInjectable(EgIdlService))
+            .factory('eg2Net',   downgradeInjectable(EgNetService))
+            .factory('eg2Auth',  downgradeInjectable(EgAuthService))
+            .factory('eg2Perm',  downgradeInjectable(EgPermService))
+            .factory('eg2Pcrud', downgradeInjectable(EgPcrudService))
+            .factory('eg2Org',   downgradeInjectable(EgOrgService))
+            .factory('ng2Title', downgradeInjectable(Title))
+            /*
+            .directive('eg2ConfirmDialog',
+                downgradeComponent({component: EgConfirmDialogComponent}))
+                */
+
+        ;
+
+        this.upgrade.bootstrap(document.body, [myWin.ang1PageApp]);
+    }
+}
+
+
diff --git a/Open-ILS/src/eg2/src/app/resolver.service.ts b/Open-ILS/src/eg2/src/app/resolver.service.ts
new file mode 100644 (file)
index 0000000..0049c40
--- /dev/null
@@ -0,0 +1,31 @@
+import {Injectable} from '@angular/core';
+import {Router, Resolve, RouterStateSnapshot,
+        ActivatedRouteSnapshot} from '@angular/router';
+import {EgIdlService} from '@eg/core/idl.service';
+import {EgOrgService} from '@eg/core/org.service';
+@Injectable()
+export class EgBaseResolver implements Resolve<Promise<void>> {
+
+    constructor(
+        private router: Router, 
+        private idl: EgIdlService,
+        private org: EgOrgService,
+    ) {}
+
+    /**
+     * Loads pre-auth data common to all applications.
+     * No auth token is available at this level.  When needed, auth is 
+     * enforced by application/group-specific resolvers at lower levels.
+     */
+    resolve(
+        route: ActivatedRouteSnapshot, 
+        state: RouterStateSnapshot): Promise<void> {
+
+        console.debug('EgBaseResolver:resolve()');
+
+        this.idl.parseIdl();
+
+        return this.org.fetchOrgs(); // anonymous PCRUD.
+    }
+}
diff --git a/Open-ILS/src/eg2/src/app/routing.module.ts b/Open-ILS/src/eg2/src/app/routing.module.ts
new file mode 100644 (file)
index 0000000..5945213
--- /dev/null
@@ -0,0 +1,29 @@
+import {NgModule}             from '@angular/core';
+import {RouterModule, Routes} from '@angular/router';
+import {EgBaseResolver}       from './resolver.service';
+import {WelcomeComponent}     from './welcome.component';
+
+/**
+ * Avoid loading all application JS up front by lazy-loading sub-modules.
+ * When lazy loading, no module references should be directly imported.
+ * The refs are encoded in the loadChildren attribute of each route.
+ * These modules are encoded as separate JS chunks that are fetched
+ * from the server only when needed.
+ */
+const routes: Routes = [
+  { path: '',
+    component: WelcomeComponent
+  }, {
+    path: 'staff', 
+    resolve : {startup : EgBaseResolver},
+    loadChildren: './staff/staff.module#EgStaffModule'
+  }
+];
+
+@NgModule({
+  imports: [RouterModule.forRoot(routes)],
+  exports: [RouterModule],
+  providers: [EgBaseResolver]
+})
+
+export class EgBaseRoutingModule {}
diff --git a/Open-ILS/src/eg2/src/app/share/README b/Open-ILS/src/eg2/src/app/share/README
new file mode 100644 (file)
index 0000000..8bd93d7
--- /dev/null
@@ -0,0 +1,6 @@
+Shared Angular services, components, directives, and associated classes.  
+
+These items are NOT automatically imported to the base module, though some
+may already be imported by intermediate modules (e.g. EgStaffCommonModule).   
+Import as needed.
+
diff --git a/Open-ILS/src/eg2/src/app/share/accesskey/accesskey-info.component.html b/Open-ILS/src/eg2/src/app/share/accesskey/accesskey-info.component.html
new file mode 100644 (file)
index 0000000..82ed72a
--- /dev/null
@@ -0,0 +1,26 @@
+<ng-template #dialogContent>
+  <div class="modal-header bg-info">
+    <h4 class="modal-title" i18n>Access Key Assignments</h4>
+    <button type="button" class="close" 
+      i18n-aria-label aria-label="Close" 
+      (click)="dismiss('cross_click')">
+      <span aria-hidden="true">&times;</span>
+    </button>
+  </div>
+  <div class="modal-body">
+    <div class="row border-bottom">
+      <div class="col-lg-3 p-1 border-right text-center" i18n>Command</div>
+      <div class="col-lg-6 p-1 border-right" i18n>Action</div>
+      <div class="col-lg-3 p-1" i18n>Context</div>
+    </div>
+    <div class="row border-bottom" *ngFor="let a of assignments()">
+      <div class="col-lg-3 p-1 border-right text-center">{{a.key}}</div>
+      <div class="col-lg-6 p-1 border-right">{{a.desc}}</div>
+      <div class="col-lg-3 p-1">{{a.ctx}}</div>
+    </div>
+  </div>
+  <div class="modal-footer">
+    <button type="button" class="btn btn-success" 
+      (click)="close()" i18n>Close</button>
+  </div>
+</ng-template>
diff --git a/Open-ILS/src/eg2/src/app/share/accesskey/accesskey-info.component.ts b/Open-ILS/src/eg2/src/app/share/accesskey/accesskey-info.component.ts
new file mode 100644 (file)
index 0000000..c925a50
--- /dev/null
@@ -0,0 +1,25 @@
+/**
+ */
+import {Component, Input, OnInit} from '@angular/core';
+import {EgAccessKeyService} from '@eg/share/accesskey/accesskey.service';
+import {EgDialogComponent} from '@eg/share/dialog/dialog.component';
+import {NgbModal} from '@ng-bootstrap/ng-bootstrap';
+
+@Component({
+  selector: 'eg-accesskey-info',
+  templateUrl: './accesskey-info.component.html'
+})
+export class EgAccessKeyInfoComponent extends EgDialogComponent {
+
+       constructor(
+        private modal: NgbModal, // required for passing to parent
+        private keyService: EgAccessKeyService) {
+        super(modal);
+    }
+
+    assignments(): any[] {
+        return this.keyService.infoIze();
+    }
+}
+
+
diff --git a/Open-ILS/src/eg2/src/app/share/accesskey/accesskey.directive.ts b/Open-ILS/src/eg2/src/app/share/accesskey/accesskey.directive.ts
new file mode 100644 (file)
index 0000000..bb1fada
--- /dev/null
@@ -0,0 +1,56 @@
+/**
+ * Assign access keys to <a> tags.
+ *
+ * Access key action is peformed via .click(). hrefs, routerLinks,
+ * and (click) actions are all supported.
+ *
+ *   <a 
+ *     routerLink="/staff/splash"                                           
+ *     egAccessKey 
+ *     keySpec="alt+h" i18n-keySpec
+ *     keyDesc="My Description" 18n-keyDesc              
+ *   >
+ */
+import {Directive, ElementRef, Input, OnInit} from '@angular/core';
+import {EgAccessKeyService} from '@eg/share/accesskey/accesskey.service';
+
+@Directive({
+  selector: '[egAccessKey]'
+})
+export class EgAccessKeyDirective implements OnInit {
+
+    // Space-separated list of key combinations
+    // E.g. "ctrl+h", "alt+h ctrl+y"
+    @Input() keySpec: string;
+
+    // Description to display in the accesskey info dialog
+    @Input() keyDesc: string;
+
+    // Context info to display in the accesskey info dialog
+    // E.g. "navbar"
+    @Input() keyCtx: string;
+    
+       constructor(
+        private elm: ElementRef,
+        private keyService: EgAccessKeyService
+    ) { }
+
+    ngOnInit() {
+
+        if (!this.keySpec) {
+            console.warn("EgAccessKey no keySpec provided");
+            return;
+        }
+
+        this.keySpec.split(/ /).forEach(keySpec => {
+            this.keyService.assign({
+                key: keySpec, 
+                desc: this.keyDesc,
+                ctx: this.keyCtx,
+                action: () => {this.elm.nativeElement.click()}
+            });
+        })
+    }
+}
+
+
diff --git a/Open-ILS/src/eg2/src/app/share/accesskey/accesskey.service.ts b/Open-ILS/src/eg2/src/app/share/accesskey/accesskey.service.ts
new file mode 100644 (file)
index 0000000..1a3dab3
--- /dev/null
@@ -0,0 +1,67 @@
+import {Injectable, EventEmitter, HostListener} from '@angular/core';
+
+export interface EgAccessKeyAssignment {
+    key: string,     // keyboard command
+    desc: string,    // human-friendly description
+    ctx: string,     // template context 
+    action: Function // handler function
+};
+
+@Injectable()
+export class EgAccessKeyService {
+
+    // Assignments stored as an array with most recently assigned
+    // items toward the front.  Most recent items have precedence.
+    assignments: EgAccessKeyAssignment[] = [];
+
+    constructor() {}
+
+    assign(assn: EgAccessKeyAssignment): void {
+        this.assignments.unshift(assn);
+    }
+
+    /**
+     * Compress a set of single-fire keyboard events into single 
+     * string.  For example:  Control and 't' becomes 'ctrl+t'.
+     */
+    compressKeys(evt: KeyboardEvent): string {
+
+        let s = '';
+        if (evt.ctrlKey || evt.metaKey) s += 'ctrl+';
+        if (evt.altKey) s += 'alt+';
+        s += String.fromCharCode(evt.keyCode).toLowerCase();
+
+        return s;
+    }
+
+    /**
+     * Checks for a key assignment and fires the assigned action.
+     */
+    fire(evt: KeyboardEvent): void {
+        let keySpec = this.compressKeys(evt);
+        for (let i in this.assignments) { // for-loop to exit early
+            if (keySpec == this.assignments[i].key) {
+                let assign = this.assignments[i];
+                console.debug(`EgAccessKey assignment found for ${assign.key}`);
+                // Allow the current digest cycle to complete before
+                // firing the access key action.
+                setTimeout(assign.action, 0);
+                evt.preventDefault();
+                return;
+            }
+        }
+    }
+
+    /**
+     * Returns a simplified key assignment list containing just
+     * the key spec and the description.  Useful for inspecting 
+     * without exposing the actions.
+     */
+    infoIze(): any[] {
+        return this.assignments.map(a => {
+            return {key: a.key, desc: a.desc, ctx: a.ctx};
+        });
+    }
+
+}
+
diff --git a/Open-ILS/src/eg2/src/app/share/catalog/catalog-url.service.ts b/Open-ILS/src/eg2/src/app/share/catalog/catalog-url.service.ts
new file mode 100644 (file)
index 0000000..707d44f
--- /dev/null
@@ -0,0 +1,128 @@
+import {Injectable} from '@angular/core';
+import {ParamMap} from '@angular/router';
+import {EgOrgService} from '@eg/core/org.service';
+import {CatalogSearchContext, FacetFilter} from './search-context';
+import {CATALOG_CCVM_FILTERS} from './catalog.service';
+
+@Injectable()
+export class EgCatalogUrlService {
+
+    // consider supporting a param name prefix/namespace
+
+    constructor(private org: EgOrgService) { }
+
+    /**
+     * Returns a URL query structure suitable for using with 
+     * router.navigate(..., {queryParams:...}).  
+     * No navigation is performed within.
+     */
+    toUrlParams(context: CatalogSearchContext): 
+            {[key: string]: string | string[]} {
+
+        let params = {
+            query: [],
+            fieldClass: [],
+            joinOp: [],
+            matchOp: [],
+            facets: [],
+            org: null,
+            limit: null,
+            offset: null
+        };
+
+        params.limit = context.pager.limit;
+        if (context.pager.offset)
+            params.offset = context.pager.offset;
+
+        // These fields can be copied directly into place
+        ['format','sort','available','global']
+        .forEach(field => {
+            if (context[field]) {
+                // Only propagate applied values to the URL.
+                params[field] = context[field];
+            }
+        });
+
+        context.query.forEach((q, idx) => {
+            ['query', 'fieldClass','joinOp','matchOp'].forEach(field => {
+                // Propagate all array-based fields regardless of 
+                // whether a value is applied to ensure correct
+                // correlation between values.
+                params[field][idx] = context[field][idx];
+            });
+        });
+
+        // CCVM filters are encoded as comma-separated lists
+        Object.keys(context.ccvmFilters).forEach(code => {
+            if (context.ccvmFilters[code] && 
+                context.ccvmFilters[code][0] != '') {
+                params[code] = context.ccvmFilters[code].join(',');
+            }
+        });
+
+        // Each facet is a JSON encoded blob of class, name, and value         
+        context.facetFilters.forEach(facet => {
+            params.facets.push(JSON.stringify({
+                c : facet.facetClass,
+                n : facet.facetName,
+                v : facet.facetValue
+            }));
+        });
+
+        params.org = context.searchOrg.id();
+
+        return params;
+    }
+
+    /**
+     * Creates a new search context from the active route params.
+     */
+    fromUrlParams(params: ParamMap): CatalogSearchContext {
+        let context = new CatalogSearchContext();
+
+        this.applyUrlParams(context, params);
+
+        return context;
+    }
+
+    applyUrlParams(context: CatalogSearchContext, params: ParamMap): void {
+
+        // Reset query/filter args.  The will be reconstructed below.
+        context.reset();
+
+        // These fields can be copied directly into place
+        ['format','sort','available','global']
+        .forEach(field => {
+            let val = params.get(field);
+            if (val !== null) context[field] = val;
+        });
+
+        if (params.get('limit'))
+            context.pager.limit = +params.get('limit');
+
+        if (params.get('offset'))
+            context.pager.offset = +params.get('offset');
+
+        ['query','fieldClass','joinOp','matchOp'].forEach(field => {
+            let arr = params.getAll(field);
+            if (arr && arr.length) context[field] = arr;
+        });
+
+        CATALOG_CCVM_FILTERS.forEach(code => {
+            let val = params.get(code);
+            if (val) {
+                context.ccvmFilters[code] = val.split(/,/);
+            } else {
+                context.ccvmFilters[code] = [''];
+            }
+        });
+
+        params.getAll('facets').forEach(blob => {
+            let facet = JSON.parse(blob);
+            context.addFacet(new FacetFilter(facet.c, facet.n, facet.v));
+        });
+
+        if (params.get('org'))
+            context.searchOrg = this.org.get(+params.get('org'));
+    }
+}
diff --git a/Open-ILS/src/eg2/src/app/share/catalog/catalog.service.ts b/Open-ILS/src/eg2/src/app/share/catalog/catalog.service.ts
new file mode 100644 (file)
index 0000000..8b0483f
--- /dev/null
@@ -0,0 +1,297 @@
+import {Injectable} from '@angular/core';
+import {EgOrgService} from '@eg/core/org.service';
+import {EgUnapiService} from '@eg/share/catalog/unapi.service';
+import {EgIdlObject} from '@eg/core/idl.service';
+import {EgNetService} from '@eg/core/net.service';
+import {EgPcrudService} from '@eg/core/pcrud.service';
+import {CatalogSearchContext, CatalogSearchState} from './search-context';
+
+export const CATALOG_CCVM_FILTERS = [
+    'item_type',
+    'item_form',
+    'item_lang',
+    'audience',
+    'audience_group',
+    'vr_format',
+    'bib_level',
+    'lit_form',
+    'search_format'
+];
+
+const MODS_XPATH_AUTO = {
+    title :  '/mods:mods/mods:titleInfo/mods:title',
+    author:  '/mods:mods/mods:name/mods:namePart',
+    edition: '/mods:mods/mods:originInfo/mods:edition',
+    pubdate: '/mods:mods/mods:originInfo/mods:dateIssued',
+    genre:   '/mods:mods/mods:genre'
+};
+
+const MODS_XPATH = {
+    extern:  '/mods:mods/biblio:extern',
+    copyCounts: '/mods:mods/holdings:holdings/holdings:counts/holdings:count',
+    attributes: '/mods:mods/indexing:attributes/indexing:field'
+};
+
+const NAMESPACE_MAPS = {
+    'mods':     'http://www.loc.gov/mods/v3',
+    'biblio':   'http://open-ils.org/spec/biblio/v1',
+    'holdings': 'http://open-ils.org/spec/holdings/v1',
+    'indexing': 'http://open-ils.org/spec/indexing/v1'
+};
+
+@Injectable()
+export class EgCatalogService {
+
+    ccvmMap: {[ccvm:string] : EgIdlObject[]} = {};
+    cmfMap: {[cmf:string] : EgIdlObject} = {};
+
+    // Keep a reference to the most recently retrieved facet data,
+    // since facet data is consistent across a given search.
+    // No need to re-fetch with every page of search data.
+    lastFacetData: any;
+    lastFacetKey: string;
+
+    constructor(
+        private net: EgNetService,
+        private org: EgOrgService,
+        private unapi: EgUnapiService,
+        private pcrud: EgPcrudService
+    ) {}
+
+    search(ctx: CatalogSearchContext): Promise<void> {
+        ctx.searchState = CatalogSearchState.SEARCHING;
+
+        var fullQuery = ctx.compileSearch();
+
+        console.debug(`search query: ${fullQuery}`);
+
+        let method = 'open-ils.search.biblio.multiclass.query';
+        if (ctx.isStaff) method += '.staff';
+
+        return new Promise((resolve, reject) => {
+            this.net.request(
+                'open-ils.search', method, {
+                    limit : ctx.pager.limit + 1, 
+                    offset : ctx.pager.offset
+                }, fullQuery, true
+            ).subscribe(result => {
+                this.applyResultData(ctx, result);
+                ctx.searchState = CatalogSearchState.COMPLETE;
+                resolve();
+            });
+        })
+    }
+
+    applyResultData(ctx: CatalogSearchContext, result: any): void {
+        ctx.result = result;
+        ctx.pager.resultCount = result.count;
+
+        // records[] tracks the current page of bib summaries.
+        result.records = [];
+
+        // If this is a new search, reset the result IDs collection.
+        if (this.lastFacetKey != result.facet_key) ctx.resultIds = [];
+
+        result.ids.forEach((blob, idx) => {ctx.addResultId(blob[0], idx)});
+    }
+
+    fetchBibSummaries(ctx: CatalogSearchContext): Promise<any> {
+        let promises = [];
+        let depth = ctx.global ? 
+            ctx.org.root().ou_type().depth() :
+            ctx.searchOrg.ou_type().depth();
+
+        ctx.currentResultIds().forEach((recId, idx) => {
+            promises.push(
+                this.getBibSummary(recId, ctx.searchOrg.id(), depth)
+                .then(
+                    // idx maintains result sort order
+                    summary => ctx.result.records[idx] = summary
+                )
+            );
+        });
+
+        return Promise.all(promises);
+    }
+
+    fetchFacets(ctx: CatalogSearchContext): Promise<void> {
+
+        if (!ctx.result)
+            return Promise.reject('Cannot fetch facets without results');
+
+        if (this.lastFacetKey == ctx.result.facet_key) {
+            ctx.result.facetData = this.lastFacetData;
+            return Promise.resolve();
+        }
+
+        return new Promise((resolve, reject) => {
+            this.net.request('open-ils.search', 
+                'open-ils.search.facet_cache.retrieve',
+                ctx.result.facet_key
+            ).subscribe(facets => {
+                let facetData = {};
+                Object.keys(facets).forEach(cmfId => {
+                    let facetHash = facets[cmfId];
+                    let cmf = this.cmfMap[cmfId];
+
+                    let cmfData = [];
+                    Object.keys(facetHash).forEach(value => {
+                        let count = facetHash[value];
+                        cmfData.push({value : value, count : count});
+                    });
+
+                    if (!facetData[cmf.field_class()])
+                        facetData[cmf.field_class()] = {};
+
+                    facetData[cmf.field_class()][cmf.name()] = {
+                        cmfLabel : cmf.label(),
+                        valueList : cmfData.sort((a, b) => {
+                            if (a.count > b.count) return -1;
+                            if (a.count < b.count) return 1;
+                            // secondary alpha sort on display value
+                            return a.value < b.value ? -1 : 1;
+                        })
+                    };
+                });
+
+                this.lastFacetKey = ctx.result.facet_key;
+                this.lastFacetData = ctx.result.facetData = facetData;
+                resolve();
+            });
+        })
+    }
+
+    fetchCcvms(): Promise<void> {
+
+        if (Object.keys(this.ccvmMap).length) 
+            return Promise.resolve();
+
+        return new Promise((resolve, reject) => {
+            this.pcrud.search('ccvm', 
+                {ctype : CATALOG_CCVM_FILTERS}, {}, 
+                {atomic: true, anonymous: true}
+            ).subscribe(list => {
+                this.compileCcvms(list);
+                resolve();
+            })
+        });
+    }
+
+    compileCcvms(ccvms : EgIdlObject[]): void {
+        ccvms.forEach(ccvm => {
+            if (!this.ccvmMap[ccvm.ctype()])
+                this.ccvmMap[ccvm.ctype()] = [];
+            this.ccvmMap[ccvm.ctype()].push(ccvm);
+        });
+
+        Object.keys(this.ccvmMap).forEach(cType => {
+            this.ccvmMap[cType] = 
+                this.ccvmMap[cType].sort((a, b) => {
+                    return a.value() < b.value() ? -1 : 1;
+                });
+        });
+    }
+
+
+    fetchCmfs(): Promise<void> {
+        // At the moment, we only need facet CMFs.
+        if (Object.keys(this.cmfMap).length) 
+            return Promise.resolve();
+
+        return new Promise((resolve, reject) => {
+            this.pcrud.search('cmf', 
+                {facet_field : 't'}, {}, {atomic: true, anonymous: true}
+            ).subscribe(
+                cmfs => {
+                    cmfs.forEach(c => this.cmfMap[c.id()] = c);
+                    resolve();
+                }
+            )
+        });
+    }
+
+
+    /**
+     * Bib record via UNAPI as mods (for now) with holdings summary
+     * and record attributes.
+     */
+    getBibSummary(bibId: number, orgId?: number, depth?: number): Promise<any> {
+        return new Promise((resolve, reject) => {
+            this.unapi.getAsXmlDocument({
+                target: 'bre', 
+                id: bibId, 
+                extras: '{bre.extern,holdings_xml,mra}', 
+                format: 'mods32', 
+                orgId: orgId,
+                depth: depth
+            }).then(xmlDoc => {
+                let summary = this.translateBibSummary(xmlDoc);
+                summary.id = bibId;
+                resolve(summary);
+            });
+        });
+    }
+
+    /**
+     * Probably don't want to require navigating the bare UNAPI
+     * blob in the template, plus that's quite a lot of stuff
+     * to sit in the scope / watch for changes.  Translate the 
+     * UNAPI content into a more digestable form.
+     * TODO: Add display field support
+     */
+    translateBibSummary(xmlDoc: XMLDocument): any { // TODO: bib summary interface
+
+        let response = {
+            copyCounts : [],
+            ccvms : {}
+        };
+
+        let resolver:any = (prefix: string): string => {
+            return NAMESPACE_MAPS[prefix] || null;
+        };
+
+        Object.keys(MODS_XPATH_AUTO).forEach(key => {
+            let result = xmlDoc.evaluate(MODS_XPATH_AUTO[key], xmlDoc, 
+                resolver, XPathResult.FIRST_ORDERED_NODE_TYPE, null);
+
+            let node = result.singleNodeValue;
+            if (node) response[key] = node.textContent;
+        });
+
+        let result = xmlDoc.evaluate(MODS_XPATH.extern, xmlDoc, 
+            resolver, XPathResult.FIRST_ORDERED_NODE_TYPE, null);
+
+        let node:any = result.singleNodeValue;
+        if (node) {
+            let attrs = node.attributes;
+            for(let i = attrs.length - 1; i >= 0; i--) {
+                response[attrs[i].name] = attrs[i].value;
+            }
+        }
+
+        result = xmlDoc.evaluate(MODS_XPATH.attributes, xmlDoc, 
+            resolver, XPathResult.ANY_TYPE, null);
+
+        while(node = result.iterateNext()) {
+            response.ccvms[node.getAttribute('name')] = {
+                code : node.textContent,
+                label : node.getAttribute('coded-value')
+            }
+        }
+
+        result = xmlDoc.evaluate(MODS_XPATH.copyCounts, xmlDoc, 
+            resolver, XPathResult.ANY_TYPE, null);
+
+        while(node = result.iterateNext()) {
+            let counts = {};
+            ['type', 'depth', 'org_unit', 'transcendant', 
+                'available', 'count', 'unshadow'].forEach(field => {
+                counts[field] = node.getAttribute(field);
+            });
+            response.copyCounts.push(counts);
+        }
+
+        //console.log(response);
+        return response;
+    }
+}
diff --git a/Open-ILS/src/eg2/src/app/share/catalog/search-context.ts b/Open-ILS/src/eg2/src/app/share/catalog/search-context.ts
new file mode 100644 (file)
index 0000000..54224ff
--- /dev/null
@@ -0,0 +1,247 @@
+import {EgOrgService} from '@eg/core/org.service';
+import {EgIdlObject} from '@eg/core/idl.service';
+import {Pager} from '@eg/share/util/pager';
+import {Params} from '@angular/router';
+
+export enum CatalogSearchState {
+    PENDING,
+    SEARCHING,
+    COMPLETE
+}
+
+export class FacetFilter {
+    facetClass: string;
+    facetName: string;
+    facetValue: string;
+
+    constructor(cls: string, name: string, value: string) {
+        this.facetClass = cls;
+        this.facetName  = name;
+        this.facetValue = value;
+    }
+
+    equals(filter: FacetFilter): boolean {
+        return (
+            this.facetClass == filter.facetClass &&
+            this.facetName  == filter.facetName &&
+            this.facetValue == filter.facetValue
+        );
+    }
+}
+
+// Not an angular service.
+// It's conceviable there could be multiple contexts.
+export class CatalogSearchContext {
+
+    // Search options and filters
+    available: boolean = false;
+    global: boolean = false;
+    sort: string;
+    fieldClass: string[];
+    query: string[];
+    joinOp: string[];
+    matchOp: string[];
+    format: string;
+    searchOrg: EgIdlObject;
+    ccvmFilters: {[ccvmCode:string] : string[]};
+    facetFilters: FacetFilter[];
+    isStaff: boolean;
+
+    // Result from most recent search.
+    result: any = {};
+    searchState: CatalogSearchState = CatalogSearchState.PENDING;
+
+    // List of IDs in page/offset context.
+    resultIds: number[] = [];
+
+    // Utility stuff
+    pager: Pager;
+    org: EgOrgService;
+
+    constructor() {
+        this.pager = new Pager();
+        this.reset();
+    }
+
+    // List of result IDs for the current page of data.
+    currentResultIds(): number[] {
+        let ids = [];
+        for (
+            let idx = this.pager.offset;
+            idx < Math.min( 
+                this.pager.offset + this.pager.limit, 
+                this.pager.resultCount
+            );
+            idx++
+        ) {ids.push(this.resultIds[idx])}
+        return ids;
+    }
+
+    addResultId(id: number, resultIdx: number ): void {
+        this.resultIds[resultIdx + this.pager.offset] = id;
+    }
+
+    // Return the record at the requested index.
+    resultIdAt(index: number): number {
+        return this.resultIds[index] || null;
+    }
+
+    // Return the index of the requested record
+    indexForResult(id: number): number {
+        for (let i = 0; i < this.resultIds.length; i++) {
+            if (this.resultIds[i] == id)
+                return i;
+        }
+        return null;
+    }
+
+    /**
+     * Return search context to its default state, resetting search
+     * parameters and clearing any cached result data.
+     * This does not reset global filters like limit-to-available 
+     * search-global, or search-org.
+     */
+    reset(): void {
+        this.pager.offset = 0;
+        this.format = '';
+        this.sort = '';
+        this.query = [''];
+        this.fieldClass  = ['keyword'];
+        this.matchOp = ['contains'];
+        this.joinOp = [''];
+        this.ccvmFilters = {};
+        this.facetFilters = [];
+        this.result= {};
+        this.resultIds = [];
+        this.searchState = CatalogSearchState.PENDING;
+    }
+
+    isSearchable(): boolean {
+        return this.query.length 
+            && this.query[0] != ''
+            && this.searchOrg != null;
+    }
+
+    compileSearch(): string {
+        let str: string = '';
+
+        if (this.available) str += '#available';
+
+        if (this.sort) {
+            // e.g. title, title.descending
+            let parts = this.sort.split(/\./);
+            if (parts[1]) str += ' #descending';
+            str += ' sort(' + parts[0] + ')';
+        }
+
+        // -------
+        // Compile boolean sub-query components
+        if (str.length) str += ' ';
+        let qcount = this.query.length;
+
+        // if we multiple boolean query components, wrap them in parens.
+        if (qcount > 1) str += '(';
+        this.query.forEach((q, idx) => {
+            str += this.compileBoolQuerySet(idx)
+        });
+        if (qcount > 1) str += ')';
+        // -------
+
+        if (this.format) {
+            str += ' format(' + this.format + ')';
+        }
+
+        if (this.global) {
+            str += ' depth(' +
+                this.org.root().ou_type().depth() + ')';
+        }
+
+        str += ' site(' + this.searchOrg.shortname() + ')';
+
+        Object.keys(this.ccvmFilters).forEach(field => {
+            if (this.ccvmFilters[field][0] != '')
+                str += ' ' + field + '(' + this.ccvmFilters[field] + ')';
+        });
+
+        this.facetFilters.forEach(f => {
+            str += ' ' + f.facetClass + '|'
+                + f.facetName + '[' + f.facetValue + ']';
+        });
+
+        return str;
+    }
+
+    stripQuotes(query: string): string {
+        return query.replace(/"/g, ''); 
+    }
+
+    stripAnchors(query: string): string {
+        return query.replace(/[\^\$]/g, ''); 
+    }
+
+    addQuotes(query: string): string {
+        if (query.match(/ /))
+            return '"' + query + '"'
+        return query;
+    };
+
+    compileBoolQuerySet(idx: number): string {
+        let query = this.query[idx];
+        let joinOp = this.joinOp[idx];
+        let matchOp = this.matchOp[idx];
+        let fieldClass = this.fieldClass[idx];
+
+        let str = '';
+        if (!query) return str;
+
+        if (idx > 0) str += ' ' + joinOp + ' ';
+
+        str += '(';
+        if (fieldClass) str += fieldClass + ':';
+
+        switch(matchOp) {
+            case 'phrase':
+                query = this.addQuotes(this.stripQuotes(query));
+                break;
+            case 'nocontains':
+                query = '-' + this.addQuotes(this.stripQuotes(query));
+                break;
+            case 'exact':
+                query = '^' + this.stripAnchors(query) + '$';
+                break;
+            case 'starts':
+                query = this.addQuotes('^' + 
+                    this.stripAnchors(this.stripQuotes(query)));
+                break;
+        }
+
+        return str + query + ')';
+    }
+
+    hasFacet(facet: FacetFilter): boolean {
+        return Boolean(
+            this.facetFilters.filter(
+                f => {return f.equals(facet)})[0]
+        );
+    }
+
+    removeFacet(facet: FacetFilter): void {
+        this.facetFilters = this.facetFilters.filter(
+            f => { return !f.equals(facet); });
+    }
+
+    addFacet(facet: FacetFilter): void {
+        if (!this.hasFacet(facet))
+            this.facetFilters.push(facet);
+    }
+
+    toggleFacet(facet: FacetFilter): void {
+        if (this.hasFacet(facet)) {
+            this.removeFacet(facet);
+        } else {
+            this.facetFilters.push(facet);
+        }
+    }
+}
+
+
diff --git a/Open-ILS/src/eg2/src/app/share/catalog/unapi.service.ts b/Open-ILS/src/eg2/src/app/share/catalog/unapi.service.ts
new file mode 100644 (file)
index 0000000..9034ae4
--- /dev/null
@@ -0,0 +1,54 @@
+import {Injectable, EventEmitter} from '@angular/core';
+import {EgOrgService} from '@eg/core/org.service';
+
+/*
+TODO: Add Display Fields to UNAPI
+https://library.biz/opac/extras/unapi?id=tag::U2@bre/1{bre.extern,holdings_xml,mra}/BR1/0&format=mods32
+*/
+
+const UNAPI_PATH = '/opac/extras/unapi?id=tag::U2@';
+
+interface EgUnapiParams {
+    target: string; // bre, ...
+    id: number | string; // 1 | 1,2,3,4,5
+    extras: string; // {holdings_xml,mra,...}
+    format: string; // mods32, marxml, ...
+    orgId?: number; // org unit ID
+    depth?: number; // org unit depth
+};
+
+@Injectable()
+export class EgUnapiService {
+
+    constructor(private org: EgOrgService) {}
+
+    createUrl(params: EgUnapiParams): string {
+        let depth = params.depth || 0;
+        let org = params.orgId ? this.org.get(params.orgId) : this.org.root();
+
+        return `${UNAPI_PATH}${params.target}/${params.id}${params.extras}/` +
+            `${org.shortname()}/${depth}&format=${params.format}`;
+    }
+
+    getAsXmlDocument(params: EgUnapiParams): Promise<XMLDocument> {
+        // XReq creates an XML document for us.  Seems like the right
+        // tool for the job.
+        let url = this.createUrl(params);
+        return new Promise((resolve, reject) => {
+            var xhttp = new XMLHttpRequest();
+            xhttp.onreadystatechange = function() {
+                if (this.readyState == 4) {
+                    if (this.status == 200) {
+                        resolve(xhttp.responseXML);
+                    } else {
+                        reject(`UNAPI request failed for ${url}`);
+                    }
+                }
+            }
+            xhttp.open("GET", url, true);
+            xhttp.send();
+        });
+    }
+}
+
+
diff --git a/Open-ILS/src/eg2/src/app/share/dialog/confirm.component.html b/Open-ILS/src/eg2/src/app/share/dialog/confirm.component.html
new file mode 100644 (file)
index 0000000..21766ca
--- /dev/null
@@ -0,0 +1,17 @@
+<ng-template #dialogContent>
+  <div class="modal-header bg-info">
+    <h4 class="modal-title">{{dialogTitle}}</h4>
+    <button type="button" class="close" 
+      i18n-aria-label aria-label="Close" 
+      (click)="dismiss('cross_click')">
+      <span aria-hidden="true">&times;</span>
+    </button>
+  </div>
+  <div class="modal-body"><p>{{dialogBody}}</p></div>
+  <div class="modal-footer">
+    <button type="button" class="btn btn-success" 
+      (click)="close('confirmed')" i18n>Confirm</button>
+    <button type="button" class="btn btn-warning" 
+      (click)="dismiss('canceled')" i18n>Cancel</button>
+  </div>
+</ng-template>
diff --git a/Open-ILS/src/eg2/src/app/share/dialog/confirm.component.ts b/Open-ILS/src/eg2/src/app/share/dialog/confirm.component.ts
new file mode 100644 (file)
index 0000000..e00731b
--- /dev/null
@@ -0,0 +1,17 @@
+import {Component, Input, ViewChild, TemplateRef} from '@angular/core';
+import {EgDialogComponent} from '@eg/share/dialog/dialog.component';
+
+@Component({
+  selector: 'eg-confirm-dialog',
+  templateUrl: './confirm.component.html'
+})
+
+/**
+ * Confirmation dialog that asks a yes/no question.
+ */
+export class EgConfirmDialogComponent extends EgDialogComponent {
+    // What question are we asking?
+    @Input() public dialogBody: string;
+}
+
+
diff --git a/Open-ILS/src/eg2/src/app/share/dialog/dialog.component.ts b/Open-ILS/src/eg2/src/app/share/dialog/dialog.component.ts
new file mode 100644 (file)
index 0000000..1f979fc
--- /dev/null
@@ -0,0 +1,75 @@
+import {Component, Input, OnInit, ViewChild, TemplateRef, EventEmitter} from '@angular/core';
+import {NgbModal, NgbModalRef, NgbModalOptions} from '@ng-bootstrap/ng-bootstrap';
+
+/** 
+ * Dialog base class.  Handles the ngbModal logic. 
+ * Sub-classed component templates must have a #dialogContent selector 
+ * at the root of the template (see EgConfirmDialogComponent).
+ */ 
+
+@Component({
+    selector: 'eg-dialog',
+    template: '<ng-template></ng-template>'
+})
+export class EgDialogComponent implements OnInit {
+
+    // Assume all dialogs support a title attribute.
+    @Input() public dialogTitle: string;
+
+    // Pointer to the dialog content template.
+    @ViewChild('dialogContent') 
+    private dialogContent: TemplateRef<any>;
+
+    // Emitted after open() is called on the ngbModal.
+    // Note when overriding open(), this will not fire unless also 
+    // called in the overridding method.
+    onOpen$ = new EventEmitter<any>();
+
+    // The modalRef allows direct control of the modal instance.
+    private modalRef: NgbModalRef = null;
+
+    constructor(private modalService: NgbModal) {}
+
+    ngOnInit() {
+        this.onOpen$ = new EventEmitter<any>();
+    }
+
+    open(options?: NgbModalOptions): Promise<any> {
+
+        if (this.modalRef !== null) {
+            console.warn('Dismissing existing dialog');
+            this.dismiss();
+        }
+
+        this.modalRef = this.modalService.open(this.dialogContent, options);
+
+        if (this.onOpen$) {
+            // Let the digest cycle complete
+            setTimeout(() => this.onOpen$.emit(true));
+        }
+
+        return new Promise( (resolve, reject) => {
+
+            this.modalRef.result.then(
+                (result) => {
+                    resolve(result);
+                    this.modalRef = null;
+                },
+                (result) => {
+                    reject(result);
+                    this.modalRef = null;
+                }
+            );
+        });
+    }
+
+    close(reason?: any): void {
+        this.modalRef.close(reason);
+    }
+
+    dismiss(reason?: any): void {
+        this.modalRef.dismiss(reason);
+    }
+}
+
+
diff --git a/Open-ILS/src/eg2/src/app/share/dialog/progress.component.css b/Open-ILS/src/eg2/src/app/share/dialog/progress.component.css
new file mode 100644 (file)
index 0000000..a79609e
--- /dev/null
@@ -0,0 +1,5 @@
+
+.eg-progress-dialog progress {
+  width: 100%;
+  height: 25px;
+}
diff --git a/Open-ILS/src/eg2/src/app/share/dialog/progress.component.html b/Open-ILS/src/eg2/src/app/share/dialog/progress.component.html
new file mode 100644 (file)
index 0000000..5d682b9
--- /dev/null
@@ -0,0 +1,32 @@
+<ng-template #dialogContent>
+  <div class="modal-header bg-info">
+    <button type="button" class="close" 
+      i18n-aria-label aria-label="Close" 
+      (click)="dismiss('cross_click')">
+      <span aria-hidden="true">&times;</span>
+    </button>
+  </div>
+
+  <div class="modal-body eg-progress-dialog">
+
+    <div *ngIf="hasValue() && hasMax()">
+      <!-- determinate progress bar.  shows max/value progress -->
+      <div class="col-lg-10">
+        <progress max="{{max}}" value="{{value}}"></progress>
+      </div>
+      <div class="col-lg-2">{{percent()}}%</div>
+    </div>
+
+    <div *ngIf="hasValue() && !hasMax()">
+      <!-- semi-determinate progress bar.  shows value -->
+      <div class="col-lg-10"><progress max="1"></progress></div>
+      <div class="col-lg-2">{{value}}...</div>
+    </div>
+
+    <div *ngIf="!hasValue()">
+      <!-- indeterminate -->
+      <div class="col-lg-12"><progress max="1"></progress></div>
+    </div>
+
+  </div>
+</ng-template>
diff --git a/Open-ILS/src/eg2/src/app/share/dialog/progress.component.ts b/Open-ILS/src/eg2/src/app/share/dialog/progress.component.ts
new file mode 100644 (file)
index 0000000..33f8099
--- /dev/null
@@ -0,0 +1,92 @@
+import {Component, Input, ViewChild, TemplateRef} from '@angular/core';
+import {EgDialogComponent} from '@eg/share/dialog/dialog.component';
+
+@Component({
+  selector: 'eg-progress-dialog',
+  templateUrl: './progress.component.html',
+  styleUrls: ['progress.component.css']
+})
+
+/**
+ * Progress Dialog. 
+ *
+ * // assuming a template reference...
+ * @ViewChild('progressDialog')
+ * private dialog: EgProgressDialogComponent;
+ *
+ * dialog.open();
+ * dialog.update({value : 0, max : 123});
+ * dialog.increment();
+ * dialog.increment();
+ * dialog.close();
+ *
+ * Each dialog has 2 numbers, 'max' and 'value'.
+ * The content of these values determines how the dialog displays.  
+ *
+ * There are 3 flavors:
+ *
+ * -- value is set, max is set
+ * determinate: shows a progression with a percent complete.
+ *
+ * -- value is set, max is unset
+ * semi-determinate, with a value report.  Shows a value-less
+ * <progress/>, but shows the value as a number in the dialog.
+ *
+ * This is useful in cases where the total number of items to retrieve
+ * from the server is unknown, but we know how many items we've
+ * retrieved thus far.  It helps to reinforce that something specific
+ * is happening, but we don't know when it will end.
+ *
+ * -- value is unset
+ * indeterminate: shows a generic value-less <progress/> with no 
+ * clear indication of progress.
+ */
+export class EgProgressDialogComponent extends EgDialogComponent {
+        
+    max: number;
+    value: number;
+
+    reset() {
+        delete this.max;
+        delete this.value;
+    }
+
+    hasValue(): boolean {
+        return Number.isInteger(this.value);
+    }
+
+    hasMax(): boolean {
+        return Number.isInteger(this.max);
+    }
+
+    percent(): number {
+        if (this.hasValue()  && 
+            this.hasMax()    && 
+            this.max > 0     &&
+            this.value <= this.max)
+            return Math.floor((this.value / this.max) * 100);
+        return 100;
+    }
+
+    // Set the current state of the progress bar.
+    update(args: {[key:string] : number}) {
+        if (args.max != undefined) 
+            this.max = args.max;
+        if (args.value != undefined) 
+            this.value = args.value;
+    }
+
+    // Increment the current value.  If no amount is specified,
+    // it increments by 1.  Calling increment() on an indetermite
+    // progress bar will force it to be a (semi-)determinate bar.
+    increment(amt: number) {
+        if (!Number.isInteger(amt)) amt = 1;
+
+        if (!this.hasValue())
+            this.value = 0;
+
+        this.value += amt;
+    }
+}
+
+
diff --git a/Open-ILS/src/eg2/src/app/share/dialog/prompt.component.html b/Open-ILS/src/eg2/src/app/share/dialog/prompt.component.html
new file mode 100644 (file)
index 0000000..1d7936b
--- /dev/null
@@ -0,0 +1,22 @@
+<ng-template #dialogContent>
+  <div class="modal-header bg-info">
+    <h4 class="modal-title">{{dialogTitle}}</h4>
+    <button type="button" class="close" 
+      i18n-aria-label aria-label="Close" 
+      (click)="dismiss('cross_click')">
+      <span aria-hidden="true">&times;</span>
+    </button>
+  </div>
+  <div class="modal-body">
+    <p>{{dialogBody}}</p>
+    <div class="text-center">
+        <input class="form-control" [(ngModel)]="promptValue"/>
+    </div>
+  </div>
+  <div class="modal-footer">
+    <button type="button" class="btn btn-success" 
+      (click)="close(promptValue)" i18n>Confirm</button>
+    <button type="button" class="btn btn-warning" 
+      (click)="dismiss('canceled')" i18n>Cancel</button>
+  </div>
+</ng-template>
diff --git a/Open-ILS/src/eg2/src/app/share/dialog/prompt.component.ts b/Open-ILS/src/eg2/src/app/share/dialog/prompt.component.ts
new file mode 100644 (file)
index 0000000..179efeb
--- /dev/null
@@ -0,0 +1,19 @@
+import {Component, Input, ViewChild, TemplateRef} from '@angular/core';
+import {EgDialogComponent} from '@eg/share/dialog/dialog.component';
+
+@Component({
+  selector: 'eg-prompt-dialog',
+  templateUrl: './prompt.component.html'
+})
+
+/**
+ * Promptation dialog that requests user input.
+ */
+export class EgPromptDialogComponent extends EgDialogComponent {
+    // What question are we asking?
+    @Input() public dialogBody: string;
+    // Value to return to the caller
+    @Input() public promptValue: string;
+}
+
+
diff --git a/Open-ILS/src/eg2/src/app/share/fm-editor/fm-editor.component.html b/Open-ILS/src/eg2/src/app/share/fm-editor/fm-editor.component.html
new file mode 100644 (file)
index 0000000..f3e7dad
--- /dev/null
@@ -0,0 +1,126 @@
+<ng-template #dialogContent>
+  <div class="modal-header bg-info">
+    <h4 class="modal-title" i18n>Record Editor: {{recordLabel}}</h4>
+    <button type="button" class="close" 
+      i18n-aria-label aria-label="Close" 
+      (click)="dismiss('cross_click')">
+      <span aria-hidden="true">&times;</span>
+    </button>
+  </div>
+  <div class="modal-body">
+    <form role="form" class="form-validated">
+      <div class="form-group row" *ngFor="let field of fields">
+        <div class="col-lg-3 offset-lg-1">
+          <label for="rec-{{field.name}}">{{field.label}}</label>
+        </div>
+        <div class="col-lg-7">
+
+          <span *ngIf="field.template">
+            <ng-container
+              *ngTemplateOutlet="field.template; context:customTemplateFieldContext(field)">
+            </ng-container> 
+          </span>
+
+          <span *ngIf="!field.template">
+
+            <span *ngIf="field.datatype == 'id' && !pkeyIsEditable">
+              {{record[field.name]()}}
+            </span>
+  
+            <input *ngIf="field.datatype == 'id' && pkeyIsEditable"
+              class="form-control"
+              name="{{field.name}}"
+              [readonly]="field.readOnly"
+              [required]="field.isRequired()"
+              [ngModel]="record[field.name]()"
+              (ngModelChange)="record[field.name]($event)"/>
+  
+            <input *ngIf="field.datatype == 'text'"
+              class="form-control"
+              name="{{field.name}}"
+              [readonly]="field.readOnly"
+              [required]="field.isRequired()"
+              [ngModel]="record[field.name]()"
+              (ngModelChange)="record[field.name]($event)"/>
+  
+            <input *ngIf="field.datatype == 'int'"
+              class="form-control"
+              type="number"
+              name="{{field.name}}"
+              [readonly]="field.readOnly"
+              [required]="field.isRequired()"
+              [ngModel]="record[field.name]()"
+              (ngModelChange)="record[field.name]($event)"/>
+  
+            <input *ngIf="field.datatype == 'float'"
+              class="form-control"
+              type="number" step="0.1"
+              name="{{field.name}}"
+              [readonly]="field.readOnly"
+              [required]="field.isRequired()"
+              [ngModel]="record[field.name]()"
+              (ngModelChange)="record[field.name]($event)"/>
+  
+            <span *ngIf="field.datatype == 'money'">
+              <!-- in read-only mode display the local-aware currency -->
+              <input *ngIf="field.readOnly"
+                class="form-control"
+                type="number" step="0.1"
+                name="{{field.name}}"
+                [readonly]="field.readOnly"
+                [required]="field.isRequired()"
+                [ngModel]="record[field.name]() | currency"/>
+  
+              <input *ngIf="!field.readOnly"
+                class="form-control"
+                type="number" step="0.1"
+                name="{{field.name}}"
+                [readonly]="field.readOnly"
+                [required]="field.isRequired()"
+                [ngModel]="record[field.name]()"
+                (ngModelChange)="record[field.name]($event)"/>
+            </span>
+  
+            <input *ngIf="field.datatype == 'bool'"
+              class="form-check-input"
+              type="checkbox"
+              name="{{field.name}}"
+              [readonly]="field.readOnly"
+              [ngModel]="record[field.name]()"
+              (ngModelChange)="record[field.name]($event)"/>
+  
+            <span *ngIf="field.datatype == 'link'"
+              [ngClass]="{nullable : !field.isRequired()}">
+              <select
+                class="form-control"
+                name="{{field.name}}"
+                [disabled]="field.readOnly"
+                [required]="field.isRequired()"
+                [ngModel]="record[field.name]()"
+                (ngModelChange)="record[field.name]($event)">
+                <option *ngFor="let item of field.linkedValues" 
+                  [value]="item.id">{{item.name}}</option>
+              </select>
+            </span>
+  
+            <eg-org-select *ngIf="field.datatype == 'org_unit'"
+              [placeholder]="field.label"
+              [applyDefault]="field.orgDefaultAllowed"
+              [initialOrgId]="record[field.name]()"
+              (onChange)="record[field.name]($event)">
+            </eg-org-select>
+
+          </span>
+        </div>
+      </div>
+    </form>
+  </div>
+  <div class="modal-footer">
+    <button type="button" class="btn btn-success" *ngIf="mode == 'view'"
+      (click)="close()" i18n>Close</button>
+    <button type="button" class="btn btn-info" *ngIf="mode != 'view'"
+      (click)="save()" i18n>Save</button>
+    <button type="button" class="btn btn-warning ml-2" *ngIf="mode != 'view'"
+      (click)="cancel()" i18n>Cancel</button>
+  </div>
+</ng-template>
diff --git a/Open-ILS/src/eg2/src/app/share/fm-editor/fm-editor.component.ts b/Open-ILS/src/eg2/src/app/share/fm-editor/fm-editor.component.ts
new file mode 100644 (file)
index 0000000..82f39be
--- /dev/null
@@ -0,0 +1,284 @@
+import {Component, OnInit, Input, 
+    Output, EventEmitter, TemplateRef} from '@angular/core';
+import {EgIdlService, EgIdlObject} from '@eg/core/idl.service';
+import {EgAuthService} from '@eg/core/auth.service';
+import {EgPcrudService} from '@eg/core/pcrud.service';
+import {EgDialogComponent} from '@eg/share/dialog/dialog.component';
+import {NgbModal, NgbModalOptions} from '@ng-bootstrap/ng-bootstrap';
+
+interface CustomFieldTemplate {
+    template: TemplateRef<any>,
+    
+    // Allow the caller to pass in a free-form context blob to 
+    // be addedto the caller's custom template context, along 
+    // with our stock context.
+    context?: {[fields: string]: any}
+}
+
+interface CustomFieldContext {
+    // Current create/edit/view record
+    record: EgIdlObject,
+
+    // IDL field definition blob
+    field: any, 
+
+    // additional context values passed via CustomFieldTemplate
+    [fields: string]: any;
+}
+
+@Component({
+  selector: 'fm-record-editor',
+  templateUrl: './fm-editor.component.html'
+})
+export class FmRecordEditorComponent 
+    extends EgDialogComponent implements OnInit {
+
+    // IDL class hint (e.g. "aou")
+    @Input() idlClass: string;
+
+    // mode: 'create' for creating a new record,
+    //       'update' for editing an existing record
+    //       'view' for viewing an existing record without editing
+    @Input() mode: 'create' | 'update' | 'view' = 'create';
+
+    // Record ID to view/update.  Value is dynamic.  Records are not
+    // fetched until .open() is called.
+    recId: any;
+    @Input() set recordId(id: any) {
+        if (id) this.recId = id;
+    }
+
+    // IDL record we are editing
+    // TODO: allow this to be update in real time by the caller?
+    record: EgIdlObject;
+
+    @Input() customFieldTemplates: 
+        {[fieldName:string] : CustomFieldTemplate} = {};
+
+    // list of fields that should not be displayed
+    @Input() hiddenFieldsList: string[] = [];
+    @Input() hiddenFields: string; // comma-separated string version
+
+    // list of fields that should always be read-only
+    @Input() readonlyFieldsList: string[] = [];
+    @Input() readonlyFields: string; // comma-separated string version
+
+    // list of required fields; this supplements what the IDL considers
+    // required
+    @Input() requiredFieldsList: string[] = [];
+    @Input() requiredFields: string; // comma-separated string version 
+
+    // list of org_unit fields where a default value may be applied by
+    // the org-select if no value is present.
+    @Input() orgDefaultAllowedList: string[] = [];
+    @Input() orgDefaultAllowed: string; // comma-separated string version
+
+    // hash, keyed by field name, of functions to invoke to check
+    // whether a field is required.  Each callback is passed the field
+    // name and the record and should return a boolean value. This
+    // supports cases where whether a field is required or not depends
+    // on the current value of another field.
+    @Input() isRequiredOverride: 
+        {[field: string] : (field: string, record: EgIdlObject) => boolean};
+
+    // IDL record display label.  Defaults to the IDL label.
+    @Input() recordLabel: string;
+
+    // Emit the modified object when the save action completes.
+    @Output() onSave$ = new EventEmitter<EgIdlObject>();
+
+    // Emit the original object when the save action is canceled.
+    @Output() onCancel$ = new EventEmitter<EgIdlObject>();
+
+    // Emit an error message when the save action fails.
+    @Output() onError$ = new EventEmitter<string>();
+
+    // IDL info for the the selected IDL class
+    idlDef: any;
+
+    // Can we edit the primary key?
+    pkeyIsEditable: boolean = false;
+
+    // List of IDL field definitions.  This is a subset of the full
+    // list of fields on the IDL, since some are hidden, virtual, etc.
+    fields: any[];
+
+    constructor(
+      private modal: NgbModal, // required for passing to parent
+      private idl: EgIdlService,
+      private auth: EgAuthService,
+      private pcrud: EgPcrudService) { 
+      super(modal) 
+    }
+    
+    // Avoid fetching data on init since that may lead to unnecessary
+    // data retrieval.
+    ngOnInit() {
+        this.listifyInputs();
+        this.idlDef = this.idl.classes[this.idlClass];
+        this.recordLabel = this.idlDef.label;
+    }
+
+    // Opening dialog, fetch data.
+    open(options?: NgbModalOptions): Promise<any> {
+        return this.initRecord().then(
+            ok => super.open(options),
+            err => console.warn(`Error fetching FM data: ${err}`)
+        );
+    }
+
+    // Translate comma-separated string versions of various inputs
+    // to arrays.
+    private listifyInputs() {
+        if (this.hiddenFields)
+            this.hiddenFieldsList = this.hiddenFields.split(/,/);
+        if (this.readonlyFields)
+            this.readonlyFieldsList = this.readonlyFields.split(/,/);
+        if (this.requiredFields)
+            this.requiredFieldsList = this.requiredFields.split(/,/);
+        if (this.orgDefaultAllowed)
+            this.orgDefaultAllowedList = this.orgDefaultAllowed.split(/,/);
+    }
+
+    private initRecord(): Promise<any> {
+
+        if (this.mode == 'update' || this.mode == 'view') {
+            return this.pcrud.retrieve(this.idlClass, this.recId)
+            .toPromise().then(rec => {
+
+                if (!rec) {
+                    return Promise.reject(`No '${this.idlClass}' 
+                        record found with id ${this.recId}`);
+                }
+
+                this.record = rec;
+                this.convertDatatypesToJs();
+                return this.getFieldList();
+            });
+        } 
+
+        // create a new record from scratch
+        this.pkeyIsEditable = !('pkey_sequence' in this.idlDef);
+        this.record = this.idl.create(this.idlClass);
+        return this.getFieldList();
+    }
+
+    // Modifies the FM record in place, replacing IDL-compatible values
+    // with native JS values.
+    private convertDatatypesToJs() {
+        this.idlDef.fields.forEach(field => {
+            if (field.datatype == 'bool') {
+                if (this.record[field.name]() == 't') {
+                    this.record[field.name](true);
+                } else if (this.record[field.name]() == 'f') {
+                    this.record[field.name](false);
+                }
+            }
+        });
+    }
+
+    // Modifies the provided FM record in place, replacing JS values
+    // with IDL-compatible values.
+    convertDatatypesToIdl(rec: EgIdlObject) {
+        var fields = this.idlDef.fields;
+        fields.forEach(field => {
+            if (field.datatype == 'bool') {
+                if (rec[field.name]() == true) {
+                    rec[field.name]('t');
+                } else if (rec[field.name]() == false) {
+                    rec[field.name]('f');
+                }
+            } else if (field.datatype == 'org_unit') {
+                let org = rec[field.name]();
+                if (org && typeof org == 'object') {
+                    rec[field.name](org.id());
+                }
+            }
+        });
+    }
+
+
+    private flattenLinkedValues(cls: string, list: EgIdlObject[]): any[] {
+        let idField = this.idl.classes[cls].pkey;
+        let selector = 
+            this.idl.classes[cls].field_map[idField].selector || idField;
+
+        return list.map(item => {
+            return {id: item[idField](), name: item[selector]()}
+        });
+    }
+
+    private getFieldList(): Promise<any> {
+
+        this.fields = this.idlDef.fields.filter(f => 
+            f.virtual != 'true' && 
+            !this.hiddenFieldsList.includes(f.name)
+        );
+
+        let promises = [];
+
+        this.fields.forEach(field => {
+            field.readOnly = this.mode == 'view' 
+                || this.readonlyFieldsList.includes(field.name);
+
+            if (this.isRequiredOverride && 
+                field.name in this.isRequiredOverride) {
+                field.isRequired = () => {
+                    return this.isRequiredOverride[field.name](field.name, this.record);
+                }
+            } else {
+                field.isRequired = () => {
+                    return field.required || 
+                        this.requiredFieldsList.includes(field.name);
+                }
+            }
+
+            if (field.datatype == 'link') {
+                promises.push(
+                    this.pcrud.retrieveAll(field.class, {}, {atomic : true})
+                    .toPromise().then(list => {
+                        field.linkedValues = 
+                            this.flattenLinkedValues(field.class, list);
+                    })
+                );
+            } else if (field.datatype == 'org_unit') {
+                field.orgDefaultAllowed = 
+                    this.orgDefaultAllowedList.includes(field.name);
+            }
+
+            if (this.customFieldTemplates[field.name]) {
+                field.template = this.customFieldTemplates[field.name].template;
+                field.context = this.customFieldTemplates[field.name].context;
+            }
+
+        });
+
+        // Wait for all network calls to complete
+        return Promise.all(promises);
+    }
+
+    // Returns a context object to be inserted into a custom 
+    // field template.
+    customTemplateFieldContext(fieldDef: any): CustomFieldContext {
+        return Object.assign(
+            {   record : this.record,
+                field: fieldDef // from this.fields
+            },  fieldDef.context || {}
+        );
+    }
+
+    save() {
+        let recToSave = this.idl.clone(this.record);
+        this.convertDatatypesToIdl(recToSave);
+        this.pcrud[this.mode]([recToSave]).toPromise().then(
+            result => this.close(result),
+            error  => this.dismiss(error)
+        );
+    }
+
+    cancel() {
+        this.dismiss('canceled');
+    }
+}
+
+
diff --git a/Open-ILS/src/eg2/src/app/share/grid/grid-body.component.html b/Open-ILS/src/eg2/src/app/share/grid/grid-body.component.html
new file mode 100644 (file)
index 0000000..7a86798
--- /dev/null
@@ -0,0 +1,18 @@
+
+<div class="eg-grid-row eg-grid-body-row"
+  [ngClass]="{'eg-grid-row-selected': selector[idx]}"
+  *ngFor="let row of dataSource.getPageOfRows(pager); let idx = index">
+
+  <div class="eg-grid-cell eg-grid-checkbox-cell eg-grid-cell-skinny">
+    <input type='checkbox' [(ngModel)]="selector[idx]">
+  </div>
+  <div class="eg-grid-cell eg-grid-header-cell eg-grid-number-cell eg-grid-cell-skinny">
+    {{pager.rowNumber(idx)}}
+  </div>
+  <div class="eg-grid-cell eg-grid-body-cell" [ngStyle]="{flex:col.flex}"
+    *ngFor="let col of columnSet.displayColumns()">
+    {{getDisplayValue(row, col)}}
+  </div>
+
+<div>
+
diff --git a/Open-ILS/src/eg2/src/app/share/grid/grid-body.component.ts b/Open-ILS/src/eg2/src/app/share/grid/grid-body.component.ts
new file mode 100644 (file)
index 0000000..364e21e
--- /dev/null
@@ -0,0 +1,31 @@
+import {Component, Input, OnInit, Host, TemplateRef} from '@angular/core';
+import {EgGridService, EgGridColumn, EgGridColumnSet} from './grid.service';
+import {EgGridDataSource} from './grid-data-source';
+import {Pager} from '@eg/share/util/pager';
+
+@Component({
+  selector: 'eg-grid-body',
+  templateUrl: 'grid-body.component.html'
+})
+
+export class EgGridBodyComponent implements OnInit {
+
+    @Input() pager: Pager;
+    @Input() dataSource: EgGridDataSource;
+    @Input() columnSet: EgGridColumnSet;
+    @Input() selector: {[idx:number] : boolean};
+
+    constructor(private gridSvc: EgGridService) { }
+
+    ngOnInit() {
+
+        // fetch the first page of data
+        this.dataSource.requestPage(this.pager);
+    }
+
+    getDisplayValue(row: any, col: EgGridColumn): string {
+        return this.gridSvc.getRowColumnValue(row, col);
+    }
+
+}
+
diff --git a/Open-ILS/src/eg2/src/app/share/grid/grid-column.component.ts b/Open-ILS/src/eg2/src/app/share/grid/grid-column.component.ts
new file mode 100644 (file)
index 0000000..41d8d04
--- /dev/null
@@ -0,0 +1,42 @@
+import {Component, Input, OnInit, Host, TemplateRef} from '@angular/core';
+import {EgGridService, EgGridColumn, EgGridColumnSet} from './grid.service';
+import {EgGridComponent} from './grid.component';
+
+@Component({
+  selector: 'eg-grid-column',
+  template: '<ng-template></ng-template>'
+})
+
+export class EgGridColumnComponent implements OnInit {
+
+    // Note most input fields should match class fields for EgGridColumn
+    @Input() name: string;
+    @Input() path: string;
+    @Input() label: string;
+    @Input() flex: number;
+    @Input() hidden: boolean = false;
+    @Input() cellTemplate: TemplateRef<any>;
+
+    // get a reference to our container grid.
+    constructor(
+        private gridSvc: EgGridService,
+        @Host() private grid: EgGridComponent) {}
+
+    ngOnInit() {
+
+        if (!this.grid) {
+            console.warn('EgGridColumnComponent needs a [grid]');
+            return;
+        }
+
+        let col = new EgGridColumn();
+        col.name = this.name;
+        col.path = this.path;
+        col.label = this.label || this.name;
+        col.flex = this.flex || 2;
+        col.hidden = this.hidden;
+        col.cellTemplate = this.cellTemplate;
+        this.grid.columnSet.add(col);
+    }
+}
+
diff --git a/Open-ILS/src/eg2/src/app/share/grid/grid-data-source.ts b/Open-ILS/src/eg2/src/app/share/grid/grid-data-source.ts
new file mode 100644 (file)
index 0000000..66da827
--- /dev/null
@@ -0,0 +1,64 @@
+import {EventEmitter} from '@angular/core';
+import {Observable} from 'rxjs/Rx';
+import {Pager} from '@eg/share/util/pager';
+
+export class EgGridDataSource {
+
+    data: any[];
+    pager: Pager;
+    allRowsRetrieved: boolean;
+    getRows: (pager: Pager) => Observable<any>;
+
+    constructor() {
+        this.data = [];
+        this.allRowsRetrieved = false;
+    }
+
+    setAllRetrieved() {
+        this.allRowsRetrieved = true;
+        this.pager.resultCount = this.data.length;
+    }
+
+    // called from the template -- no data fetching
+    getPageOfRows(pager: Pager): any[] {
+        if (this && this.data) {
+            return this.data.slice(
+                pager.offset, pager.limit + pager.offset);
+        }
+        return [];
+    }
+
+    // called on initial component load and user action (e.g. paging, sorting).
+    requestPage(pager: Pager) {
+
+        // see if the page of data is already present in the data
+        if (this.getPageOfRows(pager).length > 0) return;
+
+        if (this.allRowsRetrieved) return;
+
+        if (!this.getRows) return;
+
+        let idx = pager.offset;
+        this.getRows(pager).subscribe(
+            row => this.data[idx++] = row,
+            err => console.error(`grid getRows() error ${err}`),
+            ()  => this.checkAllRetrieved(pager, idx)
+        );
+    }
+
+    // See if the last getRows() call resulted in the final set of data.
+    checkAllRetrieved(pager: Pager, idx: number) {
+        if (this.allRowsRetrieved) return;
+
+        if (idx == 0 || idx < (pager.limit + pager.offset)) {
+            // last query returned nothing or less than one page.
+            // confirm we have all of the preceding pages.
+            if (!this.data.includes(undefined)) {
+                this.allRowsRetrieved = true;
+                pager.resultCount = this.data.length;
+            }
+        }
+    }
+}
+
+
diff --git a/Open-ILS/src/eg2/src/app/share/grid/grid-header.component.html b/Open-ILS/src/eg2/src/app/share/grid/grid-header.component.html
new file mode 100644 (file)
index 0000000..2b8e4a8
--- /dev/null
@@ -0,0 +1,15 @@
+
+<div class="eg-grid-row eg-grid-header-row">
+  <div class="eg-grid-cell eg-grid-header-cell eg-grid-checkbox-cell eg-grid-cell-skinny">
+    <input type='checkbox'> <!-- add click handlers ; shared selector mod -->
+  </div>
+  <div class="eg-grid-cell eg-grid-header-cell eg-grid-number-cell eg-grid-cell-skinny">
+    #
+  </div>
+
+    <div *ngFor="let col of columnSet.displayColumns()" 
+      class="eg-grid-cell eg-grid-header-cell" [ngStyle]="{flex:col.flex}">
+      {{col.label}}
+    </div>
+</div>
+
diff --git a/Open-ILS/src/eg2/src/app/share/grid/grid-header.component.ts b/Open-ILS/src/eg2/src/app/share/grid/grid-header.component.ts
new file mode 100644 (file)
index 0000000..254c538
--- /dev/null
@@ -0,0 +1,19 @@
+import {Component, Input, OnInit} from '@angular/core';
+import {EgGridService, EgGridColumn, EgGridColumnSet} from './grid.service';
+
+@Component({
+  selector: 'eg-grid-header',
+  templateUrl: './grid-header.component.html'
+})
+
+export class EgGridHeaderComponent implements OnInit {
+
+    @Input() columnSet: EgGridColumnSet;
+    @Input() selected: {[idx:number] : boolean};
+
+    constructor(private gridSvc: EgGridService) { }
+
+    ngOnInit() {
+    }
+}
+
diff --git a/Open-ILS/src/eg2/src/app/share/grid/grid-toolbar.component.html b/Open-ILS/src/eg2/src/app/share/grid/grid-toolbar.component.html
new file mode 100644 (file)
index 0000000..4604fd8
--- /dev/null
@@ -0,0 +1,25 @@
+
+<div class="eg-grid-toolbar">
+
+  <!-- push everything else to the right -->
+  <div class="flex-1"></div>
+
+  <div class="btn-toolbar">
+    <div class="btn-grp">
+      <button [disabled]="pager.isFirstPage()" type="button" class="btn btn-light" (click)="pager.toFirst()">
+        <span title="First Page" i18n-title class="material-icons">first_page</span>
+      </button>
+      <button [disabled]="pager.isFirstPage()" type="button" class="btn btn-light" (click)="pager.decrement()">
+        <span title="Previous Page" i18n-title class="material-icons">keyboard_arrow_left</span>
+      </button>
+      <button [disabled]="pager.isLastPage()" type="button" class="btn btn-light" (click)="pager.increment()">
+        <span title="Next Page" i18n-title class="material-icons">keyboard_arrow_right</span>
+      </button>
+      <button [disabled]="pager.isLastPage()" type="button" class="btn btn-light" (click)="pager.toLast()">
+        <span title="First Page" i18n-title class="material-icons">last_page</span>
+      </button>
+    </div>
+  </div>
+
+<div>
+
diff --git a/Open-ILS/src/eg2/src/app/share/grid/grid-toolbar.component.ts b/Open-ILS/src/eg2/src/app/share/grid/grid-toolbar.component.ts
new file mode 100644 (file)
index 0000000..01b5880
--- /dev/null
@@ -0,0 +1,23 @@
+import {Component, Input, OnInit} from '@angular/core';
+import {EgGridDataSource} from './grid-data-source';
+import {Pager} from '@eg/share/util/pager';
+
+@Component({
+  selector: 'eg-grid-toolbar',
+  templateUrl: 'grid-toolbar.component.html'
+})
+
+export class EgGridToolbarComponent implements OnInit {
+
+    @Input() dataSource: EgGridDataSource;
+    @Input() pager: Pager;
+
+    ngOnInit() {
+
+        // listen for pagination changes
+        this.pager.onChange$.subscribe(
+            val => this.dataSource.requestPage(this.pager));
+    }
+}
+
+
diff --git a/Open-ILS/src/eg2/src/app/share/grid/grid.component.css b/Open-ILS/src/eg2/src/app/share/grid/grid.component.css
new file mode 100644 (file)
index 0000000..8969cb1
--- /dev/null
@@ -0,0 +1,42 @@
+
+.eg-grid {
+    width: 100%;
+    color: rgba(0,0,0,.87); 
+}
+    
+.eg-grid-row {
+    display: flex;
+    border-bottom: 1px solid rgba(0,0,0,.12);
+    padding-left: 10px;
+    padding-right: 10px;
+}
+
+.eg-grid-header-row {
+}
+
+.eg-grid-body-row {
+}
+
+.eg-grid-header-cell {
+    font-weight: bold;
+}
+
+.eg-grid-cell {
+    flex: 1; /* applied per column */
+    padding: 6px;
+}
+
+.eg-grid-body-cell {
+}
+
+.eg-grid-toolbar {
+  display: flex;
+}
+
+.eg-grid-cell-skinny {
+  width: 2.2em;
+  text-align: center;
+  flex: none;
+}
+
+
diff --git a/Open-ILS/src/eg2/src/app/share/grid/grid.component.html b/Open-ILS/src/eg2/src/app/share/grid/grid.component.html
new file mode 100644 (file)
index 0000000..3d0434f
--- /dev/null
@@ -0,0 +1,13 @@
+
+<div class="eg-grid">
+  <eg-grid-toolbar [dataSource]="dataSource" [pager]="pager">
+  </eg-grid-toolbar>
+  <eg-grid-header [columnSet]="columnSet"></eg-grid-header>
+  <eg-grid-body 
+    [columnSet]="columnSet" 
+    [dataSource]="dataSource"
+    [pager]="pager"
+    [selector]="selector">
+  </eg-grid-body>
+</div>
+
diff --git a/Open-ILS/src/eg2/src/app/share/grid/grid.component.ts b/Open-ILS/src/eg2/src/app/share/grid/grid.component.ts
new file mode 100644 (file)
index 0000000..ff8dd86
--- /dev/null
@@ -0,0 +1,36 @@
+import {Component, Input, OnInit, ViewEncapsulation} from '@angular/core';
+import {EgGridDataSource} from './grid-data-source';
+import {EgIdlService} from '@eg/core/idl.service';
+import {EgOrgService} from '@eg/core/org.service';
+import {Pager} from '@eg/share/util/pager';
+import {EgGridService, EgGridColumnSet} from '@eg/share/grid/grid.service';
+
+@Component({
+  selector: 'eg-grid',
+  templateUrl: './grid.component.html',
+  styleUrls: ['grid.component.css'],
+  // share grid css globally once imported.
+  encapsulation: ViewEncapsulation.None
+})
+
+export class EgGridComponent implements OnInit {
+
+    @Input() dataSource: EgGridDataSource;
+    @Input() idlClass: string;
+
+    pager: Pager;
+    columnSet: EgGridColumnSet;
+    selector: {[idx:number] : boolean};
+    
+    constructor(private gridSvc: EgGridService) {
+        this.pager = new Pager();
+        this.pager.limit = 10; // TODO
+        this.selector = {};
+    }
+
+    ngOnInit() {
+        this.columnSet = this.gridSvc.initializeColumnSet(this.idlClass);
+    }
+}
+
+
diff --git a/Open-ILS/src/eg2/src/app/share/grid/grid.module.ts b/Open-ILS/src/eg2/src/app/share/grid/grid.module.ts
new file mode 100644 (file)
index 0000000..a664b43
--- /dev/null
@@ -0,0 +1,36 @@
+import {NgModule} from '@angular/core';
+import {CommonModule} from '@angular/common';
+import {FormsModule} from '@angular/forms';
+import {EgGridComponent} from './grid.component';
+import {EgGridColumnComponent} from './grid-column.component';
+import {EgGridHeaderComponent} from './grid-header.component';
+import {EgGridBodyComponent} from './grid-body.component';
+import {EgGridToolbarComponent} from './grid-toolbar.component';
+import {EgGridService} from './grid.service';
+
+@NgModule({
+    declarations: [
+        // public + internal components
+        EgGridComponent,
+        EgGridColumnComponent,
+        EgGridHeaderComponent,
+        EgGridBodyComponent,
+        EgGridToolbarComponent
+    ],
+    imports: [
+        CommonModule,
+        FormsModule
+    ],
+    exports: [
+        // public components
+        EgGridComponent,
+        EgGridColumnComponent,
+    ],
+    providers: [
+        EgGridService
+    ]
+})
+
+export class EgGridModule {
+
+}
diff --git a/Open-ILS/src/eg2/src/app/share/grid/grid.service.ts b/Open-ILS/src/eg2/src/app/share/grid/grid.service.ts
new file mode 100644 (file)
index 0000000..ead30fa
--- /dev/null
@@ -0,0 +1,91 @@
+import {Injectable, TemplateRef} from '@angular/core';
+import {EgIdlService, EgIdlObject} from '@eg/core/idl.service';
+import {EgOrgService} from '@eg/core/org.service';
+import {EgPcrudService} from '@eg/core/pcrud.service';
+
+
+@Injectable()
+export class EgGridService {
+
+    constructor(
+        private idl: EgIdlService,
+        private org: EgOrgService,
+        private pcrud: EgPcrudService
+    ) {
+    }
+
+    getRowColumnValue(row: any, col: EgGridColumn): string {
+        if (row[col.name] === undefined || row[col.name] === null) 
+            return '';
+
+        if (col.idlFieldDef) 
+            return this.getRowColumnIdlValue(row, col);
+
+        if (typeof row[col.name] == 'function') {
+            let val = row[col.name]();
+            if (val === undefined || val === null) return '';
+            return val+'';
+        }
+
+        return row[col.name]+'';
+    }
+
+    getRowColumnIdlValue(row: any, col: EgGridColumn): string {
+        let val = row[col.name]();
+        if (val === undefined || val === null) return '';
+        return val+'';
+    }
+
+    initializeColumnSet(idlClass?: string): EgGridColumnSet {
+        let columnSet = new EgGridColumnSet();
+
+        // generate columns for all non-virtual fields on the IDL class
+        if (idlClass) {
+            this.idl.classes[idlClass].fields.forEach(field => {
+                if (field.virtual) return;
+                let col = new EgGridColumn();
+                col.name = field.name;
+                col.label = field.label || field.name;
+                col.idlFieldDef = field;
+                columnSet.add(col);
+            });
+        }
+
+        return columnSet;
+    }
+}
+
+
+export class EgGridColumn {
+    name: string;
+    path: string;
+    label: string;
+    flex: number;
+    hidden: boolean;
+    idlClass: string;
+    idlFieldDef: any;
+    cellTemplate: TemplateRef<any>;
+}
+
+
+export class EgGridColumnSet {
+    columns: EgGridColumn[];
+
+    constructor() {
+        this.columns = [];
+    }
+
+    add(col: EgGridColumn) {
+        // avoid dupes
+        if (this.columns.filter(c => c.name == col.name).length) return;
+
+        this.columns.push(col);
+    }
+
+    displayColumns(): EgGridColumn[] {
+        return this.columns.filter(c => !c.hidden);
+    }
+}
+
+
+
diff --git a/Open-ILS/src/eg2/src/app/share/org-select/org-select.component.html b/Open-ILS/src/eg2/src/app/share/org-select/org-select.component.html
new file mode 100644 (file)
index 0000000..2a4bd3a
--- /dev/null
@@ -0,0 +1,17 @@
+
+<!-- todo disabled -->
+<ng-template #displayTemplate let-r="result">
+{{r.label}}
+</ng-template>
+
+<input type="text" 
+  class="form-control"
+  [placeholder]="placeholder"
+  [(ngModel)]="selected" 
+  [ngbTypeahead]="filter"
+  [resultTemplate]="displayTemplate"
+  [inputFormatter]="formatter"
+  (click)="click$.next($event.target.value)"
+  (selectItem)="orgChanged($event)"
+  #instance="ngbTypeahead"
+/>
diff --git a/Open-ILS/src/eg2/src/app/share/org-select/org-select.component.ts b/Open-ILS/src/eg2/src/app/share/org-select/org-select.component.ts
new file mode 100644 (file)
index 0000000..627dd4e
--- /dev/null
@@ -0,0 +1,152 @@
+import {Component, OnInit, Input, Output, ViewChild, EventEmitter} from '@angular/core';
+import {Observable} from 'rxjs/Observable';
+import {map, debounceTime} from 'rxjs/operators';
+import {Subject} from 'rxjs/Subject'; 
+import {EgAuthService} from '@eg/core/auth.service';
+import {EgStoreService} from '@eg/core/store.service';
+import {EgOrgService} from '@eg/core/org.service';
+import {EgIdlObject} from '@eg/core/idl.service';
+import {NgbTypeahead, NgbTypeaheadSelectItemEvent} 
+    from '@ng-bootstrap/ng-bootstrap';
+
+// Use a unicode char for spacing instead of ASCII=32 so the browser 
+// won't collapse the nested display entries down to a single space.
+const PAD_SPACE: string = ' '; // U+2007 
+
+interface OrgDisplay {
+  id: number;
+  label: string;
+  disabled: boolean;
+}
+
+@Component({
+  selector: 'eg-org-select',
+  templateUrl: './org-select.component.html'
+})
+export class EgOrgSelectComponent implements OnInit {
+
+    selected: OrgDisplay;
+    hidden: number[] = [];
+    disabled: number[] = [];
+    click$ = new Subject<string>();
+    startOrg: EgIdlObject;
+
+    @ViewChild('instance') instance: NgbTypeahead;
+
+    // Placeholder text for selector input
+    @Input() placeholder: string = '';
+    @Input() stickySetting: string;
+
+    // Org unit field displayed in the selector
+    @Input() displayField: string = 'shortname';
+
+    // Apply a default org unit value when none is set.
+    // First tries workstation org unit, then user home org unit.
+    // An onChange event WILL be generated when a default is applied.
+    @Input() applyDefault: boolean = false;
+
+    // List of org unit IDs to exclude from the selector
+    @Input() set hideOrgs(ids: number[]) {
+        if (ids) this.hidden = ids;
+    }
+
+    // List of org unit IDs to disable in the selector
+    @Input() set disableOrgs(ids: number[]) {
+        if (ids) this.disabled = ids;
+    }
+
+    // Apply an org unit value at load time.  
+    // This will NOT result in an onChange event.
+    @Input() set initialOrg(org: EgIdlObject) {
+        if (org) this.startOrg = org;
+    }
+
+    // Apply an org unit value by ID at load time.  
+    // This will NOT result in an onChange event.
+    @Input() set initialOrgId(id: number) {
+        if (id) this.startOrg = this.org.get(id);
+    }
+
+    // Modify the selected org unit via data binding.
+    // This WILL result in an onChange event firing.
+    @Input() set applyOrg(org: EgIdlObject) {
+        if (org) this.selected = this.formatForDisplay(org);
+    }
+
+    // Modify the selected org unit by ID via data binding.
+    // This WILL result in an onChange event firing.
+    @Input() set applyOrgId(id: number) {
+      if (id) this.selected = this.formatForDisplay(this.org.get(id));
+    }
+
+    // Emitted when the org unit value is changed via the selector.
+    // Does not fire on initialOrg
+    @Output() onChange = new EventEmitter<EgIdlObject>();
+
+    constructor(
+      private auth: EgAuthService,
+      private store: EgStoreService,
+      private org: EgOrgService 
+    ) {}
+    
+    ngOnInit() {
+
+        // Apply a default org unit if desired and possible.
+        if (!this.startOrg && this.applyDefault && this.auth.user()) {
+            // note: ws_ou defaults to home_ou on the server
+            // when when no workstation is used
+            this.startOrg = this.org.get(this.auth.user().ws_ou());
+            this.selected = this.formatForDisplay(
+                this.org.get(this.auth.user().ws_ou())
+            );
+
+            // avoid notifying mid-digest
+            setTimeout(() => this.onChange.emit(this.startOrg), 0);
+        }
+
+        if (this.startOrg) {
+            this.selected = this.formatForDisplay(this.startOrg);
+        }
+    }
+
+    // Format for display in the selector drop-down and input.
+    formatForDisplay(org: EgIdlObject): OrgDisplay {
+        return {
+            id : org.id(),
+            label : PAD_SPACE.repeat(org.ou_type().depth()) 
+              + org[this.displayField](),
+            disabled : false
+        };
+    }
+
+    // Fired by the typeahead to inform us of a change.
+    orgChanged(selEvent: NgbTypeaheadSelectItemEvent) {
+        this.onChange.emit(this.org.get(selEvent.item.id));
+    }
+
+    // Remove the tree-padding spaces when matching.
+    formatter = (result: OrgDisplay) => result.label.trim();
+
+    filter = (text$: Observable<string>): Observable<OrgDisplay[]> => {
+        return text$
+            .debounceTime(200)
+            .distinctUntilChanged()
+            .merge(this.click$.filter(() => !this.instance.isPopupOpen()))
+            .map(term => {
+
+                return this.org.list().filter(org => {
+
+                    // Find orgs matching the search term
+                    return org[this.displayField]()
+                      .toLowerCase().indexOf(term.toLowerCase()) > -1
+
+                }).filter(org => { // Exclude hidden orgs
+                    return this.hidden.filter(
+                        id => {return org.id() == id}).length == 0;
+
+                }).map(org => {return this.formatForDisplay(org)})
+            });
+    }
+}
+
+
diff --git a/Open-ILS/src/eg2/src/app/share/string/string.component.ts b/Open-ILS/src/eg2/src/app/share/string/string.component.ts
new file mode 100644 (file)
index 0000000..8c81898
--- /dev/null
@@ -0,0 +1,54 @@
+/*j
+ * <eg-string #helloStr text="Hello, {{name}}" i18n-text></eg-string>
+ *
+ * import {EgStringComponent} from '@eg/share/string.component';
+ * @ViewChild('helloStr') private helloStr: EgStringComponent;
+ * ...
+ * this.helloStr.currrent().then(s => console.log(s));
+ *
+ */
+import {Component, Input, OnInit, ElementRef, TemplateRef} from '@angular/core';
+import {EgStringService} from '@eg/share/string/string.service';
+
+@Component({
+  selector: 'eg-string',
+  template: `
+    <span style='display:none'>
+    <ng-container *ngTemplateOutlet="template; context:ctx"></ng-container>
+    </span>
+  `
+})
+
+export class EgStringComponent implements OnInit {
+
+    @Input() key: string;
+    @Input() ctx: any;
+    @Input() template: TemplateRef<any>;
+
+    constructor(private elm: ElementRef, private strings: EgStringService) {
+        this.elm = elm;
+        this.strings = strings;
+    }
+
+    ngOnInit() {
+        // No key means it's an unregistered (likely static) string
+        // that does not need interpolation.
+        if (this.key) {
+            this.strings.register({
+                key: this.key, 
+                resolver: (ctx:any) => this.current(ctx)
+            });
+        }
+    }
+
+
+    // Apply the new context if provided, give our container a 
+    // chance to update, then resolve with the current string.
+    current(ctx?: any): Promise<string> {
+        if (ctx) this.ctx = ctx;
+        return new Promise(resolve => {
+            setTimeout(() => resolve(this.elm.nativeElement.textContent));
+        });
+    }
+}
+
diff --git a/Open-ILS/src/eg2/src/app/share/string/string.service.ts b/Open-ILS/src/eg2/src/app/share/string/string.service.ts
new file mode 100644 (file)
index 0000000..1af8083
--- /dev/null
@@ -0,0 +1,27 @@
+import {Injectable} from '@angular/core';
+
+interface EgStringAssignment {
+    key: string,     // keyboard command
+    resolver: (ctx:any) => Promise<string>
+};
+
+@Injectable()
+export class EgStringService {
+
+    strings: {[key:string] : EgStringAssignment} = {};
+
+    constructor() {}
+
+    register(assn: EgStringAssignment) {
+        this.strings[assn.key] = assn;
+    }
+
+    interpolate(key: string, ctx?: any): Promise<string> {
+        if (!this.strings[key]) 
+            return Promise.reject('No Such String');
+        return this.strings[key].resolver(ctx);
+    }
+
+}
+
diff --git a/Open-ILS/src/eg2/src/app/share/toast/toast.component.css b/Open-ILS/src/eg2/src/app/share/toast/toast.component.css
new file mode 100644 (file)
index 0000000..1f70349
--- /dev/null
@@ -0,0 +1,11 @@
+#eg-toast-container {
+    min-width: 250px;
+    text-align: center;
+    border-radius: 2px;
+    padding: 10px;
+    position: fixed;
+    z-index: 1;
+    right: 15px;
+    bottom: 5px;
+}
+
diff --git a/Open-ILS/src/eg2/src/app/share/toast/toast.component.html b/Open-ILS/src/eg2/src/app/share/toast/toast.component.html
new file mode 100644 (file)
index 0000000..6aa1545
--- /dev/null
@@ -0,0 +1,3 @@
+<div id="eg-toast-container" *ngIf="message">
+  <ngb-alert [type]="message.style" (close)="dismiss(message)">{{message.text}}</ngb-alert>
+</div>
diff --git a/Open-ILS/src/eg2/src/app/share/toast/toast.component.ts b/Open-ILS/src/eg2/src/app/share/toast/toast.component.ts
new file mode 100644 (file)
index 0000000..eebe625
--- /dev/null
@@ -0,0 +1,43 @@
+import {Component, Input, OnInit, ViewChild} from '@angular/core';
+import {EgToastService, EgToastMessage} from '@eg/share/toast/toast.service';
+
+const EG_TOAST_TIMEOUT = 3000;
+
+@Component({
+  selector: 'eg-toast',
+  templateUrl: './toast.component.html',
+  styleUrls: ['./toast.component.css']
+})
+export class EgToastComponent implements OnInit {
+
+    message: EgToastMessage;
+
+    // track the most recent timeout event
+    timeout: any;
+
+       constructor(private toast: EgToastService) {
+    }
+
+    ngOnInit() {
+        this.toast.messages$.subscribe(msg => this.show(msg));
+    }
+
+    show(msg: EgToastMessage) {
+        this.dismiss(this.message);
+        this.message = msg;
+        this.timeout = setTimeout(
+            () => this.dismiss(this.message), 
+            EG_TOAST_TIMEOUT
+        );
+    }
+
+    dismiss(msg: EgToastMessage) {
+        this.message = null;
+        if (this.timeout) {
+            clearTimeout(this.timeout);
+            this.timeout = null;
+        }
+    }
+}
+
+
diff --git a/Open-ILS/src/eg2/src/app/share/toast/toast.service.ts b/Open-ILS/src/eg2/src/app/share/toast/toast.service.ts
new file mode 100644 (file)
index 0000000..9692c13
--- /dev/null
@@ -0,0 +1,39 @@
+import {Injectable, EventEmitter} from '@angular/core';
+
+export interface EgToastMessage {
+    text: string,
+    style: string 
+};
+
+@Injectable()
+export class EgToastService {
+
+    messages$: EventEmitter<EgToastMessage>;
+
+    constructor() {
+        this.messages$ = new EventEmitter<EgToastMessage>();
+    }
+
+    sendMessage(msg: EgToastMessage) {
+        this.messages$.emit(msg);
+    }
+
+    success(text: string) {
+        this.sendMessage({text: text, style: 'success'});
+    }
+
+    info(text: string) {
+        this.sendMessage({text: text, style: 'info'});
+    }
+
+    warning(text: string) {
+        this.sendMessage({text: text, style: 'warning'});
+    }
+
+    danger(text: string) {
+        this.sendMessage({text: text, style: 'danger'});
+    }
+
+    // Others?
+}
+
diff --git a/Open-ILS/src/eg2/src/app/share/util/audio.service.ts b/Open-ILS/src/eg2/src/app/share/util/audio.service.ts
new file mode 100644 (file)
index 0000000..971fe7e
--- /dev/null
@@ -0,0 +1,78 @@
+/**
+ * Plays audio files (alerts, generally) by key name.  Each sound uses a 
+ * dot-path to indicate  the sound.  
+ *
+ * For example:
+ *
+ * this.audio.play('warning.checkout.no_item');
+ *
+ * URLs are tested in the following order until an audio file is found
+ * or no other paths are left to check.
+ *
+ * /audio/notifications/warning/checkout/not_found.wav
+ * /audio/notifications/warning/checkout.wav
+ * /audio/notifications/warning.wav
+ *
+ * Files are only played when sounds are configured to play via 
+ * workstation settings.
+ */
+import {Injectable, EventEmitter} from '@angular/core';
+import {EgStoreService} from '@eg/core/store.service';
+const AUDIO_BASE_URL = '/audio/notifications/';
+
+@Injectable()
+export class EgAudioService {
+
+    // map of requested audio path to resolved path
+    private urlCache: {[path:string] : string} = {};
+
+    constructor(private store: EgStoreService) {}
+
+    play(path: string): void {
+        if (path) {
+            this.playUrl(path, path);
+        }
+    }
+
+    playUrl(path: string, origPath: string): void {
+        //console.debug(`audio: playUrl(${path}, ${origPath})`);
+
+        this.store.getItem('eg.audio.disable').then(audioDisabled => {
+            if (audioDisabled) return;
+        
+            let url = this.urlCache[path] || 
+                AUDIO_BASE_URL + path.replace(/\./g, '/') + '.wav';
+
+            let player = new Audio(url);
+
+            player.onloadeddata = () => {
+                this.urlCache[origPath] = url;
+                player.play();
+                console.debug(`audio: ${url}`);
+            };
+
+            if (this.urlCache[path]) {
+                // when serving from the cache, avoid secondary URL lookups.
+                return;
+            }
+
+            player.onerror = () => {
+                // Unable to play path at the requested URL.
+        
+                if (!path.match(/\./)) {
+                    // all fall-through options have been exhausted.
+                    // No path to play.
+                    console.warn(
+                        "No suitable URL found for path '" + origPath + "'");
+                    return;
+                }
+
+                // Fall through to the next (more generic) option
+                path = path.replace(/\.[^\.]+$/, '');
+                this.playUrl(path, origPath);
+            }
+        });
+    }
+}
+
+
diff --git a/Open-ILS/src/eg2/src/app/share/util/pager.ts b/Open-ILS/src/eg2/src/app/share/util/pager.ts
new file mode 100644 (file)
index 0000000..524e178
--- /dev/null
@@ -0,0 +1,70 @@
+import {EventEmitter} from '@angular/core';
+
+/**
+ * Utility class for manage paged information.
+ */
+export class Pager {
+    offset: number = 0;
+    limit: number = null;
+    resultCount: number;
+    onChange$: EventEmitter<number>;
+
+    constructor() {
+        this.onChange$ = new EventEmitter<number>();
+    }
+
+    isFirstPage(): boolean {
+        return this.offset == 0;
+    }
+
+    isLastPage(): boolean {
+        return this.currentPage() == this.pageCount();
+    }
+
+    currentPage(): number {
+        return Math.floor(this.offset / this.limit) + 1
+    }
+
+    increment(): void {
+        this.setPage(this.currentPage() + 1);
+    }
+
+    decrement(): void {
+        this.setPage(this.currentPage() - 1);
+    }
+
+    toFirst() {
+        if (!this.isFirstPage())
+            this.setPage(1);
+    }
+
+    toLast() {
+        if (!this.isLastPage())
+            this.setPage(this.pageCount());
+    }
+
+    setPage(page: number): void {
+        this.offset = (this.limit * (page - 1));
+        this.onChange$.emit(this.offset);
+    }
+
+    pageCount(): number {
+        let pages = this.resultCount / this.limit;
+        if (Math.floor(pages) < pages)
+            pages = Math.floor(pages) + 1;
+        return pages;
+    }
+
+    pageList(): number[] {
+        let list = [];
+        for(let i = 1; i <= this.pageCount(); i++)
+            list.push(i);
+        return list;
+    }
+
+    // Given a zero-based page-specific offset, return the where in the
+    // entire data set the row lives, 1-based for UI friendliness.
+    rowNumber(offset: number): number {
+        return this.offset + offset + 1;
+    }
+}
diff --git a/Open-ILS/src/eg2/src/app/staff/admin/routing.module.ts b/Open-ILS/src/eg2/src/app/staff/admin/routing.module.ts
new file mode 100644 (file)
index 0000000..4e4ef09
--- /dev/null
@@ -0,0 +1,17 @@
+import {NgModule} from '@angular/core';
+import {RouterModule, Routes} from '@angular/router';
+
+const routes: Routes = [{ 
+  path: '',
+  children : [{
+    path: 'workstation',
+    loadChildren: '@eg/staff/admin/workstation/routing.module#EgAdminWsRoutingModule'
+  }]
+}];
+
+@NgModule({
+  imports: [RouterModule.forChild(routes)],
+  exports: [RouterModule]
+})
+
+export class EgAdminRoutingModule {}
diff --git a/Open-ILS/src/eg2/src/app/staff/admin/workstation/routing.module.ts b/Open-ILS/src/eg2/src/app/staff/admin/workstation/routing.module.ts
new file mode 100644 (file)
index 0000000..bd300d7
--- /dev/null
@@ -0,0 +1,14 @@
+import {NgModule}             from '@angular/core';
+import {RouterModule, Routes} from '@angular/router';
+
+const routes: Routes = [{
+    path: 'workstations',
+    loadChildren: '@eg/staff/admin/workstation/workstations/workstations.module#ManageWorkstationsModule'
+}];
+
+@NgModule({
+  imports: [RouterModule.forChild(routes)],
+  exports: [RouterModule]
+})
+
+export class EgAdminWsRoutingModule {}
diff --git a/Open-ILS/src/eg2/src/app/staff/admin/workstation/workstations/routing.module.ts b/Open-ILS/src/eg2/src/app/staff/admin/workstation/workstations/routing.module.ts
new file mode 100644 (file)
index 0000000..a29f19b
--- /dev/null
@@ -0,0 +1,25 @@
+import {NgModule}             from '@angular/core';
+import {RouterModule, Routes} from '@angular/router';
+import {WorkstationsComponent} from './workstations.component';
+
+// Note that we need a path value (e.g. 'manage') because without it
+// there is nothing for the router to match, unless we rely on the parent
+// module to handle all of our routing for us.
+const routes: Routes = [
+  {
+    path: 'manage',
+    component: WorkstationsComponent
+  }, {
+    path: 'remove/:remove',
+    component: WorkstationsComponent
+  }
+];
+
+@NgModule({
+  imports: [RouterModule.forChild(routes)],
+  exports: [RouterModule]
+})
+
+export class WorkstationsRoutingModule {
+}
+
diff --git a/Open-ILS/src/eg2/src/app/staff/admin/workstation/workstations/workstations.component.html b/Open-ILS/src/eg2/src/app/staff/admin/workstation/workstations/workstations.component.html
new file mode 100644 (file)
index 0000000..a2358d2
--- /dev/null
@@ -0,0 +1,92 @@
+<eg-staff-banner bannerText="Workstation Administration" i18n-bannerText>
+</eg-staff-banner>
+
+<!-- this will remain hidden until opened -->
+<eg-confirm-dialog 
+  #workstationExistsDialog 
+  i18n-dialogTitle i18n-dialogBody
+  dialogTitle="Workstation Exists"
+  dialogBody='Workstation "{{newName}}" already exists.  Use it anyway?'>
+</eg-confirm-dialog>
+
+<div class="row">
+  <div class="col-lg-8 offset-1 mt-3">
+    <div class="alert alert-warning" *ngIf="removeWorkstation" i18n>
+      Workstation {{removeWorkstation}} is no longer valid.  Removing registration.
+    </div>
+    <div class="alert alert-danger" *ngIf="workstations.length == 0">
+      <span i18n>Please register a workstation.</span>
+    </div>
+
+    <div class="row">
+      <div class="col" i18n>Register a New Workstation For This Browser</div>
+    </div>
+    <div class="row mt-2">
+      <div class="col-lg-2">
+        <eg-org-select 
+          [applyDefault]="true"
+          (onChange)="orgOnChange($event)"
+          [hideOrgs]="hideOrgs"
+          [disableOrgs]="disableOrgs"
+          i18n-placeholder
+          placeholder="Owner..." >
+        </eg-org-select>
+      </div>
+      <div class="col-lg-6">
+        <div class="input-group">
+          <input type='text'
+            class='form-control'
+            i18n-title
+            title="Workstation Name"
+            i18n-placeholder
+            placeholder="Workstation Name..."
+            [(ngModel)]='newName'/>
+          <div class="input-group-btn">
+            <button class="btn btn-outline-dark" 
+              [disabled]="!newName || !newOwner"
+              (click)="registerWorkstation()">
+              <span i18n>Register</span>
+            </button>
+          </div>
+        </div>
+      </div>
+    </div>
+    <div class="row mt-3 pt-3 border border-left-0 border-right-0 border-bottom-0 border-light">
+      <div class="col">
+        <span i18n>Workstations Registered With This Browser</span>
+      </div>
+    </div>
+    <div class="row">
+      <div class="col-lg-8">
+        <select class="form-control" [(ngModel)]="selectedName">
+          <option *ngFor="let ws of workstations" value="{{ws.name}}">
+            <span *ngIf="ws.name == defaultName" i18n>
+              {{ws.name}} (Default)
+            </span>
+            <span *ngIf="ws.name != defaultName">
+              {{ws.name}}
+            </span>
+          </option>
+        </select>
+      </div>
+    </div>
+    <div class="row mt-2">
+      <div class="col-lg-6">
+        <button i18n class="btn btn-success" 
+          (click)="useNow()" [disabled]="!selected">
+          Use Now
+        </button>
+        <button i18n class="btn btn-outline-dark" 
+          (click)="setDefault()" [disabled]="!selected">
+          Mark As Default
+        </button>
+        <button i18n class="btn btn-danger"
+          (click)="removeSelected()"
+          [disabled]="!selected || !canDeleteSelected()">
+          Remove
+        </button>
+      </div>
+    </div>
+  </div>
+</div>
+
diff --git a/Open-ILS/src/eg2/src/app/staff/admin/workstation/workstations/workstations.component.ts b/Open-ILS/src/eg2/src/app/staff/admin/workstation/workstations/workstations.component.ts
new file mode 100644 (file)
index 0000000..d1ae7f2
--- /dev/null
@@ -0,0 +1,185 @@
+import {Component, OnInit, ViewChild} from '@angular/core';
+import {Router, ActivatedRoute} from '@angular/router';
+import {EgStoreService} from '@eg/core/store.service';
+import {EgIdlObject} from '@eg/core/idl.service';
+import {EgNetService} from '@eg/core/net.service';
+import {EgPermService} from '@eg/core/perm.service';
+import {EgAuthService} from '@eg/core/auth.service';
+import {EgOrgService} from '@eg/core/org.service';
+import {EgEventService} from '@eg/core/event.service';
+import {EgConfirmDialogComponent} from '@eg/share/dialog/confirm.component';
+
+// Slim version of the WS that's stored in the cache.
+interface Workstation {
+    id: number;
+    name: string;
+    owning_lib: number;
+}
+
+@Component({
+  templateUrl: 'workstations.component.html'
+})
+export class WorkstationsComponent implements OnInit {
+
+    selectedName: string;
+    workstations: Workstation[] = [];
+    removeWorkstation: string;
+    newOwner: EgIdlObject;
+    newName: string;
+    defaultName: string;
+
+    @ViewChild('workstationExistsDialog')
+    private wsExistsDialog: EgConfirmDialogComponent;
+
+    // Org selector options.
+    hideOrgs: number[];
+    disableOrgs: number[];
+    orgOnChange = (org: EgIdlObject): void => {
+        this.newOwner = org;
+    }
+
+    constructor(
+        private router: Router,
+        private route: ActivatedRoute,
+        private evt: EgEventService,
+        private net: EgNetService,
+        private store: EgStoreService,
+        private auth: EgAuthService,
+        private org: EgOrgService,
+        private perm: EgPermService
+    ) {}
+
+    ngOnInit() {
+        this.store.getItem('eg.workstation.all')
+        .then(list => this.workstations = list || [])
+        .then(noop => this.store.getItem('eg.workstation.default'))
+        .then(defWs => {
+            this.defaultName = defWs;
+            this.selectedName = this.auth.workstation() || defWs 
+        })
+        .then(noop => {
+            let rm = this.route.snapshot.paramMap.get('remove');
+            if (rm) this.removeSelected(this.removeWorkstation = rm)
+        })
+
+        this.perm.hasWorkPermAt(['REGISTER_WORKSTATION'], true)
+        .then(perms => {
+            // Disable org units that cannot have users and any
+            // that this user does not have work perms for.
+            this.disableOrgs = 
+                this.org.filterList({canHaveUsers : false}, true)
+                .concat(this.org.filterList(
+                    {notInList : perms.REGISTER_WORKSTATION}, true));
+        });
+    }
+
+    selected(): Workstation {
+        return this.workstations.filter(
+          ws => {return ws.name == this.selectedName})[0];
+    }
+
+    useNow(): void {
+        if (!this.selected()) return;
+        this.router.navigate(['/staff/login'], 
+          {queryParams: {workstation: this.selected().name}});
+    }
+
+    setDefault(): void {
+      if (!this.selected()) return;
+      this.defaultName = this.selected().name;
+      this.store.setItem('eg.workstation.default', this.defaultName);
+    }
+
+    removeSelected(name?: string): void {
+        if (!name) name = this.selected().name;
+
+        this.workstations = this.workstations.filter(w => w.name != name);
+        this.store.setItem('eg.workstation.all', this.workstations);
+
+        if (this.defaultName == name) {
+            this.defaultName = null;
+            this.store.removeItem('eg.workstation.default');
+        }
+    }
+    
+    canDeleteSelected(): boolean {
+        return true;
+    }
+
+    registerWorkstation(): void {
+        console.log(`Registering new workstation ` +
+            `"${this.newName}" at ${this.newOwner.shortname()}`);
+
+        this.newName = this.newOwner.shortname() + '-' + this.newName;
+
+        this.registerWorkstationApi().then(
+            wsId => this.registerWorkstationLocal(wsId),
+            notOk => console.log('Workstation registration canceled/failed')
+        );
+    }
+
+    private handleCollision(): Promise<number> {
+        return new Promise((resolve, reject) => {
+            this.wsExistsDialog.open()
+            .then(
+                confirmed => {
+                    this.registerWorkstationApi(true).then(
+                        wsId => resolve(wsId),
+                        notOk => reject(notOk)
+                    )
+                },
+                dismissed => reject(dismissed)
+            )
+        });
+    }
+
+
+    private registerWorkstationApi(override?: boolean): Promise<number> {
+        let method = 'open-ils.actor.workstation.register';
+        if (override) method += '.override';
+
+        return new Promise((resolve, reject) => {
+            this.net.request(
+                'open-ils.actor', method,
+                this.auth.token(), this.newName, this.newOwner.id()
+            ).subscribe(wsId => {
+                let evt = this.evt.parse(wsId);  
+                if (evt) {
+                    if (evt.textcode == 'WORKSTATION_NAME_EXISTS') {
+                        this.handleCollision().then(
+                            id => resolve(id),
+                            notOk => reject(notOk)
+                        )
+                    } else {
+                        console.error(`Registration failed ${evt}`);
+                        reject();
+                    }
+                } else {
+                   resolve(wsId); 
+                }
+            });
+        });
+    }
+
+    private registerWorkstationLocal(wsId: number) {
+        let ws: Workstation = {
+            id: wsId,
+            name: this.newName,
+            owning_lib: this.newOwner.id()
+        };
+
+        this.workstations.push(ws);
+        this.store.setItem('eg.workstation.all', this.workstations)
+        .then(ok => {
+            this.newName = '';
+            // when registering our first workstation, mark it as the
+            // default and show it as selected in the ws selector.
+            if (this.workstations.length == 1) {
+                this.selectedName = ws.name;
+                this.setDefault();
+            }
+        });
+    }
+}
+
+
diff --git a/Open-ILS/src/eg2/src/app/staff/admin/workstation/workstations/workstations.module.ts b/Open-ILS/src/eg2/src/app/staff/admin/workstation/workstations/workstations.module.ts
new file mode 100644 (file)
index 0000000..064b24d
--- /dev/null
@@ -0,0 +1,18 @@
+import {NgModule} from '@angular/core';
+import {EgStaffCommonModule} from '@eg/staff/common.module';
+import {WorkstationsRoutingModule} from './routing.module';
+import {WorkstationsComponent} from './workstations.component';
+
+@NgModule({
+  declarations: [
+    WorkstationsComponent,
+  ],
+  imports: [
+    EgStaffCommonModule,
+    WorkstationsRoutingModule
+  ]
+})
+
+export class ManageWorkstationsModule {}
+
+
diff --git a/Open-ILS/src/eg2/src/app/staff/catalog/catalog.component.html b/Open-ILS/src/eg2/src/app/staff/catalog/catalog.component.html
new file mode 100644 (file)
index 0000000..1596454
--- /dev/null
@@ -0,0 +1,6 @@
+<!-- search form sits atop every catalog page -->
+<eg-catalog-search-form></eg-catalog-search-form>
+
+<!-- search results, record details, etc. -->
+<router-outlet></router-outlet>
+
diff --git a/Open-ILS/src/eg2/src/app/staff/catalog/catalog.component.ts b/Open-ILS/src/eg2/src/app/staff/catalog/catalog.component.ts
new file mode 100644 (file)
index 0000000..0324ed4
--- /dev/null
@@ -0,0 +1,18 @@
+import {Component, OnInit} from '@angular/core';
+import {StaffCatalogService} from './catalog.service';
+
+@Component({
+  templateUrl: 'catalog.component.html'
+})
+export class EgCatalogComponent implements OnInit {
+
+    constructor(private staffCat: StaffCatalogService) {}
+
+    ngOnInit() {
+        // Create the search context that will be used by all of my
+        // child components.  After initial creation, the context is
+        // reset and updated as needed to apply new search parameters.
+        this.staffCat.createContext();
+    }
+}
+
diff --git a/Open-ILS/src/eg2/src/app/staff/catalog/catalog.module.ts b/Open-ILS/src/eg2/src/app/staff/catalog/catalog.module.ts
new file mode 100644 (file)
index 0000000..635eec1
--- /dev/null
@@ -0,0 +1,46 @@
+import {NgModule} from '@angular/core';
+import {EgStaffCommonModule} from '@eg/staff/common.module';
+import {EgUnapiService} from '@eg/share/catalog/unapi.service';
+import {EgCatalogRoutingModule} from './routing.module';
+import {EgCatalogService} from '@eg/share/catalog/catalog.service';
+import {EgCatalogUrlService} from '@eg/share/catalog/catalog-url.service';
+import {EgCatalogComponent} from './catalog.component';
+import {SearchFormComponent} from './search-form.component';
+import {ResultsComponent} from './result/results.component';
+import {RecordComponent} from './record/record.component';
+import {CopiesComponent} from './record/copies.component';
+import {EgBibSummaryComponent} from '@eg/staff/share/bib-summary/bib-summary.component';
+import {ResultPaginationComponent} from './result/pagination.component';
+import {ResultFacetsComponent} from './result/facets.component';
+import {ResultRecordComponent} from './result/record.component';
+import {StaffCatalogService} from './catalog.service';
+import {RecordPaginationComponent} from './record/pagination.component';
+
+@NgModule({
+  declarations: [
+    EgCatalogComponent,
+    ResultsComponent,
+    RecordComponent,
+    CopiesComponent,
+    EgBibSummaryComponent,
+    SearchFormComponent,
+    ResultRecordComponent,
+    ResultFacetsComponent,
+    ResultPaginationComponent,
+    RecordPaginationComponent
+  ],
+  imports: [
+    EgStaffCommonModule,
+    EgCatalogRoutingModule
+  ],
+  providers: [
+    EgUnapiService,
+    EgCatalogService,
+    EgCatalogUrlService,
+    StaffCatalogService
+  ]
+})
+
+export class EgCatalogModule { 
+
+}
diff --git a/Open-ILS/src/eg2/src/app/staff/catalog/catalog.service.ts b/Open-ILS/src/eg2/src/app/staff/catalog/catalog.service.ts
new file mode 100644 (file)
index 0000000..6cfc715
--- /dev/null
@@ -0,0 +1,82 @@
+import {Injectable} from '@angular/core';
+import {Router, ActivatedRoute} from '@angular/router';
+import {EgIdlObject} from '@eg/core/idl.service';
+import {EgOrgService} from '@eg/core/org.service';
+import {EgCatalogService} from '@eg/share/catalog/catalog.service';
+import {EgCatalogUrlService} from '@eg/share/catalog/catalog-url.service';
+import {CatalogSearchContext} from '@eg/share/catalog/search-context';
+
+/**
+ * Shared bits needed by the staff version of the catalog.
+ */
+
+@Injectable()
+export class StaffCatalogService {
+
+    searchContext: CatalogSearchContext;
+    routeIndex: number = 0;
+    defaultSearchOrg: EgIdlObject;
+    defaultSearchLimit: number;
+
+    // TODO: does unapi support pref-lib for result-page copy counts?
+    prefOrg: EgIdlObject; 
+
+    constructor(
+        private router: Router,
+        private route: ActivatedRoute,
+        private org: EgOrgService,
+        private cat: EgCatalogService,
+        private catUrl: EgCatalogUrlService
+    ) { }
+
+    createContext(): void {
+        // Initialize the search context from the load-time URL params.
+        // Do this here so the search form and other context data are
+        // applied on every page, not just the search results page.  The
+        // search results pages will handle running the actual search.
+        this.searchContext = 
+            this.catUrl.fromUrlParams(this.route.snapshot.queryParamMap);
+
+        this.searchContext.org = this.org; // service, not searchOrg
+        this.searchContext.isStaff = true;
+        this.applySearchDefaults();
+    }
+
+    applySearchDefaults(): void {
+        if (!this.searchContext.searchOrg) {
+            this.searchContext.searchOrg = 
+                this.defaultSearchOrg || this.org.root();
+        }
+
+        if (!this.searchContext.pager.limit) {
+            this.searchContext.pager.limit = this.defaultSearchLimit || 20;
+        }
+    }
+
+    /**
+     * Redirect to the search results page while propagating the current
+     * search paramters into the URL.  Let the search results component
+     * execute the actual search.
+     */
+    search(): void {
+        if (!this.searchContext.isSearchable()) return;
+
+        let params = this.catUrl.toUrlParams(this.searchContext);
+        
+        // Force a new search every time this method is called, even if
+        // it's the same as the active search.  Since router navigation
+        // exits early when the route + params is identical, add a
+        // random token to the route params to force a full navigation.
+        // This also resolves a problem where only removing secondary+
+        // versions of a query param fail to cause a route navigation.
+        // (E.g. going from two query= params to one).  Investigation
+        // pending.
+        params.ridx=''+this.routeIndex++;
+
+        this.router.navigate(
+          ['/staff/catalog/search'], {queryParams: params});
+    }
+
+}
+
+
diff --git a/Open-ILS/src/eg2/src/app/staff/catalog/record/copies.component.html b/Open-ILS/src/eg2/src/app/staff/catalog/record/copies.component.html
new file mode 100644 (file)
index 0000000..8c7a4f3
--- /dev/null
@@ -0,0 +1,71 @@
+<div class='eg-copies w-100'>
+  <ul class="pagination mb-1">
+    <li class="page-item" [ngClass]="{disabled : pager.offset == 0}">
+      <a class="no-href page-link" 
+        i18n-aria-label aria-label="Start" (click)="firstPage()">
+        <span i18n>Start</span>
+      </a>
+    </li>
+    <li class="page-item" [ngClass]="{disabled : pager.offset == 0}">
+      <a class="no-href page-link" 
+        i18n-aria-label aria-label="Previous" (click)="prevPage()">
+        <span i18n>Previous</span>
+      </a>
+    </li>
+    <!-- note disable logic is incomplete -->
+    <li class="page-item"
+      [ngClass]="{disabled: copies.length < pager.limit}">
+      <a class="no-href page-link" 
+        i18n-aria-label aria-label="Next" (click)="nextPage()">
+        <span i18n>Next</span>
+      </a>
+    </li>
+  </ul>
+  <div class='card tight-card w-100'>
+    <div class="card-header font-weight-bold d-flex bg-info">
+      <div class="flex-1" i18n>Location</div>
+      <div class="flex-1 pl-1" i18n>Call Number / Copy Notes</div>
+      <div class="flex-1 pl-1" i18n>Barcode</div>
+      <div class="flex-1 pl-1" i18n>Shelving Location</div>
+      <div class="flex-1 pl-1" i18n>Circulation Modifier</div>
+      <div class="flex-1 pl-1" i18n>Age Hold Protection</div>
+      <div class="flex-1 pl-1" i18n>Active/Create Date</div>
+      <div class="flex-1 pl-1" i18n>Holdable?</div>
+      <div class="flex-1 pl-1" i18n>Status</div>
+      <div class="flex-1 pl-1" i18n>Due Date</div>
+    </div>
+    <div class="card-body">
+      <ul class="list-group list-group-flush" *ngIf="copies && copies.length">
+        <li class="list-group-item" *ngFor="let copy of copies; let idx = index" 
+          [ngClass]="{'list-group-item-info': (idx % 2) == 1}">
+          <div class="d-flex">
+            <div class="flex-1" i18n>{{orgName(copy.circ_lib)}}</div>
+            <div class="flex-1 pl-1" i18n>
+              {{copy.call_number_prefix_label}}
+              {{copy.call_number_label}}
+              {{copy.call_number_suffix_label}}
+            </div>
+            <div class="flex-1 pl-1">
+              {{copy.barcode}}
+              <a class="pl-1" href="/eg/staff/cat/item/{{copy.id}}" i18n>View</a>
+              | 
+              <a class="pl-1" href="/eg/staff/cat/item/{{copy.id}}/edit" i18n>Edit</a>
+            </div>
+            <div class="flex-1 pl-1" i18n>{{copy.copy_location}}</div>
+            <div class="flex-1 pl-1" i18n>{{copy.circ_modifier || ''}}</div>
+            <div class="flex-1 pl-1" i18n>{{copy.age_protect}}</div>
+            <div class="flex-1 pl-1" i18n>
+              {{copy.active_date || copy.create_date | date:'shortDate'}}
+            </div>
+            <div class="flex-1 pl-1">
+              <span *ngIf="holdable(copy)" i18n>Yes</span>
+              <span *ngIf="!holdable(copy)" i18n>No</span>
+            </div>
+            <div class="flex-1 pl-1" i18n>{{copy.copy_status}}</div>
+            <div class="flex-1 pl-1" i18n>{{copy.due_date | date:'shortDate'}}</div>
+          </div>
+        </li>
+      </ul>
+    </div>
+  </div>
+</div>
diff --git a/Open-ILS/src/eg2/src/app/staff/catalog/record/copies.component.ts b/Open-ILS/src/eg2/src/app/staff/catalog/record/copies.component.ts
new file mode 100644 (file)
index 0000000..d4b6957
--- /dev/null
@@ -0,0 +1,91 @@
+import {Component, OnInit, Input} from '@angular/core';
+import {EgNetService} from '@eg/core/net.service';
+import {StaffCatalogService} from '../catalog.service';
+import {Pager} from '@eg/share/util/pager';
+import {EgOrgService} from '@eg/core/org.service';
+
+@Component({
+  selector: 'eg-catalog-copies',
+  templateUrl: 'copies.component.html'
+})
+export class CopiesComponent implements OnInit {
+
+    pager: Pager;
+    copies: any[]
+    recId: number;
+    initDone: boolean = false;
+
+    @Input() set recordId(id: number) {
+        this.recId = id;
+        // Only force new data collection when recordId()
+        // is invoked after ngInit() has already run.
+        if (this.initDone) this.collectData();
+    }
+
+    constructor(
+        private net: EgNetService,
+        private org: EgOrgService,
+        private staffCat: StaffCatalogService,
+    ) {}
+
+    ngOnInit() { 
+        this.initDone = true;
+        this.collectData();
+    }
+
+    collectData() {
+        if (!this.recId) return;
+        this.pager = new Pager();
+        this.pager.limit = 10; // TODO UI
+        this.fetchCopies();
+    }
+
+    orgName(orgId: number): string {
+        return this.org.get(orgId).shortname();
+    }
+
+    fetchCopies(): void {
+        this.copies = [];
+
+        // "Show Result from All Libraries" i.e. global search displays
+        // copies from all branches, sorted by search/pref libs.
+        let copy_depth = this.staffCat.searchContext.global ?
+            this.org.root().ou_type().depth() :
+            this.staffCat.searchContext.searchOrg.ou_type().depth(); 
+
+        this.net.request(
+            'open-ils.search',
+            'open-ils.search.bib.copies.staff',
+            this.recId,
+            this.staffCat.searchContext.searchOrg.id(),
+            copy_depth,
+            this.pager.limit,
+            this.pager.offset,
+            this.staffCat.prefOrg ? this.staffCat.prefOrg.id() : null 
+        ).subscribe(copy => {
+            this.copies.push(copy);
+        });
+    }
+
+    holdable(copy: any): boolean {
+        return copy.holdable == 't' 
+            && copy.location_holdable == 't' 
+            && copy.status_holdable == 't';
+    }
+
+    firstPage(): void {
+        this.pager.offset = 0;
+        this.fetchCopies();
+    }
+    prevPage(): void {
+        this.pager.decrement();
+        this.fetchCopies();
+    }
+    nextPage(): void {
+        this.pager.increment();
+        this.fetchCopies();
+    }
+
+}
+
+
diff --git a/Open-ILS/src/eg2/src/app/staff/catalog/record/pagination.component.html b/Open-ILS/src/eg2/src/app/staff/catalog/record/pagination.component.html
new file mode 100644 (file)
index 0000000..0edcded
--- /dev/null
@@ -0,0 +1,36 @@
+<ul class="pagination mb-0" *ngIf="index !== null">
+  <li class="page-item" [ngClass]="{disabled : index == 0}">
+    <a class="no-href page-link" 
+      i18n-aria-label aria-label="Start" (click)="firstRecord()">
+      <span i18n>Start</span>
+    </a>
+  </li>
+  <li class="page-item" [ngClass]="{disabled : index == 0}">
+    <a class="no-href page-link" 
+      i18n-aria-label aria-label="Previous" (click)="prevRecord()">
+      <span i18n>Previous</span>
+    </a>
+  </li>
+  <li class="page-item"
+    [ngClass]="{disabled : index >= searchContext.result.count - 1}">
+    <a class="no-href page-link" 
+      i18n-aria-label aria-label="Next" (click)="nextRecord()">
+      <span i18n>Next</span>
+    </a>
+  </li>
+  <li class="page-item"
+      [ngClass]="{disabled : index >= searchContext.result.count - 1}">
+    <a class="no-href page-link" 
+      i18n-aria-label aria-label="End" (click)="lastRecord()">
+      <span i18n>End</span>
+    </a>
+  </li>
+  <li class="page-item">
+    <a class="no-href page-link" 
+      i18n-aria-label aria-label="Back to Results" (click)="returnToSearch()">
+      <span i18n>
+        Back to Results ({{index + 1}} / {{searchContext.result.count}})
+      </span>
+    </a>
+  </li>
+</ul>
diff --git a/Open-ILS/src/eg2/src/app/staff/catalog/record/pagination.component.ts b/Open-ILS/src/eg2/src/app/staff/catalog/record/pagination.component.ts
new file mode 100644 (file)
index 0000000..31fee2c
--- /dev/null
@@ -0,0 +1,157 @@
+import {Component, OnInit, Input} from '@angular/core';
+import {Router} from '@angular/router';
+import {EgCatalogService} from '@eg/share/catalog/catalog.service';
+import {CatalogSearchContext} from '@eg/share/catalog/search-context';
+import {EgCatalogUrlService} from '@eg/share/catalog/catalog-url.service';
+import {StaffCatalogService} from '../catalog.service';
+import {Pager} from '@eg/share/util/pager';
+
+
+@Component({
+  selector: 'eg-catalog-record-pagination',
+  templateUrl: 'pagination.component.html'
+})
+export class RecordPaginationComponent implements OnInit {
+
+    id: number;
+    index: number;
+    initDone: boolean = false;
+    searchContext: CatalogSearchContext;
+
+    @Input() set recordId(id: number) {
+        this.id = id;
+        // Only apply new record data after the initial load
+        if (this.initDone) this.setIndex();
+    }
+
+    constructor(
+        private router: Router,
+        private cat: EgCatalogService,
+        private catUrl: EgCatalogUrlService,
+        private staffCat: StaffCatalogService,
+    ) {}
+
+    ngOnInit() { 
+        this.initDone = true;
+        this.setIndex();
+    }
+
+    firstRecord(): void {
+        this.findRecordAtIndex(0).then(id => {
+            let params = this.catUrl.toUrlParams(this.searchContext);
+            this.router.navigate(
+                ['/staff/catalog/record/' + id], {queryParams: params});
+        });
+    }
+
+    lastRecord(): void {
+        this.findRecordAtIndex(
+            this.searchContext.result.count - 1
+        ).then(id => {
+            let params = this.catUrl.toUrlParams(this.searchContext);
+            this.router.navigate(
+                ['/staff/catalog/record/' + id], {queryParams: params});
+        });
+    }
+
+    nextRecord(): void {
+        this.findRecordAtIndex(this.index + 1).then(id => {
+            let params = this.catUrl.toUrlParams(this.searchContext);
+            this.router.navigate(
+                ['/staff/catalog/record/' + id], {queryParams: params});
+        });
+    }
+
+    prevRecord(): void {
+        this.findRecordAtIndex(this.index - 1).then(id => {
+            let params = this.catUrl.toUrlParams(this.searchContext);
+            this.router.navigate(
+                ['/staff/catalog/record/' + id], {queryParams: params});
+        });
+    }
+
+
+    // Returns the offset of the record within the search results as a whole.
+    searchIndex(idx: number): number {
+        return idx + this.searchContext.pager.offset;
+    }
+
+    // Find the position of the current record in the search results
+    // If no results are present or the record is not found, expand
+    // the search scope to find the record.
+    setIndex(): Promise<void> {
+        this.searchContext = this.staffCat.searchContext;
+        this.index = null;
+
+        return new Promise((resolve, reject) => {
+
+            this.index = this.searchContext.indexForResult(this.id);
+            if (this.index !== null) return resolve();
+
+            return this.refreshSearch().then(ok => {
+                this.index = this.searchContext.indexForResult(this.id);
+                if (this.index === null) console.warn(
+                    'No search results found containing the focused record.');
+                resolve();
+            });
+        });
+    }
+
+    // Find the record ID at the specified search index.
+    // If no data exists for the requested index, expand the search
+    // to include data for that index.
+    findRecordAtIndex(index: number): Promise<number> {
+
+        // First see if the selected record sits in the current page
+        // of search results.  
+        return new Promise((resolve, reject) => {
+            let id = this.searchContext.resultIdAt(index);
+            if (id) return resolve(id);
+
+            console.debug(
+                'Record paginator unable to find record at index ' + index);
+
+            // If we have to re-run the search to find the record, 
+            // expand the search limit out just enough to find the
+            // requested record plus one more.
+            return this.refreshSearch(index + 2).then(
+                ok => {
+                    let id = this.searchContext.resultIdAt(index);
+                    if (id) {
+                        resolve(id);
+                    } else {
+                        reject('no record found');
+                    }
+                }
+            );
+        });
+    }
+
+    refreshSearch(limit?: number): Promise<void> {
+
+        console.debug('paginator refreshing search');
+
+        if (!this.searchContext.isSearchable())
+            return Promise.resolve();
+
+        let origPager = this.searchContext.pager;
+        let tmpPager = new Pager();
+        tmpPager.limit = limit || 1000;
+
+        this.searchContext.pager = tmpPager;
+
+        return this.cat.search(this.searchContext)
+        .then(
+            ok => { this.searchContext.pager = origPager; },
+            notOk => { this.searchContext.pager = origPager }
+        );
+    }
+
+    returnToSearch(): void {
+        // Fire the main search.  This will direct us back to /results/
+        this.staffCat.search();
+    }
+
+}
+
+
diff --git a/Open-ILS/src/eg2/src/app/staff/catalog/record/record.component.html b/Open-ILS/src/eg2/src/app/staff/catalog/record/record.component.html
new file mode 100644 (file)
index 0000000..127254a
--- /dev/null
@@ -0,0 +1,18 @@
+
+<div id="staff-catalog-record-container">
+  <div id='staff-catalog-bib-navigation'>
+    <div *ngIf="searchContext.isSearchable()">
+      <eg-catalog-record-pagination [recordId]="recordId">
+      </eg-catalog-record-pagination>
+    </div>
+  </div>
+  <div id='staff-catalog-bib-summary-container' class='mt-1'>
+    <eg-bib-summary [bibSummary]="bibSummary">
+    </eg-bib-summary>
+  </div>
+  <div id='staff-catalog-copies-container' class='mt-3'>
+    <eg-catalog-copies [recordId]="recordId"></eg-catalog-copies>
+  </div>
+</div>
+
+
diff --git a/Open-ILS/src/eg2/src/app/staff/catalog/record/record.component.ts b/Open-ILS/src/eg2/src/app/staff/catalog/record/record.component.ts
new file mode 100644 (file)
index 0000000..ec9a302
--- /dev/null
@@ -0,0 +1,61 @@
+import {Component, OnInit, Input} from '@angular/core';
+import {ActivatedRoute, ParamMap} from '@angular/router';
+import {EgPcrudService} from '@eg/core/pcrud.service';
+import {EgIdlObject} from '@eg/core/idl.service';
+import {CatalogSearchContext, CatalogSearchState} 
+  from '@eg/share/catalog/search-context';
+import {EgCatalogService} from '@eg/share/catalog/catalog.service';
+import {StaffCatalogService} from '../catalog.service';
+import {EgBibSummaryComponent} from '@eg/staff/share/bib-summary/bib-summary.component';
+
+@Component({
+  selector: 'eg-catalog-record',
+  templateUrl: 'record.component.html'
+})
+export class RecordComponent implements OnInit {
+
+    recordId: number;
+    bibSummary: any;
+    searchContext: CatalogSearchContext;
+
+    constructor(
+        private route: ActivatedRoute,
+        private pcrud: EgPcrudService,
+        private cat: EgCatalogService,
+        private staffCat: StaffCatalogService
+    ) {}
+
+    ngOnInit() { 
+        this.searchContext = this.staffCat.searchContext;
+
+        // Watch for URL record ID changes
+        this.route.paramMap.subscribe((params: ParamMap) => {
+            this.recordId = +params.get('id');
+            this.loadRecord();
+        })
+    }
+
+    loadRecord(): void { 
+        this.searchContext = this.staffCat.searchContext;
+
+        // If a search is encoded in the URL, be sure we have the
+        // relevant search 
+
+        this.cat.getBibSummary(
+            this.recordId,
+            this.searchContext.searchOrg.id(), 
+            this.searchContext.searchOrg.ou_type().depth()
+        ).then(summary => { 
+            this.bibSummary = summary;
+            this.pcrud.search('au', {id: [summary.creator, summary.editor]})
+            .subscribe(user => {
+                if (user.id() == summary.creator)
+                    summary.creator = user;
+                if (user.id() == summary.editor)
+                    summary.editor = user;
+            })
+        });
+    }
+}
+
+
diff --git a/Open-ILS/src/eg2/src/app/staff/catalog/resolver.service.ts b/Open-ILS/src/eg2/src/app/staff/catalog/resolver.service.ts
new file mode 100644 (file)
index 0000000..f081fc2
--- /dev/null
@@ -0,0 +1,58 @@
+import {Injectable} from '@angular/core';
+import {Observable, Observer} from 'rxjs/Rx';
+import {Router, Resolve, RouterStateSnapshot,
+        ActivatedRouteSnapshot} from '@angular/router';
+import {EgStoreService} from '@eg/core/store.service';
+import {EgNetService} from '@eg/core/net.service';
+import {EgOrgService} from '@eg/core/org.service';
+import {EgAuthService} from '@eg/core/auth.service';
+import {EgPcrudService} from '@eg/core/pcrud.service';
+import {EgCatalogService} from '@eg/share/catalog/catalog.service';
+import {StaffCatalogService} from './catalog.service';
+
+@Injectable()
+export class EgCatalogResolver implements Resolve<Promise<any[]>> {
+
+    constructor(
+        private router: Router, 
+        private store: EgStoreService,
+        private org: EgOrgService,
+        private net: EgNetService,
+        private auth: EgAuthService,
+        private cat: EgCatalogService,
+        private staffCat: StaffCatalogService
+    ) {}
+
+    resolve(
+        route: ActivatedRouteSnapshot, 
+        state: RouterStateSnapshot): Promise<any[]> {
+
+        console.debug('EgCatalogResolver:resolve()');
+
+        return Promise.all([
+            this.cat.fetchCcvms(),
+            this.cat.fetchCmfs(),
+            this.fetchSettings()
+        ]);
+    }
+
+    fetchSettings(): Promise<any> {
+        let promises = [];
+
+        promises.push(
+            this.store.getItem('eg.search.search_lib').then(
+                id => this.staffCat.defaultSearchOrg = this.org.get(id)
+            )
+        );
+
+        promises.push(
+            this.store.getItem('eg.search.pref_lib').then(
+                id => this.staffCat.prefOrg = this.org.get(id)
+            )
+        );
+
+        return Promise.all(promises);
+    }
+
+}
+
diff --git a/Open-ILS/src/eg2/src/app/staff/catalog/result/facets.component.html b/Open-ILS/src/eg2/src/app/staff/catalog/result/facets.component.html
new file mode 100644 (file)
index 0000000..9681747
--- /dev/null
@@ -0,0 +1,43 @@
+<style>
+  .facet-selected {
+    background-color: #DDD;
+  }
+  .card {
+    width: 100%;
+  }
+  .list-group-item {padding: .5rem .75rem .5rem .75rem}
+</style>
+<div *ngIf="searchContext.result.facetData">
+  <div *ngFor="let facetConf of facetConfig.display">
+    <div *ngIf="searchContext.result.facetData[facetConf.facetClass]">
+      <div *ngFor="let name of facetConf.facetOrder">
+        <div class="row"
+          *ngIf="searchContext.result.facetData[facetConf.facetClass][name]">
+          <div class="card mb-2">
+            <h4 class="card-header">
+              {{searchContext.result.facetData[facetConf.facetClass][name].cmfLabel}}
+            </h4>
+            <ul class="list-group list-group-flush">
+              <li class="list-group-item" 
+                [ngClass]="{'facet-selected' :
+                  facetIsApplied(facetConf.facetClass, name, value.value)}"
+                *ngFor="
+                  let value of searchContext.result.facetData[facetConf.facetClass][name].valueList | slice:0:facetConfig.displayCount">
+                <div class="row">
+                  <div class="col-lg-9">
+                    <a class="card-link"
+                      href='javascript:;'
+                      (click)="applyFacet(facetConf.facetClass, name, value.value)">
+                      {{value.value}}
+                    </a>
+                  </div>
+                  <div class="col-lg-3">{{value.count}}</div>
+                </div>
+              </li>
+            </ul>
+          </div>
+        </div>
+      </div>
+    </div>
+  </div>
+</div>
diff --git a/Open-ILS/src/eg2/src/app/staff/catalog/result/facets.component.ts b/Open-ILS/src/eg2/src/app/staff/catalog/result/facets.component.ts
new file mode 100644 (file)
index 0000000..be68968
--- /dev/null
@@ -0,0 +1,48 @@
+import {Component, OnInit, Input} from '@angular/core';
+import {EgCatalogService} from '@eg/share/catalog/catalog.service';
+import {CatalogSearchContext, FacetFilter} from '@eg/share/catalog/search-context';
+import {StaffCatalogService} from '../catalog.service';
+
+export const FACET_CONFIG = {
+    display: [
+        {facetClass : 'author',  facetOrder : ['personal', 'corporate']},
+        {facetClass : 'subject', facetOrder : ['topic']},
+        {facetClass : 'identifier', facetOrder : ['genre']},
+        {facetClass : 'series',  facetOrder : ['seriestitle']},
+        {facetClass : 'subject', facetOrder : ['name', 'geographic']}
+    ],
+    displayCount : 5
+};
+
+@Component({
+  selector: 'eg-catalog-result-facets',
+  templateUrl: 'facets.component.html'
+})
+export class ResultFacetsComponent implements OnInit {
+
+    searchContext: CatalogSearchContext;
+    facetConfig: any;
+
+    constructor(
+        private cat: EgCatalogService,
+        private staffCat: StaffCatalogService
+    ) {
+        this.facetConfig = FACET_CONFIG;
+    }
+
+    ngOnInit() { 
+        this.searchContext = this.staffCat.searchContext;
+    }
+
+    facetIsApplied(cls: string, name: string, value: string): boolean {
+        return this.searchContext.hasFacet(new FacetFilter(cls, name, value));
+    }
+
+    applyFacet(cls: string, name: string, value: string): void {
+        this.searchContext.toggleFacet(new FacetFilter(cls, name, value));
+        this.searchContext.pager.offset = 0;
+        this.staffCat.search();
+    }
+}
+
+
diff --git a/Open-ILS/src/eg2/src/app/staff/catalog/result/pagination.component.css b/Open-ILS/src/eg2/src/app/staff/catalog/result/pagination.component.css
new file mode 100644 (file)
index 0000000..c283ff4
--- /dev/null
@@ -0,0 +1,8 @@
+
+/* Bootstrap default is 20px */
+.pagination {margin: 0px 0px 0px 0px}
+
+.pagination li:not(.active) a {
+  cursor: pointer;
+}
+
diff --git a/Open-ILS/src/eg2/src/app/staff/catalog/result/pagination.component.html b/Open-ILS/src/eg2/src/app/staff/catalog/result/pagination.component.html
new file mode 100644 (file)
index 0000000..55b63dd
--- /dev/null
@@ -0,0 +1,28 @@
+<!-- 
+Using bare BS pagination instead of ng-bootstrap, which seemed 
+unnecessary given we have to track paging externally anyway.
+-->
+<ul class="pagination">
+  <li class="page-item" 
+    [ngClass]="{disabled : searchContext.pager.isFirstPage()}">
+    <a (click)="prevPage()"
+      class="page-link" 
+      i18n-aria-label
+      aria-label="Previous">
+      <span aria-hidden="true">&laquo;</span>
+    </a>
+  </li>
+  <li class="page-item" 
+    *ngFor="let page of searchContext.pager.pageList()"
+    [ngClass]="{active : searchContext.pager.currentPage() == page}">
+    <a class="page-link" (click)="setPage(page)">
+      {{page}} <span class="sr-only" i18n>(current)</span></a>
+  </li>
+  <li class="page-item" 
+    [ngClass]="{disabled : searchContext.pager.isLastPage()}">
+    <a (click)="nextPage()"
+      class="page-link" aria-label="Next" i18n-aria-label>
+      <span aria-hidden="true">&raquo;</span>
+    </a>
+  </li>
+</ul>
diff --git a/Open-ILS/src/eg2/src/app/staff/catalog/result/pagination.component.ts b/Open-ILS/src/eg2/src/app/staff/catalog/result/pagination.component.ts
new file mode 100644 (file)
index 0000000..189fbce
--- /dev/null
@@ -0,0 +1,41 @@
+import {Component, OnInit, Input} from '@angular/core';
+import {EgCatalogService} from '@eg/share/catalog/catalog.service';
+import {CatalogSearchContext} from '@eg/share/catalog/search-context';
+import {StaffCatalogService} from '../catalog.service';
+
+@Component({
+  selector: 'eg-catalog-result-pagination',
+  styleUrls: ['pagination.component.css'],
+  templateUrl: 'pagination.component.html'
+})
+export class ResultPaginationComponent implements OnInit {
+
+    searchContext: CatalogSearchContext;
+
+    constructor(
+        private cat: EgCatalogService,
+        private staffCat: StaffCatalogService
+    ) {}
+
+    ngOnInit() { 
+        this.searchContext = this.staffCat.searchContext;
+    }
+
+    nextPage(): void {
+        this.searchContext.pager.increment();
+        this.staffCat.search();
+    }
+
+    prevPage(): void {
+        this.searchContext.pager.decrement();
+        this.staffCat.search();
+    }
+
+    setPage(page: number): void {
+        if (this.searchContext.pager.currentPage() == page) return;
+        this.searchContext.pager.setPage(page);
+        this.staffCat.search();
+    }
+}
+
+
diff --git a/Open-ILS/src/eg2/src/app/staff/catalog/result/record.component.html b/Open-ILS/src/eg2/src/app/staff/catalog/result/record.component.html
new file mode 100644 (file)
index 0000000..1048211
--- /dev/null
@@ -0,0 +1,129 @@
+<!-- 
+  TODO
+  routerLink's
+  egDateFilter's
+-->
+
+<div class="col-lg-12 card tight-card mb-2 bg-light">
+  <div class="card-body">
+    <div class="row">
+      <div class="col-lg-1">
+        <!-- TODO router links -->
+        <a href="./cat/catalog/record/{{bibSummary.id}}">
+          <img style="height:80px"
+            src="/opac/extras/ac/jacket/small/r/{{bibSummary.id}}"/>
+        </a>
+      </div>
+      <div class="col-lg-5">
+        <div class="row">
+          <div class="col-lg-12 font-weight-bold">
+            <!-- nbsp allows the column to take shape when no value exists -->
+            <span class="font-weight-light font-italic">
+              #{{index + 1 + searchContext.pager.offset}}
+            </span>
+            <a href="javascript:void(0)"
+              (click)="navigatToRecord(bibSummary.id)">
+              {{bibSummary.title || '&nbsp;'}}
+            </a>
+          </div>
+        </div>
+        <div class="row pt-2">
+          <div class="col-lg-12">
+            <!-- nbsp allows the column to take shape when no value exists -->
+            <a href="javascript:void(0)"
+              (click)="searchAuthor(bibSummary)">
+              {{bibSummary.author || '&nbsp;'}}
+            </a>
+          </div>
+        </div>
+        <div class="row pt-2">
+          <div class="col-lg-12">
+            <span *ngIf="bibSummary.ccvms.icon_format">
+              <img class="pad-right-min"
+                src="/images/format_icons/icon_format/{{bibSummary.ccvms.icon_format.code}}.png"/>
+              <span>{{bibSummary.ccvms.icon_format.label}}</span>
+            </span>
+            <span style='pl-2'>{{bibSummary.edition}}</span>
+            <span style='pl-2'>{{bibSummary.pubdate}}</span>
+          </div>
+        </div>
+      </div>
+      <div class="col-lg-2">
+        <div class="row" [ngClass]="{'pt-2':copyIndex > 0}" 
+          *ngFor="let copyCount of bibSummary.copyCounts; let copyIdx = index">
+          <div class="w-100" *ngIf="copyCount.type == 'staff'">
+            <div class="float-left text-left w-50">
+              <span class="pr-1">
+              {{copyCount.available}} / {{copyCount.count}} items
+              </span>
+            </div>
+            <div class="float-left w-50">
+              @ {{orgName(copyCount.org_unit)}}
+            </div>
+          </div>
+        </div>
+      </div>
+      <div class="col-lg-1">
+        <div class="row">
+          <div class="w-100">
+            TCN: {{bibSummary.tcn_value}}
+          </div>
+        </div>
+        <div class="row">
+          <div class="w-100">
+            Holds: {{bibSummary.holdCount}}
+          </div>
+        </div>
+      </div>
+      <div class="col-lg-3">
+        <div class="row">
+          <div class="col-lg-12">
+            <div class="float-right small-text-1">
+              Created {{bibSummary.create_date | date:'shortDate'}} by
+              <!-- creator if fleshed after the initial data set is loaded -->
+              <a *ngIf="bibSummary.creator.usrname" target="_self" 
+                href="/eg/staff/circ/patron/{{bibSummary.creator.id()}}/checkout">
+                  {{bibSummary.creator.usrname()}}
+              </a>
+              <!-- add a spacer pending data to reduce page shuffle -->
+              <span *ngIf="!bibSummary.creator.usrname"> ... </span>
+            </div>
+          </div>
+        </div>
+        <div class="row pt-2">
+          <div class="col-lg-12">
+            <div class="float-right small-text-1">
+              Edited {{bibSummary.edit_date | date:'shortDate'}} by
+              <a *ngIf="bibSummary.editor.usrname" target="_self" 
+                href="/eg/staff/circ/patron/{{bibSummary.editor.id()}}/checkout">
+                  {{bibSummary.editor.usrname()}}
+              </a>
+              <span *ngIf="!bibSummary.editor.usrname"> ... </span>
+            </div>
+          </div>
+        </div>
+        <div class="row pt-2">
+          <div class="col-lg-12">
+            <div class="float-right">
+              <span>
+                <button (click)="placeHold()"
+                  class="btn btn-sm btn-success label-with-material-icon small-text-1">
+                  <span class="material-icons">check</span>
+                  <span i18n>Place Hold</span>
+                </button>
+              </span>
+              <span>
+                <button (click)="addToList()" 
+                  class="btn btn-sm btn-info label-with-material-icon small-text-1">
+                  <span class="material-icons">playlist_add_check</span>
+                  <span i18n>Add to List</span>
+                </button>
+              </span>
+            </div>
+          </div>
+        </div>
+      </div><!-- col -->
+    </div><!-- row -->
+  </div><!-- card-body -->
+</div><!-- card -->
+
diff --git a/Open-ILS/src/eg2/src/app/staff/catalog/result/record.component.ts b/Open-ILS/src/eg2/src/app/staff/catalog/result/record.component.ts
new file mode 100644 (file)
index 0000000..14f33e2
--- /dev/null
@@ -0,0 +1,72 @@
+import {Component, OnInit, Input} from '@angular/core';
+import {Router} from '@angular/router';
+import {EgOrgService} from '@eg/core/org.service';
+import {EgNetService} from '@eg/core/net.service';
+import {EgCatalogService} from '@eg/share/catalog/catalog.service';
+import {CatalogSearchContext} from '@eg/share/catalog/search-context';
+import {EgCatalogUrlService} from '@eg/share/catalog/catalog-url.service';
+import {StaffCatalogService} from '../catalog.service';
+
+@Component({
+  selector: 'eg-catalog-result-record',
+  templateUrl: 'record.component.html'
+})
+export class ResultRecordComponent implements OnInit {
+
+    @Input() index: number;  // 0-index display row
+    @Input() bibSummary: any;
+    searchContext: CatalogSearchContext;
+
+    constructor(
+        private router: Router,
+        private org: EgOrgService,
+        private net: EgNetService,
+        private cat: EgCatalogService,
+        private catUrl: EgCatalogUrlService,
+        private staffCat: StaffCatalogService
+    ) {}
+
+    ngOnInit() { 
+        this.searchContext = this.staffCat.searchContext;
+        this.fleshHoldCount();
+    }
+
+    fleshHoldCount(): void {
+        this.net.request(
+            'open-ils.circ',
+            'open-ils.circ.bre.holds.count', this.bibSummary.id
+        ).subscribe(count => this.bibSummary.holdCount = count);
+    }
+
+    orgName(orgId: number): string {
+        return this.org.get(orgId).shortname();
+    }
+
+    placeHold(): void {
+        alert('Placing hold on bib ' + this.bibSummary.id);
+    }
+
+    addToList(): void {
+        alert('Adding to list for bib ' + this.bibSummary.id);
+    }
+
+    searchAuthor(bibSummary: any) {
+        this.searchContext.reset();
+        this.searchContext.fieldClass = ['author'];
+        this.searchContext.query = [bibSummary.author];
+        this.staffCat.search();
+    }
+
+    /**
+     * Propagate the search params along when navigating to each record.
+     */
+    navigatToRecord(id: number) {
+        let params = this.catUrl.toUrlParams(this.searchContext);
+
+        this.router.navigate(
+          ['/staff/catalog/record/' + id], {queryParams: params});
+    }
+
+}
+
+
diff --git a/Open-ILS/src/eg2/src/app/staff/catalog/result/results.component.html b/Open-ILS/src/eg2/src/app/staff/catalog/result/results.component.html
new file mode 100644 (file)
index 0000000..f357a6c
--- /dev/null
@@ -0,0 +1,30 @@
+
+<div id="staff-catalog-results-container" *ngIf="searchIsDone()">
+  <div class="row">
+    <div class="col-lg-2"><!--match pagination margin-->
+      <h3 i18n>Search Results ({{searchContext.result.count}})</h3>
+    </div>
+    <div class="col-lg-1"></div>
+    <div class="col-lg-9">
+      <div class="float-right">
+                               <eg-catalog-result-pagination></eg-catalog-result-pagination>
+      </div>
+    </div>
+  </div>
+       <div class="row mt-2">
+               <div class="col-lg-2">
+      <eg-catalog-result-facets></eg-catalog-result-facets>
+               </div>
+               <div class="col-lg-10">
+                       <div *ngIf="searchContext.result">
+                               <div *ngFor="let bibSummary of searchContext.result.records; let idx = index">
+          <div *ngIf="bibSummary">
+                                         <eg-catalog-result-record [bibSummary]="bibSummary" [index]="idx">
+                                         </eg-catalog-result-record>
+          </div>
+                               </div>
+                       </div>
+               </div>
+       </div>
+</div>
+
diff --git a/Open-ILS/src/eg2/src/app/staff/catalog/result/results.component.ts b/Open-ILS/src/eg2/src/app/staff/catalog/result/results.component.ts
new file mode 100644 (file)
index 0000000..a970154
--- /dev/null
@@ -0,0 +1,109 @@
+import {Component, OnInit, Input} from '@angular/core';
+import {Observable} from 'rxjs/Rx';
+import {map, switchMap, distinctUntilChanged} from 'rxjs/operators';
+import {ActivatedRoute, ParamMap} from '@angular/router';
+import {EgCatalogService} from '@eg/share/catalog/catalog.service';
+import {EgCatalogUrlService} from '@eg/share/catalog/catalog-url.service';
+import {CatalogSearchContext, CatalogSearchState}
+  from '@eg/share/catalog/search-context';
+import {EgPcrudService} from '@eg/core/pcrud.service';
+import {StaffCatalogService} from '../catalog.service';
+import {EgIdlObject} from '@eg/core/idl.service';
+
+@Component({
+  selector: 'eg-catalog-results',
+  templateUrl: 'results.component.html'
+})
+export class ResultsComponent implements OnInit {
+
+    searchContext: CatalogSearchContext;
+
+    // Cache record creator/editor since this will likely be a 
+    // reasonably small set of data w/ lots of repitition.
+    userCache: {[id:number] : EgIdlObject} = {};
+
+    constructor(
+        private route: ActivatedRoute,
+        private pcrud: EgPcrudService,
+        private cat: EgCatalogService,
+        private catUrl: EgCatalogUrlService,
+        private staffCat: StaffCatalogService
+    ) {}
+
+    ngOnInit() { 
+        this.searchContext = this.staffCat.searchContext;
+
+        // Our search context is initialized on page load.  Once
+        // ResultsComponent is active, it will not be reinitialized,
+        // even if the route parameters changes (unless we change the
+        // route reuse policy).  Watch for changes here to pick up new
+        // searches.  
+        // 
+        // This will also fire on page load.
+        this.route.queryParamMap.subscribe((params: ParamMap) => {
+
+              // TODO: Angular docs suggest using switchMap(), but
+              // it's not firing for some reason.  Also, could avoid
+              // firing unnecessary searches when a param unrelated to
+              // searching is changed by .map()'ing out only the desired
+              // params and running through .distinctUntilChanged(), but
+              // .map() is not firing either.  I'm missing something.
+              this.searchByUrl(params);
+        })
+    }
+
+    searchByUrl(params: ParamMap): void {
+        this.catUrl.applyUrlParams(this.searchContext, params);
+
+        // A query string is required at minimum.
+        if (!this.searchContext.isSearchable()) return;
+
+        this.cat.search(this.searchContext)
+        .then(ok => {
+            this.cat.fetchFacets(this.searchContext);
+            this.cat.fetchBibSummaries(this.searchContext)
+            .then(ok2 => this.fleshSearchResults());
+        });
+    }
+
+    fleshSearchResults(): void {
+        let records = this.searchContext.result.records;
+        if (records.length == 0) return;
+
+        // Flesh the creator / editor fields with the user object.
+        // Handle the user fleshing here (instead of record.component so
+        // we only need to grab one copy of each user.
+        let userIds: {[id:number]: boolean} = {};
+        records.forEach(recSum => {
+            if (this.userCache[recSum.creator]) {
+                recSum.creator = this.userCache[recSum.creator];
+            } else {
+                userIds[Number(recSum.creator)] = true;
+            }
+
+            if (this.userCache[recSum.editor]) {
+                recSum.editor = this.userCache[recSum.editor];
+            } else {
+                userIds[Number(recSum.editor)] = true;
+            }
+        });
+
+        if (!Object.keys(userIds).length) return;
+
+        this.pcrud.search('au', {id : Object.keys(userIds)})
+        .subscribe(usr => {
+            this.userCache[usr.id()] = usr;
+            records.forEach(recSum => {
+                if (recSum.creator == usr.id()) recSum.creator = usr;
+                if (recSum.editor == usr.id()) recSum.editor = usr;
+            });
+        });
+    }
+
+    searchIsDone(): boolean {
+        return this.searchContext.searchState == CatalogSearchState.COMPLETE;
+    }
+
+}
+
+
diff --git a/Open-ILS/src/eg2/src/app/staff/catalog/routing.module.ts b/Open-ILS/src/eg2/src/app/staff/catalog/routing.module.ts
new file mode 100644 (file)
index 0000000..2376f80
--- /dev/null
@@ -0,0 +1,27 @@
+import {NgModule} from '@angular/core';
+import {RouterModule, Routes} from '@angular/router';
+import {EgCatalogComponent} from './catalog.component';
+import {ResultsComponent} from './result/results.component';
+import {RecordComponent} from './record/record.component';
+import {EgCatalogResolver} from './resolver.service';
+
+const routes: Routes = [{ 
+  path: '',
+  component: EgCatalogComponent,
+  resolve: {catResolver : EgCatalogResolver},
+  children : [{
+    path: 'search',
+    component: ResultsComponent,
+  }, {
+    path: 'record/:id',
+    component: RecordComponent,
+  }]
+}];
+
+@NgModule({
+  imports: [RouterModule.forChild(routes)],
+  exports: [RouterModule],
+  providers: [EgCatalogResolver]
+})
+
+export class EgCatalogRoutingModule {}
diff --git a/Open-ILS/src/eg2/src/app/staff/catalog/search-form.component.css b/Open-ILS/src/eg2/src/app/staff/catalog/search-form.component.css
new file mode 100644 (file)
index 0000000..6201dff
--- /dev/null
@@ -0,0 +1,16 @@
+
+/* filter checkbox labels move to bottom */
+.checkbox label {
+  margin-bottom: .1rem;
+}
+
+/* BS default height is 2.25rem + 2px which is quite chunky.
+ * This better matches the text input heights */
+select.form-control:not([size]):not([multiple]) {
+  padding: .355rem .55rem;
+  height: 2.2rem;
+}
+
+#staffcat-search-form {
+  border-bottom: 2px dashed rgba(0,0,0,.225);
+}
diff --git a/Open-ILS/src/eg2/src/app/staff/catalog/search-form.component.html b/Open-ILS/src/eg2/src/app/staff/catalog/search-form.component.html
new file mode 100644 (file)
index 0000000..650c785
--- /dev/null
@@ -0,0 +1,227 @@
+<!--
+TODO focus search input
+-->
+<div id='staffcat-search-form' class='pb-2 mb-3'>
+  <div class="row"
+    *ngFor="let q of searchContext.query; let idx = index; trackBy:trackByIdx">
+    <div class="col-lg-9 d-flex">
+      <div class="flex-1">
+        <div *ngIf="idx == 0">
+          <select class="form-control" [(ngModel)]="searchContext.format">
+            <option value=''>All Formats</option>
+            <option *ngFor="let fmt of ccvmMap.search_format"
+              value="{{fmt.code()}}">{{fmt.value()}}</option>
+          </select>
+        </div>
+        <div *ngIf="idx > 0">
+          <select class="form-control"
+            [(ngModel)]="searchContext.joinOp[idx]">
+            <option value='&&'>And</option>
+            <option value='||'>Or</option>
+          </select>
+        </div>
+      </div>
+      <div class="flex-1 pl-1">
+        <select class="form-control" 
+          [(ngModel)]="searchContext.fieldClass[idx]">
+          <option value='keyword'>Keyword</option>
+          <option value='title'>Title</option>
+          <option value='jtitle'>Journal Title</option>
+          <option value='author'>Author</option>
+          <option value='subject'>Subject</option>
+          <option value='series'>Series</option>
+        </select>
+      </div>
+      <div class="flex-1 pl-1">
+        <select class="form-control" 
+          [(ngModel)]="searchContext.matchOp[idx]">
+          <option value='contains'>Contains</option>
+          <option value='nocontains'>Does not contain</option>
+          <option value='phrase'>Contains phrase</option>
+          <option value='exact'>Matches exactly</option>
+          <option value='starts'>Starts with</option>
+        </select>
+      </div>
+      <div class="flex-2 pl-1">
+        <div class="form-group">
+          <div *ngIf="idx == 0">
+            <input type="text" class="form-control"
+              id='first-query-input'
+              [(ngModel)]="searchContext.query[idx]"
+              (keyup)="checkEnter($event)"
+              placeholder="Query..."/>
+          </div>
+          <div *ngIf="idx > 0">
+            <input type="text" class="form-control"
+              [(ngModel)]="searchContext.query[idx]"
+              (keyup)="checkEnter($event)"
+              placeholder="Query..."/>
+          </div>
+        </div>
+      </div>
+      <div class="flex-1 pl-1">
+        <button class="btn btn-sm material-icon-button"
+          (click)="addSearchRow(idx + 1)">
+          <span class="material-icons">add_circle_outline</span>
+        </button>
+        <button class="btn btn-sm material-icon-button"
+          [disabled]="searchContext.query.length < 2"
+          (click)="delSearchRow(idx)">
+          <span class="material-icons">remove_circle_outline</span>
+        </button>
+      </div>
+    </div><!-- col -->
+    <div class="col-lg-3">
+      <div *ngIf="idx == 0" class="float-right">
+        <button class="btn btn-success" type="button"
+          [disabled]="searchIsActive()"
+          (click)="searchContext.pager.offset=0;searchByForm()">
+          Search
+        </button>
+        <button class="btn btn-warning" type="button"
+          [disabled]="searchIsActive()"
+          (click)="searchContext.reset()">
+          Clear Form
+        </button>
+        <button class="btn btn-outline-secondary" type="button"
+          *ngIf="!showAdvanced()"
+          [disabled]="searchIsActive()"
+          (click)="showAdvancedSearch=true">
+          More Filters
+        </button>
+        <button class="btn btn-outline-secondary" type="button"
+          *ngIf="showAdvanced()"
+          (click)="showAdvancedSearch=false">
+          Hide Filters
+        </button>
+      </div>
+    </div>
+  </div><!-- row -->
+
+  <div class="row">
+    <div class="col-lg-9 d-flex">
+      <div class="flex-1">
+        <eg-org-select 
+          (onChange)="orgOnChange($event)"
+          [initialOrg]="searchContext.searchOrg"
+          [placeholder]="'Library'" >
+        </eg-org-select>
+      </div>
+      <div class="flex-3 pl-1">
+        <select class="form-control" [(ngModel)]="searchContext.sort">
+          <option value='' i18n>Sort by Relevance</option>
+          <optgroup label="Sort by Title" i18n-label>
+            <option value='titlesort' i18n>Title: A to Z</option>
+            <option value='titlesort.descending' i18n>Title: Z to A</option>
+          </optgroup>
+          <optgroup label="Sort by Author" i18n-label>
+            <option value='authorsort' i18n>Author: A to Z</option>
+            <option value='authorsort.descending' i18n>Author: Z to A</option>
+          </optgroup>
+          <optgroup label="Sort by Publication Date" i18n-label>
+            <option value='pubdate' i18n>Date: A to Z</option>
+            <option value='pubdate.descending' i18n>Date: Z to A</option>
+          </optgroup>
+          <optgroup label="Sort by Popularity" i18n-label>
+            <option value='popularity' i18n>Most Popular</option>
+            <option value='poprel' i18n>Popularity Adjusted Relevance</option>
+          </optgroup>
+        </select>
+      </div>
+      <div class="flex-2 pl-2 align-self-end">
+        <div class="checkbox">
+          <label>
+            <input type="checkbox" [(ngModel)]="searchContext.available"/>
+            <span i18n>Limit to Available</span>
+          </label>
+        </div>
+      </div>
+      <div class="flex-4 pl-2 align-self-end">
+        <div class="checkbox">
+          <label>
+            <input type="checkbox" [(ngModel)]="searchContext.global"/>
+            <span i18n>Show Results from All Libraries</span>
+          </label>
+        </div>
+      </div>
+      <div class="flex-2 pl-1">
+        <!-- alignment -->
+      </div>
+    </div>
+    <div class="col-lg-3">
+      <div *ngIf="searchIsActive()">
+        <div class="progress">
+          <div class="progress-bar progress-bar-striped active w-100"
+            role="progressbar" aria-valuenow="100" 
+            aria-valuemin="0" aria-valuemax="100">
+            <span class="sr-only" i18n>Searching..</span>
+          </div>
+        </div>
+      </div>
+    </div>
+  </div>
+  <div class="row pt-2" *ngIf="showAdvanced()">
+    <div class="col-lg-2">
+      <select class="form-control"  multiple="true"
+        [(ngModel)]="searchContext.ccvmFilters.item_type">
+        <option value='' i18n>All Item Types</option>
+        <option *ngFor="let itemType of ccvmMap.item_type"
+          value="{{itemType.code()}}">{{itemType.value()}}</option>
+      </select>
+    </div>
+    <div class="col-lg-2">
+      <select class="form-control" multiple="true"
+        [(ngModel)]="searchContext.ccvmFilters.item_form">
+        <option value='' i18n>All Item Forms</option>
+        <option *ngFor="let itemForm of ccvmMap.item_form"
+          value="{{itemForm.code()}}">{{itemForm.value()}}</option>
+      </select>
+    </div>
+    <div class="col-lg-2">
+      <select class="form-control" 
+        [(ngModel)]="searchContext.ccvmFilters.item_lang" multiple="true">
+        <option value='' i18n>All Languages</option>
+        <option *ngFor="let lang of ccvmMap.item_lang"
+          value="{{lang.code()}}">{{lang.value()}}</option>
+      </select>
+    </div>
+    <div class="col-lg-2">
+      <select class="form-control" 
+        [(ngModel)]="searchContext.ccvmFilters.audience" multiple="true">
+        <option value='' i18n>All Audiences</option>
+        <option *ngFor="let audience of ccvmMap.audience"
+          value="{{audience.code()}}">{{audience.value()}}</option>
+      </select>
+    </div>
+  </div>
+  <div class="row pt-2" *ngIf="showAdvanced()">
+    <div class="col-lg-2">
+      <select class="form-control" 
+        [(ngModel)]="searchContext.ccvmFilters.vr_format" multiple="true">
+        <option value='' i18n>All Video Formats</option>
+        <option *ngFor="let vrFormat of ccvmMap.vr_format"
+          value="{{vrFormat.code()}}">{{vrFormat.value()}}</option>
+      </select>
+    </div>
+    <div class="col-lg-2">
+      <select class="form-control" 
+        [(ngModel)]="searchContext.ccvmFilters.bib_level" multiple="true">
+        <option value='' i18n>All Bib Levels</option>
+        <option *ngFor="let bibLevel of ccvmMap.bib_level"
+          value="{{bibLevel.code()}}">{{bibLevel.value()}}</option>
+      </select>
+    </div>
+    <div class="col-lg-2">
+      <select class="form-control" 
+        [(ngModel)]="searchContext.ccvmFilters.lit_form" multiple="true">
+        <option value='' i18n>All Literary Forms</option>
+        <option *ngFor="let litForm of ccvmMap.lit_form"
+          value="{{litForm.code()}}">{{litForm.value()}}</option>
+      </select>
+    </div>
+    <div class="col-lg-2">
+      <i>Copy location filter goes here...</i>
+    </div>
+  </div>
+</div>
+
diff --git a/Open-ILS/src/eg2/src/app/staff/catalog/search-form.component.ts b/Open-ILS/src/eg2/src/app/staff/catalog/search-form.component.ts
new file mode 100644 (file)
index 0000000..cf6d66d
--- /dev/null
@@ -0,0 +1,106 @@
+import {Component, OnInit, AfterViewInit, Renderer} from '@angular/core';
+import {EgIdlObject} from '@eg/core/idl.service';
+import {EgOrgService} from '@eg/core/org.service';
+import {EgCatalogService,} from '@eg/share/catalog/catalog.service';
+import {CatalogSearchContext, CatalogSearchState} 
+  from '@eg/share/catalog/search-context';
+import {StaffCatalogService} from './catalog.service';
+
+@Component({
+  selector: 'eg-catalog-search-form',
+  styleUrls: ['search-form.component.css'],
+  templateUrl: 'search-form.component.html'
+})
+export class SearchFormComponent implements OnInit, AfterViewInit {
+
+    searchContext: CatalogSearchContext;
+    ccvmMap: {[ccvm:string] : EgIdlObject[]} = {};
+    cmfMap: {[cmf:string] : EgIdlObject} = {};
+    showAdvancedSearch: boolean = false;
+
+    constructor(
+        private renderer: Renderer,
+        private org: EgOrgService,
+        private cat: EgCatalogService,
+        private staffCat: StaffCatalogService
+    ) {}
+
+    ngOnInit() { 
+        this.ccvmMap = this.cat.ccvmMap;
+        this.cmfMap = this.cat.cmfMap;
+        this.searchContext = this.staffCat.searchContext;
+
+        // Start with advanced search options open 
+        // if any filters are active.
+        this.showAdvancedSearch = this.hasAdvancedOptions();
+
+    }
+
+    ngAfterViewInit() {
+        // Query inputs are generated from search context data,
+        // so they are not available until after the first render.
+        // Search context data is extracted synchronously from the URL.
+        this.renderer.selectRootElement('#first-query-input').focus();
+    }
+
+    /**
+     * Display the advanced/extended search options when asked to
+     * or if any advanced options are selected.
+     */
+    showAdvanced(): boolean {
+        return this.showAdvancedSearch;
+    }
+
+    hasAdvancedOptions(): boolean {
+        // ccvm filters may be present without any filters applied.
+        // e.g. if filters were applied then removed.
+        let show = false;
+        Object.keys(this.searchContext.ccvmFilters).forEach(ccvm => {
+            if (this.searchContext.ccvmFilters[ccvm][0] != '')
+                show = true;
+        });
+
+        return show;
+    }
+
+    orgOnChange = (org: EgIdlObject): void => {
+        this.searchContext.searchOrg = org;
+    }
+
+    addSearchRow(index: number): void {
+        this.searchContext.query.splice(index, 0, '');
+        this.searchContext.fieldClass.splice(index, 0, 'keyword');
+        this.searchContext.joinOp.splice(index, 0, '&&');
+        this.searchContext.matchOp.splice(index, 0, 'contains');
+    }
+
+    delSearchRow(index: number): void {
+        this.searchContext.query.splice(index, 1);
+        this.searchContext.fieldClass.splice(index, 1);
+        this.searchContext.joinOp.splice(index, 1);
+        this.searchContext.matchOp.splice(index, 1);
+    }
+
+    checkEnter($event: any): void {
+        if ($event.keyCode == 13) {
+            this.searchContext.pager.offset = 0;
+            this.searchByForm();
+        }
+    }
+
+    // https://stackoverflow.com/questions/42322968/angular2-dynamic-input-field-lose-focus-when-input-changes
+    trackByIdx(index: any, item: any) {
+       return index;
+    }
+
+    searchByForm(): void {
+        this.staffCat.search();
+    }
+
+    searchIsActive(): boolean {
+        return this.searchContext.searchState == CatalogSearchState.SEARCHING;
+    }
+
+}
+
+
diff --git a/Open-ILS/src/eg2/src/app/staff/circ/patron/bcsearch/bcsearch.component.html b/Open-ILS/src/eg2/src/app/staff/circ/patron/bcsearch/bcsearch.component.html
new file mode 100644 (file)
index 0000000..e83cf9e
--- /dev/null
@@ -0,0 +1,19 @@
+
+<eg-staff-banner bannerText="Search for Patron by Barcode" i18n-bannerText>
+</eg-staff-banner>
+
+<div class="col-lg-4">
+  <div class="input-group">
+    <div class="input-group-prepend">
+      <span class="input-group-text" i18n>Barcode:</span>
+    </div>
+    <input type='text' id='barcode-search-input' class="form-control" 
+      placeholder="Barcode" i18n-placeholder [ngModel]='barcode'/>
+    <div class="input-group-append">
+      <button class="btn btn-outline-secondary" 
+        (click)="findUser()" i18n>Submit</button>
+    </div>
+  </div>
+</div>
+
+
diff --git a/Open-ILS/src/eg2/src/app/staff/circ/patron/bcsearch/bcsearch.component.ts b/Open-ILS/src/eg2/src/app/staff/circ/patron/bcsearch/bcsearch.component.ts
new file mode 100644 (file)
index 0000000..86e85f3
--- /dev/null
@@ -0,0 +1,36 @@
+import {Component, OnInit, Renderer} from '@angular/core';
+import {ActivatedRoute} from '@angular/router';
+import {EgNetService} from '@eg/core/net.service';
+import {EgAuthService} from '@eg/core/auth.service';
+
+@Component({
+  templateUrl: 'bcsearch.component.html'
+})
+
+export class EgBcSearchComponent implements OnInit {
+
+    barcode: string = '';
+
+    constructor(
+        private route: ActivatedRoute,
+        private renderer: Renderer,
+        private net: EgNetService,
+        private auth: EgAuthService
+    ) {}
+
+    ngOnInit() {
+
+        this.renderer.selectRootElement('#barcode-search-input').focus();
+        this.barcode = this.route.snapshot.paramMap.get('barcode');
+
+        if (this.barcode) {
+            this.findUser();
+        }
+    }
+
+    findUser(): void {
+        alert('Searching for user ' + this.barcode);
+    }
+}
+
+
diff --git a/Open-ILS/src/eg2/src/app/staff/circ/patron/bcsearch/bcsearch.module.ts b/Open-ILS/src/eg2/src/app/staff/circ/patron/bcsearch/bcsearch.module.ts
new file mode 100644 (file)
index 0000000..cbb97b5
--- /dev/null
@@ -0,0 +1,17 @@
+import {NgModule}                from '@angular/core';
+import {EgStaffCommonModule}     from '@eg/staff/common.module';
+import {EgBcSearchRoutingModule} from './routing.module';
+import {EgBcSearchComponent}     from './bcsearch.component';
+
+@NgModule({
+  declarations: [
+    EgBcSearchComponent
+  ],
+  imports: [
+    EgStaffCommonModule,
+    EgBcSearchRoutingModule,
+  ],
+})
+
+export class EgBcSearchModule {}
+
diff --git a/Open-ILS/src/eg2/src/app/staff/circ/patron/bcsearch/routing.module.ts b/Open-ILS/src/eg2/src/app/staff/circ/patron/bcsearch/routing.module.ts
new file mode 100644 (file)
index 0000000..82f8a8b
--- /dev/null
@@ -0,0 +1,19 @@
+import {NgModule}             from '@angular/core';
+import {RouterModule, Routes} from '@angular/router';
+import {EgBcSearchComponent}  from './bcsearch.component';
+
+const routes: Routes = [
+  { path: '',
+    component: EgBcSearchComponent
+  },
+  { path: ':barcode',
+    component: EgBcSearchComponent
+  },
+];
+
+@NgModule({
+  imports: [RouterModule.forChild(routes)],
+  exports: [RouterModule]
+})
+
+export class EgBcSearchRoutingModule {}
diff --git a/Open-ILS/src/eg2/src/app/staff/circ/patron/routing.module.ts b/Open-ILS/src/eg2/src/app/staff/circ/patron/routing.module.ts
new file mode 100644 (file)
index 0000000..0f0bbb0
--- /dev/null
@@ -0,0 +1,15 @@
+import {NgModule}             from '@angular/core';
+import {RouterModule, Routes} from '@angular/router';
+
+const routes: Routes = [
+  { path: 'bcsearch',
+    loadChildren: '@eg/staff/circ/patron/bcsearch/bcsearch.module#EgBcSearchModule'
+  }
+];
+
+@NgModule({
+  imports: [RouterModule.forChild(routes)],
+  exports: [RouterModule]
+})
+
+export class EgCircPatronRoutingModule {}
diff --git a/Open-ILS/src/eg2/src/app/staff/circ/routing.module.ts b/Open-ILS/src/eg2/src/app/staff/circ/routing.module.ts
new file mode 100644 (file)
index 0000000..3a4ffe7
--- /dev/null
@@ -0,0 +1,15 @@
+import { NgModule }             from '@angular/core';
+import { RouterModule, Routes } from '@angular/router';
+
+const routes: Routes = [
+  { path: 'patron',
+    loadChildren: '@eg/staff/circ/patron/routing.module#EgCircPatronRoutingModule'
+  }
+];
+
+@NgModule({
+  imports: [RouterModule.forChild(routes)],
+  exports: [RouterModule]
+})
+
+export class EgCircRoutingModule {}
diff --git a/Open-ILS/src/eg2/src/app/staff/common.module.ts b/Open-ILS/src/eg2/src/app/staff/common.module.ts
new file mode 100644 (file)
index 0000000..61094e4
--- /dev/null
@@ -0,0 +1,67 @@
+import {NgModule, ModuleWithProviders} from '@angular/core';
+import {EgCommonModule} from '@eg/common.module';
+import {EgStaffBannerComponent} from './share/staff-banner.component';
+import {EgOrgSelectComponent} from '@eg/share/org-select/org-select.component';
+import {EgDialogComponent} from '@eg/share/dialog/dialog.component';
+import {EgConfirmDialogComponent} from '@eg/share/dialog/confirm.component';
+import {EgPromptDialogComponent} from '@eg/share/dialog/prompt.component';
+import {EgProgressDialogComponent} from '@eg/share/dialog/progress.component';
+import {EgAccessKeyDirective} from '@eg/share/accesskey/accesskey.directive';
+import {EgAccessKeyService} from '@eg/share/accesskey/accesskey.service';
+import {EgAccessKeyInfoComponent} from '@eg/share/accesskey/accesskey-info.component';
+import {EgOpChangeComponent} from '@eg/staff/share/op-change/op-change.component';
+import {EgToastService} from '@eg/share/toast/toast.service';
+import {EgToastComponent} from '@eg/share/toast/toast.component';
+import {EgStringComponent} from '@eg/share/string/string.component';
+import {EgStringService} from '@eg/share/string/string.service';
+
+/**
+ * Imports the EG common modules and adds modules common to all staff UI's.
+ */ 
+
+@NgModule({
+  declarations: [
+    EgStaffBannerComponent,
+    EgOrgSelectComponent,
+    EgDialogComponent,
+    EgConfirmDialogComponent,
+    EgPromptDialogComponent,
+    EgProgressDialogComponent,
+    EgAccessKeyDirective,
+    EgAccessKeyInfoComponent,
+    EgToastComponent,
+    EgStringComponent,
+    EgOpChangeComponent
+  ],
+  imports: [
+    EgCommonModule
+  ],
+  exports: [
+    EgCommonModule,
+    EgStaffBannerComponent,
+    EgOrgSelectComponent,
+    EgDialogComponent,
+    EgConfirmDialogComponent,
+    EgPromptDialogComponent,
+    EgProgressDialogComponent,
+    EgAccessKeyDirective,
+    EgAccessKeyInfoComponent,
+    EgToastComponent,
+    EgStringComponent,
+    EgOpChangeComponent
+  ]
+})
+
+export class EgStaffCommonModule {
+    static forRoot(): ModuleWithProviders {
+        return {
+            ngModule: EgStaffCommonModule,
+            providers: [ // Export staff-wide services
+                EgAccessKeyService,
+                EgStringService,
+                EgToastService
+            ]
+        };
+    }
+}
+
diff --git a/Open-ILS/src/eg2/src/app/staff/login.component.html b/Open-ILS/src/eg2/src/app/staff/login.component.html
new file mode 100644 (file)
index 0000000..ba474f8
--- /dev/null
@@ -0,0 +1,58 @@
+<div class="container">
+  <div class="col-lg-6 offset-lg-3">
+    <fieldset>
+      <legend class="mb-0" i18n>Sign In</legend>
+      <hr class="mt-1"/>
+      <form (ngSubmit)="handleSubmit()" #loginForm="ngForm" class="form-validated">
+
+        <div class="form-group row">
+          <label class="col-lg-4 text-right font-weight-bold" for="username" i18n>Username</label>
+          <input 
+            type="text" 
+            class="form-control col-lg-8"
+            id="username" 
+            name="username"
+            required
+            autocomplete="username"
+            i18n-placeholder
+            placeholder="Username" 
+            [(ngModel)]="args.username"/>
+        </div>
+
+        <div class="form-group row">
+          <label class="col-lg-4 text-right font-weight-bold" for="password" i18n>Password</label>
+          <input 
+            type="password" 
+            class="form-control col-lg-8"
+            id="password" 
+            name="password"
+            required
+            autocomplete="current-password"
+            i18n-placeholder
+            placeholder="Password" 
+            [(ngModel)]="args.password"/>
+        </div>
+
+        <div class="form-group row" *ngIf="workstations && workstations.length">
+          <label class="col-lg-4 text-right font-weight-bold" for="workstation" i18n>Workstation</label>
+          <select 
+            class="form-control col-lg-8" 
+            id="workstation" 
+            name="workstation"
+            required
+            [(ngModel)]="args.workstation">
+            <option *ngFor="let ws of workstations" [value]="ws.name">
+              {{ws.name}}
+            </option>
+          </select>
+        </div>
+
+        <div class="row">
+          <div class="col-lg-8 offset-lg-4 pl-0">
+            <button type="submit" class="btn btn-outline-dark" i18n>Sign in</button>
+          </div>
+        </div>
+      </form>
+    </fieldset>
+  </div>
+</div>
diff --git a/Open-ILS/src/eg2/src/app/staff/login.component.ts b/Open-ILS/src/eg2/src/app/staff/login.component.ts
new file mode 100644 (file)
index 0000000..d46bb74
--- /dev/null
@@ -0,0 +1,90 @@
+import {Component, OnInit, Renderer} from '@angular/core';
+import {Location} from '@angular/common';
+import {Router, ActivatedRoute} from '@angular/router';
+import {EgAuthService, EgAuthWsState} from '@eg/core/auth.service';
+import {EgStoreService} from '@eg/core/store.service';
+
+@Component({
+  templateUrl : './login.component.html'
+})
+
+export class EgStaffLoginComponent implements OnInit {
+
+    workstations: any[];
+
+    args = {
+      username : '',
+      password : '',
+      workstation : '',
+      type : 'staff'
+    };
+
+    constructor(
+      private router: Router,
+      private route: ActivatedRoute,
+      private ngLocation: Location,
+      private renderer: Renderer,
+      private auth: EgAuthService,
+      private store: EgStoreService 
+    ) {}
+
+    ngOnInit() {
+        console.debug('login ngOnInit()');
+
+        // clear out any stale auth data
+        this.auth.logout();
+
+        // Focus username
+        this.renderer.selectRootElement('#username').focus();
+
+        this.store.getItem('eg.workstation.all')
+        .then(list => this.workstations = list || [])
+        .then(list => this.store.getItem('eg.workstation.default'))
+        .then(defWs => this.args.workstation = defWs)
+        .then(noOp => this.applyWorkstation())
+    }
+
+    applyWorkstation() {
+        let wanted = this.route.snapshot.queryParamMap.get('workstation');
+        if (!wanted) return; // use the default
+
+        let exists = this.workstations.filter(w => w.name == wanted)[0];
+        if (exists) {
+            this.args.workstation = wanted;
+        } else {
+            console.error(`Unknown workstation requested: ${wanted}`);
+        }
+    }
+
+    handleSubmit() {
+
+        // post-login URL
+        let url: string = this.auth.redirectUrl || '/staff/splash';
+        let workstation: string = this.args.workstation;
+
+        this.auth.login(this.args).then(
+            ok => {
+                this.auth.redirectUrl = null;
+
+                if (this.auth.workstationState == EgAuthWsState.NOT_FOUND_SERVER) {
+                    // User attempted to login with a workstation that is
+                    // unknown to the server. Redirect to the WS admin page.
+                    this.router.navigate(
+                        [`/staff/admin/workstation/workstations/remove/${workstation}`]);
+                } else {
+                    // Force reload of the app after a successful login.
+                    // This allows the route resolver to re-run with a 
+                    // valid auth token and workstation.
+                    window.location.href = 
+                        this.ngLocation.prepareExternalUrl(url);
+                }
+            },
+            notOk => {
+                // indicate failure in the UI.
+            }
+        );
+    }
+}
+
+
+
diff --git a/Open-ILS/src/eg2/src/app/staff/nav.component.css b/Open-ILS/src/eg2/src/app/staff/nav.component.css
new file mode 100644 (file)
index 0000000..63d3e37
--- /dev/null
@@ -0,0 +1,72 @@
+/* remove dropdown carret for icon-based entries */
+#staff-navbar .no-caret::after {
+    display:none;
+}
+
+/* move the caret closer to the dropdown text */
+#staff-navbar {
+    padding-left: 0px;
+}
+
+#staff-navbar {
+    background: -webkit-linear-gradient(#00593d, #007a54);
+    background-color: #007a54;
+    color: #fff;
+    font-size: 14px;
+}
+
+#staff-navbar .navbar-nav {
+  padding: 4px;
+}
+
+/* align top of dropdown w/ bottom of nav */
+#staff-navbar .dropdown-menu {
+    margin-top: 7px;
+}
+#staff-navbar .material-icons {
+    padding-right:3px;
+}
+#staff-navbar .dropdown-item {
+    font-size: 14px;
+    font-weight: 400;
+    font-family: "Helvetica Neue", Helvetica, Arial, sans-serif;
+    padding-left: 0.7rem;
+    padding-right: 0.7rem;
+    margin: -4px;
+}
+
+#staff-navbar .dropdown-item .material-icons {
+  font-size: 18px;
+}
+
+#staff-navbar .nav-link {
+    color: #fff;
+    padding-top:1px;
+    padding-bottom:1px;
+}
+#staff-navbar .nav-link:hover {
+    color: #ddd;
+    cursor: pointer;
+}
+
+#staff-navbar .navbar-nav > .open > a,
+#staff-navbar .navbar-nav > .open > a:focus,
+#staff-navbar .navbar-nav > .open > a:hover {
+    background-color: #7a7a7a;
+}
+#staff-navbar .navbar-nav>.dropdown>a .caret {
+    border-top-color: #fff;
+    border-bottom-color: #fff;
+}
+#staff-navbar .navbar-nav>.dropdown>a:hover .caret {
+    border-top-color: #ddd;
+    border-bottom-color: #ddd;
+}
+
+/* Align material-icons with sibling text; otherwise they float up */
+#staff-navbar .with-material-icon, #staff-navbar .dropdown-item {
+    display: inline-flex;
+    vertical-align: middle;
+    align-items: center;
+}
+
diff --git a/Open-ILS/src/eg2/src/app/staff/nav.component.html b/Open-ILS/src/eg2/src/app/staff/nav.component.html
new file mode 100644 (file)
index 0000000..c5d5fcb
--- /dev/null
@@ -0,0 +1,230 @@
+<div id="staff-navbar" class="navbar fixed-top navbar-expand navbar-default">
+  <div class="collapse navbar-collapse">
+    <div class="navbar-nav">
+      <div class="nav-item">
+        <a i18n class="nav-link with-material-icon" 
+          routerLink="/staff/splash"
+          egAccessKey keyCtx="navbar"
+          keySpec="alt+h" i18n-keySpec
+          keyDesc="Navigate Home" i18n-keyDesc>
+          <span class="material-icons">home</span>
+        </a>
+      </div>
+    </div>
+
+    <div class="navbar-nav">
+      <div ngbDropdown class="nav-item dropdown">
+        <a ngbDropdownToggle i18n class="nav-link dropdown-toggle">
+         Search
+        </a>
+        <div class="dropdown-menu" ngbDropdownMenu>
+          <a class="dropdown-item" href="/eg/staff/circ/patron/search">
+            <span class="material-icons">person</span>
+            <span i18n>Search for Patrons</span>
+          </a>
+          <a class="dropdown-item" href="/eg/staff/cat/item/search">
+            <span class="material-icons">assignment</span>
+            <span i18n>Search for Copies by Barcode</span>
+          </a>
+          <a class="dropdown-item" routerLink="/staff/catalog/search"
+            egAccessKey keyCtx="navbar"
+            keySpec="alt+c" i18n-keySpec
+            keyDesc="Navigate To Catalog" i18n-keyDesc>
+            <span class="material-icons">search</span>
+            <span i18n>Search the Catalog</span>
+          </a>
+        </div>
+      </div>
+    </div>
+
+    <div class="navbar-nav">
+      <div ngbDropdown class="nav-item dropdown">
+        <a ngbDropdownToggle class="nav-link dropdown-toggle">
+         <span i18n>Circulation</span>
+        </a>
+        <div class="dropdown-menu" ngbDropdownMenu>
+          <a class="dropdown-item" href="/eg/staff/circ/patron/bcsearch">
+            <span class="material-icons">trending_up</span>
+            <span i18n>Check Out</span>
+          </a>
+          <a class="dropdown-item" href="/eg/staff/circ/checkin/checkin">
+            <span class="material-icons">trending_down</span>
+            <span i18n>Check In</span>
+          </a>
+          <a class="dropdown-item" href="/eg/staff/circ/checkin/capture">
+            <span class="material-icons">pin_drop</span>
+            <span i18n>Capture Holds</span>
+          </a>
+          <a class="dropdown-item" href="/eg/staff/circ/holds/pull">
+            <span class="material-icons">view_list</span>
+            <span i18n>Pull List for Hold Requests</span>
+          </a>
+          <a class="dropdown-item" href="/eg/staff/circ/renew/renew">
+            <span class="material-icons">autorenew</span>
+            <span i18n>Renew Items</span>
+          </a>
+          <a class="dropdown-item" href="/eg/staff/circ/patron/register">
+            <span class="material-icons">person_add</span>
+            <span i18n>Register Patron</span>
+          </a>
+          <a class="dropdown-item" href="/eg/staff/circ/patron/last">
+            <span class="material-icons">redo</span>
+            <span i18n>Retrieve Last Patron</span>
+          </a>
+          <a class="dropdown-item" href="/eg/staff/circ/patron/search?show_recent=1">
+            <span class="material-icons">redo</span>
+            <span i18n>Retrieve Recent Patrons</span>
+          </a>
+          <a class="dropdown-item" href="/eg/staff/circ/patron/pending/list">
+            <span class="material-icons">thumb_up</span>
+            <span i18n>Pending Patrons</span>
+          </a>
+          <a class="dropdown-item" href="/eg/staff/circ/patron/bucket/view">
+            <span class="material-icons">list</span>
+            <span i18n>User Buckets</span>
+          </a>
+          <div class="dropdown-divider"></div>
+          <a class="dropdown-item" href="/eg/staff/circ/patron/credentials">
+            <span class="material-icons">check_circle</span>
+            <span i18n>Verify Credentials</span>
+          </a>
+          <a class="dropdown-item" href="/eg/staff/circ/in_house_use/index">
+            <span class="material-icons">playlist_add</span>
+            <span i18n>Record In-House Use</span>
+          </a>
+          <a class="dropdown-item" href="/eg/staff/circ/holds/shelf">
+            <span class="material-icons">format_list_bulleted</span>
+            <span i18n>Holds Shelf</span>
+          </a>
+          <div class="dropdown-divider"></div>
+          <a class="dropdown-item" href="/eg/staff/cat/item/replace_barcode/index">
+            <span class="material-icons">library_books</span>
+            <span i18n>Replace Barcode</span>
+          </a>
+          <a class="dropdown-item" href="/eg/staff/cat/item/search">
+            <span class="material-icons">question_answer</span>
+            <span i18n>Item Status</span>
+          </a>
+          <a class="dropdown-item" href="/eg/staff/cat/item/missing_pieces">
+            <span class="material-icons">grid_on</span>
+            <span i18n>Scan Item as Missing Pieces</span>
+          </a>
+          <div class="dropdown-divider"></div>
+          <a class="dropdown-item" href="/eg/staff/cat/item/missing_pieces">
+            <span class="material-icons">redo</span>
+            <span i18n>Reprint Last Receipt</span>
+          </a>
+          <div class="dropdown-divider"></div>
+          <a class="dropdown-item" href="/eg/staff/offline-interface">
+            <span class="material-icons">signal_wifi_off</span>
+            <span i18n>Offline Circulation</span>
+          </a>
+        </div>
+      </div>
+    </div>
+
+
+    <div class="navbar-nav">
+      <div ngbDropdown class="nav-item dropdown">
+        <a ngbDropdownToggle i18n class="nav-link dropdown-toggle">
+         Cataloging
+        </a>
+        <div class="dropdown-menu" ngbDropdownMenu>
+          <a class="dropdown-item"
+              routerLink="/staff/catalog/search">
+            <span class="material-icons">search</span>
+            <span i18n>Search the Catalog</span>
+          </a>
+        </div>
+      </div>
+    </div>
+
+    <div class="navbar-nav">
+      <div ngbDropdown class="nav-item dropdown">
+        <a ngbDropdownToggle i18n class="nav-link dropdown-toggle">
+          Acquisitions
+        </a>
+        <div class="dropdown-menu" ngbDropdownMenu>
+          <a class="dropdown-item"
+              routerLink="/staff/catalog/search">
+            <span class="material-icons">search</span>
+            <span i18n>TODO</span>
+          </a>
+        </div>
+      </div>
+    </div>
+
+    <div class="navbar-nav">
+      <div ngbDropdown class="nav-item dropdown">
+        <a ngbDropdownToggle i18n class="nav-link dropdown-toggle">
+          Booking
+        </a>
+        <div class="dropdown-menu" ngbDropdownMenu>
+          <a class="dropdown-item"
+              routerLink="/staff/catalog/search">
+            <span class="material-icons">search</span>
+            <span i18n>TODO</span>
+          </a>
+        </div>
+      </div>
+    </div>
+
+    <div class="navbar-nav">
+      <div ngbDropdown class="nav-item dropdown">
+        <a ngbDropdownToggle i18n class="nav-link dropdown-toggle">
+          Administration
+        </a>
+        <div class="dropdown-menu" ngbDropdownMenu>
+          <a class="dropdown-item"
+              routerLink="/staff/admin/workstation/workstations/manage">
+            <span class="material-icons">computer</span>
+            <span i18n>Registered Workstations</span>
+          </a>
+        </div>
+      </div>
+    </div>
+
+
+    <div class="navbar-nav mr-auto"></div>
+    <div class="navbar-nav" *ngIf="user()">
+      <span i18n>{{user()}} @ {{workstation()}}</span>
+    </div>
+    <div class="navbar-nav" *ngIf="user()">
+      <div ngbDropdown class="nav-item dropdown" placement="bottom-right">
+        <a ngbDropdownToggle i18n 
+          i18n-title
+          title="Log out and more..."
+          class="nav-link dropdown-toggle no-caret with-material-icon">
+          <i class="material-icons">list</i>
+        </a>
+        <div class="dropdown-menu" ngbDropdownMenu>
+          <eg-op-change #navOpChange
+            i18n-failMessage
+            i18n-successMessage
+            failMessage="Operator Change Failed"
+            successMessage="Operator Change Succeeded">
+          </eg-op-change>
+          <a class="dropdown-item" *ngIf="!opChangeActive()" 
+            (click)="navOpChange.open()">
+            <span class="material-icons">transform</span>
+            <span i18n>Change Operator</span>
+          </a>
+          <a *ngIf="opChangeActive()" class="dropdown-item" 
+            (click)="navOpChange.restore()">
+            <span class="material-icons">transform</span>
+            <span i18n>Restore Operator</span>
+          </a>
+          <a class="dropdown-item" (click)="logout()">
+            <span class="material-icons">lock_outline</span>
+            <span i18n>Logout</span>
+          </a>
+          <a class="dropdown-item" href="/eg/staff/about">
+            <span class="material-icons">info_outline</span>
+            <span i18n>About</span>
+          </a>
+        </div>
+      </div>
+    </div>
+  </div>
+</div>
+
diff --git a/Open-ILS/src/eg2/src/app/staff/nav.component.ts b/Open-ILS/src/eg2/src/app/staff/nav.component.ts
new file mode 100644 (file)
index 0000000..14db1d8
--- /dev/null
@@ -0,0 +1,42 @@
+import {Component, OnInit, ViewChild} from '@angular/core';
+import {ActivatedRoute, Router} from '@angular/router';
+import {EgAuthService} from '@eg/core/auth.service';
+
+@Component({
+    selector: 'eg-staff-nav-bar',
+    styleUrls: ['nav.component.css'],
+    templateUrl: 'nav.component.html'
+})
+
+export class EgStaffNavComponent implements OnInit {
+
+    constructor(
+        private router: Router,
+        private auth: EgAuthService
+    ) {}
+
+    ngOnInit() {
+    }
+
+    user() {
+        return this.auth.user() ? this.auth.user().usrname() : '';
+    }
+
+    workstation() {
+        return this.auth.user() ? this.auth.workstation() : '';
+    }
+
+    opChangeActive(): boolean {
+        return this.auth.opChangeIsActive();
+    }
+
+    // Broadcast to all tabs that we're logging out.
+    // Redirect to the login page, which performs the remaining 
+    // logout duties.
+    logout(): void {
+        this.auth.broadcastLogout();
+        this.router.navigate(['/staff/login']);
+    }
+}
+
+
diff --git a/Open-ILS/src/eg2/src/app/staff/resolver.service.ts b/Open-ILS/src/eg2/src/app/staff/resolver.service.ts
new file mode 100644 (file)
index 0000000..301979f
--- /dev/null
@@ -0,0 +1,127 @@
+import {Injectable} from '@angular/core';
+import {Location} from '@angular/common';
+import {Observable, Observer} from 'rxjs/Rx';
+import {Router, Resolve, RouterStateSnapshot,
+        ActivatedRoute, ActivatedRouteSnapshot} from '@angular/router';
+import {EgStoreService} from '@eg/core/store.service';
+import {EgNetService} from '@eg/core/net.service';
+import {EgAuthService, EgAuthWsState} from '@eg/core/auth.service';
+import {EgPermService} from '@eg/core/perm.service';
+
+const LOGIN_PATH = '/staff/login';
+const WS_MANAGE_PATH = '/staff/admin/workstation/workstations/manage';
+
+/**
+ * Load data used by all staff modules.
+ */
+@Injectable()
+export class EgStaffResolver implements Resolve<Observable<any>> {
+
+    // Tracks the primary resolve observable.
+    observer: Observer<any>;
+
+    constructor(
+        private router: Router, 
+        private route: ActivatedRoute, 
+        private ngLocation: Location,
+        private store: EgStoreService,
+        private net: EgNetService,
+        private auth: EgAuthService,
+        private perm: EgPermService,
+    ) {}
+
+    resolve(
+        route: ActivatedRouteSnapshot, 
+        state: RouterStateSnapshot): Observable<any> {
+
+        console.debug('EgStaffResolver:resolve()');
+
+        // Staff cookies stay in /$base/staff/
+        // NOTE: storing session data at '/' so it can be shared by
+        // Angularjs apps.
+        this.store.loginSessionBasePath = '/';
+        // ^-- = this.ngLocation.prepareExternalUrl('/staff');
+
+        // Not sure how to get the path without params... using this for now.
+        let path = state.url.split('?')[0]
+        if (path == '/staff/login') return Observable.of(true);
+
+        let observable: Observable<any> 
+            = Observable.create(o => this.observer = o);
+
+        this.auth.testAuthToken().then(
+            tokenOk => {
+                console.debug('EgStaffResolver: authtoken verified');
+                this.confirmStaffPerms().then(
+                    hasPerms => {
+                        this.auth.verifyWorkstation().then(
+                            wsOk => {
+                                this.loadStartupData()
+                                .then(ok => this.observer.complete())
+                            },
+                            wsNotOk => this.handleInvalidWorkstation(path)
+                        );
+                    }, 
+                    hasNotPerms => {
+                        this.observer.error(
+                            'User does not have staff permissions');
+                    }
+                );
+            }, 
+            tokenNotOk => this.handleInvalidToken(state)
+        );
+
+        return observable;
+    }
+
+
+    // Confirm the user has the STAFF_LOGIN permission anywhere before
+    // allowing the staff sub-tree to load. This will prevent users
+    // with valid, non-staff authtokens from attempting to connect and
+    // subsequently getting redirected to the workstation admin page
+    // (since they won't have a valid WS either).
+    confirmStaffPerms(): Promise<any> {
+        return new Promise((resolve, reject) => {
+            this.perm.hasWorkPermAt(['STAFF_LOGIN']).then(
+                permMap => {
+                    if (permMap.STAFF_LOGIN.length) {
+                        resolve('perm check OK');
+                    } else {
+                        reject('perm check faield');
+                    }
+                }
+            );
+        });
+    }
+    
+
+    // A page that's not the login page was requested without a 
+    // valid auth token.  Send the caller back to the login page.
+    handleInvalidToken(state: RouterStateSnapshot): void {
+        console.debug('EgStaffResolver: authtoken is not valid');
+        this.auth.redirectUrl = state.url;
+        this.router.navigate([LOGIN_PATH]);
+        this.observer.error('invalid or no auth token');
+    }
+
+    handleInvalidWorkstation(path: string): void {
+
+        if (path.startsWith(WS_MANAGE_PATH)) {
+            // user is navigating to the WS admin page.
+            this.observer.complete();
+        } else {
+            this.router.navigate([WS_MANAGE_PATH]);
+            this.observer.error(`Auth session linked to no 
+                workstation or a workstation unknown to this browser`);
+        }
+    }
+
+    /**
+     * Fetches data common to all staff interfaces.
+     */
+    loadStartupData(): Promise<void> {
+        console.debug('EgStaffResolver:loadStartupData()');
+        return Promise.resolve();
+    }
+}
+
diff --git a/Open-ILS/src/eg2/src/app/staff/routing.module.ts b/Open-ILS/src/eg2/src/app/staff/routing.module.ts
new file mode 100644 (file)
index 0000000..134140a
--- /dev/null
@@ -0,0 +1,48 @@
+import {NgModule} from '@angular/core';
+import {RouterModule, Routes} from '@angular/router';
+import {EgStaffResolver} from './resolver.service';
+import {EgStaffComponent} from './staff.component';
+import {EgStaffLoginComponent} from './login.component';
+import {EgStaffSplashComponent} from './splash.component';
+
+// Not using 'canActivate' because it's called before all resolvers,
+// even the parent resolver, but the resolvers parse the IDL, load settings, 
+// etc.  Chicken, meet egg.
+
+const routes: Routes = [{
+  path: '',
+  component: EgStaffComponent,
+  resolve: {staffResolver : EgStaffResolver},
+  children: [{
+    path: '',
+    redirectTo: 'splash',
+    pathMatch: 'full',
+  }, {
+    path: 'login',
+    component: EgStaffLoginComponent
+  }, {
+    path: 'splash',
+    component: EgStaffSplashComponent
+  }, {
+    path: 'circ',
+    loadChildren : '@eg/staff/circ/routing.module#EgCircRoutingModule'
+  }, {
+    path: 'catalog',
+    loadChildren : '@eg/staff/catalog/catalog.module#EgCatalogModule'
+  }, {
+    path: 'sandbox',
+    loadChildren : '@eg/staff/sandbox/sandbox.module#EgSandboxModule'
+  }, {
+    path: 'admin',
+    loadChildren : '@eg/staff/admin/routing.module#EgAdminRoutingModule'
+  }]
+}];
+
+@NgModule({
+  imports: [RouterModule.forChild(routes)],
+  exports: [RouterModule],
+  providers: [EgStaffResolver]
+})
+
+export class EgStaffRoutingModule {}
+
diff --git a/Open-ILS/src/eg2/src/app/staff/sandbox/README b/Open-ILS/src/eg2/src/app/staff/sandbox/README
new file mode 100644 (file)
index 0000000..66e77dc
--- /dev/null
@@ -0,0 +1 @@
+Place for experimenting with code.
diff --git a/Open-ILS/src/eg2/src/app/staff/sandbox/routing.module.ts b/Open-ILS/src/eg2/src/app/staff/sandbox/routing.module.ts
new file mode 100644 (file)
index 0000000..0fa4d3e
--- /dev/null
@@ -0,0 +1,16 @@
+import {NgModule} from '@angular/core';
+import {RouterModule, Routes} from '@angular/router';
+import {EgSandboxComponent} from './sandbox.component';
+
+const routes: Routes = [{ 
+  path: '',
+  component: EgSandboxComponent
+}];
+
+@NgModule({
+  imports: [RouterModule.forChild(routes)],
+  exports: [RouterModule],
+  providers: []
+})
+
+export class EgSandboxRoutingModule {}
diff --git a/Open-ILS/src/eg2/src/app/staff/sandbox/sandbox.component.html b/Open-ILS/src/eg2/src/app/staff/sandbox/sandbox.component.html
new file mode 100644 (file)
index 0000000..f2ae275
--- /dev/null
@@ -0,0 +1,79 @@
+
+<eg-staff-banner bannerText="Sandbox" i18n-bannerText>
+</eg-staff-banner>
+
+<!-- FM Editor Experiments ----------------------------- -->
+<div class="row mb-3">
+  <ng-template #descriptionTemplate 
+      let-field="field" let-record="record" let-hello="hello">
+  <!-- example custom template for editing the 'description' field -->
+    <textarea
+      placeholder="{{hello}}"
+      class="form-control"
+      name="{{field.name}}"
+      [readonly]="field.readOnly"
+      [required]="field.isRequired()"
+      [ngModel]="record[field.name]()"
+      (ngModelChange)="record[field.name]($event)">
+    </textarea>
+  </ng-template>
+  <fm-record-editor #fmRecordEditor 
+      idlClass="cmrcfld" mode="create" 
+      [customFieldTemplates]="{description:{template:descriptionTemplate,context:{'hello':'goodbye'}}}"
+      recordId="1" orgDefaultAllowed="owner">
+  </fm-record-editor>
+  <button class="btn btn-dark" (click)="fmRecordEditor.open({size:'lg'})">
+      Fm Record Editor
+  </button>
+</div>
+<!-- / FM Editor Experiments ----------------------------- -->
+
+<!-- Progress Dialog Experiments ----------------------------- -->
+<div class="row mb-3">
+  <eg-progress-dialog #progressDialog>
+  </eg-progress-dialog>
+  <button class="btn btn-light" (click)="showProgress()">Test Progress Dialog</button>
+</div>
+<!-- /Progress Dialog Experiments ----------------------------- -->
+
+<!-- eg toast -->
+<div class="row mb-3">
+   <button class="btn btn-info" (click)="testToast()">Test Toast Message</button>
+</div>
+
+<!-- eg strings -->
+<!--
+<div class="row mb-3">
+    <eg-string #helloString text="Hello, {{name}}" i18n-text></eg-string>
+    <button class="btn btn-success" (click)="testStrings()">Test Strings</button>
+</div>
+-->
+
+<div class="row mb-3">
+    <ng-template #helloStrTmpl let-name="name" i18n>Hello, {{name}}</ng-template>
+    <!--
+    <eg-string #helloStr key="helloKey" [template]="helloStrTmpl"></eg-string>
+    -->
+    <eg-string key="staff.sandbox.test" [template]="helloStrTmpl"></eg-string>
+    <button class="btn btn-success" (click)="testStrings()">Test Strings</button>
+</div>
+
+
+<!-- grid stuff -->
+
+
+<eg-grid #cbtGrid idlClass="cbt" [dataSource]="btSource">
+</eg-grid>
+
+<!--
+<eg-grid #testGrid [dataSource]="gridDataSource">
+  <eg-grid-column  name="name" label="Name" i18n-label></eg-grid-column>
+  <eg-grid-column  name="state" label="State" i18n-label></eg-grid-column>
+</eg-grid>
+-->
+
+
+
+
+
+
diff --git a/Open-ILS/src/eg2/src/app/staff/sandbox/sandbox.component.ts b/Open-ILS/src/eg2/src/app/staff/sandbox/sandbox.component.ts
new file mode 100644 (file)
index 0000000..78d2263
--- /dev/null
@@ -0,0 +1,82 @@
+import {Component, OnInit, ViewChild, Input, TemplateRef} from '@angular/core';
+import {EgProgressDialogComponent} from '@eg/share/dialog/progress.component';
+import {EgToastService} from '@eg/share/toast/toast.service';
+import {EgStringService} from '@eg/share/string/string.service';
+import {Observable} from 'rxjs/Rx';
+import {EgGridDataSource} from '@eg/share/grid/grid-data-source';
+import {EgIdlService, EgIdlObject} from '@eg/core/idl.service';
+import {EgPcrudService} from '@eg/core/pcrud.service';
+import {Pager} from '@eg/share/util/pager';
+
+@Component({
+  templateUrl: 'sandbox.component.html'
+})
+export class EgSandboxComponent implements OnInit {
+
+    @ViewChild('progressDialog')
+    private progressDialog: EgProgressDialogComponent;
+
+    //@ViewChild('helloStr') private helloStr: EgStringComponent;
+
+    gridDataSource: EgGridDataSource = new EgGridDataSource();
+
+    btSource: EgGridDataSource = new EgGridDataSource();
+
+    testStr: string;
+    @Input() set testString(str: string) {
+        this.testStr = str;
+    }
+
+    name: string = 'Jane';
+
+    constructor(
+        private idl: EgIdlService,
+        private pcrud: EgPcrudService,
+        private strings: EgStringService,
+        private toast: EgToastService
+    ) {}
+
+    ngOnInit() {
+
+        this.gridDataSource.data = [
+            {name: 'Jane', state: 'AZ'},
+            {name: 'Al', state: 'CA'},
+            {name: 'The Tick', state: 'TX'}
+        ];
+
+        this.btSource.getRows = (pager: Pager) => {
+            return this.pcrud.retrieveAll('cbt', {
+                offset: pager.offset, 
+                limit: pager.limit,
+                order_by: {cbt: 'name'}
+            });
+        }
+    }
+
+    showProgress() {
+        this.progressDialog.open();
+
+        // every 250ms emit x*10 for 0-10
+        Observable.timer(0, 250).map(x => x * 10).take(11).subscribe(
+            val => this.progressDialog.update({value: val, max: 100}),
+            err => {},
+            ()  => this.progressDialog.close()
+        );
+    }
+
+    testToast() {
+        this.toast.success('HELLO TOAST TEST');
+        setTimeout(() => this.toast.danger('DANGER TEST AHHH!'), 4000);
+    }
+
+    testStrings() {
+        this.strings.interpolate('staff.sandbox.test', {name : 'janey'})
+            .then(txt => this.toast.success(txt));
+
+        setTimeout(() => {
+            this.strings.interpolate('staff.sandbox.test', {name : 'johnny'})
+                .then(txt => this.toast.success(txt));
+        }, 4000);
+    }
+}
+
diff --git a/Open-ILS/src/eg2/src/app/staff/sandbox/sandbox.module.ts b/Open-ILS/src/eg2/src/app/staff/sandbox/sandbox.module.ts
new file mode 100644 (file)
index 0000000..6302e73
--- /dev/null
@@ -0,0 +1,24 @@
+import {NgModule} from '@angular/core';
+import {EgStaffCommonModule} from '@eg/staff/common.module';
+import {EgSandboxRoutingModule} from './routing.module';
+import {EgSandboxComponent} from './sandbox.component';
+import {FmRecordEditorComponent} from '@eg/share/fm-editor/fm-editor.component';
+import {EgGridModule} from '@eg/share/grid/grid.module';
+
+@NgModule({
+  declarations: [
+    EgSandboxComponent,
+    FmRecordEditorComponent
+  ],
+  imports: [
+    EgStaffCommonModule,
+    EgSandboxRoutingModule,
+    EgGridModule
+  ],
+  providers: [
+  ]
+})
+
+export class EgSandboxModule { 
+
+}
diff --git a/Open-ILS/src/eg2/src/app/staff/share/README b/Open-ILS/src/eg2/src/app/staff/share/README
new file mode 100644 (file)
index 0000000..1d6d167
--- /dev/null
@@ -0,0 +1 @@
+Classes, services, and components shared in the staff app.
diff --git a/Open-ILS/src/eg2/src/app/staff/share/bib-summary/bib-summary.component.html b/Open-ILS/src/eg2/src/app/staff/share/bib-summary/bib-summary.component.html
new file mode 100644 (file)
index 0000000..6626608
--- /dev/null
@@ -0,0 +1,66 @@
+
+<div class='eg-bib-summary card tight-card w-100' *ngIf="summary">
+  <div class="card-header d-flex">
+    <div class="font-weight-bold">
+      Record Summary
+    </div>
+    <div class="flex-1"></div>
+    <div>
+      <a class="with-material-icon no-href text-primary" 
+        title="Show More" i18n-title
+        *ngIf="!expandDisplay" (click)="expandDisplay=true">
+        <span class="material-icons">expand_more</span>
+      </a>
+      <a class="with-material-icon no-href text-primary" 
+        title="Show Less" i18n-title
+        *ngIf="expandDisplay" (click)="expandDisplay=false">
+        <span class="material-icons">expand_less</span>
+      </a>
+    </div>
+  </div>
+  <div class="card-body">
+    <ul class="list-group list-group-flush">
+      <li class="list-group-item">
+        <div class="d-flex">
+          <div class="flex-1 font-weight-bold" i18n>Title:</div>
+          <div class="flex-3">{{summary.title}}</div>
+          <div class="flex-1 font-weight-bold pl-1" i18n>Edition:</div>
+          <div class="flex-1">{{summary.edition}}</div>
+          <div class="flex-1 font-weight-bold" i18n>TCN:</div>
+          <div class="flex-1">{{summary.tcn_value}}</div>
+          <div class="flex-1 font-weight-bold pl-1" i18n>Created By:</div>
+          <div class="flex-1" *ngIf="summary.creator.usrname">
+            {{summary.creator.usrname()}}
+          </div>
+        </div>
+      </li>
+      <li class="list-group-item" *ngIf="expandDisplay">
+        <div class="d-flex">
+          <div class="flex-1 font-weight-bold" i18n>Author:</div>
+          <div class="flex-3">{{summary.author}}</div>
+          <div class="flex-1 font-weight-bold pl-1" i18n>Pubdate:</div>
+          <div class="flex-1">{{summary.pubdate}}</div>
+          <div class="flex-1 font-weight-bold" i18n>Database ID:</div>
+          <div class="flex-1">{{summary.id}}</div>
+          <div class="flex-1 font-weight-bold pl-1" i18n>Last Edited By:</div>
+          <div class="flex-1" *ngIf="summary.editor.usrname">
+            {{summary.editor.usrname()}}
+          </div>
+        </div>
+      </li>
+      <li class="list-group-item" *ngIf="expandDisplay">
+        <div class="d-flex">
+          <div class="flex-1 font-weight-bold" i18n>Bib Call #:</div>
+          <div class="flex-3">{{summary.callNumber}}</div>
+          <div class="flex-1 font-weight-bold" i18n>Record Owner:</div>
+          <div class="flex-1">TODO</div>
+          <div class="flex-1 font-weight-bold pl-1" i18n>Created On:</div>
+          <div class="flex-1">{{summary.create_date | date:'shortDate'}}</div>
+          <div class="flex-1 font-weight-bold pl-1" i18n>Last Edited On:</div>
+          <div class="flex-1">{{summary.edit_date | date:'shortDate'}}</div>
+        </div>
+      </li>
+    </ul>
+  </div>
+</div>
+
diff --git a/Open-ILS/src/eg2/src/app/staff/share/bib-summary/bib-summary.component.ts b/Open-ILS/src/eg2/src/app/staff/share/bib-summary/bib-summary.component.ts
new file mode 100644 (file)
index 0000000..c672add
--- /dev/null
@@ -0,0 +1,76 @@
+import {Component, OnInit, Input} from '@angular/core';
+import {EgNetService} from '@eg/core/net.service';
+import {EgPcrudService} from '@eg/core/pcrud.service';
+import {EgCatalogService} from '@eg/share/catalog/catalog.service';
+
+@Component({
+  selector: 'eg-bib-summary',
+  templateUrl: 'bib-summary.component.html'
+})
+export class EgBibSummaryComponent implements OnInit {
+
+    initDone: boolean = false;
+
+    // If provided, the record will be fetched by the component.
+    @Input() recordId: number;
+
+    // Otherwise, we'll use the provided bib summary object.
+    summary: any;
+    @Input() set bibSummary(s: any) {
+        this.summary = s;
+        if (this.initDone) this.fetchBibCallNumber();
+    }
+
+    expandDisplay: boolean = true;
+
+    constructor(
+        private cat: EgCatalogService,
+        private net: EgNetService,
+        private pcrud: EgPcrudService
+    ) {}
+
+    ngOnInit() { 
+        this.initDone = true;
+        if (this.summary) {
+            this.fetchBibCallNumber();
+        } else {
+            if (this.recordId) this.loadSummary();
+        }
+    }
+
+    loadSummary(): void {
+        this.cat.getBibSummary(this.recordId).then(summary => { 
+            this.summary = summary;
+            this.fetchBibCallNumber();
+            
+            // Flesh the user data
+            this.pcrud.search('au', {id: [summary.creator, summary.editor]})
+            .subscribe(user => {
+                if (user.id() == summary.creator)
+                    summary.creator = user;
+                if (user.id() == summary.editor)
+                    summary.editor = user;
+            })
+        });
+    }
+
+    fetchBibCallNumber(): void {
+        if (!this.summary || this.summary.callNumber) return;
+
+        // TODO labelClass = cat.default_classification_scheme YAOUS
+        let labelClass = 1;
+
+        this.net.request(
+            'open-ils.cat',
+            'open-ils.cat.biblio.record.marc_cn.retrieve',
+            this.summary.id, labelClass
+        ).subscribe(cnArray => {
+            if (cnArray && cnArray.length > 0) {
+                let key1 = Object.keys(cnArray[0])[0];
+                this.summary.callNumber = cnArray[0][key1];
+            }
+        });
+    }
+}
+
+
diff --git a/Open-ILS/src/eg2/src/app/staff/share/op-change/op-change.component.html b/Open-ILS/src/eg2/src/app/staff/share/op-change/op-change.component.html
new file mode 100644 (file)
index 0000000..3befa3e
--- /dev/null
@@ -0,0 +1,65 @@
+<ng-template #dialogContent>
+  <div class="modal-header bg-info">
+    <h4 class="modal-title" i18n>Change Operator</h4>
+    <button type="button" class="close" 
+      i18n-aria-label aria-label="Close" 
+      (click)="dismiss('cross_click')">
+      <span aria-hidden="true">&times;</span>
+    </button>
+  </div>
+  <div class="modal-body">
+    <form class="form-validated">
+      <div class="form-group row">
+        <label class="col-lg-4 text-right font-weight-bold" for="username" i18n>Username</label>
+        <input 
+          type="text" 
+          class="form-control col-lg-7"
+          id="username" 
+          name="username"
+          required
+          (keyup)="checkEnter($event)"
+          autocomplete="username"
+          i18n-placeholder
+          placeholder="Username..." 
+          [(ngModel)]="username"/>
+      </div>
+
+      <div class="form-group row">
+        <label class="col-lg-4 text-right font-weight-bold" 
+            for="password" i18n>Password</label>
+        <input 
+          type="password" 
+          class="form-control col-lg-7"
+          id="password" 
+          name="password"
+          required
+          (keyup)="checkEnter($event)"
+          autocomplete="current-password"
+          i18n-placeholder
+          placeholder="Password..." 
+          [(ngModel)]="password"/>
+      </div>
+
+      <div class="form-group row">
+        <label class="col-lg-4 text-right font-weight-bold" 
+            for="loginType" i18n>Login Type</label>
+        <select 
+          class="form-control col-lg-7" 
+          id="loginType" 
+          name="loginType"
+          placeholder="Login Type..."
+          i18n-placeholder
+          required
+          [(ngModel)]="loginType">
+          <option value="temp" selected i18n>Temporary</option>                   
+          <option value="staff" i18n>Staff</option>             
+          <option value="persist" i18n>Persistent</option>      
+        </select>
+      </div>
+    </form>
+  </div>
+  <div class="modal-footer">
+    <button (click)="login()" class="btn btn-info" i18n>OK/Continue</button>
+    <button (click)="dismiss('canceled')" class="btn btn-warning ml-2" i18n>Cancel</button>
+  </div>
+</ng-template>
diff --git a/Open-ILS/src/eg2/src/app/staff/share/op-change/op-change.component.ts b/Open-ILS/src/eg2/src/app/staff/share/op-change/op-change.component.ts
new file mode 100644 (file)
index 0000000..39444f6
--- /dev/null
@@ -0,0 +1,81 @@
+import {Component, OnInit, Input, Renderer} from '@angular/core';
+import {EgToastService} from '@eg/share/toast/toast.service';               
+import {EgAuthService} from '@eg/core/auth.service';
+import {EgDialogComponent} from '@eg/share/dialog/dialog.component';
+import {NgbModal} from '@ng-bootstrap/ng-bootstrap';
+
+@Component({
+  selector: 'eg-op-change',
+  templateUrl: 'op-change.component.html'
+})
+
+export class EgOpChangeComponent 
+    extends EgDialogComponent implements OnInit {
+
+    @Input() username: string;
+    @Input() password: string;
+    @Input() loginType: string = 'temp';
+
+    @Input() successMessage: string;
+    @Input() failMessage: string;
+
+       constructor(
+        private modal: NgbModal, // required for passing to parent
+        private renderer: Renderer,
+        private toast: EgToastService,
+        private auth: EgAuthService) {
+        super(modal);
+    }
+
+    ngOnInit() {
+
+        // Focus the username any time the dialog is opened.
+        this.onOpen$.subscribe(
+            val => this.renderer.selectRootElement('#username').focus()
+        );
+    }
+
+    checkEnter($event: any): void {
+        if ($event.keyCode == 13)
+            this.login();
+    }
+
+    login(): Promise<any> {
+        if (!(this.username && this.password)) 
+            return Promise.reject('Missing Params');
+        
+        return this.auth.login(
+            {   username    : this.username, 
+                password    : this.password, 
+                workstation : this.auth.workstation(),
+                type        : this.loginType
+            },  true        // isOpChange
+        ).then(
+            ok => {
+                this.password = '';
+                this.username = '';
+
+                // Fetch the user object
+                this.auth.testAuthToken().then(
+                    ok => {
+                        this.close();
+                        this.toast.success(this.successMessage);
+                    }
+                );
+            },
+            notOk => {
+                this.password = '';
+                this.toast.danger(this.failMessage);
+            }
+        );
+    }
+
+    restore(): Promise<any> { 
+        return this.auth.undoOpChange().then(
+            ok => this.toast.success(this.successMessage),
+            err => this.toast.danger(this.failMessage)
+        );
+    }
+}
+
+
diff --git a/Open-ILS/src/eg2/src/app/staff/share/staff-banner.component.ts b/Open-ILS/src/eg2/src/app/staff/share/staff-banner.component.ts
new file mode 100644 (file)
index 0000000..3d22b88
--- /dev/null
@@ -0,0 +1,15 @@
+import {Component, OnInit, Input} from '@angular/core';
+
+@Component({
+  selector: 'eg-staff-banner',
+  template: 
+    '<div class="lead alert alert-primary text-center pt-1 pb-1" role="alert">' +
+      '<span>{{bannerText}}</span>' +
+    '</div>'
+})
+
+export class EgStaffBannerComponent {
+    @Input() public bannerText: string;
+}
+
+
diff --git a/Open-ILS/src/eg2/src/app/staff/splash.component.html b/Open-ILS/src/eg2/src/app/staff/splash.component.html
new file mode 100644 (file)
index 0000000..0018fc3
--- /dev/null
@@ -0,0 +1,128 @@
+
+
+<style>
+    /* TODO change BS color scheme so this isn't necessary */
+    .bg-evergreen {
+      background: -webkit-linear-gradient(#00593d, #007a54);
+      background-color: #007a54;
+      color: #fff;
+    }
+
+    /* Match the ang1 splash page */
+    .card-header {
+        color: #3c763d;
+        background-color: #dff0d8;
+        border-color: #d6e9c6;
+    }
+</style>
+
+<div class="container">
+
+  <!-- header icon -->
+  <div class="row mb-3">
+    <div class="col-lg-12 text-center">
+      <img src="/images/portal/logo.png"/>
+    </div>
+  </div>
+
+  <div class="row" id="splash-nav">
+    <div class="col-lg-4">
+      <div class="card">
+        <div class="card-header">
+          <div class="panel-title text-center">Circulation and Patrons</div>
+        </div>
+        <div class="card-body">
+          <div class="list-group">
+            <div class="list-group-item border-0 p-2">
+              <img src="/images/portal/forward.png"/>
+              <a href="/eg/staff/circ/patron/bcsearch">Check Out Items</a>
+            </div>
+            <div class="list-group-item border-0 p-2">
+              <img src="/images/portal/back.png"/>
+              <a href="/eg/staff/circ/checkin/index">Check In Items</a>
+            </div>
+            <div class="list-group-item border-0 p-2">
+              <img src="/images/portal/retreivepatron.png"/>
+              <a href="/eg/staff/circ/patron/search">Search For Patron By Name</a>
+            </div>
+          </div>
+        </div>
+      </div>
+    </div>
+
+    <div class="col-lg-4">
+      <div class="card">
+        <div class="card-header">
+          <div class="panel-title text-center">Item Search and Cataloging</div>
+        </div>
+        <div class="card-body">
+          <div class="list-group">
+            <div class="list-group-item border-0 p-2">
+              <div class="input-group">
+                <input type="text" class="form-control" 
+                  [(ngModel)]="catSearchQuery"
+                  id='catalog-search-input'
+                  (keyup)="checkEnter($event)"
+                  i18n-placeholder placeholder="Search for...">
+                <span class="input-group-btn">
+                  <button class="btn btn-outline-secondary" 
+                    (click)="searchCatalog()" type="button">
+                    Search
+                  </button>
+                </span>
+                  <!--
+                  <input focus-me="focus_search" 
+                      class="form-control" ng-model="cat_query" type="text" 
+                      ng-keypress="catalog_search($event)"
+                      placeholder="Search catalog for..."/>
+                  <button class='btn btn-light' ng-click="catalog_search()">
+                      Search
+                  </button>
+                  -->
+              </div>
+            </div>
+            <div class="list-group-item border-0 p-2">
+              <img src="/images/portal/bucket.png"/>
+              <a href="/eg/staff/cat/bucket/record/">Record Buckets</a>
+            </div>
+            <div class="list-group-item border-0 p-2">
+              <img src="/images/portal/bucket.png"/>
+              <a href="/eg/staff/cat/bucket/copy/">Copy Buckets</a>
+            </div>
+          </div>
+        </div>
+      </div>
+    </div>
+
+    <div class="col-lg-4">
+      <div class="card">
+        <div class="card-header">
+          <div class="panel-title text-center">Administration</div>
+        </div>
+        <div class="card-body">
+          <div class="list-group">
+            <div class="list-group-item border-0 p-2">
+              <img src="/images/portal/helpdesk.png"/>
+              <a target="_top" href="http://docs.evergreen-ils.org/">
+                Evergreen Documentation
+              </a>
+            </div>
+            <div class="list-group-item border-0 p-2">
+              <img src="/images/portal/helpdesk.png"/>
+              <a target="_top" href="/eg/staff/admin/workstation/index">
+                Workstation Administration
+              </a>
+            </div>
+            <div class="list-group-item border-0 p-2">
+              <img src="/images/portal/reports.png"/>
+              <a target="_top" href="/eg/staff/reporter/legacy/main">
+                Reports
+              </a>
+            </div>
+          </div>
+        </div>
+      </div>
+    </div>
+  </div>
+</div>
+
diff --git a/Open-ILS/src/eg2/src/app/staff/splash.component.ts b/Open-ILS/src/eg2/src/app/staff/splash.component.ts
new file mode 100644 (file)
index 0000000..beba23a
--- /dev/null
@@ -0,0 +1,38 @@
+import {Component, OnInit, Renderer} from '@angular/core';
+import {Router} from '@angular/router';
+
+@Component({
+    templateUrl: 'splash.component.html'
+})
+
+export class EgStaffSplashComponent implements OnInit {
+
+    catSearchQuery: string;
+
+    constructor(
+        private renderer: Renderer,
+        private router: Router
+    ) {}
+
+    ngOnInit() {
+
+        // Focus catalog search form
+        this.renderer.selectRootElement('#catalog-search-input').focus();
+    }
+
+    checkEnter($event: any): void {
+        if ($event.keyCode == 13)
+            this.searchCatalog();
+    }
+
+    searchCatalog(): void {
+        if (!this.catSearchQuery) return;
+
+        this.router.navigate(
+            ['/staff/catalog/search'], 
+            {queryParams: {query : this.catSearchQuery}}
+        );
+    }
+}
+
+
diff --git a/Open-ILS/src/eg2/src/app/staff/staff.component.css b/Open-ILS/src/eg2/src/app/staff/staff.component.css
new file mode 100644 (file)
index 0000000..508d879
--- /dev/null
@@ -0,0 +1,8 @@
+#staff-content-container {
+  width: 95%;
+  margin-top:56px;
+  padding-right: 10px;
+  padding-left: 10px;
+  margin-right: auto;
+  margin-left: auto;
+}
diff --git a/Open-ILS/src/eg2/src/app/staff/staff.component.html b/Open-ILS/src/eg2/src/app/staff/staff.component.html
new file mode 100644 (file)
index 0000000..2a2539c
--- /dev/null
@@ -0,0 +1,19 @@
+<!-- top navigation bar -->
+<eg-staff-nav-bar></eg-staff-nav-bar>
+
+<div id='staff-content-container'>
+  <!-- page content -->
+  <router-outlet></router-outlet>
+</div>
+
+<!-- EgAccessKey Info Panel -->
+<eg-accesskey-info #egAccessKeyInfo></eg-accesskey-info>
+<a egAccessKey keyCtx="base"
+    keySpec="ctrl+h" i18n-keySpec
+    keyDesc="Display AccessKey Info Dialog" i18n-keyDesc
+    (click)="egAccessKeyInfo.open()">
+</a>
+
+<!-- global toast alerts -->
+<eg-toast></eg-toast>
+
diff --git a/Open-ILS/src/eg2/src/app/staff/staff.component.ts b/Open-ILS/src/eg2/src/app/staff/staff.component.ts
new file mode 100644 (file)
index 0000000..c8daf73
--- /dev/null
@@ -0,0 +1,112 @@
+import {Component, OnInit, NgZone, HostListener/*, ViewChild*/} from '@angular/core';
+import {Router, ActivatedRoute, NavigationEnd} from '@angular/router';
+import {EgAuthService, EgAuthWsState} from '@eg/core/auth.service';
+import {EgNetService} from '@eg/core/net.service';
+import {EgAccessKeyService} from '@eg/share/accesskey/accesskey.service';
+import {EgAccessKeyInfoComponent} 
+    from '@eg/share/accesskey/accesskey-info.component';
+
+const LOGIN_PATH = '/staff/login';
+const WS_BASE_PATH = '/staff/admin/workstation/workstations/';
+const WS_MANAGE_PATH = '/staff/admin/workstation/workstations/manage';
+
+@Component({
+  templateUrl: 'staff.component.html',
+  styleUrls: ['staff.component.css']
+})
+
+export class EgStaffComponent implements OnInit {
+
+    constructor(
+        private router: Router,
+        private route: ActivatedRoute,
+        private zone: NgZone,
+        private net: EgNetService,
+        private auth: EgAuthService,
+        private keys: EgAccessKeyService
+    ) {}
+
+    ngOnInit() {
+
+        console.debug('EgStaffComponent:ngOnInit()');
+
+        // Fires on all in-app router navigation, but not initial page load.
+        this.router.events.subscribe(routeEvent => {
+            if (routeEvent instanceof NavigationEnd) {
+                //console.debug(`EgStaffComponent routing to ${routeEvent.url}`);
+                this.preventForbiddenNavigation(routeEvent.url);
+            }
+        });
+
+        // Redirect to the login page on any auth timeout events.
+        this.net.authExpired$.subscribe(expireEvent => {
+
+            // If the expired authtoken was identified locally (i.e. 
+            // in this browser tab) notify all tabs of imminent logout.
+            if (!expireEvent.viaExternal) this.auth.broadcastLogout();
+
+            console.debug('Auth session has expired. Redirecting to login');
+            this.auth.redirectUrl = this.router.url;
+
+            // https://github.com/angular/angular/issues/18254
+            // When a tab redirects to a login page as a result of
+            // another tab broadcasting a logout, ngOnInit() fails to
+            // fire in the login component, until the user interacts
+            // with the page.  Fix it by wrapping it in zone.run().
+            // This is the only navigate() where I have seen this happen.
+            this.zone.run(() => {
+                this.router.navigate([LOGIN_PATH]);
+            });
+        });
+
+        this.route.data.subscribe((data: {staffResolver : any}) => {
+            // Data fetched via EgStaffResolver is available here.
+        });
+
+
+    }
+
+    /**
+     * Prevent the user from leaving the login page when they don't have
+     * a valid authoken.
+     *
+     * Prevent the user from leaving the workstation admin page when
+     * they don't have a valid workstation.
+     *
+     * This does not verify auth tokens with the server on every route, 
+     * because that would be overkill.  This is only here to keep
+     * people boxed in with their authenication state was already
+     * known to be less then 100%.
+     */
+    preventForbiddenNavigation(url: string): void {
+
+        // No auth checks needed for login page.
+        if (url.startsWith(LOGIN_PATH)) return;
+
+        // We lost our authtoken, go back to the login page.
+        if (!this.auth.token()) 
+            this.router.navigate([LOGIN_PATH]);
+
+        // No workstation checks needed for workstation admin page.
+        if (url.startsWith(WS_BASE_PATH)) return;
+
+        if (this.auth.workstationState != EgAuthWsState.VALID)
+            this.router.navigate([WS_MANAGE_PATH]);
+    }
+
+    /**
+     * Listen for keyboard events here -- the root directive --  and pass
+     * events down to the key service for processing.
+     */
+    @HostListener('window:keydown', ['$event']) onKeyDown(evt: KeyboardEvent) {
+        this.keys.fire(evt);
+    }
+
+    /*
+    @ViewChild('egAccessKeyInfo') 
+    private keyComponent: EgAccessKeyInfoComponent;
+    */
+
+}
+
+
diff --git a/Open-ILS/src/eg2/src/app/staff/staff.module.ts b/Open-ILS/src/eg2/src/app/staff/staff.module.ts
new file mode 100644 (file)
index 0000000..52f6abd
--- /dev/null
@@ -0,0 +1,24 @@
+import {NgModule} from '@angular/core';
+import {EgStaffCommonModule} from '@eg/staff/common.module';
+
+import {EgStaffComponent} from './staff.component';
+import {EgStaffRoutingModule} from './routing.module';
+import {EgStaffNavComponent} from './nav.component';
+import {EgStaffLoginComponent} from './login.component';
+import {EgStaffSplashComponent} from './splash.component';
+
+@NgModule({
+  declarations: [
+    EgStaffComponent,
+    EgStaffNavComponent,
+    EgStaffSplashComponent,
+    EgStaffLoginComponent
+  ],
+  imports: [
+    EgStaffCommonModule.forRoot(),
+    EgStaffRoutingModule
+  ]
+})
+
+export class EgStaffModule {}
+
diff --git a/Open-ILS/src/eg2/src/app/welcome.component.html b/Open-ILS/src/eg2/src/app/welcome.component.html
new file mode 100644 (file)
index 0000000..eaa1c71
--- /dev/null
@@ -0,0 +1,11 @@
+<div class="jumbotron">
+  <h1 i18n class="display-3">Welcome to Webby</h1>
+  <p i18n class="lead">
+    If you see this page, you're probably in good shape...
+  </p>
+  <hr class="my-4"/>
+  <p i18n>
+    But maybe you meant to go to the 
+    <a routerLink="/staff/splash">staff page</a>
+  </p>
+</div>
diff --git a/Open-ILS/src/eg2/src/app/welcome.component.ts b/Open-ILS/src/eg2/src/app/welcome.component.ts
new file mode 100644 (file)
index 0000000..398d127
--- /dev/null
@@ -0,0 +1,14 @@
+import { Component, OnInit } from '@angular/core';
+
+@Component({
+  templateUrl : './welcome.component.html'
+})
+
+export class WelcomeComponent implements OnInit {
+    
+    ngOnInit() {
+    }
+}
+
+
+
diff --git a/Open-ILS/src/eg2/src/assets/.gitkeep b/Open-ILS/src/eg2/src/assets/.gitkeep
new file mode 100644 (file)
index 0000000..e69de29
diff --git a/Open-ILS/src/eg2/src/environments/environment.prod.ts b/Open-ILS/src/eg2/src/environments/environment.prod.ts
new file mode 100644 (file)
index 0000000..3612073
--- /dev/null
@@ -0,0 +1,3 @@
+export const environment = {
+  production: true
+};
diff --git a/Open-ILS/src/eg2/src/environments/environment.ts b/Open-ILS/src/eg2/src/environments/environment.ts
new file mode 100644 (file)
index 0000000..b7f639a
--- /dev/null
@@ -0,0 +1,8 @@
+// The file contents for the current environment will overwrite these during build.
+// The build system defaults to the dev environment which uses `environment.ts`, but if you do
+// `ng build --env=prod` then `environment.prod.ts` will be used instead.
+// The list of which env maps to which file can be found in `.angular-cli.json`.
+
+export const environment = {
+  production: false
+};
diff --git a/Open-ILS/src/eg2/src/favicon.ico b/Open-ILS/src/eg2/src/favicon.ico
new file mode 100644 (file)
index 0000000..8081c7c
Binary files /dev/null and b/Open-ILS/src/eg2/src/favicon.ico differ
diff --git a/Open-ILS/src/eg2/src/index.html b/Open-ILS/src/eg2/src/index.html
new file mode 100644 (file)
index 0000000..d84bab7
--- /dev/null
@@ -0,0 +1,31 @@
+<!doctype html>
+<html lang="en">
+<head>
+  <meta charset="utf-8">
+  <title i18n="Page Title">AngEG</title>
+  <base href="/webby">
+
+  <meta name="viewport" content="width=device-width, initial-scale=1">
+  <link rel="icon" type="image/x-icon" href="favicon.ico">
+  <!-- todo -->
+  <!-- see self-hosting options https://google.github.io/material-design-icons/#icon-font-for-the-web -->
+  <link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet">
+  <!-- link to bootstrap manually for the time being.  With 
+        ng-bootstrap, we only need the CSS, not the JS -->
+  <!--
+  <link rel="stylesheet" crossorigin="anonymous"
+    href="https://maxcdn.bootstrapcdn.com/bootstrap/4.0.0/css/bootstrap.min.css" 
+    integrity="sha384-Gn5384xqQ1aoWXA+058RXPxPg6fy4IWvTNh0E263XmFcJlSAwiGgFAW/dAiS6JXm">
+    -->
+  <!-- lnk to local copy for dev on the go -->
+  <link rel="stylesheet" crossorigin="anonymous"
+    href="/css/bootstrap.min.css" 
+</head>
+<body>
+  <eg-root></eg-root>
+  <script src="/IDL2js"></script>
+  <script src="/js/dojo/opensrf/JSON_v1.js"></script>
+  <script src="/js/dojo/opensrf/opensrf.js"></script>
+  <script src="/js/dojo/opensrf/opensrf_ws.js"></script>
+</body>
+</html>
diff --git a/Open-ILS/src/eg2/src/main.ts b/Open-ILS/src/eg2/src/main.ts
new file mode 100644 (file)
index 0000000..08b359c
--- /dev/null
@@ -0,0 +1,12 @@
+import { enableProdMode } from '@angular/core';
+import { platformBrowserDynamic } from '@angular/platform-browser-dynamic';
+
+import { EgBaseModule } from './app/app.module';
+import { environment } from './environments/environment';
+
+if (environment.production) {
+  enableProdMode();
+}
+
+platformBrowserDynamic().bootstrapModule(EgBaseModule)
+  .catch(err => console.log(err));
diff --git a/Open-ILS/src/eg2/src/polyfills.ts b/Open-ILS/src/eg2/src/polyfills.ts
new file mode 100644 (file)
index 0000000..20d4075
--- /dev/null
@@ -0,0 +1,76 @@
+/**
+ * This file includes polyfills needed by Angular and is loaded before the app.
+ * You can add your own extra polyfills to this file.
+ *
+ * This file is divided into 2 sections:
+ *   1. Browser polyfills. These are applied before loading ZoneJS and are sorted by browsers.
+ *   2. Application imports. Files imported after ZoneJS that should be loaded before your main
+ *      file.
+ *
+ * The current setup is for so-called "evergreen" browsers; the last versions of browsers that
+ * automatically update themselves. This includes Safari >= 10, Chrome >= 55 (including Opera),
+ * Edge >= 13 on the desktop, and iOS 10 and Chrome on mobile.
+ *
+ * Learn more in https://angular.io/docs/ts/latest/guide/browser-support.html
+ */
+
+/***************************************************************************************************
+ * BROWSER POLYFILLS
+ */
+
+/** IE9, IE10 and IE11 requires all of the following polyfills. **/
+// import 'core-js/es6/symbol';
+// import 'core-js/es6/object';
+// import 'core-js/es6/function';
+// import 'core-js/es6/parse-int';
+// import 'core-js/es6/parse-float';
+// import 'core-js/es6/number';
+// import 'core-js/es6/math';
+// import 'core-js/es6/string';
+// import 'core-js/es6/date';
+// import 'core-js/es6/array';
+// import 'core-js/es6/regexp';
+// import 'core-js/es6/map';
+// import 'core-js/es6/weak-map';
+// import 'core-js/es6/set';
+
+/** IE10 and IE11 requires the following for NgClass support on SVG elements */
+// import 'classlist.js';  // Run `npm install --save classlist.js`.
+
+/** IE10 and IE11 requires the following for the Reflect API. */
+// import 'core-js/es6/reflect';
+
+
+/** Evergreen browsers require these. **/
+// Used for reflect-metadata in JIT. If you use AOT (and only Angular decorators), you can remove.
+import 'core-js/es7/reflect';
+
+
+/**
+ * Required to support Web Animations `@angular/platform-browser/animations`.
+ * Needed for: All but Chrome, Firefox and Opera. http://caniuse.com/#feat=web-animation
+ **/
+// import 'web-animations-js';  // Run `npm install --save web-animations-js`.
+
+
+
+/***************************************************************************************************
+ * Zone JS is required by Angular itself.
+ */
+import 'zone.js/dist/zone';  // Included with Angular CLI.
+
+
+
+/***************************************************************************************************
+ * APPLICATION IMPORTS
+ */
+
+/**
+ * Date, currency, decimal and percent pipes.
+ * Needed for: All but Chrome, Firefox, Edge, IE11 and Safari 10
+ */
+// import 'intl';  // Run `npm install --save intl`.
+/**
+ * Need to import at least one locale-data with intl.
+ */
+// import 'intl/locale-data/jsonp/en';
diff --git a/Open-ILS/src/eg2/src/styles.css b/Open-ILS/src/eg2/src/styles.css
new file mode 100644 (file)
index 0000000..9db4fe8
--- /dev/null
@@ -0,0 +1,91 @@
+/* You can add global styles to this file, and also import other style files */
+
+/** material design experiments
+@import "~@angular/material/prebuilt-themes/indigo-pink.css";
+*/
+
+
+/** BS default fonts are huge */
+body, .form-control, .btn {
+  /* This more or less matches the font size of the angularjs client.
+   * The default BS4 font of 1rem is comically large. 
+   */
+  font-size: .88rem;
+}
+h2 {font-size: 1.25rem}
+h3 {font-size: 1.15rem}
+h4 {font-size: 1.05rem}
+h5 {font-size: .95rem}
+
+.small-text-1 {font-size: 85%}
+
+
+/** Ang5 routes on clicks to href's with no values, so we can't have
+ * bare href's to force anchor styling.  Use this for anchors w/ no href.
+ * TODO: should we style all of them?  a:not([href]) ....
+ * */
+.no-href {
+  cursor: pointer;
+  color: #007bff;
+}
+
+
+/** BS has flex utility classes, but none for specifying flex widths.
+ *  BS class="col" is roughly equivelent to flex-1, but col-2 is not
+ *  equivalent to flex-2, since col-2 really means 2/12 width. */
+.flex-1 {flex: 1}
+.flex-2 {flex: 2}
+.flex-3 {flex: 3}
+.flex-4 {flex: 4}
+.flex-5 {flex: 5}
+
+
+/* usefuf for mat-icon buttons without any background or borders */
+.material-icon-button {
+  /* Transparent background */
+  border: none;
+  background-color: rgba(0, 0, 0, 0.0);
+  padding-left: .25rem;
+  padding-right: .25rem; /* default .5rem */
+}
+
+.material-icons {
+  /** default is 24px which is pretty chunky */
+  font-size: 22px;
+}
+
+/* allow spans/labels to vertically orient with material icons */
+.label-with-material-icon {
+    display: inline-flex;
+    vertical-align: middle;
+    align-items: center;
+}
+
+/* Default .card padding is extreme */
+.tight-card .card-body,
+.tight-card .list-group-item {
+  padding: .25rem;
+}
+
+@media all and (min-width: 800px) {                                            
+    /* scrollable typeahead menus for full-size screens */                               
+    ngb-typeahead-window {
+        height: auto;                                                          
+        max-height: 200px;                                                     
+        overflow-x: hidden;                                                    
+    }
+}
+
+/* --------------------------------------------------------------------------
+/* Form Validation CSS - https://angular.io/guide/form-validation
+ * TODO: these colors don't fit the EG color scheme
+ * Required valid fields are left-border styled in green-ish.
+ * Invalid fields are left-border styled in red-ish.
+ */
+.form-validated .ng-valid[required], .form-validated .ng-valid.required {
+  border-left: 8px solid #78FA89;
+}
+.form-validated .ng-invalid:not(form) {
+  border-left: 8px solid #FA787E;
+}
+
diff --git a/Open-ILS/src/eg2/src/test.ts b/Open-ILS/src/eg2/src/test.ts
new file mode 100644 (file)
index 0000000..cd612ee
--- /dev/null
@@ -0,0 +1,32 @@
+// This file is required by karma.conf.js and loads recursively all the .spec and framework files
+
+import 'zone.js/dist/long-stack-trace-zone';
+import 'zone.js/dist/proxy.js';
+import 'zone.js/dist/sync-test';
+import 'zone.js/dist/jasmine-patch';
+import 'zone.js/dist/async-test';
+import 'zone.js/dist/fake-async-test';
+import { getTestBed } from '@angular/core/testing';
+import {
+  BrowserDynamicTestingModule,
+  platformBrowserDynamicTesting
+} from '@angular/platform-browser-dynamic/testing';
+
+// Unfortunately there's no typing for the `__karma__` variable. Just declare it as any.
+declare const __karma__: any;
+declare const require: any;
+
+// Prevent Karma from running prematurely.
+__karma__.loaded = function () {};
+
+// First, initialize the Angular testing environment.
+getTestBed().initTestEnvironment(
+  BrowserDynamicTestingModule,
+  platformBrowserDynamicTesting()
+);
+// Then we find all the tests.
+const context = require.context('./', true, /\.spec\.ts$/);
+// And load the modules.
+context.keys().map(context);
+// Finally, start Karma to run the tests.
+__karma__.start();
diff --git a/Open-ILS/src/eg2/src/tsconfig.app.json b/Open-ILS/src/eg2/src/tsconfig.app.json
new file mode 100644 (file)
index 0000000..39ba8db
--- /dev/null
@@ -0,0 +1,13 @@
+{
+  "extends": "../tsconfig.json",
+  "compilerOptions": {
+    "outDir": "../out-tsc/app",
+    "baseUrl": "./",
+    "module": "es2015",
+    "types": []
+  },
+  "exclude": [
+    "test.ts",
+    "**/*.spec.ts"
+  ]
+}
diff --git a/Open-ILS/src/eg2/src/tsconfig.spec.json b/Open-ILS/src/eg2/src/tsconfig.spec.json
new file mode 100644 (file)
index 0000000..63d89ff
--- /dev/null
@@ -0,0 +1,20 @@
+{
+  "extends": "../tsconfig.json",
+  "compilerOptions": {
+    "outDir": "../out-tsc/spec",
+    "baseUrl": "./",
+    "module": "commonjs",
+    "target": "es5",
+    "types": [
+      "jasmine",
+      "node"
+    ]
+  },
+  "files": [
+    "test.ts"
+  ],
+  "include": [
+    "**/*.spec.ts",
+    "**/*.d.ts"
+  ]
+}
diff --git a/Open-ILS/src/eg2/src/typings.d.ts b/Open-ILS/src/eg2/src/typings.d.ts
new file mode 100644 (file)
index 0000000..ef5c7bd
--- /dev/null
@@ -0,0 +1,5 @@
+/* SystemJS module definition */
+declare var module: NodeModule;
+interface NodeModule {
+  id: string;
+}
diff --git a/Open-ILS/src/eg2/tsconfig.json b/Open-ILS/src/eg2/tsconfig.json
new file mode 100644 (file)
index 0000000..14a504d
--- /dev/null
@@ -0,0 +1,24 @@
+{
+  "compileOnSave": false,
+  "compilerOptions": {
+    "outDir": "./dist/out-tsc",
+    "sourceMap": true,
+    "declaration": false,
+    "moduleResolution": "node",
+    "emitDecoratorMetadata": true,
+    "experimentalDecorators": true,
+    "target": "es5",
+    "baseUrl": "src",
+    "paths": {
+        "@eg/*": ["app/*"],
+        "@env/*": ["environments/*"]
+    },
+    "typeRoots": [
+      "node_modules/@types"
+    ],
+    "lib": [
+      "es2017",
+      "dom"
+    ]
+  }
+}
diff --git a/Open-ILS/src/eg2/tslint.json b/Open-ILS/src/eg2/tslint.json
new file mode 100644 (file)
index 0000000..c24dc29
--- /dev/null
@@ -0,0 +1,141 @@
+{
+  "rulesDirectory": [
+    "node_modules/codelyzer"
+  ],
+  "rules": {
+    "arrow-return-shorthand": true,
+    "callable-types": true,
+    "class-name": true,
+    "comment-format": [
+      true,
+      "check-space"
+    ],
+    "curly": true,
+    "eofline": true,
+    "forin": true,
+    "import-blacklist": [
+      true,
+      "rxjs",
+      "rxjs/Rx"
+    ],
+    "import-spacing": true,
+    "indent": [
+      true,
+      "spaces"
+    ],
+    "interface-over-type-literal": true,
+    "label-position": true,
+    "max-line-length": [
+      true,
+      140
+    ],
+    "member-access": false,
+    "member-ordering": [
+      true,
+      {
+        "order": [
+          "static-field",
+          "instance-field",
+          "static-method",
+          "instance-method"
+        ]
+      }
+    ],
+    "no-arg": true,
+    "no-bitwise": true,
+    "no-console": [
+      true,
+      "debug",
+      "info",
+      "time",
+      "timeEnd",
+      "trace"
+    ],
+    "no-construct": true,
+    "no-debugger": true,
+    "no-duplicate-super": true,
+    "no-empty": false,
+    "no-empty-interface": true,
+    "no-eval": true,
+    "no-inferrable-types": [
+      true,
+      "ignore-params"
+    ],
+    "no-misused-new": true,
+    "no-non-null-assertion": true,
+    "no-shadowed-variable": true,
+    "no-string-literal": false,
+    "no-string-throw": true,
+    "no-switch-case-fall-through": true,
+    "no-trailing-whitespace": true,
+    "no-unnecessary-initializer": true,
+    "no-unused-expression": true,
+    "no-use-before-declare": true,
+    "no-var-keyword": true,
+    "object-literal-sort-keys": false,
+    "one-line": [
+      true,
+      "check-open-brace",
+      "check-catch",
+      "check-else",
+      "check-whitespace"
+    ],
+    "prefer-const": true,
+    "quotemark": [
+      true,
+      "single"
+    ],
+    "radix": true,
+    "semicolon": [
+      true,
+      "always"
+    ],
+    "triple-equals": [
+      true,
+      "allow-null-check"
+    ],
+    "typedef-whitespace": [
+      true,
+      {
+        "call-signature": "nospace",
+        "index-signature": "nospace",
+        "parameter": "nospace",
+        "property-declaration": "nospace",
+        "variable-declaration": "nospace"
+      }
+    ],
+    "typeof-compare": true,
+    "unified-signatures": true,
+    "variable-name": false,
+    "whitespace": [
+      true,
+      "check-branch",
+      "check-decl",
+      "check-operator",
+      "check-separator",
+      "check-type"
+    ],
+    "directive-selector": [
+      true,
+      "attribute",
+      "app",
+      "camelCase"
+    ],
+    "component-selector": [
+      true,
+      "element",
+      "app",
+      "kebab-case"
+    ],
+    "use-input-property-decorator": true,
+    "use-output-property-decorator": true,
+    "use-host-property-decorator": true,
+    "no-input-rename": true,
+    "no-output-rename": true,
+    "use-life-cycle-interface": true,
+    "use-pipe-transform-interface": true,
+    "component-class-suffix": true,
+    "directive-class-suffix": true,
+    "invoke-injectable": true
+  }
+}
index ca3f85a..56fd048 100644 (file)
@@ -2664,6 +2664,86 @@ sub copies_by_cn_label {
     return [ map { ($U->is_true($_->location->opac_visible) && $U->is_true($_->status->opac_visible)) ? ($_->id) : () } @$copies ];
 }
 
+__PACKAGE__->register_method(
+    method   => 'bib_copies',
+    api_name => 'open-ils.search.bib.copies',
+    stream => 1
+);
+__PACKAGE__->register_method(
+    method   => 'bib_copies',
+    api_name => 'open-ils.search.bib.copies.staff',
+    stream => 1
+);
+
+sub bib_copies {
+    my ($self, $client, $rec_id, $org, $depth, $limit, $offset, $pref_ou) = @_;
+    my $is_staff = ($self->api_name =~ /staff/);
+
+    my $cstore = OpenSRF::AppSession->create('open-ils.cstore');
+    my $req = $cstore->request(
+        'open-ils.cstore.json_query', mk_copy_query(
+        $rec_id, $org, $depth, $limit, $offset, $pref_ou, $is_staff));
+
+    my $resp;
+    while ($resp = $req->recv) {
+        $client->respond($resp->content); 
+    }
+
+    return undef;
+}
+
+# TODO: this comes almost directly from WWW/EGCatLoader/Record.pm
+# Refactor to share
+sub mk_copy_query {
+    my $rec_id = shift;
+    my $org = shift;
+    my $depth = shift;
+    my $copy_limit = shift;
+    my $copy_offset = shift;
+    my $pref_ou = shift;
+    my $is_staff = shift;
+
+    my $query = $U->basic_opac_copy_query(
+        $rec_id, undef, undef, $copy_limit, $copy_offset, $is_staff
+    );
+
+    if ($org) { # TODO: root org test
+        # no need to add the org join filter if we're not actually filtering
+        $query->{from}->{acp}->[1] = { aou => {
+            fkey => 'circ_lib',
+            field => 'id',
+            filter => {
+                id => {
+                    in => {
+                        select => {aou => [{
+                            column => 'id', 
+                            transform => 'actor.org_unit_descendants',
+                            result_field => 'id', 
+                            params => [$depth]
+                        }]},
+                        from => 'aou',
+                        where => {id => $org}
+                    }
+                }
+            }
+        }};
+    };
+
+    # Unsure if we want these in the shared function, leaving here for now
+    unshift(@{$query->{order_by}},
+        { class => "aou", field => 'id',
+          transform => 'evergreen.rank_ou', params => [$org, $pref_ou]
+        }
+    );
+    push(@{$query->{order_by}},
+        { class => "acp", field => 'id',
+          transform => 'evergreen.rank_cp'
+        }
+    );
+
+    return $query;
+}
+
 
 1;
 
diff --git a/Open-ILS/src/templates/staff/ang2_js.tt2 b/Open-ILS/src/templates/staff/ang2_js.tt2
new file mode 100644 (file)
index 0000000..0aa2bb6
--- /dev/null
@@ -0,0 +1,13 @@
+<!-- 
+Import the compiled Angular2 shell script.
+-->
+
+<script type="text/javascript" 
+  src="[% ctx.media_prefix %]/js/ui/default/staff/ng2-shell/inline.bundle.js"></script>
+<script type="text/javascript" 
+  src="[% ctx.media_prefix %]/js/ui/default/staff/ng2-shell/polyfills.bundle.js"></script>
+<script type="text/javascript" 
+  src="[% ctx.media_prefix %]/js/ui/default/staff/ng2-shell/vendor.bundle.js"></script>
+<script type="text/javascript" 
+  src="[% ctx.media_prefix %]/js/ui/default/staff/ng2-shell/main.bundle.js"></script>
+
index 7ce42ae..386e4f1 100644 (file)
@@ -1,9 +1,17 @@
 <!doctype html>
 [%- PROCESS 'staff/config.tt2' %]
 <html lang="[% ctx.locale %]"
+  [%- IF NOT ctx.is_ang2_app %]
     [%- IF ctx.page_app %] ng-app="[% ctx.page_app %]"[% END -%]
-    [%- IF ctx.page_ctrl %] ng-controller="[% ctx.page_ctrl %]"[% END %]>
+    [%- IF ctx.page_ctrl %] ng-controller="[% ctx.page_ctrl %]"[% END %]
+  [% END %]>
   <head>
+    [% IF ctx.is_ang2_app AND ctx.page_app %]
+    <script>
+      // tell ang2 what ang1 app to load
+      window.ang1PageApp = "[% ctx.page_app %]";
+    </script>
+    [% END %]
     <!-- enables ng-cloak to be usable before angular has been able to fully load -->
     <style type="text/css">
     [ng\:cloak], [ng-cloak], [data-ng-cloak], [x-ng-cloak], .ng-cloak, .x-ng-cloak {
     </style>
     <!-- The page title changes with $rootScope.pageTitle, 
         defaulting to the static template page title. -->
-    <title ng-cloak>{{pageTitle || "[% ctx.page_title %]"}}</title>
+
+    [% IF ctx.is_ang2_app %]
+      <!-- 
+        ang1 title service does not function within ang2. 
+        Apply a default and let apps override via the ng2Title service.
+      -->
+      <title>[% ctx.page_title || l('Evergreen') %]</title>
+    [% ELSE %]
+      <title ng-cloak>{{pageTitle || "[% ctx.page_title %]"}}</title>
+    [% END %]
+
     <base href="/eg/staff/">
     <meta charset="utf-8">
     <meta name="viewport" content="width=device-width, initial-scale=1.0">
 
       # App-specific JS load commands go into an APP_JS block.
       PROCESS APP_JS;
+
+      # Angular2 scripts must be imported after app-specific ang1 imports
+      IF ctx.is_ang2_app;
+        INCLUDE "staff/ang2_js.tt2";
+      END;
     %]
 
     <!-- content printed via the browser is inserted here for 
index 64e5510..6995cce 100644 (file)
@@ -2,6 +2,7 @@
   WRAPPER "staff/base.tt2";
   ctx.page_title = l("Check In"); 
   ctx.page_app = "egCheckinApp";
+  ctx.is_ang2_app = 1;
 %]
 
 [% BLOCK APP_JS %]
index a6a65ad..794d011 100644 (file)
             </a>
           </li>
           <li>
+            <a href="/eg2/staff/catalog/search" target="_self">
+              <span class="glyphicon glyphicon-search"></span>
+              <span>[% l('Staff Catalog (Experimental)') %]</span>
+            </a>
+          </li>
+          <li>
             <a href="./cat/bucket/record/view" target="_self">
               <span class="glyphicon glyphicon-list-alt"></span>
               [% l('Record Buckets') %]
index ef3944f..626052d 100644 (file)
@@ -34,8 +34,27 @@ angular.module('egCheckinApp', ['ngRoute', 'ui.bootstrap',
  * Manages checkin
  */
 .controller('CheckinCtrl',
-       ['$scope','$q','$window','$location', '$timeout','egCore','checkinSvc','egGridDataProvider','egCirc', 'egItem',
-function($scope , $q , $window , $location , $timeout , egCore , checkinSvc , egGridDataProvider , egCirc, itemSvc)  {
+       ['$scope','$q','$window','$location', '$timeout','egCore',
+        'checkinSvc','egGridDataProvider','egCirc','egItem','eg2Net',
+        'eg2Store','ng2Title',
+function($scope , $q , $window , $location , $timeout , egCore , 
+         checkinSvc , egGridDataProvider , egCirc, itemSvc , eg2Net , 
+         eg2Store , ng2Title)  {
+
+    // TODO: TESTING
+    eg2Net.request('open-ils.actor', 'opensrf.system.echo', 'Hello, Ang2')
+    .subscribe(function(res) {console.log('eg2Net returned ' + res)});
+
+    ng2Title.setTitle('Checkin'); // TODO: TESTING
+
+    var testDialog = angular.element(document.querySelector('testHello'));
+    //var testDialog = angular.element();
+    //console.log(testDialog);
+    //console.log(testDialog.controller());
+    console.log(testDialog.controller());
+    console.log(testDialog.controller('eg2HelloWorld'));
+    //testDialog.controller('eg2ConfirmDialog').open();
+    //testDialog.controller('egConfirmDialog').open();
 
     $scope.focusMe = true;
     $scope.checkins = checkinSvc.checkins;
@@ -49,7 +68,7 @@ function($scope , $q , $window , $location , $timeout , egCore , checkinSvc , eg
     $scope.grid_persist_key = $scope.is_capture ? 
         'circ.checkin.capture' : 'circ.checkin.checkin';
 
-    egCore.hatch.getItem('circ.checkin.strict_barcode')
+    eg2Store.getItem('circ.checkin.strict_barcode')
         .then(function(sb){ $scope.strict_barcode = sb });
 
     egCore.org.settings([
@@ -89,7 +108,7 @@ function($scope , $q , $window , $location , $timeout , egCore , checkinSvc , eg
 
     // set modifiers from stored preferences
     angular.forEach(modifiers, function(mod) {
-        egCore.hatch.getItem('eg.circ.checkin.' + mod)
+        eg2Store.getItem('eg.circ.checkin.' + mod)
         .then(function(val) { if (val) $scope.modifiers[mod] = true });
     });
 
@@ -98,10 +117,10 @@ function($scope , $q , $window , $location , $timeout , egCore , checkinSvc , eg
     $scope.toggle_mod = function(mod) {
         if ($scope.modifiers[mod]) {
             $scope.modifiers[mod] = false;
-            egCore.hatch.removeItem('eg.circ.checkin.' + mod);
+            eg2Store.removeItem('eg.circ.checkin.' + mod);
         } else {
             $scope.modifiers[mod] = true;
-            egCore.hatch.setItem('eg.circ.checkin.' + mod, true);
+            eg2Store.setItem('eg.circ.checkin.' + mod, true);
         }
     }
 
@@ -154,7 +173,7 @@ function($scope , $q , $window , $location , $timeout , egCore , checkinSvc , eg
             }
         }
 
-        egCore.hatch.setItem('circ.checkin.strict_barcode', $scope.strict_barcode);
+        eg2Store.setItem('circ.checkin.strict_barcode', $scope.strict_barcode);
         var options = {
             check_barcode : $scope.strict_barcode,
             no_precat_alert : $scope.modifiers.no_precat_alert,
index f7083a7..c36e562 100644 (file)
@@ -355,7 +355,7 @@ angular.module('egCoreMod')
         service.addLoginSessionKey(key);
         if (jsonified === undefined ) 
             jsonified = JSON.stringify(value);
-        $cookies.put(key, jsonified);
+        $cookies.put(key, jsonified, {path: '/'});
     }
 
     // Set the value for the given key.  
@@ -402,7 +402,7 @@ angular.module('egCoreMod')
 
     service.removeLoginSessionItem = function(key) {
         service.removeLoginSessionKey(key);
-        $cookies.remove(key);
+        $cookies.remove(key, {path: '/'});
     }
 
     service.removeSessionItem = function(key) {