From b6f92a74494e1a39d5c828d593eb70c7002bb0f6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jovan=20Kruni=C4=87?= Date: Tue, 5 Mar 2019 18:02:29 +0100 Subject: [PATCH] feat(data): implement basic facets handling Related to #1 --- .../modules/data/data-facets.provider.spec.ts | 122 ++++++++++++++ src/app/modules/data/data-facets.provider.ts | 152 ++++++++++++++++++ src/app/modules/data/data.module.ts | 2 + src/app/modules/data/data.provider.spec.ts | 45 +----- .../data/detail/data-detail.component.spec.ts | 18 +-- .../modules/data/list/data-list.component.ts | 11 +- 6 files changed, 291 insertions(+), 59 deletions(-) create mode 100644 src/app/modules/data/data-facets.provider.spec.ts create mode 100644 src/app/modules/data/data-facets.provider.ts diff --git a/src/app/modules/data/data-facets.provider.spec.ts b/src/app/modules/data/data-facets.provider.spec.ts new file mode 100644 index 00000000..1fffaab2 --- /dev/null +++ b/src/app/modules/data/data-facets.provider.spec.ts @@ -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 . + */ +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); + }); +}); diff --git a/src/app/modules/data/data-facets.provider.ts b/src/app/modules/data/data-facets.provider.ts new file mode 100644 index 00000000..452e6ef6 --- /dev/null +++ b/src/app/modules/data/data-facets.provider.ts @@ -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 . + */ +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); + } +} diff --git a/src/app/modules/data/data.module.ts b/src/app/modules/data/data.module.ts index 44b17a5a..3501f798 100644 --- a/src/app/modules/data/data.module.ts +++ b/src/app/modules/data/data.module.ts @@ -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, ], }) diff --git a/src/app/modules/data/data.provider.spec.ts b/src/app/modules/data/data.provider.spec.ts index b842763a..ca2dbc1d 100644 --- a/src/app/modules/data/data.provider.spec.ts +++ b/src/app/modules/data/data.provider.spec.ts @@ -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 = { 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({ diff --git a/src/app/modules/data/detail/data-detail.component.spec.ts b/src/app/modules/data/detail/data-detail.component.spec.ts index 6d4e3a44..b2c4c242 100644 --- a/src/app/modules/data/detail/data-detail.component.spec.ts +++ b/src/app/modules/data/detail/data-detail.component.spec.ts @@ -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: { diff --git a/src/app/modules/data/list/data-list.component.ts b/src/app/modules/data/list/data-list.component.ts index c26bf020..6da72370 100644 --- a/src/app/modules/data/list/data-list.component.ts +++ b/src/app/modules/data/list/data-list.component.ts @@ -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 { + async loadMore(event: any): Promise { this.from += this.size; await this.fetchItems(); event.target.complete(); - return; } search(query: string) { this.queryChanged.next(query); } - }