--- /dev/null
+{
+ "$schema": "./node_modules/@angular/cli/lib/config/schema.json",
+ "project": {
+ "name": "eg"
+ },
+ "apps": [
+ {
+ "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/webby">
+ FallbackResource /webby/index.html
+</Directory>
+---------------------------------------------------------------------
+
+=== Transpile + Deploy in --watch mode for Dev ===
+
+[source,sh]
+---------------------------------------------------------------------
+ng build --deploy-url /webby/ --base-href /webby/ --output-path ../web/webby/ --watch
+---------------------------------------------------------------------
--- /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.0.0",
+ "@angular/common": "^5.0.0",
+ "@angular/compiler": "^5.0.0",
+ "@angular/core": "^5.0.0",
+ "@angular/forms": "^5.0.0",
+ "@angular/http": "^5.0.0",
+ "@angular/platform-browser": "^5.0.0",
+ "@angular/platform-browser-dynamic": "^5.0.0",
+ "@angular/router": "^5.0.0",
+ "@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.2",
+ "zone.js": "^0.8.14"
+ },
+ "devDependencies": {
+ "@angular/cli": "1.5.1",
+ "@angular/compiler-cli": "^5.0.0",
+ "@angular/language-service": "^5.0.0",
+ "@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.4.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 router and a simple welcome page for
+ * users that end up here accidentally.
+ */
+import {BrowserModule} from '@angular/platform-browser';
+import {NgModule} from '@angular/core';
+import {Router} from '@angular/router'; // Debugging
+import {NgbModule} from '@ng-bootstrap/ng-bootstrap'; // ng-bootstrap
+import {CookieModule} from 'ngx-cookie'; // import CookieMonster
+
+import {EgBaseComponent} from './app.component';
+import {EgBaseRoutingModule} from './routing.module';
+import {WelcomeComponent} from './welcome.component';
+
+// Import and 'provide' globally required services.
+import {EgEventService} from '@eg/core/event';
+import {EgStoreService} from '@eg/core/store';
+import {EgIdlService} from '@eg/core/idl';
+import {EgNetService} from '@eg/core/net';
+import {EgAuthService} from '@eg/core/auth';
+import {EgPcrudService} from '@eg/core/pcrud';
+import {EgOrgService} from '@eg/core/org';
+
+@NgModule({
+ declarations: [
+ EgBaseComponent,
+ WelcomeComponent
+ ],
+ imports: [
+ EgBaseRoutingModule,
+ BrowserModule,
+ NgbModule.forRoot(),
+ CookieModule.forRoot()
+ ],
+ providers: [
+ EgEventService,
+ EgStoreService,
+ EgIdlService,
+ EgNetService,
+ EgAuthService,
+ EgPcrudService,
+ EgOrgService
+ ],
+ exports: [],
+ bootstrap: [EgBaseComponent]
+})
+
+export class EgBaseModule {
+ constructor(router: Router) {
+ /*
+ console.debug('Routes: ',
+ JSON.stringify(router.config, undefined, 2));
+ */
+ }
+}
--- /dev/null
+Core Angular services and assocated types/classes.
+
+Core services are imported and exported by the base module, which means
+they are automatically added as dependencies to ALL applications.
+
+1. Only add services here that are universally required!
+2. Avoid path navigation in the core services as paths will vary by application.
+
--- /dev/null
+/**
+ *
+ */
+import { Injectable, EventEmitter } from '@angular/core';
+import { Observable } from 'rxjs/Rx';
+import { EgNetService } from './net';
+import { EgEventService, EgEvent } from './event';
+import { EgIdlService, EgIdlObject } from './idl';
+import { EgStoreService } from './store';
+
+// Models a login instance.
+class EgAuthUser {
+ user: EgIdlObject;
+ 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 activeUser: EgAuthUser;
+
+ // opChangeUser refers to the user that has been superseded during
+ // an op-change event. This use will become the activeUser once
+ // again, when the op-change cycle has completed.
+ private opChangeUser: EgAuthUser;
+
+ workstationState: EgAuthWsState = EgAuthWsState.PENDING;
+
+ redirectUrl: string;
+
+ constructor(
+ private egEvt: EgEventService,
+ private net: EgNetService,
+ private store: EgStoreService
+ ) {}
+
+ // - Accessor functions alway refer to the active user.
+
+ user(): EgIdlObject {
+ return this.activeUser.user
+ };
+
+ // Workstation name.
+ workstation(): string {
+ return this.activeUser.workstation;
+ };
+
+ token(): string {
+ return this.activeUser ? this.activeUser.token : null;
+ };
+
+ authtime(): Number {
+ return this.activeUser.authtime
+ };
+
+ // NOTE: EgNetService emits an event if the auth session has expired.
+ testAuthToken(): Promise<any> {
+
+ 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 new Promise<any>( (resolve, reject) => {
+ this.net.request(
+ 'open-ils.auth',
+ 'open-ils.auth.session.retrieve', this.token()
+ ).subscribe(
+ user => {
+ // EgNetService interceps NO_SESSION events.
+ // We can only get here if the session is valid.
+ this.activeUser.user = user;
+ this.sessionPoll();
+ resolve();
+ },
+ err => { reject(); }
+ );
+ });
+ }
+
+ checkWorkstation(): void {
+ // TODO:
+ // Emits event on invalid workstation.
+ }
+
+ login(args: EgAuthLoginArgs, isOpChange?: boolean): Promise<void> {
+
+ return new Promise<void>((resolve, reject) => {
+ this.net.request('open-ils.auth', 'open-ils.auth.login', args)
+ .subscribe(res => {
+ this.handleLoginResponse(args, this.egEvt.parse(res), isOpChange)
+ .then(
+ ok => resolve(ok),
+ notOk => reject(notOk)
+ );
+ });
+ });
+ }
+
+ handleLoginResponse(
+ args: EgAuthLoginArgs, evt: EgEvent, isOpChange: boolean): Promise<void> {
+
+ switch (evt.textcode) {
+ case 'SUCCESS':
+ this.handleLoginOk(args, evt, isOpChange);
+ return Promise.resolve();
+
+ 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): void {
+
+ if (isOpChange) {
+ this.store.setLoginSessionItem('eg.auth.token.oc', this.token());
+ this.store.setLoginSessionItem('eg.auth.time.oc', this.authtime());
+ this.opChangeUser = this.activeUser;
+ }
+
+ 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());
+ }
+
+ undoOpChange(): Promise<any> {
+ if (this.opChangeUser) {
+ this.deleteSession();
+ this.activeUser = this.opChangeUser;
+ this.opChangeUser = null;
+ 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());
+ }
+ return this.testAuthToken();
+ }
+
+ sessionPoll(): void {
+ // TODO
+ }
+
+ // Resolves if login workstation matches a workstation known to this
+ // browser instance.
+ verifyWorkstation(): Promise<void> {
+ return new Promise((resolve, reject) => {
+
+ if (!this.user()) {
+ this.workstationState = EgAuthWsState.PENDING;
+ reject();
+ return;
+ }
+
+ if (!this.user().wsid()) {
+ this.workstationState = EgAuthWsState.NOT_USED;
+ reject();
+ return;
+ }
+
+ this.store.getItem('eg.workstation.all')
+ .then(workstations => {
+ if (!workstations) workstations = [];
+
+ let ws = workstations.filter(
+ w => {return w.id == this.user().wsid()})[0];
+
+ if (ws) {
+ this.activeUser.workstation = ws.name;
+ this.workstationState = EgAuthWsState.VALID;
+ resolve();
+ } else {
+ 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'))
+ }
+ }
+
+ logout(broadcast?: boolean) {
+ console.debug('logging out');
+
+ if (broadcast) {
+ // TODO
+ //this.authChannel.postMessage({action : 'logout'});
+ }
+
+ this.deleteSession();
+ this.store.clearLoginSessionItems();
+ this.activeUser = null;
+ this.opChangeUser = null;
+ }
+}
--- /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 = new Number(thing.code);
+ evt.ilspermloc = new Number(thing.ilspermloc);
+ 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;
+
+/**
+ * NOTE: To achieve full type strictness and avoid compile warnings,
+ * we would likely have to pre-compile the IDL down to a .ts file with all
+ * of the IDL class and field definitions.
+ */
+
+/**
+ * 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);
+ };
+}
+
--- /dev/null
+/**
+ *
+ * constructor(private net : EgNetService) {
+ * ...
+ * egNet.request(service, method, param1 [, param2, ...])
+ * .subscribe(
+ * (res) => console.log('received one resopnse: ' + res),
+ * (err) => console.error('recived request error: ' + err),
+ * () => console.log('request complete')
+ * )
+ * );
+ * ...
+ * }
+ *
+ * Each response is relayed via Observable onNext(). 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';
+
+// 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;
+
+ // 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;
+ } else {
+ this.session = new OpenSRF.ClientSession(service);
+ }
+ }
+}
+
+@Injectable()
+export class EgNetService {
+
+ permFailed$: EventEmitter<EgNetRequest>;
+ authExpired$: EventEmitter<EgNetRequest>;
+
+ // 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<EgNetRequest>();
+ }
+
+ // 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));
+ }
+
+ requestCompiled(request: EgNetRequest): Observable<any> {
+ return Observable.create(
+ observer => {
+ request.observer = observer;
+ this.sendCompiledRequest(request);
+ }
+ );
+ }
+
+ // Version with pre-compiled EgNetRequest object
+ sendCompiledRequest(request: EgNetRequest): void {
+ OpenSRF.Session.transport = OSRF_TRANSPORT_TYPE_WS;
+ var this_ = this;
+
+ request.session.request({
+ async : true,
+ method : request.method,
+ params : request.params,
+ oncomplete : function() {
+ // A superseded request will be complete()'ed by the
+ // superseder at a later time.
+ if (!request.superseded)
+ request.observer.complete();
+ },
+ onresponse : function(r) {
+ this_.dispatchResponse(request, r.recv().content());
+ },
+ onerror : function(errmsg) {
+ let msg = `${request.method} failed! See server logs. ${errmsg}`;
+ console.error(msg);
+ request.observer.error(msg);
+ },
+ onmethoderror : function(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.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 = function(request, response) {
+ 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);
+ 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';
+import {EgPcrudService} from './pcrud';
+
+type EgOrgNodeOrId = number | EgIdlObject;
+
+interface OrgFilter {
+ canHaveUsers?: boolean;
+ canHaveVolumes?: boolean;
+ opacVisible?: boolean;
+}
+
+@Injectable()
+export class EgOrgService {
+
+ private orgMap = {};
+ private orgList: EgIdlObject[] = [];
+ private orgTree: EgIdlObject; // root node + children
+
+ constructor(
+ 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.
+ * 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;
+
+ // 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();
+ });
+ }
+
+ // NOTE: see ./org-settings.service for settings
+ // TODO: ^--
+}
--- /dev/null
+import {Injectable} from '@angular/core';
+import {Observable, Observer} from 'rxjs/Rx';
+//import {toPromise} from 'rxjs/operators';
+import {EgIdlService, EgIdlObject} from './idl';
+import {EgNetService, EgNetRequest} from './net';
+import {EgAuthService} from './auth';
+
+// Used for debugging.
+declare var js2JSON: (jsThing:any) => string;
+declare var OpenSRF: any; // creating sessions
+
+export 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[]): Observable<EgPcrudResponse> {
+ return this.cud('create', list)
+ }
+ update(list: EgIdlObject[]): Observable<EgPcrudResponse> {
+ return this.cud('update', list)
+ }
+ remove(list: EgIdlObject[]): Observable<EgPcrudResponse> {
+ return this.cud('delete', list)
+ }
+ autoApply(list: 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> {
+ let this_ = this;
+
+ 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.log(`CUD(): ${action}`);
+
+ this.cudIdx = 0;
+ this.cudAction = action;
+ this.xactCloseMode = 'commit';
+
+ if (!Array.isArray(list)) this.cudList = [list];
+
+ let this_ = this;
+
+ 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 {
+ let this_ = this;
+
+ 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[]): Observable<EgPcrudResponse> {
+ return this.newContext().create(list);
+ }
+
+ update(list: EgIdlObject[]): Observable<EgPcrudResponse> {
+ return this.newContext().update(list);
+ }
+
+ remove(list: EgIdlObject[]): Observable<EgPcrudResponse> {
+ return this.newContext().remove(list);
+ }
+
+ autoApply(list: EgIdlObject[]): Observable<EgPcrudResponse> {
+ return this.newContext().autoApply(list);
+ }
+}
+
+
--- /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.
+ 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<any> {
+ // 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<any> {
+ 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<any> {
+ 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
+import {Injectable} from '@angular/core';
+import {Router, Resolve, RouterStateSnapshot,
+ ActivatedRouteSnapshot} from '@angular/router';
+import {EgIdlService} from '@eg/core/idl';
+import {EgOrgService} from '@eg/core/org';
+
+@Injectable()
+export class EgBaseResolver implements Resolve<Promise<void>> {
+
+ constructor(
+ private router: Router,
+ private idl: EgIdlService,
+ private org: EgOrgService,
+ ) {}
+
+ resolve(
+ route: ActivatedRouteSnapshot,
+ state: RouterStateSnapshot): Promise<void> {
+
+ console.debug('EgBaseResolver:resolve()');
+
+ // Load data common to all applications.
+
+ this.idl.parseIdl();
+
+ return this.org.fetchOrgs();
+ // Note that authentication happens at a deeper level, since
+ // some applications (e.g. a public catalog) do not require
+ // up-front authentication to access.
+ }
+}
--- /dev/null
+import { NgModule } from '@angular/core';
+import { RouterModule, Routes } from '@angular/router';
+import { EgBaseResolver } from './resolver.service';
+import { WelcomeComponent } from './welcome.component';
+
+/**
+ * Avoid requiring all apps to load all JS 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.
+ */
+const routes: Routes = [
+ { path: '',
+ component: WelcomeComponent
+ }, {
+ path: 'staff',
+ resolve : {startup : EgBaseResolver},
+ loadChildren: './staff/app.module#EgStaffModule'
+ }
+];
+
+@NgModule({
+ imports: [ RouterModule.forRoot(routes) ],
+ exports: [ RouterModule ],
+ providers: [ EgBaseResolver ]
+})
+
+export class EgBaseRoutingModule {}
--- /dev/null
+Common Angular services and associated types/classes.
+
+This collection of services MIGHT be used by practically all applications.
+They are NOT automatically imported/exported by the base module and should
+be loaded within the requesting application as needed.
+
+
--- /dev/null
+import {Injectable} from '@angular/core';
+import {ParamMap} from '@angular/router';
+import {EgOrgService} from '@eg/core/org';
+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));
+ });
+
+ context.searchOrg =
+ this.org.get(+params.get('org')) || this.org.root();
+ }
+}
--- /dev/null
+import {Injectable} from '@angular/core';
+import {EgOrgService} from '@eg/core/org';
+import {EgUnapiService} from '@eg/share/unapi';
+import {EgIdlObject} from '@eg/core/idl';
+import {EgNetService} from '@eg/core/net';
+import {EgPcrudService} from '@eg/core/pcrud';
+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}
+ ).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}
+ ).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';
+import {EgIdlObject} from '@eg/core/idl';
+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
+ * or search-global.
+ */
+ 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] != '';
+ }
+
+ 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
+
+<!-- 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"
+ (selectItem)="orgChanged($event)"
+/>
--- /dev/null
+import {Component, OnInit, Input, Output, EventEmitter} from '@angular/core';
+import {Observable} from 'rxjs/Observable';
+import {map, debounceTime} from 'rxjs/operators';
+import {EgAuthService} from '@eg/core/auth';
+import {EgStoreService} from '@eg/core/store';
+import {EgOrgService} from '@eg/core/org';
+import {EgIdlObject} from '@eg/core/idl';
+import {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;
+ startOrg: EgIdlObject;
+ hidden: number[] = [];
+ disabled: number[] = [];
+
+ // Read-only properties optionally provided by the calling component.
+ @Input() placeholder: string;
+ @Input() stickySetting: string;
+ @Input() displayField: string = 'shortname';
+
+ @Input() set initialOrg(org: EgIdlObject) {
+ if (org) this.startOrg = org;
+ }
+
+ @Input() set hideOrgs(ids: number[]) {
+ if (ids) this.hidden = ids;
+ }
+
+ @Input() set disableOrgs(ids: number[]) {
+ if (ids) this.disabled = ids;
+ }
+
+ /** 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() {
+ if (this.startOrg) {
+ this.selected = this.formatForDisplay(this.startOrg);
+ }
+ }
+
+ formatForDisplay(org: EgIdlObject): OrgDisplay {
+ return {
+ id : org.id(),
+ label : PAD_SPACE.repeat(org.ou_type().depth())
+ + org[this.displayField](),
+ disabled : false
+ };
+ }
+
+ orgChanged(selEvent: NgbTypeaheadSelectItemEvent) {
+ this.onChange.emit(this.org.get(selEvent.item.id));
+ }
+
+ // Formats the selected value
+ formatter = (result: OrgDisplay) => result.label.trim();
+
+ filter = (text$: Observable<string>): Observable<OrgDisplay[]> => {
+ return text$
+ .debounceTime(100)
+ .distinctUntilChanged()
+ .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
+import {Injectable, EventEmitter} from '@angular/core';
+import {EgOrgService} from '@eg/core/org';
+
+/*
+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
+
+/**
+ * Utility class for manage paged information.
+ */
+export class Pager {
+ offset: number = 0;
+ limit: number = null;
+ resultCount: 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);
+ }
+
+ setPage(page: number): void {
+ this.offset = (this.limit * (page - 1));
+ }
+
+ 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;
+ }
+}
--- /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/app.module#ManageWorkstationsModule'
+}];
+
+@NgModule({
+ imports: [RouterModule.forChild(routes)],
+ exports: [RouterModule]
+})
+
+export class EgAdminWsRoutingModule {}
--- /dev/null
+<div class="row">
+ <div class="col-8 offset-1">
+ <div class="alert alert-warning" *ngIf="removingWs" i18n>
+ Workstation {{removingWs}} 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-2">
+ <eg-org-select
+ (onChange)="orgOnChange"
+ [hideOrgs]="hideOrgs"
+ [disableOrgs]="disableOrgs"
+ [initialOrg]="initialOrg"
+ [placeholder]="'Owner'" >
+ </eg-org-select>
+ </div>
+ <div class="col-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-light" (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-6">
+ <select
+ class="form-control"
+ [(ngModel)]="selectedId">
+ <option *ngFor="let ws of workstations" value="{{ws.id}}">
+ {{ws.name}}
+ </option>
+ </select>
+ </div>
+ </div>
+ <div class="row mt-2">
+ <div class="col-md-6">
+ <button i18n class="btn btn-success"
+ (click)="useNow()" [disabled]="!selected">
+ Use Now
+ </button>
+ <button i18n class="btn btn-light"
+ (click)="setDefault()" [disabled]="!selected">
+ Mark As Default
+ </button>
+ <button i18n class="btn btn-danger"
+ (click)="removeSelected()"
+ [disabled]="!selected || isRemoving || !canDeleteSelected()">
+ Remove
+ </button>
+ </div>
+ </div>
+ </div>
+</div>
+
--- /dev/null
+import {Component, OnInit} from '@angular/core';
+import {ActivatedRoute} from '@angular/router';
+import {EgStoreService} from '@eg/core/store';
+import {EgIdlObject} from '@eg/core/idl';
+import {EgNetService} from '@eg/core/net';
+import {EgAuthService} from '@eg/core/auth';
+import {EgOrgService} from '@eg/core/org';
+
+// Slim version of the WS that's stored in the cache.
+interface Workstation {
+ id: number;
+ name: string;
+ owning_lib: number;
+}
+
+@Component({
+ templateUrl: 'app.component.html'
+})
+export class WorkstationsComponent implements OnInit {
+
+ selectedId: Number;
+ workstations: Workstation[] = [];
+ removeWorkstation: string;
+ newOwner: EgIdlObject;
+ newName: String;
+
+ // Org selector options.
+ hideOrgs: number[];
+ disableOrgs: number[];
+ orgOnChange = (org: EgIdlObject): void => {
+ this.newOwner = org;
+ }
+
+ constructor(
+ private route: ActivatedRoute,
+ private net: EgNetService,
+ private store: EgStoreService,
+ private auth: EgAuthService,
+ private org: EgOrgService
+ ) {}
+
+ ngOnInit() {
+ this.store.getItem('eg.workstation.all')
+ .then(res => this.workstations = res);
+
+ // TODO: perm limits required here too
+ this.disableOrgs = this.org.filterList({canHaveUsers : true}, true);
+
+ this.removeWorkstation = this.route.snapshot.paramMap.get('remove');
+ if (this.removeWorkstation) {
+ console.log('Removing workstation ' + this.removeWorkstation);
+ // TODO remove
+ }
+ }
+
+ selected(): Workstation {
+ return this.workstations.filter(
+ ws => {return ws.id == this.selectedId})[0];
+ }
+
+ useNow(): void {
+ console.debug('using ' + this.selected().name);
+ }
+
+ setDefault(): void {
+ console.debug('defaulting ' + this.selected().name);
+ }
+
+ removeSelected(): void {
+ console.debug('removing ' + this.selected().name);
+ }
+
+ canDeleteSelected(): boolean {
+ return true;
+ }
+
+ registerWorkstation(): void {
+ console.log(`Registering new workstation ` +
+ `"${this.newName}" at ${this.newOwner.shortname()}`);
+ }
+}
+
+
--- /dev/null
+import {NgModule} from '@angular/core';
+import {CommonModule} from '@angular/common';
+import {EgStaffModule} from '@eg/staff/app.module';
+import {WorkstationsRoutingModule} from './routing.module';
+import {WorkstationsComponent} from './app.component';
+
+@NgModule({
+ declarations: [
+ WorkstationsComponent
+ ],
+ imports: [
+ CommonModule,
+ EgStaffModule,
+ WorkstationsRoutingModule
+ ]
+})
+
+export class ManageWorkstationsModule {
+ constructor() {console.log('Loading ManageWorkstationsModule')}
+}
+
--- /dev/null
+import {NgModule} from '@angular/core';
+import {RouterModule, Routes} from '@angular/router';
+import {WorkstationsComponent} from './app.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
+#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>
+
--- /dev/null
+import { Component, OnInit } from '@angular/core';
+import { Router, ActivatedRoute, NavigationEnd } from '@angular/router';
+import { EgAuthService, EgAuthWsState } from '@eg/core/auth';
+import { EgNetService } from '@eg/core/net';
+
+@Component({
+ templateUrl: 'app.component.html',
+ styleUrls: ['app.component.css']
+})
+
+export class EgStaffComponent implements OnInit {
+
+ readonly loginPath = '/staff/login';
+ readonly wsAdminPath = '/staff/admin/workstation/workstations/manage';
+
+ constructor(
+ private router: Router,
+ private route: ActivatedRoute,
+ private net: EgNetService,
+ private auth: EgAuthService
+ ) {}
+
+ 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.basicAuthChecks(routeEvent);
+ }
+ });
+
+ // Redirect to the login page on any auth timeout events.
+ this.net.authExpired$.subscribe(uhOh => {
+ console.debug('Auth session has expired. Redirecting to login');
+ this.auth.redirectUrl = this.router.url;
+ this.router.navigate([this.loginPath]);
+ });
+
+ this.route.data.subscribe((data: {staffResolver : any}) => {
+ console.debug('EgStaff ngOnInit complete');
+
+ });
+ }
+
+ /**
+ * Verifying auth token on every route is overkill, since an expired
+ * token will make itself known with the first API call, but we do
+ * want to prevent navigation from the login or workstation admin
+ * page, since these can be accessed without a valid authtoken or
+ * workstation, respectively, once the initial route resolvers
+ * have done their jobs.
+ */
+ basicAuthChecks(routeEvent: NavigationEnd): void {
+
+ // Access to login page is always granted
+ if (routeEvent.url == this.loginPath) return;
+
+ if (!this.auth.token())
+ this.router.navigate([this.loginPath]);
+
+ // Access to workstation admin page is granted regardless
+ // of workstation validity.
+ if (routeEvent.url == this.wsAdminPath) return;
+
+ if (this.auth.workstationState != EgAuthWsState.VALID)
+ this.router.navigate([this.wsAdminPath]);
+ }
+}
+
+
--- /dev/null
+import {CommonModule} from '@angular/common';
+import {NgModule} from '@angular/core';
+import {FormsModule} from '@angular/forms';
+import {NgbModule} from '@ng-bootstrap/ng-bootstrap';
+import {EgBaseModule} from '@eg/app.module';
+
+import {EgStaffComponent} from './app.component';
+import {EgStaffRoutingModule} from './routing.module';
+import {EgStaffNavComponent} from './nav.component';
+import {EgStaffLoginComponent} from './login.component';
+import {EgStaffSplashComponent} from './splash.component';
+import {EgOrgSelectComponent} from '@eg/share/org-select.component';
+
+@NgModule({
+ declarations: [
+ EgStaffComponent,
+ EgStaffNavComponent,
+ EgStaffSplashComponent,
+ EgStaffLoginComponent,
+ EgOrgSelectComponent
+ ],
+ imports: [
+ EgStaffRoutingModule,
+ FormsModule,
+ NgbModule
+ ],
+ exports: [
+ // Components available to all staff/sub modules
+ EgOrgSelectComponent,
+ FormsModule,
+ NgbModule
+ ]
+})
+
+export class EgStaffModule {
+
+}
--- /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 './app.service';
+
+@Component({
+ templateUrl: 'app.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.
+ this.staffCat.createContext();
+ }
+}
+
--- /dev/null
+import {CommonModule} from '@angular/common';
+import {NgModule} from '@angular/core';
+import {EgStaffModule} from '../app.module';
+import {EgUnapiService} from '@eg/share/unapi';
+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 './app.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 '../share/bib-summary.component';
+import {ResultPaginationComponent} from './result/pagination.component';
+import {ResultFacetsComponent} from './result/facets.component';
+import {ResultRecordComponent} from './result/record.component';
+import {StaffCatalogService} from './app.service';
+import {RecordPaginationComponent} from './record/pagination.component';
+
+@NgModule({
+ declarations: [
+ EgCatalogComponent,
+ ResultsComponent,
+ RecordComponent,
+ CopiesComponent,
+ EgBibSummaryComponent,
+ SearchFormComponent,
+ ResultRecordComponent,
+ ResultFacetsComponent,
+ ResultPaginationComponent,
+ RecordPaginationComponent
+ ],
+ imports: [
+ EgStaffModule,
+ CommonModule,
+ EgCatalogRoutingModule
+ ],
+ providers: [
+ EgUnapiService,
+ EgCatalogService,
+ EgCatalogUrlService,
+ StaffCatalogService
+ ]
+})
+
+export class EgCatalogModule {
+
+}
--- /dev/null
+import {Injectable} from '@angular/core';
+import {Router, ActivatedRoute} from '@angular/router';
+import {EgOrgService} from '@eg/core/org';
+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;
+
+ 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;
+ this.searchContext.isStaff = true;
+
+ // TODO: UI / settings
+ if (!this.searchContext.pager.limit)
+ this.searchContext.pager.limit = 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 {
+ let params = this.catUrl.toUrlParams(this.searchContext);
+
+ // Avoid redirect on empty-query searches
+ if (params.query[0] == '') return;
+
+ // 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" i18n>
+ {{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';
+import {StaffCatalogService} from '../app.service';
+import {Pager} from '@eg/share/util/pager';
+import {EgOrgService} from '@eg/core/org';
+
+@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 = [];
+ this.net.request(
+ 'open-ils.search',
+ 'open-ils.search.bib.copies.staff',
+ this.recId,
+ this.staffCat.searchContext.searchOrg.id(),
+ this.staffCat.searchContext.searchOrg.ou_type().depth(), // TODO
+ this.pager.limit,
+ this.pager.offset,
+ this.staffCat.searchContext.searchOrg.id() // TODO pref_ou
+ ).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 '../app.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';
+import {EgIdlObject} from '@eg/core/idl';
+import {CatalogSearchContext, CatalogSearchState}
+ from '@eg/share/catalog/search-context';
+import {EgCatalogService} from '@eg/share/catalog/catalog.service';
+import {StaffCatalogService} from '../app.service';
+import {EgBibSummaryComponent} from '../../share/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 {Location} from '@angular/common';
+import {Observable, Observer} from 'rxjs/Rx';
+import {Router, Resolve, RouterStateSnapshot,
+ ActivatedRouteSnapshot} from '@angular/router';
+import {EgStoreService} from '@eg/core/store';
+import {EgNetService} from '@eg/core/net';
+import {EgAuthService} from '@eg/core/auth';
+import {EgPcrudService} from '@eg/core/pcrud';
+import {EgCatalogService} from '@eg/share/catalog/catalog.service';
+
+@Injectable()
+export class EgCatalogResolver implements Resolve<Promise<any[]>> {
+
+ constructor(
+ private router: Router,
+ private ngLocation: Location,
+ private store: EgStoreService,
+ private net: EgNetService,
+ private auth: EgAuthService,
+ private cat: EgCatalogService
+ ) {}
+
+ resolve(
+ route: ActivatedRouteSnapshot,
+ state: RouterStateSnapshot): Promise<any[]> {
+
+ console.debug('EgCatalogResolver:resolve()');
+
+ return Promise.all([
+ this.cat.fetchCcvms(),
+ this.cat.fetchCmfs()
+ ]);
+ }
+}
+
--- /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-9">
+ <a class="card-link"
+ href='javascript:;'
+ (click)="applyFacet(facetConf.facetClass, name, value.value)">
+ {{value.value}}
+ </a>
+ </div>
+ <div class="col-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 '../app.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 '../app.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-12 card tight-card mb-2 bg-light">
+ <div class="card-body">
+ <div class="row">
+ <div class="col-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-5">
+ <div class="row">
+ <div class="col-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-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-12">
+ <span>
+ <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-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-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-3">
+ <div class="row">
+ <div class="col-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="./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-12">
+ <div class="float-right small-text-1">
+ Edited {{bibSummary.edit_date | date:'shortDate'}} by
+ <a *ngIf="bibSummary.editor.usrname" target="_self"
+ href="./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-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';
+import {EgCatalogService} from '@eg/share/catalog/catalog.service';
+import {CatalogSearchContext} from '@eg/share/catalog/search-context';
+import {EgNetService} from '@eg/core/net';
+import {EgCatalogUrlService} from '@eg/share/catalog/catalog-url.service';
+import {StaffCatalogService} from '../app.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-2"><!--match pagination margin-->
+ <h3 i18n>Search Results ({{searchContext.result.count}})</h3>
+ </div>
+ <div class="col-1"></div>
+ <div class="col-9">
+ <div class="float-right">
+ <eg-catalog-result-pagination></eg-catalog-result-pagination>
+ </div>
+ </div>
+ </div>
+ <div class="row mt-2">
+ <div class="col-2">
+ <eg-catalog-result-facets></eg-catalog-result-facets>
+ </div>
+ <div class="col-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';
+import {StaffCatalogService} from '../app.service';
+import {EgIdlObject} from '@eg/core/idl';
+
+@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 './app.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;
+}
+
+#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-9 d-flex flex-row">
+ <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">
+ <input type="text" class="form-control"
+ TODOfocus-me="searchContext.focus_query[idx]"
+ [(ngModel)]="searchContext.query[idx]"
+ (keyup)="checkEnter($event)"
+ placeholder="Query..."/>
+ </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-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-9 d-flex flex-row">
+ <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-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-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-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-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-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-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-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-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-2">
+ <i>Copy location filter goes here...</i>
+ </div>
+ </div>
+</div>
+
--- /dev/null
+import {Component, OnInit} from '@angular/core';
+import {EgIdlObject} from '@eg/core/idl';
+import {EgOrgService} from '@eg/core/org';
+import {EgCatalogService,} from '@eg/share/catalog/catalog.service';
+import {CatalogSearchContext, CatalogSearchState}
+ from '@eg/share/catalog/search-context';
+import {StaffCatalogService} from './app.service';
+
+@Component({
+ selector: 'eg-catalog-search-form',
+ styleUrls: ['search-form.component.css'],
+ templateUrl: 'search-form.component.html'
+})
+export class SearchFormComponent implements OnInit {
+
+ searchContext: CatalogSearchContext;
+ ccvmMap: {[ccvm:string] : EgIdlObject[]} = {};
+ cmfMap: {[cmf:string] : EgIdlObject} = {};
+ showAdvancedSearch: boolean = false;
+
+ constructor(
+ 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();
+ }
+
+ /**
+ * 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
+<h2 i18n="Barcode Search Header">Search for Patron by Barcode</h2>
+
+<span i18n>Barcode:</span><input type='text' [ngModel]='barcode'/>
+
+<br/>
+<ul>
+ <li *ngFor="let str of strList">{{str}}</li>
+</ul>
--- /dev/null
+import { Component, OnInit } from '@angular/core';
+import { ActivatedRoute } from '@angular/router';
+import { EgNetService } from '@eg/core/net';
+import { EgAuthService } from '@eg/core/auth';
+
+@Component({
+ templateUrl: 'app.component.html'
+})
+
+export class EgBcSearchComponent implements OnInit {
+
+ barcode: String = '';
+ strList: String[] = [];
+
+ constructor(
+ private route: ActivatedRoute,
+ private net: EgNetService,
+ private auth: EgAuthService
+ ) {}
+
+ ngOnInit() {
+
+ this.barcode = this.route.snapshot.paramMap.get('barcode');
+
+ if (this.barcode) {
+ // Find the user and redirect to the
+ }
+
+ this.route.data.subscribe((data: { startup : any }) => {
+ console.debug('EgBcSearch ngOnInit complete');
+ });
+
+ this.net.request(
+ 'open-ils.actor',
+ 'opensrf.system.echo',
+ 'hello', 'goodbye', 'in the middle'
+ ).subscribe(res => this.strList.push(res));
+ }
+
+ findUser(): void {
+ // find user by this.barcode;
+ }
+}
+
+
--- /dev/null
+import { CommonModule } from '@angular/common';
+import { NgModule } from '@angular/core';
+import { FormsModule } from '@angular/forms';
+import { EgBcSearchComponent } from './app.component';
+import { EgBcSearchRoutingModule } from './routing.module';
+
+@NgModule({
+ declarations: [
+ EgBcSearchComponent
+ ],
+ imports: [
+ EgBcSearchRoutingModule,
+ CommonModule,
+ FormsModule
+ ],
+})
+
+export class EgBcSearchModule {}
+
--- /dev/null
+import { NgModule } from '@angular/core';
+import { RouterModule, Routes } from '@angular/router';
+import { EgBcSearchComponent } from './app.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: '',
+ children : [{
+ path: 'patron',
+ children: [{
+ path: 'bcsearch',
+ loadChildren: '@eg/staff/circ/patron/bcsearch/app.module#EgBcSearchModule'
+ }]
+ }]
+}];
+
+@NgModule({
+ imports: [ RouterModule.forChild(routes) ],
+ exports: [ RouterModule ]
+})
+
+export class EgCircRoutingModule {}
--- /dev/null
+<div class="col-md-4 offset-md-4">
+ <fieldset>
+ <legend i18n>Sign In</legend>
+ <hr/>
+ <form (ngSubmit)="handleSubmit()" #loginForm="ngForm">
+
+ <div class="form-group">
+ <label for="username" i18n>Username</label>
+ <input
+ type="text"
+ class="form-control"
+ id="username"
+ name="username"
+ required
+ i18n-placeholder
+ placeholder="Username"
+ [(ngModel)]="args.username"/>
+ </div>
+
+ <div class="form-group">
+ <label for="password" i18n>Password</label>
+ <input
+ type="password"
+ class="form-control"
+ id="password"
+ name="password"
+ required
+ i18n-placeholder
+ placeholder="Password"
+ [(ngModel)]="args.password"/>
+ </div>
+
+ <button type="submit" class="btn btn-light" i18n>Sign in</button>
+ </form>
+ </fieldset>
+</div>
--- /dev/null
+import { Component, OnInit, Renderer } from '@angular/core';
+import { Location } from '@angular/common';
+import { Router } from '@angular/router';
+import { EgAuthService, EgAuthWsState } from '@eg/core/auth';
+import { EgStoreService } from '@eg/core/store'; // TODO: testing
+
+@Component({
+ templateUrl : './login.component.html'
+})
+
+export class EgStaffLoginComponent implements OnInit {
+
+ args = {
+ username : '',
+ password : '',
+ type : 'staff',
+ //workstation : ''
+ workstation : 'BR1-skiddoo' // testing
+ };
+
+ workstations = [];
+
+ constructor(
+ private router: Router,
+ private ngLocation: Location,
+ private renderer: Renderer,
+ private auth: EgAuthService,
+ private store: EgStoreService
+ ) {}
+
+ ngOnInit() {
+
+ // clear out any stale auth data
+ this.auth.logout();
+
+ // Focus username
+ this.renderer.selectRootElement('#username').focus();
+
+ // load browser-local workstation data
+
+ // TODO: insert for testing.
+ this.store.setItem(
+ 'eg.workstation.all',
+ [{name:'BR1-skiddoo',id:1,owning_lib:4}]
+ );
+ }
+
+ 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 .dropdown-toggle::after {
+ margin-left:0px;
+}
+
+#staff-navbar {
+ background: -webkit-linear-gradient(#00593d, #007a54);
+ background-color: #007a54;
+ color: #fff;
+ font-size: 14px;
+}
+
+#staff-navbar .navbar-nav {
+ padding: 3px;
+}
+
+/* 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">
+ <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">
+ <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/catalog/search">
+ <span class="material-icons">search</span>
+ <span i18n>TODO</span>
+ </a>
+ </div>
+ </div>
+ </div>
+
+
+ <div class="navbar-nav mr-auto"></div>
+ <div class="navbar-nav">
+ <span i18n>{{user}} @ {{workstation}}</span>
+ </div>
+ <div class="navbar-nav">
+ <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>
+ <a i18n class="dropdown-item" routerLink="/staff/login">
+ <span class="material-icons">lock_outline</span>
+ <span i18n>Logout</span>
+ </a>
+ </div>
+ </div>
+ </div>
+ </div>
+</div>
+
--- /dev/null
+import {Component, OnInit} from '@angular/core';
+import {ActivatedRoute, Router} from '@angular/router';
+import {EgAuthService} from '@eg/core/auth';
+
+@Component({
+ selector: 'eg-staff-nav-bar',
+ styleUrls: ['nav.component.css'],
+ templateUrl: 'nav.component.html'
+})
+
+export class EgStaffNavComponent implements OnInit {
+
+ user: string;
+ workstation: string;
+
+ constructor(private auth: EgAuthService) {}
+
+ ngOnInit() {
+ this.user = this.auth.user().usrname();
+ this.workstation = this.auth.workstation();
+ }
+}
+
+
--- /dev/null
+import {Injectable} from '@angular/core';
+import {Location} from '@angular/common';
+import {Observable, Observer} from 'rxjs/Rx';
+import {Router, Resolve, RouterStateSnapshot,
+ ActivatedRouteSnapshot} from '@angular/router';
+import {EgStoreService} from '@eg/core/store';
+import {EgNetService} from '@eg/core/net';
+import {EgAuthService} from '@eg/core/auth';
+
+/**
+ * Apply configuration, etc. required by all staff components.
+ * This resolver is called before authentication is confirmed.
+ * See EgStaffCommonDataResolver for staff-wide, post-auth activities.
+ */
+@Injectable()
+export class EgStaffResolver implements Resolve<Observable<any>> {
+
+ readonly loginPath = '/staff/login';
+ readonly wsAdminPath = '/staff/admin/workstation/workstations/manage';
+
+ constructor(
+ private router: Router,
+ private ngLocation: Location,
+ private store: EgStoreService,
+ private net: EgNetService,
+ private auth: EgAuthService
+ ) {}
+
+ 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');
+
+ // Login resets everything. No need to load data.
+ if (state.url == '/staff/login') return Observable.of(true);
+
+ return Observable.create(observer => {
+ this.auth.testAuthToken().then(
+ tokenOk => {
+ console.debug('EgStaffResolver: authtoken verified');
+ this.auth.verifyWorkstation().then(
+ wsOk => {
+ this.loadStartupData(observer).then(
+ ok => observer.complete()
+ );
+ },
+ wsNotOk => {
+ if (state.url != this.wsAdminPath) {
+ this.router.navigate([this.wsAdminPath]);
+ }
+ observer.complete();
+ }
+ );
+ },
+ tokenNotOk => {
+ // Authtoken is not OK.
+ console.debug('EgStaffResolver: authtoken is not valid');
+ this.auth.redirectUrl = state.url;
+ this.router.navigate([this.loginPath]);
+ observer.error('invalid auth');
+ }
+ );
+ });
+ }
+
+ loadStartupData(observer: Observer<any>): 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 './app.component';
+import {EgStaffLoginComponent} from './login.component';
+import {EgStaffSplashComponent} from './splash.component';
+
+// Not using 'canActivate' because it's called before all resolvers,
+// but the resolvers parse the IDL, etc.
+
+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/app.module#EgCatalogModule'
+ }, {
+ path: 'admin',
+ loadChildren : '@eg/staff/admin/routing.module#EgAdminRoutingModule'
+ }]
+}];
+
+@NgModule({
+ imports: [ RouterModule.forChild(routes) ],
+ exports: [ RouterModule ],
+ providers: [
+ EgStaffResolver
+ ]
+})
+
+export class EgStaffRoutingModule {}
+
--- /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';
+import {EgPcrudService} from '@eg/core/pcrud';
+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
+
+
+<style>
+ /* TODO change BS color scheme so this isn't necessary */
+ .bg-evergreen {
+ background: -webkit-linear-gradient(#00593d, #007a54);
+ background-color: #007a54;
+ color: #fff;
+ }
+</style>
+
+<div class="container">
+
+ <!-- header icon -->
+ <div class="row mb-3">
+ <div class="col-12 text-center">
+ <img src="/images/portal/logo.png"/>
+ </div>
+ </div>
+
+ <div class="row" id="splash-nav">
+ <div class="col-4">
+ <div class="card">
+ <div class="card-header bg-evergreen">
+ <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-4">
+ <div class="card">
+ <div class="card-header bg-evergreen">
+ <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-4">
+ <div class="card">
+ <div class="card-header bg-evergreen">
+ <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
+<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>
+ or <a routerLink="/catalog">the catalog.</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"
+ href="https://maxcdn.bootstrapcdn.com/bootstrap/4.0.0-beta.2/css/bootstrap.min.css"
+ integrity="sha384-PsH8R72JQ3SOdhVi3uxftmaW6Vc51MKb0q5P2rRUpPvrszuE4W1povHYgTpBfshb"
+ crossorigin="anonymous">
+</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 */
+.flex-1 {flex: 1}
+.flex-2 {flex: 2}
+.flex-3 {flex: 3}
+.flex-4 {flex: 4}
+.flex-5 {flex: 5}
+
+
+/* usefulf 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;
+}
+
--- /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
+ }
+}
+++ /dev/null
-{
- "$schema": "./node_modules/@angular/cli/lib/config/schema.json",
- "project": {
- "name": "eg"
- },
- "apps": [
- {
- "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/webby">
- FallbackResource /webby/index.html
-</Directory>
----------------------------------------------------------------------
-
-=== Transpile + Deploy in --watch mode for Dev ===
-
-[source,sh]
----------------------------------------------------------------------
-ng build --deploy-url /webby/ --base-href /webby/ --output-path ../web/webby/ --watch
----------------------------------------------------------------------
+++ /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.0.0",
- "@angular/common": "^5.0.0",
- "@angular/compiler": "^5.0.0",
- "@angular/core": "^5.0.0",
- "@angular/forms": "^5.0.0",
- "@angular/http": "^5.0.0",
- "@angular/platform-browser": "^5.0.0",
- "@angular/platform-browser-dynamic": "^5.0.0",
- "@angular/router": "^5.0.0",
- "@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.2",
- "zone.js": "^0.8.14"
- },
- "devDependencies": {
- "@angular/cli": "1.5.1",
- "@angular/compiler-cli": "^5.0.0",
- "@angular/language-service": "^5.0.0",
- "@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.4.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 router and a simple welcome page for
- * users that end up here accidentally.
- */
-import {BrowserModule} from '@angular/platform-browser';
-import {NgModule} from '@angular/core';
-import {Router} from '@angular/router'; // Debugging
-import {NgbModule} from '@ng-bootstrap/ng-bootstrap'; // ng-bootstrap
-import {CookieModule} from 'ngx-cookie'; // import CookieMonster
-
-import {EgBaseComponent} from './app.component';
-import {EgBaseRoutingModule} from './routing.module';
-import {WelcomeComponent} from './welcome.component';
-
-// Import and 'provide' globally required services.
-import {EgEventService} from '@eg/core/event';
-import {EgStoreService} from '@eg/core/store';
-import {EgIdlService} from '@eg/core/idl';
-import {EgNetService} from '@eg/core/net';
-import {EgAuthService} from '@eg/core/auth';
-import {EgPcrudService} from '@eg/core/pcrud';
-import {EgOrgService} from '@eg/core/org';
-
-@NgModule({
- declarations: [
- EgBaseComponent,
- WelcomeComponent
- ],
- imports: [
- EgBaseRoutingModule,
- BrowserModule,
- NgbModule.forRoot(),
- CookieModule.forRoot()
- ],
- providers: [
- EgEventService,
- EgStoreService,
- EgIdlService,
- EgNetService,
- EgAuthService,
- EgPcrudService,
- EgOrgService
- ],
- exports: [],
- bootstrap: [EgBaseComponent]
-})
-
-export class EgBaseModule {
- constructor(router: Router) {
- /*
- console.debug('Routes: ',
- JSON.stringify(router.config, undefined, 2));
- */
- }
-}
+++ /dev/null
-Core Angular services and assocated types/classes.
-
-Core services are imported and exported by the base module, which means
-they are automatically added as dependencies to ALL applications.
-
-1. Only add services here that are universally required!
-2. Avoid path navigation in the core services as paths will vary by application.
-
+++ /dev/null
-/**
- *
- */
-import { Injectable, EventEmitter } from '@angular/core';
-import { Observable } from 'rxjs/Rx';
-import { EgNetService } from './net';
-import { EgEventService, EgEvent } from './event';
-import { EgIdlService, EgIdlObject } from './idl';
-import { EgStoreService } from './store';
-
-// Models a login instance.
-class EgAuthUser {
- user: EgIdlObject;
- 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 activeUser: EgAuthUser;
-
- // opChangeUser refers to the user that has been superseded during
- // an op-change event. This use will become the activeUser once
- // again, when the op-change cycle has completed.
- private opChangeUser: EgAuthUser;
-
- workstationState: EgAuthWsState = EgAuthWsState.PENDING;
-
- redirectUrl: string;
-
- constructor(
- private egEvt: EgEventService,
- private net: EgNetService,
- private store: EgStoreService
- ) {}
-
- // - Accessor functions alway refer to the active user.
-
- user(): EgIdlObject {
- return this.activeUser.user
- };
-
- // Workstation name.
- workstation(): string {
- return this.activeUser.workstation;
- };
-
- token(): string {
- return this.activeUser ? this.activeUser.token : null;
- };
-
- authtime(): Number {
- return this.activeUser.authtime
- };
-
- // NOTE: EgNetService emits an event if the auth session has expired.
- testAuthToken(): Promise<any> {
-
- 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 new Promise<any>( (resolve, reject) => {
- this.net.request(
- 'open-ils.auth',
- 'open-ils.auth.session.retrieve', this.token()
- ).subscribe(
- user => {
- // EgNetService interceps NO_SESSION events.
- // We can only get here if the session is valid.
- this.activeUser.user = user;
- this.sessionPoll();
- resolve();
- },
- err => { reject(); }
- );
- });
- }
-
- checkWorkstation(): void {
- // TODO:
- // Emits event on invalid workstation.
- }
-
- login(args: EgAuthLoginArgs, isOpChange?: boolean): Promise<void> {
-
- return new Promise<void>((resolve, reject) => {
- this.net.request('open-ils.auth', 'open-ils.auth.login', args)
- .subscribe(res => {
- this.handleLoginResponse(args, this.egEvt.parse(res), isOpChange)
- .then(
- ok => resolve(ok),
- notOk => reject(notOk)
- );
- });
- });
- }
-
- handleLoginResponse(
- args: EgAuthLoginArgs, evt: EgEvent, isOpChange: boolean): Promise<void> {
-
- switch (evt.textcode) {
- case 'SUCCESS':
- this.handleLoginOk(args, evt, isOpChange);
- return Promise.resolve();
-
- 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): void {
-
- if (isOpChange) {
- this.store.setLoginSessionItem('eg.auth.token.oc', this.token());
- this.store.setLoginSessionItem('eg.auth.time.oc', this.authtime());
- this.opChangeUser = this.activeUser;
- }
-
- 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());
- }
-
- undoOpChange(): Promise<any> {
- if (this.opChangeUser) {
- this.deleteSession();
- this.activeUser = this.opChangeUser;
- this.opChangeUser = null;
- 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());
- }
- return this.testAuthToken();
- }
-
- sessionPoll(): void {
- // TODO
- }
-
- // Resolves if login workstation matches a workstation known to this
- // browser instance.
- verifyWorkstation(): Promise<void> {
- return new Promise((resolve, reject) => {
-
- if (!this.user()) {
- this.workstationState = EgAuthWsState.PENDING;
- reject();
- return;
- }
-
- if (!this.user().wsid()) {
- this.workstationState = EgAuthWsState.NOT_USED;
- reject();
- return;
- }
-
- this.store.getItem('eg.workstation.all')
- .then(workstations => {
- if (!workstations) workstations = [];
-
- let ws = workstations.filter(
- w => {return w.id == this.user().wsid()})[0];
-
- if (ws) {
- this.activeUser.workstation = ws.name;
- this.workstationState = EgAuthWsState.VALID;
- resolve();
- } else {
- 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'))
- }
- }
-
- logout(broadcast?: boolean) {
- console.debug('logging out');
-
- if (broadcast) {
- // TODO
- //this.authChannel.postMessage({action : 'logout'});
- }
-
- this.deleteSession();
- this.store.clearLoginSessionItems();
- this.activeUser = null;
- this.opChangeUser = null;
- }
-}
+++ /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 = new Number(thing.code);
- evt.ilspermloc = new Number(thing.ilspermloc);
- 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;
-
-/**
- * NOTE: To achieve full type strictness and avoid compile warnings,
- * we would likely have to pre-compile the IDL down to a .ts file with all
- * of the IDL class and field definitions.
- */
-
-/**
- * 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);
- };
-}
-
+++ /dev/null
-/**
- *
- * constructor(private net : EgNetService) {
- * ...
- * egNet.request(service, method, param1 [, param2, ...])
- * .subscribe(
- * (res) => console.log('received one resopnse: ' + res),
- * (err) => console.error('recived request error: ' + err),
- * () => console.log('request complete')
- * )
- * );
- * ...
- * }
- *
- * Each response is relayed via Observable onNext(). 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';
-
-// 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;
-
- // 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;
- } else {
- this.session = new OpenSRF.ClientSession(service);
- }
- }
-}
-
-@Injectable()
-export class EgNetService {
-
- permFailed$: EventEmitter<EgNetRequest>;
- authExpired$: EventEmitter<EgNetRequest>;
-
- // 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<EgNetRequest>();
- }
-
- // 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));
- }
-
- requestCompiled(request: EgNetRequest): Observable<any> {
- return Observable.create(
- observer => {
- request.observer = observer;
- this.sendCompiledRequest(request);
- }
- );
- }
-
- // Version with pre-compiled EgNetRequest object
- sendCompiledRequest(request: EgNetRequest): void {
- OpenSRF.Session.transport = OSRF_TRANSPORT_TYPE_WS;
- var this_ = this;
-
- request.session.request({
- async : true,
- method : request.method,
- params : request.params,
- oncomplete : function() {
- // A superseded request will be complete()'ed by the
- // superseder at a later time.
- if (!request.superseded)
- request.observer.complete();
- },
- onresponse : function(r) {
- this_.dispatchResponse(request, r.recv().content());
- },
- onerror : function(errmsg) {
- let msg = `${request.method} failed! See server logs. ${errmsg}`;
- console.error(msg);
- request.observer.error(msg);
- },
- onmethoderror : function(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.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 = function(request, response) {
- 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);
- 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';
-import {EgPcrudService} from './pcrud';
-
-type EgOrgNodeOrId = number | EgIdlObject;
-
-interface OrgFilter {
- canHaveUsers?: boolean;
- canHaveVolumes?: boolean;
- opacVisible?: boolean;
-}
-
-@Injectable()
-export class EgOrgService {
-
- private orgMap = {};
- private orgList: EgIdlObject[] = [];
- private orgTree: EgIdlObject; // root node + children
-
- constructor(
- 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.
- * 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;
-
- // 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();
- });
- }
-
- // NOTE: see ./org-settings.service for settings
- // TODO: ^--
-}
+++ /dev/null
-import {Injectable} from '@angular/core';
-import {Observable, Observer} from 'rxjs/Rx';
-//import {toPromise} from 'rxjs/operators';
-import {EgIdlService, EgIdlObject} from './idl';
-import {EgNetService, EgNetRequest} from './net';
-import {EgAuthService} from './auth';
-
-// Used for debugging.
-declare var js2JSON: (jsThing:any) => string;
-declare var OpenSRF: any; // creating sessions
-
-export 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[]): Observable<EgPcrudResponse> {
- return this.cud('create', list)
- }
- update(list: EgIdlObject[]): Observable<EgPcrudResponse> {
- return this.cud('update', list)
- }
- remove(list: EgIdlObject[]): Observable<EgPcrudResponse> {
- return this.cud('delete', list)
- }
- autoApply(list: 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> {
- let this_ = this;
-
- 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.log(`CUD(): ${action}`);
-
- this.cudIdx = 0;
- this.cudAction = action;
- this.xactCloseMode = 'commit';
-
- if (!Array.isArray(list)) this.cudList = [list];
-
- let this_ = this;
-
- 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 {
- let this_ = this;
-
- 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[]): Observable<EgPcrudResponse> {
- return this.newContext().create(list);
- }
-
- update(list: EgIdlObject[]): Observable<EgPcrudResponse> {
- return this.newContext().update(list);
- }
-
- remove(list: EgIdlObject[]): Observable<EgPcrudResponse> {
- return this.newContext().remove(list);
- }
-
- autoApply(list: EgIdlObject[]): Observable<EgPcrudResponse> {
- return this.newContext().autoApply(list);
- }
-}
-
-
+++ /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.
- 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<any> {
- // 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<any> {
- 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<any> {
- 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
-import {Injectable} from '@angular/core';
-import {Router, Resolve, RouterStateSnapshot,
- ActivatedRouteSnapshot} from '@angular/router';
-import {EgIdlService} from '@eg/core/idl';
-import {EgOrgService} from '@eg/core/org';
-
-@Injectable()
-export class EgBaseResolver implements Resolve<Promise<void>> {
-
- constructor(
- private router: Router,
- private idl: EgIdlService,
- private org: EgOrgService,
- ) {}
-
- resolve(
- route: ActivatedRouteSnapshot,
- state: RouterStateSnapshot): Promise<void> {
-
- console.debug('EgBaseResolver:resolve()');
-
- // Load data common to all applications.
-
- this.idl.parseIdl();
-
- return this.org.fetchOrgs();
- // Note that authentication happens at a deeper level, since
- // some applications (e.g. a public catalog) do not require
- // up-front authentication to access.
- }
-}
+++ /dev/null
-import { NgModule } from '@angular/core';
-import { RouterModule, Routes } from '@angular/router';
-import { EgBaseResolver } from './resolver.service';
-import { WelcomeComponent } from './welcome.component';
-
-/**
- * Avoid requiring all apps to load all JS 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.
- */
-const routes: Routes = [
- { path: '',
- component: WelcomeComponent
- }, {
- path: 'staff',
- resolve : {startup : EgBaseResolver},
- loadChildren: './staff/app.module#EgStaffModule'
- }
-];
-
-@NgModule({
- imports: [ RouterModule.forRoot(routes) ],
- exports: [ RouterModule ],
- providers: [ EgBaseResolver ]
-})
-
-export class EgBaseRoutingModule {}
+++ /dev/null
-Common Angular services and associated types/classes.
-
-This collection of services MIGHT be used by practically all applications.
-They are NOT automatically imported/exported by the base module and should
-be loaded within the requesting application as needed.
-
-
+++ /dev/null
-import {Injectable} from '@angular/core';
-import {ParamMap} from '@angular/router';
-import {EgOrgService} from '@eg/core/org';
-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));
- });
-
- context.searchOrg =
- this.org.get(+params.get('org')) || this.org.root();
- }
-}
+++ /dev/null
-import {Injectable} from '@angular/core';
-import {EgOrgService} from '@eg/core/org';
-import {EgUnapiService} from '@eg/share/unapi';
-import {EgIdlObject} from '@eg/core/idl';
-import {EgNetService} from '@eg/core/net';
-import {EgPcrudService} from '@eg/core/pcrud';
-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}
- ).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}
- ).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';
-import {EgIdlObject} from '@eg/core/idl';
-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
- * or search-global.
- */
- 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] != '';
- }
-
- 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
-
-<!-- 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"
- (selectItem)="orgChanged($event)"
-/>
+++ /dev/null
-import {Component, OnInit, Input, Output, EventEmitter} from '@angular/core';
-import {Observable} from 'rxjs/Observable';
-import {map, debounceTime} from 'rxjs/operators';
-import {EgAuthService} from '@eg/core/auth';
-import {EgStoreService} from '@eg/core/store';
-import {EgOrgService} from '@eg/core/org';
-import {EgIdlObject} from '@eg/core/idl';
-import {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;
- startOrg: EgIdlObject;
- hidden: number[] = [];
- disabled: number[] = [];
-
- // Read-only properties optionally provided by the calling component.
- @Input() placeholder: string;
- @Input() stickySetting: string;
- @Input() displayField: string = 'shortname';
-
- @Input() set initialOrg(org: EgIdlObject) {
- if (org) this.startOrg = org;
- }
-
- @Input() set hideOrgs(ids: number[]) {
- if (ids) this.hidden = ids;
- }
-
- @Input() set disableOrgs(ids: number[]) {
- if (ids) this.disabled = ids;
- }
-
- /** 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() {
- if (this.startOrg) {
- this.selected = this.formatForDisplay(this.startOrg);
- }
- }
-
- formatForDisplay(org: EgIdlObject): OrgDisplay {
- return {
- id : org.id(),
- label : PAD_SPACE.repeat(org.ou_type().depth())
- + org[this.displayField](),
- disabled : false
- };
- }
-
- orgChanged(selEvent: NgbTypeaheadSelectItemEvent) {
- this.onChange.emit(this.org.get(selEvent.item.id));
- }
-
- // Formats the selected value
- formatter = (result: OrgDisplay) => result.label.trim();
-
- filter = (text$: Observable<string>): Observable<OrgDisplay[]> => {
- return text$
- .debounceTime(100)
- .distinctUntilChanged()
- .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
-import {Injectable, EventEmitter} from '@angular/core';
-import {EgOrgService} from '@eg/core/org';
-
-/*
-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
-
-/**
- * Utility class for manage paged information.
- */
-export class Pager {
- offset: number = 0;
- limit: number = null;
- resultCount: 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);
- }
-
- setPage(page: number): void {
- this.offset = (this.limit * (page - 1));
- }
-
- 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;
- }
-}
+++ /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/app.module#ManageWorkstationsModule'
-}];
-
-@NgModule({
- imports: [RouterModule.forChild(routes)],
- exports: [RouterModule]
-})
-
-export class EgAdminWsRoutingModule {}
+++ /dev/null
-<div class="row">
- <div class="col-8 offset-1">
- <div class="alert alert-warning" *ngIf="removingWs" i18n>
- Workstation {{removingWs}} 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-2">
- <eg-org-select
- (onChange)="orgOnChange"
- [hideOrgs]="hideOrgs"
- [disableOrgs]="disableOrgs"
- [initialOrg]="initialOrg"
- [placeholder]="'Owner'" >
- </eg-org-select>
- </div>
- <div class="col-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-light" (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-6">
- <select
- class="form-control"
- [(ngModel)]="selectedId">
- <option *ngFor="let ws of workstations" value="{{ws.id}}">
- {{ws.name}}
- </option>
- </select>
- </div>
- </div>
- <div class="row mt-2">
- <div class="col-md-6">
- <button i18n class="btn btn-success"
- (click)="useNow()" [disabled]="!selected">
- Use Now
- </button>
- <button i18n class="btn btn-light"
- (click)="setDefault()" [disabled]="!selected">
- Mark As Default
- </button>
- <button i18n class="btn btn-danger"
- (click)="removeSelected()"
- [disabled]="!selected || isRemoving || !canDeleteSelected()">
- Remove
- </button>
- </div>
- </div>
- </div>
-</div>
-
+++ /dev/null
-import {Component, OnInit} from '@angular/core';
-import {ActivatedRoute} from '@angular/router';
-import {EgStoreService} from '@eg/core/store';
-import {EgIdlObject} from '@eg/core/idl';
-import {EgNetService} from '@eg/core/net';
-import {EgAuthService} from '@eg/core/auth';
-import {EgOrgService} from '@eg/core/org';
-
-// Slim version of the WS that's stored in the cache.
-interface Workstation {
- id: number;
- name: string;
- owning_lib: number;
-}
-
-@Component({
- templateUrl: 'app.component.html'
-})
-export class WorkstationsComponent implements OnInit {
-
- selectedId: Number;
- workstations: Workstation[] = [];
- removeWorkstation: string;
- newOwner: EgIdlObject;
- newName: String;
-
- // Org selector options.
- hideOrgs: number[];
- disableOrgs: number[];
- orgOnChange = (org: EgIdlObject): void => {
- this.newOwner = org;
- }
-
- constructor(
- private route: ActivatedRoute,
- private net: EgNetService,
- private store: EgStoreService,
- private auth: EgAuthService,
- private org: EgOrgService
- ) {}
-
- ngOnInit() {
- this.store.getItem('eg.workstation.all')
- .then(res => this.workstations = res);
-
- // TODO: perm limits required here too
- this.disableOrgs = this.org.filterList({canHaveUsers : true}, true);
-
- this.removeWorkstation = this.route.snapshot.paramMap.get('remove');
- if (this.removeWorkstation) {
- console.log('Removing workstation ' + this.removeWorkstation);
- // TODO remove
- }
- }
-
- selected(): Workstation {
- return this.workstations.filter(
- ws => {return ws.id == this.selectedId})[0];
- }
-
- useNow(): void {
- console.debug('using ' + this.selected().name);
- }
-
- setDefault(): void {
- console.debug('defaulting ' + this.selected().name);
- }
-
- removeSelected(): void {
- console.debug('removing ' + this.selected().name);
- }
-
- canDeleteSelected(): boolean {
- return true;
- }
-
- registerWorkstation(): void {
- console.log(`Registering new workstation ` +
- `"${this.newName}" at ${this.newOwner.shortname()}`);
- }
-}
-
-
+++ /dev/null
-import {NgModule} from '@angular/core';
-import {CommonModule} from '@angular/common';
-import {EgStaffModule} from '@eg/staff/app.module';
-import {WorkstationsRoutingModule} from './routing.module';
-import {WorkstationsComponent} from './app.component';
-
-@NgModule({
- declarations: [
- WorkstationsComponent
- ],
- imports: [
- CommonModule,
- EgStaffModule,
- WorkstationsRoutingModule
- ]
-})
-
-export class ManageWorkstationsModule {
- constructor() {console.log('Loading ManageWorkstationsModule')}
-}
-
+++ /dev/null
-import {NgModule} from '@angular/core';
-import {RouterModule, Routes} from '@angular/router';
-import {WorkstationsComponent} from './app.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
-#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>
-
+++ /dev/null
-import { Component, OnInit } from '@angular/core';
-import { Router, ActivatedRoute, NavigationEnd } from '@angular/router';
-import { EgAuthService, EgAuthWsState } from '@eg/core/auth';
-import { EgNetService } from '@eg/core/net';
-
-@Component({
- templateUrl: 'app.component.html',
- styleUrls: ['app.component.css']
-})
-
-export class EgStaffComponent implements OnInit {
-
- readonly loginPath = '/staff/login';
- readonly wsAdminPath = '/staff/admin/workstation/workstations/manage';
-
- constructor(
- private router: Router,
- private route: ActivatedRoute,
- private net: EgNetService,
- private auth: EgAuthService
- ) {}
-
- 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.basicAuthChecks(routeEvent);
- }
- });
-
- // Redirect to the login page on any auth timeout events.
- this.net.authExpired$.subscribe(uhOh => {
- console.debug('Auth session has expired. Redirecting to login');
- this.auth.redirectUrl = this.router.url;
- this.router.navigate([this.loginPath]);
- });
-
- this.route.data.subscribe((data: {staffResolver : any}) => {
- console.debug('EgStaff ngOnInit complete');
-
- });
- }
-
- /**
- * Verifying auth token on every route is overkill, since an expired
- * token will make itself known with the first API call, but we do
- * want to prevent navigation from the login or workstation admin
- * page, since these can be accessed without a valid authtoken or
- * workstation, respectively, once the initial route resolvers
- * have done their jobs.
- */
- basicAuthChecks(routeEvent: NavigationEnd): void {
-
- // Access to login page is always granted
- if (routeEvent.url == this.loginPath) return;
-
- if (!this.auth.token())
- this.router.navigate([this.loginPath]);
-
- // Access to workstation admin page is granted regardless
- // of workstation validity.
- if (routeEvent.url == this.wsAdminPath) return;
-
- if (this.auth.workstationState != EgAuthWsState.VALID)
- this.router.navigate([this.wsAdminPath]);
- }
-}
-
-
+++ /dev/null
-import {CommonModule} from '@angular/common';
-import {NgModule} from '@angular/core';
-import {FormsModule} from '@angular/forms';
-import {NgbModule} from '@ng-bootstrap/ng-bootstrap';
-import {EgBaseModule} from '@eg/app.module';
-
-import {EgStaffComponent} from './app.component';
-import {EgStaffRoutingModule} from './routing.module';
-import {EgStaffNavComponent} from './nav.component';
-import {EgStaffLoginComponent} from './login.component';
-import {EgStaffSplashComponent} from './splash.component';
-import {EgOrgSelectComponent} from '@eg/share/org-select.component';
-
-@NgModule({
- declarations: [
- EgStaffComponent,
- EgStaffNavComponent,
- EgStaffSplashComponent,
- EgStaffLoginComponent,
- EgOrgSelectComponent
- ],
- imports: [
- EgStaffRoutingModule,
- FormsModule,
- NgbModule
- ],
- exports: [
- // Components available to all staff/sub modules
- EgOrgSelectComponent,
- FormsModule,
- NgbModule
- ]
-})
-
-export class EgStaffModule {
-
-}
+++ /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 './app.service';
-
-@Component({
- templateUrl: 'app.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.
- this.staffCat.createContext();
- }
-}
-
+++ /dev/null
-import {CommonModule} from '@angular/common';
-import {NgModule} from '@angular/core';
-import {EgStaffModule} from '../app.module';
-import {EgUnapiService} from '@eg/share/unapi';
-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 './app.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 '../share/bib-summary.component';
-import {ResultPaginationComponent} from './result/pagination.component';
-import {ResultFacetsComponent} from './result/facets.component';
-import {ResultRecordComponent} from './result/record.component';
-import {StaffCatalogService} from './app.service';
-import {RecordPaginationComponent} from './record/pagination.component';
-
-@NgModule({
- declarations: [
- EgCatalogComponent,
- ResultsComponent,
- RecordComponent,
- CopiesComponent,
- EgBibSummaryComponent,
- SearchFormComponent,
- ResultRecordComponent,
- ResultFacetsComponent,
- ResultPaginationComponent,
- RecordPaginationComponent
- ],
- imports: [
- EgStaffModule,
- CommonModule,
- EgCatalogRoutingModule
- ],
- providers: [
- EgUnapiService,
- EgCatalogService,
- EgCatalogUrlService,
- StaffCatalogService
- ]
-})
-
-export class EgCatalogModule {
-
-}
+++ /dev/null
-import {Injectable} from '@angular/core';
-import {Router, ActivatedRoute} from '@angular/router';
-import {EgOrgService} from '@eg/core/org';
-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;
-
- 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;
- this.searchContext.isStaff = true;
-
- // TODO: UI / settings
- if (!this.searchContext.pager.limit)
- this.searchContext.pager.limit = 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 {
- let params = this.catUrl.toUrlParams(this.searchContext);
-
- // Avoid redirect on empty-query searches
- if (params.query[0] == '') return;
-
- // 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" i18n>
- {{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';
-import {StaffCatalogService} from '../app.service';
-import {Pager} from '@eg/share/util/pager';
-import {EgOrgService} from '@eg/core/org';
-
-@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 = [];
- this.net.request(
- 'open-ils.search',
- 'open-ils.search.bib.copies.staff',
- this.recId,
- this.staffCat.searchContext.searchOrg.id(),
- this.staffCat.searchContext.searchOrg.ou_type().depth(), // TODO
- this.pager.limit,
- this.pager.offset,
- this.staffCat.searchContext.searchOrg.id() // TODO pref_ou
- ).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 '../app.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';
-import {EgIdlObject} from '@eg/core/idl';
-import {CatalogSearchContext, CatalogSearchState}
- from '@eg/share/catalog/search-context';
-import {EgCatalogService} from '@eg/share/catalog/catalog.service';
-import {StaffCatalogService} from '../app.service';
-import {EgBibSummaryComponent} from '../../share/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 {Location} from '@angular/common';
-import {Observable, Observer} from 'rxjs/Rx';
-import {Router, Resolve, RouterStateSnapshot,
- ActivatedRouteSnapshot} from '@angular/router';
-import {EgStoreService} from '@eg/core/store';
-import {EgNetService} from '@eg/core/net';
-import {EgAuthService} from '@eg/core/auth';
-import {EgPcrudService} from '@eg/core/pcrud';
-import {EgCatalogService} from '@eg/share/catalog/catalog.service';
-
-@Injectable()
-export class EgCatalogResolver implements Resolve<Promise<any[]>> {
-
- constructor(
- private router: Router,
- private ngLocation: Location,
- private store: EgStoreService,
- private net: EgNetService,
- private auth: EgAuthService,
- private cat: EgCatalogService
- ) {}
-
- resolve(
- route: ActivatedRouteSnapshot,
- state: RouterStateSnapshot): Promise<any[]> {
-
- console.debug('EgCatalogResolver:resolve()');
-
- return Promise.all([
- this.cat.fetchCcvms(),
- this.cat.fetchCmfs()
- ]);
- }
-}
-
+++ /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-9">
- <a class="card-link"
- href='javascript:;'
- (click)="applyFacet(facetConf.facetClass, name, value.value)">
- {{value.value}}
- </a>
- </div>
- <div class="col-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 '../app.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 '../app.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-12 card tight-card mb-2 bg-light">
- <div class="card-body">
- <div class="row">
- <div class="col-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-5">
- <div class="row">
- <div class="col-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-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-12">
- <span>
- <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-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-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-3">
- <div class="row">
- <div class="col-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="./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-12">
- <div class="float-right small-text-1">
- Edited {{bibSummary.edit_date | date:'shortDate'}} by
- <a *ngIf="bibSummary.editor.usrname" target="_self"
- href="./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-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';
-import {EgCatalogService} from '@eg/share/catalog/catalog.service';
-import {CatalogSearchContext} from '@eg/share/catalog/search-context';
-import {EgNetService} from '@eg/core/net';
-import {EgCatalogUrlService} from '@eg/share/catalog/catalog-url.service';
-import {StaffCatalogService} from '../app.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-2"><!--match pagination margin-->
- <h3 i18n>Search Results ({{searchContext.result.count}})</h3>
- </div>
- <div class="col-1"></div>
- <div class="col-9">
- <div class="float-right">
- <eg-catalog-result-pagination></eg-catalog-result-pagination>
- </div>
- </div>
- </div>
- <div class="row mt-2">
- <div class="col-2">
- <eg-catalog-result-facets></eg-catalog-result-facets>
- </div>
- <div class="col-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';
-import {StaffCatalogService} from '../app.service';
-import {EgIdlObject} from '@eg/core/idl';
-
-@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 './app.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;
-}
-
-#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-9 d-flex flex-row">
- <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">
- <input type="text" class="form-control"
- TODOfocus-me="searchContext.focus_query[idx]"
- [(ngModel)]="searchContext.query[idx]"
- (keyup)="checkEnter($event)"
- placeholder="Query..."/>
- </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-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-9 d-flex flex-row">
- <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-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-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-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-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-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-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-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-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-2">
- <i>Copy location filter goes here...</i>
- </div>
- </div>
-</div>
-
+++ /dev/null
-import {Component, OnInit} from '@angular/core';
-import {EgIdlObject} from '@eg/core/idl';
-import {EgOrgService} from '@eg/core/org';
-import {EgCatalogService,} from '@eg/share/catalog/catalog.service';
-import {CatalogSearchContext, CatalogSearchState}
- from '@eg/share/catalog/search-context';
-import {StaffCatalogService} from './app.service';
-
-@Component({
- selector: 'eg-catalog-search-form',
- styleUrls: ['search-form.component.css'],
- templateUrl: 'search-form.component.html'
-})
-export class SearchFormComponent implements OnInit {
-
- searchContext: CatalogSearchContext;
- ccvmMap: {[ccvm:string] : EgIdlObject[]} = {};
- cmfMap: {[cmf:string] : EgIdlObject} = {};
- showAdvancedSearch: boolean = false;
-
- constructor(
- 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();
- }
-
- /**
- * 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
-<h2 i18n="Barcode Search Header">Search for Patron by Barcode</h2>
-
-<span i18n>Barcode:</span><input type='text' [ngModel]='barcode'/>
-
-<br/>
-<ul>
- <li *ngFor="let str of strList">{{str}}</li>
-</ul>
+++ /dev/null
-import { Component, OnInit } from '@angular/core';
-import { ActivatedRoute } from '@angular/router';
-import { EgNetService } from '@eg/core/net';
-import { EgAuthService } from '@eg/core/auth';
-
-@Component({
- templateUrl: 'app.component.html'
-})
-
-export class EgBcSearchComponent implements OnInit {
-
- barcode: String = '';
- strList: String[] = [];
-
- constructor(
- private route: ActivatedRoute,
- private net: EgNetService,
- private auth: EgAuthService
- ) {}
-
- ngOnInit() {
-
- this.barcode = this.route.snapshot.paramMap.get('barcode');
-
- if (this.barcode) {
- // Find the user and redirect to the
- }
-
- this.route.data.subscribe((data: { startup : any }) => {
- console.debug('EgBcSearch ngOnInit complete');
- });
-
- this.net.request(
- 'open-ils.actor',
- 'opensrf.system.echo',
- 'hello', 'goodbye', 'in the middle'
- ).subscribe(res => this.strList.push(res));
- }
-
- findUser(): void {
- // find user by this.barcode;
- }
-}
-
-
+++ /dev/null
-import { CommonModule } from '@angular/common';
-import { NgModule } from '@angular/core';
-import { FormsModule } from '@angular/forms';
-import { EgBcSearchComponent } from './app.component';
-import { EgBcSearchRoutingModule } from './routing.module';
-
-@NgModule({
- declarations: [
- EgBcSearchComponent
- ],
- imports: [
- EgBcSearchRoutingModule,
- CommonModule,
- FormsModule
- ],
-})
-
-export class EgBcSearchModule {}
-
+++ /dev/null
-import { NgModule } from '@angular/core';
-import { RouterModule, Routes } from '@angular/router';
-import { EgBcSearchComponent } from './app.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: '',
- children : [{
- path: 'patron',
- children: [{
- path: 'bcsearch',
- loadChildren: '@eg/staff/circ/patron/bcsearch/app.module#EgBcSearchModule'
- }]
- }]
-}];
-
-@NgModule({
- imports: [ RouterModule.forChild(routes) ],
- exports: [ RouterModule ]
-})
-
-export class EgCircRoutingModule {}
+++ /dev/null
-<div class="col-md-4 offset-md-4">
- <fieldset>
- <legend i18n>Sign In</legend>
- <hr/>
- <form (ngSubmit)="handleSubmit()" #loginForm="ngForm">
-
- <div class="form-group">
- <label for="username" i18n>Username</label>
- <input
- type="text"
- class="form-control"
- id="username"
- name="username"
- required
- i18n-placeholder
- placeholder="Username"
- [(ngModel)]="args.username"/>
- </div>
-
- <div class="form-group">
- <label for="password" i18n>Password</label>
- <input
- type="password"
- class="form-control"
- id="password"
- name="password"
- required
- i18n-placeholder
- placeholder="Password"
- [(ngModel)]="args.password"/>
- </div>
-
- <button type="submit" class="btn btn-light" i18n>Sign in</button>
- </form>
- </fieldset>
-</div>
+++ /dev/null
-import { Component, OnInit, Renderer } from '@angular/core';
-import { Location } from '@angular/common';
-import { Router } from '@angular/router';
-import { EgAuthService, EgAuthWsState } from '@eg/core/auth';
-import { EgStoreService } from '@eg/core/store'; // TODO: testing
-
-@Component({
- templateUrl : './login.component.html'
-})
-
-export class EgStaffLoginComponent implements OnInit {
-
- args = {
- username : '',
- password : '',
- type : 'staff',
- //workstation : ''
- workstation : 'BR1-skiddoo' // testing
- };
-
- workstations = [];
-
- constructor(
- private router: Router,
- private ngLocation: Location,
- private renderer: Renderer,
- private auth: EgAuthService,
- private store: EgStoreService
- ) {}
-
- ngOnInit() {
-
- // clear out any stale auth data
- this.auth.logout();
-
- // Focus username
- this.renderer.selectRootElement('#username').focus();
-
- // load browser-local workstation data
-
- // TODO: insert for testing.
- this.store.setItem(
- 'eg.workstation.all',
- [{name:'BR1-skiddoo',id:1,owning_lib:4}]
- );
- }
-
- 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 .dropdown-toggle::after {
- margin-left:0px;
-}
-
-#staff-navbar {
- background: -webkit-linear-gradient(#00593d, #007a54);
- background-color: #007a54;
- color: #fff;
- font-size: 14px;
-}
-
-#staff-navbar .navbar-nav {
- padding: 3px;
-}
-
-/* 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">
- <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">
- <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/catalog/search">
- <span class="material-icons">search</span>
- <span i18n>TODO</span>
- </a>
- </div>
- </div>
- </div>
-
-
- <div class="navbar-nav mr-auto"></div>
- <div class="navbar-nav">
- <span i18n>{{user}} @ {{workstation}}</span>
- </div>
- <div class="navbar-nav">
- <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>
- <a i18n class="dropdown-item" routerLink="/staff/login">
- <span class="material-icons">lock_outline</span>
- <span i18n>Logout</span>
- </a>
- </div>
- </div>
- </div>
- </div>
-</div>
-
+++ /dev/null
-import {Component, OnInit} from '@angular/core';
-import {ActivatedRoute, Router} from '@angular/router';
-import {EgAuthService} from '@eg/core/auth';
-
-@Component({
- selector: 'eg-staff-nav-bar',
- styleUrls: ['nav.component.css'],
- templateUrl: 'nav.component.html'
-})
-
-export class EgStaffNavComponent implements OnInit {
-
- user: string;
- workstation: string;
-
- constructor(private auth: EgAuthService) {}
-
- ngOnInit() {
- this.user = this.auth.user().usrname();
- this.workstation = this.auth.workstation();
- }
-}
-
-
+++ /dev/null
-import {Injectable} from '@angular/core';
-import {Location} from '@angular/common';
-import {Observable, Observer} from 'rxjs/Rx';
-import {Router, Resolve, RouterStateSnapshot,
- ActivatedRouteSnapshot} from '@angular/router';
-import {EgStoreService} from '@eg/core/store';
-import {EgNetService} from '@eg/core/net';
-import {EgAuthService} from '@eg/core/auth';
-
-/**
- * Apply configuration, etc. required by all staff components.
- * This resolver is called before authentication is confirmed.
- * See EgStaffCommonDataResolver for staff-wide, post-auth activities.
- */
-@Injectable()
-export class EgStaffResolver implements Resolve<Observable<any>> {
-
- readonly loginPath = '/staff/login';
- readonly wsAdminPath = '/staff/admin/workstation/workstations/manage';
-
- constructor(
- private router: Router,
- private ngLocation: Location,
- private store: EgStoreService,
- private net: EgNetService,
- private auth: EgAuthService
- ) {}
-
- 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');
-
- // Login resets everything. No need to load data.
- if (state.url == '/staff/login') return Observable.of(true);
-
- return Observable.create(observer => {
- this.auth.testAuthToken().then(
- tokenOk => {
- console.debug('EgStaffResolver: authtoken verified');
- this.auth.verifyWorkstation().then(
- wsOk => {
- this.loadStartupData(observer).then(
- ok => observer.complete()
- );
- },
- wsNotOk => {
- if (state.url != this.wsAdminPath) {
- this.router.navigate([this.wsAdminPath]);
- }
- observer.complete();
- }
- );
- },
- tokenNotOk => {
- // Authtoken is not OK.
- console.debug('EgStaffResolver: authtoken is not valid');
- this.auth.redirectUrl = state.url;
- this.router.navigate([this.loginPath]);
- observer.error('invalid auth');
- }
- );
- });
- }
-
- loadStartupData(observer: Observer<any>): 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 './app.component';
-import {EgStaffLoginComponent} from './login.component';
-import {EgStaffSplashComponent} from './splash.component';
-
-// Not using 'canActivate' because it's called before all resolvers,
-// but the resolvers parse the IDL, etc.
-
-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/app.module#EgCatalogModule'
- }, {
- path: 'admin',
- loadChildren : '@eg/staff/admin/routing.module#EgAdminRoutingModule'
- }]
-}];
-
-@NgModule({
- imports: [ RouterModule.forChild(routes) ],
- exports: [ RouterModule ],
- providers: [
- EgStaffResolver
- ]
-})
-
-export class EgStaffRoutingModule {}
-
+++ /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';
-import {EgPcrudService} from '@eg/core/pcrud';
-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
-
-
-<style>
- /* TODO change BS color scheme so this isn't necessary */
- .bg-evergreen {
- background: -webkit-linear-gradient(#00593d, #007a54);
- background-color: #007a54;
- color: #fff;
- }
-</style>
-
-<div class="container">
-
- <!-- header icon -->
- <div class="row mb-3">
- <div class="col-12 text-center">
- <img src="/images/portal/logo.png"/>
- </div>
- </div>
-
- <div class="row" id="splash-nav">
- <div class="col-4">
- <div class="card">
- <div class="card-header bg-evergreen">
- <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-4">
- <div class="card">
- <div class="card-header bg-evergreen">
- <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-4">
- <div class="card">
- <div class="card-header bg-evergreen">
- <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
-<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>
- or <a routerLink="/catalog">the catalog.</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"
- href="https://maxcdn.bootstrapcdn.com/bootstrap/4.0.0-beta.2/css/bootstrap.min.css"
- integrity="sha384-PsH8R72JQ3SOdhVi3uxftmaW6Vc51MKb0q5P2rRUpPvrszuE4W1povHYgTpBfshb"
- crossorigin="anonymous">
-</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 */
-.flex-1 {flex: 1}
-.flex-2 {flex: 2}
-.flex-3 {flex: 3}
-.flex-4 {flex: 4}
-.flex-5 {flex: 5}
-
-
-/* usefulf 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;
-}
-
+++ /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
- }
-}