--- /dev/null
+{
+ "$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": {}
+ }
+}
--- /dev/null
+# 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
--- /dev/null
+# 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
--- /dev/null
+= EG Angular2 App =
+
+=== Apache Configuration ===
+
+[source,conf]
+---------------------------------------------------------------------
+<Directory "/openils/var/web/eg2">
+ FallbackResource /eg2/index.html
+</Directory>
+---------------------------------------------------------------------
+
+=== Transpile + Deploy in --watch mode for Dev ===
+
+[source,sh]
+---------------------------------------------------------------------
+ng build --aot --app eg --deploy-url /eg2/ --base-href /eg2/ --output-path ../../web/eg2/ --watch
+---------------------------------------------------------------------
+
+=== Link build files into place for ease of dev
+
+[source,sh]
+---------------------------------------------------------------------
+sudo -u opensrf ln -s /PATH/TO/Evergreen/Open-ILS/web/eg2 /openils/var/web/eg2
+---------------------------------------------------------------------
--- /dev/null
+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!');
+ });
+});
--- /dev/null
+import { browser, by, element } from 'protractor';
+
+export class AppPage {
+ navigateTo() {
+ return browser.get('/');
+ }
+
+ getParagraphText() {
+ return element(by.css('app-root h1')).getText();
+ }
+}
--- /dev/null
+{
+ "extends": "../tsconfig.json",
+ "compilerOptions": {
+ "outDir": "../out-tsc/e2e",
+ "baseUrl": "./",
+ "module": "commonjs",
+ "target": "es5",
+ "types": [
+ "jasmine",
+ "jasminewd2",
+ "node"
+ ]
+ }
+}
--- /dev/null
+// 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
+ });
+};
--- /dev/null
+{
+ "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"
+ }
+}
--- /dev/null
+// 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 } }));
+ }
+};
--- /dev/null
+import {Component} from '@angular/core';
+
+@Component({
+ selector: 'eg-root',
+ template: '<router-outlet></router-outlet>'
+})
+
+export class EgBaseComponent {
+}
+
+
--- /dev/null
+/**
+ * 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 {}
+
--- /dev/null
+/**
+ * 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
+ ]
+ };
+ }
+}
+
--- /dev/null
+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.
+
--- /dev/null
+import {Injectable, EventEmitter} from '@angular/core';
+import {EgNetService} from './net.service';
+import {EgEventService, EgEvent} from './event.service';
+import {EgIdlService, EgIdlObject} from './idl.service';
+import {EgStoreService} from './store.service';
+
+// Not universally available.
+declare var BroadcastChannel;
+
+// Models a login instance.
+class EgAuthUser {
+ user: EgIdlObject; // actor.usr (au) object
+ workstation: string; // workstation name
+ token: string;
+ authtime: number;
+
+ constructor(token: string, authtime: number, workstation?: string) {
+ this.token = token;
+ this.workstation = workstation;
+ this.authtime = authtime;
+ }
+}
+
+// Params required for calling the login() method.
+interface EgAuthLoginArgs {
+ username: string,
+ password: string,
+ type: string,
+ workstation?: string
+}
+
+export enum EgAuthWsState {
+ PENDING,
+ NOT_USED,
+ NOT_FOUND_SERVER,
+ NOT_FOUND_LOCAL,
+ VALID
+};
+
+@Injectable()
+export class EgAuthService {
+
+ private authChannel: any;
+
+ private activeUser: EgAuthUser = null;
+
+ workstationState: EgAuthWsState = EgAuthWsState.PENDING;
+
+ // Used by auth-checking resolvers
+ redirectUrl: string;
+
+ // reference to active auth validity setTimeout handler.
+ pollTimeout: any;
+
+ constructor(
+ private egEvt: EgEventService,
+ private net: EgNetService,
+ private store: EgStoreService
+ ) {
+
+ console.log("egAuth constructor()");
+
+ // BroadcastChannel is not yet defined in PhantomJS
+ this.authChannel = BroadcastChannel ?
+ new BroadcastChannel('eg.auth') : {};
+ }
+
+ // Returns true if we are currently in op-change mode.
+ opChangeIsActive(): boolean {
+ return Boolean(this.store.getLoginSessionItem('eg.auth.time.oc'));
+ }
+
+ // - Accessor functions always refer to the active user.
+
+ user(): EgIdlObject {
+ return this.activeUser ? this.activeUser.user : null;
+ };
+
+ // Workstation name.
+ workstation(): string {
+ return this.activeUser ? this.activeUser.workstation : null;
+ };
+
+ token(): string {
+ return this.activeUser ? this.activeUser.token : null;
+ };
+
+ authtime(): number {
+ return this.activeUser ? this.activeUser.authtime : 0;
+ };
+
+ // NOTE: EgNetService emits an event if the auth session has expired.
+ // This only rejects when no authtoken is found.
+ testAuthToken(): Promise<any> {
+
+ if (!this.activeUser) {
+ // Only necessary on new page loads. During op-change,
+ // for example, we already have an activeUser.
+ this.activeUser = new EgAuthUser(
+ this.store.getLoginSessionItem('eg.auth.token'),
+ this.store.getLoginSessionItem('eg.auth.time')
+ );
+ }
+
+ if (!this.token()) return Promise.reject('no authtoken');
+
+ return this.net.request(
+ 'open-ils.auth',
+ 'open-ils.auth.session.retrieve', this.token()).toPromise()
+ .then(user => {
+ // EgNetService interceps NO_SESSION events.
+ // We can only get here if the session is valid.
+ this.activeUser.user = user;
+ this.listenForLogout();
+ this.sessionPoll();
+ });
+ }
+
+ login(args: EgAuthLoginArgs, isOpChange?: boolean): Promise<void> {
+ return this.net.request('open-ils.auth', 'open-ils.auth.login', args)
+ .toPromise().then(res => {
+ return this.handleLoginResponse(
+ args, this.egEvt.parse(res), isOpChange)
+ })
+ }
+
+ handleLoginResponse(
+ args: EgAuthLoginArgs, evt: EgEvent, isOpChange: boolean): Promise<void> {
+
+ switch (evt.textcode) {
+ case 'SUCCESS':
+ return this.handleLoginOk(args, evt, isOpChange);
+
+ case 'WORKSTATION_NOT_FOUND':
+ console.error(`No such workstation "${args.workstation}"`);
+ this.workstationState = EgAuthWsState.NOT_FOUND_SERVER;
+ delete args.workstation;
+ return this.login(args, isOpChange);
+
+ default:
+ console.error(`Login returned unexpected event: ${evt}`);
+ return Promise.reject('login failed');
+ }
+ }
+
+ // Stash the login data
+ handleLoginOk(args: EgAuthLoginArgs, evt: EgEvent, isOpChange: boolean): Promise<void> {
+
+ if (isOpChange) {
+ this.store.setLoginSessionItem('eg.auth.token.oc', this.token());
+ this.store.setLoginSessionItem('eg.auth.time.oc', this.authtime());
+ }
+
+ this.activeUser = new EgAuthUser(
+ evt.payload.authtoken,
+ evt.payload.authtime,
+ args.workstation
+ );
+
+ this.store.setLoginSessionItem('eg.auth.token', this.token());
+ this.store.setLoginSessionItem('eg.auth.time', this.authtime());
+
+ return Promise.resolve();
+ }
+
+ undoOpChange(): Promise<any> {
+ if (this.opChangeIsActive()) {
+ this.deleteSession();
+ this.activeUser = new EgAuthUser(
+ this.store.getLoginSessionItem('eg.auth.token.oc'),
+ this.store.getLoginSessionItem('eg.auth.time.oc'),
+ this.activeUser.workstation
+ );
+ this.store.removeLoginSessionItem('eg.auth.token.oc');
+ this.store.removeLoginSessionItem('eg.auth.time.oc');
+ this.store.setLoginSessionItem('eg.auth.token', this.token());
+ this.store.setLoginSessionItem('eg.auth.time', this.authtime());
+ }
+ // Re-fetch the user.
+ return this.testAuthToken();
+ }
+
+ /**
+ * Listen for logout events initiated by other browser tabs.
+ */
+ listenForLogout(): void {
+ if (this.authChannel.onmessage) return;
+
+ this.authChannel.onmessage = (e) => {
+ console.debug(
+ `received eg.auth broadcast ${JSON.stringify(e.data)}`);
+
+ if (e.data.action == 'logout') {
+ // Logout will be handled by the originating tab.
+ // We just need to clear tab-local memory.
+ this.cleanup();
+ this.net.authExpired$.emit({viaExternal: true});
+ }
+ }
+ }
+
+ /**
+ * Force-check the validity of the authtoken on occasion.
+ * This allows us to redirect an idle staff client back to the login
+ * page after the session times out. Otherwise, the UI would stay
+ * open with potentially sensitive data visible.
+ * TODO: What is the practical difference (for a browser) between
+ * checking auth validity and the ui.general.idle_timeout setting?
+ * Does that setting serve a purpose in a browser environment?
+ */
+ sessionPoll(): void {
+
+ // add a 5 second delay to give the token plenty of time
+ // to expire on the server.
+ let pollTime = this.authtime() * 1000 + 5000;
+
+ this.pollTimeout = setTimeout(() => {
+ this.net.request(
+ 'open-ils.auth',
+ 'open-ils.auth.session.retrieve',
+ this.token(),
+ 0, // return extra auth details, unneeded here.
+ 1 // avoid extending the auth timeout
+
+ // EgNetService intercepts NO_SESSION events.
+ // If the promise resolves, the session is valid.
+ ).toPromise().then(user => this.sessionPoll())
+
+ }, pollTime);
+ }
+
+
+ // Resolves if login workstation matches a workstation known to this
+ // browser instance. No attempt is made to see if the workstation
+ // is present on the server. That happens at login time.
+ verifyWorkstation(): Promise<void> {
+
+ if (!this.user()) {
+ this.workstationState = EgAuthWsState.PENDING;
+ return Promise.reject('Cannot verify workstation without user');
+ }
+
+ if (!this.user().wsid()) {
+ this.workstationState = EgAuthWsState.NOT_USED;
+ return Promise.reject('User has no workstation ID to verify');
+ }
+
+ return new Promise((resolve, reject) => {
+ this.store.getItem('eg.workstation.all')
+ .then(workstations => {
+
+ if (workstations) {
+ let ws = workstations.filter(
+ w => {return w.id == this.user().wsid()})[0];
+
+ if (ws) {
+ this.activeUser.workstation = ws.name;
+ this.workstationState = EgAuthWsState.VALID;
+ return resolve();
+ }
+ }
+
+ this.workstationState = EgAuthWsState.NOT_FOUND_LOCAL;
+ reject();
+ });
+ });
+ }
+
+ deleteSession(): void {
+ if (this.token()) {
+ this.net.request(
+ 'open-ils.auth',
+ 'open-ils.auth.session.delete', this.token())
+ .subscribe(x => console.debug('logged out'))
+ }
+ }
+
+ // Tell all listening browser tabs that it's time to logout.
+ // This should only be invoked by one tab.
+ broadcastLogout(): void {
+ console.debug('Notifying tabs of imminent auth token removal');
+ this.authChannel.postMessage({action : 'logout'});
+ }
+
+ // Remove/reset session data
+ cleanup(): void {
+ this.activeUser = null;
+ if (this.pollTimeout) {
+ clearTimeout(this.pollTimeout);
+ this.pollTimeout = null;
+ }
+ }
+
+ // Invalidate server auth token and clean up.
+ logout(): void {
+ this.deleteSession();
+ this.store.clearLoginSessionItems();
+ this.cleanup();
+ }
+}
--- /dev/null
+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;
+ }
+}
+
+
--- /dev/null
+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;
+ }
+}
+
--- /dev/null
+/**
+ *
+ * constructor(private net : EgNetService) {
+ * ...
+ * this.net.request(service, method, param1 [, param2, ...])
+ * .subscribe(
+ * (res) => console.log('received one resopnse: ' + res),
+ * (err) => console.error('recived request error: ' + err),
+ * () => console.log('request complete')
+ * )
+ * );
+ * ...
+ *
+ * // Example translating a net request into a promise.
+ * this.net.request(service, method, param1)
+ * .toPromise().then(result => console.log(result));
+ *
+ * }
+ *
+ * Each response is relayed via Observable.next(). The interface is
+ * the same for streaming and atomic requests.
+ */
+import {Injectable, EventEmitter} from '@angular/core';
+import {Observable, Observer} from 'rxjs/Rx';
+import {EgEventService, EgEvent} from './event.service';
+
+// Global vars from opensrf.js
+// These are availavble at runtime, but are not exported.
+declare var OpenSRF, OSRF_TRANSPORT_TYPE_WS;
+
+export class EgNetRequest {
+ service : string;
+ method : string;
+ params : any[];
+ observer : Observer<any>;
+ superseded : boolean = false;
+ // If set, this will be used instead of a one-off OpenSRF.ClientSession.
+ session? : any;
+ // True if we're using a single-use local session
+ localSession: boolean = true;
+
+ // Last EgEvent encountered by this request.
+ // Most callers will not need to import EgEvent since the parsed
+ // event will be available here.
+ evt: EgEvent;
+
+ constructor(service: string, method: string, params: any[], session?: any) {
+ this.service = service;
+ this.method = method;
+ this.params = params;
+ if (session) {
+ this.session = session;
+ this.localSession = false;
+ } else {
+ this.session = new OpenSRF.ClientSession(service);
+ }
+ }
+}
+
+export interface EgAuthExpiredEvent {
+ // request is set when the auth expiration was determined as a
+ // by-product of making an API call.
+ request?: EgNetRequest;
+
+ // True if this environment (e.g. browser tab) was notified of the
+ // expired auth token from an external source (e.g. another browser tab).
+ viaExternal?: boolean;
+}
+
+@Injectable()
+export class EgNetService {
+
+ permFailed$: EventEmitter<EgNetRequest>;
+ authExpired$: EventEmitter<EgAuthExpiredEvent>;
+
+ // If true, permission failures are emitted via permFailed$
+ // and the active request is marked as superseded.
+ permFailedHasHandler: Boolean = false;
+
+ constructor(
+ private egEvt: EgEventService
+ ) {
+ this.permFailed$ = new EventEmitter<EgNetRequest>();
+ this.authExpired$ = new EventEmitter<EgAuthExpiredEvent>();
+ }
+
+ // Standard request call -- Variadic params version
+ request(service: string, method: string, ...params: any[]): Observable<any> {
+ return this.requestWithParamList(service, method, params);
+ }
+
+ // Array params version
+ requestWithParamList(service: string,
+ method: string, params: any[]): Observable<any> {
+ return this.requestCompiled(
+ new EgNetRequest(service, method, params));
+ }
+
+ // Request with pre-compiled EgNetRequest
+ requestCompiled(request: EgNetRequest): Observable<any> {
+ return Observable.create(
+ observer => {
+ request.observer = observer;
+ this.sendCompiledRequest(request);
+ }
+ );
+ }
+
+ // Send the compiled request to the server via WebSockets
+ sendCompiledRequest(request: EgNetRequest): void {
+ OpenSRF.Session.transport = OSRF_TRANSPORT_TYPE_WS;
+ console.debug(`EgNet: request ${request.method}`);
+
+ request.session.request({
+ async : true, // WS only operates in async mode
+ method : request.method,
+ params : request.params,
+ oncomplete : () => {
+
+ // TODO: teach opensrf.js to call cleanup() inside
+ // disconnect() and teach EgPcrud to call cleanup()
+ // as needed to avoid long-lived session data bloat.
+ if (request.localSession)
+ request.session.cleanup();
+
+ // A superseded request will be complete()'ed by the
+ // superseder at a later time.
+ if (!request.superseded)
+ request.observer.complete();
+ },
+ onresponse : r => {
+ this.dispatchResponse(request, r.recv().content());
+ },
+ onerror : errmsg => {
+ let msg = `${request.method} failed! See server logs. ${errmsg}`;
+ console.error(msg);
+ request.observer.error(msg);
+ },
+ onmethoderror : (req, statCode, statMsg) => {
+ let msg =
+ `${request.method} failed! stat=${statCode} msg=${statMsg}`;
+ console.error(msg);
+
+ if (request.service == 'open-ils.pcrud' && statCode == 401) {
+ // 401 is the PCRUD equivalent of a NO_SESSION event
+ this.authExpired$.emit({request: request});
+ }
+
+ request.observer.error(msg);
+ }
+
+ }).send();
+ }
+
+ // Relay response object to the caller for typical/successful
+ // responses. Applies special handling to response events that
+ // require global attention.
+ private dispatchResponse(request, response): void {
+ request.evt = this.egEvt.parse(response);
+
+ if (request.evt) {
+ switch(request.evt.textcode) {
+
+ case 'NO_SESSION':
+ console.debug(`EgNet emitting event: ${request.evt}`);
+ request.observer.error(request.evt.toString());
+ this.authExpired$.emit({request: request});
+ return;
+
+ case 'PERM_FAILURE':
+ if (this.permFailedHasHandler) {
+ console.debug(`EgNet emitting event: ${request.evt}`);
+ request.superseded = true;
+ this.permFailed$.emit(request);
+ return;
+ }
+ }
+ }
+
+ // Pass the response to the caller.
+ request.observer.next(response);
+ };
+}
--- /dev/null
+import {Injectable} from '@angular/core';
+import {Observable} from 'rxjs/Rx';
+import {EgIdlObject, EgIdlService} from './idl.service';
+import {EgNetService} from './net.service';
+import {EgAuthService} from './auth.service';
+import {EgPcrudService} from './pcrud.service';
+
+type EgOrgNodeOrId = number | EgIdlObject;
+
+interface OrgFilter {
+ canHaveUsers?: boolean;
+ canHaveVolumes?: boolean;
+ opacVisible?: boolean;
+ inList?: number[];
+ notInList?: number[];
+}
+
+interface OrgSettingsBatch {
+ [key: string]: any;
+}
+
+@Injectable()
+export class EgOrgService {
+
+ private orgList: EgIdlObject[] = [];
+ private orgTree: EgIdlObject; // root node + children
+ private orgMap: {[id:number] : EgIdlObject} = {};
+ private settingsCache: OrgSettingsBatch = {};
+
+ constructor(
+ private net: EgNetService,
+ private auth: EgAuthService,
+ private pcrud: EgPcrudService
+ ) {}
+
+ get(nodeOrId: EgOrgNodeOrId): EgIdlObject {
+ if (typeof nodeOrId == 'object')
+ return nodeOrId;
+ return this.orgMap[nodeOrId];
+ }
+
+ list(): EgIdlObject[] {
+ return this.orgList;
+ }
+
+ /**
+ * Returns a list of org units that match the selected criteria.
+ * All filters must match for an org to be included in the result set.
+ * Unset filter options are ignored.
+ */
+ filterList(filter: OrgFilter, asId?: boolean): any[] {
+ let list = [];
+ this.list().forEach(org => {
+
+ let chu = filter.canHaveUsers;
+ if (chu && !this.canHaveUsers(org)) return;
+ if (chu === false && this.canHaveUsers(org)) return;
+
+ let chv = filter.canHaveVolumes;
+ if (chv && !this.canHaveVolumes(org)) return;
+ if (chv === false && this.canHaveVolumes(org)) return;
+
+ let ov = filter.opacVisible;
+ if (ov && !this.opacVisible(org)) return;
+ if (ov === false && this.opacVisible(org)) return;
+
+ if (filter.inList && filter.inList.indexOf(org.id()) == -1)
+ return;
+
+ if (filter.notInList && filter.notInList.indexOf(org.id()) > -1)
+ return;
+
+ // All filter tests passed. Add it to the list
+ list.push(asId ? org.id() : org);
+ });
+
+ return list;
+ }
+
+ tree(): EgIdlObject {
+ return this.orgTree;
+ }
+
+ // get the root OU
+ root(): EgIdlObject {
+ return this.orgList[0];
+ }
+
+ // list of org_unit objects or IDs for ancestors + me
+ ancestors(nodeOrId: EgOrgNodeOrId, asId?: boolean): any[] {
+ let node = this.get(nodeOrId);
+ if (!node) return [];
+ let nodes = [node];
+ while( (node = this.get(node.parent_ou())))
+ nodes.push(node);
+ if (asId) return nodes.map(n => n.id());
+ return nodes;
+ }
+
+ // tests that a node can have users
+ canHaveUsers(nodeOrId): boolean {
+ return this
+ .get(nodeOrId)
+ .ou_type()
+ .can_have_users() == 't';
+ }
+
+ // tests that a node can have volumes
+ canHaveVolumes(nodeOrId): boolean {
+ return this
+ .get(nodeOrId)
+ .ou_type()
+ .can_have_vols() == 't';
+ }
+
+ opacVisible(nodeOrId): boolean {
+ return this.get(nodeOrId).opac_visible() == 't';
+ }
+
+ // list of org_unit objects or IDs for me + descendants
+ descendants(nodeOrId: EgOrgNodeOrId, asId?: boolean): any[] {
+ let node = this.get(nodeOrId);
+ if (!node) return [];
+ let nodes = [];
+ function descend(n) {
+ nodes.push(n);
+ n.children().forEach(descend);
+ }
+ descend(node);
+ if (asId)
+ return nodes.map(function(n){return n.id()});
+ return nodes;
+ }
+
+ // list of org_unit objects or IDs for ancestors + me + descendants
+ fullPath(nodeOrId: EgOrgNodeOrId, asId?: boolean): any[] {
+ let list = this.ancestors(nodeOrId, false).concat(
+ this.descendants(nodeOrId, false).slice(1));
+ if (asId)
+ return list.map(function(n){return n.id()});
+ return list;
+ }
+
+ sortTree(sortField?: string, node?: EgIdlObject): void {
+ if (!sortField) sortField = 'shortname';
+ if (!node) node = this.orgTree;
+ node.children(
+ node.children.sort((a, b) => {
+ return a[sortField]() < b[sortField]() ? -1 : 1
+ })
+ );
+ node.children.forEach(n => this.sortTree(n));
+ }
+
+ absorbTree(node?: EgIdlObject): void {
+ if (!node) {
+ node = this.orgTree;
+ this.orgMap = {};
+ this.orgList = [];
+ }
+ this.orgMap[node.id()] = node;
+ this.orgList.push(node);
+ node.children().forEach(c => this.absorbTree(c));
+ }
+
+ /**
+ * Grabs all of the org units from the server, chops them up into
+ * various shapes, then returns an "all done" promise.
+ */
+ fetchOrgs(): Promise<void> {
+ return this.pcrud.search('aou', {parent_ou : null},
+ {flesh : -1, flesh_fields : {aou : ['children', 'ou_type']}},
+ {anonymous : true}
+ ).toPromise().then(tree => {
+ // ingest tree, etc.
+ this.orgTree = tree;
+ this.absorbTree();
+ });
+ }
+
+ /**
+ * Populate 'target' with settings from cache where available.
+ * Return the list of settings /not/ pulled from cache.
+ */
+ private settingsFromCache(names: string[], target: any) {
+ let cacheKeys = Object.keys(this.settingsCache);
+
+ cacheKeys.forEach(key => {
+ let matchIdx = names.indexOf(key);
+ if (matchIdx > -1) {
+ target[key] = this.settingsCache[key];
+ names.splice(matchIdx, 1);
+ }
+ });
+
+ return names;
+ }
+
+ /**
+ * Fetch org settings from the network.
+ * 'auth' is null for anonymous lookup.
+ */
+ private settingsFromNet(orgId: number,
+ names: string[], auth?: string): Promise<any> {
+
+ let settings = {};
+ return new Promise((resolve, reject) => {
+ this.net.request(
+ 'open-ils.actor',
+ 'open-ils.actor.ou_setting.ancestor_default.batch',
+ orgId, names, auth
+ ).subscribe(
+ blob => {
+ Object.keys(blob).forEach(key => {
+ let val = blob[key]; // null or hash
+ settings[key] = val ? val.value : null;
+ });
+ resolve(settings);
+ },
+ err => reject(err)
+ );
+ });
+ }
+
+
+ /**
+ *
+ */
+ settings(names: string[],
+ orgId?: number, anonymous?: boolean): Promise<OrgSettingsBatch> {
+
+ let settings = {};
+ let auth: string = null;
+ let useCache: boolean = false;
+
+ if (this.auth.user()) {
+ if (orgId) {
+ useCache = orgId == this.auth.user().ws_ou();
+ } else {
+ orgId = this.auth.user().ws_ou();
+ useCache = true;
+ }
+
+ // avoid passing auth token when anonymous is requested.
+ if (!anonymous) auth = this.auth.token();
+
+ } else if (!anonymous) {
+ return Promise.reject(
+ 'Use "anonymous" To retrieve org settings without an authtoken');
+ }
+
+ if (useCache) names = this.settingsFromCache(names, settings);
+
+ // All requested settings found in cache (or name list is empty)
+ if (names.length == 0) return Promise.resolve(settings);
+
+ return this.settingsFromNet(orgId, names, auth)
+ .then(settings => {
+ if (useCache) {
+ Object.keys(settings).forEach(key => {
+ this.settingsCache[key] = settings[key];
+ });
+ }
+ return settings;
+ });
+ }
+}
--- /dev/null
+import {Injectable} from '@angular/core';
+import {Observable, Observer} from 'rxjs/Rx';
+import {EgIdlService, EgIdlObject} from './idl.service';
+import {EgNetService, EgNetRequest} from './net.service';
+import {EgAuthService} from './auth.service';
+
+// Externally defined. Used here for debugging.
+declare var js2JSON: (jsThing:any) => string;
+declare var OpenSRF: any; // creating sessions
+
+interface EgPcrudReqOps {
+ authoritative?: boolean;
+ anonymous?: boolean;
+ idlist?: boolean;
+ atomic?: boolean;
+}
+
+// For for documentation purposes.
+type EgPcrudResponse = any;
+
+export class EgPcrudContext {
+
+ static verboseLogging: boolean = true; //
+ static identGenerator: number = 0; // for debug logging
+
+ private ident: number;
+ private authoritative: boolean;
+ private xactCloseMode: string;
+ private cudIdx: number;
+ private cudAction: string;
+ private cudLast: EgPcrudResponse;
+ private cudList: EgIdlObject[];
+
+ private idl: EgIdlService;
+ private net: EgNetService;
+ private auth: EgAuthService;
+
+ // Tracks nested CUD actions
+ cudObserver: Observer<EgPcrudResponse>;
+
+ session: any; // OpenSRF.ClientSession
+
+ constructor( // passed in by parent service -- not injected
+ egIdl: EgIdlService,
+ egNet: EgNetService,
+ egAuth: EgAuthService
+ ) {
+ this.idl = egIdl;
+ this.net = egNet;
+ this.auth = egAuth;
+ this.xactCloseMode = 'rollback';
+ this.ident = EgPcrudContext.identGenerator++;
+ this.session = new OpenSRF.ClientSession('open-ils.pcrud');
+ }
+
+ toString(): string {
+ return '[PCRUDContext ' + this.ident + ']';
+ }
+
+ log(msg: string): void {
+ if (EgPcrudContext.verboseLogging)
+ console.debug(this + ': ' + msg);
+ }
+
+ err(msg: string): void {
+ console.error(this + ': ' + msg);
+ }
+
+ token(reqOps?: EgPcrudReqOps): string {
+ return (reqOps && reqOps.anonymous) ?
+ 'ANONYMOUS' : this.auth.token();
+ }
+
+ connect(): Promise<EgPcrudContext> {
+ this.log('connect');
+ return new Promise( (resolve, reject) => {
+ this.session.connect({
+ onconnect : () => { resolve(this); }
+ });
+ })
+ }
+
+ disconnect(): void {
+ this.log('disconnect');
+ this.session.disconnect();
+ }
+
+ retrieve(fmClass: string, pkey: Number | string,
+ pcrudOps?: any, reqOps?: EgPcrudReqOps): Observable<EgPcrudResponse> {
+ if (!reqOps) reqOps = {};
+ this.authoritative = reqOps.authoritative || false;
+ return this.dispatch(
+ `open-ils.pcrud.retrieve.${fmClass}`,
+ [this.token(reqOps), pkey, pcrudOps]);
+ }
+
+ retrieveAll(fmClass: string, pcrudOps?: any,
+ reqOps?: EgPcrudReqOps): Observable<EgPcrudResponse> {
+ let search = {};
+ search[this.idl.classes[fmClass].pkey] = {'!=' : null};
+ return this.search(fmClass, search, pcrudOps, reqOps);
+ }
+
+ search(fmClass: string, search: any,
+ pcrudOps?: any, reqOps?: EgPcrudReqOps): Observable<EgPcrudResponse> {
+ reqOps = reqOps || {};
+ this.authoritative = reqOps.authoritative || false;
+
+ let returnType = reqOps.idlist ? 'id_list' : 'search';
+ let method = `open-ils.pcrud.${returnType}.${fmClass}`;
+
+ if (reqOps.atomic) method += '.atomic';
+
+ return this.dispatch(method, [this.token(reqOps), search, pcrudOps]);
+ }
+
+ create(list: EgIdlObject | EgIdlObject[]): Observable<EgPcrudResponse> {
+ return this.cud('create', list)
+ }
+ update(list: EgIdlObject | EgIdlObject[]): Observable<EgPcrudResponse> {
+ return this.cud('update', list)
+ }
+ remove(list: EgIdlObject | EgIdlObject[]): Observable<EgPcrudResponse> {
+ return this.cud('delete', list)
+ }
+ autoApply(list: EgIdlObject | EgIdlObject[]): Observable<EgPcrudResponse> { // RENAMED
+ return this.cud('auto', list)
+ }
+
+ xactClose(): Observable<EgPcrudResponse> {
+ return this.sendRequest(
+ 'open-ils.pcrud.transaction.' + this.xactCloseMode,
+ [this.token()]
+ );
+ };
+
+ xactBegin(): Observable<EgPcrudResponse> {
+ return this.sendRequest(
+ 'open-ils.pcrud.transaction.begin', [this.token()]
+ );
+ };
+
+ private dispatch(method: string, params: any[]): Observable<EgPcrudResponse> {
+ if (this.authoritative) {
+ return this.wrapXact(() => {
+ return this.sendRequest(method, params);
+ });
+ } else {
+ return this.sendRequest(method, params)
+ }
+ };
+
+
+ // => connect
+ // => xact_begin
+ // => action
+ // => xact_close(commit/rollback)
+ // => disconnect
+ wrapXact(mainFunc: () => Observable<EgPcrudResponse>): Observable<EgPcrudResponse> {
+ return Observable.create(observer => {
+
+ // 1. connect
+ this.connect()
+
+ // 2. start the transaction
+ .then(() => {return this.xactBegin().toPromise()})
+
+ // 3. execute the main body
+ .then(() => {
+
+ mainFunc().subscribe(
+ res => observer.next(res),
+ err => observer.error(err),
+ () => {
+ this.xactClose().toPromise().then(() => {
+ // 5. disconnect
+ this.disconnect();
+ // 6. all done
+ observer.complete();
+ });
+ }
+ );
+ })
+ });
+ };
+
+ private sendRequest(method: string,
+ params: any[]): Observable<EgPcrudResponse> {
+
+ this.log(`sendRequest(${method})`);
+
+ return this.net.requestCompiled(
+ new EgNetRequest(
+ 'open-ils.pcrud', method, params, this.session)
+ );
+ }
+
+ private cud(action: string,
+ list: EgIdlObject | EgIdlObject[]): Observable<EgPcrudResponse> {
+ this.cudList = [].concat(list); // value or array
+
+ this.log(`CUD(): ${action}`);
+
+ this.cudIdx = 0;
+ this.cudAction = action;
+ this.xactCloseMode = 'commit';
+
+ return this.wrapXact(() => {
+ return Observable.create(observer => {
+ this.cudObserver = observer;
+ this.nextCudRequest();
+ });
+ });
+ }
+
+ /**
+ * Loops through the list of objects to update and sends
+ * them one at a time to the server for processing. Once
+ * all are done, the cudObserver is resolved.
+ */
+ nextCudRequest(): void {
+ if (this.cudIdx >= this.cudList.length) {
+ this.cudObserver.complete();
+ return;
+ }
+
+ let action = this.cudAction;
+ let fmObj = this.cudList[this.cudIdx++];
+
+ if (action == 'auto') {
+ if (fmObj.ischanged()) action = 'update';
+ if (fmObj.isnew()) action = 'create';
+ if (fmObj.isdeleted()) action = 'delete';
+
+ if (action == 'auto') {
+ // object does not need updating; move along
+ this.nextCudRequest();
+ }
+ }
+
+ this.sendRequest(
+ `open-ils.pcrud.${action}.${fmObj.classname}`,
+ [this.token(), fmObj]
+ ).subscribe(
+ res => this.cudObserver.next(res),
+ err => this.cudObserver.error(err),
+ () => this.nextCudRequest()
+ );
+ };
+}
+
+@Injectable()
+export class EgPcrudService {
+
+ constructor(
+ private idl: EgIdlService,
+ private net: EgNetService,
+ private auth: EgAuthService
+ ) {}
+
+ // Pass-thru functions for one-off PCRUD calls
+
+ connect(): Promise<EgPcrudContext> {
+ return this.newContext().connect();
+ }
+
+ newContext(): EgPcrudContext {
+ return new EgPcrudContext(this.idl, this.net, this.auth);
+ }
+
+ retrieve(fmClass: string, pkey: Number | string,
+ pcrudOps?: any, reqOps?: EgPcrudReqOps): Observable<EgPcrudResponse> {
+ return this.newContext().retrieve(fmClass, pkey, pcrudOps, reqOps);
+ }
+
+ retrieveAll(fmClass: string, pcrudOps?: any,
+ reqOps?: EgPcrudReqOps): Observable<EgPcrudResponse> {
+ return this.newContext().retrieveAll(fmClass, pcrudOps, reqOps);
+ }
+
+ search(fmClass: string, search: any,
+ pcrudOps?: any, reqOps?: EgPcrudReqOps): Observable<EgPcrudResponse> {
+ return this.newContext().search(fmClass, search, pcrudOps, reqOps);
+ }
+
+ create(list: EgIdlObject | EgIdlObject[]): Observable<EgPcrudResponse> {
+ return this.newContext().create(list);
+ }
+
+ update(list: EgIdlObject | EgIdlObject[]): Observable<EgPcrudResponse> {
+ return this.newContext().update(list);
+ }
+
+ remove(list: EgIdlObject | EgIdlObject[]): Observable<EgPcrudResponse> {
+ return this.newContext().remove(list);
+ }
+
+ autoApply(list: EgIdlObject | EgIdlObject[]): Observable<EgPcrudResponse> {
+ return this.newContext().autoApply(list);
+ }
+}
+
+
--- /dev/null
+import {Injectable} from '@angular/core';
+import {EgNetService} from './net.service';
+import {EgOrgService} from './org.service';
+import {EgAuthService} from './auth.service';
+
+interface HasPermAtResult {
+ [permName: string]: any[]; // org IDs or org unit objects
+}
+
+interface HasPermHereResult {
+ [permName: string]: boolean;
+}
+
+@Injectable()
+export class EgPermService {
+
+ constructor(
+ private net: EgNetService,
+ private org: EgOrgService,
+ private auth: EgAuthService,
+ ) {}
+
+ // workstation not required.
+ hasWorkPermAt(permNames: string[], asId?: boolean): Promise<HasPermAtResult> {
+ return this.net.request(
+ 'open-ils.actor',
+ 'open-ils.actor.user.has_work_perm_at.batch',
+ this.auth.token(), permNames
+ ).toPromise().then(resp => {
+ var answer: HasPermAtResult = {};
+ permNames.forEach(perm => {
+ var orgs = [];
+ resp[perm].forEach(oneOrg => {
+ orgs = orgs.concat(this.org.descendants(oneOrg, asId));
+ });
+ answer[perm] = orgs;
+ });
+
+ return answer;
+ });
+ }
+
+ // workstation required
+ hasWorkPermHere(permNames: string[]): Promise<HasPermHereResult> {
+ let wsId: number = +this.auth.user().wsid();
+
+ if (!wsId)
+ return Promise.reject('hasWorkPermHere requires a workstation');
+
+ return this.hasWorkPermAt(permNames, true).then(resp => {
+ let answer: HasPermHereResult = {};
+ Object.keys(resp).forEach(perm => {
+ answer[perm] = resp[perm].indexOf(wsId) > -1;
+ });
+ return answer;
+ });
+ }
+}
--- /dev/null
+/**
+ * Store and retrieve data from various sources.
+ */
+import {Injectable} from '@angular/core';
+import {Observable} from 'rxjs/Rx';
+import {CookieService} from 'ngx-cookie';
+
+@Injectable()
+export class EgStoreService {
+
+ // Base path for cookie-based storage.
+ // Useful for limiting cookies to subsections of the application.
+ // Store cookies globally by default.
+ // Note cookies shared with /eg/staff must be stored at "/"
+ loginSessionBasePath: string = '/';
+
+ // Set of keys whose values should disappear at logout.
+ loginSessionKeys: string[] = [
+ 'eg.auth.token',
+ 'eg.auth.time',
+ 'eg.auth.token.oc',
+ 'eg.auth.time.oc'
+ ];
+
+ constructor(private cookieService: CookieService) {}
+
+ private parseJson(valJson: string): any {
+ if (valJson == null || valJson == '') return null;
+ try {
+ return JSON.parse(valJson);
+ } catch(E) {
+ console.error(`Failure to parse JSON: ${E} => ${valJson}`);
+ return null;
+ }
+ }
+
+ /**
+ * Add a an app-local login session key
+ */
+ addLoginSessionKey(key: string): void {
+ this.loginSessionKeys.push(key);
+ }
+
+ setItem(key: string, val: any, isJson?: Boolean): Promise<void> {
+ // TODO: route keys appropriately
+ this.setLocalItem(key, val, false);
+ return Promise.resolve();
+ }
+
+ setLocalItem(key: string, val: any, isJson?: Boolean): void {
+ if (!isJson) val = JSON.stringify(val);
+ window.localStorage.setItem(key, val);
+ }
+
+ setServerItem(key: string, val: any): Promise<void> {
+ return Promise.resolve();
+ }
+
+ setSessionItem(key: string, val: any, isJson?: Boolean): void {
+ if (!isJson) val = JSON.stringify(val);
+ window.sessionStorage.setItem(key, val);
+ }
+
+ setLoginSessionItem(key: string, val: any, isJson?:Boolean): void {
+ if (!isJson) val = JSON.stringify(val);
+ this.cookieService.put(key, val, {path : this.loginSessionBasePath});
+ }
+
+ getItem(key: string): Promise<any> {
+ // TODO: route keys appropriately
+ return Promise.resolve(this.getLocalItem(key));
+ }
+
+ getLocalItem(key: string): any {
+ return this.parseJson(window.localStorage.getItem(key));
+ }
+
+ getServerItem(key: string): Promise<any> {
+ return Promise.resolve();
+ }
+
+ getSessionItem(key: string): any {
+ return this.parseJson(window.sessionStorage.getItem(key));
+ }
+
+ getLoginSessionItem(key: string): any {
+ return this.parseJson(this.cookieService.get(key));
+ }
+
+ removeItem(key: string): Promise<any> {
+ // TODO: route keys appropriately
+ return Promise.resolve(this.removeLocalItem(key));
+ }
+
+ removeLocalItem(key: string): void {
+ window.localStorage.removeItem(key);
+ }
+
+ removeServerItem(key: string): Promise<void> {
+ return Promise.resolve();
+ }
+
+ removeSessionItem(key: string): void {
+ window.sessionStorage.removeItem(key);
+ }
+
+ removeLoginSessionItem(key: string): void {
+ this.cookieService.remove(key, {path : this.loginSessionBasePath});
+ }
+
+ clearLoginSessionItems(): void {
+ this.loginSessionKeys.forEach(
+ key => this.removeLoginSessionItem(key)
+ );
+ }
+}
+
--- /dev/null
+/**
+ * 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]);
+ }
+}
+
+
--- /dev/null
+import {Injectable} from '@angular/core';
+import {Router, Resolve, RouterStateSnapshot,
+ ActivatedRouteSnapshot} from '@angular/router';
+import {EgIdlService} from '@eg/core/idl.service';
+import {EgOrgService} from '@eg/core/org.service';
+
+@Injectable()
+export class EgBaseResolver implements Resolve<Promise<void>> {
+
+ constructor(
+ private router: Router,
+ private idl: EgIdlService,
+ private org: EgOrgService,
+ ) {}
+
+ /**
+ * Loads pre-auth data common to all applications.
+ * No auth token is available at this level. When needed, auth is
+ * enforced by application/group-specific resolvers at lower levels.
+ */
+ resolve(
+ route: ActivatedRouteSnapshot,
+ state: RouterStateSnapshot): Promise<void> {
+
+ console.debug('EgBaseResolver:resolve()');
+
+ this.idl.parseIdl();
+
+ return this.org.fetchOrgs(); // anonymous PCRUD.
+ }
+}
--- /dev/null
+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 {}
--- /dev/null
+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.
+
--- /dev/null
+<ng-template #dialogContent>
+ <div class="modal-header bg-info">
+ <h4 class="modal-title" i18n>Access Key Assignments</h4>
+ <button type="button" class="close"
+ i18n-aria-label aria-label="Close"
+ (click)="dismiss('cross_click')">
+ <span aria-hidden="true">×</span>
+ </button>
+ </div>
+ <div class="modal-body">
+ <div class="row border-bottom">
+ <div class="col-lg-3 p-1 border-right text-center" i18n>Command</div>
+ <div class="col-lg-6 p-1 border-right" i18n>Action</div>
+ <div class="col-lg-3 p-1" i18n>Context</div>
+ </div>
+ <div class="row border-bottom" *ngFor="let a of assignments()">
+ <div class="col-lg-3 p-1 border-right text-center">{{a.key}}</div>
+ <div class="col-lg-6 p-1 border-right">{{a.desc}}</div>
+ <div class="col-lg-3 p-1">{{a.ctx}}</div>
+ </div>
+ </div>
+ <div class="modal-footer">
+ <button type="button" class="btn btn-success"
+ (click)="close()" i18n>Close</button>
+ </div>
+</ng-template>
--- /dev/null
+/**
+ */
+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();
+ }
+}
+
+
--- /dev/null
+/**
+ * Assign access keys to <a> tags.
+ *
+ * Access key action is peformed via .click(). hrefs, routerLinks,
+ * and (click) actions are all supported.
+ *
+ * <a
+ * routerLink="/staff/splash"
+ * egAccessKey
+ * keySpec="alt+h" i18n-keySpec
+ * keyDesc="My Description" 18n-keyDesc
+ * >
+ */
+import {Directive, ElementRef, Input, OnInit} from '@angular/core';
+import {EgAccessKeyService} from '@eg/share/accesskey/accesskey.service';
+
+@Directive({
+ selector: '[egAccessKey]'
+})
+export class EgAccessKeyDirective implements OnInit {
+
+ // Space-separated list of key combinations
+ // E.g. "ctrl+h", "alt+h ctrl+y"
+ @Input() keySpec: string;
+
+ // Description to display in the accesskey info dialog
+ @Input() keyDesc: string;
+
+ // Context info to display in the accesskey info dialog
+ // E.g. "navbar"
+ @Input() keyCtx: string;
+
+ constructor(
+ private elm: ElementRef,
+ private keyService: EgAccessKeyService
+ ) { }
+
+ ngOnInit() {
+
+ if (!this.keySpec) {
+ console.warn("EgAccessKey no keySpec provided");
+ return;
+ }
+
+ this.keySpec.split(/ /).forEach(keySpec => {
+ this.keyService.assign({
+ key: keySpec,
+ desc: this.keyDesc,
+ ctx: this.keyCtx,
+ action: () => {this.elm.nativeElement.click()}
+ });
+ })
+ }
+}
+
+
--- /dev/null
+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};
+ });
+ }
+
+}
+
--- /dev/null
+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'));
+ }
+}
--- /dev/null
+import {Injectable} from '@angular/core';
+import {EgOrgService} from '@eg/core/org.service';
+import {EgUnapiService} from '@eg/share/catalog/unapi.service';
+import {EgIdlObject} from '@eg/core/idl.service';
+import {EgNetService} from '@eg/core/net.service';
+import {EgPcrudService} from '@eg/core/pcrud.service';
+import {CatalogSearchContext, CatalogSearchState} from './search-context';
+
+export const CATALOG_CCVM_FILTERS = [
+ 'item_type',
+ 'item_form',
+ 'item_lang',
+ 'audience',
+ 'audience_group',
+ 'vr_format',
+ 'bib_level',
+ 'lit_form',
+ 'search_format'
+];
+
+const MODS_XPATH_AUTO = {
+ title : '/mods:mods/mods:titleInfo/mods:title',
+ author: '/mods:mods/mods:name/mods:namePart',
+ edition: '/mods:mods/mods:originInfo/mods:edition',
+ pubdate: '/mods:mods/mods:originInfo/mods:dateIssued',
+ genre: '/mods:mods/mods:genre'
+};
+
+const MODS_XPATH = {
+ extern: '/mods:mods/biblio:extern',
+ copyCounts: '/mods:mods/holdings:holdings/holdings:counts/holdings:count',
+ attributes: '/mods:mods/indexing:attributes/indexing:field'
+};
+
+const NAMESPACE_MAPS = {
+ 'mods': 'http://www.loc.gov/mods/v3',
+ 'biblio': 'http://open-ils.org/spec/biblio/v1',
+ 'holdings': 'http://open-ils.org/spec/holdings/v1',
+ 'indexing': 'http://open-ils.org/spec/indexing/v1'
+};
+
+@Injectable()
+export class EgCatalogService {
+
+ ccvmMap: {[ccvm:string] : EgIdlObject[]} = {};
+ cmfMap: {[cmf:string] : EgIdlObject} = {};
+
+ // Keep a reference to the most recently retrieved facet data,
+ // since facet data is consistent across a given search.
+ // No need to re-fetch with every page of search data.
+ lastFacetData: any;
+ lastFacetKey: string;
+
+ constructor(
+ private net: EgNetService,
+ private org: EgOrgService,
+ private unapi: EgUnapiService,
+ private pcrud: EgPcrudService
+ ) {}
+
+ search(ctx: CatalogSearchContext): Promise<void> {
+ ctx.searchState = CatalogSearchState.SEARCHING;
+
+ var fullQuery = ctx.compileSearch();
+
+ console.debug(`search query: ${fullQuery}`);
+
+ let method = 'open-ils.search.biblio.multiclass.query';
+ if (ctx.isStaff) method += '.staff';
+
+ return new Promise((resolve, reject) => {
+ this.net.request(
+ 'open-ils.search', method, {
+ limit : ctx.pager.limit + 1,
+ offset : ctx.pager.offset
+ }, fullQuery, true
+ ).subscribe(result => {
+ this.applyResultData(ctx, result);
+ ctx.searchState = CatalogSearchState.COMPLETE;
+ resolve();
+ });
+ })
+ }
+
+ applyResultData(ctx: CatalogSearchContext, result: any): void {
+ ctx.result = result;
+ ctx.pager.resultCount = result.count;
+
+ // records[] tracks the current page of bib summaries.
+ result.records = [];
+
+ // If this is a new search, reset the result IDs collection.
+ if (this.lastFacetKey != result.facet_key) ctx.resultIds = [];
+
+ result.ids.forEach((blob, idx) => {ctx.addResultId(blob[0], idx)});
+ }
+
+ fetchBibSummaries(ctx: CatalogSearchContext): Promise<any> {
+ let promises = [];
+ let depth = ctx.global ?
+ ctx.org.root().ou_type().depth() :
+ ctx.searchOrg.ou_type().depth();
+
+ ctx.currentResultIds().forEach((recId, idx) => {
+ promises.push(
+ this.getBibSummary(recId, ctx.searchOrg.id(), depth)
+ .then(
+ // idx maintains result sort order
+ summary => ctx.result.records[idx] = summary
+ )
+ );
+ });
+
+ return Promise.all(promises);
+ }
+
+ fetchFacets(ctx: CatalogSearchContext): Promise<void> {
+
+ if (!ctx.result)
+ return Promise.reject('Cannot fetch facets without results');
+
+ if (this.lastFacetKey == ctx.result.facet_key) {
+ ctx.result.facetData = this.lastFacetData;
+ return Promise.resolve();
+ }
+
+ return new Promise((resolve, reject) => {
+ this.net.request('open-ils.search',
+ 'open-ils.search.facet_cache.retrieve',
+ ctx.result.facet_key
+ ).subscribe(facets => {
+ let facetData = {};
+ Object.keys(facets).forEach(cmfId => {
+ let facetHash = facets[cmfId];
+ let cmf = this.cmfMap[cmfId];
+
+ let cmfData = [];
+ Object.keys(facetHash).forEach(value => {
+ let count = facetHash[value];
+ cmfData.push({value : value, count : count});
+ });
+
+ if (!facetData[cmf.field_class()])
+ facetData[cmf.field_class()] = {};
+
+ facetData[cmf.field_class()][cmf.name()] = {
+ cmfLabel : cmf.label(),
+ valueList : cmfData.sort((a, b) => {
+ if (a.count > b.count) return -1;
+ if (a.count < b.count) return 1;
+ // secondary alpha sort on display value
+ return a.value < b.value ? -1 : 1;
+ })
+ };
+ });
+
+ this.lastFacetKey = ctx.result.facet_key;
+ this.lastFacetData = ctx.result.facetData = facetData;
+ resolve();
+ });
+ })
+ }
+
+ fetchCcvms(): Promise<void> {
+
+ if (Object.keys(this.ccvmMap).length)
+ return Promise.resolve();
+
+ return new Promise((resolve, reject) => {
+ this.pcrud.search('ccvm',
+ {ctype : CATALOG_CCVM_FILTERS}, {},
+ {atomic: true, anonymous: true}
+ ).subscribe(list => {
+ this.compileCcvms(list);
+ resolve();
+ })
+ });
+ }
+
+ compileCcvms(ccvms : EgIdlObject[]): void {
+ ccvms.forEach(ccvm => {
+ if (!this.ccvmMap[ccvm.ctype()])
+ this.ccvmMap[ccvm.ctype()] = [];
+ this.ccvmMap[ccvm.ctype()].push(ccvm);
+ });
+
+ Object.keys(this.ccvmMap).forEach(cType => {
+ this.ccvmMap[cType] =
+ this.ccvmMap[cType].sort((a, b) => {
+ return a.value() < b.value() ? -1 : 1;
+ });
+ });
+ }
+
+
+ fetchCmfs(): Promise<void> {
+ // At the moment, we only need facet CMFs.
+ if (Object.keys(this.cmfMap).length)
+ return Promise.resolve();
+
+ return new Promise((resolve, reject) => {
+ this.pcrud.search('cmf',
+ {facet_field : 't'}, {}, {atomic: true, anonymous: true}
+ ).subscribe(
+ cmfs => {
+ cmfs.forEach(c => this.cmfMap[c.id()] = c);
+ resolve();
+ }
+ )
+ });
+ }
+
+
+ /**
+ * Bib record via UNAPI as mods (for now) with holdings summary
+ * and record attributes.
+ */
+ getBibSummary(bibId: number, orgId?: number, depth?: number): Promise<any> {
+ return new Promise((resolve, reject) => {
+ this.unapi.getAsXmlDocument({
+ target: 'bre',
+ id: bibId,
+ extras: '{bre.extern,holdings_xml,mra}',
+ format: 'mods32',
+ orgId: orgId,
+ depth: depth
+ }).then(xmlDoc => {
+ let summary = this.translateBibSummary(xmlDoc);
+ summary.id = bibId;
+ resolve(summary);
+ });
+ });
+ }
+
+ /**
+ * Probably don't want to require navigating the bare UNAPI
+ * blob in the template, plus that's quite a lot of stuff
+ * to sit in the scope / watch for changes. Translate the
+ * UNAPI content into a more digestable form.
+ * TODO: Add display field support
+ */
+ translateBibSummary(xmlDoc: XMLDocument): any { // TODO: bib summary interface
+
+ let response = {
+ copyCounts : [],
+ ccvms : {}
+ };
+
+ let resolver:any = (prefix: string): string => {
+ return NAMESPACE_MAPS[prefix] || null;
+ };
+
+ Object.keys(MODS_XPATH_AUTO).forEach(key => {
+ let result = xmlDoc.evaluate(MODS_XPATH_AUTO[key], xmlDoc,
+ resolver, XPathResult.FIRST_ORDERED_NODE_TYPE, null);
+
+ let node = result.singleNodeValue;
+ if (node) response[key] = node.textContent;
+ });
+
+ let result = xmlDoc.evaluate(MODS_XPATH.extern, xmlDoc,
+ resolver, XPathResult.FIRST_ORDERED_NODE_TYPE, null);
+
+ let node:any = result.singleNodeValue;
+ if (node) {
+ let attrs = node.attributes;
+ for(let i = attrs.length - 1; i >= 0; i--) {
+ response[attrs[i].name] = attrs[i].value;
+ }
+ }
+
+ result = xmlDoc.evaluate(MODS_XPATH.attributes, xmlDoc,
+ resolver, XPathResult.ANY_TYPE, null);
+
+ while(node = result.iterateNext()) {
+ response.ccvms[node.getAttribute('name')] = {
+ code : node.textContent,
+ label : node.getAttribute('coded-value')
+ }
+ }
+
+ result = xmlDoc.evaluate(MODS_XPATH.copyCounts, xmlDoc,
+ resolver, XPathResult.ANY_TYPE, null);
+
+ while(node = result.iterateNext()) {
+ let counts = {};
+ ['type', 'depth', 'org_unit', 'transcendant',
+ 'available', 'count', 'unshadow'].forEach(field => {
+ counts[field] = node.getAttribute(field);
+ });
+ response.copyCounts.push(counts);
+ }
+
+ //console.log(response);
+ return response;
+ }
+}
--- /dev/null
+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);
+ }
+ }
+}
+
+
--- /dev/null
+import {Injectable, EventEmitter} from '@angular/core';
+import {EgOrgService} from '@eg/core/org.service';
+
+/*
+TODO: Add Display Fields to UNAPI
+https://library.biz/opac/extras/unapi?id=tag::U2@bre/1{bre.extern,holdings_xml,mra}/BR1/0&format=mods32
+*/
+
+const UNAPI_PATH = '/opac/extras/unapi?id=tag::U2@';
+
+interface EgUnapiParams {
+ target: string; // bre, ...
+ id: number | string; // 1 | 1,2,3,4,5
+ extras: string; // {holdings_xml,mra,...}
+ format: string; // mods32, marxml, ...
+ orgId?: number; // org unit ID
+ depth?: number; // org unit depth
+};
+
+@Injectable()
+export class EgUnapiService {
+
+ constructor(private org: EgOrgService) {}
+
+ createUrl(params: EgUnapiParams): string {
+ let depth = params.depth || 0;
+ let org = params.orgId ? this.org.get(params.orgId) : this.org.root();
+
+ return `${UNAPI_PATH}${params.target}/${params.id}${params.extras}/` +
+ `${org.shortname()}/${depth}&format=${params.format}`;
+ }
+
+ getAsXmlDocument(params: EgUnapiParams): Promise<XMLDocument> {
+ // XReq creates an XML document for us. Seems like the right
+ // tool for the job.
+ let url = this.createUrl(params);
+ return new Promise((resolve, reject) => {
+ var xhttp = new XMLHttpRequest();
+ xhttp.onreadystatechange = function() {
+ if (this.readyState == 4) {
+ if (this.status == 200) {
+ resolve(xhttp.responseXML);
+ } else {
+ reject(`UNAPI request failed for ${url}`);
+ }
+ }
+ }
+ xhttp.open("GET", url, true);
+ xhttp.send();
+ });
+ }
+}
+
+
--- /dev/null
+<ng-template #dialogContent>
+ <div class="modal-header bg-info">
+ <h4 class="modal-title">{{dialogTitle}}</h4>
+ <button type="button" class="close"
+ i18n-aria-label aria-label="Close"
+ (click)="dismiss('cross_click')">
+ <span aria-hidden="true">×</span>
+ </button>
+ </div>
+ <div class="modal-body"><p>{{dialogBody}}</p></div>
+ <div class="modal-footer">
+ <button type="button" class="btn btn-success"
+ (click)="close('confirmed')" i18n>Confirm</button>
+ <button type="button" class="btn btn-warning"
+ (click)="dismiss('canceled')" i18n>Cancel</button>
+ </div>
+</ng-template>
--- /dev/null
+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;
+}
+
+
--- /dev/null
+import {Component, Input, OnInit, ViewChild, TemplateRef, EventEmitter} from '@angular/core';
+import {NgbModal, NgbModalRef, NgbModalOptions} from '@ng-bootstrap/ng-bootstrap';
+
+/**
+ * Dialog base class. Handles the ngbModal logic.
+ * Sub-classed component templates must have a #dialogContent selector
+ * at the root of the template (see EgConfirmDialogComponent).
+ */
+
+@Component({
+ selector: 'eg-dialog',
+ template: '<ng-template></ng-template>'
+})
+export class EgDialogComponent implements OnInit {
+
+ // Assume all dialogs support a title attribute.
+ @Input() public dialogTitle: string;
+
+ // Pointer to the dialog content template.
+ @ViewChild('dialogContent')
+ private dialogContent: TemplateRef<any>;
+
+ // Emitted after open() is called on the ngbModal.
+ // Note when overriding open(), this will not fire unless also
+ // called in the overridding method.
+ onOpen$ = new EventEmitter<any>();
+
+ // The modalRef allows direct control of the modal instance.
+ private modalRef: NgbModalRef = null;
+
+ constructor(private modalService: NgbModal) {}
+
+ ngOnInit() {
+ this.onOpen$ = new EventEmitter<any>();
+ }
+
+ open(options?: NgbModalOptions): Promise<any> {
+
+ if (this.modalRef !== null) {
+ console.warn('Dismissing existing dialog');
+ this.dismiss();
+ }
+
+ this.modalRef = this.modalService.open(this.dialogContent, options);
+
+ if (this.onOpen$) {
+ // Let the digest cycle complete
+ setTimeout(() => this.onOpen$.emit(true));
+ }
+
+ return new Promise( (resolve, reject) => {
+
+ this.modalRef.result.then(
+ (result) => {
+ resolve(result);
+ this.modalRef = null;
+ },
+ (result) => {
+ reject(result);
+ this.modalRef = null;
+ }
+ );
+ });
+ }
+
+ close(reason?: any): void {
+ this.modalRef.close(reason);
+ }
+
+ dismiss(reason?: any): void {
+ this.modalRef.dismiss(reason);
+ }
+}
+
+
--- /dev/null
+
+.eg-progress-dialog progress {
+ width: 100%;
+ height: 25px;
+}
--- /dev/null
+<ng-template #dialogContent>
+ <div class="modal-header bg-info">
+ <button type="button" class="close"
+ i18n-aria-label aria-label="Close"
+ (click)="dismiss('cross_click')">
+ <span aria-hidden="true">×</span>
+ </button>
+ </div>
+
+ <div class="modal-body eg-progress-dialog">
+
+ <div *ngIf="hasValue() && hasMax()">
+ <!-- determinate progress bar. shows max/value progress -->
+ <div class="col-lg-10">
+ <progress max="{{max}}" value="{{value}}"></progress>
+ </div>
+ <div class="col-lg-2">{{percent()}}%</div>
+ </div>
+
+ <div *ngIf="hasValue() && !hasMax()">
+ <!-- semi-determinate progress bar. shows value -->
+ <div class="col-lg-10"><progress max="1"></progress></div>
+ <div class="col-lg-2">{{value}}...</div>
+ </div>
+
+ <div *ngIf="!hasValue()">
+ <!-- indeterminate -->
+ <div class="col-lg-12"><progress max="1"></progress></div>
+ </div>
+
+ </div>
+</ng-template>
--- /dev/null
+import {Component, Input, ViewChild, TemplateRef} from '@angular/core';
+import {EgDialogComponent} from '@eg/share/dialog/dialog.component';
+
+@Component({
+ selector: 'eg-progress-dialog',
+ templateUrl: './progress.component.html',
+ styleUrls: ['progress.component.css']
+})
+
+/**
+ * Progress Dialog.
+ *
+ * // assuming a template reference...
+ * @ViewChild('progressDialog')
+ * private dialog: EgProgressDialogComponent;
+ *
+ * dialog.open();
+ * dialog.update({value : 0, max : 123});
+ * dialog.increment();
+ * dialog.increment();
+ * dialog.close();
+ *
+ * Each dialog has 2 numbers, 'max' and 'value'.
+ * The content of these values determines how the dialog displays.
+ *
+ * There are 3 flavors:
+ *
+ * -- value is set, max is set
+ * determinate: shows a progression with a percent complete.
+ *
+ * -- value is set, max is unset
+ * semi-determinate, with a value report. Shows a value-less
+ * <progress/>, but shows the value as a number in the dialog.
+ *
+ * This is useful in cases where the total number of items to retrieve
+ * from the server is unknown, but we know how many items we've
+ * retrieved thus far. It helps to reinforce that something specific
+ * is happening, but we don't know when it will end.
+ *
+ * -- value is unset
+ * indeterminate: shows a generic value-less <progress/> with no
+ * clear indication of progress.
+ */
+export class EgProgressDialogComponent extends EgDialogComponent {
+
+ max: number;
+ value: number;
+
+ reset() {
+ delete this.max;
+ delete this.value;
+ }
+
+ hasValue(): boolean {
+ return Number.isInteger(this.value);
+ }
+
+ hasMax(): boolean {
+ return Number.isInteger(this.max);
+ }
+
+ percent(): number {
+ if (this.hasValue() &&
+ this.hasMax() &&
+ this.max > 0 &&
+ this.value <= this.max)
+ return Math.floor((this.value / this.max) * 100);
+ return 100;
+ }
+
+ // Set the current state of the progress bar.
+ update(args: {[key:string] : number}) {
+ if (args.max != undefined)
+ this.max = args.max;
+ if (args.value != undefined)
+ this.value = args.value;
+ }
+
+ // Increment the current value. If no amount is specified,
+ // it increments by 1. Calling increment() on an indetermite
+ // progress bar will force it to be a (semi-)determinate bar.
+ increment(amt: number) {
+ if (!Number.isInteger(amt)) amt = 1;
+
+ if (!this.hasValue())
+ this.value = 0;
+
+ this.value += amt;
+ }
+}
+
+
--- /dev/null
+<ng-template #dialogContent>
+ <div class="modal-header bg-info">
+ <h4 class="modal-title">{{dialogTitle}}</h4>
+ <button type="button" class="close"
+ i18n-aria-label aria-label="Close"
+ (click)="dismiss('cross_click')">
+ <span aria-hidden="true">×</span>
+ </button>
+ </div>
+ <div class="modal-body">
+ <p>{{dialogBody}}</p>
+ <div class="text-center">
+ <input class="form-control" [(ngModel)]="promptValue"/>
+ </div>
+ </div>
+ <div class="modal-footer">
+ <button type="button" class="btn btn-success"
+ (click)="close(promptValue)" i18n>Confirm</button>
+ <button type="button" class="btn btn-warning"
+ (click)="dismiss('canceled')" i18n>Cancel</button>
+ </div>
+</ng-template>
--- /dev/null
+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;
+}
+
+
--- /dev/null
+<ng-template #dialogContent>
+ <div class="modal-header bg-info">
+ <h4 class="modal-title" i18n>Record Editor: {{recordLabel}}</h4>
+ <button type="button" class="close"
+ i18n-aria-label aria-label="Close"
+ (click)="dismiss('cross_click')">
+ <span aria-hidden="true">×</span>
+ </button>
+ </div>
+ <div class="modal-body">
+ <form role="form" class="form-validated">
+ <div class="form-group row" *ngFor="let field of fields">
+ <div class="col-lg-3 offset-lg-1">
+ <label for="rec-{{field.name}}">{{field.label}}</label>
+ </div>
+ <div class="col-lg-7">
+
+ <span *ngIf="field.template">
+ <ng-container
+ *ngTemplateOutlet="field.template; context:customTemplateFieldContext(field)">
+ </ng-container>
+ </span>
+
+ <span *ngIf="!field.template">
+
+ <span *ngIf="field.datatype == 'id' && !pkeyIsEditable">
+ {{record[field.name]()}}
+ </span>
+
+ <input *ngIf="field.datatype == 'id' && pkeyIsEditable"
+ class="form-control"
+ name="{{field.name}}"
+ [readonly]="field.readOnly"
+ [required]="field.isRequired()"
+ [ngModel]="record[field.name]()"
+ (ngModelChange)="record[field.name]($event)"/>
+
+ <input *ngIf="field.datatype == 'text'"
+ class="form-control"
+ name="{{field.name}}"
+ [readonly]="field.readOnly"
+ [required]="field.isRequired()"
+ [ngModel]="record[field.name]()"
+ (ngModelChange)="record[field.name]($event)"/>
+
+ <input *ngIf="field.datatype == 'int'"
+ class="form-control"
+ type="number"
+ name="{{field.name}}"
+ [readonly]="field.readOnly"
+ [required]="field.isRequired()"
+ [ngModel]="record[field.name]()"
+ (ngModelChange)="record[field.name]($event)"/>
+
+ <input *ngIf="field.datatype == 'float'"
+ class="form-control"
+ type="number" step="0.1"
+ name="{{field.name}}"
+ [readonly]="field.readOnly"
+ [required]="field.isRequired()"
+ [ngModel]="record[field.name]()"
+ (ngModelChange)="record[field.name]($event)"/>
+
+ <span *ngIf="field.datatype == 'money'">
+ <!-- in read-only mode display the local-aware currency -->
+ <input *ngIf="field.readOnly"
+ class="form-control"
+ type="number" step="0.1"
+ name="{{field.name}}"
+ [readonly]="field.readOnly"
+ [required]="field.isRequired()"
+ [ngModel]="record[field.name]() | currency"/>
+
+ <input *ngIf="!field.readOnly"
+ class="form-control"
+ type="number" step="0.1"
+ name="{{field.name}}"
+ [readonly]="field.readOnly"
+ [required]="field.isRequired()"
+ [ngModel]="record[field.name]()"
+ (ngModelChange)="record[field.name]($event)"/>
+ </span>
+
+ <input *ngIf="field.datatype == 'bool'"
+ class="form-check-input"
+ type="checkbox"
+ name="{{field.name}}"
+ [readonly]="field.readOnly"
+ [ngModel]="record[field.name]()"
+ (ngModelChange)="record[field.name]($event)"/>
+
+ <span *ngIf="field.datatype == 'link'"
+ [ngClass]="{nullable : !field.isRequired()}">
+ <select
+ class="form-control"
+ name="{{field.name}}"
+ [disabled]="field.readOnly"
+ [required]="field.isRequired()"
+ [ngModel]="record[field.name]()"
+ (ngModelChange)="record[field.name]($event)">
+ <option *ngFor="let item of field.linkedValues"
+ [value]="item.id">{{item.name}}</option>
+ </select>
+ </span>
+
+ <eg-org-select *ngIf="field.datatype == 'org_unit'"
+ [placeholder]="field.label"
+ [applyDefault]="field.orgDefaultAllowed"
+ [initialOrgId]="record[field.name]()"
+ (onChange)="record[field.name]($event)">
+ </eg-org-select>
+
+ </span>
+ </div>
+ </div>
+ </form>
+ </div>
+ <div class="modal-footer">
+ <button type="button" class="btn btn-success" *ngIf="mode == 'view'"
+ (click)="close()" i18n>Close</button>
+ <button type="button" class="btn btn-info" *ngIf="mode != 'view'"
+ (click)="save()" i18n>Save</button>
+ <button type="button" class="btn btn-warning ml-2" *ngIf="mode != 'view'"
+ (click)="cancel()" i18n>Cancel</button>
+ </div>
+</ng-template>
--- /dev/null
+import {Component, OnInit, Input,
+ Output, EventEmitter, TemplateRef} from '@angular/core';
+import {EgIdlService, EgIdlObject} from '@eg/core/idl.service';
+import {EgAuthService} from '@eg/core/auth.service';
+import {EgPcrudService} from '@eg/core/pcrud.service';
+import {EgDialogComponent} from '@eg/share/dialog/dialog.component';
+import {NgbModal, NgbModalOptions} from '@ng-bootstrap/ng-bootstrap';
+
+interface CustomFieldTemplate {
+ template: TemplateRef<any>,
+
+ // Allow the caller to pass in a free-form context blob to
+ // be addedto the caller's custom template context, along
+ // with our stock context.
+ context?: {[fields: string]: any}
+}
+
+interface CustomFieldContext {
+ // Current create/edit/view record
+ record: EgIdlObject,
+
+ // IDL field definition blob
+ field: any,
+
+ // additional context values passed via CustomFieldTemplate
+ [fields: string]: any;
+}
+
+@Component({
+ selector: 'fm-record-editor',
+ templateUrl: './fm-editor.component.html'
+})
+export class FmRecordEditorComponent
+ extends EgDialogComponent implements OnInit {
+
+ // IDL class hint (e.g. "aou")
+ @Input() idlClass: string;
+
+ // mode: 'create' for creating a new record,
+ // 'update' for editing an existing record
+ // 'view' for viewing an existing record without editing
+ @Input() mode: 'create' | 'update' | 'view' = 'create';
+
+ // Record ID to view/update. Value is dynamic. Records are not
+ // fetched until .open() is called.
+ recId: any;
+ @Input() set recordId(id: any) {
+ if (id) this.recId = id;
+ }
+
+ // IDL record we are editing
+ // TODO: allow this to be update in real time by the caller?
+ record: EgIdlObject;
+
+ @Input() customFieldTemplates:
+ {[fieldName:string] : CustomFieldTemplate} = {};
+
+ // list of fields that should not be displayed
+ @Input() hiddenFieldsList: string[] = [];
+ @Input() hiddenFields: string; // comma-separated string version
+
+ // list of fields that should always be read-only
+ @Input() readonlyFieldsList: string[] = [];
+ @Input() readonlyFields: string; // comma-separated string version
+
+ // list of required fields; this supplements what the IDL considers
+ // required
+ @Input() requiredFieldsList: string[] = [];
+ @Input() requiredFields: string; // comma-separated string version
+
+ // list of org_unit fields where a default value may be applied by
+ // the org-select if no value is present.
+ @Input() orgDefaultAllowedList: string[] = [];
+ @Input() orgDefaultAllowed: string; // comma-separated string version
+
+ // hash, keyed by field name, of functions to invoke to check
+ // whether a field is required. Each callback is passed the field
+ // name and the record and should return a boolean value. This
+ // supports cases where whether a field is required or not depends
+ // on the current value of another field.
+ @Input() isRequiredOverride:
+ {[field: string] : (field: string, record: EgIdlObject) => boolean};
+
+ // IDL record display label. Defaults to the IDL label.
+ @Input() recordLabel: string;
+
+ // Emit the modified object when the save action completes.
+ @Output() onSave$ = new EventEmitter<EgIdlObject>();
+
+ // Emit the original object when the save action is canceled.
+ @Output() onCancel$ = new EventEmitter<EgIdlObject>();
+
+ // Emit an error message when the save action fails.
+ @Output() onError$ = new EventEmitter<string>();
+
+ // IDL info for the the selected IDL class
+ idlDef: any;
+
+ // Can we edit the primary key?
+ pkeyIsEditable: boolean = false;
+
+ // List of IDL field definitions. This is a subset of the full
+ // list of fields on the IDL, since some are hidden, virtual, etc.
+ fields: any[];
+
+ constructor(
+ private modal: NgbModal, // required for passing to parent
+ private idl: EgIdlService,
+ private auth: EgAuthService,
+ private pcrud: EgPcrudService) {
+ super(modal)
+ }
+
+ // Avoid fetching data on init since that may lead to unnecessary
+ // data retrieval.
+ ngOnInit() {
+ this.listifyInputs();
+ this.idlDef = this.idl.classes[this.idlClass];
+ this.recordLabel = this.idlDef.label;
+ }
+
+ // Opening dialog, fetch data.
+ open(options?: NgbModalOptions): Promise<any> {
+ return this.initRecord().then(
+ ok => super.open(options),
+ err => console.warn(`Error fetching FM data: ${err}`)
+ );
+ }
+
+ // Translate comma-separated string versions of various inputs
+ // to arrays.
+ private listifyInputs() {
+ if (this.hiddenFields)
+ this.hiddenFieldsList = this.hiddenFields.split(/,/);
+ if (this.readonlyFields)
+ this.readonlyFieldsList = this.readonlyFields.split(/,/);
+ if (this.requiredFields)
+ this.requiredFieldsList = this.requiredFields.split(/,/);
+ if (this.orgDefaultAllowed)
+ this.orgDefaultAllowedList = this.orgDefaultAllowed.split(/,/);
+ }
+
+ private initRecord(): Promise<any> {
+
+ if (this.mode == 'update' || this.mode == 'view') {
+ return this.pcrud.retrieve(this.idlClass, this.recId)
+ .toPromise().then(rec => {
+
+ if (!rec) {
+ return Promise.reject(`No '${this.idlClass}'
+ record found with id ${this.recId}`);
+ }
+
+ this.record = rec;
+ this.convertDatatypesToJs();
+ return this.getFieldList();
+ });
+ }
+
+ // create a new record from scratch
+ this.pkeyIsEditable = !('pkey_sequence' in this.idlDef);
+ this.record = this.idl.create(this.idlClass);
+ return this.getFieldList();
+ }
+
+ // Modifies the FM record in place, replacing IDL-compatible values
+ // with native JS values.
+ private convertDatatypesToJs() {
+ this.idlDef.fields.forEach(field => {
+ if (field.datatype == 'bool') {
+ if (this.record[field.name]() == 't') {
+ this.record[field.name](true);
+ } else if (this.record[field.name]() == 'f') {
+ this.record[field.name](false);
+ }
+ }
+ });
+ }
+
+ // Modifies the provided FM record in place, replacing JS values
+ // with IDL-compatible values.
+ convertDatatypesToIdl(rec: EgIdlObject) {
+ var fields = this.idlDef.fields;
+ fields.forEach(field => {
+ if (field.datatype == 'bool') {
+ if (rec[field.name]() == true) {
+ rec[field.name]('t');
+ } else if (rec[field.name]() == false) {
+ rec[field.name]('f');
+ }
+ } else if (field.datatype == 'org_unit') {
+ let org = rec[field.name]();
+ if (org && typeof org == 'object') {
+ rec[field.name](org.id());
+ }
+ }
+ });
+ }
+
+
+ private flattenLinkedValues(cls: string, list: EgIdlObject[]): any[] {
+ let idField = this.idl.classes[cls].pkey;
+ let selector =
+ this.idl.classes[cls].field_map[idField].selector || idField;
+
+ return list.map(item => {
+ return {id: item[idField](), name: item[selector]()}
+ });
+ }
+
+ private getFieldList(): Promise<any> {
+
+ this.fields = this.idlDef.fields.filter(f =>
+ f.virtual != 'true' &&
+ !this.hiddenFieldsList.includes(f.name)
+ );
+
+ let promises = [];
+
+ this.fields.forEach(field => {
+ field.readOnly = this.mode == 'view'
+ || this.readonlyFieldsList.includes(field.name);
+
+ if (this.isRequiredOverride &&
+ field.name in this.isRequiredOverride) {
+ field.isRequired = () => {
+ return this.isRequiredOverride[field.name](field.name, this.record);
+ }
+ } else {
+ field.isRequired = () => {
+ return field.required ||
+ this.requiredFieldsList.includes(field.name);
+ }
+ }
+
+ if (field.datatype == 'link') {
+ promises.push(
+ this.pcrud.retrieveAll(field.class, {}, {atomic : true})
+ .toPromise().then(list => {
+ field.linkedValues =
+ this.flattenLinkedValues(field.class, list);
+ })
+ );
+ } else if (field.datatype == 'org_unit') {
+ field.orgDefaultAllowed =
+ this.orgDefaultAllowedList.includes(field.name);
+ }
+
+ if (this.customFieldTemplates[field.name]) {
+ field.template = this.customFieldTemplates[field.name].template;
+ field.context = this.customFieldTemplates[field.name].context;
+ }
+
+ });
+
+ // Wait for all network calls to complete
+ return Promise.all(promises);
+ }
+
+ // Returns a context object to be inserted into a custom
+ // field template.
+ customTemplateFieldContext(fieldDef: any): CustomFieldContext {
+ return Object.assign(
+ { record : this.record,
+ field: fieldDef // from this.fields
+ }, fieldDef.context || {}
+ );
+ }
+
+ save() {
+ let recToSave = this.idl.clone(this.record);
+ this.convertDatatypesToIdl(recToSave);
+ this.pcrud[this.mode]([recToSave]).toPromise().then(
+ result => this.close(result),
+ error => this.dismiss(error)
+ );
+ }
+
+ cancel() {
+ this.dismiss('canceled');
+ }
+}
+
+
--- /dev/null
+
+<div class="eg-grid-row eg-grid-body-row"
+ [ngClass]="{'eg-grid-row-selected': selector[idx]}"
+ *ngFor="let row of dataSource.getPageOfRows(pager); let idx = index">
+
+ <div class="eg-grid-cell eg-grid-checkbox-cell eg-grid-cell-skinny">
+ <input type='checkbox' [(ngModel)]="selector[idx]">
+ </div>
+ <div class="eg-grid-cell eg-grid-header-cell eg-grid-number-cell eg-grid-cell-skinny">
+ {{pager.rowNumber(idx)}}
+ </div>
+ <div class="eg-grid-cell eg-grid-body-cell" [ngStyle]="{flex:col.flex}"
+ *ngFor="let col of columnSet.displayColumns()">
+ {{getDisplayValue(row, col)}}
+ </div>
+
+<div>
+
--- /dev/null
+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);
+ }
+
+}
+
--- /dev/null
+import {Component, Input, OnInit, Host, TemplateRef} from '@angular/core';
+import {EgGridService, EgGridColumn, EgGridColumnSet} from './grid.service';
+import {EgGridComponent} from './grid.component';
+
+@Component({
+ selector: 'eg-grid-column',
+ template: '<ng-template></ng-template>'
+})
+
+export class EgGridColumnComponent implements OnInit {
+
+ // Note most input fields should match class fields for EgGridColumn
+ @Input() name: string;
+ @Input() path: string;
+ @Input() label: string;
+ @Input() flex: number;
+ @Input() hidden: boolean = false;
+ @Input() cellTemplate: TemplateRef<any>;
+
+ // get a reference to our container grid.
+ constructor(
+ private gridSvc: EgGridService,
+ @Host() private grid: EgGridComponent) {}
+
+ ngOnInit() {
+
+ if (!this.grid) {
+ console.warn('EgGridColumnComponent needs a [grid]');
+ return;
+ }
+
+ let col = new EgGridColumn();
+ col.name = this.name;
+ col.path = this.path;
+ col.label = this.label || this.name;
+ col.flex = this.flex || 2;
+ col.hidden = this.hidden;
+ col.cellTemplate = this.cellTemplate;
+ this.grid.columnSet.add(col);
+ }
+}
+
--- /dev/null
+import {EventEmitter} from '@angular/core';
+import {Observable} from 'rxjs/Rx';
+import {Pager} from '@eg/share/util/pager';
+
+export class EgGridDataSource {
+
+ data: any[];
+ pager: Pager;
+ allRowsRetrieved: boolean;
+ getRows: (pager: Pager) => Observable<any>;
+
+ constructor() {
+ this.data = [];
+ this.allRowsRetrieved = false;
+ }
+
+ setAllRetrieved() {
+ this.allRowsRetrieved = true;
+ this.pager.resultCount = this.data.length;
+ }
+
+ // called from the template -- no data fetching
+ getPageOfRows(pager: Pager): any[] {
+ if (this && this.data) {
+ return this.data.slice(
+ pager.offset, pager.limit + pager.offset);
+ }
+ return [];
+ }
+
+ // called on initial component load and user action (e.g. paging, sorting).
+ requestPage(pager: Pager) {
+
+ // see if the page of data is already present in the data
+ if (this.getPageOfRows(pager).length > 0) return;
+
+ if (this.allRowsRetrieved) return;
+
+ if (!this.getRows) return;
+
+ let idx = pager.offset;
+ this.getRows(pager).subscribe(
+ row => this.data[idx++] = row,
+ err => console.error(`grid getRows() error ${err}`),
+ () => this.checkAllRetrieved(pager, idx)
+ );
+ }
+
+ // See if the last getRows() call resulted in the final set of data.
+ checkAllRetrieved(pager: Pager, idx: number) {
+ if (this.allRowsRetrieved) return;
+
+ if (idx == 0 || idx < (pager.limit + pager.offset)) {
+ // last query returned nothing or less than one page.
+ // confirm we have all of the preceding pages.
+ if (!this.data.includes(undefined)) {
+ this.allRowsRetrieved = true;
+ pager.resultCount = this.data.length;
+ }
+ }
+ }
+}
+
+
--- /dev/null
+
+<div class="eg-grid-row eg-grid-header-row">
+ <div class="eg-grid-cell eg-grid-header-cell eg-grid-checkbox-cell eg-grid-cell-skinny">
+ <input type='checkbox'> <!-- add click handlers ; shared selector mod -->
+ </div>
+ <div class="eg-grid-cell eg-grid-header-cell eg-grid-number-cell eg-grid-cell-skinny">
+ #
+ </div>
+
+ <div *ngFor="let col of columnSet.displayColumns()"
+ class="eg-grid-cell eg-grid-header-cell" [ngStyle]="{flex:col.flex}">
+ {{col.label}}
+ </div>
+</div>
+
--- /dev/null
+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() {
+ }
+}
+
--- /dev/null
+
+<div class="eg-grid-toolbar">
+
+ <!-- push everything else to the right -->
+ <div class="flex-1"></div>
+
+ <div class="btn-toolbar">
+ <div class="btn-grp">
+ <button [disabled]="pager.isFirstPage()" type="button" class="btn btn-light" (click)="pager.toFirst()">
+ <span title="First Page" i18n-title class="material-icons">first_page</span>
+ </button>
+ <button [disabled]="pager.isFirstPage()" type="button" class="btn btn-light" (click)="pager.decrement()">
+ <span title="Previous Page" i18n-title class="material-icons">keyboard_arrow_left</span>
+ </button>
+ <button [disabled]="pager.isLastPage()" type="button" class="btn btn-light" (click)="pager.increment()">
+ <span title="Next Page" i18n-title class="material-icons">keyboard_arrow_right</span>
+ </button>
+ <button [disabled]="pager.isLastPage()" type="button" class="btn btn-light" (click)="pager.toLast()">
+ <span title="First Page" i18n-title class="material-icons">last_page</span>
+ </button>
+ </div>
+ </div>
+
+<div>
+
--- /dev/null
+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));
+ }
+}
+
+
--- /dev/null
+
+.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;
+}
+
+
--- /dev/null
+
+<div class="eg-grid">
+ <eg-grid-toolbar [dataSource]="dataSource" [pager]="pager">
+ </eg-grid-toolbar>
+ <eg-grid-header [columnSet]="columnSet"></eg-grid-header>
+ <eg-grid-body
+ [columnSet]="columnSet"
+ [dataSource]="dataSource"
+ [pager]="pager"
+ [selector]="selector">
+ </eg-grid-body>
+</div>
+
--- /dev/null
+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);
+ }
+}
+
+
--- /dev/null
+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 {
+
+}
--- /dev/null
+import {Injectable, TemplateRef} from '@angular/core';
+import {EgIdlService, EgIdlObject} from '@eg/core/idl.service';
+import {EgOrgService} from '@eg/core/org.service';
+import {EgPcrudService} from '@eg/core/pcrud.service';
+
+
+@Injectable()
+export class EgGridService {
+
+ constructor(
+ private idl: EgIdlService,
+ private org: EgOrgService,
+ private pcrud: EgPcrudService
+ ) {
+ }
+
+ getRowColumnValue(row: any, col: EgGridColumn): string {
+ if (row[col.name] === undefined || row[col.name] === null)
+ return '';
+
+ if (col.idlFieldDef)
+ return this.getRowColumnIdlValue(row, col);
+
+ if (typeof row[col.name] == 'function') {
+ let val = row[col.name]();
+ if (val === undefined || val === null) return '';
+ return val+'';
+ }
+
+ return row[col.name]+'';
+ }
+
+ getRowColumnIdlValue(row: any, col: EgGridColumn): string {
+ let val = row[col.name]();
+ if (val === undefined || val === null) return '';
+ return val+'';
+ }
+
+ initializeColumnSet(idlClass?: string): EgGridColumnSet {
+ let columnSet = new EgGridColumnSet();
+
+ // generate columns for all non-virtual fields on the IDL class
+ if (idlClass) {
+ this.idl.classes[idlClass].fields.forEach(field => {
+ if (field.virtual) return;
+ let col = new EgGridColumn();
+ col.name = field.name;
+ col.label = field.label || field.name;
+ col.idlFieldDef = field;
+ columnSet.add(col);
+ });
+ }
+
+ return columnSet;
+ }
+}
+
+
+export class EgGridColumn {
+ name: string;
+ path: string;
+ label: string;
+ flex: number;
+ hidden: boolean;
+ idlClass: string;
+ idlFieldDef: any;
+ cellTemplate: TemplateRef<any>;
+}
+
+
+export class EgGridColumnSet {
+ columns: EgGridColumn[];
+
+ constructor() {
+ this.columns = [];
+ }
+
+ add(col: EgGridColumn) {
+ // avoid dupes
+ if (this.columns.filter(c => c.name == col.name).length) return;
+
+ this.columns.push(col);
+ }
+
+ displayColumns(): EgGridColumn[] {
+ return this.columns.filter(c => !c.hidden);
+ }
+}
+
+
+
--- /dev/null
+
+<!-- todo disabled -->
+<ng-template #displayTemplate let-r="result">
+{{r.label}}
+</ng-template>
+
+<input type="text"
+ class="form-control"
+ [placeholder]="placeholder"
+ [(ngModel)]="selected"
+ [ngbTypeahead]="filter"
+ [resultTemplate]="displayTemplate"
+ [inputFormatter]="formatter"
+ (click)="click$.next($event.target.value)"
+ (selectItem)="orgChanged($event)"
+ #instance="ngbTypeahead"
+/>
--- /dev/null
+import {Component, OnInit, Input, Output, ViewChild, EventEmitter} from '@angular/core';
+import {Observable} from 'rxjs/Observable';
+import {map, debounceTime} from 'rxjs/operators';
+import {Subject} from 'rxjs/Subject';
+import {EgAuthService} from '@eg/core/auth.service';
+import {EgStoreService} from '@eg/core/store.service';
+import {EgOrgService} from '@eg/core/org.service';
+import {EgIdlObject} from '@eg/core/idl.service';
+import {NgbTypeahead, NgbTypeaheadSelectItemEvent}
+ from '@ng-bootstrap/ng-bootstrap';
+
+// Use a unicode char for spacing instead of ASCII=32 so the browser
+// won't collapse the nested display entries down to a single space.
+const PAD_SPACE: string = ' '; // U+2007
+
+interface OrgDisplay {
+ id: number;
+ label: string;
+ disabled: boolean;
+}
+
+@Component({
+ selector: 'eg-org-select',
+ templateUrl: './org-select.component.html'
+})
+export class EgOrgSelectComponent implements OnInit {
+
+ selected: OrgDisplay;
+ hidden: number[] = [];
+ disabled: number[] = [];
+ click$ = new Subject<string>();
+ startOrg: EgIdlObject;
+
+ @ViewChild('instance') instance: NgbTypeahead;
+
+ // Placeholder text for selector input
+ @Input() placeholder: string = '';
+ @Input() stickySetting: string;
+
+ // Org unit field displayed in the selector
+ @Input() displayField: string = 'shortname';
+
+ // Apply a default org unit value when none is set.
+ // First tries workstation org unit, then user home org unit.
+ // An onChange event WILL be generated when a default is applied.
+ @Input() applyDefault: boolean = false;
+
+ // List of org unit IDs to exclude from the selector
+ @Input() set hideOrgs(ids: number[]) {
+ if (ids) this.hidden = ids;
+ }
+
+ // List of org unit IDs to disable in the selector
+ @Input() set disableOrgs(ids: number[]) {
+ if (ids) this.disabled = ids;
+ }
+
+ // Apply an org unit value at load time.
+ // This will NOT result in an onChange event.
+ @Input() set initialOrg(org: EgIdlObject) {
+ if (org) this.startOrg = org;
+ }
+
+ // Apply an org unit value by ID at load time.
+ // This will NOT result in an onChange event.
+ @Input() set initialOrgId(id: number) {
+ if (id) this.startOrg = this.org.get(id);
+ }
+
+ // Modify the selected org unit via data binding.
+ // This WILL result in an onChange event firing.
+ @Input() set applyOrg(org: EgIdlObject) {
+ if (org) this.selected = this.formatForDisplay(org);
+ }
+
+ // Modify the selected org unit by ID via data binding.
+ // This WILL result in an onChange event firing.
+ @Input() set applyOrgId(id: number) {
+ if (id) this.selected = this.formatForDisplay(this.org.get(id));
+ }
+
+ // Emitted when the org unit value is changed via the selector.
+ // Does not fire on initialOrg
+ @Output() onChange = new EventEmitter<EgIdlObject>();
+
+ constructor(
+ private auth: EgAuthService,
+ private store: EgStoreService,
+ private org: EgOrgService
+ ) {}
+
+ ngOnInit() {
+
+ // Apply a default org unit if desired and possible.
+ if (!this.startOrg && this.applyDefault && this.auth.user()) {
+ // note: ws_ou defaults to home_ou on the server
+ // when when no workstation is used
+ this.startOrg = this.org.get(this.auth.user().ws_ou());
+ this.selected = this.formatForDisplay(
+ this.org.get(this.auth.user().ws_ou())
+ );
+
+ // avoid notifying mid-digest
+ setTimeout(() => this.onChange.emit(this.startOrg), 0);
+ }
+
+ if (this.startOrg) {
+ this.selected = this.formatForDisplay(this.startOrg);
+ }
+ }
+
+ // Format for display in the selector drop-down and input.
+ formatForDisplay(org: EgIdlObject): OrgDisplay {
+ return {
+ id : org.id(),
+ label : PAD_SPACE.repeat(org.ou_type().depth())
+ + org[this.displayField](),
+ disabled : false
+ };
+ }
+
+ // Fired by the typeahead to inform us of a change.
+ orgChanged(selEvent: NgbTypeaheadSelectItemEvent) {
+ this.onChange.emit(this.org.get(selEvent.item.id));
+ }
+
+ // Remove the tree-padding spaces when matching.
+ formatter = (result: OrgDisplay) => result.label.trim();
+
+ filter = (text$: Observable<string>): Observable<OrgDisplay[]> => {
+ return text$
+ .debounceTime(200)
+ .distinctUntilChanged()
+ .merge(this.click$.filter(() => !this.instance.isPopupOpen()))
+ .map(term => {
+
+ return this.org.list().filter(org => {
+
+ // Find orgs matching the search term
+ return org[this.displayField]()
+ .toLowerCase().indexOf(term.toLowerCase()) > -1
+
+ }).filter(org => { // Exclude hidden orgs
+ return this.hidden.filter(
+ id => {return org.id() == id}).length == 0;
+
+ }).map(org => {return this.formatForDisplay(org)})
+ });
+ }
+}
+
+
--- /dev/null
+/*j
+ * <eg-string #helloStr text="Hello, {{name}}" i18n-text></eg-string>
+ *
+ * import {EgStringComponent} from '@eg/share/string.component';
+ * @ViewChild('helloStr') private helloStr: EgStringComponent;
+ * ...
+ * this.helloStr.currrent().then(s => console.log(s));
+ *
+ */
+import {Component, Input, OnInit, ElementRef, TemplateRef} from '@angular/core';
+import {EgStringService} from '@eg/share/string/string.service';
+
+@Component({
+ selector: 'eg-string',
+ template: `
+ <span style='display:none'>
+ <ng-container *ngTemplateOutlet="template; context:ctx"></ng-container>
+ </span>
+ `
+})
+
+export class EgStringComponent implements OnInit {
+
+ @Input() key: string;
+ @Input() ctx: any;
+ @Input() template: TemplateRef<any>;
+
+ constructor(private elm: ElementRef, private strings: EgStringService) {
+ this.elm = elm;
+ this.strings = strings;
+ }
+
+ ngOnInit() {
+ // No key means it's an unregistered (likely static) string
+ // that does not need interpolation.
+ if (this.key) {
+ this.strings.register({
+ key: this.key,
+ resolver: (ctx:any) => this.current(ctx)
+ });
+ }
+ }
+
+
+ // Apply the new context if provided, give our container a
+ // chance to update, then resolve with the current string.
+ current(ctx?: any): Promise<string> {
+ if (ctx) this.ctx = ctx;
+ return new Promise(resolve => {
+ setTimeout(() => resolve(this.elm.nativeElement.textContent));
+ });
+ }
+}
+
--- /dev/null
+import {Injectable} from '@angular/core';
+
+interface EgStringAssignment {
+ key: string, // keyboard command
+ resolver: (ctx:any) => Promise<string>
+};
+
+@Injectable()
+export class EgStringService {
+
+ strings: {[key:string] : EgStringAssignment} = {};
+
+ constructor() {}
+
+ register(assn: EgStringAssignment) {
+ this.strings[assn.key] = assn;
+ }
+
+ interpolate(key: string, ctx?: any): Promise<string> {
+ if (!this.strings[key])
+ return Promise.reject('No Such String');
+ return this.strings[key].resolver(ctx);
+ }
+
+}
+
+
--- /dev/null
+#eg-toast-container {
+ min-width: 250px;
+ text-align: center;
+ border-radius: 2px;
+ padding: 10px;
+ position: fixed;
+ z-index: 1;
+ right: 15px;
+ bottom: 5px;
+}
+
--- /dev/null
+<div id="eg-toast-container" *ngIf="message">
+ <ngb-alert [type]="message.style" (close)="dismiss(message)">{{message.text}}</ngb-alert>
+</div>
--- /dev/null
+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;
+ }
+ }
+}
+
+
--- /dev/null
+import {Injectable, EventEmitter} from '@angular/core';
+
+export interface EgToastMessage {
+ text: string,
+ style: string
+};
+
+@Injectable()
+export class EgToastService {
+
+ messages$: EventEmitter<EgToastMessage>;
+
+ constructor() {
+ this.messages$ = new EventEmitter<EgToastMessage>();
+ }
+
+ sendMessage(msg: EgToastMessage) {
+ this.messages$.emit(msg);
+ }
+
+ success(text: string) {
+ this.sendMessage({text: text, style: 'success'});
+ }
+
+ info(text: string) {
+ this.sendMessage({text: text, style: 'info'});
+ }
+
+ warning(text: string) {
+ this.sendMessage({text: text, style: 'warning'});
+ }
+
+ danger(text: string) {
+ this.sendMessage({text: text, style: 'danger'});
+ }
+
+ // Others?
+}
+
--- /dev/null
+/**
+ * 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);
+ }
+ });
+ }
+}
+
+
--- /dev/null
+import {EventEmitter} from '@angular/core';
+
+/**
+ * Utility class for manage paged information.
+ */
+export class Pager {
+ offset: number = 0;
+ limit: number = null;
+ resultCount: number;
+ onChange$: EventEmitter<number>;
+
+ constructor() {
+ this.onChange$ = new EventEmitter<number>();
+ }
+
+ isFirstPage(): boolean {
+ return this.offset == 0;
+ }
+
+ isLastPage(): boolean {
+ return this.currentPage() == this.pageCount();
+ }
+
+ currentPage(): number {
+ return Math.floor(this.offset / this.limit) + 1
+ }
+
+ increment(): void {
+ this.setPage(this.currentPage() + 1);
+ }
+
+ decrement(): void {
+ this.setPage(this.currentPage() - 1);
+ }
+
+ toFirst() {
+ if (!this.isFirstPage())
+ this.setPage(1);
+ }
+
+ toLast() {
+ if (!this.isLastPage())
+ this.setPage(this.pageCount());
+ }
+
+ setPage(page: number): void {
+ this.offset = (this.limit * (page - 1));
+ this.onChange$.emit(this.offset);
+ }
+
+ pageCount(): number {
+ let pages = this.resultCount / this.limit;
+ if (Math.floor(pages) < pages)
+ pages = Math.floor(pages) + 1;
+ return pages;
+ }
+
+ pageList(): number[] {
+ let list = [];
+ for(let i = 1; i <= this.pageCount(); i++)
+ list.push(i);
+ return list;
+ }
+
+ // Given a zero-based page-specific offset, return the where in the
+ // entire data set the row lives, 1-based for UI friendliness.
+ rowNumber(offset: number): number {
+ return this.offset + offset + 1;
+ }
+}
--- /dev/null
+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 {}
--- /dev/null
+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 {}
--- /dev/null
+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 {
+}
+
--- /dev/null
+<eg-staff-banner bannerText="Workstation Administration" i18n-bannerText>
+</eg-staff-banner>
+
+<!-- this will remain hidden until opened -->
+<eg-confirm-dialog
+ #workstationExistsDialog
+ i18n-dialogTitle i18n-dialogBody
+ dialogTitle="Workstation Exists"
+ dialogBody='Workstation "{{newName}}" already exists. Use it anyway?'>
+</eg-confirm-dialog>
+
+<div class="row">
+ <div class="col-lg-8 offset-1 mt-3">
+ <div class="alert alert-warning" *ngIf="removeWorkstation" i18n>
+ Workstation {{removeWorkstation}} is no longer valid. Removing registration.
+ </div>
+ <div class="alert alert-danger" *ngIf="workstations.length == 0">
+ <span i18n>Please register a workstation.</span>
+ </div>
+
+ <div class="row">
+ <div class="col" i18n>Register a New Workstation For This Browser</div>
+ </div>
+ <div class="row mt-2">
+ <div class="col-lg-2">
+ <eg-org-select
+ [applyDefault]="true"
+ (onChange)="orgOnChange($event)"
+ [hideOrgs]="hideOrgs"
+ [disableOrgs]="disableOrgs"
+ i18n-placeholder
+ placeholder="Owner..." >
+ </eg-org-select>
+ </div>
+ <div class="col-lg-6">
+ <div class="input-group">
+ <input type='text'
+ class='form-control'
+ i18n-title
+ title="Workstation Name"
+ i18n-placeholder
+ placeholder="Workstation Name..."
+ [(ngModel)]='newName'/>
+ <div class="input-group-btn">
+ <button class="btn btn-outline-dark"
+ [disabled]="!newName || !newOwner"
+ (click)="registerWorkstation()">
+ <span i18n>Register</span>
+ </button>
+ </div>
+ </div>
+ </div>
+ </div>
+ <div class="row mt-3 pt-3 border border-left-0 border-right-0 border-bottom-0 border-light">
+ <div class="col">
+ <span i18n>Workstations Registered With This Browser</span>
+ </div>
+ </div>
+ <div class="row">
+ <div class="col-lg-8">
+ <select class="form-control" [(ngModel)]="selectedName">
+ <option *ngFor="let ws of workstations" value="{{ws.name}}">
+ <span *ngIf="ws.name == defaultName" i18n>
+ {{ws.name}} (Default)
+ </span>
+ <span *ngIf="ws.name != defaultName">
+ {{ws.name}}
+ </span>
+ </option>
+ </select>
+ </div>
+ </div>
+ <div class="row mt-2">
+ <div class="col-lg-6">
+ <button i18n class="btn btn-success"
+ (click)="useNow()" [disabled]="!selected">
+ Use Now
+ </button>
+ <button i18n class="btn btn-outline-dark"
+ (click)="setDefault()" [disabled]="!selected">
+ Mark As Default
+ </button>
+ <button i18n class="btn btn-danger"
+ (click)="removeSelected()"
+ [disabled]="!selected || !canDeleteSelected()">
+ Remove
+ </button>
+ </div>
+ </div>
+ </div>
+</div>
+
--- /dev/null
+import {Component, OnInit, ViewChild} from '@angular/core';
+import {Router, ActivatedRoute} from '@angular/router';
+import {EgStoreService} from '@eg/core/store.service';
+import {EgIdlObject} from '@eg/core/idl.service';
+import {EgNetService} from '@eg/core/net.service';
+import {EgPermService} from '@eg/core/perm.service';
+import {EgAuthService} from '@eg/core/auth.service';
+import {EgOrgService} from '@eg/core/org.service';
+import {EgEventService} from '@eg/core/event.service';
+import {EgConfirmDialogComponent} from '@eg/share/dialog/confirm.component';
+
+// Slim version of the WS that's stored in the cache.
+interface Workstation {
+ id: number;
+ name: string;
+ owning_lib: number;
+}
+
+@Component({
+ templateUrl: 'workstations.component.html'
+})
+export class WorkstationsComponent implements OnInit {
+
+ selectedName: string;
+ workstations: Workstation[] = [];
+ removeWorkstation: string;
+ newOwner: EgIdlObject;
+ newName: string;
+ defaultName: string;
+
+ @ViewChild('workstationExistsDialog')
+ private wsExistsDialog: EgConfirmDialogComponent;
+
+ // Org selector options.
+ hideOrgs: number[];
+ disableOrgs: number[];
+ orgOnChange = (org: EgIdlObject): void => {
+ this.newOwner = org;
+ }
+
+ constructor(
+ private router: Router,
+ private route: ActivatedRoute,
+ private evt: EgEventService,
+ private net: EgNetService,
+ private store: EgStoreService,
+ private auth: EgAuthService,
+ private org: EgOrgService,
+ private perm: EgPermService
+ ) {}
+
+ ngOnInit() {
+ this.store.getItem('eg.workstation.all')
+ .then(list => this.workstations = list || [])
+ .then(noop => this.store.getItem('eg.workstation.default'))
+ .then(defWs => {
+ this.defaultName = defWs;
+ this.selectedName = this.auth.workstation() || defWs
+ })
+ .then(noop => {
+ let rm = this.route.snapshot.paramMap.get('remove');
+ if (rm) this.removeSelected(this.removeWorkstation = rm)
+ })
+
+ this.perm.hasWorkPermAt(['REGISTER_WORKSTATION'], true)
+ .then(perms => {
+ // Disable org units that cannot have users and any
+ // that this user does not have work perms for.
+ this.disableOrgs =
+ this.org.filterList({canHaveUsers : false}, true)
+ .concat(this.org.filterList(
+ {notInList : perms.REGISTER_WORKSTATION}, true));
+ });
+ }
+
+ selected(): Workstation {
+ return this.workstations.filter(
+ ws => {return ws.name == this.selectedName})[0];
+ }
+
+ useNow(): void {
+ if (!this.selected()) return;
+ this.router.navigate(['/staff/login'],
+ {queryParams: {workstation: this.selected().name}});
+ }
+
+ setDefault(): void {
+ if (!this.selected()) return;
+ this.defaultName = this.selected().name;
+ this.store.setItem('eg.workstation.default', this.defaultName);
+ }
+
+ removeSelected(name?: string): void {
+ if (!name) name = this.selected().name;
+
+ this.workstations = this.workstations.filter(w => w.name != name);
+ this.store.setItem('eg.workstation.all', this.workstations);
+
+ if (this.defaultName == name) {
+ this.defaultName = null;
+ this.store.removeItem('eg.workstation.default');
+ }
+ }
+
+ canDeleteSelected(): boolean {
+ return true;
+ }
+
+ registerWorkstation(): void {
+ console.log(`Registering new workstation ` +
+ `"${this.newName}" at ${this.newOwner.shortname()}`);
+
+ this.newName = this.newOwner.shortname() + '-' + this.newName;
+
+ this.registerWorkstationApi().then(
+ wsId => this.registerWorkstationLocal(wsId),
+ notOk => console.log('Workstation registration canceled/failed')
+ );
+ }
+
+ private handleCollision(): Promise<number> {
+ return new Promise((resolve, reject) => {
+ this.wsExistsDialog.open()
+ .then(
+ confirmed => {
+ this.registerWorkstationApi(true).then(
+ wsId => resolve(wsId),
+ notOk => reject(notOk)
+ )
+ },
+ dismissed => reject(dismissed)
+ )
+ });
+ }
+
+
+ private registerWorkstationApi(override?: boolean): Promise<number> {
+ let method = 'open-ils.actor.workstation.register';
+ if (override) method += '.override';
+
+ return new Promise((resolve, reject) => {
+ this.net.request(
+ 'open-ils.actor', method,
+ this.auth.token(), this.newName, this.newOwner.id()
+ ).subscribe(wsId => {
+ let evt = this.evt.parse(wsId);
+ if (evt) {
+ if (evt.textcode == 'WORKSTATION_NAME_EXISTS') {
+ this.handleCollision().then(
+ id => resolve(id),
+ notOk => reject(notOk)
+ )
+ } else {
+ console.error(`Registration failed ${evt}`);
+ reject();
+ }
+ } else {
+ resolve(wsId);
+ }
+ });
+ });
+ }
+
+ private registerWorkstationLocal(wsId: number) {
+ let ws: Workstation = {
+ id: wsId,
+ name: this.newName,
+ owning_lib: this.newOwner.id()
+ };
+
+ this.workstations.push(ws);
+ this.store.setItem('eg.workstation.all', this.workstations)
+ .then(ok => {
+ this.newName = '';
+ // when registering our first workstation, mark it as the
+ // default and show it as selected in the ws selector.
+ if (this.workstations.length == 1) {
+ this.selectedName = ws.name;
+ this.setDefault();
+ }
+ });
+ }
+}
+
+
--- /dev/null
+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 {}
+
+
--- /dev/null
+<!-- search form sits atop every catalog page -->
+<eg-catalog-search-form></eg-catalog-search-form>
+
+<!-- search results, record details, etc. -->
+<router-outlet></router-outlet>
+
--- /dev/null
+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();
+ }
+}
+
--- /dev/null
+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 {
+
+}
--- /dev/null
+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});
+ }
+
+}
+
+
--- /dev/null
+<div class='eg-copies w-100'>
+ <ul class="pagination mb-1">
+ <li class="page-item" [ngClass]="{disabled : pager.offset == 0}">
+ <a class="no-href page-link"
+ i18n-aria-label aria-label="Start" (click)="firstPage()">
+ <span i18n>Start</span>
+ </a>
+ </li>
+ <li class="page-item" [ngClass]="{disabled : pager.offset == 0}">
+ <a class="no-href page-link"
+ i18n-aria-label aria-label="Previous" (click)="prevPage()">
+ <span i18n>Previous</span>
+ </a>
+ </li>
+ <!-- note disable logic is incomplete -->
+ <li class="page-item"
+ [ngClass]="{disabled: copies.length < pager.limit}">
+ <a class="no-href page-link"
+ i18n-aria-label aria-label="Next" (click)="nextPage()">
+ <span i18n>Next</span>
+ </a>
+ </li>
+ </ul>
+ <div class='card tight-card w-100'>
+ <div class="card-header font-weight-bold d-flex bg-info">
+ <div class="flex-1" i18n>Location</div>
+ <div class="flex-1 pl-1" i18n>Call Number / Copy Notes</div>
+ <div class="flex-1 pl-1" i18n>Barcode</div>
+ <div class="flex-1 pl-1" i18n>Shelving Location</div>
+ <div class="flex-1 pl-1" i18n>Circulation Modifier</div>
+ <div class="flex-1 pl-1" i18n>Age Hold Protection</div>
+ <div class="flex-1 pl-1" i18n>Active/Create Date</div>
+ <div class="flex-1 pl-1" i18n>Holdable?</div>
+ <div class="flex-1 pl-1" i18n>Status</div>
+ <div class="flex-1 pl-1" i18n>Due Date</div>
+ </div>
+ <div class="card-body">
+ <ul class="list-group list-group-flush" *ngIf="copies && copies.length">
+ <li class="list-group-item" *ngFor="let copy of copies; let idx = index"
+ [ngClass]="{'list-group-item-info': (idx % 2) == 1}">
+ <div class="d-flex">
+ <div class="flex-1" i18n>{{orgName(copy.circ_lib)}}</div>
+ <div class="flex-1 pl-1" i18n>
+ {{copy.call_number_prefix_label}}
+ {{copy.call_number_label}}
+ {{copy.call_number_suffix_label}}
+ </div>
+ <div class="flex-1 pl-1">
+ {{copy.barcode}}
+ <a class="pl-1" href="/eg/staff/cat/item/{{copy.id}}" i18n>View</a>
+ |
+ <a class="pl-1" href="/eg/staff/cat/item/{{copy.id}}/edit" i18n>Edit</a>
+ </div>
+ <div class="flex-1 pl-1" i18n>{{copy.copy_location}}</div>
+ <div class="flex-1 pl-1" i18n>{{copy.circ_modifier || ''}}</div>
+ <div class="flex-1 pl-1" i18n>{{copy.age_protect}}</div>
+ <div class="flex-1 pl-1" i18n>
+ {{copy.active_date || copy.create_date | date:'shortDate'}}
+ </div>
+ <div class="flex-1 pl-1">
+ <span *ngIf="holdable(copy)" i18n>Yes</span>
+ <span *ngIf="!holdable(copy)" i18n>No</span>
+ </div>
+ <div class="flex-1 pl-1" i18n>{{copy.copy_status}}</div>
+ <div class="flex-1 pl-1" i18n>{{copy.due_date | date:'shortDate'}}</div>
+ </div>
+ </li>
+ </ul>
+ </div>
+ </div>
+</div>
--- /dev/null
+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();
+ }
+
+}
+
+
--- /dev/null
+<ul class="pagination mb-0" *ngIf="index !== null">
+ <li class="page-item" [ngClass]="{disabled : index == 0}">
+ <a class="no-href page-link"
+ i18n-aria-label aria-label="Start" (click)="firstRecord()">
+ <span i18n>Start</span>
+ </a>
+ </li>
+ <li class="page-item" [ngClass]="{disabled : index == 0}">
+ <a class="no-href page-link"
+ i18n-aria-label aria-label="Previous" (click)="prevRecord()">
+ <span i18n>Previous</span>
+ </a>
+ </li>
+ <li class="page-item"
+ [ngClass]="{disabled : index >= searchContext.result.count - 1}">
+ <a class="no-href page-link"
+ i18n-aria-label aria-label="Next" (click)="nextRecord()">
+ <span i18n>Next</span>
+ </a>
+ </li>
+ <li class="page-item"
+ [ngClass]="{disabled : index >= searchContext.result.count - 1}">
+ <a class="no-href page-link"
+ i18n-aria-label aria-label="End" (click)="lastRecord()">
+ <span i18n>End</span>
+ </a>
+ </li>
+ <li class="page-item">
+ <a class="no-href page-link"
+ i18n-aria-label aria-label="Back to Results" (click)="returnToSearch()">
+ <span i18n>
+ Back to Results ({{index + 1}} / {{searchContext.result.count}})
+ </span>
+ </a>
+ </li>
+</ul>
--- /dev/null
+import {Component, OnInit, Input} from '@angular/core';
+import {Router} from '@angular/router';
+import {EgCatalogService} from '@eg/share/catalog/catalog.service';
+import {CatalogSearchContext} from '@eg/share/catalog/search-context';
+import {EgCatalogUrlService} from '@eg/share/catalog/catalog-url.service';
+import {StaffCatalogService} from '../catalog.service';
+import {Pager} from '@eg/share/util/pager';
+
+
+@Component({
+ selector: 'eg-catalog-record-pagination',
+ templateUrl: 'pagination.component.html'
+})
+export class RecordPaginationComponent implements OnInit {
+
+ id: number;
+ index: number;
+ initDone: boolean = false;
+ searchContext: CatalogSearchContext;
+
+ @Input() set recordId(id: number) {
+ this.id = id;
+ // Only apply new record data after the initial load
+ if (this.initDone) this.setIndex();
+ }
+
+ constructor(
+ private router: Router,
+ private cat: EgCatalogService,
+ private catUrl: EgCatalogUrlService,
+ private staffCat: StaffCatalogService,
+ ) {}
+
+ ngOnInit() {
+ this.initDone = true;
+ this.setIndex();
+ }
+
+ firstRecord(): void {
+ this.findRecordAtIndex(0).then(id => {
+ let params = this.catUrl.toUrlParams(this.searchContext);
+ this.router.navigate(
+ ['/staff/catalog/record/' + id], {queryParams: params});
+ });
+ }
+
+ lastRecord(): void {
+ this.findRecordAtIndex(
+ this.searchContext.result.count - 1
+ ).then(id => {
+ let params = this.catUrl.toUrlParams(this.searchContext);
+ this.router.navigate(
+ ['/staff/catalog/record/' + id], {queryParams: params});
+ });
+ }
+
+ nextRecord(): void {
+ this.findRecordAtIndex(this.index + 1).then(id => {
+ let params = this.catUrl.toUrlParams(this.searchContext);
+ this.router.navigate(
+ ['/staff/catalog/record/' + id], {queryParams: params});
+ });
+ }
+
+ prevRecord(): void {
+ this.findRecordAtIndex(this.index - 1).then(id => {
+ let params = this.catUrl.toUrlParams(this.searchContext);
+ this.router.navigate(
+ ['/staff/catalog/record/' + id], {queryParams: params});
+ });
+ }
+
+
+ // Returns the offset of the record within the search results as a whole.
+ searchIndex(idx: number): number {
+ return idx + this.searchContext.pager.offset;
+ }
+
+ // Find the position of the current record in the search results
+ // If no results are present or the record is not found, expand
+ // the search scope to find the record.
+ setIndex(): Promise<void> {
+ this.searchContext = this.staffCat.searchContext;
+ this.index = null;
+
+ return new Promise((resolve, reject) => {
+
+ this.index = this.searchContext.indexForResult(this.id);
+ if (this.index !== null) return resolve();
+
+ return this.refreshSearch().then(ok => {
+ this.index = this.searchContext.indexForResult(this.id);
+ if (this.index === null) console.warn(
+ 'No search results found containing the focused record.');
+ resolve();
+ });
+ });
+ }
+
+ // Find the record ID at the specified search index.
+ // If no data exists for the requested index, expand the search
+ // to include data for that index.
+ findRecordAtIndex(index: number): Promise<number> {
+
+ // First see if the selected record sits in the current page
+ // of search results.
+ return new Promise((resolve, reject) => {
+ let id = this.searchContext.resultIdAt(index);
+ if (id) return resolve(id);
+
+ console.debug(
+ 'Record paginator unable to find record at index ' + index);
+
+ // If we have to re-run the search to find the record,
+ // expand the search limit out just enough to find the
+ // requested record plus one more.
+ return this.refreshSearch(index + 2).then(
+ ok => {
+ let id = this.searchContext.resultIdAt(index);
+ if (id) {
+ resolve(id);
+ } else {
+ reject('no record found');
+ }
+ }
+ );
+ });
+ }
+
+ refreshSearch(limit?: number): Promise<void> {
+
+ console.debug('paginator refreshing search');
+
+ if (!this.searchContext.isSearchable())
+ return Promise.resolve();
+
+ let origPager = this.searchContext.pager;
+ let tmpPager = new Pager();
+ tmpPager.limit = limit || 1000;
+
+ this.searchContext.pager = tmpPager;
+
+ return this.cat.search(this.searchContext)
+ .then(
+ ok => { this.searchContext.pager = origPager; },
+ notOk => { this.searchContext.pager = origPager }
+ );
+ }
+
+ returnToSearch(): void {
+ // Fire the main search. This will direct us back to /results/
+ this.staffCat.search();
+ }
+
+}
+
+
--- /dev/null
+
+<div id="staff-catalog-record-container">
+ <div id='staff-catalog-bib-navigation'>
+ <div *ngIf="searchContext.isSearchable()">
+ <eg-catalog-record-pagination [recordId]="recordId">
+ </eg-catalog-record-pagination>
+ </div>
+ </div>
+ <div id='staff-catalog-bib-summary-container' class='mt-1'>
+ <eg-bib-summary [bibSummary]="bibSummary">
+ </eg-bib-summary>
+ </div>
+ <div id='staff-catalog-copies-container' class='mt-3'>
+ <eg-catalog-copies [recordId]="recordId"></eg-catalog-copies>
+ </div>
+</div>
+
+
--- /dev/null
+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;
+ })
+ });
+ }
+}
+
+
--- /dev/null
+import {Injectable} from '@angular/core';
+import {Observable, Observer} from 'rxjs/Rx';
+import {Router, Resolve, RouterStateSnapshot,
+ ActivatedRouteSnapshot} from '@angular/router';
+import {EgStoreService} from '@eg/core/store.service';
+import {EgNetService} from '@eg/core/net.service';
+import {EgOrgService} from '@eg/core/org.service';
+import {EgAuthService} from '@eg/core/auth.service';
+import {EgPcrudService} from '@eg/core/pcrud.service';
+import {EgCatalogService} from '@eg/share/catalog/catalog.service';
+import {StaffCatalogService} from './catalog.service';
+
+@Injectable()
+export class EgCatalogResolver implements Resolve<Promise<any[]>> {
+
+ constructor(
+ private router: Router,
+ private store: EgStoreService,
+ private org: EgOrgService,
+ private net: EgNetService,
+ private auth: EgAuthService,
+ private cat: EgCatalogService,
+ private staffCat: StaffCatalogService
+ ) {}
+
+ resolve(
+ route: ActivatedRouteSnapshot,
+ state: RouterStateSnapshot): Promise<any[]> {
+
+ console.debug('EgCatalogResolver:resolve()');
+
+ return Promise.all([
+ this.cat.fetchCcvms(),
+ this.cat.fetchCmfs(),
+ this.fetchSettings()
+ ]);
+ }
+
+ fetchSettings(): Promise<any> {
+ let promises = [];
+
+ promises.push(
+ this.store.getItem('eg.search.search_lib').then(
+ id => this.staffCat.defaultSearchOrg = this.org.get(id)
+ )
+ );
+
+ promises.push(
+ this.store.getItem('eg.search.pref_lib').then(
+ id => this.staffCat.prefOrg = this.org.get(id)
+ )
+ );
+
+ return Promise.all(promises);
+ }
+
+}
+
--- /dev/null
+<style>
+ .facet-selected {
+ background-color: #DDD;
+ }
+ .card {
+ width: 100%;
+ }
+ .list-group-item {padding: .5rem .75rem .5rem .75rem}
+</style>
+<div *ngIf="searchContext.result.facetData">
+ <div *ngFor="let facetConf of facetConfig.display">
+ <div *ngIf="searchContext.result.facetData[facetConf.facetClass]">
+ <div *ngFor="let name of facetConf.facetOrder">
+ <div class="row"
+ *ngIf="searchContext.result.facetData[facetConf.facetClass][name]">
+ <div class="card mb-2">
+ <h4 class="card-header">
+ {{searchContext.result.facetData[facetConf.facetClass][name].cmfLabel}}
+ </h4>
+ <ul class="list-group list-group-flush">
+ <li class="list-group-item"
+ [ngClass]="{'facet-selected' :
+ facetIsApplied(facetConf.facetClass, name, value.value)}"
+ *ngFor="
+ let value of searchContext.result.facetData[facetConf.facetClass][name].valueList | slice:0:facetConfig.displayCount">
+ <div class="row">
+ <div class="col-lg-9">
+ <a class="card-link"
+ href='javascript:;'
+ (click)="applyFacet(facetConf.facetClass, name, value.value)">
+ {{value.value}}
+ </a>
+ </div>
+ <div class="col-lg-3">{{value.count}}</div>
+ </div>
+ </li>
+ </ul>
+ </div>
+ </div>
+ </div>
+ </div>
+ </div>
+</div>
--- /dev/null
+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();
+ }
+}
+
+
--- /dev/null
+
+/* Bootstrap default is 20px */
+.pagination {margin: 0px 0px 0px 0px}
+
+.pagination li:not(.active) a {
+ cursor: pointer;
+}
+
--- /dev/null
+<!--
+Using bare BS pagination instead of ng-bootstrap, which seemed
+unnecessary given we have to track paging externally anyway.
+-->
+<ul class="pagination">
+ <li class="page-item"
+ [ngClass]="{disabled : searchContext.pager.isFirstPage()}">
+ <a (click)="prevPage()"
+ class="page-link"
+ i18n-aria-label
+ aria-label="Previous">
+ <span aria-hidden="true">«</span>
+ </a>
+ </li>
+ <li class="page-item"
+ *ngFor="let page of searchContext.pager.pageList()"
+ [ngClass]="{active : searchContext.pager.currentPage() == page}">
+ <a class="page-link" (click)="setPage(page)">
+ {{page}} <span class="sr-only" i18n>(current)</span></a>
+ </li>
+ <li class="page-item"
+ [ngClass]="{disabled : searchContext.pager.isLastPage()}">
+ <a (click)="nextPage()"
+ class="page-link" aria-label="Next" i18n-aria-label>
+ <span aria-hidden="true">»</span>
+ </a>
+ </li>
+</ul>
--- /dev/null
+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();
+ }
+}
+
+
--- /dev/null
+<!--
+ TODO
+ routerLink's
+ egDateFilter's
+-->
+
+<div class="col-lg-12 card tight-card mb-2 bg-light">
+ <div class="card-body">
+ <div class="row">
+ <div class="col-lg-1">
+ <!-- TODO router links -->
+ <a href="./cat/catalog/record/{{bibSummary.id}}">
+ <img style="height:80px"
+ src="/opac/extras/ac/jacket/small/r/{{bibSummary.id}}"/>
+ </a>
+ </div>
+ <div class="col-lg-5">
+ <div class="row">
+ <div class="col-lg-12 font-weight-bold">
+ <!-- nbsp allows the column to take shape when no value exists -->
+ <span class="font-weight-light font-italic">
+ #{{index + 1 + searchContext.pager.offset}}
+ </span>
+ <a href="javascript:void(0)"
+ (click)="navigatToRecord(bibSummary.id)">
+ {{bibSummary.title || ' '}}
+ </a>
+ </div>
+ </div>
+ <div class="row pt-2">
+ <div class="col-lg-12">
+ <!-- nbsp allows the column to take shape when no value exists -->
+ <a href="javascript:void(0)"
+ (click)="searchAuthor(bibSummary)">
+ {{bibSummary.author || ' '}}
+ </a>
+ </div>
+ </div>
+ <div class="row pt-2">
+ <div class="col-lg-12">
+ <span *ngIf="bibSummary.ccvms.icon_format">
+ <img class="pad-right-min"
+ src="/images/format_icons/icon_format/{{bibSummary.ccvms.icon_format.code}}.png"/>
+ <span>{{bibSummary.ccvms.icon_format.label}}</span>
+ </span>
+ <span style='pl-2'>{{bibSummary.edition}}</span>
+ <span style='pl-2'>{{bibSummary.pubdate}}</span>
+ </div>
+ </div>
+ </div>
+ <div class="col-lg-2">
+ <div class="row" [ngClass]="{'pt-2':copyIndex > 0}"
+ *ngFor="let copyCount of bibSummary.copyCounts; let copyIdx = index">
+ <div class="w-100" *ngIf="copyCount.type == 'staff'">
+ <div class="float-left text-left w-50">
+ <span class="pr-1">
+ {{copyCount.available}} / {{copyCount.count}} items
+ </span>
+ </div>
+ <div class="float-left w-50">
+ @ {{orgName(copyCount.org_unit)}}
+ </div>
+ </div>
+ </div>
+ </div>
+ <div class="col-lg-1">
+ <div class="row">
+ <div class="w-100">
+ TCN: {{bibSummary.tcn_value}}
+ </div>
+ </div>
+ <div class="row">
+ <div class="w-100">
+ Holds: {{bibSummary.holdCount}}
+ </div>
+ </div>
+ </div>
+ <div class="col-lg-3">
+ <div class="row">
+ <div class="col-lg-12">
+ <div class="float-right small-text-1">
+ Created {{bibSummary.create_date | date:'shortDate'}} by
+ <!-- creator if fleshed after the initial data set is loaded -->
+ <a *ngIf="bibSummary.creator.usrname" target="_self"
+ href="/eg/staff/circ/patron/{{bibSummary.creator.id()}}/checkout">
+ {{bibSummary.creator.usrname()}}
+ </a>
+ <!-- add a spacer pending data to reduce page shuffle -->
+ <span *ngIf="!bibSummary.creator.usrname"> ... </span>
+ </div>
+ </div>
+ </div>
+ <div class="row pt-2">
+ <div class="col-lg-12">
+ <div class="float-right small-text-1">
+ Edited {{bibSummary.edit_date | date:'shortDate'}} by
+ <a *ngIf="bibSummary.editor.usrname" target="_self"
+ href="/eg/staff/circ/patron/{{bibSummary.editor.id()}}/checkout">
+ {{bibSummary.editor.usrname()}}
+ </a>
+ <span *ngIf="!bibSummary.editor.usrname"> ... </span>
+ </div>
+ </div>
+ </div>
+ <div class="row pt-2">
+ <div class="col-lg-12">
+ <div class="float-right">
+ <span>
+ <button (click)="placeHold()"
+ class="btn btn-sm btn-success label-with-material-icon small-text-1">
+ <span class="material-icons">check</span>
+ <span i18n>Place Hold</span>
+ </button>
+ </span>
+ <span>
+ <button (click)="addToList()"
+ class="btn btn-sm btn-info label-with-material-icon small-text-1">
+ <span class="material-icons">playlist_add_check</span>
+ <span i18n>Add to List</span>
+ </button>
+ </span>
+ </div>
+ </div>
+ </div>
+ </div><!-- col -->
+ </div><!-- row -->
+ </div><!-- card-body -->
+</div><!-- card -->
+
--- /dev/null
+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});
+ }
+
+}
+
+
--- /dev/null
+
+<div id="staff-catalog-results-container" *ngIf="searchIsDone()">
+ <div class="row">
+ <div class="col-lg-2"><!--match pagination margin-->
+ <h3 i18n>Search Results ({{searchContext.result.count}})</h3>
+ </div>
+ <div class="col-lg-1"></div>
+ <div class="col-lg-9">
+ <div class="float-right">
+ <eg-catalog-result-pagination></eg-catalog-result-pagination>
+ </div>
+ </div>
+ </div>
+ <div class="row mt-2">
+ <div class="col-lg-2">
+ <eg-catalog-result-facets></eg-catalog-result-facets>
+ </div>
+ <div class="col-lg-10">
+ <div *ngIf="searchContext.result">
+ <div *ngFor="let bibSummary of searchContext.result.records; let idx = index">
+ <div *ngIf="bibSummary">
+ <eg-catalog-result-record [bibSummary]="bibSummary" [index]="idx">
+ </eg-catalog-result-record>
+ </div>
+ </div>
+ </div>
+ </div>
+ </div>
+</div>
+
--- /dev/null
+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;
+ }
+
+}
+
+
--- /dev/null
+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 {}
--- /dev/null
+
+/* 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);
+}
--- /dev/null
+<!--
+TODO focus search input
+-->
+<div id='staffcat-search-form' class='pb-2 mb-3'>
+ <div class="row"
+ *ngFor="let q of searchContext.query; let idx = index; trackBy:trackByIdx">
+ <div class="col-lg-9 d-flex">
+ <div class="flex-1">
+ <div *ngIf="idx == 0">
+ <select class="form-control" [(ngModel)]="searchContext.format">
+ <option value=''>All Formats</option>
+ <option *ngFor="let fmt of ccvmMap.search_format"
+ value="{{fmt.code()}}">{{fmt.value()}}</option>
+ </select>
+ </div>
+ <div *ngIf="idx > 0">
+ <select class="form-control"
+ [(ngModel)]="searchContext.joinOp[idx]">
+ <option value='&&'>And</option>
+ <option value='||'>Or</option>
+ </select>
+ </div>
+ </div>
+ <div class="flex-1 pl-1">
+ <select class="form-control"
+ [(ngModel)]="searchContext.fieldClass[idx]">
+ <option value='keyword'>Keyword</option>
+ <option value='title'>Title</option>
+ <option value='jtitle'>Journal Title</option>
+ <option value='author'>Author</option>
+ <option value='subject'>Subject</option>
+ <option value='series'>Series</option>
+ </select>
+ </div>
+ <div class="flex-1 pl-1">
+ <select class="form-control"
+ [(ngModel)]="searchContext.matchOp[idx]">
+ <option value='contains'>Contains</option>
+ <option value='nocontains'>Does not contain</option>
+ <option value='phrase'>Contains phrase</option>
+ <option value='exact'>Matches exactly</option>
+ <option value='starts'>Starts with</option>
+ </select>
+ </div>
+ <div class="flex-2 pl-1">
+ <div class="form-group">
+ <div *ngIf="idx == 0">
+ <input type="text" class="form-control"
+ id='first-query-input'
+ [(ngModel)]="searchContext.query[idx]"
+ (keyup)="checkEnter($event)"
+ placeholder="Query..."/>
+ </div>
+ <div *ngIf="idx > 0">
+ <input type="text" class="form-control"
+ [(ngModel)]="searchContext.query[idx]"
+ (keyup)="checkEnter($event)"
+ placeholder="Query..."/>
+ </div>
+ </div>
+ </div>
+ <div class="flex-1 pl-1">
+ <button class="btn btn-sm material-icon-button"
+ (click)="addSearchRow(idx + 1)">
+ <span class="material-icons">add_circle_outline</span>
+ </button>
+ <button class="btn btn-sm material-icon-button"
+ [disabled]="searchContext.query.length < 2"
+ (click)="delSearchRow(idx)">
+ <span class="material-icons">remove_circle_outline</span>
+ </button>
+ </div>
+ </div><!-- col -->
+ <div class="col-lg-3">
+ <div *ngIf="idx == 0" class="float-right">
+ <button class="btn btn-success" type="button"
+ [disabled]="searchIsActive()"
+ (click)="searchContext.pager.offset=0;searchByForm()">
+ Search
+ </button>
+ <button class="btn btn-warning" type="button"
+ [disabled]="searchIsActive()"
+ (click)="searchContext.reset()">
+ Clear Form
+ </button>
+ <button class="btn btn-outline-secondary" type="button"
+ *ngIf="!showAdvanced()"
+ [disabled]="searchIsActive()"
+ (click)="showAdvancedSearch=true">
+ More Filters
+ </button>
+ <button class="btn btn-outline-secondary" type="button"
+ *ngIf="showAdvanced()"
+ (click)="showAdvancedSearch=false">
+ Hide Filters
+ </button>
+ </div>
+ </div>
+ </div><!-- row -->
+
+ <div class="row">
+ <div class="col-lg-9 d-flex">
+ <div class="flex-1">
+ <eg-org-select
+ (onChange)="orgOnChange($event)"
+ [initialOrg]="searchContext.searchOrg"
+ [placeholder]="'Library'" >
+ </eg-org-select>
+ </div>
+ <div class="flex-3 pl-1">
+ <select class="form-control" [(ngModel)]="searchContext.sort">
+ <option value='' i18n>Sort by Relevance</option>
+ <optgroup label="Sort by Title" i18n-label>
+ <option value='titlesort' i18n>Title: A to Z</option>
+ <option value='titlesort.descending' i18n>Title: Z to A</option>
+ </optgroup>
+ <optgroup label="Sort by Author" i18n-label>
+ <option value='authorsort' i18n>Author: A to Z</option>
+ <option value='authorsort.descending' i18n>Author: Z to A</option>
+ </optgroup>
+ <optgroup label="Sort by Publication Date" i18n-label>
+ <option value='pubdate' i18n>Date: A to Z</option>
+ <option value='pubdate.descending' i18n>Date: Z to A</option>
+ </optgroup>
+ <optgroup label="Sort by Popularity" i18n-label>
+ <option value='popularity' i18n>Most Popular</option>
+ <option value='poprel' i18n>Popularity Adjusted Relevance</option>
+ </optgroup>
+ </select>
+ </div>
+ <div class="flex-2 pl-2 align-self-end">
+ <div class="checkbox">
+ <label>
+ <input type="checkbox" [(ngModel)]="searchContext.available"/>
+ <span i18n>Limit to Available</span>
+ </label>
+ </div>
+ </div>
+ <div class="flex-4 pl-2 align-self-end">
+ <div class="checkbox">
+ <label>
+ <input type="checkbox" [(ngModel)]="searchContext.global"/>
+ <span i18n>Show Results from All Libraries</span>
+ </label>
+ </div>
+ </div>
+ <div class="flex-2 pl-1">
+ <!-- alignment -->
+ </div>
+ </div>
+ <div class="col-lg-3">
+ <div *ngIf="searchIsActive()">
+ <div class="progress">
+ <div class="progress-bar progress-bar-striped active w-100"
+ role="progressbar" aria-valuenow="100"
+ aria-valuemin="0" aria-valuemax="100">
+ <span class="sr-only" i18n>Searching..</span>
+ </div>
+ </div>
+ </div>
+ </div>
+ </div>
+ <div class="row pt-2" *ngIf="showAdvanced()">
+ <div class="col-lg-2">
+ <select class="form-control" multiple="true"
+ [(ngModel)]="searchContext.ccvmFilters.item_type">
+ <option value='' i18n>All Item Types</option>
+ <option *ngFor="let itemType of ccvmMap.item_type"
+ value="{{itemType.code()}}">{{itemType.value()}}</option>
+ </select>
+ </div>
+ <div class="col-lg-2">
+ <select class="form-control" multiple="true"
+ [(ngModel)]="searchContext.ccvmFilters.item_form">
+ <option value='' i18n>All Item Forms</option>
+ <option *ngFor="let itemForm of ccvmMap.item_form"
+ value="{{itemForm.code()}}">{{itemForm.value()}}</option>
+ </select>
+ </div>
+ <div class="col-lg-2">
+ <select class="form-control"
+ [(ngModel)]="searchContext.ccvmFilters.item_lang" multiple="true">
+ <option value='' i18n>All Languages</option>
+ <option *ngFor="let lang of ccvmMap.item_lang"
+ value="{{lang.code()}}">{{lang.value()}}</option>
+ </select>
+ </div>
+ <div class="col-lg-2">
+ <select class="form-control"
+ [(ngModel)]="searchContext.ccvmFilters.audience" multiple="true">
+ <option value='' i18n>All Audiences</option>
+ <option *ngFor="let audience of ccvmMap.audience"
+ value="{{audience.code()}}">{{audience.value()}}</option>
+ </select>
+ </div>
+ </div>
+ <div class="row pt-2" *ngIf="showAdvanced()">
+ <div class="col-lg-2">
+ <select class="form-control"
+ [(ngModel)]="searchContext.ccvmFilters.vr_format" multiple="true">
+ <option value='' i18n>All Video Formats</option>
+ <option *ngFor="let vrFormat of ccvmMap.vr_format"
+ value="{{vrFormat.code()}}">{{vrFormat.value()}}</option>
+ </select>
+ </div>
+ <div class="col-lg-2">
+ <select class="form-control"
+ [(ngModel)]="searchContext.ccvmFilters.bib_level" multiple="true">
+ <option value='' i18n>All Bib Levels</option>
+ <option *ngFor="let bibLevel of ccvmMap.bib_level"
+ value="{{bibLevel.code()}}">{{bibLevel.value()}}</option>
+ </select>
+ </div>
+ <div class="col-lg-2">
+ <select class="form-control"
+ [(ngModel)]="searchContext.ccvmFilters.lit_form" multiple="true">
+ <option value='' i18n>All Literary Forms</option>
+ <option *ngFor="let litForm of ccvmMap.lit_form"
+ value="{{litForm.code()}}">{{litForm.value()}}</option>
+ </select>
+ </div>
+ <div class="col-lg-2">
+ <i>Copy location filter goes here...</i>
+ </div>
+ </div>
+</div>
+
--- /dev/null
+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;
+ }
+
+}
+
+
--- /dev/null
+
+<eg-staff-banner bannerText="Search for Patron by Barcode" i18n-bannerText>
+</eg-staff-banner>
+
+<div class="col-lg-4">
+ <div class="input-group">
+ <div class="input-group-prepend">
+ <span class="input-group-text" i18n>Barcode:</span>
+ </div>
+ <input type='text' id='barcode-search-input' class="form-control"
+ placeholder="Barcode" i18n-placeholder [ngModel]='barcode'/>
+ <div class="input-group-append">
+ <button class="btn btn-outline-secondary"
+ (click)="findUser()" i18n>Submit</button>
+ </div>
+ </div>
+</div>
+
+
--- /dev/null
+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);
+ }
+}
+
+
--- /dev/null
+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 {}
+
--- /dev/null
+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 {}
--- /dev/null
+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 {}
--- /dev/null
+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 {}
--- /dev/null
+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
+ ]
+ };
+ }
+}
+
--- /dev/null
+<div class="container">
+ <div class="col-lg-6 offset-lg-3">
+ <fieldset>
+ <legend class="mb-0" i18n>Sign In</legend>
+ <hr class="mt-1"/>
+ <form (ngSubmit)="handleSubmit()" #loginForm="ngForm" class="form-validated">
+
+ <div class="form-group row">
+ <label class="col-lg-4 text-right font-weight-bold" for="username" i18n>Username</label>
+ <input
+ type="text"
+ class="form-control col-lg-8"
+ id="username"
+ name="username"
+ required
+ autocomplete="username"
+ i18n-placeholder
+ placeholder="Username"
+ [(ngModel)]="args.username"/>
+ </div>
+
+ <div class="form-group row">
+ <label class="col-lg-4 text-right font-weight-bold" for="password" i18n>Password</label>
+ <input
+ type="password"
+ class="form-control col-lg-8"
+ id="password"
+ name="password"
+ required
+ autocomplete="current-password"
+ i18n-placeholder
+ placeholder="Password"
+ [(ngModel)]="args.password"/>
+ </div>
+
+ <div class="form-group row" *ngIf="workstations && workstations.length">
+ <label class="col-lg-4 text-right font-weight-bold" for="workstation" i18n>Workstation</label>
+ <select
+ class="form-control col-lg-8"
+ id="workstation"
+ name="workstation"
+ required
+ [(ngModel)]="args.workstation">
+ <option *ngFor="let ws of workstations" [value]="ws.name">
+ {{ws.name}}
+ </option>
+ </select>
+ </div>
+
+ <div class="row">
+ <div class="col-lg-8 offset-lg-4 pl-0">
+ <button type="submit" class="btn btn-outline-dark" i18n>Sign in</button>
+ </div>
+ </div>
+ </form>
+ </fieldset>
+ </div>
+</div>
--- /dev/null
+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.
+ }
+ );
+ }
+}
+
+
+
--- /dev/null
+/* 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;
+}
+
--- /dev/null
+<div id="staff-navbar" class="navbar fixed-top navbar-expand navbar-default">
+ <div class="collapse navbar-collapse">
+ <div class="navbar-nav">
+ <div class="nav-item">
+ <a i18n class="nav-link with-material-icon"
+ routerLink="/staff/splash"
+ egAccessKey keyCtx="navbar"
+ keySpec="alt+h" i18n-keySpec
+ keyDesc="Navigate Home" i18n-keyDesc>
+ <span class="material-icons">home</span>
+ </a>
+ </div>
+ </div>
+
+ <div class="navbar-nav">
+ <div ngbDropdown class="nav-item dropdown">
+ <a ngbDropdownToggle i18n class="nav-link dropdown-toggle">
+ Search
+ </a>
+ <div class="dropdown-menu" ngbDropdownMenu>
+ <a class="dropdown-item" href="/eg/staff/circ/patron/search">
+ <span class="material-icons">person</span>
+ <span i18n>Search for Patrons</span>
+ </a>
+ <a class="dropdown-item" href="/eg/staff/cat/item/search">
+ <span class="material-icons">assignment</span>
+ <span i18n>Search for Copies by Barcode</span>
+ </a>
+ <a class="dropdown-item" routerLink="/staff/catalog/search"
+ egAccessKey keyCtx="navbar"
+ keySpec="alt+c" i18n-keySpec
+ keyDesc="Navigate To Catalog" i18n-keyDesc>
+ <span class="material-icons">search</span>
+ <span i18n>Search the Catalog</span>
+ </a>
+ </div>
+ </div>
+ </div>
+
+ <div class="navbar-nav">
+ <div ngbDropdown class="nav-item dropdown">
+ <a ngbDropdownToggle class="nav-link dropdown-toggle">
+ <span i18n>Circulation</span>
+ </a>
+ <div class="dropdown-menu" ngbDropdownMenu>
+ <a class="dropdown-item" href="/eg/staff/circ/patron/bcsearch">
+ <span class="material-icons">trending_up</span>
+ <span i18n>Check Out</span>
+ </a>
+ <a class="dropdown-item" href="/eg/staff/circ/checkin/checkin">
+ <span class="material-icons">trending_down</span>
+ <span i18n>Check In</span>
+ </a>
+ <a class="dropdown-item" href="/eg/staff/circ/checkin/capture">
+ <span class="material-icons">pin_drop</span>
+ <span i18n>Capture Holds</span>
+ </a>
+ <a class="dropdown-item" href="/eg/staff/circ/holds/pull">
+ <span class="material-icons">view_list</span>
+ <span i18n>Pull List for Hold Requests</span>
+ </a>
+ <a class="dropdown-item" href="/eg/staff/circ/renew/renew">
+ <span class="material-icons">autorenew</span>
+ <span i18n>Renew Items</span>
+ </a>
+ <a class="dropdown-item" href="/eg/staff/circ/patron/register">
+ <span class="material-icons">person_add</span>
+ <span i18n>Register Patron</span>
+ </a>
+ <a class="dropdown-item" href="/eg/staff/circ/patron/last">
+ <span class="material-icons">redo</span>
+ <span i18n>Retrieve Last Patron</span>
+ </a>
+ <a class="dropdown-item" href="/eg/staff/circ/patron/search?show_recent=1">
+ <span class="material-icons">redo</span>
+ <span i18n>Retrieve Recent Patrons</span>
+ </a>
+ <a class="dropdown-item" href="/eg/staff/circ/patron/pending/list">
+ <span class="material-icons">thumb_up</span>
+ <span i18n>Pending Patrons</span>
+ </a>
+ <a class="dropdown-item" href="/eg/staff/circ/patron/bucket/view">
+ <span class="material-icons">list</span>
+ <span i18n>User Buckets</span>
+ </a>
+ <div class="dropdown-divider"></div>
+ <a class="dropdown-item" href="/eg/staff/circ/patron/credentials">
+ <span class="material-icons">check_circle</span>
+ <span i18n>Verify Credentials</span>
+ </a>
+ <a class="dropdown-item" href="/eg/staff/circ/in_house_use/index">
+ <span class="material-icons">playlist_add</span>
+ <span i18n>Record In-House Use</span>
+ </a>
+ <a class="dropdown-item" href="/eg/staff/circ/holds/shelf">
+ <span class="material-icons">format_list_bulleted</span>
+ <span i18n>Holds Shelf</span>
+ </a>
+ <div class="dropdown-divider"></div>
+ <a class="dropdown-item" href="/eg/staff/cat/item/replace_barcode/index">
+ <span class="material-icons">library_books</span>
+ <span i18n>Replace Barcode</span>
+ </a>
+ <a class="dropdown-item" href="/eg/staff/cat/item/search">
+ <span class="material-icons">question_answer</span>
+ <span i18n>Item Status</span>
+ </a>
+ <a class="dropdown-item" href="/eg/staff/cat/item/missing_pieces">
+ <span class="material-icons">grid_on</span>
+ <span i18n>Scan Item as Missing Pieces</span>
+ </a>
+ <div class="dropdown-divider"></div>
+ <a class="dropdown-item" href="/eg/staff/cat/item/missing_pieces">
+ <span class="material-icons">redo</span>
+ <span i18n>Reprint Last Receipt</span>
+ </a>
+ <div class="dropdown-divider"></div>
+ <a class="dropdown-item" href="/eg/staff/offline-interface">
+ <span class="material-icons">signal_wifi_off</span>
+ <span i18n>Offline Circulation</span>
+ </a>
+ </div>
+ </div>
+ </div>
+
+
+ <div class="navbar-nav">
+ <div ngbDropdown class="nav-item dropdown">
+ <a ngbDropdownToggle i18n class="nav-link dropdown-toggle">
+ Cataloging
+ </a>
+ <div class="dropdown-menu" ngbDropdownMenu>
+ <a class="dropdown-item"
+ routerLink="/staff/catalog/search">
+ <span class="material-icons">search</span>
+ <span i18n>Search the Catalog</span>
+ </a>
+ </div>
+ </div>
+ </div>
+
+ <div class="navbar-nav">
+ <div ngbDropdown class="nav-item dropdown">
+ <a ngbDropdownToggle i18n class="nav-link dropdown-toggle">
+ Acquisitions
+ </a>
+ <div class="dropdown-menu" ngbDropdownMenu>
+ <a class="dropdown-item"
+ routerLink="/staff/catalog/search">
+ <span class="material-icons">search</span>
+ <span i18n>TODO</span>
+ </a>
+ </div>
+ </div>
+ </div>
+
+ <div class="navbar-nav">
+ <div ngbDropdown class="nav-item dropdown">
+ <a ngbDropdownToggle i18n class="nav-link dropdown-toggle">
+ Booking
+ </a>
+ <div class="dropdown-menu" ngbDropdownMenu>
+ <a class="dropdown-item"
+ routerLink="/staff/catalog/search">
+ <span class="material-icons">search</span>
+ <span i18n>TODO</span>
+ </a>
+ </div>
+ </div>
+ </div>
+
+ <div class="navbar-nav">
+ <div ngbDropdown class="nav-item dropdown">
+ <a ngbDropdownToggle i18n class="nav-link dropdown-toggle">
+ Administration
+ </a>
+ <div class="dropdown-menu" ngbDropdownMenu>
+ <a class="dropdown-item"
+ routerLink="/staff/admin/workstation/workstations/manage">
+ <span class="material-icons">computer</span>
+ <span i18n>Registered Workstations</span>
+ </a>
+ </div>
+ </div>
+ </div>
+
+
+ <div class="navbar-nav mr-auto"></div>
+ <div class="navbar-nav" *ngIf="user()">
+ <span i18n>{{user()}} @ {{workstation()}}</span>
+ </div>
+ <div class="navbar-nav" *ngIf="user()">
+ <div ngbDropdown class="nav-item dropdown" placement="bottom-right">
+ <a ngbDropdownToggle i18n
+ i18n-title
+ title="Log out and more..."
+ class="nav-link dropdown-toggle no-caret with-material-icon">
+ <i class="material-icons">list</i>
+ </a>
+ <div class="dropdown-menu" ngbDropdownMenu>
+ <eg-op-change #navOpChange
+ i18n-failMessage
+ i18n-successMessage
+ failMessage="Operator Change Failed"
+ successMessage="Operator Change Succeeded">
+ </eg-op-change>
+ <a class="dropdown-item" *ngIf="!opChangeActive()"
+ (click)="navOpChange.open()">
+ <span class="material-icons">transform</span>
+ <span i18n>Change Operator</span>
+ </a>
+ <a *ngIf="opChangeActive()" class="dropdown-item"
+ (click)="navOpChange.restore()">
+ <span class="material-icons">transform</span>
+ <span i18n>Restore Operator</span>
+ </a>
+ <a class="dropdown-item" (click)="logout()">
+ <span class="material-icons">lock_outline</span>
+ <span i18n>Logout</span>
+ </a>
+ <a class="dropdown-item" href="/eg/staff/about">
+ <span class="material-icons">info_outline</span>
+ <span i18n>About</span>
+ </a>
+ </div>
+ </div>
+ </div>
+ </div>
+</div>
+
--- /dev/null
+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']);
+ }
+}
+
+
--- /dev/null
+import {Injectable} from '@angular/core';
+import {Location} from '@angular/common';
+import {Observable, Observer} from 'rxjs/Rx';
+import {Router, Resolve, RouterStateSnapshot,
+ ActivatedRoute, ActivatedRouteSnapshot} from '@angular/router';
+import {EgStoreService} from '@eg/core/store.service';
+import {EgNetService} from '@eg/core/net.service';
+import {EgAuthService, EgAuthWsState} from '@eg/core/auth.service';
+import {EgPermService} from '@eg/core/perm.service';
+
+const LOGIN_PATH = '/staff/login';
+const WS_MANAGE_PATH = '/staff/admin/workstation/workstations/manage';
+
+/**
+ * Load data used by all staff modules.
+ */
+@Injectable()
+export class EgStaffResolver implements Resolve<Observable<any>> {
+
+ // Tracks the primary resolve observable.
+ observer: Observer<any>;
+
+ constructor(
+ private router: Router,
+ private route: ActivatedRoute,
+ private ngLocation: Location,
+ private store: EgStoreService,
+ private net: EgNetService,
+ private auth: EgAuthService,
+ private perm: EgPermService,
+ ) {}
+
+ resolve(
+ route: ActivatedRouteSnapshot,
+ state: RouterStateSnapshot): Observable<any> {
+
+ console.debug('EgStaffResolver:resolve()');
+
+ // Staff cookies stay in /$base/staff/
+ // NOTE: storing session data at '/' so it can be shared by
+ // Angularjs apps.
+ this.store.loginSessionBasePath = '/';
+ // ^-- = this.ngLocation.prepareExternalUrl('/staff');
+
+ // Not sure how to get the path without params... using this for now.
+ let path = state.url.split('?')[0]
+ if (path == '/staff/login') return Observable.of(true);
+
+ let observable: Observable<any>
+ = Observable.create(o => this.observer = o);
+
+ this.auth.testAuthToken().then(
+ tokenOk => {
+ console.debug('EgStaffResolver: authtoken verified');
+ this.confirmStaffPerms().then(
+ hasPerms => {
+ this.auth.verifyWorkstation().then(
+ wsOk => {
+ this.loadStartupData()
+ .then(ok => this.observer.complete())
+ },
+ wsNotOk => this.handleInvalidWorkstation(path)
+ );
+ },
+ hasNotPerms => {
+ this.observer.error(
+ 'User does not have staff permissions');
+ }
+ );
+ },
+ tokenNotOk => this.handleInvalidToken(state)
+ );
+
+ return observable;
+ }
+
+
+ // Confirm the user has the STAFF_LOGIN permission anywhere before
+ // allowing the staff sub-tree to load. This will prevent users
+ // with valid, non-staff authtokens from attempting to connect and
+ // subsequently getting redirected to the workstation admin page
+ // (since they won't have a valid WS either).
+ confirmStaffPerms(): Promise<any> {
+ return new Promise((resolve, reject) => {
+ this.perm.hasWorkPermAt(['STAFF_LOGIN']).then(
+ permMap => {
+ if (permMap.STAFF_LOGIN.length) {
+ resolve('perm check OK');
+ } else {
+ reject('perm check faield');
+ }
+ }
+ );
+ });
+ }
+
+
+ // A page that's not the login page was requested without a
+ // valid auth token. Send the caller back to the login page.
+ handleInvalidToken(state: RouterStateSnapshot): void {
+ console.debug('EgStaffResolver: authtoken is not valid');
+ this.auth.redirectUrl = state.url;
+ this.router.navigate([LOGIN_PATH]);
+ this.observer.error('invalid or no auth token');
+ }
+
+ handleInvalidWorkstation(path: string): void {
+
+ if (path.startsWith(WS_MANAGE_PATH)) {
+ // user is navigating to the WS admin page.
+ this.observer.complete();
+ } else {
+ this.router.navigate([WS_MANAGE_PATH]);
+ this.observer.error(`Auth session linked to no
+ workstation or a workstation unknown to this browser`);
+ }
+ }
+
+ /**
+ * Fetches data common to all staff interfaces.
+ */
+ loadStartupData(): Promise<void> {
+ console.debug('EgStaffResolver:loadStartupData()');
+ return Promise.resolve();
+ }
+}
+
--- /dev/null
+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 {}
+
--- /dev/null
+Place for experimenting with code.
--- /dev/null
+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 {}
--- /dev/null
+
+<eg-staff-banner bannerText="Sandbox" i18n-bannerText>
+</eg-staff-banner>
+
+<!-- FM Editor Experiments ----------------------------- -->
+<div class="row mb-3">
+ <ng-template #descriptionTemplate
+ let-field="field" let-record="record" let-hello="hello">
+ <!-- example custom template for editing the 'description' field -->
+ <textarea
+ placeholder="{{hello}}"
+ class="form-control"
+ name="{{field.name}}"
+ [readonly]="field.readOnly"
+ [required]="field.isRequired()"
+ [ngModel]="record[field.name]()"
+ (ngModelChange)="record[field.name]($event)">
+ </textarea>
+ </ng-template>
+ <fm-record-editor #fmRecordEditor
+ idlClass="cmrcfld" mode="create"
+ [customFieldTemplates]="{description:{template:descriptionTemplate,context:{'hello':'goodbye'}}}"
+ recordId="1" orgDefaultAllowed="owner">
+ </fm-record-editor>
+ <button class="btn btn-dark" (click)="fmRecordEditor.open({size:'lg'})">
+ Fm Record Editor
+ </button>
+</div>
+<!-- / FM Editor Experiments ----------------------------- -->
+
+<!-- Progress Dialog Experiments ----------------------------- -->
+<div class="row mb-3">
+ <eg-progress-dialog #progressDialog>
+ </eg-progress-dialog>
+ <button class="btn btn-light" (click)="showProgress()">Test Progress Dialog</button>
+</div>
+<!-- /Progress Dialog Experiments ----------------------------- -->
+
+<!-- eg toast -->
+<div class="row mb-3">
+ <button class="btn btn-info" (click)="testToast()">Test Toast Message</button>
+</div>
+
+<!-- eg strings -->
+<!--
+<div class="row mb-3">
+ <eg-string #helloString text="Hello, {{name}}" i18n-text></eg-string>
+ <button class="btn btn-success" (click)="testStrings()">Test Strings</button>
+</div>
+-->
+
+<div class="row mb-3">
+ <ng-template #helloStrTmpl let-name="name" i18n>Hello, {{name}}</ng-template>
+ <!--
+ <eg-string #helloStr key="helloKey" [template]="helloStrTmpl"></eg-string>
+ -->
+ <eg-string key="staff.sandbox.test" [template]="helloStrTmpl"></eg-string>
+ <button class="btn btn-success" (click)="testStrings()">Test Strings</button>
+</div>
+
+
+<!-- grid stuff -->
+
+
+<eg-grid #cbtGrid idlClass="cbt" [dataSource]="btSource">
+</eg-grid>
+
+<!--
+<eg-grid #testGrid [dataSource]="gridDataSource">
+ <eg-grid-column name="name" label="Name" i18n-label></eg-grid-column>
+ <eg-grid-column name="state" label="State" i18n-label></eg-grid-column>
+</eg-grid>
+-->
+
+
+
+
+
+
--- /dev/null
+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);
+ }
+}
+
--- /dev/null
+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 {
+
+}
--- /dev/null
+Classes, services, and components shared in the staff app.
--- /dev/null
+
+<div class='eg-bib-summary card tight-card w-100' *ngIf="summary">
+ <div class="card-header d-flex">
+ <div class="font-weight-bold">
+ Record Summary
+ </div>
+ <div class="flex-1"></div>
+ <div>
+ <a class="with-material-icon no-href text-primary"
+ title="Show More" i18n-title
+ *ngIf="!expandDisplay" (click)="expandDisplay=true">
+ <span class="material-icons">expand_more</span>
+ </a>
+ <a class="with-material-icon no-href text-primary"
+ title="Show Less" i18n-title
+ *ngIf="expandDisplay" (click)="expandDisplay=false">
+ <span class="material-icons">expand_less</span>
+ </a>
+ </div>
+ </div>
+ <div class="card-body">
+ <ul class="list-group list-group-flush">
+ <li class="list-group-item">
+ <div class="d-flex">
+ <div class="flex-1 font-weight-bold" i18n>Title:</div>
+ <div class="flex-3">{{summary.title}}</div>
+ <div class="flex-1 font-weight-bold pl-1" i18n>Edition:</div>
+ <div class="flex-1">{{summary.edition}}</div>
+ <div class="flex-1 font-weight-bold" i18n>TCN:</div>
+ <div class="flex-1">{{summary.tcn_value}}</div>
+ <div class="flex-1 font-weight-bold pl-1" i18n>Created By:</div>
+ <div class="flex-1" *ngIf="summary.creator.usrname">
+ {{summary.creator.usrname()}}
+ </div>
+ </div>
+ </li>
+ <li class="list-group-item" *ngIf="expandDisplay">
+ <div class="d-flex">
+ <div class="flex-1 font-weight-bold" i18n>Author:</div>
+ <div class="flex-3">{{summary.author}}</div>
+ <div class="flex-1 font-weight-bold pl-1" i18n>Pubdate:</div>
+ <div class="flex-1">{{summary.pubdate}}</div>
+ <div class="flex-1 font-weight-bold" i18n>Database ID:</div>
+ <div class="flex-1">{{summary.id}}</div>
+ <div class="flex-1 font-weight-bold pl-1" i18n>Last Edited By:</div>
+ <div class="flex-1" *ngIf="summary.editor.usrname">
+ {{summary.editor.usrname()}}
+ </div>
+ </div>
+ </li>
+ <li class="list-group-item" *ngIf="expandDisplay">
+ <div class="d-flex">
+ <div class="flex-1 font-weight-bold" i18n>Bib Call #:</div>
+ <div class="flex-3">{{summary.callNumber}}</div>
+ <div class="flex-1 font-weight-bold" i18n>Record Owner:</div>
+ <div class="flex-1">TODO</div>
+ <div class="flex-1 font-weight-bold pl-1" i18n>Created On:</div>
+ <div class="flex-1">{{summary.create_date | date:'shortDate'}}</div>
+ <div class="flex-1 font-weight-bold pl-1" i18n>Last Edited On:</div>
+ <div class="flex-1">{{summary.edit_date | date:'shortDate'}}</div>
+ </div>
+ </li>
+ </ul>
+ </div>
+</div>
+
--- /dev/null
+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];
+ }
+ });
+ }
+}
+
+
--- /dev/null
+<ng-template #dialogContent>
+ <div class="modal-header bg-info">
+ <h4 class="modal-title" i18n>Change Operator</h4>
+ <button type="button" class="close"
+ i18n-aria-label aria-label="Close"
+ (click)="dismiss('cross_click')">
+ <span aria-hidden="true">×</span>
+ </button>
+ </div>
+ <div class="modal-body">
+ <form class="form-validated">
+ <div class="form-group row">
+ <label class="col-lg-4 text-right font-weight-bold" for="username" i18n>Username</label>
+ <input
+ type="text"
+ class="form-control col-lg-7"
+ id="username"
+ name="username"
+ required
+ (keyup)="checkEnter($event)"
+ autocomplete="username"
+ i18n-placeholder
+ placeholder="Username..."
+ [(ngModel)]="username"/>
+ </div>
+
+ <div class="form-group row">
+ <label class="col-lg-4 text-right font-weight-bold"
+ for="password" i18n>Password</label>
+ <input
+ type="password"
+ class="form-control col-lg-7"
+ id="password"
+ name="password"
+ required
+ (keyup)="checkEnter($event)"
+ autocomplete="current-password"
+ i18n-placeholder
+ placeholder="Password..."
+ [(ngModel)]="password"/>
+ </div>
+
+ <div class="form-group row">
+ <label class="col-lg-4 text-right font-weight-bold"
+ for="loginType" i18n>Login Type</label>
+ <select
+ class="form-control col-lg-7"
+ id="loginType"
+ name="loginType"
+ placeholder="Login Type..."
+ i18n-placeholder
+ required
+ [(ngModel)]="loginType">
+ <option value="temp" selected i18n>Temporary</option>
+ <option value="staff" i18n>Staff</option>
+ <option value="persist" i18n>Persistent</option>
+ </select>
+ </div>
+ </form>
+ </div>
+ <div class="modal-footer">
+ <button (click)="login()" class="btn btn-info" i18n>OK/Continue</button>
+ <button (click)="dismiss('canceled')" class="btn btn-warning ml-2" i18n>Cancel</button>
+ </div>
+</ng-template>
--- /dev/null
+import {Component, OnInit, Input, Renderer} from '@angular/core';
+import {EgToastService} from '@eg/share/toast/toast.service';
+import {EgAuthService} from '@eg/core/auth.service';
+import {EgDialogComponent} from '@eg/share/dialog/dialog.component';
+import {NgbModal} from '@ng-bootstrap/ng-bootstrap';
+
+@Component({
+ selector: 'eg-op-change',
+ templateUrl: 'op-change.component.html'
+})
+
+export class EgOpChangeComponent
+ extends EgDialogComponent implements OnInit {
+
+ @Input() username: string;
+ @Input() password: string;
+ @Input() loginType: string = 'temp';
+
+ @Input() successMessage: string;
+ @Input() failMessage: string;
+
+ constructor(
+ private modal: NgbModal, // required for passing to parent
+ private renderer: Renderer,
+ private toast: EgToastService,
+ private auth: EgAuthService) {
+ super(modal);
+ }
+
+ ngOnInit() {
+
+ // Focus the username any time the dialog is opened.
+ this.onOpen$.subscribe(
+ val => this.renderer.selectRootElement('#username').focus()
+ );
+ }
+
+ checkEnter($event: any): void {
+ if ($event.keyCode == 13)
+ this.login();
+ }
+
+ login(): Promise<any> {
+ if (!(this.username && this.password))
+ return Promise.reject('Missing Params');
+
+ return this.auth.login(
+ { username : this.username,
+ password : this.password,
+ workstation : this.auth.workstation(),
+ type : this.loginType
+ }, true // isOpChange
+ ).then(
+ ok => {
+ this.password = '';
+ this.username = '';
+
+ // Fetch the user object
+ this.auth.testAuthToken().then(
+ ok => {
+ this.close();
+ this.toast.success(this.successMessage);
+ }
+ );
+ },
+ notOk => {
+ this.password = '';
+ this.toast.danger(this.failMessage);
+ }
+ );
+ }
+
+ restore(): Promise<any> {
+ return this.auth.undoOpChange().then(
+ ok => this.toast.success(this.successMessage),
+ err => this.toast.danger(this.failMessage)
+ );
+ }
+}
+
+
--- /dev/null
+import {Component, OnInit, Input} from '@angular/core';
+
+@Component({
+ selector: 'eg-staff-banner',
+ template:
+ '<div class="lead alert alert-primary text-center pt-1 pb-1" role="alert">' +
+ '<span>{{bannerText}}</span>' +
+ '</div>'
+})
+
+export class EgStaffBannerComponent {
+ @Input() public bannerText: string;
+}
+
+
--- /dev/null
+
+
+<style>
+ /* TODO change BS color scheme so this isn't necessary */
+ .bg-evergreen {
+ background: -webkit-linear-gradient(#00593d, #007a54);
+ background-color: #007a54;
+ color: #fff;
+ }
+
+ /* Match the ang1 splash page */
+ .card-header {
+ color: #3c763d;
+ background-color: #dff0d8;
+ border-color: #d6e9c6;
+ }
+</style>
+
+<div class="container">
+
+ <!-- header icon -->
+ <div class="row mb-3">
+ <div class="col-lg-12 text-center">
+ <img src="/images/portal/logo.png"/>
+ </div>
+ </div>
+
+ <div class="row" id="splash-nav">
+ <div class="col-lg-4">
+ <div class="card">
+ <div class="card-header">
+ <div class="panel-title text-center">Circulation and Patrons</div>
+ </div>
+ <div class="card-body">
+ <div class="list-group">
+ <div class="list-group-item border-0 p-2">
+ <img src="/images/portal/forward.png"/>
+ <a href="/eg/staff/circ/patron/bcsearch">Check Out Items</a>
+ </div>
+ <div class="list-group-item border-0 p-2">
+ <img src="/images/portal/back.png"/>
+ <a href="/eg/staff/circ/checkin/index">Check In Items</a>
+ </div>
+ <div class="list-group-item border-0 p-2">
+ <img src="/images/portal/retreivepatron.png"/>
+ <a href="/eg/staff/circ/patron/search">Search For Patron By Name</a>
+ </div>
+ </div>
+ </div>
+ </div>
+ </div>
+
+ <div class="col-lg-4">
+ <div class="card">
+ <div class="card-header">
+ <div class="panel-title text-center">Item Search and Cataloging</div>
+ </div>
+ <div class="card-body">
+ <div class="list-group">
+ <div class="list-group-item border-0 p-2">
+ <div class="input-group">
+ <input type="text" class="form-control"
+ [(ngModel)]="catSearchQuery"
+ id='catalog-search-input'
+ (keyup)="checkEnter($event)"
+ i18n-placeholder placeholder="Search for...">
+ <span class="input-group-btn">
+ <button class="btn btn-outline-secondary"
+ (click)="searchCatalog()" type="button">
+ Search
+ </button>
+ </span>
+ <!--
+ <input focus-me="focus_search"
+ class="form-control" ng-model="cat_query" type="text"
+ ng-keypress="catalog_search($event)"
+ placeholder="Search catalog for..."/>
+ <button class='btn btn-light' ng-click="catalog_search()">
+ Search
+ </button>
+ -->
+ </div>
+ </div>
+ <div class="list-group-item border-0 p-2">
+ <img src="/images/portal/bucket.png"/>
+ <a href="/eg/staff/cat/bucket/record/">Record Buckets</a>
+ </div>
+ <div class="list-group-item border-0 p-2">
+ <img src="/images/portal/bucket.png"/>
+ <a href="/eg/staff/cat/bucket/copy/">Copy Buckets</a>
+ </div>
+ </div>
+ </div>
+ </div>
+ </div>
+
+ <div class="col-lg-4">
+ <div class="card">
+ <div class="card-header">
+ <div class="panel-title text-center">Administration</div>
+ </div>
+ <div class="card-body">
+ <div class="list-group">
+ <div class="list-group-item border-0 p-2">
+ <img src="/images/portal/helpdesk.png"/>
+ <a target="_top" href="http://docs.evergreen-ils.org/">
+ Evergreen Documentation
+ </a>
+ </div>
+ <div class="list-group-item border-0 p-2">
+ <img src="/images/portal/helpdesk.png"/>
+ <a target="_top" href="/eg/staff/admin/workstation/index">
+ Workstation Administration
+ </a>
+ </div>
+ <div class="list-group-item border-0 p-2">
+ <img src="/images/portal/reports.png"/>
+ <a target="_top" href="/eg/staff/reporter/legacy/main">
+ Reports
+ </a>
+ </div>
+ </div>
+ </div>
+ </div>
+ </div>
+ </div>
+</div>
+
--- /dev/null
+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}}
+ );
+ }
+}
+
+
--- /dev/null
+#staff-content-container {
+ width: 95%;
+ margin-top:56px;
+ padding-right: 10px;
+ padding-left: 10px;
+ margin-right: auto;
+ margin-left: auto;
+}
--- /dev/null
+<!-- top navigation bar -->
+<eg-staff-nav-bar></eg-staff-nav-bar>
+
+<div id='staff-content-container'>
+ <!-- page content -->
+ <router-outlet></router-outlet>
+</div>
+
+<!-- EgAccessKey Info Panel -->
+<eg-accesskey-info #egAccessKeyInfo></eg-accesskey-info>
+<a egAccessKey keyCtx="base"
+ keySpec="ctrl+h" i18n-keySpec
+ keyDesc="Display AccessKey Info Dialog" i18n-keyDesc
+ (click)="egAccessKeyInfo.open()">
+</a>
+
+<!-- global toast alerts -->
+<eg-toast></eg-toast>
+
--- /dev/null
+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;
+ */
+
+}
+
+
--- /dev/null
+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 {}
+
--- /dev/null
+<div class="jumbotron">
+ <h1 i18n class="display-3">Welcome to Webby</h1>
+ <p i18n class="lead">
+ If you see this page, you're probably in good shape...
+ </p>
+ <hr class="my-4"/>
+ <p i18n>
+ But maybe you meant to go to the
+ <a routerLink="/staff/splash">staff page</a>
+ </p>
+</div>
--- /dev/null
+import { Component, OnInit } from '@angular/core';
+
+@Component({
+ templateUrl : './welcome.component.html'
+})
+
+export class WelcomeComponent implements OnInit {
+
+ ngOnInit() {
+ }
+}
+
+
+
--- /dev/null
+export const environment = {
+ production: true
+};
--- /dev/null
+// 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
+};
--- /dev/null
+<!doctype html>
+<html lang="en">
+<head>
+ <meta charset="utf-8">
+ <title i18n="Page Title">AngEG</title>
+ <base href="/webby">
+
+ <meta name="viewport" content="width=device-width, initial-scale=1">
+ <link rel="icon" type="image/x-icon" href="favicon.ico">
+ <!-- todo -->
+ <!-- see self-hosting options https://google.github.io/material-design-icons/#icon-font-for-the-web -->
+ <link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet">
+ <!-- link to bootstrap manually for the time being. With
+ ng-bootstrap, we only need the CSS, not the JS -->
+ <!--
+ <link rel="stylesheet" crossorigin="anonymous"
+ href="https://maxcdn.bootstrapcdn.com/bootstrap/4.0.0/css/bootstrap.min.css"
+ integrity="sha384-Gn5384xqQ1aoWXA+058RXPxPg6fy4IWvTNh0E263XmFcJlSAwiGgFAW/dAiS6JXm">
+ -->
+ <!-- lnk to local copy for dev on the go -->
+ <link rel="stylesheet" crossorigin="anonymous"
+ href="/css/bootstrap.min.css"
+</head>
+<body>
+ <eg-root></eg-root>
+ <script src="/IDL2js"></script>
+ <script src="/js/dojo/opensrf/JSON_v1.js"></script>
+ <script src="/js/dojo/opensrf/opensrf.js"></script>
+ <script src="/js/dojo/opensrf/opensrf_ws.js"></script>
+</body>
+</html>
--- /dev/null
+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));
--- /dev/null
+/**
+ * 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';
--- /dev/null
+/* 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;
+}
+
--- /dev/null
+// 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();
--- /dev/null
+{
+ "extends": "../tsconfig.json",
+ "compilerOptions": {
+ "outDir": "../out-tsc/app",
+ "baseUrl": "./",
+ "module": "es2015",
+ "types": []
+ },
+ "exclude": [
+ "test.ts",
+ "**/*.spec.ts"
+ ]
+}
--- /dev/null
+{
+ "extends": "../tsconfig.json",
+ "compilerOptions": {
+ "outDir": "../out-tsc/spec",
+ "baseUrl": "./",
+ "module": "commonjs",
+ "target": "es5",
+ "types": [
+ "jasmine",
+ "node"
+ ]
+ },
+ "files": [
+ "test.ts"
+ ],
+ "include": [
+ "**/*.spec.ts",
+ "**/*.d.ts"
+ ]
+}
--- /dev/null
+/* SystemJS module definition */
+declare var module: NodeModule;
+interface NodeModule {
+ id: string;
+}
--- /dev/null
+{
+ "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"
+ ]
+ }
+}
--- /dev/null
+{
+ "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
+ }
+}
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;
--- /dev/null
+<!--
+Import the compiled Angular2 shell script.
+-->
+
+<script type="text/javascript"
+ src="[% ctx.media_prefix %]/js/ui/default/staff/ng2-shell/inline.bundle.js"></script>
+<script type="text/javascript"
+ src="[% ctx.media_prefix %]/js/ui/default/staff/ng2-shell/polyfills.bundle.js"></script>
+<script type="text/javascript"
+ src="[% ctx.media_prefix %]/js/ui/default/staff/ng2-shell/vendor.bundle.js"></script>
+<script type="text/javascript"
+ src="[% ctx.media_prefix %]/js/ui/default/staff/ng2-shell/main.bundle.js"></script>
+
<!doctype html>
[%- PROCESS 'staff/config.tt2' %]
<html lang="[% ctx.locale %]"
+ [%- IF NOT ctx.is_ang2_app %]
[%- IF ctx.page_app %] ng-app="[% ctx.page_app %]"[% END -%]
- [%- IF ctx.page_ctrl %] ng-controller="[% ctx.page_ctrl %]"[% END %]>
+ [%- IF ctx.page_ctrl %] ng-controller="[% ctx.page_ctrl %]"[% END %]
+ [% END %]>
<head>
+ [% IF ctx.is_ang2_app AND ctx.page_app %]
+ <script>
+ // tell ang2 what ang1 app to load
+ window.ang1PageApp = "[% ctx.page_app %]";
+ </script>
+ [% END %]
<!-- enables ng-cloak to be usable before angular has been able to fully load -->
<style type="text/css">
[ng\:cloak], [ng-cloak], [data-ng-cloak], [x-ng-cloak], .ng-cloak, .x-ng-cloak {
</style>
<!-- The page title changes with $rootScope.pageTitle,
defaulting to the static template page title. -->
- <title ng-cloak>{{pageTitle || "[% ctx.page_title %]"}}</title>
+
+ [% IF ctx.is_ang2_app %]
+ <!--
+ ang1 title service does not function within ang2.
+ Apply a default and let apps override via the ng2Title service.
+ -->
+ <title>[% ctx.page_title || l('Evergreen') %]</title>
+ [% ELSE %]
+ <title ng-cloak>{{pageTitle || "[% ctx.page_title %]"}}</title>
+ [% END %]
+
<base href="/eg/staff/">
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
# App-specific JS load commands go into an APP_JS block.
PROCESS APP_JS;
+
+ # Angular2 scripts must be imported after app-specific ang1 imports
+ IF ctx.is_ang2_app;
+ INCLUDE "staff/ang2_js.tt2";
+ END;
%]
<!-- content printed via the browser is inserted here for
WRAPPER "staff/base.tt2";
ctx.page_title = l("Check In");
ctx.page_app = "egCheckinApp";
+ ctx.is_ang2_app = 1;
%]
[% BLOCK APP_JS %]
</a>
</li>
<li>
+ <a href="/eg2/staff/catalog/search" target="_self">
+ <span class="glyphicon glyphicon-search"></span>
+ <span>[% l('Staff Catalog (Experimental)') %]</span>
+ </a>
+ </li>
+ <li>
<a href="./cat/bucket/record/view" target="_self">
<span class="glyphicon glyphicon-list-alt"></span>
[% l('Record Buckets') %]
* Manages checkin
*/
.controller('CheckinCtrl',
- ['$scope','$q','$window','$location', '$timeout','egCore','checkinSvc','egGridDataProvider','egCirc', 'egItem',
-function($scope , $q , $window , $location , $timeout , egCore , checkinSvc , egGridDataProvider , egCirc, itemSvc) {
+ ['$scope','$q','$window','$location', '$timeout','egCore',
+ 'checkinSvc','egGridDataProvider','egCirc','egItem','eg2Net',
+ 'eg2Store','ng2Title',
+function($scope , $q , $window , $location , $timeout , egCore ,
+ checkinSvc , egGridDataProvider , egCirc, itemSvc , eg2Net ,
+ eg2Store , ng2Title) {
+
+ // TODO: TESTING
+ eg2Net.request('open-ils.actor', 'opensrf.system.echo', 'Hello, Ang2')
+ .subscribe(function(res) {console.log('eg2Net returned ' + res)});
+
+ ng2Title.setTitle('Checkin'); // TODO: TESTING
+
+ var testDialog = angular.element(document.querySelector('testHello'));
+ //var testDialog = angular.element();
+ //console.log(testDialog);
+ //console.log(testDialog.controller());
+ console.log(testDialog.controller());
+ console.log(testDialog.controller('eg2HelloWorld'));
+ //testDialog.controller('eg2ConfirmDialog').open();
+ //testDialog.controller('egConfirmDialog').open();
$scope.focusMe = true;
$scope.checkins = checkinSvc.checkins;
$scope.grid_persist_key = $scope.is_capture ?
'circ.checkin.capture' : 'circ.checkin.checkin';
- egCore.hatch.getItem('circ.checkin.strict_barcode')
+ eg2Store.getItem('circ.checkin.strict_barcode')
.then(function(sb){ $scope.strict_barcode = sb });
egCore.org.settings([
// set modifiers from stored preferences
angular.forEach(modifiers, function(mod) {
- egCore.hatch.getItem('eg.circ.checkin.' + mod)
+ eg2Store.getItem('eg.circ.checkin.' + mod)
.then(function(val) { if (val) $scope.modifiers[mod] = true });
});
$scope.toggle_mod = function(mod) {
if ($scope.modifiers[mod]) {
$scope.modifiers[mod] = false;
- egCore.hatch.removeItem('eg.circ.checkin.' + mod);
+ eg2Store.removeItem('eg.circ.checkin.' + mod);
} else {
$scope.modifiers[mod] = true;
- egCore.hatch.setItem('eg.circ.checkin.' + mod, true);
+ eg2Store.setItem('eg.circ.checkin.' + mod, true);
}
}
}
}
- egCore.hatch.setItem('circ.checkin.strict_barcode', $scope.strict_barcode);
+ eg2Store.setItem('circ.checkin.strict_barcode', $scope.strict_barcode);
var options = {
check_barcode : $scope.strict_barcode,
no_precat_alert : $scope.modifiers.no_precat_alert,
service.addLoginSessionKey(key);
if (jsonified === undefined )
jsonified = JSON.stringify(value);
- $cookies.put(key, jsonified);
+ $cookies.put(key, jsonified, {path: '/'});
}
// Set the value for the given key.
service.removeLoginSessionItem = function(key) {
service.removeLoginSessionKey(key);
- $cookies.remove(key);
+ $cookies.remove(key, {path: '/'});
}
service.removeSessionItem = function(key) {