feat: show menu for multiple days for canteens and cafes

Closes #19, #79
This commit is contained in:
Wieland Schöbl
2021-01-19 13:50:51 +00:00
committed by Jovan Krunić
parent 66b8720da0
commit 3c079cd189
27 changed files with 1347 additions and 523 deletions

1000
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -50,7 +50,7 @@
"@ionic/storage": "2.2.0",
"@ngx-translate/core": "11.0.1",
"@ngx-translate/http-loader": "4.0.0",
"@openstapps/api": "0.22.0",
"@openstapps/api": "0.25.0",
"@openstapps/configuration": "0.25.0",
"@openstapps/core": "0.36.0",
"cordova-android": "8.0.0",

View File

@@ -13,6 +13,9 @@
* this program. If not, see <https://www.gnu.org/licenses/>.
*/
/* tslint:disable */
import moment from 'moment'
export const sampleResources = [{
'errorNames': [],
"instance": {
@@ -981,8 +984,8 @@ export const sampleResources = [{
'offers': [
{
'availability': 'in stock',
'availabilityStarts': '2017-01-30T00:00:00.000Z',
'availabilityEnds': '2017-01-30T23:59:59.999Z',
'availabilityStarts': moment().startOf('day').add(2,'days').toISOString(),
'availabilityEnds': moment().endOf('day').add(2,'days').toISOString(),
'prices': {
'default': 6.5,
'student': 5,
@@ -1101,8 +1104,8 @@ export const sampleResources = [{
'offers': [
{
'availability': 'in stock',
'availabilityStarts': '2017-01-30T00:00:00.000Z',
'availabilityEnds': '2017-01-30T23:59:59.999Z',
'availabilityStarts': moment().startOf('day').toISOString(),
'availabilityEnds': moment().endOf('day').add(2,'days').toISOString(),
'prices': {
'default': 4.85,
'student': 2.85,
@@ -1117,7 +1120,7 @@ export const sampleResources = [{
'type': 'remote'
},
'type': 'organization',
'uid': '3b9b3df6-3a7a-58cc-922f-c7335c002634'
'uid': 'b7206fb5-bd77-5572-928f-16aa70910f64'
},
'inPlace': {
'geo': {
@@ -1138,7 +1141,7 @@ export const sampleResources = [{
'alternateNames': [
'MensaHardenberg'
],
'uid': '72fbc8a3-ebd1-58f9-9526-ad65cba2e402',
'uid': 'b7206fb5-bd77-5572-928f-16aa70910f64',
'address': {
'addressCountry': 'Germany',
'addressLocality': 'Berlin',
@@ -1191,8 +1194,8 @@ export const sampleResources = [{
'uid': '3b9b3df6-3a7a-58cc-922f-c7335c002634'
},
'availability': 'in stock',
'availabilityStarts': '2017-01-30T00:00:00.000Z',
'availabilityEnds': '2017-01-30T23:59:59.999Z',
'availabilityStarts': moment().startOf('day').add(2,'days').toISOString(),
'availabilityEnds': moment().endOf('day').add(2,'days').toISOString(),
'inPlace': {
'geo': {
'point': {
@@ -1264,8 +1267,8 @@ export const sampleResources = [{
],
'offers': [
{
'availabilityEnds': '2017-03-27T23:59:59.000Z',
'availabilityStarts': '2017-03-27T00:00:00.000Z',
'availabilityEnds': moment().endOf('day').toISOString(),
'availabilityStarts': moment().startOf('day').toISOString(),
'availability': 'in stock',
'inPlace': {
'type': 'room',
@@ -1273,7 +1276,7 @@ export const sampleResources = [{
'categories': [
'cafe'
],
'uid': 'e5492c9c-064e-547c-8633-c8fc8955cfcf',
'uid': 'b7206fb5-bd77-5572-928f-16aa70910f64',
'alternateNames': [
'Cafeteria LEVEL'
],
@@ -1306,7 +1309,7 @@ export const sampleResources = [{
'type': 'remote'
},
'type': 'organization',
'uid': '3b9b3df6-3a7a-58cc-922f-c7335c002634'
'uid': 'b7206fb5-bd77-5572-928f-16aa70910f64'
}
}
],

View File

@@ -14,10 +14,13 @@
*/
import {HttpClient} from '@angular/common/http';
import {Injectable} from '@angular/core';
import {SCAcademicEvent, SCArticle, SCBook, SCBuilding, SCCatalog,
SCDateSeries, SCDish, SCFavorite, SCMessage, SCPerson, SCRoom,
SCThing, SCThingOriginType, SCThingType, SCToDo, SCToDoPriority} from '@openstapps/core';
import {
SCAcademicEvent, SCArticle, SCBook, SCBuilding, SCCatalog,
SCDateSeries, SCDish, SCFavorite, SCMessage, SCPerson, SCRoom, SCSearchFilter,
SCThing, SCThingOriginType, SCThingType, SCToDo, SCToDoPriority,
} from '@openstapps/core';
import {Observable, of} from 'rxjs';
import {checkFilter} from '../fakesearch/filters';
import {sampleResources} from './resources/test-resources';
// tslint:disable:no-magic-numbers
@@ -339,7 +342,7 @@ const sampleDateSeries: SCDateSeries[] = [
},
];
export const sampleThingsMap: {[key in SCThingType | string]: SCThing[]} = {
export const sampleThingsMap: { [key in SCThingType | string]: SCThing[] } = {
'academic event': sampleAcademicEvents,
article: sampleArticles,
book: sampleBooks,
@@ -364,35 +367,6 @@ export const sampleThingsMap: {[key in SCThingType | string]: SCThing[]} = {
tour: [],
video: [],
};
// tslint:enable:no-magic-numbers
/**
* TODO
*
* provides all or certain sample things to be used in tests or backendless development
*/
export function getSampleThings(...uids: string[]): SCThing[] {
const sampleThings: SCThing[] = [];
if (typeof uids !== 'undefined' && uids.length > 0) {
uids.forEach((uid) => {
Object.keys(sampleThingsMap)
.forEach((key) => {
sampleThingsMap[key].forEach((thing) => {
if (thing.uid === uid) {
sampleThings.push(thing);
}
});
});
});
} else {
Object.keys(sampleThingsMap)
.forEach((key) => {
sampleThings.push(...sampleThingsMap[key]);
});
}
return sampleThings;
}
/**
* TODO
@@ -407,17 +381,13 @@ export class SampleThings {
constructor(http: HttpClient) {
this.http = http;
}
// getSampleThings(): Observable<any> {
// return this.http.get('http://localhost:8100/assets/json/joined.json');
// // return of(sampleDishes[0]);
// }
/**
* TODO
*/
// tslint:disable-next-line:prefer-function-over-method
// tslint:disable-next-line:prefer-function-over-method no-any
getSampleThing(uid: string): Observable<any[]> {
// const jsonContent: any[] = await this.http.get('http://localhost:8100/assets/json/joined.json').toPromise();
// tslint:disable-next-line:no-any
const sampleThings: any[] = [];
for (const resource of sampleResources) {
if (resource.instance.uid as SCThingType === uid) {
@@ -433,16 +403,18 @@ export class SampleThings {
/**
* TODO
*/
// tslint:disable-next-line:prefer-function-over-method
getSampleThings(): Observable<any[]> {
// const jsonContent: any[] = await this.http.get('http://localhost:8100/assets/json/joined.json').toPromise();
// tslint:disable-next-line:prefer-function-over-method no-any
getSampleThings(filter?: SCSearchFilter): Observable<any[]> {
// tslint:disable-next-line:no-any
const sampleThings: any[] = [];
sampleResources.forEach((resource) => {
for (const resource of sampleResources) {
// tslint:disable-next-line:max-line-length
// if ([SCThingType.Video].includes(resource.instance.type as SCThingType)) {
if (typeof filter === 'undefined' || checkFilter(resource.instance as SCThing, filter)) {
sampleThings.push(resource.instance);
}
// }
});
}
return of(sampleThings);
}

View File

@@ -382,13 +382,13 @@ export class FakeBackendInterceptor implements HttpInterceptor {
if (request.body.filter.arguments.field === 'uid') {
return this.sampleFetcher.getSampleThing(request.body.filter.arguments.value)
// tslint:disable-next-line:no-any
.pipe(map((sampleData: any) => {
return new HttpResponse({status: 200, body: {data: sampleData}});
}), delay(this.RESPONSE_DELAY)); // add delay for skeleton screens to be seen (see !16)
.pipe(map((sampleData: any) => {
return new HttpResponse({status: 200, body: {data: sampleData}});
}), delay(this.RESPONSE_DELAY)); // add delay for skeleton screens to be seen (see !16)
}
}
return this.sampleFetcher.getSampleThings()
return this.sampleFetcher.getSampleThings(request.body.filter)
// tslint:disable-next-line:no-any
.pipe(map((sampleData: any) => {
return new HttpResponse({status: 200, body: {data: sampleData, facets: facetsMock}});

View File

@@ -0,0 +1,170 @@
/*
* Copyright (C) 2020 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 {SCSearchBooleanFilter, SCSearchFilter, SCSearchValueFilter, SCThing} from '@openstapps/core';
import {logger} from '../ts-logger';
/**
* Checks if any filter applies to an SCThing
*/
export function checkFilter(thing: SCThing, filter: SCSearchFilter): boolean {
switch (filter.type) {
case 'availability': /*TODO*/
break;
case 'boolean':
return applyBooleanFilter(thing, filter);
case 'distance': /*TODO*/
break;
case 'value':
return applyValueFilter(thing, filter);
}
void logger.error(`Not implemented filter method "${filter.type}" in fake backend!`);
return false;
}
/**
* Checks if a value filter applies to an SCThing
*/
function applyValueFilter(thing: SCThing, filter: SCSearchValueFilter): boolean {
const path = filter.arguments.field.split('.');
const thingFieldValue = traverseToFieldPath(thing, path, filter.arguments.value);
if (!(thingFieldValue.found)) {
return false;
}
return thingFieldValue.result;
}
/**
* Object that can be accessed using foo[bar]
*/
interface IndexableObject {
// tslint:disable-next-line:no-any
[key: string]: any;
}
/**
* Result of a search for a field and comparison to a desired value
*/
type FieldSearchResult = {
/**
* Weather the field was found
*/
found: true;
/**
* The result of the comparison
*/
result: boolean;
} | {
/**
* Weather the field was found
*/
found: false;
};
// tslint:disable-next-line:completed-docs
function traverseToFieldPath(value: IndexableObject, path: string[], desiredFieldValue: unknown): FieldSearchResult {
if (path.length === 0) {
void logger.error(`Value filter provided with zero length path`);
return {found: false};
}
if (value.hasOwnProperty(path[0])) {
const nestedProperty = value[path[0]];
if (path.length === 1) {
return esStyleFieldHandler(nestedProperty, (nestedValue) => {
return {
found: true,
result: nestedValue === desiredFieldValue,
};
});
}
return esStyleFieldHandler(nestedProperty, (nestedValue) => {
if (typeof nestedValue === 'object') {
return traverseToFieldPath(
nestedValue as IndexableObject,
// tslint:disable-next-line:no-magic-numbers
path.slice(1),
desiredFieldValue);
}
return {found: false};
});
}
return {found: false};
}
/**
* ES treats arrays like normal fields
*/
function esStyleFieldHandler<T>(field: T | T[],
handler: (value: T) => FieldSearchResult): FieldSearchResult {
if (Array.isArray(field)) {
for (const nestedField of field) {
const result = handler(nestedField);
if (result.found && result.result) {
return result;
}
}
// TODO: found is not accurate
return {found: false};
}
return handler(field);
}
/**
* Checks if a boolean filter applies to an SCThing
*/
function applyBooleanFilter(thing: SCThing, filter: SCSearchBooleanFilter): boolean {
let out = false;
switch (filter.arguments.operation) {
case 'and':
out = true;
for (const nesterFilter of filter.arguments.filters) {
out = out && checkFilter(thing, nesterFilter);
}
return out;
case 'or':
for (const nesterFilter of filter.arguments.filters) {
out = out || checkFilter(thing, nesterFilter);
}
return out;
case 'not':
if (filter.arguments.filters.length === 1) {
return !checkFilter(thing, filter.arguments.filters[0]);
}
void logger.error(`Too many filters for "not" boolean operation`);
return false;
}
void logger.error(`Not implemented boolean filter "${filter.arguments.operation}"`);
return false;
}

View File

@@ -0,0 +1,19 @@
/*
* Copyright (C) 2020 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 {NGXLogger} from 'ngx-logger';
export let logger: NGXLogger;
export const initLogger = (newLogger: NGXLogger) => logger = newLogger;

View File

@@ -21,6 +21,7 @@ import {NGXLogger} from 'ngx-logger';
import {ConfigProvider} from './modules/config/config.provider';
import {SettingsProvider} from './modules/settings/settings.provider';
import {initLogger} from './_helpers/ts-logger';
/**
* TODO
*/
@@ -60,6 +61,7 @@ export class AppComponent {
private readonly settingsProvider: SettingsProvider,
private readonly configProvider: ConfigProvider,
private readonly logger: NGXLogger) {
initLogger(logger);
this.initializeApp();
// this language will be used as a fallback when a translation isn't found in the current language

View File

@@ -16,10 +16,12 @@ import {NgModule} from '@angular/core';
import {RouterModule, Routes} from '@angular/router';
import {DataDetailComponent} from './detail/data-detail.component';
import {DataListComponent} from './list/data-list.component';
import {FoodDataListComponent} from './list/food-data-list.component';
const dataRoutes: Routes = [
{path: 'search', component: DataListComponent},
{path: 'data-detail/:uid', component: DataDetailComponent},
{path: 'canteen', component: FoodDataListComponent},
];
/**

View File

@@ -35,9 +35,11 @@ import {OriginDetailComponent} from './elements/origin-detail.component';
import {OriginInListComponent} from './elements/origin-in-list.component';
import {SimpleCardComponent} from './elements/simple-card.component';
import {SkeletonListItem} from './elements/skeleton-list-item.component';
import {SkeletonSegment} from './elements/skeleton-segment-button.component';
import {SkeletonSimpleCard} from './elements/skeleton-simple-card.component';
import {DataListItem} from './list/data-list-item.component';
import {DataListComponent} from './list/data-list.component';
import {FoodDataListComponent} from './list/food-data-list.component';
import {StAppsWebHttpClient} from './stapps-web-http-client.provider';
import {ArticleDetailContentComponent} from './types/article/article-detail-content.component';
import {ArticleListItem} from './types/article/article-list-item.component';
@@ -59,6 +61,7 @@ import {PersonDetailContentComponent} from './types/person/person-detail-content
import {PersonListItem} from './types/person/person-list-item.component';
import {PlaceDetailContentComponent} from './types/place/place-detail-content.component';
import {PlaceListItem} from './types/place/place-list-item.component';
import {PlaceMensaDetailComponent} from './types/place/special/mensa/place-mensa-detail.component';
import {SemesterDetailContentComponent} from './types/semester/semester-detail-content.component';
import {SemesterListItem} from './types/semester/semester-list-item.component';
import {VideoDetailContentComponent} from './types/video/video-detail-content.component';
@@ -80,6 +83,7 @@ import {VideoListItem} from './types/video/video-list-item.component';
CatalogListItem,
DataDetailComponent,
DataDetailContentComponent,
FoodDataListComponent,
DataListComponent,
DataListItem,
DateSeriesDetailContentComponent,
@@ -101,9 +105,11 @@ import {VideoListItem} from './types/video/video-list-item.component';
PersonListItem,
PlaceDetailContentComponent,
PlaceListItem,
PlaceMensaDetailComponent,
SemesterDetailContentComponent,
SemesterListItem,
SkeletonListItem,
SkeletonSegment,
VideoDetailContentComponent,
VideoListItem,
],

View File

@@ -16,9 +16,9 @@ import {Injectable} from '@angular/core';
import {Client} from '@openstapps/api/lib/client';
import {SCSearchQuery, SCSearchResponse, SCThingOriginType, SCThings, SCThingType} from '@openstapps/core';
import {SCSaveableThing} from '@openstapps/core';
import {environment} from '../../../environments/environment';
import {StorageProvider} from '../storage/storage.provider';
import {StAppsWebHttpClient} from './stapps-web-http-client.provider';
import {environment} from './../../../environments/environment';
export enum DataScope {
Local = 'local',

View File

@@ -27,4 +27,5 @@ export class DataDetailContentComponent {
* TODO
*/
@Input() item: SCThings;
}

View File

@@ -0,0 +1,25 @@
/*
* Copyright (C) 2019 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} from '@angular/core';
/**
* TODO
*/
@Component({
selector: 'stapps-skeleton-segment-button',
templateUrl: 'skeleton-segment-button.html',
})
export class SkeletonSegment {
}

View File

@@ -0,0 +1,4 @@
<ion-segment-button>
<ion-skeleton-text animated style="width: 85%;"></ion-skeleton-text>
</ion-segment-button>

View File

@@ -22,9 +22,9 @@ import {
SCThing,
} from '@openstapps/core';
import {NGXLogger} from 'ngx-logger';
import {Subject} from 'rxjs';
import {Subject, Subscription} from 'rxjs';
import {debounceTime, distinctUntilChanged} from 'rxjs/operators';
import {MenuService} from '../../menu/menu.service';
import {ContextMenuService} from '../../menu/context/context-menu.service';
import {SettingsProvider} from '../../settings/settings.provider';
import {DataProvider} from '../data.provider';
@@ -34,6 +34,7 @@ import {DataProvider} from '../data.provider';
@Component({
selector: 'stapps-data-list',
templateUrl: 'data-list.html',
providers: [ContextMenuService],
})
export class DataListComponent implements OnInit {
@@ -50,7 +51,7 @@ export class DataListComponent implements OnInit {
/**
* Container for queried things
*/
items: SCThing[];
items: Promise<SCThing[]>;
/**
* Page size of queries
@@ -77,22 +78,27 @@ export class DataListComponent implements OnInit {
*/
sortQuery: SCSearchSort | undefined;
/**
* Array of all subscriptions to Observables
*/
subscriptions: Subscription[] = [];
/**
*
* @param alertController AlertController
* @param dataProvider DataProvider
* @param menuService MenuService
* @param contextMenuService ContextMenuService
* @param settingsProvider SettingsProvider
* @param logger An angular logger
*/
constructor(
private readonly alertController: AlertController,
private readonly dataProvider: DataProvider,
private readonly menuService: MenuService,
private readonly settingsProvider: SettingsProvider,
private readonly logger: NGXLogger,
protected readonly alertController: AlertController,
protected dataProvider: DataProvider,
protected readonly contextMenuService: ContextMenuService,
protected readonly settingsProvider: SettingsProvider,
protected readonly logger: NGXLogger,
) {
this.queryTextChanged
this.subscriptions.push(this.queryTextChanged
.pipe(
debounceTime(this.searchQueryDueTime),
distinctUntilChanged())
@@ -100,30 +106,32 @@ export class DataListComponent implements OnInit {
this.from = 0;
this.queryText = model;
this.fetchAndUpdateItems();
});
}));
this.menuService.filterQueryChanged$.subscribe((query) => {
this.subscriptions.push(this.contextMenuService.filterQueryChanged$.subscribe((query) => {
this.filterQuery = query;
this.from = 0;
this.fetchAndUpdateItems();
});
this.menuService.sortQueryChanged$.subscribe((query) => {
}));
this.subscriptions.push(this.contextMenuService.sortQueryChanged$.subscribe((query) => {
this.sortQuery = query;
this.from = 0;
this.fetchAndUpdateItems();
});
}));
this.fetchAndUpdateItems();
/**
* Subscribe to 'settings.changed' events
*/
this.settingsProvider.settingsActionChanged$.subscribe(({type, payload}) => {
this.subscriptions.push(this.settingsProvider.settingsActionChanged$.subscribe(({type, payload}) => {
if (type === 'stapps.settings.changed') {
const {category, name, value} = payload!;
this.logger.log(`received event "settings.changed" with category:
${category}, name: ${name}, value: ${JSON.stringify(value)}`);
}
},
);
));
}
/**
@@ -131,7 +139,7 @@ export class DataListComponent implements OnInit {
*
* @param append If true fetched data gets appended to existing, override otherwise (default false)
*/
private async fetchAndUpdateItems(append = false): Promise<void> {
protected async fetchAndUpdateItems(append = false): Promise<void> {
// build query search options
const searchOptions: SCSearchQuery = {
from: this.from,
@@ -154,17 +162,17 @@ export class DataListComponent implements OnInit {
}
return this.dataProvider.search(searchOptions)
.then((result) => {
.then(async (result) => {
if (append) {
let items = await this.items;
// append results
this.items = this.items.concat(result.data);
items = items.concat(result.data);
this.items = (async () => items)();
} else {
// override items with results
this.items = result.data;
}
// update filter options if result contains facets
if (typeof result.facets !== 'undefined') {
this.updateContextFilter(result.facets);
this.items = (async () => {
this.updateContextFilter(result.facets);
return result.data} )();
}
}, async (err) => {
const alert: HTMLIonAlertElement = await this.alertController.create({
@@ -188,11 +196,10 @@ export class DataListComponent implements OnInit {
}
/**
* Initialises the sort context of menuService
* Initialises the possible sort options in ContextMenuService
*/
ngOnInit(): void {
// initialise sort option for context menu
this.menuService.setContextSort({
this.contextMenuService.setContextSort({
name: 'sort',
reversed: false,
value: 'relevance',
@@ -213,6 +220,15 @@ export class DataListComponent implements OnInit {
});
}
/**
* Unsubscribe from Observables
*/
ngOnDestroy() {
for (let sub of this.subscriptions) {
sub.unsubscribe();
}
}
/**
* Search event of search bar
*/
@@ -221,9 +237,9 @@ export class DataListComponent implements OnInit {
}
/**
* Updates context filter in menuService with facets
* Updates the possible filter options in ContextMenuService with facets
*/
updateContextFilter(facets: SCFacet[]) {
this.menuService.updateContextFilter(facets);
this.contextMenuService.updateContextFilter(facets);
}
}

View File

@@ -6,7 +6,7 @@
<ion-menu-button></ion-menu-button>
</ion-buttons>
<ion-buttons slot="end">
<ion-menu-button menu="context">
<ion-menu-button menu="context" auto-hide="false">
<ion-icon name="options"></ion-icon>
</ion-menu-button>
</ion-buttons>
@@ -17,13 +17,15 @@
</ion-toolbar>
</ion-header>
<ion-content id="data-list">
<ion-list *ngIf="items">
<stapps-data-list-item [item]="item" *ngFor="let item of items"></stapps-data-list-item>
</ion-list>
<ion-list *ngIf="!items">
<ion-content id="data-list">
<div *ngIf="items | async as items; else loading">
<ion-list>
<stapps-data-list-item [item]="item" *ngFor="let item of items"></stapps-data-list-item>
</ion-list>
</div>
<ng-template #loading>
<stapps-skeleton-list-item *ngFor="let skeleton of [1, 2, 3, 4, 5]"></stapps-skeleton-list-item>
</ion-list>
</ng-template>
<ion-infinite-scroll (ionInfinite)="loadMore($event)">
<ion-infinite-scroll-content></ion-infinite-scroll-content>
</ion-infinite-scroll>

View File

@@ -0,0 +1,126 @@
/*
* Copyright (C) 2018-2020 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} from '@angular/core';
import {
SCSearchQuery, SCThing,
} from '@openstapps/core';
import {DataListComponent} from './data-list.component';
/**
* TODO
*/
@Component({
selector: 'stapps-data-list',
templateUrl: 'data-list.html',
})
export class FoodDataListComponent extends DataListComponent {
/**
* Fetches items with set query configuration
*
* @param append If true fetched data gets appended to existing, override otherwise (default false)
*/
protected async fetchAndUpdateItems(append = false): Promise<void> {
try {
// build query search options
const searchOptions: SCSearchQuery = {
from: this.from,
size: this.pageSize,
};
if (this.queryText && this.queryText.length > 0) {
// add query string
searchOptions.query = this.queryText;
}
if (this.sortQuery) {
// add query sorting
searchOptions.sort = [this.sortQuery];
}
searchOptions.filter = {
arguments: {
filters: [
{
arguments: {
field: 'categories',
value: 'canteen',
},
type: 'value',
},
{
arguments: {
field: 'categories',
value: 'student canteen',
},
type: 'value',
},
{
arguments: {
field: 'categories',
value: 'cafe',
},
type: 'value',
},
{
arguments: {
field: 'categories',
value: 'restaurant',
},
type: 'value',
},
],
operation: 'or',
},
type: 'boolean',
};
if (this.filterQuery !== undefined) {
searchOptions.filter = {
arguments: {
filters: [
searchOptions.filter,
this.filterQuery,
],
operation: 'and',
},
type: 'boolean',
};
}
const result = await this.dataProvider.search(searchOptions);
this.items = (async () => {
this.updateContextFilter(result.facets);
let items: SCThing[];
if(append) {
items = (await this.items).concat(result.data);
} else {
items = result.data;
}
return items;
})();
} catch (err) {
const alert: HTMLIonAlertElement = await this.alertController.create({
buttons: ['Dismiss'],
header: 'Error',
subHeader: err.message,
});
await alert.present();
}
}
}

View File

@@ -14,12 +14,14 @@
*/
import {Component, Input} from '@angular/core';
import {SCBuilding, SCFloor, SCPointOfInterest, SCRoom, SCThing, SCTranslations} from '@openstapps/core';
import {SCThingTranslator} from '@openstapps/core';
import {SCThings, SCThingTranslator} from '@openstapps/core';
import {DataProvider} from '../../data.provider';
/**
* TODO
*/
@Component({
providers: [ DataProvider ],
selector: 'stapps-place-detail-content',
templateUrl: 'place-detail-content.html',
})
@@ -47,4 +49,26 @@ export class PlaceDetailContentComponent {
constructor() {
this.translator = new SCThingTranslator(this.language);
}
/**
* TODO
*
* @param item TODO
*/
// tslint:disable-next-line:completed-docs prefer-function-over-method
hasCategories(item: SCThings): item is SCThings & { categories: string[]; } {
// tslint:disable-next-line:completed-docs
return typeof (item as { categories: string[]; }).categories !== 'undefined';
}
/**
* Helper function as 'typeof' is not accessible in HTML
*
* @param item TODO
*/
isMensaThing(item: SCThings): boolean {
return this.hasCategories(item) &&
((item.categories as string[]).includes('canteen') || (item.categories as string[]).includes('cafe')
|| (item.categories as string[]).includes('student canteen') || (item.categories as string[]).includes('restaurant'));
}
}

View File

@@ -1,3 +1,4 @@
<stapps-place-mensa-detail-content [item]="item" [language]="language" *ngIf="isMensaThing(item)"></stapps-place-mensa-detail-content>
<ng-container *ngIf="item.type !== 'floor'">
<stapps-simple-card *ngIf="item.type !== 'floor' && item.categories" [title]="'Categories'" [content]="translator.translate(item).categories()"></stapps-simple-card>
<stapps-address-detail *ngIf="item.type !== 'floor' && item.address" [address]="item.address"></stapps-address-detail>

View File

@@ -0,0 +1,132 @@
/*
* Copyright (C) 2019 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 moment, {Moment} from 'moment';
import {AfterViewInit, ChangeDetectorRef, Component, Input} from '@angular/core';
import {SCDish, SCPlace, SCThing, SCThingTranslator, SCTranslations} from '@openstapps/core';
import {PlaceMensaService} from './place-mensa-service';
/**
* TODO
*/
@Component({
providers: [PlaceMensaService],
selector: 'stapps-place-mensa-detail-content',
templateUrl: 'place-mensa.html',
})
export class PlaceMensaDetailComponent implements AfterViewInit {
/**
* TODO
*/
dishes: Promise<Record<string, SCDish[]>> | null = null;
/**
* number of days to display mensa menus for
*/
displayRange = 5;
/**
* TODO
*/
@Input() item: SCPlace;
/**
* TODO
*/
@Input() language: keyof SCTranslations<SCThing>;
/**
* TODO
*/
selectedDay: string;
/**
* First day to display menu items for
*/
startingDay: Moment;
/**
* TODO
*/
translator: SCThingTranslator;
constructor(private mensaService: PlaceMensaService, private changeDectectorRef: ChangeDetectorRef) {
// TODO: translation
this.translator = new SCThingTranslator(this.language);
this.startingDay = moment();
this.startingDay.hour(0);
this.startingDay.minute(0);
this.startingDay.millisecond(0);
}
/**
* TODO
*/
ngAfterViewInit() {
this.dishes = this.mensaService.getAllDishes(this.item)
.then((dishesResult) => {
const out = this.splitDishes(dishesResult, this.item, this.startingDay, this.displayRange);
for (const key in out) {
if (out.hasOwnProperty(key)) {
this.selectedDay = key;
break;
}
}
this.changeDectectorRef.detectChanges();
return out;
});
}
/**
* Splits a list of dishes with availability start and end into multiple lists by day
*/
// tslint:disable-next-line:prefer-function-over-method
splitDishes(dishes: SCDish[], place: SCPlace, startingDay: Moment, displayRange: number): Record<string, SCDish[]> {
const out: Record<string, SCDish[]> = {};
for (let i = 0; i < displayRange; i++) {
const nextDay: Moment = moment(startingDay)
.add(1, 'days');
const selectedDishes: SCDish[] = [];
for (const dish of dishes) {
// go through all offers
if (dish.offers === undefined) { continue; }
for (const offer of dish.offers) {
if (offer.inPlace === undefined || offer.inPlace.uid !== place.uid) { continue; }
// get availabilities
const availabilityStarts = offer.availabilityStarts === undefined ? undefined : moment(offer.availabilityStarts);
const availabilityEnds = offer.availabilityEnds === undefined ? undefined : moment(offer.availabilityEnds);
if ((availabilityStarts === undefined || availabilityStarts.isBefore(nextDay))
&& (availabilityEnds === undefined || availabilityEnds.isAfter(startingDay))) {
selectedDishes.push(dish);
break;
}
}
}
if (selectedDishes.length) {
out[startingDay.format('YYYY-MM-DD')] = selectedDishes;
}
// tslint:disable-next-line:no-parameter-reassignment
startingDay = nextDay;
}
return out;
}
}

View File

@@ -0,0 +1,63 @@
/*
* Copyright (C) 2019 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 {Injectable} from '@angular/core';
import {SCDish, SCPlace, SCThingType} from '@openstapps/core';
import {DataProvider} from '../../../../data.provider';
/**
* TODO
*/
@Injectable({
providedIn: 'root',
})
export class PlaceMensaService {
constructor(private dataProvider: DataProvider) { }
/**
* Fetches all dishes for this building
*/
async getAllDishes(place: SCPlace): Promise<SCDish[]> {
// use filter to get all dishes with the building's uid in one of the offer's inPlace field
// TODO: make sure this actually works with ES
const result = await this.dataProvider.search({
filter: {
arguments: {
filters: [
{
arguments: {
field: 'offers.inPlace.uid',
value: place.uid,
},
type: 'value',
},
{
arguments: {
field: 'type',
value: SCThingType.Dish,
},
type: 'value',
},
],
operation: 'and',
},
type: 'boolean',
},
size: 1000,
});
return result.data as SCDish[];
}
}

View File

@@ -0,0 +1,24 @@
<ng-container>
<div *ngIf="dishes | async as dishes; else loading">
<ion-segment [(ngModel)]="selectedDay">
<ion-segment-button *ngFor="let day of dishes | keyvalue" [value]="day.key">
<ion-label>{{day.key | amParse:'YYYY-MM-DD' | amDateFormat:'dddd, L'}}</ion-label>
</ion-segment-button>
</ion-segment>
<div [ngSwitch]="selectedDay">
<div *ngFor="let day of dishes | keyvalue">
<ion-list *ngSwitchCase="day.key">
<stapps-data-list-item [item]="dish" *ngFor="let dish of day.value"></stapps-data-list-item>
</ion-list>
</div>
</div>
</div>
<ng-template #loading>
<ion-segment>
<stapps-skeleton-segment-button *ngFor="let skeleton of [1, 2, 3]"></stapps-skeleton-segment-button>
</ion-segment>
<ion-list>
<stapps-skeleton-list-item *ngFor="let skeleton of [1, 2]"></stapps-skeleton-list-item>
</ion-list>
</ng-template>
</ng-container>

View File

@@ -21,7 +21,7 @@ import {TranslateModule,} from '@ngx-translate/core';
import {SCFacet, SCThingType} from '@openstapps/core';
import {ContextMenuComponent} from './context-menu.component';
import {SettingsModule} from '../../settings/settings.module';
import {MenuService} from '../menu.service';
import {ContextMenuService} from '../context/context-menu.service';
import {FilterContext, SortContext} from './context-type';
describe('ContextMenuComponent', async () => {
@@ -36,7 +36,7 @@ describe('ContextMenuComponent', async () => {
ChildrenOutletContexts,
Location,
UrlSerializer,
MenuService,
ContextMenuService,
{provide: LocationStrategy, useClass: PathLocationStrategy},
{provide: APP_BASE_HREF, useValue: '/'},
],

View File

@@ -20,8 +20,9 @@ import {
SCThingType,
SCTranslations,
} from '@openstapps/core';
import {MenuService} from '../menu.service';
import {ContextMenuService} from './context-menu.service';
import {FilterContext, SortContext, SortContextOption} from './context-type';
import {Subscription} from 'rxjs';
/**
* The context menu
@@ -68,30 +69,45 @@ export class ContextMenuComponent {
*/
translator: SCThingTranslator;
/**
* Array of all Subscriptions
*/
subscriptions: Subscription[] = [];
constructor(private translateService: TranslateService,
private readonly menuService: MenuService) {
private readonly contextMenuService: ContextMenuService) {
this.language = this.translateService.currentLang as keyof SCTranslations<SCLanguage>;
this.translator = new SCThingTranslator(this.language);
this.translateService.onLangChange.subscribe((event: LangChangeEvent) => {
this.subscriptions.push(this.translateService.onLangChange.subscribe((event: LangChangeEvent) => {
this.language = event.lang as keyof SCTranslations<SCLanguage>;
this.translator = new SCThingTranslator(this.language);
});
}));
this.menuService.filterContextChanged$.subscribe((filterContext) => {
this.subscriptions.push(this.contextMenuService.filterContextChanged$.subscribe((filterContext) => {
this.filterOption = filterContext;
});
}));
this.menuService.sortOptions.subscribe((sortContext) => {
this.subscriptions.push(this.contextMenuService.sortOptions.subscribe((sortContext) => {
this.sortOption = sortContext;
});
}));
}
/**
* Unsubscribe from Observables
*/
ngOnDestroy() {
for (let sub of this.subscriptions) {
sub.unsubscribe();
}
}
/**
* Sets selected filter options and updates listener
*/
filterChanged = () => {
this.menuService.contextFilterChanged(this.filterOption);
this.contextMenuService.contextFilterChanged(this.filterOption);
}
/**
@@ -117,7 +133,7 @@ export class ContextMenuComponent {
option.options.forEach((filterFacet) => filterFacet.buckets.forEach((filterBucket) => {
filterBucket.checked = false;
}));
this.menuService.contextFilterChanged(this.filterOption);
this.contextMenuService.contextFilterChanged(this.filterOption);
}
/**
@@ -134,6 +150,6 @@ export class ContextMenuComponent {
option.reversed = false;
}
}
this.menuService.contextSortChanged(option);
this.contextMenuService.contextSortChanged(option);
}
}

View File

@@ -1,17 +1,17 @@
import {TestBed} from '@angular/core/testing';
import {MenuService} from './menu.service';
import {ContextMenuService} from './context-menu.service';
import {SCFacet} from '@openstapps/core';
import {FilterContext, SortContext} from './context/context-type';
import {FilterContext, SortContext} from './context-type';
describe('MenuService', () => {
let service: MenuService;
describe('ContextMenuService', () => {
let service: ContextMenuService;
beforeEach(() => {
TestBed.configureTestingModule({
providers: [MenuService]
providers: [ContextMenuService]
});
service = TestBed.get(MenuService);
service = TestBed.get(ContextMenuService);
});
it('should be created', () => {

View File

@@ -15,13 +15,13 @@
import {Injectable} from '@angular/core';
import {SCFacet, SCFacetBucket, SCSearchFilter, SCSearchSort} from '@openstapps/core';
import {Subject} from 'rxjs';
import {FilterBucket, FilterContext, FilterFacet, SortContext} from './context/context-type';
import {FilterBucket, FilterContext, FilterFacet, SortContext} from './context-type';
/**
* MenuService provides bidirectional communication of context menu options and search queries
* ContextMenuService provides bidirectional communication of context menu options and search queries
*/
@Injectable()
export class MenuService {
export class ContextMenuService {
/**
* Local filter context object
@@ -82,9 +82,10 @@ export class MenuService {
const filters: SCSearchFilter[] = [];
filterContext.options.forEach((filterFacet) => {
const optionFilters: SCSearchFilter[] = [];
filterFacet.buckets.forEach((filterBucket) => {
if (filterBucket.checked) {
filters.push(
optionFilters.push(
{
arguments: {
field: filterFacet.field,
@@ -94,13 +95,22 @@ export class MenuService {
});
}
});
if (optionFilters.length > 0) {
filters.push({
arguments: {
filters: optionFilters,
operation: 'or',
},
type: 'boolean',
});
}
});
if (filters.length > 0) {
return {
arguments: {
filters: filters,
operation: 'or',
operation: 'and',
},
type: 'boolean',
};

View File

@@ -20,7 +20,7 @@ import {IonicModule} from '@ionic/angular';
import {TranslateModule} from '@ngx-translate/core';
import {SettingsModule} from '../settings/settings.module';
import {ContextMenuComponent} from './context/context-menu.component';
import {MenuService} from './menu.service';
import {ContextMenuService} from './context/context-menu.service';
import {NavigationComponent} from './navigation/navigation.component';
/**
@@ -44,7 +44,7 @@ import {NavigationComponent} from './navigation/navigation.component';
SettingsModule,
],
providers: [
MenuService,
ContextMenuService,
],
})
export class MenuModule {}