feat: add library account screens

This commit is contained in:
Jovan Krunić
2022-01-31 08:53:46 +00:00
parent 863a3ffd48
commit 080e6fa3e8
29 changed files with 770 additions and 1 deletions

View File

@@ -63,6 +63,7 @@ import {DebugDataCollectorService} from './modules/data/debug-data-collector.ser
import {Browser} from './util/browser.factory';
import {browserFactory} from './util/browser.factory';
import {AuthModule} from './modules/auth/auth.module';
import {LibraryModule} from './modules/library/library.module';
registerLocaleData(localeDe);
@@ -158,6 +159,7 @@ const providers: Provider[] = [
EndSessionPageModule,
IonicModule.forRoot(),
FavoritesModule,
LibraryModule,
HttpClientModule,
ProfilePageModule,
FeedbackModule,

View File

@@ -22,6 +22,8 @@ import {HttpClient} from '@angular/common/http';
import {DataProvider} from '../data/data.provider';
import {SCHebisSearchRoute} from './protocol/route';
const HEBIS_PREFIX = 'HEB';
/**
* Generated class for the DataProvider provider.
*
@@ -72,10 +74,17 @@ export class HebisDataProvider extends DataProvider {
* All results will be returned if no size is set in the request.
*
* @param searchRequest Search request
* @param options Search options
* @param options.addPrefix Add HeBIS prefix (useful when having only an ID, e.g. when using PAIA)
*/
async hebisSearch(
searchRequest: SCHebisSearchRequest,
options?: {addPrefix: boolean},
): Promise<SCHebisSearchResponse> {
if (options?.addPrefix) {
searchRequest.query = [HEBIS_PREFIX, searchRequest.query].join('');
}
let page: number | undefined = searchRequest.page;
if (typeof page === 'undefined') {

View File

@@ -0,0 +1,33 @@
<ion-header>
<ion-toolbar>
<ion-buttons slot="start">
<ion-back-button></ion-back-button>
<ion-menu-button></ion-menu-button>
</ion-buttons>
<ion-title>{{ 'library.account.title' | translate | titlecase }}</ion-title>
</ion-toolbar>
</ion-header>
<ion-content>
<p *ngIf="name">
{{ 'library.account.greeting' | translate | titlecase }}
{{ name | firstLastName }}!
{{ 'library.account.login.success' | translate }}
</p>
<ion-item [routerLink]="['profile']">
<ion-icon name="person" slot="start"></ion-icon
>{{ 'library.account.pages.profile.title' | translate | titlecase }}
</ion-item>
<ion-item [routerLink]="['holds-and-reservations']">
<ion-icon name="library" slot="start"></ion-icon
>{{ 'library.account.pages.holds.title' | translate | titlecase }}
</ion-item>
<ion-item [routerLink]="['checked-out']">
<ion-icon name="book" slot="start"></ion-icon
>{{ 'library.account.pages.checked_out.title' | translate | titlecase }}
</ion-item>
<ion-item [routerLink]="['fines']">
<ion-icon name="cash-outline" slot="start"></ion-icon
>{{ 'library.account.pages.fines.title' | translate | titlecase }}
</ion-item>
</ion-content>

View File

@@ -0,0 +1,4 @@
ion-content {
--padding-start: 8px;
--padding-top: 8px;
}

View File

@@ -0,0 +1,22 @@
import {Component} from '@angular/core';
import {LibraryAccountService} from './library-account.service';
// import {Router} from '@angular/router';
@Component({
templateUrl: './account.page.html',
styleUrls: ['./account.page.scss'],
})
export class LibraryAccountPageComponent {
name: string;
constructor(private readonly libraryAccountService: LibraryAccountService) {}
async ionViewWillEnter(): Promise<void> {
try {
const patron = await this.libraryAccountService.getProfile();
this.name = patron.name;
} catch {
// this.router.navigate(['profile']);
}
}
}

View File

@@ -0,0 +1,22 @@
<ion-header>
<ion-toolbar>
<ion-buttons slot="start">
<ion-back-button></ion-back-button>
<ion-menu-button></ion-menu-button>
</ion-buttons>
<ion-title>{{
'library.account.pages.checked_out.title' | translate | titlecase
}}</ion-title>
</ion-toolbar>
</ion-header>
<ion-content>
<ng-container *ngIf="checkedOutItems">
<stapps-paia-item
*ngFor="let checkedOutItem of checkedOutItems"
[item]="checkedOutItem"
pageName="checked_out"
>
</stapps-paia-item>
</ng-container>
</ion-content>

View File

@@ -0,0 +1,23 @@
import {Component} from '@angular/core';
import {PAIADocument} from '../../types';
import {LibraryAccountService} from '../library-account.service';
@Component({
selector: 'app-checked-out',
templateUrl: './checked-out-page.component.html',
styleUrls: ['./checked-out-page.component.scss'],
})
export class CheckedOutPageComponent {
checkedOutItems: PAIADocument[];
constructor(private readonly libraryAccountService: LibraryAccountService) {}
async ionViewWillEnter(): Promise<void> {
try {
this.checkedOutItems =
await this.libraryAccountService.getCheckedOutItems();
} catch {
// TODO: error handling
}
}
}

View File

@@ -0,0 +1,38 @@
<!--
~ Copyright (C) 2022 StApps
~ This program is free software: you can redistribute it and/or modify it
~ under the terms of the GNU General Public License as published by the Free
~ Software Foundation, version 3.
~
~ This program is distributed in the hope that it will be useful, but WITHOUT
~ ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
~ FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
~ more details.
~
~ You should have received a copy of the GNU General Public License along with
~ this program. If not, see <https://www.gnu.org/licenses/>.
-->
<ion-card class="bold-header">
<ion-card-header *ngIf="book">{{ book.name }}</ion-card-header>
<ion-card-content>
<ion-grid *ngFor="let property of additionalPropertiesToShow">
<ion-row *ngIf="item[property]">
<ion-col>
{{
'library.account.pages' +
'.' +
pageName +
'.' +
'labels' +
'.' +
property
| translate
| titlecase
}}:</ion-col
>
<ion-col width-60 text-right>{{ item[property] }}</ion-col>
</ion-row>
</ion-grid>
</ion-card-content>
</ion-card>

View File

@@ -0,0 +1,14 @@
/*!
* Copyright (C) 2022 StApps
* This program is free software: you can redistribute it and/or modify it
* under the terms of the GNU General Public License as published by the Free
* Software Foundation, version 3.
*
* This program is distributed in the hope that it will be useful, but WITHOUT
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
* FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
*
* You should have received a copy of the GNU General Public License along with
* this program. If not, see <https://www.gnu.org/licenses/>.
*/

View File

@@ -0,0 +1,55 @@
/*
* Copyright (C) 2022 StApps
* This program is free software: you can redistribute it and/or modify it
* under the terms of the GNU General Public License as published by the Free
* Software Foundation, version 3.
*
* This program is distributed in the hope that it will be useful, but WITHOUT
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
* FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
*
* You should have received a copy of the GNU General Public License along with
* this program. If not, see <https://www.gnu.org/licenses/>.
*/
import {Component, Input} from '@angular/core';
import {PAIADocument} from '../../../types';
import {SCArticle, SCBook, SCPeriodical} from '@openstapps/core';
import {LibraryAccountService} from '../../library-account.service';
@Component({
selector: 'stapps-paia-item',
templateUrl: './paiaitem.component.html',
styleUrls: ['./paiaitem.component.scss'],
})
export class PAIAItemComponent {
book?: SCBook | SCPeriodical | SCArticle;
private _item: PAIADocument;
additionalPropertiesToShow: (keyof PAIADocument)[] = [
'about',
'endtime',
'label',
];
@Input()
set item(item: PAIADocument) {
this._item = item;
this.libraryAccountService
.getDocumentFromHDS(item.edition as string)
.then(book => {
this.book = book;
});
}
get item() {
return this._item;
}
@Input()
pageName: string;
constructor(private readonly libraryAccountService: LibraryAccountService) {}
}

View File

@@ -0,0 +1,42 @@
<ion-header>
<ion-toolbar>
<ion-buttons slot="start">
<ion-back-button></ion-back-button>
<ion-menu-button></ion-menu-button>
</ion-buttons>
<ion-title>{{
'library.account.pages.fines.title' | translate | titlecase
}}</ion-title>
</ion-toolbar>
</ion-header>
<ion-content>
<ion-card *ngFor="let fine of fines" class="bold-header">
<ion-card-header *ngIf="fine.about">{{ fine.about }}</ion-card-header>
<ion-card-content>
<ion-grid *ngFor="let property of additionalPropertiesToShow">
<ion-row *ngIf="fine[property]">
<ion-col>
{{
'library.account.pages.fines.labels' + '.' + property
| translate
| titlecase
}}:</ion-col
>
<ion-col width-60 text-right>{{ fine[property] }}</ion-col>
</ion-row>
</ion-grid>
</ion-card-content>
</ion-card>
<ion-grid *ngIf="amount">
<ion-row *ngIf="amount">
<ion-col>
{{
'library.account.pages.fines.labels.amount' | translate | titlecase
}}:
</ion-col>
<ion-col>
{{ amount }}
</ion-col>
</ion-row>
</ion-grid>
</ion-content>

View File

@@ -0,0 +1,46 @@
import {Component} from '@angular/core';
import {LibraryAccountService} from '../library-account.service';
import {PAIAFee, PAIAFees} from '../../types';
@Component({
selector: 'app-fines',
templateUrl: './fines-page.component.html',
styleUrls: ['./fines-page.component.scss'],
})
export class FinesPageComponent {
amount: string | undefined;
additionalPropertiesToShow: (keyof PAIAFee)[] = [
'item',
'date',
'feetype',
'feeid',
];
fines: PAIAFee[];
constructor(private readonly libraryAccountService: LibraryAccountService) {}
async ionViewWillEnter(): Promise<void> {
let fees: PAIAFees;
try {
fees = await this.libraryAccountService.getFees();
this.amount = fees.amount;
this.fines = this.cleanUp(fees.fee);
} catch {
// TODO: error handling
}
}
private cleanUp(fines: PAIAFee[]): PAIAFee[] {
for (const fine of fines) {
for (const property in fine) {
// remove properties with "null" included
if (fine[<keyof PAIAFee>property]?.includes('null')) {
delete fine.item;
}
}
}
return fines;
}
}

View File

@@ -0,0 +1,10 @@
import {Pipe, PipeTransform} from '@angular/core';
@Pipe({
name: 'firstLastName',
})
export class FirstLastNamePipe implements PipeTransform {
transform(value: string): unknown {
return value.split(', ').reverse().join(' ');
}
}

View File

@@ -0,0 +1,17 @@
<ion-header>
<ion-toolbar>
<ion-buttons slot="start">
<ion-back-button></ion-back-button>
<ion-menu-button></ion-menu-button>
</ion-buttons>
<ion-title>{{
'library.account.pages.holds.title' | translate | titlecase
}}</ion-title>
</ion-toolbar>
</ion-header>
<ion-content>
<ng-container *ngIf="holds">
<stapps-paia-item *ngFor="let hold of holds" [item]="hold" pageName="holds">
</stapps-paia-item>
</ng-container>
</ion-content>

View File

@@ -0,0 +1,22 @@
import {Component} from '@angular/core';
import {LibraryAccountService} from '../library-account.service';
import {PAIADocument} from '../../types';
@Component({
selector: 'app-holds-and-reservations',
templateUrl: './holds-and-reservations-page.component.html',
styleUrls: ['./holds-and-reservations-page.component.scss'],
})
export class HoldsAndReservationsPageComponent {
holds: PAIADocument[];
constructor(private readonly libraryAccountService: LibraryAccountService) {}
async ionViewWillEnter(): Promise<void> {
try {
this.holds = await this.libraryAccountService.getHoldsAndReservations();
} catch {
// TODO: error handling
}
}
}

View File

@@ -0,0 +1,100 @@
import {Injectable} from '@angular/core';
// import {ConfigProvider} from '../../config/config.provider';
import {PAIAAuthService} from '../../auth/paia/paia-auth.service';
import {JQueryRequestor, Requestor} from '@openid/appauth';
import {SCFeatureConfigurationExtern, SCMap} from '@openstapps/core';
import {PAIADocumentStatus, PAIAFees, PAIAItems, PAIAPatron} from '../types';
import {HebisDataProvider} from '../../hebis/hebis-data.provider';
@Injectable({
providedIn: 'root',
})
export class LibraryAccountService {
// TODO: must come from backend (not that stable)
private _config: SCMap<SCFeatureConfigurationExtern> = {
profile: {
url: 'https://hds.hebis.de:8443/core/{patron}',
},
items: {
url: 'https://hds.hebis.de:8443/core/{patron}/items',
},
fees: {
url: 'https://hds.hebis.de:8443/core/{patron}/fees',
},
};
constructor(
protected requestor: Requestor = new JQueryRequestor(),
private readonly paiaAuth: PAIAAuthService,
private readonly hebisDataProvider: HebisDataProvider,
) {}
get config() {
return this._config;
}
async getProfile() {
return this.performRequest<PAIAPatron>(this.config.profile.url);
}
async getItems() {
return this.performRequest<PAIAItems>(this.config.items.url);
}
async getFees() {
return this.performRequest<PAIAFees>(this.config.fees.url);
}
private async performRequest<T>(urlTemplate: string): Promise<T> {
const token = await this.paiaAuth.getValidToken();
const url = urlTemplate.replace('{patron}', token.patron);
const settings: JQueryAjaxSettings = {
url: url,
dataType: 'json',
method: 'GET',
headers: {
'Authorization': `Bearer: ${token.accessToken}`,
'Content-Type': 'application/json',
},
};
return this.requestor.xhr(settings);
}
getRawId(input: string) {
return input.split(':').pop();
}
async getHoldsAndReservations() {
return (await this.getItems()).doc.filter(document => {
return [
PAIADocumentStatus.Reserved,
PAIADocumentStatus.Ordered,
PAIADocumentStatus.Provided,
].includes(Number(document.status));
});
}
async getCheckedOutItems() {
return (await this.getItems()).doc.filter(document => {
// return [PAIADocumentStatus.Held].includes(Number(document.status));
// TODO: Put back the line above (demo purposes)
return [PAIADocumentStatus.Rejected].includes(Number(document.status));
});
}
async getDocumentFromHDS(edition: string) {
if (typeof edition === 'undefined') {
return;
}
const response = await this.hebisDataProvider.hebisSearch(
{
query: this.getRawId(edition) as string,
page: 0,
},
{addPrefix: true},
);
return response.data[0];
}
}

View File

@@ -0,0 +1,29 @@
<ion-header>
<ion-toolbar>
<ion-buttons slot="start">
<ion-back-button></ion-back-button>
<ion-menu-button></ion-menu-button>
</ion-buttons>
<ion-title>{{
'library.account.pages.profile.title' | translate | titlecase
}}</ion-title>
</ion-toolbar>
</ion-header>
<ion-content>
<ion-grid *ngIf="patron">
<ng-container *ngFor="let property of propertiesToShow">
<ion-row *ngIf="patron[property]">
<ion-col>
{{
'library.account.pages.profile.labels' + '.' + property
| translate
| titlecase
}}:
</ion-col>
<ion-col>
{{ patron[property] }}
</ion-col>
</ion-row>
</ng-container>
</ion-grid>
</ion-content>

View File

@@ -0,0 +1,31 @@
import {Component} from '@angular/core';
import {LibraryAccountService} from '../library-account.service';
import {PAIAPatron} from '../../types';
@Component({
selector: 'app-profile',
templateUrl: './profile-page.component.html',
styleUrls: ['./profile-page.component.scss'],
})
export class ProfilePageComponent {
patron: PAIAPatron;
propertiesToShow: (keyof PAIAPatron)[] = [
'name',
'email',
'address',
'expires',
'note',
];
constructor(private readonly libraryAccountService: LibraryAccountService) {}
async ionViewWillEnter(): Promise<void> {
try {
this.patron = await this.libraryAccountService.getProfile();
console.log(this.patron);
} catch {
// TODO: error handling
}
}
}

View File

@@ -0,0 +1,51 @@
import {NgModule} from '@angular/core';
import {CommonModule} from '@angular/common';
import {FormsModule} from '@angular/forms';
import {IonicModule} from '@ionic/angular';
import {RouterModule, Routes} from '@angular/router';
import {TranslateModule} from '@ngx-translate/core';
import {LibraryAccountPageComponent} from './account/account.page';
import {AuthRoutes} from '../auth/auth-routes';
import {ProfilePageComponent} from './account/profile/profile-page.component';
import {CheckedOutPageComponent} from './account/checked-out/checked-out-page.component';
import {HoldsAndReservationsPageComponent} from './account/holds-and-reservations/holds-and-reservations-page.component';
import {FinesPageComponent} from './account/fines/fines-page.component';
import {PAIAItemComponent} from './account/elements/paia-item/paiaitem.component';
import {FirstLastNamePipe} from './account/first-last-name.pipe';
import {AuthGuardService} from '../auth/auth-guard.service';
const routes: AuthRoutes | Routes = [
{
path: 'library-account',
component: LibraryAccountPageComponent,
data: {authProvider: 'paia'},
canActivate: [AuthGuardService],
},
{path: 'library-account/profile', component: ProfilePageComponent},
{path: 'library-account/checked-out', component: CheckedOutPageComponent},
{
path: 'library-account/holds-and-reservations',
component: HoldsAndReservationsPageComponent,
},
{path: 'library-account/fines', component: FinesPageComponent},
];
@NgModule({
imports: [
CommonModule,
FormsModule,
IonicModule,
RouterModule.forChild(routes),
TranslateModule,
],
declarations: [
LibraryAccountPageComponent,
ProfilePageComponent,
CheckedOutPageComponent,
HoldsAndReservationsPageComponent,
FinesPageComponent,
PAIAItemComponent,
FirstLastNamePipe,
],
})
export class LibraryModule {}

View File

@@ -0,0 +1,69 @@
/*
* Copyright (C) 2022 StApps
* This program is free software: you can redistribute it and/or modify it
* under the terms of the GNU General Public License as published by the Free
* Software Foundation, version 3.
*
* This program is distributed in the hope that it will be useful, but WITHOUT
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
* FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
*
* You should have received a copy of the GNU General Public License along with
* this program. If not, see <https://www.gnu.org/licenses/>.
*/
export interface PAIAPatron {
name: string;
email?: string;
address?: string;
expires?: string;
status?: string;
type?: string;
note?: string;
}
export interface PAIADocument {
status: string;
item?: string;
edition?: string;
about?: string;
label?: string;
queue?: string;
renewals?: string;
reminder?: string;
endtime?: string;
duedate?: string;
cancancel?: boolean;
canrenew?: boolean;
// with locations
condition?: unknown;
}
export interface PAIAItems {
doc: PAIADocument[];
}
export interface PAIAFee {
amount: string;
date?: string;
about?: string;
item?: string;
edition?: string;
feetype?: string;
feeid?: string;
}
export interface PAIAFees {
amount?: string;
fee: PAIAFee[];
}
export enum PAIADocumentStatus {
NoRelation = 0,
Reserved = 1,
Ordered = 2,
Held = 3,
Provided = 4,
Rejected = 5,
}

View File

@@ -21,6 +21,21 @@ export class NavigationService {
} catch (error) {
this.logger.error(`error from loading menu entries: ${error}`);
}
// TODO: move this menu item to the config (backend)
menu[1].items.unshift({
icon: 'library',
route: '/library-account',
title: 'library account',
translations: {
de: {
title: 'Bibliothekskonto',
},
en: {
title: 'Library account',
},
},
});
return menu;
}
}

View File

@@ -169,6 +169,61 @@
"EMPTY_SEMESTER": "Keine Verzeichnisdaten für das ausgewählte Semester vorhanden"
}
},
"library": {
"account": {
"title": "Bibliothekskonto",
"greeting": "hallo",
"login": {
"success": "Du bist eingeloggt und kannst Dein Konto nutzen.",
"error": "Du bist nicht eingeloggt oder deine Sitzung ist abgelaufen."
},
"pages": {
"profile": {
"title": "Deine persönlichen Daten",
"labels": {
"id": "Nutzer-ID",
"name": "Name",
"email": "E-Mail",
"address": "Adresse",
"expires": "Nutzungsberechtigung endet am",
"status": "Status",
"type": "Typ",
"note": "Nachricht"
}
},
"holds": {
"title": "Bestellungen und Vormerkungen",
"labels": {
"title": "Titel",
"about": "mehr Informationen",
"label": "Label",
"endtime": "voraussichtliche Verfügbarkeit"
}
},
"checked_out": {
"title": "checked out items",
"labels": {
"title": "Titel",
"about": "mehr Informationen",
"label": "Label",
"endtime": "Leihfrist"
}
},
"fines": {
"title": "Gebühren",
"labels": {
"amount": "Betrag",
"about": "Information",
"date": "Datum",
"item": "Artikel",
"edition": "Ausgabe",
"feetype": "Gebührenart",
"feeid": "Gebühren ID"
}
}
}
}
},
"menu": {
"context": {
"title": "Kontext Menü",

View File

@@ -169,6 +169,61 @@
"EMPTY_SEMESTER": "No catalog data available for selected semester"
}
},
"library": {
"account": {
"title": "library account",
"greeting": "hello",
"login": {
"success": "You are logged-in and ready to access your account.",
"error": "Not logged in or login expired."
},
"pages": {
"profile": {
"title": "library profile",
"labels": {
"id": "user ID",
"name": "name",
"email": "email",
"address": "Address",
"expires": "membership expires",
"status": "status",
"type": "type",
"note": "note"
}
},
"holds": {
"title": "holds and reservations",
"labels": {
"title": "title",
"about": "more information",
"label": "label",
"endtime": "Expected to be available"
}
},
"checked_out": {
"title": "checked out items",
"labels": {
"title": "title",
"about": "more information",
"label": "label",
"endtime": "Loan period ends"
}
},
"fines": {
"title": "fines",
"labels": {
"amount": "amount",
"about": "about",
"date": "date",
"item": "item",
"edition": "edition",
"feetype": "fee type",
"feeid": "fee ID"
}
}
}
}
},
"menu": {
"context": {
"title": "context menu",

View File

@@ -47,7 +47,6 @@ export const environment = {
// TODO: Use Custom URL Scheme (ideally bundle ID from capacitor.config)
redirect_url: `https://${appDomain}/auth/paia/callback`,
scopes: '',
// TODO: PAIA need to support PKCE, it will then work "out-of-the-box"
pkce: true,
} as IAuthConfig,
},

View File

@@ -82,3 +82,9 @@ ion-item, ion-card.compact {
--width: fit-content;
--max-width: 95%;
}
ion-card.bold-header {
ion-card-header {
font-weight: bold;
}
}