From 1353352fb2477f518026dca509675b5ce1f34c3a Mon Sep 17 00:00:00 2001 From: Bill Erickson Date: Sat, 18 Nov 2017 19:51:20 -0500 Subject: [PATCH] LP#1775466 Angular5 base app + some services, UI's, etc. Signed-off-by: Bill Erickson --- Open-ILS/src/eg2/.angular-cli.json | 62 +++++ Open-ILS/src/eg2/.editorconfig | 13 + Open-ILS/src/eg2/.gitignore | 42 +++ Open-ILS/src/eg2/README.adoc | 24 ++ Open-ILS/src/eg2/e2e/app.e2e-spec.ts | 14 + Open-ILS/src/eg2/e2e/app.po.ts | 11 + Open-ILS/src/eg2/e2e/tsconfig.e2e.json | 14 + Open-ILS/src/eg2/karma.conf.js | 33 +++ Open-ILS/src/eg2/package.json | 55 ++++ Open-ILS/src/eg2/protractor.conf.js | 28 ++ Open-ILS/src/eg2/src/app/app.component.ts | 11 + Open-ILS/src/eg2/src/app/app.module.ts | 34 +++ Open-ILS/src/eg2/src/app/common.module.ts | 54 ++++ Open-ILS/src/eg2/src/app/core/README | 9 + Open-ILS/src/eg2/src/app/core/auth.service.ts | 300 ++++++++++++++++++++ Open-ILS/src/eg2/src/app/core/event.service.ts | 53 ++++ Open-ILS/src/eg2/src/app/core/idl.service.ts | 120 ++++++++ Open-ILS/src/eg2/src/app/core/net.service.ts | 183 +++++++++++++ Open-ILS/src/eg2/src/app/core/org.service.ts | 267 ++++++++++++++++++ Open-ILS/src/eg2/src/app/core/pcrud.service.ts | 303 +++++++++++++++++++++ Open-ILS/src/eg2/src/app/core/perm.service.ts | 58 ++++ Open-ILS/src/eg2/src/app/core/store.service.ts | 117 ++++++++ Open-ILS/src/eg2/src/app/migration.module.ts | 92 +++++++ Open-ILS/src/eg2/src/app/resolver.service.ts | 31 +++ Open-ILS/src/eg2/src/app/routing.module.ts | 29 ++ Open-ILS/src/eg2/src/app/share/README | 6 + .../share/accesskey/accesskey-info.component.html | 26 ++ .../share/accesskey/accesskey-info.component.ts | 25 ++ .../src/app/share/accesskey/accesskey.directive.ts | 56 ++++ .../src/app/share/accesskey/accesskey.service.ts | 67 +++++ .../src/app/share/catalog/catalog-url.service.ts | 128 +++++++++ .../eg2/src/app/share/catalog/catalog.service.ts | 297 ++++++++++++++++++++ .../eg2/src/app/share/catalog/search-context.ts | 247 +++++++++++++++++ .../src/eg2/src/app/share/catalog/unapi.service.ts | 54 ++++ .../src/app/share/dialog/confirm.component.html | 17 ++ .../eg2/src/app/share/dialog/confirm.component.ts | 17 ++ .../eg2/src/app/share/dialog/dialog.component.ts | 75 +++++ .../src/app/share/dialog/progress.component.css | 5 + .../src/app/share/dialog/progress.component.html | 32 +++ .../eg2/src/app/share/dialog/progress.component.ts | 92 +++++++ .../eg2/src/app/share/dialog/prompt.component.html | 22 ++ .../eg2/src/app/share/dialog/prompt.component.ts | 19 ++ .../app/share/fm-editor/fm-editor.component.html | 126 +++++++++ .../src/app/share/fm-editor/fm-editor.component.ts | 284 +++++++++++++++++++ .../src/app/share/grid/grid-body.component.html | 18 ++ .../eg2/src/app/share/grid/grid-body.component.ts | 31 +++ .../src/app/share/grid/grid-column.component.ts | 42 +++ .../src/eg2/src/app/share/grid/grid-data-source.ts | 64 +++++ .../src/app/share/grid/grid-header.component.html | 15 + .../src/app/share/grid/grid-header.component.ts | 19 ++ .../src/app/share/grid/grid-toolbar.component.html | 25 ++ .../src/app/share/grid/grid-toolbar.component.ts | 23 ++ .../src/eg2/src/app/share/grid/grid.component.css | 42 +++ .../src/eg2/src/app/share/grid/grid.component.html | 13 + .../src/eg2/src/app/share/grid/grid.component.ts | 36 +++ Open-ILS/src/eg2/src/app/share/grid/grid.module.ts | 36 +++ .../src/eg2/src/app/share/grid/grid.service.ts | 91 +++++++ .../app/share/org-select/org-select.component.html | 17 ++ .../app/share/org-select/org-select.component.ts | 152 +++++++++++ .../eg2/src/app/share/string/string.component.ts | 54 ++++ .../src/eg2/src/app/share/string/string.service.ts | 27 ++ .../eg2/src/app/share/toast/toast.component.css | 11 + .../eg2/src/app/share/toast/toast.component.html | 3 + .../src/eg2/src/app/share/toast/toast.component.ts | 43 +++ .../src/eg2/src/app/share/toast/toast.service.ts | 39 +++ .../src/eg2/src/app/share/util/audio.service.ts | 78 ++++++ Open-ILS/src/eg2/src/app/share/util/pager.ts | 70 +++++ .../src/eg2/src/app/staff/admin/routing.module.ts | 17 ++ .../app/staff/admin/workstation/routing.module.ts | 14 + .../workstation/workstations/routing.module.ts | 25 ++ .../workstations/workstations.component.html | 92 +++++++ .../workstations/workstations.component.ts | 185 +++++++++++++ .../workstations/workstations.module.ts | 18 ++ .../src/app/staff/catalog/catalog.component.html | 6 + .../eg2/src/app/staff/catalog/catalog.component.ts | 18 ++ .../eg2/src/app/staff/catalog/catalog.module.ts | 46 ++++ .../eg2/src/app/staff/catalog/catalog.service.ts | 82 ++++++ .../app/staff/catalog/record/copies.component.html | 71 +++++ .../app/staff/catalog/record/copies.component.ts | 91 +++++++ .../staff/catalog/record/pagination.component.html | 36 +++ .../staff/catalog/record/pagination.component.ts | 157 +++++++++++ .../app/staff/catalog/record/record.component.html | 18 ++ .../app/staff/catalog/record/record.component.ts | 61 +++++ .../eg2/src/app/staff/catalog/resolver.service.ts | 58 ++++ .../app/staff/catalog/result/facets.component.html | 43 +++ .../app/staff/catalog/result/facets.component.ts | 48 ++++ .../staff/catalog/result/pagination.component.css | 8 + .../staff/catalog/result/pagination.component.html | 28 ++ .../staff/catalog/result/pagination.component.ts | 41 +++ .../app/staff/catalog/result/record.component.html | 129 +++++++++ .../app/staff/catalog/result/record.component.ts | 72 +++++ .../staff/catalog/result/results.component.html | 30 ++ .../app/staff/catalog/result/results.component.ts | 109 ++++++++ .../eg2/src/app/staff/catalog/routing.module.ts | 27 ++ .../app/staff/catalog/search-form.component.css | 16 ++ .../app/staff/catalog/search-form.component.html | 227 +++++++++++++++ .../src/app/staff/catalog/search-form.component.ts | 106 +++++++ .../circ/patron/bcsearch/bcsearch.component.html | 19 ++ .../circ/patron/bcsearch/bcsearch.component.ts | 36 +++ .../staff/circ/patron/bcsearch/bcsearch.module.ts | 17 ++ .../staff/circ/patron/bcsearch/routing.module.ts | 19 ++ .../src/app/staff/circ/patron/routing.module.ts | 15 + .../src/eg2/src/app/staff/circ/routing.module.ts | 15 + Open-ILS/src/eg2/src/app/staff/common.module.ts | 67 +++++ .../src/eg2/src/app/staff/login.component.html | 58 ++++ Open-ILS/src/eg2/src/app/staff/login.component.ts | 90 ++++++ Open-ILS/src/eg2/src/app/staff/nav.component.css | 72 +++++ Open-ILS/src/eg2/src/app/staff/nav.component.html | 230 ++++++++++++++++ Open-ILS/src/eg2/src/app/staff/nav.component.ts | 42 +++ Open-ILS/src/eg2/src/app/staff/resolver.service.ts | 127 +++++++++ Open-ILS/src/eg2/src/app/staff/routing.module.ts | 48 ++++ Open-ILS/src/eg2/src/app/staff/sandbox/README | 1 + .../eg2/src/app/staff/sandbox/routing.module.ts | 16 ++ .../src/app/staff/sandbox/sandbox.component.html | 79 ++++++ .../eg2/src/app/staff/sandbox/sandbox.component.ts | 82 ++++++ .../eg2/src/app/staff/sandbox/sandbox.module.ts | 24 ++ Open-ILS/src/eg2/src/app/staff/share/README | 1 + .../share/bib-summary/bib-summary.component.html | 66 +++++ .../share/bib-summary/bib-summary.component.ts | 76 ++++++ .../staff/share/op-change/op-change.component.html | 65 +++++ .../staff/share/op-change/op-change.component.ts | 81 ++++++ .../src/app/staff/share/staff-banner.component.ts | 15 + .../src/eg2/src/app/staff/splash.component.html | 128 +++++++++ Open-ILS/src/eg2/src/app/staff/splash.component.ts | 38 +++ Open-ILS/src/eg2/src/app/staff/staff.component.css | 8 + .../src/eg2/src/app/staff/staff.component.html | 19 ++ Open-ILS/src/eg2/src/app/staff/staff.component.ts | 112 ++++++++ Open-ILS/src/eg2/src/app/staff/staff.module.ts | 24 ++ Open-ILS/src/eg2/src/app/welcome.component.html | 11 + Open-ILS/src/eg2/src/app/welcome.component.ts | 14 + Open-ILS/src/eg2/src/assets/.gitkeep | 0 .../src/eg2/src/environments/environment.prod.ts | 3 + Open-ILS/src/eg2/src/environments/environment.ts | 8 + Open-ILS/src/eg2/src/favicon.ico | Bin 0 -> 5430 bytes Open-ILS/src/eg2/src/index.html | 31 +++ Open-ILS/src/eg2/src/main.ts | 12 + Open-ILS/src/eg2/src/polyfills.ts | 76 ++++++ Open-ILS/src/eg2/src/styles.css | 91 +++++++ Open-ILS/src/eg2/src/test.ts | 32 +++ Open-ILS/src/eg2/src/tsconfig.app.json | 13 + Open-ILS/src/eg2/src/tsconfig.spec.json | 20 ++ Open-ILS/src/eg2/src/typings.d.ts | 5 + Open-ILS/src/eg2/tsconfig.json | 24 ++ Open-ILS/src/eg2/tslint.json | 141 ++++++++++ .../lib/OpenILS/Application/Search/Biblio.pm | 80 ++++++ Open-ILS/src/templates/staff/ang2_js.tt2 | 13 + Open-ILS/src/templates/staff/base.tt2 | 27 +- .../src/templates/staff/circ/checkin/index.tt2 | 1 + Open-ILS/src/templates/staff/navbar.tt2 | 6 + .../web/js/ui/default/staff/circ/checkin/app.js | 33 ++- Open-ILS/web/js/ui/default/staff/services/hatch.js | 4 +- 151 files changed, 8704 insertions(+), 11 deletions(-) create mode 100644 Open-ILS/src/eg2/.angular-cli.json create mode 100644 Open-ILS/src/eg2/.editorconfig create mode 100644 Open-ILS/src/eg2/.gitignore create mode 100644 Open-ILS/src/eg2/README.adoc create mode 100644 Open-ILS/src/eg2/e2e/app.e2e-spec.ts create mode 100644 Open-ILS/src/eg2/e2e/app.po.ts create mode 100644 Open-ILS/src/eg2/e2e/tsconfig.e2e.json create mode 100644 Open-ILS/src/eg2/karma.conf.js create mode 100644 Open-ILS/src/eg2/package.json create mode 100644 Open-ILS/src/eg2/protractor.conf.js create mode 100644 Open-ILS/src/eg2/src/app/app.component.ts create mode 100644 Open-ILS/src/eg2/src/app/app.module.ts create mode 100644 Open-ILS/src/eg2/src/app/common.module.ts create mode 100644 Open-ILS/src/eg2/src/app/core/README create mode 100644 Open-ILS/src/eg2/src/app/core/auth.service.ts create mode 100644 Open-ILS/src/eg2/src/app/core/event.service.ts create mode 100644 Open-ILS/src/eg2/src/app/core/idl.service.ts create mode 100644 Open-ILS/src/eg2/src/app/core/net.service.ts create mode 100644 Open-ILS/src/eg2/src/app/core/org.service.ts create mode 100644 Open-ILS/src/eg2/src/app/core/pcrud.service.ts create mode 100644 Open-ILS/src/eg2/src/app/core/perm.service.ts create mode 100644 Open-ILS/src/eg2/src/app/core/store.service.ts create mode 100644 Open-ILS/src/eg2/src/app/migration.module.ts create mode 100644 Open-ILS/src/eg2/src/app/resolver.service.ts create mode 100644 Open-ILS/src/eg2/src/app/routing.module.ts create mode 100644 Open-ILS/src/eg2/src/app/share/README create mode 100644 Open-ILS/src/eg2/src/app/share/accesskey/accesskey-info.component.html create mode 100644 Open-ILS/src/eg2/src/app/share/accesskey/accesskey-info.component.ts create mode 100644 Open-ILS/src/eg2/src/app/share/accesskey/accesskey.directive.ts create mode 100644 Open-ILS/src/eg2/src/app/share/accesskey/accesskey.service.ts create mode 100644 Open-ILS/src/eg2/src/app/share/catalog/catalog-url.service.ts create mode 100644 Open-ILS/src/eg2/src/app/share/catalog/catalog.service.ts create mode 100644 Open-ILS/src/eg2/src/app/share/catalog/search-context.ts create mode 100644 Open-ILS/src/eg2/src/app/share/catalog/unapi.service.ts create mode 100644 Open-ILS/src/eg2/src/app/share/dialog/confirm.component.html create mode 100644 Open-ILS/src/eg2/src/app/share/dialog/confirm.component.ts create mode 100644 Open-ILS/src/eg2/src/app/share/dialog/dialog.component.ts create mode 100644 Open-ILS/src/eg2/src/app/share/dialog/progress.component.css create mode 100644 Open-ILS/src/eg2/src/app/share/dialog/progress.component.html create mode 100644 Open-ILS/src/eg2/src/app/share/dialog/progress.component.ts create mode 100644 Open-ILS/src/eg2/src/app/share/dialog/prompt.component.html create mode 100644 Open-ILS/src/eg2/src/app/share/dialog/prompt.component.ts create mode 100644 Open-ILS/src/eg2/src/app/share/fm-editor/fm-editor.component.html create mode 100644 Open-ILS/src/eg2/src/app/share/fm-editor/fm-editor.component.ts create mode 100644 Open-ILS/src/eg2/src/app/share/grid/grid-body.component.html create mode 100644 Open-ILS/src/eg2/src/app/share/grid/grid-body.component.ts create mode 100644 Open-ILS/src/eg2/src/app/share/grid/grid-column.component.ts create mode 100644 Open-ILS/src/eg2/src/app/share/grid/grid-data-source.ts create mode 100644 Open-ILS/src/eg2/src/app/share/grid/grid-header.component.html create mode 100644 Open-ILS/src/eg2/src/app/share/grid/grid-header.component.ts create mode 100644 Open-ILS/src/eg2/src/app/share/grid/grid-toolbar.component.html create mode 100644 Open-ILS/src/eg2/src/app/share/grid/grid-toolbar.component.ts create mode 100644 Open-ILS/src/eg2/src/app/share/grid/grid.component.css create mode 100644 Open-ILS/src/eg2/src/app/share/grid/grid.component.html create mode 100644 Open-ILS/src/eg2/src/app/share/grid/grid.component.ts create mode 100644 Open-ILS/src/eg2/src/app/share/grid/grid.module.ts create mode 100644 Open-ILS/src/eg2/src/app/share/grid/grid.service.ts create mode 100644 Open-ILS/src/eg2/src/app/share/org-select/org-select.component.html create mode 100644 Open-ILS/src/eg2/src/app/share/org-select/org-select.component.ts create mode 100644 Open-ILS/src/eg2/src/app/share/string/string.component.ts create mode 100644 Open-ILS/src/eg2/src/app/share/string/string.service.ts create mode 100644 Open-ILS/src/eg2/src/app/share/toast/toast.component.css create mode 100644 Open-ILS/src/eg2/src/app/share/toast/toast.component.html create mode 100644 Open-ILS/src/eg2/src/app/share/toast/toast.component.ts create mode 100644 Open-ILS/src/eg2/src/app/share/toast/toast.service.ts create mode 100644 Open-ILS/src/eg2/src/app/share/util/audio.service.ts create mode 100644 Open-ILS/src/eg2/src/app/share/util/pager.ts create mode 100644 Open-ILS/src/eg2/src/app/staff/admin/routing.module.ts create mode 100644 Open-ILS/src/eg2/src/app/staff/admin/workstation/routing.module.ts create mode 100644 Open-ILS/src/eg2/src/app/staff/admin/workstation/workstations/routing.module.ts create mode 100644 Open-ILS/src/eg2/src/app/staff/admin/workstation/workstations/workstations.component.html create mode 100644 Open-ILS/src/eg2/src/app/staff/admin/workstation/workstations/workstations.component.ts create mode 100644 Open-ILS/src/eg2/src/app/staff/admin/workstation/workstations/workstations.module.ts create mode 100644 Open-ILS/src/eg2/src/app/staff/catalog/catalog.component.html create mode 100644 Open-ILS/src/eg2/src/app/staff/catalog/catalog.component.ts create mode 100644 Open-ILS/src/eg2/src/app/staff/catalog/catalog.module.ts create mode 100644 Open-ILS/src/eg2/src/app/staff/catalog/catalog.service.ts create mode 100644 Open-ILS/src/eg2/src/app/staff/catalog/record/copies.component.html create mode 100644 Open-ILS/src/eg2/src/app/staff/catalog/record/copies.component.ts create mode 100644 Open-ILS/src/eg2/src/app/staff/catalog/record/pagination.component.html create mode 100644 Open-ILS/src/eg2/src/app/staff/catalog/record/pagination.component.ts create mode 100644 Open-ILS/src/eg2/src/app/staff/catalog/record/record.component.html create mode 100644 Open-ILS/src/eg2/src/app/staff/catalog/record/record.component.ts create mode 100644 Open-ILS/src/eg2/src/app/staff/catalog/resolver.service.ts create mode 100644 Open-ILS/src/eg2/src/app/staff/catalog/result/facets.component.html create mode 100644 Open-ILS/src/eg2/src/app/staff/catalog/result/facets.component.ts create mode 100644 Open-ILS/src/eg2/src/app/staff/catalog/result/pagination.component.css create mode 100644 Open-ILS/src/eg2/src/app/staff/catalog/result/pagination.component.html create mode 100644 Open-ILS/src/eg2/src/app/staff/catalog/result/pagination.component.ts create mode 100644 Open-ILS/src/eg2/src/app/staff/catalog/result/record.component.html create mode 100644 Open-ILS/src/eg2/src/app/staff/catalog/result/record.component.ts create mode 100644 Open-ILS/src/eg2/src/app/staff/catalog/result/results.component.html create mode 100644 Open-ILS/src/eg2/src/app/staff/catalog/result/results.component.ts create mode 100644 Open-ILS/src/eg2/src/app/staff/catalog/routing.module.ts create mode 100644 Open-ILS/src/eg2/src/app/staff/catalog/search-form.component.css create mode 100644 Open-ILS/src/eg2/src/app/staff/catalog/search-form.component.html create mode 100644 Open-ILS/src/eg2/src/app/staff/catalog/search-form.component.ts create mode 100644 Open-ILS/src/eg2/src/app/staff/circ/patron/bcsearch/bcsearch.component.html create mode 100644 Open-ILS/src/eg2/src/app/staff/circ/patron/bcsearch/bcsearch.component.ts create mode 100644 Open-ILS/src/eg2/src/app/staff/circ/patron/bcsearch/bcsearch.module.ts create mode 100644 Open-ILS/src/eg2/src/app/staff/circ/patron/bcsearch/routing.module.ts create mode 100644 Open-ILS/src/eg2/src/app/staff/circ/patron/routing.module.ts create mode 100644 Open-ILS/src/eg2/src/app/staff/circ/routing.module.ts create mode 100644 Open-ILS/src/eg2/src/app/staff/common.module.ts create mode 100644 Open-ILS/src/eg2/src/app/staff/login.component.html create mode 100644 Open-ILS/src/eg2/src/app/staff/login.component.ts create mode 100644 Open-ILS/src/eg2/src/app/staff/nav.component.css create mode 100644 Open-ILS/src/eg2/src/app/staff/nav.component.html create mode 100644 Open-ILS/src/eg2/src/app/staff/nav.component.ts create mode 100644 Open-ILS/src/eg2/src/app/staff/resolver.service.ts create mode 100644 Open-ILS/src/eg2/src/app/staff/routing.module.ts create mode 100644 Open-ILS/src/eg2/src/app/staff/sandbox/README create mode 100644 Open-ILS/src/eg2/src/app/staff/sandbox/routing.module.ts create mode 100644 Open-ILS/src/eg2/src/app/staff/sandbox/sandbox.component.html create mode 100644 Open-ILS/src/eg2/src/app/staff/sandbox/sandbox.component.ts create mode 100644 Open-ILS/src/eg2/src/app/staff/sandbox/sandbox.module.ts create mode 100644 Open-ILS/src/eg2/src/app/staff/share/README create mode 100644 Open-ILS/src/eg2/src/app/staff/share/bib-summary/bib-summary.component.html create mode 100644 Open-ILS/src/eg2/src/app/staff/share/bib-summary/bib-summary.component.ts create mode 100644 Open-ILS/src/eg2/src/app/staff/share/op-change/op-change.component.html create mode 100644 Open-ILS/src/eg2/src/app/staff/share/op-change/op-change.component.ts create mode 100644 Open-ILS/src/eg2/src/app/staff/share/staff-banner.component.ts create mode 100644 Open-ILS/src/eg2/src/app/staff/splash.component.html create mode 100644 Open-ILS/src/eg2/src/app/staff/splash.component.ts create mode 100644 Open-ILS/src/eg2/src/app/staff/staff.component.css create mode 100644 Open-ILS/src/eg2/src/app/staff/staff.component.html create mode 100644 Open-ILS/src/eg2/src/app/staff/staff.component.ts create mode 100644 Open-ILS/src/eg2/src/app/staff/staff.module.ts create mode 100644 Open-ILS/src/eg2/src/app/welcome.component.html create mode 100644 Open-ILS/src/eg2/src/app/welcome.component.ts create mode 100644 Open-ILS/src/eg2/src/assets/.gitkeep create mode 100644 Open-ILS/src/eg2/src/environments/environment.prod.ts create mode 100644 Open-ILS/src/eg2/src/environments/environment.ts create mode 100644 Open-ILS/src/eg2/src/favicon.ico create mode 100644 Open-ILS/src/eg2/src/index.html create mode 100644 Open-ILS/src/eg2/src/main.ts create mode 100644 Open-ILS/src/eg2/src/polyfills.ts create mode 100644 Open-ILS/src/eg2/src/styles.css create mode 100644 Open-ILS/src/eg2/src/test.ts create mode 100644 Open-ILS/src/eg2/src/tsconfig.app.json create mode 100644 Open-ILS/src/eg2/src/tsconfig.spec.json create mode 100644 Open-ILS/src/eg2/src/typings.d.ts create mode 100644 Open-ILS/src/eg2/tsconfig.json create mode 100644 Open-ILS/src/eg2/tslint.json create mode 100644 Open-ILS/src/templates/staff/ang2_js.tt2 diff --git a/Open-ILS/src/eg2/.angular-cli.json b/Open-ILS/src/eg2/.angular-cli.json new file mode 100644 index 0000000000..56fbfef3c2 --- /dev/null +++ b/Open-ILS/src/eg2/.angular-cli.json @@ -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 index 0000000000..6e87a003da --- /dev/null +++ b/Open-ILS/src/eg2/.editorconfig @@ -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 index 0000000000..54bfd2001e --- /dev/null +++ b/Open-ILS/src/eg2/.gitignore @@ -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 index 0000000000..2e4f209ca0 --- /dev/null +++ b/Open-ILS/src/eg2/README.adoc @@ -0,0 +1,24 @@ += EG Angular2 App = + +=== Apache Configuration === + +[source,conf] +--------------------------------------------------------------------- + + FallbackResource /eg2/index.html + +--------------------------------------------------------------------- + +=== 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 index 0000000000..c2a69a8a6c --- /dev/null +++ b/Open-ILS/src/eg2/e2e/app.e2e-spec.ts @@ -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 index 0000000000..82ea75ba50 --- /dev/null +++ b/Open-ILS/src/eg2/e2e/app.po.ts @@ -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 index 0000000000..1d9e5edf09 --- /dev/null +++ b/Open-ILS/src/eg2/e2e/tsconfig.e2e.json @@ -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 index 0000000000..af139fada3 --- /dev/null +++ b/Open-ILS/src/eg2/karma.conf.js @@ -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 index 0000000000..88366df146 --- /dev/null +++ b/Open-ILS/src/eg2/package.json @@ -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 index 0000000000..7ee3b5ee86 --- /dev/null +++ b/Open-ILS/src/eg2/protractor.conf.js @@ -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 index 0000000000..d049f7a828 --- /dev/null +++ b/Open-ILS/src/eg2/src/app/app.component.ts @@ -0,0 +1,11 @@ +import {Component} from '@angular/core'; + +@Component({ + selector: 'eg-root', + template: '' +}) + +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 index 0000000000..8998b01d73 --- /dev/null +++ b/Open-ILS/src/eg2/src/app/app.module.ts @@ -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 index 0000000000..8cc94c74e5 --- /dev/null +++ b/Open-ILS/src/eg2/src/app/common.module.ts @@ -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 index 0000000000..3cf0ec4708 --- /dev/null +++ b/Open-ILS/src/eg2/src/app/core/README @@ -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 index 0000000000..84de51a8e6 --- /dev/null +++ b/Open-ILS/src/eg2/src/app/core/auth.service.ts @@ -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 { + + 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 { + 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 { + + 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 { + + 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 { + 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 { + + 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 index 0000000000..33e3f84697 --- /dev/null +++ b/Open-ILS/src/eg2/src/app/core/event.service.ts @@ -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 index 0000000000..f1051400d3 --- /dev/null +++ b/Open-ILS/src/eg2/src/app/core/idl.service.ts @@ -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 index 0000000000..bcedfc7033 --- /dev/null +++ b/Open-ILS/src/eg2/src/app/core/net.service.ts @@ -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; + 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; + authExpired$: EventEmitter; + + // 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(); + this.authExpired$ = new EventEmitter(); + } + + // Standard request call -- Variadic params version + request(service: string, method: string, ...params: any[]): Observable { + return this.requestWithParamList(service, method, params); + } + + // Array params version + requestWithParamList(service: string, + method: string, params: any[]): Observable { + return this.requestCompiled( + new EgNetRequest(service, method, params)); + } + + // Request with pre-compiled EgNetRequest + requestCompiled(request: EgNetRequest): Observable { + 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 index 0000000000..b2ad2505da --- /dev/null +++ b/Open-ILS/src/eg2/src/app/core/org.service.ts @@ -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 { + 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 { + + 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 { + + 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 index 0000000000..dddf209a0d --- /dev/null +++ b/Open-ILS/src/eg2/src/app/core/pcrud.service.ts @@ -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; + + 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 { + 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 { + 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 { + 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 { + 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 { + return this.cud('create', list) + } + update(list: EgIdlObject | EgIdlObject[]): Observable { + return this.cud('update', list) + } + remove(list: EgIdlObject | EgIdlObject[]): Observable { + return this.cud('delete', list) + } + autoApply(list: EgIdlObject | EgIdlObject[]): Observable { // RENAMED + return this.cud('auto', list) + } + + xactClose(): Observable { + return this.sendRequest( + 'open-ils.pcrud.transaction.' + this.xactCloseMode, + [this.token()] + ); + }; + + xactBegin(): Observable { + return this.sendRequest( + 'open-ils.pcrud.transaction.begin', [this.token()] + ); + }; + + private dispatch(method: string, params: any[]): Observable { + 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): Observable { + 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 { + + 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 { + 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 { + 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 { + return this.newContext().retrieve(fmClass, pkey, pcrudOps, reqOps); + } + + retrieveAll(fmClass: string, pcrudOps?: any, + reqOps?: EgPcrudReqOps): Observable { + return this.newContext().retrieveAll(fmClass, pcrudOps, reqOps); + } + + search(fmClass: string, search: any, + pcrudOps?: any, reqOps?: EgPcrudReqOps): Observable { + return this.newContext().search(fmClass, search, pcrudOps, reqOps); + } + + create(list: EgIdlObject | EgIdlObject[]): Observable { + return this.newContext().create(list); + } + + update(list: EgIdlObject | EgIdlObject[]): Observable { + return this.newContext().update(list); + } + + remove(list: EgIdlObject | EgIdlObject[]): Observable { + return this.newContext().remove(list); + } + + autoApply(list: EgIdlObject | EgIdlObject[]): Observable { + 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 index 0000000000..2e535d14e9 --- /dev/null +++ b/Open-ILS/src/eg2/src/app/core/perm.service.ts @@ -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 { + 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 { + 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 index 0000000000..218ad8375b --- /dev/null +++ b/Open-ILS/src/eg2/src/app/core/store.service.ts @@ -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 { + // 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 { + 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 { + // 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 { + 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 { + // TODO: route keys appropriately + return Promise.resolve(this.removeLocalItem(key)); + } + + removeLocalItem(key: string): void { + window.localStorage.removeItem(key); + } + + removeServerItem(key: string): Promise { + 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 index 0000000000..5c878b54c4 --- /dev/null +++ b/Open-ILS/src/eg2/src/app/migration.module.ts @@ -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 index 0000000000..0049c40452 --- /dev/null +++ b/Open-ILS/src/eg2/src/app/resolver.service.ts @@ -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> { + + 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 { + + 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 index 0000000000..594521303f --- /dev/null +++ b/Open-ILS/src/eg2/src/app/routing.module.ts @@ -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 index 0000000000..8bd93d7a56 --- /dev/null +++ b/Open-ILS/src/eg2/src/app/share/README @@ -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 index 0000000000..82ed72a4b0 --- /dev/null +++ b/Open-ILS/src/eg2/src/app/share/accesskey/accesskey-info.component.html @@ -0,0 +1,26 @@ + + + + + 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 index 0000000000..c925a50342 --- /dev/null +++ b/Open-ILS/src/eg2/src/app/share/accesskey/accesskey-info.component.ts @@ -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 index 0000000000..bb1fada38c --- /dev/null +++ b/Open-ILS/src/eg2/src/app/share/accesskey/accesskey.directive.ts @@ -0,0 +1,56 @@ +/** + * Assign access keys to tags. + * + * Access key action is peformed via .click(). hrefs, routerLinks, + * and (click) actions are all supported. + * + * + */ +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 index 0000000000..1a3dab32c1 --- /dev/null +++ b/Open-ILS/src/eg2/src/app/share/accesskey/accesskey.service.ts @@ -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 index 0000000000..707d44f91e --- /dev/null +++ b/Open-ILS/src/eg2/src/app/share/catalog/catalog-url.service.ts @@ -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 index 0000000000..8b0483f3e7 --- /dev/null +++ b/Open-ILS/src/eg2/src/app/share/catalog/catalog.service.ts @@ -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 { + 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 { + 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 { + + 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 { + + 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 { + // 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 { + 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 index 0000000000..54224ff8c1 --- /dev/null +++ b/Open-ILS/src/eg2/src/app/share/catalog/search-context.ts @@ -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 index 0000000000..9034ae43f0 --- /dev/null +++ b/Open-ILS/src/eg2/src/app/share/catalog/unapi.service.ts @@ -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 { + // 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 index 0000000000..21766cac09 --- /dev/null +++ b/Open-ILS/src/eg2/src/app/share/dialog/confirm.component.html @@ -0,0 +1,17 @@ + + + + + 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 index 0000000000..e00731b233 --- /dev/null +++ b/Open-ILS/src/eg2/src/app/share/dialog/confirm.component.ts @@ -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 index 0000000000..1f979fcd7e --- /dev/null +++ b/Open-ILS/src/eg2/src/app/share/dialog/dialog.component.ts @@ -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: '' +}) +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; + + // 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(); + + // The modalRef allows direct control of the modal instance. + private modalRef: NgbModalRef = null; + + constructor(private modalService: NgbModal) {} + + ngOnInit() { + this.onOpen$ = new EventEmitter(); + } + + open(options?: NgbModalOptions): Promise { + + 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 index 0000000000..a79609e08a --- /dev/null +++ b/Open-ILS/src/eg2/src/app/share/dialog/progress.component.css @@ -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 index 0000000000..5d682b94ae --- /dev/null +++ b/Open-ILS/src/eg2/src/app/share/dialog/progress.component.html @@ -0,0 +1,32 @@ + + + + + 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 index 0000000000..33f80998d4 --- /dev/null +++ b/Open-ILS/src/eg2/src/app/share/dialog/progress.component.ts @@ -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 + * , 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 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 index 0000000000..1d7936b176 --- /dev/null +++ b/Open-ILS/src/eg2/src/app/share/dialog/prompt.component.html @@ -0,0 +1,22 @@ + + + + + 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 index 0000000000..179efeb6bc --- /dev/null +++ b/Open-ILS/src/eg2/src/app/share/dialog/prompt.component.ts @@ -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 index 0000000000..f3e7dadc53 --- /dev/null +++ b/Open-ILS/src/eg2/src/app/share/fm-editor/fm-editor.component.html @@ -0,0 +1,126 @@ + + + + + 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 index 0000000000..82f39be290 --- /dev/null +++ b/Open-ILS/src/eg2/src/app/share/fm-editor/fm-editor.component.ts @@ -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, + + // 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(); + + // Emit the original object when the save action is canceled. + @Output() onCancel$ = new EventEmitter(); + + // Emit an error message when the save action fails. + @Output() onError$ = new EventEmitter(); + + // 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 { + 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 { + + 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 { + + 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 index 0000000000..7a86798668 --- /dev/null +++ b/Open-ILS/src/eg2/src/app/share/grid/grid-body.component.html @@ -0,0 +1,18 @@ + +
+ +
+ +
+
+ {{pager.rowNumber(idx)}} +
+
+ {{getDisplayValue(row, col)}} +
+ +
+ 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 index 0000000000..364e21ec28 --- /dev/null +++ b/Open-ILS/src/eg2/src/app/share/grid/grid-body.component.ts @@ -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 index 0000000000..41d8d04f19 --- /dev/null +++ b/Open-ILS/src/eg2/src/app/share/grid/grid-column.component.ts @@ -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: '' +}) + +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; + + // 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 index 0000000000..66da827a78 --- /dev/null +++ b/Open-ILS/src/eg2/src/app/share/grid/grid-data-source.ts @@ -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; + + 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 index 0000000000..2b8e4a8466 --- /dev/null +++ b/Open-ILS/src/eg2/src/app/share/grid/grid-header.component.html @@ -0,0 +1,15 @@ + +
+
+ +
+
+ # +
+ +
+ {{col.label}} +
+
+ 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 index 0000000000..254c53851d --- /dev/null +++ b/Open-ILS/src/eg2/src/app/share/grid/grid-header.component.ts @@ -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 index 0000000000..4604fd89dd --- /dev/null +++ b/Open-ILS/src/eg2/src/app/share/grid/grid-toolbar.component.html @@ -0,0 +1,25 @@ + +
+ + +
+ +
+
+ + + + +
+
+ +
+ 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 index 0000000000..01b5880695 --- /dev/null +++ b/Open-ILS/src/eg2/src/app/share/grid/grid-toolbar.component.ts @@ -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 index 0000000000..8969cb1c33 --- /dev/null +++ b/Open-ILS/src/eg2/src/app/share/grid/grid.component.css @@ -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 index 0000000000..3d0434f316 --- /dev/null +++ b/Open-ILS/src/eg2/src/app/share/grid/grid.component.html @@ -0,0 +1,13 @@ + +
+ + + + + +
+ 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 index 0000000000..ff8dd86700 --- /dev/null +++ b/Open-ILS/src/eg2/src/app/share/grid/grid.component.ts @@ -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 index 0000000000..a664b43e95 --- /dev/null +++ b/Open-ILS/src/eg2/src/app/share/grid/grid.module.ts @@ -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 index 0000000000..ead30fabeb --- /dev/null +++ b/Open-ILS/src/eg2/src/app/share/grid/grid.service.ts @@ -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; +} + + +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 index 0000000000..2a4bd3a0c2 --- /dev/null +++ b/Open-ILS/src/eg2/src/app/share/org-select/org-select.component.html @@ -0,0 +1,17 @@ + + + +{{r.label}} + + + 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 index 0000000000..627dd4e803 --- /dev/null +++ b/Open-ILS/src/eg2/src/app/share/org-select/org-select.component.ts @@ -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(); + 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(); + + 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): Observable => { + 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 index 0000000000..8c81898b73 --- /dev/null +++ b/Open-ILS/src/eg2/src/app/share/string/string.component.ts @@ -0,0 +1,54 @@ +/*j + * + * + * 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: ` + + + + ` +}) + +export class EgStringComponent implements OnInit { + + @Input() key: string; + @Input() ctx: any; + @Input() template: TemplateRef; + + 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 { + 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 index 0000000000..1af8083a9f --- /dev/null +++ b/Open-ILS/src/eg2/src/app/share/string/string.service.ts @@ -0,0 +1,27 @@ +import {Injectable} from '@angular/core'; + +interface EgStringAssignment { + key: string, // keyboard command + resolver: (ctx:any) => Promise +}; + +@Injectable() +export class EgStringService { + + strings: {[key:string] : EgStringAssignment} = {}; + + constructor() {} + + register(assn: EgStringAssignment) { + this.strings[assn.key] = assn; + } + + interpolate(key: string, ctx?: any): Promise { + 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 index 0000000000..1f70349f5c --- /dev/null +++ b/Open-ILS/src/eg2/src/app/share/toast/toast.component.css @@ -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 index 0000000000..6aa1545dc6 --- /dev/null +++ b/Open-ILS/src/eg2/src/app/share/toast/toast.component.html @@ -0,0 +1,3 @@ +
+ {{message.text}} +
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 index 0000000000..eebe6255f5 --- /dev/null +++ b/Open-ILS/src/eg2/src/app/share/toast/toast.component.ts @@ -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 index 0000000000..9692c13e91 --- /dev/null +++ b/Open-ILS/src/eg2/src/app/share/toast/toast.service.ts @@ -0,0 +1,39 @@ +import {Injectable, EventEmitter} from '@angular/core'; + +export interface EgToastMessage { + text: string, + style: string +}; + +@Injectable() +export class EgToastService { + + messages$: EventEmitter; + + constructor() { + this.messages$ = new EventEmitter(); + } + + 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 index 0000000000..971fe7e932 --- /dev/null +++ b/Open-ILS/src/eg2/src/app/share/util/audio.service.ts @@ -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 index 0000000000..524e1787b7 --- /dev/null +++ b/Open-ILS/src/eg2/src/app/share/util/pager.ts @@ -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; + + constructor() { + this.onChange$ = new EventEmitter(); + } + + 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 index 0000000000..4e4ef09152 --- /dev/null +++ b/Open-ILS/src/eg2/src/app/staff/admin/routing.module.ts @@ -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 index 0000000000..bd300d7a2b --- /dev/null +++ b/Open-ILS/src/eg2/src/app/staff/admin/workstation/routing.module.ts @@ -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 index 0000000000..a29f19b686 --- /dev/null +++ b/Open-ILS/src/eg2/src/app/staff/admin/workstation/workstations/routing.module.ts @@ -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 index 0000000000..a2358d25bf --- /dev/null +++ b/Open-ILS/src/eg2/src/app/staff/admin/workstation/workstations/workstations.component.html @@ -0,0 +1,92 @@ + + + + + + + +
+
+
+ Workstation {{removeWorkstation}} is no longer valid. Removing registration. +
+
+ Please register a workstation. +
+ +
+
Register a New Workstation For This Browser
+
+
+
+ + +
+
+
+ +
+ +
+
+
+
+
+
+ Workstations Registered With This Browser +
+
+
+
+ +
+
+
+
+ + + +
+
+
+
+ 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 index 0000000000..d1ae7f2f15 --- /dev/null +++ b/Open-ILS/src/eg2/src/app/staff/admin/workstation/workstations/workstations.component.ts @@ -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 { + 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 { + 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 index 0000000000..064b24dd45 --- /dev/null +++ b/Open-ILS/src/eg2/src/app/staff/admin/workstation/workstations/workstations.module.ts @@ -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 index 0000000000..1596454ac1 --- /dev/null +++ b/Open-ILS/src/eg2/src/app/staff/catalog/catalog.component.html @@ -0,0 +1,6 @@ + + + + + + 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 index 0000000000..0324ed406b --- /dev/null +++ b/Open-ILS/src/eg2/src/app/staff/catalog/catalog.component.ts @@ -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 index 0000000000..635eec1304 --- /dev/null +++ b/Open-ILS/src/eg2/src/app/staff/catalog/catalog.module.ts @@ -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 index 0000000000..6cfc715816 --- /dev/null +++ b/Open-ILS/src/eg2/src/app/staff/catalog/catalog.service.ts @@ -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 index 0000000000..8c7a4f3204 --- /dev/null +++ b/Open-ILS/src/eg2/src/app/staff/catalog/record/copies.component.html @@ -0,0 +1,71 @@ +
+ +
+
+
Location
+
Call Number / Copy Notes
+
Barcode
+
Shelving Location
+
Circulation Modifier
+
Age Hold Protection
+
Active/Create Date
+
Holdable?
+
Status
+
Due Date
+
+
+
    +
  • +
    +
    {{orgName(copy.circ_lib)}}
    +
    + {{copy.call_number_prefix_label}} + {{copy.call_number_label}} + {{copy.call_number_suffix_label}} +
    +
    + {{copy.barcode}} + View + | + Edit +
    +
    {{copy.copy_location}}
    +
    {{copy.circ_modifier || ''}}
    +
    {{copy.age_protect}}
    +
    + {{copy.active_date || copy.create_date | date:'shortDate'}} +
    +
    + Yes + No +
    +
    {{copy.copy_status}}
    +
    {{copy.due_date | date:'shortDate'}}
    +
    +
  • +
+
+
+
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 index 0000000000..d4b69571c3 --- /dev/null +++ b/Open-ILS/src/eg2/src/app/staff/catalog/record/copies.component.ts @@ -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 index 0000000000..0edcded4cc --- /dev/null +++ b/Open-ILS/src/eg2/src/app/staff/catalog/record/pagination.component.html @@ -0,0 +1,36 @@ + 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 index 0000000000..31fee2cde6 --- /dev/null +++ b/Open-ILS/src/eg2/src/app/staff/catalog/record/pagination.component.ts @@ -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 { + 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 { + + // 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 { + + 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 index 0000000000..127254aa5d --- /dev/null +++ b/Open-ILS/src/eg2/src/app/staff/catalog/record/record.component.html @@ -0,0 +1,18 @@ + +
+
+
+ + +
+
+
+ + +
+
+ +
+
+ + 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 index 0000000000..ec9a30215e --- /dev/null +++ b/Open-ILS/src/eg2/src/app/staff/catalog/record/record.component.ts @@ -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 index 0000000000..f081fc2077 --- /dev/null +++ b/Open-ILS/src/eg2/src/app/staff/catalog/resolver.service.ts @@ -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> { + + 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 { + + console.debug('EgCatalogResolver:resolve()'); + + return Promise.all([ + this.cat.fetchCcvms(), + this.cat.fetchCmfs(), + this.fetchSettings() + ]); + } + + fetchSettings(): Promise { + 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 index 0000000000..9681747043 --- /dev/null +++ b/Open-ILS/src/eg2/src/app/staff/catalog/result/facets.component.html @@ -0,0 +1,43 @@ + +
+
+
+
+
+
+

+ {{searchContext.result.facetData[facetConf.facetClass][name].cmfLabel}} +

+ +
+
+
+
+
+
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 index 0000000000..be689686c4 --- /dev/null +++ b/Open-ILS/src/eg2/src/app/staff/catalog/result/facets.component.ts @@ -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 index 0000000000..c283ff45d5 --- /dev/null +++ b/Open-ILS/src/eg2/src/app/staff/catalog/result/pagination.component.css @@ -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 index 0000000000..55b63dd0d9 --- /dev/null +++ b/Open-ILS/src/eg2/src/app/staff/catalog/result/pagination.component.html @@ -0,0 +1,28 @@ + + 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 index 0000000000..189fbced81 --- /dev/null +++ b/Open-ILS/src/eg2/src/app/staff/catalog/result/pagination.component.ts @@ -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 index 0000000000..1048211238 --- /dev/null +++ b/Open-ILS/src/eg2/src/app/staff/catalog/result/record.component.html @@ -0,0 +1,129 @@ + + +
+
+
+
+ + + + +
+
+
+
+ + + #{{index + 1 + searchContext.pager.offset}} + + + {{bibSummary.title || ' '}} + +
+
+ +
+
+ + + {{bibSummary.ccvms.icon_format.label}} + + {{bibSummary.edition}} + {{bibSummary.pubdate}} +
+
+
+
+
+
+
+ + {{copyCount.available}} / {{copyCount.count}} items + +
+
+ @ {{orgName(copyCount.org_unit)}} +
+
+
+
+
+
+
+ TCN: {{bibSummary.tcn_value}} +
+
+
+
+ Holds: {{bibSummary.holdCount}} +
+
+
+
+
+
+
+ Created {{bibSummary.create_date | date:'shortDate'}} by + + + {{bibSummary.creator.usrname()}} + + + ... +
+
+
+
+
+
+ Edited {{bibSummary.edit_date | date:'shortDate'}} by + + {{bibSummary.editor.usrname()}} + + ... +
+
+
+
+
+
+ + + + + + +
+
+
+
+
+
+
+ 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 index 0000000000..14f33e2d79 --- /dev/null +++ b/Open-ILS/src/eg2/src/app/staff/catalog/result/record.component.ts @@ -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 index 0000000000..f357a6c579 --- /dev/null +++ b/Open-ILS/src/eg2/src/app/staff/catalog/result/results.component.html @@ -0,0 +1,30 @@ + +
+
+
+

Search Results ({{searchContext.result.count}})

+
+
+
+
+ +
+
+
+
+
+ +
+
+
+
+
+ + +
+
+
+
+
+
+ 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 index 0000000000..a970154831 --- /dev/null +++ b/Open-ILS/src/eg2/src/app/staff/catalog/result/results.component.ts @@ -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 index 0000000000..2376f80cb4 --- /dev/null +++ b/Open-ILS/src/eg2/src/app/staff/catalog/routing.module.ts @@ -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 index 0000000000..6201dff923 --- /dev/null +++ b/Open-ILS/src/eg2/src/app/staff/catalog/search-form.component.css @@ -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 index 0000000000..650c785f8c --- /dev/null +++ b/Open-ILS/src/eg2/src/app/staff/catalog/search-form.component.html @@ -0,0 +1,227 @@ + +
+
+
+
+
+ +
+
+ +
+
+
+ +
+
+ +
+
+
+
+ +
+
+ +
+
+
+
+ + +
+
+
+
+ + + + +
+
+
+ +
+
+
+ + +
+
+ +
+
+
+ +
+
+
+
+ +
+
+
+ +
+
+
+
+
+
+ Searching.. +
+
+
+
+
+
+
+ +
+
+ +
+
+ +
+
+ +
+
+
+
+ +
+
+ +
+
+ +
+
+ Copy location filter goes here... +
+
+
+ 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 index 0000000000..cf6d66dc6a --- /dev/null +++ b/Open-ILS/src/eg2/src/app/staff/catalog/search-form.component.ts @@ -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 index 0000000000..e83cf9e9d8 --- /dev/null +++ b/Open-ILS/src/eg2/src/app/staff/circ/patron/bcsearch/bcsearch.component.html @@ -0,0 +1,19 @@ + + + + +
+
+
+ Barcode: +
+ +
+ +
+
+
+ + 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 index 0000000000..86e85f3e7d --- /dev/null +++ b/Open-ILS/src/eg2/src/app/staff/circ/patron/bcsearch/bcsearch.component.ts @@ -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 index 0000000000..cbb97b54be --- /dev/null +++ b/Open-ILS/src/eg2/src/app/staff/circ/patron/bcsearch/bcsearch.module.ts @@ -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 index 0000000000..82f8a8bfc2 --- /dev/null +++ b/Open-ILS/src/eg2/src/app/staff/circ/patron/bcsearch/routing.module.ts @@ -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 index 0000000000..0f0bbb07bb --- /dev/null +++ b/Open-ILS/src/eg2/src/app/staff/circ/patron/routing.module.ts @@ -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 index 0000000000..3a4ffe7189 --- /dev/null +++ b/Open-ILS/src/eg2/src/app/staff/circ/routing.module.ts @@ -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 index 0000000000..61094e4162 --- /dev/null +++ b/Open-ILS/src/eg2/src/app/staff/common.module.ts @@ -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 index 0000000000..ba474f809f --- /dev/null +++ b/Open-ILS/src/eg2/src/app/staff/login.component.html @@ -0,0 +1,58 @@ +
+
+
+ Sign In +
+
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+
+ +
+
+
+
+
+
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 index 0000000000..d46bb749a1 --- /dev/null +++ b/Open-ILS/src/eg2/src/app/staff/login.component.ts @@ -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 index 0000000000..63d3e37b29 --- /dev/null +++ b/Open-ILS/src/eg2/src/app/staff/nav.component.css @@ -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 index 0000000000..c5d5fcbf93 --- /dev/null +++ b/Open-ILS/src/eg2/src/app/staff/nav.component.html @@ -0,0 +1,230 @@ + + 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 index 0000000000..14db1d8e2b --- /dev/null +++ b/Open-ILS/src/eg2/src/app/staff/nav.component.ts @@ -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 index 0000000000..301979fb18 --- /dev/null +++ b/Open-ILS/src/eg2/src/app/staff/resolver.service.ts @@ -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> { + + // Tracks the primary resolve observable. + observer: Observer; + + 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 { + + 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 + = 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 { + 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 { + 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 index 0000000000..134140af5c --- /dev/null +++ b/Open-ILS/src/eg2/src/app/staff/routing.module.ts @@ -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 index 0000000000..66e77dcb4f --- /dev/null +++ b/Open-ILS/src/eg2/src/app/staff/sandbox/README @@ -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 index 0000000000..0fa4d3e2d1 --- /dev/null +++ b/Open-ILS/src/eg2/src/app/staff/sandbox/routing.module.ts @@ -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 index 0000000000..f2ae275e4e --- /dev/null +++ b/Open-ILS/src/eg2/src/app/staff/sandbox/sandbox.component.html @@ -0,0 +1,79 @@ + + + + + +
+ + + + + + + +
+ + + +
+ + + +
+ + + +
+ +
+ + + + +
+ Hello, {{name}} + + + +
+ + + + + + + + + + + + + + + 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 index 0000000000..78d226356c --- /dev/null +++ b/Open-ILS/src/eg2/src/app/staff/sandbox/sandbox.component.ts @@ -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 index 0000000000..6302e73996 --- /dev/null +++ b/Open-ILS/src/eg2/src/app/staff/sandbox/sandbox.module.ts @@ -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 index 0000000000..1d6d167d9c --- /dev/null +++ b/Open-ILS/src/eg2/src/app/staff/share/README @@ -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 index 0000000000..66266086d8 --- /dev/null +++ b/Open-ILS/src/eg2/src/app/staff/share/bib-summary/bib-summary.component.html @@ -0,0 +1,66 @@ + +
+
+
+ Record Summary +
+
+ +
+
+
    +
  • +
    +
    Title:
    +
    {{summary.title}}
    +
    Edition:
    +
    {{summary.edition}}
    +
    TCN:
    +
    {{summary.tcn_value}}
    +
    Created By:
    +
    + {{summary.creator.usrname()}} +
    +
    +
  • +
  • +
    +
    Author:
    +
    {{summary.author}}
    +
    Pubdate:
    +
    {{summary.pubdate}}
    +
    Database ID:
    +
    {{summary.id}}
    +
    Last Edited By:
    +
    + {{summary.editor.usrname()}} +
    +
    +
  • +
  • +
    +
    Bib Call #:
    +
    {{summary.callNumber}}
    +
    Record Owner:
    +
    TODO
    +
    Created On:
    +
    {{summary.create_date | date:'shortDate'}}
    +
    Last Edited On:
    +
    {{summary.edit_date | date:'shortDate'}}
    +
    +
  • +
+
+
+ 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 index 0000000000..c672adda2c --- /dev/null +++ b/Open-ILS/src/eg2/src/app/staff/share/bib-summary/bib-summary.component.ts @@ -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 index 0000000000..3befa3ef94 --- /dev/null +++ b/Open-ILS/src/eg2/src/app/staff/share/op-change/op-change.component.html @@ -0,0 +1,65 @@ + + + + + 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 index 0000000000..39444f653d --- /dev/null +++ b/Open-ILS/src/eg2/src/app/staff/share/op-change/op-change.component.ts @@ -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 { + 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 { + 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 index 0000000000..3d22b88d57 --- /dev/null +++ b/Open-ILS/src/eg2/src/app/staff/share/staff-banner.component.ts @@ -0,0 +1,15 @@ +import {Component, OnInit, Input} from '@angular/core'; + +@Component({ + selector: 'eg-staff-banner', + template: + '' +}) + +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 index 0000000000..0018fc37b7 --- /dev/null +++ b/Open-ILS/src/eg2/src/app/staff/splash.component.html @@ -0,0 +1,128 @@ + + + + +
+ + +
+
+ +
+
+ +
+
+
+
+
Circulation and Patrons
+
+ +
+
+ +
+
+
+
Item Search and Cataloging
+
+
+
+
+
+ + + + + +
+
+ +
+ + Copy Buckets +
+
+
+
+
+ +
+
+
+
Administration
+
+ +
+
+
+
+ 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 index 0000000000..beba23ae83 --- /dev/null +++ b/Open-ILS/src/eg2/src/app/staff/splash.component.ts @@ -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 index 0000000000..508d879b9b --- /dev/null +++ b/Open-ILS/src/eg2/src/app/staff/staff.component.css @@ -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 index 0000000000..2a2539c067 --- /dev/null +++ b/Open-ILS/src/eg2/src/app/staff/staff.component.html @@ -0,0 +1,19 @@ + + + +
+ + +
+ + + + + + + + + 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 index 0000000000..c8daf732b7 --- /dev/null +++ b/Open-ILS/src/eg2/src/app/staff/staff.component.ts @@ -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 index 0000000000..52f6abdd31 --- /dev/null +++ b/Open-ILS/src/eg2/src/app/staff/staff.module.ts @@ -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 index 0000000000..eaa1c71896 --- /dev/null +++ b/Open-ILS/src/eg2/src/app/welcome.component.html @@ -0,0 +1,11 @@ +
+

Welcome to Webby

+

+ If you see this page, you're probably in good shape... +

+
+

+ But maybe you meant to go to the + staff page +

+
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 index 0000000000..398d12776b --- /dev/null +++ b/Open-ILS/src/eg2/src/app/welcome.component.ts @@ -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 index 0000000000..e69de29bb2 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 index 0000000000..3612073bc3 --- /dev/null +++ b/Open-ILS/src/eg2/src/environments/environment.prod.ts @@ -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 index 0000000000..b7f639aeca --- /dev/null +++ b/Open-ILS/src/eg2/src/environments/environment.ts @@ -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 index 0000000000000000000000000000000000000000..8081c7ceaf2be08bf59010158c586170d9d2d517 GIT binary patch literal 5430 zcmc(je{54#6vvCoAI3i*G5%$U7!sA3wtMZ$fH6V9C`=eXGJb@R1%(I_{vnZtpD{6n z5Pl{DmxzBDbrB>}`90e12m8T*36WoeDLA&SD_hw{H^wM!cl_RWcVA!I+x87ee975; z@4kD^=bYPn&pmG@(+JZ`rqQEKxW<}RzhW}I!|ulN=fmjVi@x{p$cC`)5$a!)X&U+blKNvN5tg=uLvuLnuqRM;Yc*swiexsoh#XPNu{9F#c`G zQLe{yWA(Y6(;>y|-efAy11k<09(@Oo1B2@0`PtZSkqK&${ zgEY}`W@t{%?9u5rF?}Y7OL{338l*JY#P!%MVQY@oqnItpZ}?s z!r?*kwuR{A@jg2Chlf0^{q*>8n5Ir~YWf*wmsh7B5&EpHfd5@xVaj&gqsdui^spyL zB|kUoblGoO7G(MuKTfa9?pGH0@QP^b#!lM1yHWLh*2iq#`C1TdrnO-d#?Oh@XV2HK zKA{`eo{--^K&MW66Lgsktfvn#cCAc*(}qsfhrvOjMGLE?`dHVipu1J3Kgr%g?cNa8 z)pkmC8DGH~fG+dlrp(5^-QBeEvkOvv#q7MBVLtm2oD^$lJZx--_=K&Ttd=-krx(Bb zcEoKJda@S!%%@`P-##$>*u%T*mh+QjV@)Qa=Mk1?#zLk+M4tIt%}wagT{5J%!tXAE;r{@=bb%nNVxvI+C+$t?!VJ@0d@HIyMJTI{vEw0Ul ze(ha!e&qANbTL1ZneNl45t=#Ot??C0MHjjgY8%*mGisN|S6%g3;Hlx#fMNcL<87MW zZ>6moo1YD?P!fJ#Jb(4)_cc50X5n0KoDYfdPoL^iV`k&o{LPyaoqMqk92wVM#_O0l z09$(A-D+gVIlq4TA&{1T@BsUH`Bm=r#l$Z51J-U&F32+hfUP-iLo=jg7Xmy+WLq6_tWv&`wDlz#`&)Jp~iQf zZP)tu>}pIIJKuw+$&t}GQuqMd%Z>0?t%&BM&Wo^4P^Y z)c6h^f2R>X8*}q|bblAF?@;%?2>$y+cMQbN{X$)^R>vtNq_5AB|0N5U*d^T?X9{xQnJYeU{ zoZL#obI;~Pp95f1`%X3D$Mh*4^?O?IT~7HqlWguezmg?Ybq|7>qQ(@pPHbE9V?f|( z+0xo!#m@Np9PljsyxBY-UA*{U*la#8Wz2sO|48_-5t8%_!n?S$zlGe+NA%?vmxjS- zHE5O3ZarU=X}$7>;Okp(UWXJxI%G_J-@IH;%5#Rt$(WUX?6*Ux!IRd$dLP6+SmPn= z8zjm4jGjN772R{FGkXwcNv8GBcZI#@Y2m{RNF_w8(Z%^A*!bS*!}s6sh*NnURytky humW;*g7R+&|Ledvc- + + + + AngEG + + + + + + + + + + + + + + + + + + + diff --git a/Open-ILS/src/eg2/src/main.ts b/Open-ILS/src/eg2/src/main.ts new file mode 100644 index 0000000000..08b359c3b7 --- /dev/null +++ b/Open-ILS/src/eg2/src/main.ts @@ -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 index 0000000000..20d40751a6 --- /dev/null +++ b/Open-ILS/src/eg2/src/polyfills.ts @@ -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 index 0000000000..9db4fe8d72 --- /dev/null +++ b/Open-ILS/src/eg2/src/styles.css @@ -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 index 0000000000..cd612eeb0e --- /dev/null +++ b/Open-ILS/src/eg2/src/test.ts @@ -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 index 0000000000..39ba8dbacb --- /dev/null +++ b/Open-ILS/src/eg2/src/tsconfig.app.json @@ -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 index 0000000000..63d89ff283 --- /dev/null +++ b/Open-ILS/src/eg2/src/tsconfig.spec.json @@ -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 index 0000000000..ef5c7bd620 --- /dev/null +++ b/Open-ILS/src/eg2/src/typings.d.ts @@ -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 index 0000000000..14a504dc91 --- /dev/null +++ b/Open-ILS/src/eg2/tsconfig.json @@ -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 index 0000000000..c24dc293d7 --- /dev/null +++ b/Open-ILS/src/eg2/tslint.json @@ -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 + } +} diff --git a/Open-ILS/src/perlmods/lib/OpenILS/Application/Search/Biblio.pm b/Open-ILS/src/perlmods/lib/OpenILS/Application/Search/Biblio.pm index ca3f85a27f..56fd048153 100644 --- a/Open-ILS/src/perlmods/lib/OpenILS/Application/Search/Biblio.pm +++ b/Open-ILS/src/perlmods/lib/OpenILS/Application/Search/Biblio.pm @@ -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 index 0000000000..0aa2bb6c90 --- /dev/null +++ b/Open-ILS/src/templates/staff/ang2_js.tt2 @@ -0,0 +1,13 @@ + + + + + + + diff --git a/Open-ILS/src/templates/staff/base.tt2 b/Open-ILS/src/templates/staff/base.tt2 index 7ce42ae73f..386e4f1ebc 100644 --- a/Open-ILS/src/templates/staff/base.tt2 +++ b/Open-ILS/src/templates/staff/base.tt2 @@ -1,9 +1,17 @@ [%- PROCESS 'staff/config.tt2' %] + [%- IF ctx.page_ctrl %] ng-controller="[% ctx.page_ctrl %]"[% END %] + [% END %]> + [% IF ctx.is_ang2_app AND ctx.page_app %] + + [% END %] - {{pageTitle || "[% ctx.page_title %]"}} + + [% IF ctx.is_ang2_app %] + + [% ctx.page_title || l('Evergreen') %] + [% ELSE %] + {{pageTitle || "[% ctx.page_title %]"}} + [% END %] + @@ -49,6 +67,11 @@ # 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; %]