refactor: improve library account views

This commit is contained in:
Jovan Krunić
2022-05-19 15:41:07 +00:00
parent 9efc41a8f8
commit 5edec7154e
36 changed files with 694 additions and 303 deletions

View File

@@ -125,7 +125,7 @@ executable:
review: review:
stage: deploy stage: deploy
script: script:
- npm run build:prod - npm run build
- .gitlab/ci/enableGitlabReviewToolbar.sh www/index.html "$CI_PROJECT_ID" "$CI_OPEN_MERGE_REQUESTS" - .gitlab/ci/enableGitlabReviewToolbar.sh www/index.html "$CI_PROJECT_ID" "$CI_OPEN_MERGE_REQUESTS"
- cp www/index.html www/200.html - cp www/index.html www/200.html
- ./node_modules/.bin/surge -p ./www -d https://$CI_PROJECT_PATH_SLUG-$CI_ENVIRONMENT_SLUG.surge.sh/ - ./node_modules/.bin/surge -p ./www -d https://$CI_PROJECT_PATH_SLUG-$CI_ENVIRONMENT_SLUG.surge.sh/

View File

@@ -0,0 +1,36 @@
import {Injectable} from '@angular/core';
import {
HttpRequest,
HttpHandler,
HttpEvent,
HttpInterceptor,
HttpErrorResponse,
} from '@angular/common/http';
import {Observable, throwError} from 'rxjs';
import {NGXLogger} from 'ngx-logger';
import {catchError} from 'rxjs/operators';
@Injectable()
export class ServiceHandlerInterceptor implements HttpInterceptor {
constructor(private readonly logger: NGXLogger) {}
intercept(
request: HttpRequest<unknown>,
next: HttpHandler,
): Observable<HttpEvent<unknown>> {
return next.handle(request).pipe(
// Fixes the issue of errors dropping into "toPromise()"
// and being not able to catch it in the "caller methods"
catchError((error: HttpErrorResponse) => {
const errorMessage =
error.error instanceof ErrorEvent
? `Error: ${error.error.message}`
: `Error Code: ${error.status}, Message: ${error.message}`;
this.logger.error(errorMessage);
return throwError(errorMessage);
}),
);
}
}

View File

