feat(data): implement basic facets handling

Related to #1
This commit is contained in:
Jovan Krunić
2019-03-05 18:02:29 +01:00
parent 2558163ad6
commit b6f92a7449
6 changed files with 291 additions and 59 deletions

View File

@@ -0,0 +1,122 @@
/*
* Copyright (C) 2018, 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 {TestBed} from '@angular/core/testing';
import {SCFacet, SCThing} from '@openstapps/core';
import {sampleAggregations} from '../../_helpers/data/sampleConfiguration';
import {sampleThingsMap} from '../../_helpers/data/sampleThings';
import {DataFacetsProvider} from './data-facets.provider';
import {DataModule} from './data.module';
import {DataProvider} from './data.provider';
import {StAppsWebHttpClient} from './stapps-web-http-client.provider';
describe('DataProvider', () => {
let dataFacetsProvider: DataFacetsProvider;
const sampleFacets: SCFacet[] = [
{
buckets: [{education: 4}, {learn: 3}, {computer: 3}],
field: 'categories',
},
{
buckets: [{'Major One': 1}, {'Major Two': 2}, {'Major Three': 1}],
field: 'majors',
},
{
buckets: [{building: 3}, {room: 7}],
field: 'type',
},
];
const sampleFacetsMap: {[key: string]: {[key: string]: number}} = {
categories: {education: 4, learn: 3, computer: 3},
majors: {'Major One': 1, 'Major Two': 2, 'Major Three': 1},
type: {building: 3, room: 7},
};
const sampleItems: SCThing[] = [
...sampleThingsMap.building,
...sampleThingsMap.person,
...sampleThingsMap.room,
...sampleThingsMap['academic event'],
];
const sampleBuckets: Array<{[key: string]: number}> = [{foo: 1}, {bar: 2}, {'foo bar': 3}];
const sampleBucketsMap: {[key: string]: number} = {foo: 1, bar: 2, 'foo bar': 3};
beforeEach(() => {
TestBed.configureTestingModule({
imports: [DataModule],
providers: [
DataProvider,
StAppsWebHttpClient,
],
});
dataFacetsProvider = TestBed.get(DataFacetsProvider);
});
it('should add buckets properly', () => {
let bucketsMap: {[key: string]: number} = {};
bucketsMap = dataFacetsProvider.addBuckets(bucketsMap, ['foo']);
expect(bucketsMap).toEqual({foo: 1});
bucketsMap = dataFacetsProvider.addBuckets(bucketsMap, ['foo']);
expect(bucketsMap).toEqual({foo: 2});
bucketsMap = dataFacetsProvider.addBuckets(bucketsMap, ['bar']);
expect(bucketsMap).toEqual({foo: 2, bar: 1});
});
it('should convert buckets to buckets map', () => {
expect(dataFacetsProvider.bucketsToMap(sampleBuckets)).toEqual(sampleBucketsMap);
});
it('should convert buckets map into buckets', () => {
expect(dataFacetsProvider.mapToBuckets(sampleBucketsMap)).toEqual(sampleBuckets);
});
it('should convert facets into a facets map', () => {
expect(dataFacetsProvider.facetsToMap(sampleFacets)).toEqual(sampleFacetsMap);
});
it('should convert facets map into facets', () => {
expect(dataFacetsProvider.mapToFacets(sampleFacetsMap)).toEqual(sampleFacets);
});
it('should extract facets (and append them if needed) from the data', () => {
const sampleCombinedFacets: SCFacet[] = [
{
buckets: [{computer: 3}, {course: 1}, {education: 5}, {learn: 3}, {library: 1}, {practicum: 1}],
field: 'categories',
},
{
buckets: [{'Major One': 2}, {'Major Two': 4}, {'Major Three': 2}],
field: 'majors',
},
{
buckets: [{building: 4}, {'academic event': 2}, {person: 2}, {room: 8}],
field: 'type',
},
];
const checkEqual = (expected: SCFacet[], actual: SCFacet[]) => {
const expectedMap = dataFacetsProvider.facetsToMap(expected);
const actualMap = dataFacetsProvider.facetsToMap(actual);
Object.keys(actualMap).forEach((key) => {
Object.keys(actualMap[key]).forEach((subKey) => {
expect(actualMap[key][subKey]).toBe(expectedMap[key][subKey]);
});
});
};
checkEqual(dataFacetsProvider.extractFacets(sampleItems, sampleAggregations, sampleFacets), sampleCombinedFacets);
});
});

View File

@@ -0,0 +1,152 @@
/*
* Copyright (C) 2018, 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 {SCBackendAggregationConfiguration, SCFacet, SCThing} from '@openstapps/core';
@Injectable()
export class DataFacetsProvider {
// tslint:disable-next-line:no-empty
constructor() {
}
/**
* Adds buckets to a map of buckets (e.g. if a buckets array is [{foo: 1}, {bar: 3}],
* its bucketsMap is {foo: 1, bar: 3}), if a field 'bar' is added to it it becomes:
* {foo: 1, bar: 4}
*
* @param bucketsMap Buckets array transformed into a map
* @param fields A field that should be added to buckets (its map)
*/
addBuckets(bucketsMap: {[key: string]: number}, fields: string[]): {[key: string]: number} {
fields.forEach((field) => {
if (typeof bucketsMap[field] !== 'undefined') {
bucketsMap[field] = bucketsMap[field] + 1;
} else {
bucketsMap[field] = 1;
}
});
return bucketsMap;
}
/**
* Converts a buckets array to a map
*
* @param buckets Buckets from a facet
*/
bucketsToMap(buckets: Array<{[key: string]: number}>): {[key: string]: number} {
const bucketsMap: {[key: string]: number} = {};
buckets.forEach((bucket) => {
for (const key in bucket) {
if (bucket.hasOwnProperty(key)) {
bucketsMap[key] = bucket[key];
}
}
});
return bucketsMap;
}
/**
* Converts a buckets map into buckets array (as it is inside of a facet)
*
* @param bucketsMap A map from a buckets array
*/
mapToBuckets(bucketsMap: {[key: string]: number}): Array<{[key: string]: number}> {
const buckets: Array<{[key: string]: number}> = [];
for (const key in bucketsMap) {
if (bucketsMap.hasOwnProperty(key)) {
const bucket: {[key: string]: number} = {};
bucket[key] = bucketsMap[key];
buckets.push(bucket);
}
}
return buckets;
}
/**
* Converts facets array into a map (for quicker operations with facets)
*
* @param facets Array of facets
*/
facetsToMap(facets: SCFacet[]): {[key: string]: {[key: string]: number}} {
const facetsMap: {[key: string]: {[key: string]: number}} = {};
facets.forEach((facet) => {
facetsMap[facet.field] = this.bucketsToMap(facet.buckets);
});
return facetsMap;
}
/**
* Converts facets map into an array of facets (as they are provided by backend)
*
* @param facetsMap A map from facets array
*/
mapToFacets(facetsMap: {[key: string]: {[key: string]: number}}): SCFacet[] {
const facets: SCFacet[] = [];
for (const key in facetsMap) {
if (facetsMap.hasOwnProperty(key)) {
const facet: SCFacet = {buckets: [], field: ''};
facet.field = key;
facet.buckets = this.mapToBuckets(facetsMap[key]);
facets.push(facet);
}
}
return facets;
}
/**
* Extract facets from data items, optionally combine them with a list of existing facets
*
* @param items Items to extract facets from
* @param aggregations Aggregations configuration(s) from backend
* @param facets Existing facets to be combined with the facets from the items
*/
extractFacets(
items: SCThing[],
aggregations: SCBackendAggregationConfiguration[],
facets: SCFacet[] = []): SCFacet[] {
if (items.length === 0) {
if (facets.length === 0) {
return [];
} else {
return facets;
}
}
const combinedFacets: SCFacet[] = facets;
const combinedFacetsMap: {[key: string]: {[key: string]: number}} = this.facetsToMap(combinedFacets);
(items as any[]).forEach((item) => {
aggregations.forEach((aggregation) => {
let fieldValues: string | string[] = item[aggregation.fieldName];
if (typeof fieldValues === 'undefined') {
return;
}
if (typeof fieldValues === 'string') {
fieldValues = [fieldValues];
}
if (typeof aggregation.onlyOnTypes === 'undefined') {
combinedFacetsMap[aggregation.fieldName] = this.addBuckets(
combinedFacetsMap[aggregation.fieldName] || {},
fieldValues,
);
} else if (aggregation.onlyOnTypes.indexOf(item.type) !== -1) {
combinedFacetsMap[aggregation.fieldName] = this.addBuckets(
combinedFacetsMap[aggregation.fieldName] || {},
fieldValues,
);
}
});
});
return this.mapToFacets(combinedFacetsMap);
}
}

