feat: backend-supplied id cards on profile page

feat: SCIdCard thing
This commit is contained in:
2023-08-21 12:49:57 +02:00
parent 905ebf8c59
commit 1f62b5c5b0
30 changed files with 635 additions and 162 deletions

View File

@@ -0,0 +1,6 @@
---
'@openstapps/core': minor
'@openstapps/app': minor
---
Add support for web-service-provided id cards on the profile page

View File

@@ -0,0 +1,5 @@
---
'@openstapps/app': patch
---
Make section swiper buttons reactive

View File

@@ -70,6 +70,10 @@ const config = {
authProvider: 'paia',
url: 'https://hds.hebis.de/paia/core',
},
/** TODO: idCards: {
authProvider: 'default',
url: 'TODO',
} */
},
},
aboutPages,

View File

@@ -29,6 +29,7 @@ export const DataIcons: Record<SCThingType, string> = {
'dish': SCIcon`lunch_dining`,
'favorite': SCIcon`favorite`,
'floor': SCIcon`foundation`,
'id card': SCIcon`badge`,
'message': SCIcon`newspaper`,
'organization': SCIcon`business_center`,
'periodical': SCIcon`feed`,

View File

@@ -0,0 +1,29 @@
import {ChangeDetectionStrategy, Component, Input} from '@angular/core';
import {SCIdCard} from '@openstapps/core';
import {FullScreenImageDirective} from '../../util/full-screen-image.directive';
import {ThingTranslateModule} from '../../translation/thing-translate.module';
import {AsyncPipe, NgIf, TitleCasePipe} from '@angular/common';
import {InRangeNowPipe, ToDateRangePipe} from '../../util/in-range.pipe';
import {TranslateModule} from '@ngx-translate/core';
@Component({
selector: 'stapps-id-card',
templateUrl: 'id-card.html',
styleUrls: ['id-card.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
standalone: true,
hostDirectives: [FullScreenImageDirective],
imports: [
FullScreenImageDirective,
ThingTranslateModule,
NgIf,
InRangeNowPipe,
ToDateRangePipe,
AsyncPipe,
TranslateModule,
TitleCasePipe,
],
})
export class IdCardComponent {
@Input({required: true}) item: SCIdCard;
}

View File

@@ -0,0 +1,4 @@
<img [src]="item.image" [alt]="'name' | thingTranslate : item" draggable="false" />
<div *ngIf="item.validity && (item.validity | toDateRange | isInRangeNow | async) === false" class="expired">
{{ 'profile.userInfo.expired' | translate | titlecase }}
</div>

View File

@@ -0,0 +1,34 @@
:host {
position: relative;
overflow: hidden;
}
:host:fullscreen {
margin: 0;
padding: 0;
}
img {
border-radius: 3mm;
}
.expired {
position: absolute;
top: 48px;
left: 48px;
transform-origin: center;
translate: -50% -50%;
rotate: -45deg;
display: flex;
align-items: center;
justify-content: center;
width: 256px;
padding: var(--spacing-xs);
font-weight: bold;
color: var(--ion-color-danger-contrast);
background: var(--ion-color-danger);
}

View File

@@ -0,0 +1,51 @@
/*
* Copyright (C) 2023 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 {ChangeDetectionStrategy, Component} from '@angular/core';
import {IdCardsProvider} from './id-cards.provider';
import {SCIdCard} from '@openstapps/core';
import {IonicModule} from '@ionic/angular';
import {AsyncPipe, NgForOf, NgIf, TitleCasePipe} from '@angular/common';
import {ThingTranslateModule} from '../../translation/thing-translate.module';
import {UtilModule} from '../../util/util.module';
import {FullScreenImageDirective} from '../../util/full-screen-image.directive';
import {IdCardComponent} from './id-card.component';
import {TranslateModule} from '@ngx-translate/core';
import {Observable} from 'rxjs';
@Component({
selector: 'stapps-id-cards',
templateUrl: 'id-cards.html',
styleUrls: ['id-cards.scss'],
providers: [IdCardsProvider],
changeDetection: ChangeDetectionStrategy.OnPush,
standalone: true,
imports: [
IonicModule,
NgForOf,
NgIf,
AsyncPipe,
ThingTranslateModule,
UtilModule,
FullScreenImageDirective,
IdCardComponent,
TranslateModule,
TitleCasePipe,
],
})
export class IdCardsComponent {
idCards: Observable<SCIdCard[]> = this.idCardsProvider.getIdCards();
constructor(readonly idCardsProvider: IdCardsProvider) {}
}

View File

@@ -0,0 +1,25 @@
<!--
~ Copyright (C) 2023 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/>.
-->
<stapps-section buttonColor="primary-contrast">
<simple-swiper *ngIf="idCards | async as idCards">
<div *ngIf="idCards.length === 0">
<div class="log-in">
{{'profile.userInfo.logInPrompt' | translate | sentencecase}}
<ion-icon name="person" fill="true"></ion-icon>
</div>
</div>
<stapps-id-card *ngFor="let idCard of idCards" [item]="idCard"></stapps-id-card>
</simple-swiper>
</stapps-section>

View File

@@ -0,0 +1,74 @@
import {Injectable} from '@angular/core';
import {SCIdCard, SCThingOriginType, SCThingType, SCUserConfiguration} from '@openstapps/core';
import {from, Observable, of} from 'rxjs';
import {AuthHelperService} from '../auth/auth-helper.service';
import {mergeMap, filter, map, startWith} from 'rxjs/operators';
import {ConfigProvider} from '../config/config.provider';
import {HttpClient} from '@angular/common/http';
@Injectable({providedIn: 'root'})
export class IdCardsProvider {
constructor(
private authHelper: AuthHelperService,
private config: ConfigProvider,
private httpClient: HttpClient,
) {}
getIdCards(): Observable<SCIdCard[]> {
const feature = this.config.config.app.features.extern?.['idCards'];
const auth = this.authHelper.getProvider(feature?.authProvider ?? 'default');
return auth.isAuthenticated$.pipe(
mergeMap(isAuthenticated =>
isAuthenticated
? feature
? from(auth.getValidToken()).pipe(
mergeMap(token => this.fetchIdCards(feature.url, token.accessToken)),
)
: auth.user$.pipe(
filter(user => user !== undefined),
map(userInfo => this.authHelper.getUserFromUserInfo(userInfo as object)),
mergeMap(user => this.fetchFallbackIdCards(user)),
startWith([]),
)
: // TODO: find a better solution here (async pipe stuff...)
of([]),
),
);
}
private fetchIdCards(url: string, token: string): Observable<SCIdCard[]> {
// eslint-disable-next-line unicorn/no-null
return this.httpClient.post(url, null, {
headers: {
Authorization: `Bearer: ${token}`,
},
responseType: 'json',
}) as Observable<SCIdCard[]>;
}
private fetchFallbackIdCards(user: SCUserConfiguration): Observable<SCIdCard[]> {
return this.httpClient.get('/assets/examples/student-id.sample.svg', {responseType: 'text'}).pipe(
map(svg => {
let result = svg;
for (const key in user) {
result = result.replaceAll(`{{${key}}`, (user as unknown as Record<string, string>)[key]);
}
return `data:image/svg+xml;base64,${Buffer.from(result, 'base64').toString('base64')}`;
}),
map(image => [
{
name: 'Student ID',
image,
type: SCThingType.IdCard,
uid: '1234',
origin: {
name: 'Sample Origin',
type: SCThingOriginType.Remote,
indexed: new Date().toISOString(),
},
},
]),
);
}
}

View File

@@ -0,0 +1,52 @@
/*
* Copyright (C) 2023 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/>.
*/
:host {
display: contents;
}
simple-swiper {
--swiper-slide-width: calc(min(90cqw, 12cm));
filter: drop-shadow(0 0 16px rgba(0 0 0 / 10%));
}
.log-in {
position: relative;
display: flex;
align-items: flex-end;
justify-content: center;
height: calc(min(90cqw, 12cm) * (53.98 / 85.6));
padding: var(--spacing-xxl);
color: var(--ion-color-medium);
background: var(--ion-color-light);
border-radius: 3mm;
outline: 1px dashed var(--ion-color-medium);
outline-offset: calc(-1 * var(--spacing-sm));
> ion-icon {
position: absolute;
top: 0;
left: 0;
font-size: calc(min(90cqw, 12cm) / 2);
opacity: 0.1;
}
}

View File

@@ -0,0 +1,65 @@
import {ConfigProvider} from '../config/config.provider';
import {IdCardsProvider} from './id-cards.provider';
import {HttpClient} from '@angular/common/http';
import {AuthHelperService} from '../auth/auth-helper.service';
import {BehaviorSubject, firstValueFrom, of} from 'rxjs';
import {SCAuthorizationProviderType} from '@openstapps/core';
class FakeAuth {
isAuthenticated$ = new BehaviorSubject(false);
// eslint-disable-next-line @typescript-eslint/no-empty-function
getValidToken() {}
}
describe('IdCards', () => {
let configProvider: ConfigProvider;
let httpClient: HttpClient;
let authHelper: AuthHelperService;
let fakeAuth: FakeAuth;
beforeEach(() => {
configProvider = jasmine.createSpyObj('ConfigProvider', ['config']);
configProvider.config = {
app: {features: {extern: {idCards: {url: 'http://id-cards.local', authProvider: 'fakeAuth'}}}},
} as never;
httpClient = jasmine.createSpyObj('HttpClient', ['post']);
fakeAuth = new FakeAuth();
authHelper = jasmine.createSpyObj('AuthHelperService', ['getProvider']);
authHelper.getProvider = jasmine.createSpy().and.returnValue(fakeAuth);
});
it('should emit undefined if not logged in', async () => {
const provider = new IdCardsProvider(authHelper, configProvider, httpClient);
expect(await firstValueFrom(provider.getIdCards())).toEqual([]);
expect(authHelper.getProvider).toHaveBeenCalledOnceWith('fakeAuth' as SCAuthorizationProviderType);
});
it('should emit network result when logged in', async () => {
fakeAuth.isAuthenticated$.next(true);
httpClient.post = jasmine.createSpy().and.returnValue(of(['abc']));
fakeAuth.getValidToken = jasmine.createSpy().and.resolveTo({accessToken: 'fake-token'});
const provider = new IdCardsProvider(authHelper, configProvider, httpClient);
expect(await firstValueFrom(provider.getIdCards())).toEqual(['abc' as never]);
expect(authHelper.getProvider).toHaveBeenCalledOnceWith('fakeAuth' as SCAuthorizationProviderType);
// eslint-disable-next-line unicorn/no-null
expect(httpClient.post).toHaveBeenCalledOnceWith('http://id-cards.local', null, {
headers: {
Authorization: 'Bearer: fake-token',
},
responseType: 'json',
});
});
it('should react to logins', async () => {
const provider = new IdCardsProvider(authHelper, configProvider, httpClient);
const observable = provider.getIdCards();
expect(await firstValueFrom(observable)).toEqual([]);
httpClient.post = jasmine.createSpy().and.returnValue(of(['abc']));
fakeAuth.getValidToken = jasmine.createSpy().and.resolveTo({accessToken: 'fake-token'});
fakeAuth.isAuthenticated$.next(true);
// this is counter-intuitive, but because we unsubscribed above the first value
// will now contain the network result.
expect(await firstValueFrom(observable)).toEqual(['abc' as never]);
});
});

View File

@@ -1,3 +1,17 @@
<!--
~ Copyright (C) 2023 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-accordion-group *ngIf="myCourses | async as myCourses" [value]="myCourses[0][0]">
<ion-accordion
*ngFor="let myCoursesDay of myCourses"

View File

@@ -14,11 +14,10 @@
*/
import {Component} from '@angular/core';
import {AuthHelperService} from '../../auth/auth-helper.service';
import {SCAuthorizationProviderType, SCUserConfiguration} from '@openstapps/core';
import {SCAuthorizationProviderType} from '@openstapps/core';
import {ActivatedRoute} from '@angular/router';
import {ScheduleProvider} from '../../calendar/schedule.provider';
import {profilePageSections} from '../../../../config/profile-page-sections';
import {filter, map} from 'rxjs/operators';
@Component({
selector: 'app-home',
@@ -26,19 +25,8 @@ import {filter, map} from 'rxjs/operators';
styleUrls: ['profile-page.scss'],
})
export class ProfilePageComponent {
user$ = this.authHelper.getProvider('default').user$.pipe(
filter(user => user !== undefined),
map(userInfo => {
return this.authHelper.getUserFromUserInfo(userInfo as object);
}),
);
sections = profilePageSections;
logins: SCAuthorizationProviderType[] = [];
userInfo?: SCUserConfiguration;
constructor(
readonly authHelper: AuthHelperService,
readonly activatedRoute: ActivatedRoute,
@@ -53,7 +41,6 @@ export class ProfilePageComponent {
async signOut(providerType: SCAuthorizationProviderType) {
await this.authHelper.getProvider(providerType).signOut();
this.userInfo = undefined;
}
ionViewWillEnter() {

View File

@@ -23,48 +23,7 @@
</ion-header>
<ion-content color="light" parallax [parallaxSize]="130">
<ion-card class="user-card">
<ion-card-header>
<ion-img src="assets/imgs/header.svg"></ion-img>
<span *ngIf="user$ | async as userInfo">
{{ userInfo.role ? (userInfo?.role | titlecase) : ('profile.role_guest' | translate | titlecase) }}
</span>
</ion-card-header>
<ion-card-content>
<ion-img class="profile-card-img" src="assets/imgs/profile-card-head.svg"></ion-img>
<ion-grid>
<ion-row>
<ion-col size="3"></ion-col>
<ion-col
*ngIf="authHelper.getProvider('default').isAuthenticated$ | async as loggedIn; else logInPrompt"
size="9"
class="main-info"
>
<ng-container *ngIf="user$ | async as userInfo">
<ion-text class="full-name"> {{ userInfo?.name }} </ion-text>
<div class="matriculation-number">
<ion-label> {{ 'profile.userInfo.studentId' | translate | uppercase }} </ion-label>
<ion-text> {{ userInfo?.studentId }} </ion-text>
</div>
<div class="user-name">
<ion-label> {{ 'profile.userInfo.username' | translate | uppercase }} </ion-label>
<ion-text>{{ userInfo?.id }}</ion-text>
</div>
<div class="email">
<ion-label> {{ 'profile.userInfo.email' | translate | uppercase }} </ion-label>
<ion-text> {{ userInfo?.email }} </ion-text>
</div>
</ng-container>
</ion-col>
<ng-template #logInPrompt>
<ion-col size="9">
<ion-text class="log-in-prompt"> {{ 'profile.userInfo.logInPrompt' | translate }} </ion-text>
</ion-col>
</ng-template>
</ion-row>
</ion-grid>
</ion-card-content>
</ion-card>
<stapps-id-cards></stapps-id-cards>
<stapps-profile-page-section
*ngFor="let section of sections"
[item]="section"

View File

@@ -12,106 +12,3 @@
* You should have received a copy of the GNU General Public License along with
* this program. If not, see <https://www.gnu.org/licenses/>.
*/
// TODO: clean up this mess
.user-card {
position: relative;
max-width: 400px;
margin: var(--spacing-xl);
border-radius: var(--border-radius-default);
box-shadow: var(--shadow-profile-card);
ion-card-header {
--background: var(--ion-color-tertiary);
display: flex;
align-items: center;
padding-top: var(--spacing-sm);
padding-bottom: var(--spacing-sm);
ion-img {
display: block;
height: 36px;
margin-right: auto;
object-position: left 50%;
}
span {
padding-top: 3px;
font-size: var(--font-size-lg);
font-weight: var(--font-weight-bold);
line-height: 1;
color: var(--ion-color-light);
}
}
ion-card-content {
min-height: 15vh;
.profile-card-img {
position: absolute;
width: 50%;
height: 100%;
margin-left: calc(var(--spacing-md) * -4);
opacity: 0.13;
object-position: left bottom;
}
.main-info {
display: grid;
grid-template-areas:
'fullName fullName'
'matriculationNumber userName'
'email email';
ion-label {
display: block;
font-size: var(--font-size-sm);
font-weight: var(--font-weight-bold);
color: var(--ion-color-medium-shade);
}
ion-text {
display: block;
font-size: var(--font-size-md);
font-weight: var(--font-weight-bold);
color: var(--ion-color-text);
}
.full-name {
display: block;
grid-area: fullName;
margin-bottom: var(--spacing-sm);
font-size: var(--font-size-lg);
font-weight: var(--font-weight-bold);
}
.matriculation-number {
grid-area: matriculationNumber;
margin-bottom: var(--spacing-sm);
}
.user-name {
grid-area: userName;
margin-bottom: var(--spacing-sm);
}
.email {
grid-area: email;
}
}
.log-in-prompt {
margin: auto 0;
font-size: var(--font-size-lg);
font-weight: var(--font-weight-semi-bold);
color: var(--ion-color-text);
}
}
}

View File

@@ -27,6 +27,7 @@ import {ThingTranslateModule} from '../../translation/thing-translate.module';
import {DataModule} from '../data/data.module';
import {MyCoursesComponent} from './page/my-courses.component';
import {MomentModule} from 'ngx-moment';
import {IdCardsComponent} from './id-cards.component';
const routes: Routes = [
{
@@ -49,6 +50,7 @@ const routes: Routes = [
ThingTranslateModule,
DataModule,
MomentModule,
IdCardsComponent,
],
})
export class ProfilePageModule {}

View File

@@ -0,0 +1,24 @@
import {Directive, ElementRef, HostListener} from '@angular/core';
@Directive({
selector: 'img[fullScreenImage]',
standalone: true,
})
export class FullScreenImageDirective {
constructor(private host: ElementRef) {}
@HostListener('click')
async onClick() {
if (document.fullscreenElement) {
await document.exitFullscreen();
} else {
await this.host.nativeElement.requestFullscreen();
if (
Math.sign(screen.width - screen.height) ===
Math.sign(this.host.nativeElement.width - this.host.nativeElement.height)
) {
await screen.orientation.lock('landscape');
}
}
}
}

View File

@@ -0,0 +1,41 @@
import {Pipe, PipeTransform} from '@angular/core';
import {SCRange, isInRange, SCISO8601DateRange} from '@openstapps/core';
import {merge, Observable, timer} from 'rxjs';
import {distinctUntilChanged, map, startWith} from 'rxjs/operators';
@Pipe({
name: 'isInRange',
pure: true,
standalone: true,
})
export class InRangePipe implements PipeTransform {
transform<T>(value: T, range: SCRange<T>): boolean {
return isInRange(value, range);
}
}
@Pipe({
name: 'toDateRange',
pure: true,
standalone: true,
})
export class ToDateRangePipe implements PipeTransform {
transform(value: SCISO8601DateRange): SCRange<Date> {
return Object.fromEntries(Object.entries(value).map(([key, value]) => [key, new Date(value)]));
}
}
@Pipe({
name: 'isInRangeNow',
pure: true,
standalone: true,
})
export class InRangeNowPipe implements PipeTransform {
transform(value: SCRange<Date>): Observable<boolean> {
return merge(timer(value.lte || value.lt || 0), timer(value.gte || value.gt || 0)).pipe(
startWith(0),
map(() => isInRange(new Date(), value)),
distinctUntilChanged(),
);
}
}

View File

@@ -14,7 +14,7 @@
-->
<ion-grid>
<ion-row>
<ion-col>
<ion-col *ngIf="title || item">
<a *ngIf="item; else titleTemplate" [routerLink]="['/data-detail', item.uid]" [state]="{item}">
<ng-container *ngTemplateOutlet="titleTemplate"></ng-container>
</a>
@@ -29,7 +29,7 @@
<ion-col size="auto" class="swiper-button">
<ion-button
fill="clear"
color="medium"
[color]="buttonColor"
(click)="swiper.scrollBy({left: -swiper.offsetWidth, behavior: 'smooth'})"
[disabled]="false"
>
@@ -39,7 +39,7 @@
<ion-col size="auto" class="swiper-button">
<ion-button
fill="clear"
color="medium"
[color]="buttonColor"
(click)="swiper.scrollBy({left: swiper.offsetWidth, behavior: 'smooth'})"
[disabled]="false"
>

View File

@@ -32,6 +32,8 @@ export class SectionComponent implements AfterContentInit {
@Input() item?: SCThings;
@Input() buttonColor = 'medium';
nativeElement = new ReplaySubject<HTMLElement>(1);
swiper = this.nativeElement.pipe(

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 68 KiB

View File

@@ -512,6 +512,7 @@
}
},
"userInfo": {
"expired": "Abgelaufen",
"studentId": "Matrikelnr.",
"username": "Nutzername",
"email": "Email",

View File

@@ -512,6 +512,7 @@
}
},
"userInfo": {
"expired": "expired",
"studentId": "Matriculation Nr.",
"username": "Username",
"email": "Email",

View File

@@ -33,6 +33,7 @@ export * from './things/diff.js';
export * from './things/dish.js';
export * from './things/favorite.js';
export * from './things/floor.js';
export * from './things/id-card.js';
export * from './things/message.js';
export * from './things/organization.js';
export * from './things/periodical.js';

View File

@@ -62,6 +62,7 @@ import {SCTicket, SCTicketMeta, SCTicketWithoutReferences} from './things/ticket
import {SCToDo, SCToDoMeta, SCToDoWithoutReferences} from './things/todo.js';
import {SCTour, SCTourMeta, SCTourWithoutReferences} from './things/tour.js';
import {SCVideo, SCVideoMeta, SCVideoWithoutReferences} from './things/video.js';
import {SCIdCard, SCIdCardMeta, SCIdCardWithoutReferences} from './things/id-card.js';
/**
* A map of things, from type to meta data
@@ -80,6 +81,7 @@ export const SCClasses: {[K in SCThingType]: object} = {
'dish': SCDishMeta,
'favorite': SCFavoriteMeta,
'floor': SCFloorMeta,
'id card': SCIdCardMeta,
'message': SCMessageMeta,
'organization': SCOrganizationMeta,
'periodical': SCPeriodicalMeta,
@@ -111,6 +113,7 @@ export type SCIndexableThings =
| SCDateSeries
| SCDish
| SCFloor
| SCIdCard
| SCMessage
| SCOrganization
| SCPeriodical
@@ -167,6 +170,8 @@ export type SCAssociatedThingWithoutReferences<THING extends SCThings> = THING e
? SCFavoriteWithoutReferences
: THING extends SCFloor
? SCFloorWithoutReferences
: THING extends SCIdCard
? SCIdCardWithoutReferences
: THING extends SCMessage
? SCMessageWithoutReferences
: THING extends SCOrganization
@@ -230,6 +235,8 @@ export type SCAssociatedThing<THING extends SCThings> = THING extends SCAssessme
? SCFavorite
: THING extends SCFloorWithoutReferences
? SCFloor
: THING extends SCIdCardWithoutReferences
? SCIdCard
: THING extends SCMessageWithoutReferences
? SCMessage
: THING extends SCOrganizationWithoutReferences

View File

@@ -21,6 +21,30 @@ import {SCISO8601Date} from '../../general/time.js';
*/
export type SCISO8601DateRange = SCRange<SCISO8601Date>;
/**
* Checks if a value is inside a range
* @param value the value to check
* @param range the range
*/
export function isInRange<T>(value: T, range: SCRange<T>): boolean {
return (
(range.lt == undefined ? (range.lte == undefined ? true : range.lte >= value) : range.lt > value) &&
(range.gt == undefined ? (range.gte == undefined ? true : range.gte <= value) : range.gt < value)
);
}
/**
* Format a range
* @example '0..4'
* @example '1=..=3'
* @example '0..=3'
*/
export function formatRange<T>(range: SCRange<T>): string {
return `${range.gt ?? range.gte}${range.gte == undefined ? '' : '='}..${range.lte == undefined ? '' : '='}${
range.lt ?? range.lte
}`;
}
/**
* Generic range type
*/

View File

@@ -38,6 +38,7 @@ export enum SCThingType {
Dish = 'dish',
Favorite = 'favorite',
Floor = 'floor',
IdCard = 'id card',
Message = 'message',
Organization = 'organization',
Person = 'person',

View File

@@ -0,0 +1,93 @@
/*
* Copyright (C) 2019-2022 Open 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 {SCMetaTranslations, SCTranslations} from '../general/i18n.js';
import {
SCThing,
SCThingMeta,
SCThingTranslatableProperties,
SCThingType,
SCThingWithoutReferences,
} from './abstract/thing.js';
import {SCISO8601DateRange} from './abstract/range.js';
/**
* An ID-Card without references
*/
export interface SCIdCardWithoutReferences extends SCThingWithoutReferences {
/**
* Validity range
*/
validity?: SCISO8601DateRange;
/**
* Type
*/
type: SCThingType.IdCard;
}
/**
* A message
* @validatable
* @indexable
*/
export interface SCIdCard extends SCIdCardWithoutReferences, SCThing {
/**
* Translated fields of a message
*/
translations?: SCTranslations<SCIdCardTranslatableProperties>;
/**
* Type
*/
type: SCThingType.IdCard;
}
/**
* Translatable properties of a message
*/
export interface SCIdCardTranslatableProperties extends SCThingTranslatableProperties {}
/**
* Meta information about messages
*/
export class SCIdCardMeta extends SCThingMeta implements SCMetaTranslations<SCIdCard> {
/**
* Translations of fields
*/
fieldTranslations = {
de: {
...new SCThingMeta().fieldTranslations.de,
validity: 'Gültigkeit',
},
en: {
...new SCThingMeta().fieldTranslations.en,
validity: 'validity',
},
};
/**
* Translations of values of fields
*/
fieldValueTranslations = {
de: {
...new SCThingMeta().fieldValueTranslations.de,
type: 'Ausweis',
},
en: {
...new SCThingMeta().fieldValueTranslations.en,
type: SCThingType.Message,
},
};
}

View File

@@ -0,0 +1,41 @@
import {expect} from 'chai';
import {formatRange, isInRange} from '../src/index.js';
import {SCRange} from '../lib/index.js';
const cases: Record<'accept' | 'reject', [number, SCRange<number>][]> = {
accept: [
[4, {gt: 3, lt: 5}],
[4, {gte: 4, lte: 4}],
[3, {gt: 2, lt: 4}],
[5, {gte: 3, lte: 5}],
[10, {gt: 5, lt: 15}],
[0, {gte: 0, lte: 10}],
],
reject: [
[4, {gt: 3, lt: 4}],
[4, {gte: 5, lte: 6}],
[2, {gt: 5, lt: 10}],
[6, {gte: 7, lte: 8}],
[-1, {gt: 0, lt: 5}],
[20, {gte: 10, lte: 15}],
],
};
describe('Range', () => {
for (const constructor of ['Number', 'Date'] as const) {
describe(`${constructor} range`, () => {
for (const [accept, [value, range]] of Object.entries(cases).flatMap(([accept, cases]) =>
cases.map(it => [accept, it] as const),
)) {
it(`should ${accept} ${value} in the range ${formatRange(range)}`, () => {
const result = isInRange(constructor === 'Number' ? value : new Date(value), range);
if (accept === 'accept') {
expect(result).to.be.true;
} else {
expect(result).to.be.false;
}
});
}
});
}
});