@@ -18,7 +18,11 @@ import {
PathLocationStrategy, PathLocationStrategy,
registerLocaleData, registerLocaleData,
} from '@angular/common'; } from '@angular/common';
import {HttpClient, HttpClientModule} from '@angular/common/http'; import {
HTTP_INTERCEPTORS,
HttpClient,
HttpClientModule,
} from '@angular/common/http';
import localeDe from '@angular/common/locales/de'; import localeDe from '@angular/common/locales/de';
import {APP_INITIALIZER, NgModule} from '@angular/core'; import {APP_INITIALIZER, NgModule} from '@angular/core';
import {BrowserModule} from '@angular/platform-browser'; import {BrowserModule} from '@angular/platform-browser';
@@ -64,6 +68,7 @@ import {BackgroundModule} from './modules/background/background.module';
import {LibraryModule} from './modules/library/library.module'; import {LibraryModule} from './modules/library/library.module';
import {StorageProvider} from './modules/storage/storage.provider'; import {StorageProvider} from './modules/storage/storage.provider';
import {AssessmentsModule} from './modules/assessments/assessments.module'; import {AssessmentsModule} from './modules/assessments/assessments.module';
import {ServiceHandlerInterceptor} from './_helpers/service-handler.interceptor';
import {RoutingStackService} from './util/routing-stack.service'; import {RoutingStackService} from './util/routing-stack.service';
import {SCSettingValue} from '@openstapps/core'; import {SCSettingValue} from '@openstapps/core';
import {DefaultAuthService} from './modules/auth/default-auth.service'; import {DefaultAuthService} from './modules/auth/default-auth.service';
@@ -79,7 +84,6 @@ registerLocaleData(localeDe);
* @param settingsProvider provider of settings (e.g. language that has been set) * @param settingsProvider provider of settings (e.g. language that has been set)
* @param configProvider TODO * @param configProvider TODO
* @param translateService TODO * @param translateService TODO
* @param _routingStackService Just for init and to track the stack from the get go
*/ */
export function initializerFactory( export function initializerFactory(
storageProvider: StorageProvider, storageProvider: StorageProvider,
@@ -207,6 +211,11 @@ export function createTranslateLoader(http: HttpClient) {
], ],
useFactory: initializerFactory, useFactory: initializerFactory,
}, },
{
provide: HTTP_INTERCEPTORS,
useClass: ServiceHandlerInterceptor,
multi: true,
},
], ],
}) })
export class AppModule { export class AppModule {

View File

@@ -2,6 +2,7 @@ import {Injectable} from '@angular/core';
import {Requestor} from '@openid/appauth'; import {Requestor} from '@openid/appauth';
import {HttpClient, HttpHeaders} from '@angular/common/http'; import {HttpClient, HttpHeaders} from '@angular/common/http';
import {XhrSettings} from 'ionic-appauth/lib/cordova'; import {XhrSettings} from 'ionic-appauth/lib/cordova';
import {Observable} from 'rxjs';
@Injectable({ @Injectable({
providedIn: 'root', providedIn: 'root',
@@ -14,28 +15,32 @@ export class NgHttpService implements Requestor {
settings.method = 'GET'; settings.method = 'GET';
} }
let observable: Observable<T>;
switch (settings.method) { switch (settings.method) {
case 'GET': case 'GET':
return this.http observable = this.http.get<T>(settings.url, {
.get<T>(settings.url, {headers: this.getHeaders(settings.headers)}) headers: this.getHeaders(settings.headers),
.toPromise(); });
break;
case 'POST': case 'POST':
return this.http observable = this.http.post<T>(settings.url, settings.data, {
.post<T>(settings.url, settings.data, { headers: this.getHeaders(settings.headers),
headers: this.getHeaders(settings.headers), });
}) break;
.toPromise();
case 'PUT': case 'PUT':
return this.http observable = this.http.put<T>(settings.url, settings.data, {
.put<T>(settings.url, settings.data, { headers: this.getHeaders(settings.headers),
headers: this.getHeaders(settings.headers), });
}) break;
.toPromise();
case 'DELETE': case 'DELETE':
return this.http observable = this.http.delete<T>(settings.url, {
.delete<T>(settings.url, {headers: this.getHeaders(settings.headers)}) headers: this.getHeaders(settings.headers),
.toPromise(); });
break;
} }
return observable.toPromise();
} }
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any

View File

@@ -12,7 +12,7 @@
* You should have received a copy of the GNU General Public License along with * You should have received a copy of the GNU General Public License along with
* this program. If not, see <https://www.gnu.org/licenses/>. * this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
import {Component} from '@angular/core'; import {Component, Input} from '@angular/core';
/** /**
* A placeholder to show when a simple card is being loaded * A placeholder to show when a simple card is being loaded
@@ -21,4 +21,16 @@ import {Component} from '@angular/core';
selector: 'stapps-skeleton-simple-card', selector: 'stapps-skeleton-simple-card',
templateUrl: 'skeleton-simple-card.html', templateUrl: 'skeleton-simple-card.html',
}) })
export class SkeletonSimpleCardComponent {} export class SkeletonSimpleCardComponent {
/**
* Show title
*/
@Input()
title = true;
/**
* The number of lines after the title
*/
@Input()
lines = 1;
}

View File

@@ -1,8 +1,14 @@
<ion-card> <ion-card>
<ion-card-header> <ion-card-header *ngIf="title">
<ion-skeleton-text animated style="width: 15%"></ion-skeleton-text> <ion-skeleton-text animated style="width: 15%"></ion-skeleton-text>
</ion-card-header> </ion-card-header>
<ion-card-content> <ion-card-content>
<p><ion-skeleton-text animated style="width: 85%"></ion-skeleton-text></p> <p>
<ion-skeleton-text
*ngFor="let line of [].constructor(lines)"
animated
style="width: 85%"
></ion-skeleton-text>
</p>
</ion-card-content> </ion-card-content>
</ion-card> </ion-card>

View File

@@ -9,11 +9,14 @@
</ion-header> </ion-header>
<ion-content> <ion-content>
<p *ngIf="name"> <p *ngIf="name; else loading">
{{ 'library.account.greeting' | translate | titlecase }} {{ 'library.account.greeting' | translate }}
{{ name | firstLastName }}! {{ name | firstLastName }}!
{{ 'library.account.login.success' | translate }} {{ 'library.account.login.success' | translate }}
</p> </p>
<ng-template #loading>
<p><ion-skeleton-text animated style="width: 80%"></ion-skeleton-text></p>
</ng-template>
<ion-item [routerLink]="['profile']"> <ion-item [routerLink]="['profile']">
<ion-icon name="person" slot="start"></ion-icon <ion-icon name="person" slot="start"></ion-icon
>{{ 'library.account.pages.profile.title' | translate | titlecase }} >{{ 'library.account.pages.profile.title' | translate | titlecase }}

View File

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

View File

@@ -1,22 +0,0 @@
<ion-header>
<ion-toolbar color="primary">
<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

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

View File

@@ -0,0 +1,38 @@
<ion-header>
<ion-toolbar color="primary">
<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; else fallback">
<stapps-paia-item
*ngFor="let checkedOutItem of checkedOutItems"
[item]="checkedOutItem"
[propertiesToShow]="['endtime', 'label']"
(documentAction)="onDocumentAction($event)"
listName="checked_out"
>
</stapps-paia-item>
</ng-container>
<ng-template #fallback>
<stapps-skeleton-list-item
hideThumbnail="true"
*ngIf="!checkedOutItems; else nothingFound"
></stapps-skeleton-list-item>
<ng-template #nothingFound>
<ion-label
*ngIf="checkedOutItems && checkedOutItems.length === 0"
class="centeredMessageContainer"
>
{{ 'search.nothing_found' | translate | titlecase }}
</ion-label>
</ng-template>
</ng-template>
</ion-content>

View File

@@ -0,0 +1,33 @@
import {Component, Input} from '@angular/core';
import {PAIAFee} from '../../../types';
import {SCArticle, SCBook, SCPeriodical} from '@openstapps/core';
import {LibraryAccountService} from '../../library-account.service';
@Component({
selector: 'stapps-fee-item',
templateUrl: './fee-item.html',
styleUrls: ['./fee-item.scss'],
})
export class FeeItemComponent {
_fee: PAIAFee;
book?: SCBook | SCPeriodical | SCArticle;
@Input()
set fee(fee: PAIAFee) {
this._fee = fee;
this.libraryAccountService
.getDocumentFromHDS(fee.edition as string)
.then(book => {
this.book = book;
});
}
get fee() {
return this._fee;
}
propertiesToShow: (keyof PAIAFee)[] = ['about', 'date', 'amount'];
constructor(private readonly libraryAccountService: LibraryAccountService) {}
}

View File

@@ -0,0 +1,23 @@
<ion-item>
<ion-label class="ion-text-wrap">
<h2 *ngIf="book; else loading" class="name">
{{ 'library.account.pages.fines.labels.edition' | translate }}:
{{ book.name }}
</h2>
<ng-template #loading
><h2>
<ion-skeleton-text animated style="width: 80%"></ion-skeleton-text></h2
></ng-template>
<ng-container *ngFor="let property of propertiesToShow">
<p *ngIf="fee[property]">
{{ 'library.account.pages.fines.labels' + '.' + property | translate }}:
<ng-container *ngIf="!['date'].includes(property); else date">
{{ fee[property] }}
</ng-container>
<ng-template #date>
{{ fee[property] | amDateFormat: 'll' }}
</ng-template>
</p></ng-container
>
</ion-label>
</ion-item>

View File

@@ -1,38 +0,0 @@
<!--
~ 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

@@ -13,43 +13,27 @@
* this program. If not, see <https://www.gnu.org/licenses/>. * this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
import {Component, Input} from '@angular/core'; import {Component, EventEmitter, Input, Output} from '@angular/core';
import {PAIADocument} from '../../../types'; import {DocumentAction, PAIADocument} from '../../../types';
import {SCArticle, SCBook, SCPeriodical} from '@openstapps/core';
import {LibraryAccountService} from '../../library-account.service';
@Component({ @Component({
selector: 'stapps-paia-item', selector: 'stapps-paia-item',
templateUrl: './paiaitem.component.html', templateUrl: './paiaitem.html',
styleUrls: ['./paiaitem.component.scss'], styleUrls: ['./paiaitem.scss'],
}) })
export class PAIAItemComponent { export class PAIAItemComponent {
book?: SCBook | SCPeriodical | SCArticle; @Input() item: PAIADocument;
private _item: PAIADocument;
additionalPropertiesToShow: (keyof PAIADocument)[] = [
'about',
'endtime',
'label',
];
@Input() @Input()
set item(item: PAIADocument) { propertiesToShow: (keyof PAIADocument)[];
this._item = item;
this.libraryAccountService
.getDocumentFromHDS(item.edition as string)
.then(book => {
this.book = book;
});
}
get item() {
return this._item;
}
@Input() @Input()
pageName: string; listName: string;
constructor(private readonly libraryAccountService: LibraryAccountService) {} @Output()
documentAction: EventEmitter<DocumentAction> = new EventEmitter<DocumentAction>();
async onClick(action: DocumentAction['action']) {
this.documentAction.emit({doc: this.item, action});
}
} }

View File

@@ -0,0 +1,58 @@
<!--
~ 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-item>
<!-- TODO: text not selectable in Chrome, bugfix needed https://github.com/ionic-team/ionic-framework/issues/24956 -->
<ion-label class="ion-text-wrap"
><h2 *ngIf="item.about" class="name">{{ item.about }}</h2>
<ng-container *ngFor="let property of propertiesToShow">
<p *ngIf="item[property]">
{{
'library.account.pages' +
'.' +
listName +
'.' +
'labels' +
'.' +
property | translate
}}:
<ng-container
*ngIf="!['endtime', 'duedate'].includes(property); else date"
>
{{ item[property] }}
</ng-container>
<ng-template #date>
{{ item[property] | amDateFormat: 'll' }}
</ng-template>
</p>
</ng-container>
<span class="ion-float-right">
<ion-button
*ngIf="item.cancancel"
color="primary"
(click)="onClick('cancel')"
>
{{ 'library.account.actions.cancel.header' | translate }}</ion-button
>
<ion-button
*ngIf="item.canrenew"
color="primary"
(click)="onClick('renew')"
>
{{ 'library.account.actions.renew.header' | translate }}</ion-button
>
</span>
</ion-label>
</ion-item>

View File

@@ -1,42 +0,0 @@
<ion-header>
<ion-toolbar color="primary">
<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

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

View File

@@ -0,0 +1,40 @@
<ion-header>
<ion-toolbar color="primary">
<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-list *ngIf="fines; else loading">
<stapps-fee-item *ngFor="let fine of fines" [fee]="fine"></stapps-fee-item>
</ion-list>
<ng-template #loading>
<stapps-skeleton-list-item
*ngFor="let _ of [].constructor(2)"
hideThumbnail="true"
>
</stapps-skeleton-list-item>
</ng-template>
<ion-grid>
<ion-row *ngIf="amount; else amount_loading" class="ion-float-right">
<ion-col size="auto">
{{ 'library.account.pages.fines.labels.total_amount' | translate }}:
</ion-col>
<ion-col size="auto">
{{ amount }}
</ion-col>
</ion-row>
<ng-template #amount_loading>
<ion-row class="ion-float-right">
<ion-col size="auto">
<ion-skeleton-text animated style="width: 100px"></ion-skeleton-text>
</ion-col>
</ion-row>
</ng-template>
</ion-grid>
</ion-content>

View File

@@ -1,17 +0,0 @@
<ion-header>
<ion-toolbar color="primary">
<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

@@ -1,22 +1,45 @@
import {Component} from '@angular/core'; import {Component} from '@angular/core';
import {DocumentAction, PAIADocument, PAIADocumentStatus} from '../../types';
import {LibraryAccountService} from '../library-account.service'; import {LibraryAccountService} from '../library-account.service';
import {PAIADocument} from '../../types';
@Component({ @Component({
selector: 'app-holds-and-reservations', selector: 'app-holds-and-reservations',
templateUrl: './holds-and-reservations-page.component.html', templateUrl: './holds-and-reservations-page.html',
styleUrls: ['./holds-and-reservations-page.component.scss'], styleUrls: ['./holds-and-reservations-page.scss'],
}) })
export class HoldsAndReservationsPageComponent { export class HoldsAndReservationsPageComponent {
holds: PAIADocument[]; paiaDocuments?: PAIADocument[];
paiaDocumentStatus = PAIADocumentStatus;
constructor(private readonly libraryAccountService: LibraryAccountService) {} constructor(private readonly libraryAccountService: LibraryAccountService) {}
async ionViewWillEnter(): Promise<void> { async ionViewWillEnter(): Promise<void> {
await this.fetchItems();
}
async fetchItems(status: PAIADocumentStatus = PAIADocumentStatus.Ordered) {
this.paiaDocuments = undefined;
try { try {
this.holds = await this.libraryAccountService.getHoldsAndReservations(); this.paiaDocuments = await (Number(status) === PAIADocumentStatus.Ordered
? this.libraryAccountService.getHolds()
: this.libraryAccountService.getReservations());
} catch { } catch {
// TODO: error handling await this.libraryAccountService.handleError();
this.paiaDocuments = [];
} }
} }
// eslint-disable-next-line @typescript-eslint/no-explicit-any
async segmentChanged(event: any) {
await this.fetchItems(event.detail.value);
}
async onDocumentAction(documentAction: DocumentAction) {
const answer = await this.libraryAccountService.handleDocumentAction(
documentAction,
);
if (answer) await this.fetchItems();
}
} }

View File

@@ -0,0 +1,53 @@
<ion-header>
<ion-toolbar color="primary">
<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>
<ion-segment
(ionChange)="segmentChanged($event)"
[value]="paiaDocumentStatus.Ordered"
mode="md"
>
<ion-segment-button [value]="paiaDocumentStatus.Ordered">
<ion-label>{{
'library.account.pages.holds.holds' | translate
}}</ion-label>
</ion-segment-button>
<ion-segment-button [value]="paiaDocumentStatus.Reserved">
<ion-label>{{
'library.account.pages.holds.reservations' | translate
}}</ion-label>
</ion-segment-button>
</ion-segment>
<ion-list *ngIf="paiaDocuments && paiaDocuments.length > 0; else fallback">
<stapps-paia-item
*ngFor="let hold of paiaDocuments"
[item]="hold"
[propertiesToShow]="['label']"
(documentAction)="onDocumentAction($event)"
listName="holds"
>
</stapps-paia-item>
</ion-list>
<ng-template #fallback>
<stapps-skeleton-list-item
hideThumbnail="true"
*ngIf="!paiaDocuments; else nothingFound"
></stapps-skeleton-list-item>
<ng-template #nothingFound>
<ion-label
*ngIf="paiaDocuments && paiaDocuments.length === 0"
class="centeredMessageContainer"
>
{{ 'search.nothing_found' | translate | titlecase }}
</ion-label>
</ng-template>
</ng-template>
</ion-content>

View File

@@ -20,11 +20,21 @@ import {
SCFeatureConfiguration, SCFeatureConfiguration,
SCFeatureConfigurationExtern, SCFeatureConfigurationExtern,
} from '@openstapps/core'; } from '@openstapps/core';
import {PAIADocumentStatus, PAIAFees, PAIAItems, PAIAPatron} from '../types'; import {
DocumentAction,
PAIADocument,
PAIADocumentStatus,
PAIAFees,
PAIAItems,
PAIAPatron,
} from '../types';
import {HebisDataProvider} from '../../hebis/hebis-data.provider'; import {HebisDataProvider} from '../../hebis/hebis-data.provider';
import {ConfigProvider} from '../../config/config.provider';
import {AuthHelperService} from '../../auth/auth-helper.service';
import {PAIATokenResponse} from '../../auth/paia/paia-token-response'; import {PAIATokenResponse} from '../../auth/paia/paia-token-response';
import {AuthHelperService} from '../../auth/auth-helper.service';
import {ConfigProvider} from '../../config/config.provider';
import {TranslateService} from '@ngx-translate/core';
import {AlertController} from '@ionic/angular';
import {SCHebisSearchResponse} from '../../hebis/protocol/response';
@Injectable({ @Injectable({
providedIn: 'root', providedIn: 'root',
@@ -45,6 +55,8 @@ export class LibraryAccountService {
private readonly hebisDataProvider: HebisDataProvider, private readonly hebisDataProvider: HebisDataProvider,
private readonly authHelper: AuthHelperService, private readonly authHelper: AuthHelperService,
readonly configProvider: ConfigProvider, readonly configProvider: ConfigProvider,
private readonly translateService: TranslateService,
private readonly alertController: AlertController,
) { ) {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const config: SCFeatureConfigurationExtern = ( const config: SCFeatureConfigurationExtern = (
@@ -66,7 +78,11 @@ export class LibraryAccountService {
return this.performRequest<PAIAFees>(`${this.baseUrl}/{patron}/fees`); return this.performRequest<PAIAFees>(`${this.baseUrl}/{patron}/fees`);
} }
private async performRequest<T>(urlTemplate: string): Promise<T> { private async performRequest<T>(
urlTemplate: string,
method = 'GET',
data?: JQuery.PlainObject,
): Promise<T | undefined> {
const token = await this.authHelper const token = await this.authHelper
.getProvider(this.authType) .getProvider(this.authType)
.getValidToken(); .getValidToken();
@@ -77,30 +93,45 @@ export class LibraryAccountService {
const settings: JQueryAjaxSettings = { const settings: JQueryAjaxSettings = {
url: url, url: url,
dataType: 'json', dataType: 'json',
method: 'GET', method: method,
headers: { headers: {
'Authorization': `Bearer: ${token.accessToken}`, 'Authorization': `Bearer: ${token.accessToken}`,
'Content-Type': 'application/json', 'Content-Type': 'application/json',
}, },
}; };
return this.requestor.xhr(settings); if (method === 'POST') settings.data = data;
let result: T;
try {
result = await this.requestor.xhr(settings);
return result;
} catch {
void this.handleError();
}
return;
} }
getRawId(input: string) { getRawId(input: string) {
return input.split(':').pop(); return input.split(':').pop();
} }
async getHoldsAndReservations() { async getHolds() {
return (await this.getItems()).doc.filter(document => { return (await this.getItems())?.doc.filter(document => {
return [PAIADocumentStatus.Reserved, PAIADocumentStatus.Ordered].includes( return [PAIADocumentStatus.Ordered].includes(Number(document.status));
Number(document.status), });
); }
async getReservations() {
return (await this.getItems())?.doc.filter(document => {
return [PAIADocumentStatus.Reserved].includes(Number(document.status));
}); });
} }
async getCheckedOutItems() { async getCheckedOutItems() {
return (await this.getItems()).doc.filter(document => { return (await this.getItems())?.doc.filter(document => {
return PAIADocumentStatus.Held === Number(document.status); return PAIADocumentStatus.Held === Number(document.status);
}); });
} }
@@ -109,14 +140,92 @@ export class LibraryAccountService {
if (typeof edition === 'undefined') { if (typeof edition === 'undefined') {
return; return;
} }
const response = await this.hebisDataProvider.hebisSearch(
{
query: this.getRawId(edition) as string,
page: 0,
},
{addPrefix: true},
);
return response.data[0]; let response: SCHebisSearchResponse;
try {
response = await this.hebisDataProvider.hebisSearch(
{
query: this.getRawId(edition) as string,
page: 0,
},
{addPrefix: true},
);
return response.data[0];
} catch {
await this.handleError();
}
return;
}
cancelReservation(document: PAIADocument) {
return this.performRequest<void>(
`${this.baseUrl}/{patron}/cancel`,
'POST',
{doc: [document]},
);
}
renewLanding(document: PAIADocument) {
return this.performRequest<void>(`${this.baseUrl}/{patron}/renew`, 'POST', {
doc: [document],
});
}
async handleDocumentAction(documentAction: DocumentAction): Promise<boolean> {
return new Promise(async resolve => {
const handleDocument = () => {
switch (documentAction.action) {
case 'cancel':
return this.cancelReservation(documentAction.doc);
break;
case 'renew':
return this.renewLanding(documentAction.doc);
}
};
const alert = await this.alertController.create({
buttons: [
{
role: 'cancel',
text: this.translateService.instant('abort'),
handler: () => {
resolve(false);
},
},
{
handler: async () => {
await handleDocument();
resolve(true);
},
text: this.translateService.instant('OK'),
},
],
header: this.translateService.instant(
`library.account.actions.${documentAction.action}.header`,
),
message: this.translateService.instant(
`library.account.actions.${documentAction.action}.text`,
{
value:
documentAction.doc.about ??
this.translateService.instant(
`library.account.actions.${documentAction.doc.about}.unknown_book`,
),
},
),
});
await alert.present();
});
}
async handleError() {
const alert = await this.alertController.create({
header: this.translateService.instant('app.ui.ERROR'),
message: this.translateService.instant('app.errors.SERVICE'),
buttons: ['OK'],
});
await alert.present();
} }
} }

View File

@@ -1,29 +0,0 @@
<ion-header>
<ion-toolbar color="primary">
<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

@@ -4,11 +4,11 @@ import {PAIAPatron} from '../../types';
@Component({ @Component({
selector: 'app-profile', selector: 'app-profile',
templateUrl: './profile-page.component.html', templateUrl: './profile-page.html',
styleUrls: ['./profile-page.component.scss'], styleUrls: ['./profile-page.scss'],
}) })
export class ProfilePageComponent { export class ProfilePageComponent {
patron: PAIAPatron; patron?: PAIAPatron;
propertiesToShow: (keyof PAIAPatron)[] = [ propertiesToShow: (keyof PAIAPatron)[] = [
'name', 'name',
@@ -23,9 +23,8 @@ export class ProfilePageComponent {
async ionViewWillEnter(): Promise<void> { async ionViewWillEnter(): Promise<void> {
try { try {
this.patron = await this.libraryAccountService.getProfile(); this.patron = await this.libraryAccountService.getProfile();
console.log(this.patron);
} catch { } catch {
// TODO: error handling await this.libraryAccountService.handleError();
} }
} }
} }

View File

@@ -0,0 +1,41 @@
<ion-header>
<ion-toolbar color="primary">
<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-card *ngIf="patron; else loading">
<ion-card-content>
<ion-grid>
<ng-container *ngFor="let property of propertiesToShow">
<ion-row *ngIf="patron[property]">
<ion-col>
{{
'library.account.pages.profile.labels' + '.' + property
| translate
}}:
</ion-col>
<ion-col>
<ng-container *ngIf="!['expires'].includes(property); else date">
{{ patron[property] }}
</ng-container>
<ng-template #date>
{{ patron[property] | amDateFormat: 'll' }}
</ng-template>
</ion-col>
</ion-row>
</ng-container>
</ion-grid>
</ion-card-content>
</ion-card>
<ng-template #loading>
<stapps-skeleton-simple-card [title]="false" [lines]="4">
</stapps-skeleton-simple-card>
</ng-template>
</ion-content>

View File

@@ -13,6 +13,9 @@ import {PAIAItemComponent} from './account/elements/paia-item/paiaitem.component
import {FirstLastNamePipe} from './account/first-last-name.pipe'; import {FirstLastNamePipe} from './account/first-last-name.pipe';
import {AuthGuardService} from '../auth/auth-guard.service'; import {AuthGuardService} from '../auth/auth-guard.service';
import {ProtectedRoutes} from '../auth/protected.routes'; import {ProtectedRoutes} from '../auth/protected.routes';
import {MomentModule} from 'ngx-moment';
import {FeeItemComponent} from './account/elements/fee-item/fee-item.component';
import {DataModule} from '../data/data.module';
const routes: ProtectedRoutes | Routes = [ const routes: ProtectedRoutes | Routes = [
{ {
@@ -21,13 +24,30 @@ const routes: ProtectedRoutes | Routes = [
data: {authProvider: 'paia'}, data: {authProvider: 'paia'},
canActivate: [AuthGuardService], canActivate: [AuthGuardService],
}, },
{path: 'library-account/profile', component: ProfilePageComponent}, {
{path: 'library-account/checked-out', component: CheckedOutPageComponent}, path: 'library-account/profile',
component: ProfilePageComponent,
data: {authProvider: 'paia'},
canActivate: [AuthGuardService],
},
{
path: 'library-account/checked-out',
component: CheckedOutPageComponent,
data: {authProvider: 'paia'},
canActivate: [AuthGuardService],
},
{ {
path: 'library-account/holds-and-reservations', path: 'library-account/holds-and-reservations',
component: HoldsAndReservationsPageComponent, component: HoldsAndReservationsPageComponent,
data: {authProvider: 'paia'},
canActivate: [AuthGuardService],
},
{
path: 'library-account/fines',
component: FinesPageComponent,
data: {authProvider: 'paia'},
canActivate: [AuthGuardService],
}, },
{path: 'library-account/fines', component: FinesPageComponent},
]; ];
@NgModule({ @NgModule({
@@ -37,6 +57,8 @@ const routes: ProtectedRoutes | Routes = [
IonicModule, IonicModule,
RouterModule.forChild(routes), RouterModule.forChild(routes),
TranslateModule, TranslateModule,
MomentModule,
DataModule,
], ],
declarations: [ declarations: [
LibraryAccountPageComponent, LibraryAccountPageComponent,
@@ -46,6 +68,7 @@ const routes: ProtectedRoutes | Routes = [
FinesPageComponent, FinesPageComponent,
PAIAItemComponent, PAIAItemComponent,
FirstLastNamePipe, FirstLastNamePipe,
FeeItemComponent,
], ],
}) })
export class LibraryModule {} export class LibraryModule {}

View File

@@ -67,3 +67,8 @@ export enum PAIADocumentStatus {
Provided = 4, Provided = 4,
Rejected = 5, Rejected = 5,
} }
export interface DocumentAction {
action: 'cancel' | 'renew';
doc: PAIADocument;
}

View File

@@ -12,6 +12,7 @@
"ERROR": "Fehler" "ERROR": "Fehler"
}, },
"errors": { "errors": {
"SERVICE": "Fehler bei Dienstausführung.",
"UNKNOWN": "Unbekannter Fehler." "UNKNOWN": "Unbekannter Fehler."
} }
}, },
@@ -181,7 +182,7 @@
"library": { "library": {
"account": { "account": {
"title": "Bibliothekskonto", "title": "Bibliothekskonto",
"greeting": "hallo", "greeting": "Hallo",
"login": { "login": {
"success": "Du bist eingeloggt und kannst Dein Konto nutzen.", "success": "Du bist eingeloggt und kannst Dein Konto nutzen.",
"error": "Du bist nicht eingeloggt oder deine Sitzung ist abgelaufen." "error": "Du bist nicht eingeloggt oder deine Sitzung ist abgelaufen."
@@ -204,18 +205,20 @@
"title": "Bestellungen und Vormerkungen", "title": "Bestellungen und Vormerkungen",
"labels": { "labels": {
"title": "Titel", "title": "Titel",
"about": "mehr Informationen", "about": "Mehr Informationen",
"label": "Label", "label": "Signatur",
"endtime": "voraussichtliche Verfügbarkeit" "endtime": "Zum Abholen bis"
} },
"holds": "Bestellungen",
"reservations": "Vormerkungen"
}, },
"checked_out": { "checked_out": {
"title": "Deine Ausleihen", "title": "Deine Ausleihen",
"labels": { "labels": {
"title": "Titel", "title": "Titel",
"about": "mehr Informationen", "about": "Mehr Informationen",
"label": "Label", "label": "Signatur",
"endtime": "Leihfrist" "endtime": "Rückgabedatum"
} }
}, },
"fines": { "fines": {
@@ -223,13 +226,26 @@
"labels": { "labels": {
"amount": "Betrag", "amount": "Betrag",
"about": "Information", "about": "Information",
"date": "Datum", "date": "Rückgabedatum",
"item": "Artikel", "item": "Artikel",
"edition": "Ausgabe", "edition": "Ausgabe",
"feetype": "Gebührenart", "feetype": "Gebührenart",
"feeid": "Gebühren ID" "feeid": "Gebühren ID",
"total_amount": "Gesamtbetrag"
} }
} }
},
"actions": {
"cancel": {
"header": "Vormerkung löschen",
"text": "Bist Du dir sicher, die Vormerkung von \"{{value}}\" zu löschen?",
"unknown_book": "unbekanntem Titel"
},
"renew": {
"header": "Ausleihfrist verlängern",
"text": "Bist Du dir sicher, die Ausleihfrist von \"{{value}}\" zu verlängern?",
"unknown_book": "unbekanntem Titel"
}
} }
} }
}, },

View File

@@ -12,6 +12,7 @@
"ERROR": "Error" "ERROR": "Error"
}, },
"errors": { "errors": {
"SERVICE": "Service error.",
"UNKNOWN": "Unknown problem." "UNKNOWN": "Unknown problem."
} }
}, },
@@ -181,7 +182,7 @@
"library": { "library": {
"account": { "account": {
"title": "library account", "title": "library account",
"greeting": "hello", "greeting": "Hello",
"login": { "login": {
"success": "You are logged-in and ready to access your account.", "success": "You are logged-in and ready to access your account.",
"error": "Not logged in or login expired." "error": "Not logged in or login expired."
@@ -190,46 +191,61 @@
"profile": { "profile": {
"title": "library profile", "title": "library profile",
"labels": { "labels": {
"id": "user ID", "id": "User ID",
"name": "name", "name": "Name",
"email": "email", "email": "Email",
"address": "Address", "address": "Address",
"expires": "membership expires", "expires": "Membership expires",
"status": "status", "status": "Status",
"type": "type", "type": "Type",
"note": "note" "note": "Note"
} }
}, },
"holds": { "holds": {
"title": "holds and reservations", "title": "holds and reservations",
"labels": { "labels": {
"title": "title", "title": "Title",
"about": "more information", "about": "More information",
"label": "label", "label": "Label",
"endtime": "Expected to be available" "endtime": "Available for pickup until"
} },
"holds": "holds",
"reservations": "reservations"
}, },
"checked_out": { "checked_out": {
"title": "checked out items", "title": "checked out items",
"labels": { "labels": {
"title": "title", "title": "Title",
"about": "more information", "about": "More information",
"label": "label", "label": "Label",
"endtime": "Loan period ends" "endtime": "Return date"
} }
}, },
"fines": { "fines": {
"title": "fines", "title": "fines",
"labels": { "labels": {
"amount": "amount", "amount": "Amount",
"about": "about", "about": "About",
"date": "date", "date": "Return date",
"item": "item", "item": "Item",
"edition": "edition", "edition": "Edition",
"feetype": "fee type", "feetype": "Fee type",
"feeid": "fee ID" "feeid": "Fee ID",
"total_amount": "Total amount"
} }
} }
},
"actions": {
"cancel": {
"header": "Cancel reservation",
"text": "Are you sure you want to extend the landing period of \"{{value}}\"?",
"unknown_book": "unknown title"
},
"renew": {
"header": "Extend landing period",
"text": "Are you sure you want to extend the landing period of \"{{value}}\"?",
"unknown_book": "unknown title"
}
} }
} }
}, },