View File

@@ -19,6 +19,7 @@ import {FormsModule} from '@angular/forms';
import {IonicModule} from '@ionic/angular';
import {TranslateModule} from '@ngx-translate/core';
import {StorageModule} from '../storage/storage.module';
import {DataFacetsProvider} from './data-facets.provider';
import {DataRoutingModule} from './data-routing.module';
import {DataProvider} from './data.provider';
import {DataDetailComponent} from './detail/data-detail.component';
@@ -55,6 +56,7 @@ import {EventListItemComponent} from './types/event/event-list-item.component';
],
providers: [
DataProvider,
DataFacetsProvider,
StAppsWebHttpClient,
],
})

View File

@@ -14,8 +14,9 @@
*/
import {TestBed} from '@angular/core/testing';
import {Client} from '@openstapps/api/lib/client';
import {SCMessage, SCSaveableThing, SCSearchQuery, SCSearchResponse,
SCSearchValueFilter, SCThing, SCThingOriginType, SCThings, SCThingType} from '@openstapps/core';
import {SCDish, SCMessage, SCSaveableThing, SCSearchQuery,
SCSearchResponse, SCSearchValueFilter, SCThing, SCThingOriginType, SCThings, SCThingType} from '@openstapps/core';
import {sampleThingsMap} from '../../_helpers/data/sampleThings';
import {StorageProvider} from '../storage/storage.provider';
import {DataModule} from './data.module';
import {DataProvider, DataScope} from './data.provider';
@@ -24,31 +25,9 @@ import {StAppsWebHttpClient} from './stapps-web-http-client.provider';
describe('DataProvider', () => {
let dataProvider: DataProvider;
let storageProvider: StorageProvider;
const sampleThing: SCMessage = sampleThingsMap.message[0] as SCMessage;
const sampleResponse: SCSearchResponse = {
data: [
{
categories: ['main dish'],
name: 'foo dish',
origin: {
indexed: '12345',
name: 'bar',
type: SCThingOriginType.Remote,
},
type: SCThingType.Dish,
uid: '123',
},
{
categories: ['dessert'],
name: 'foo dessert',
origin: {
indexed: '12345',
name: 'bar',
type: SCThingOriginType.Remote,
},
type: SCThingType.Dish,
uid: '123',
},
],
data: sampleThingsMap.dish as SCDish[],
facets: [
{
buckets: [],
@@ -75,18 +54,6 @@ describe('DataProvider', () => {
filter: sampleFilter,
};
const sampleThing: SCMessage = {
audiences: ['students'],
message: 'Foo Message',
name: 'foo',
origin: {
indexed: 'SOME-DATE',
name: 'some name',
type: SCThingOriginType.Remote,
},
type: SCThingType.Message,
uid: '123',
};
const sampleSavable: SCSaveableThing<SCThings> = {
data: sampleThing,
name: sampleThing.name,
@@ -97,7 +64,7 @@ describe('DataProvider', () => {
type: SCThingType.Message,
uid: sampleThing.uid,
};
const otherSampleThing: SCMessage = {...sampleThing, uid: '456', name: 'bar'};
const otherSampleThing: SCMessage = {...sampleThing, uid: 'message-456', name: 'bar'};
beforeEach(async () => {
TestBed.configureTestingModule({

View File

@@ -16,12 +16,12 @@ import {CUSTOM_ELEMENTS_SCHEMA} from '@angular/core';
import {async, ComponentFixture, TestBed} from '@angular/core/testing';
import {ActivatedRoute, RouterModule} from '@angular/router';
import {IonRefresher} from '@ionic/angular';
import {SCMessage, SCThingOriginType, SCThingType} from '@openstapps/core';
import {TranslateFakeLoader, TranslateLoader, TranslateModule} from '@ngx-translate/core';
import {sampleThingsMap} from '../../../_helpers/data/sampleThings';
import {DataRoutingModule} from '../data-routing.module';
import {DataModule} from '../data.module';
import {DataProvider} from '../data.provider';
import {DataDetailComponent} from './data-detail.component';
import {TranslateModule, TranslateLoader, TranslateFakeLoader} from '@ngx-translate/core';
describe('DataDetailComponent', () => {
let comp: DataDetailComponent;
@@ -29,25 +29,13 @@ describe('DataDetailComponent', () => {
let detailPage: HTMLElement;
let dataProvider: DataProvider;
let refresher: IonRefresher;
const sampleThing = sampleThingsMap.message[0];
// @Component({ selector: 'stapps-data-list-item', template: '' })
// class DataListItemComponent {
// @Input() item;
// }
const sampleThing: SCMessage = {
audiences: ['students'],
message: 'Foo Message',
name: 'foo',
origin: {
indexed: 'SOME-DATE',
name: 'some name',
type: SCThingOriginType.Remote,
},
type: SCThingType.Message,
uid: '123',
};
const fakeActivatedRoute = {
snapshot: {
paramMap: {

View File

@@ -36,8 +36,11 @@ export class DataListComponent {
loading: HTMLIonLoadingElement;
// tslint:disable-next-line:max-line-length
constructor(private loadingController: LoadingController, private alertController: AlertController, dataProvider: DataProvider) {
constructor(
private loadingController: LoadingController,
private alertController: AlertController,
dataProvider: DataProvider,
) {
this.dataProvider = dataProvider;
this.queryChanged
.pipe(
@@ -84,15 +87,13 @@ export class DataListComponent {
});
}
async loadMore(event: any): Promise<any> {
async loadMore(event: any): Promise<void> {
this.from += this.size;
await this.fetchItems();
event.target.complete();
return;
}
search(query: string) {
this.queryChanged.next(query);
}
}