diff --git a/src/app/child-dev-project/children/child-block/child-block.component.html b/src/app/child-dev-project/children/child-block/child-block.component.html index 684674a246..50ee012fae 100644 --- a/src/app/child-dev-project/children/child-block/child-block.component.html +++ b/src/app/child-dev-project/children/child-block/child-block.component.html @@ -1,12 +1,12 @@ - + {{entity?.name}} ({{entity?.projectNumber}})
- +

{{entity?.name}}

diff --git a/src/app/child-dev-project/children/child-details/child-details.component.css b/src/app/child-dev-project/children/child-details/child-details.component.css index ade807d12b..835eb06e27 100644 --- a/src/app/child-dev-project/children/child-details/child-details.component.css +++ b/src/app/child-dev-project/children/child-details/child-details.component.css @@ -61,6 +61,8 @@ .child-pic-photofile { position: absolute; - left: 0; - top: 150px; + left: 1px; + top: 115px; + width: 120px; + background: white; } diff --git a/src/app/child-dev-project/children/child-details/child-details.component.html b/src/app/child-dev-project/children/child-details/child-details.component.html index 8026bc5a68..24ab4d352a 100644 --- a/src/app/child-dev-project/children/child-details/child-details.component.html +++ b/src/app/child-dev-project/children/child-details/child-details.component.html @@ -58,7 +58,7 @@

- child's photo + child's photo - + + + +
diff --git a/src/app/child-dev-project/children/child-details/child-details.component.ts b/src/app/child-dev-project/children/child-details/child-details.component.ts index 0a7ceefe93..0cce2b52bb 100644 --- a/src/app/child-dev-project/children/child-details/child-details.component.ts +++ b/src/app/child-dev-project/children/child-details/child-details.component.ts @@ -117,7 +117,7 @@ export class ChildDetailsComponent implements OnInit { dropoutType: [{value: this.child.dropoutType, disabled: !this.editing}], dropoutRemarks: [{value: this.child.dropoutRemarks, disabled: !this.editing}], - photoFile: [this.child.photoFile], + photoFile: [{value: this.child.photoFile, disabled: !this.editing}], }); @@ -223,6 +223,6 @@ export class ChildDetailsComponent implements OnInit { */ async uploadChildPhoto(event) { await this.childPhotoService.setImage(event.target.files[0], this.child.entityId); - this.child.photo = await this.childPhotoService.getImage(this.child); + this.child.photo.next(await this.childPhotoService.getImage(this.child)); } } diff --git a/src/app/child-dev-project/children/child-photo-service/child-photo.service.spec.ts b/src/app/child-dev-project/children/child-photo-service/child-photo.service.spec.ts index 131573d5bd..3f0b4b0a7e 100644 --- a/src/app/child-dev-project/children/child-photo-service/child-photo.service.spec.ts +++ b/src/app/child-dev-project/children/child-photo-service/child-photo.service.spec.ts @@ -1,4 +1,4 @@ -import { TestBed } from '@angular/core/testing'; +import { fakeAsync, TestBed, tick } from '@angular/core/testing'; import { ChildPhotoService } from './child-photo.service'; import { CloudFileService } from '../../../core/webdav/cloud-file-service.service'; @@ -69,6 +69,21 @@ describe('ChildPhotoService', () => { }); + it('should getImageAsyncObservable with multiple next() images', fakeAsync(() => { + const testChild = new Child('1'); + const testImg = 'url-encoded-img'; + mockCloudFileService.isConnected.and.returnValue(true); + mockCloudFileService.doesFileExist.and.returnValue(Promise.resolve(true)); + mockCloudFileService.getFile.and.returnValue(Promise.resolve(testImg)); + + const resultSubject = service.getImageAsyncObservable(testChild); + expect(resultSubject.value).toBe(DEFAULT_IMG); + + tick(); + expect(resultSubject.value).toBe(testImg); + })); + + it('should return false for canSetImage if no webdav connection', async () => { mockCloudFileService.isConnected.and.returnValue(false); diff --git a/src/app/child-dev-project/children/child-photo-service/child-photo.service.ts b/src/app/child-dev-project/children/child-photo-service/child-photo.service.ts index b8c5f1a5db..eb749341c2 100644 --- a/src/app/child-dev-project/children/child-photo-service/child-photo.service.ts +++ b/src/app/child-dev-project/children/child-photo-service/child-photo.service.ts @@ -2,6 +2,7 @@ import { Injectable } from '@angular/core'; import { SafeUrl } from '@angular/platform-browser'; import { CloudFileService } from '../../../core/webdav/cloud-file-service.service'; import { Child } from '../model/child'; +import { BehaviorSubject } from 'rxjs'; @Injectable({ providedIn: 'root', @@ -17,13 +18,20 @@ export class ChildPhotoService { * Creates an ArrayBuffer of the photo for that Child or the default image url. * @param child */ - public async getImage(child: Child): Promise { - let image: SafeUrl; + public async getImage(child: { entityId: string, photoFile?: string }): Promise { + let image = await this.getImageFromCloudService(child); + if (!image) { + image = this.getImageFromAssets(child); + } + return image; + } + private async getImageFromCloudService(child: { entityId: string }): Promise { + let image; if (this.cloudFileService.isConnected()) { - const imageType = [ '.png' , '.jpg', '.jpeg', '' ]; + const imageType = ['.png', '.jpg', '.jpeg', '']; for (const ext of imageType) { - const filepath = this.basePath + child.getId() + ext; + const filepath = this.basePath + child.entityId + ext; try { image = await this.cloudFileService.getFile(filepath); break; @@ -36,16 +44,11 @@ export class ChildPhotoService { } } } - - if (!image) { - image = this.getImageFromAssets(child); - } - return image; } - private getImageFromAssets(child: Child): SafeUrl { - if (!child.photoFile) { + private getImageFromAssets(child: { photoFile?: string }): SafeUrl { + if (!child.photoFile || child.photoFile.trim() === '') { return this.getDefaultImage(); } return Child.generatePhotoPath(child.photoFile); @@ -55,6 +58,24 @@ export class ChildPhotoService { return 'assets/child.png'; } + /** + * Load the image for the given child asynchronously, immediately returning an Observable + * that initially emits the static image and later resolves to the image from the cloud service if one exists. + * This allows to immediately display a proper placeholder while the loading may take some time. + * @param child The Child instance for which the photo should be loaded. + */ + public getImageAsyncObservable(child: { entityId: string, photoFile?: string }): BehaviorSubject { + const resultSubject = new BehaviorSubject(this.getImageFromAssets(child)); + this.getImageFromCloudService(child) + .then(photo => { + if (photo && photo !== resultSubject.value) { + resultSubject.next(photo); + } + resultSubject.complete(); + }); + return resultSubject; + } + /** * Check if saving/uploading images is supported in the current state. diff --git a/src/app/child-dev-project/children/child-photo-service/datatype-load-child-photo.spec.ts b/src/app/child-dev-project/children/child-photo-service/datatype-load-child-photo.spec.ts new file mode 100644 index 0000000000..3c338d183e --- /dev/null +++ b/src/app/child-dev-project/children/child-photo-service/datatype-load-child-photo.spec.ts @@ -0,0 +1,84 @@ +/* + * This file is part of ndb-core. + * + * ndb-core 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, either version 3 of the License, or + * (at your option) any later version. + * + * ndb-core 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 ndb-core. If not, see . + */ + +import { fakeAsync, TestBed, tick } from '@angular/core/testing'; +import { SafeUrl } from '@angular/platform-browser'; +import { EntitySchemaService } from '../../../core/entity/schema/entity-schema.service'; +import { DatabaseField } from '../../../core/entity/database-field.decorator'; +import { Entity } from '../../../core/entity/entity'; +import { ChildPhotoService } from './child-photo.service'; +import { LoadChildPhotoEntitySchemaDatatype } from './datatype-load-child-photo'; +import { BehaviorSubject } from 'rxjs'; + +describe('dataType load-child-photo', () => { + let entitySchemaService: EntitySchemaService; + let mockChildPhotoService: jasmine.SpyObj; + + beforeEach(() => { + mockChildPhotoService = jasmine.createSpyObj('mockChildPhotoService', ['getImageAsyncObservable']); + + TestBed.configureTestingModule({ + providers: [ + EntitySchemaService, + { provide: ChildPhotoService, useValue: mockChildPhotoService }, + ], + }, + ); + + entitySchemaService = TestBed.get(EntitySchemaService); + entitySchemaService.registerSchemaDatatype(new LoadChildPhotoEntitySchemaDatatype(mockChildPhotoService)); + }); + + + it('schema:load-child-photo is removed from rawData to be saved', function () { + class TestEntity extends Entity { + @DatabaseField({dataType: 'load-child-photo'}) photo: SafeUrl; + } + const id = 'test1'; + const entity = new TestEntity(id); + entity.photo = '12345'; + + const rawData = entitySchemaService.transformEntityToDatabaseFormat(entity); + expect(rawData.photo).toBeUndefined(); + }); + + it('schema:load-child-photo is provided through ChildPhotoService on load', fakeAsync(() => { + class TestEntity extends Entity { + @DatabaseField({dataType: 'load-child-photo'}) photo: BehaviorSubject; + } + const id = 'test1'; + const entity = new TestEntity(id); + + const defaultImg = 'default-img'; + const mockCloudImg = 'test-img-data'; + + const mockImgObs = new BehaviorSubject(defaultImg); + mockChildPhotoService.getImageAsyncObservable.and.returnValue(mockImgObs); + + const data = { + _id: id, + }; + entitySchemaService.loadDataIntoEntity(entity, data); + + expect(entity.photo.value).toEqual(defaultImg); + + mockImgObs.next(mockCloudImg); + mockImgObs.complete(); + tick(); + expect(entity.photo.value).toEqual(mockCloudImg); + })); +}); diff --git a/src/app/child-dev-project/children/child-photo-service/datatype-load-child-photo.ts b/src/app/child-dev-project/children/child-photo-service/datatype-load-child-photo.ts new file mode 100644 index 0000000000..0f2764b352 --- /dev/null +++ b/src/app/child-dev-project/children/child-photo-service/datatype-load-child-photo.ts @@ -0,0 +1,48 @@ +/* + * This file is part of ndb-core. + * + * ndb-core 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, either version 3 of the License, or + * (at your option) any later version. + * + * ndb-core 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 ndb-core. If not, see . + */ + + +import { EntitySchemaDatatype } from '../../../core/entity/schema/entity-schema-datatype'; +import { ChildPhotoService } from './child-photo.service'; +import { EntitySchemaField } from '../../../core/entity/schema/entity-schema-field'; +import { EntitySchemaService } from '../../../core/entity/schema/entity-schema.service'; +import { Entity } from '../../../core/entity/entity'; + +/** + * Dynamically load the child's photo through the ChildPhotoService during Entity loading process. + */ +export class LoadChildPhotoEntitySchemaDatatype implements EntitySchemaDatatype { + public readonly name = 'load-child-photo'; + + constructor( + private childPhotoService: ChildPhotoService, + ) { } + + + public transformToDatabaseFormat(value) { + return undefined; + } + + public transformToObjectFormat(value, schemaField: EntitySchemaField, schemaService: EntitySchemaService, parent: Entity) { + const childDummy: any = Object.assign({}, parent); + if (!childDummy.entityId) { + childDummy.entityId = Entity.extractEntityIdFromId(childDummy._id); + } + + return this.childPhotoService.getImageAsyncObservable(childDummy); + } +} diff --git a/src/app/child-dev-project/children/children.service.spec.ts b/src/app/child-dev-project/children/children.service.spec.ts index 22ce785ab6..362850d7c1 100644 --- a/src/app/child-dev-project/children/children.service.spec.ts +++ b/src/app/child-dev-project/children/children.service.spec.ts @@ -9,6 +9,8 @@ import { MockDatabase } from '../../core/database/mock-database'; import { TestBed } from '@angular/core/testing'; import { Database } from 'app/core/database/database'; import { ChildPhotoService } from './child-photo-service/child-photo.service'; +import { CloudFileService } from '../../core/webdav/cloud-file-service.service'; +import { MockCloudFileService } from '../../core/webdav/mock-cloud-file-service'; function generateChildEntities(): Child[] { const data = []; @@ -103,10 +105,12 @@ describe('ChildrenService', () => { beforeEach(() => { mockChildPhotoService = jasmine.createSpyObj('mockChildPhotoService', ['getImage']); TestBed.configureTestingModule({ - providers: [EntityMapperService, + providers: [ + EntityMapperService, EntitySchemaService, { provide: Database, useClass: MockDatabase }, - { provide: ChildPhotoService, useValue: mockChildPhotoService }, + { provide: CloudFileService, useClass: MockCloudFileService }, + ChildPhotoService, ChildrenService, ], }, @@ -140,28 +144,6 @@ describe('ChildrenService', () => { expect(childrenBefore.length).toBe(childrenAfter.length - 1); }); - it('should load image for a single child', async() => { - let child = new Child('10'); - await entityMapper.save(child); - expect(child.photo).not.toBeDefined(); - mockChildPhotoService.getImage.and.returnValue(Promise.resolve('test-img')); - child = await service.getChild('10').toPromise(); - expect(mockChildPhotoService.getImage).toHaveBeenCalledWith(child); - expect(child.photo).toEqual('test-img'); - }); - - it('should load images for children', async() => { - let child = new Child('10'); - await entityMapper.save(child); - expect(child.photo).not.toBeDefined(); - mockChildPhotoService.getImage.and.returnValue(Promise.resolve('test-img')); - const childrenList = await service.getChildren().toPromise(); - child = childrenList[0]; - expect(mockChildPhotoService.getImage).toHaveBeenCalledWith(child); - expect(child.photo).toEqual('test-img'); - }); - - it('should find a newly saved child', async () => { const child = new Child('10'); let error; diff --git a/src/app/child-dev-project/children/children.service.ts b/src/app/child-dev-project/children/children.service.ts index 810bffaf78..1db12d5888 100644 --- a/src/app/child-dev-project/children/children.service.ts +++ b/src/app/child-dev-project/children/children.service.ts @@ -12,6 +12,7 @@ import { School } from '../schools/model/school'; import { HealthCheck } from '../health-checkup/model/health-check'; import { EntitySchemaService } from '../../core/entity/schema/entity-schema.service'; import { ChildPhotoService } from './child-photo-service/child-photo.service'; +import { LoadChildPhotoEntitySchemaDatatype } from './child-photo-service/datatype-load-child-photo'; @Injectable() export class ChildrenService { @@ -19,7 +20,9 @@ export class ChildrenService { constructor(private entityMapper: EntityMapperService, private entitySchemaService: EntitySchemaService, private db: Database, - private childPhotoService: ChildPhotoService) { + childPhotoService: ChildPhotoService, + ) { + this.entitySchemaService.registerSchemaDatatype(new LoadChildPhotoEntitySchemaDatatype(childPhotoService)); this.createAttendanceAnalysisIndex(); this.createNotesIndex(); this.createAttendancesIndex(); @@ -30,21 +33,7 @@ export class ChildrenService { * returns an observable which retrieves children from the database and loads their pictures */ getChildren(): Observable { - return new Observable((observer) => { - this.entityMapper.loadType(Child).then( - children => { - observer.next(children); - children.forEach(async (child) => { - if (!child.photo) { - child.photo = await this.childPhotoService.getImage(child); - observer.next(children); - } - }); - observer.complete(); - }).catch((error) => { - observer.error(error); - }); - }); + return from(this.entityMapper.loadType(Child)); } /** @@ -52,19 +41,7 @@ export class ChildrenService { * @param id id of child */ getChild(id: string): Observable { - return new Observable((observer) => { - this.entityMapper.load(Child, id).then( - async (child) => { - observer.next(child); - if (!child.photo) { - child.photo = await this.childPhotoService.getImage(child); - observer.next(child); - } - observer.complete(); - }).catch((error) => { - observer.error(error); - }); - }); + return from(this.entityMapper.load(Child, id)); } getAttendances(): Observable { diff --git a/src/app/child-dev-project/children/model/child.ts b/src/app/child-dev-project/children/model/child.ts index 85658b9db3..76a9ca5bea 100644 --- a/src/app/child-dev-project/children/model/child.ts +++ b/src/app/child-dev-project/children/model/child.ts @@ -20,6 +20,7 @@ import { Gender } from './Gender'; import { DatabaseEntity } from '../../../core/entity/database-entity.decorator'; import { DatabaseField } from '../../../core/entity/database-field.decorator'; import { SafeUrl } from '@angular/platform-browser'; +import { BehaviorSubject } from 'rxjs'; @DatabaseEntity('Child') export class Child extends Entity { @@ -76,7 +77,8 @@ export class Child extends Entity { */ @DatabaseField() photoFile: string; - photo: SafeUrl; + @DatabaseField({ dataType: 'load-child-photo' }) + photo: BehaviorSubject; get age(): number { let age = -1; diff --git a/src/app/core/entity/schema-datatypes/datatype-array.ts b/src/app/core/entity/schema-datatypes/datatype-array.ts index 89700f52ac..670398a06e 100644 --- a/src/app/core/entity/schema-datatypes/datatype-array.ts +++ b/src/app/core/entity/schema-datatypes/datatype-array.ts @@ -23,7 +23,7 @@ import { EntitySchemaService } from '../schema/entity-schema.service'; export const arrayEntitySchemaDatatype: EntitySchemaDatatype = { name: 'array', - transformToDatabaseFormat: (value: any[], schemaField: EntitySchemaField, schemaService: EntitySchemaService) => { + transformToDatabaseFormat: (value: any[], schemaField: EntitySchemaField, schemaService: EntitySchemaService, parent) => { if (!Array.isArray(value)) { console.warn('property to be transformed with "array" EntitySchema is not an array', value); return value; @@ -31,11 +31,11 @@ export const arrayEntitySchemaDatatype: EntitySchemaDatatype = { const arrayElementDatatype: EntitySchemaDatatype = schemaService.getDatatypeOrDefault(schemaField.arrayDataType); return value.map((el) => arrayElementDatatype - .transformToDatabaseFormat(el, generateSubSchemaField(schemaField), schemaService)); + .transformToDatabaseFormat(el, generateSubSchemaField(schemaField), schemaService, parent)); }, - transformToObjectFormat: (value: any[], schemaField: EntitySchemaField, schemaService: EntitySchemaService) => { + transformToObjectFormat: (value: any[], schemaField: EntitySchemaField, schemaService: EntitySchemaService, parent) => { if (!Array.isArray(value)) { console.warn('property to be transformed with "array" EntitySchema is not an array', value); return value; @@ -44,7 +44,7 @@ export const arrayEntitySchemaDatatype: EntitySchemaDatatype = { const arrayElementDatatype: EntitySchemaDatatype = schemaService.getDatatypeOrDefault(schemaField.arrayDataType); return value.map((el) => arrayElementDatatype - .transformToObjectFormat(el, generateSubSchemaField(schemaField), schemaService)); + .transformToObjectFormat(el, generateSubSchemaField(schemaField), schemaService, parent)); }, }; diff --git a/src/app/core/entity/schema-datatypes/datatype-date-only.ts b/src/app/core/entity/schema-datatypes/datatype-date-only.ts index 3a0d39a052..3f5fa1ef63 100644 --- a/src/app/core/entity/schema-datatypes/datatype-date-only.ts +++ b/src/app/core/entity/schema-datatypes/datatype-date-only.ts @@ -25,18 +25,21 @@ export const dateOnlyEntitySchemaDatatype: EntitySchemaDatatype = { name: 'date-only', transformToDatabaseFormat: (value: Date) => { + if (!value) { + return undefined; + } + return dateObjectToSimpleDateString(value); }, transformToObjectFormat: (value) => { - let date; - if (!value || value === '') { - date = null; - } else { - date = new Date(value); - if (isNaN(date.getTime())) { - throw new Error('failed to convert data to Date object: ' + value); - } + if (!value) { + return undefined; + } + + const date = new Date(value); + if (isNaN(date.getTime())) { + throw new Error('failed to convert data to Date object: ' + value); } return date; }, diff --git a/src/app/core/entity/schema-datatypes/datatype-date.ts b/src/app/core/entity/schema-datatypes/datatype-date.ts index b3b70e3969..47af55274c 100644 --- a/src/app/core/entity/schema-datatypes/datatype-date.ts +++ b/src/app/core/entity/schema-datatypes/datatype-date.ts @@ -28,14 +28,13 @@ export const dateEntitySchemaDatatype: EntitySchemaDatatype = { }, transformToObjectFormat: (value) => { - let date; - if (!value || value === '') { - date = null; - } else { - date = new Date(value); - if (isNaN(date.getTime())) { - throw new Error('failed to convert data to Date object: ' + value); - } + if (!value) { + return undefined; + } + + const date = new Date(value); + if (isNaN(date.getTime())) { + throw new Error('failed to convert data to Date object: ' + value); } return date; }, diff --git a/src/app/core/entity/schema-datatypes/datatype-month.ts b/src/app/core/entity/schema-datatypes/datatype-month.ts index 66e19c281e..b710ed00ea 100644 --- a/src/app/core/entity/schema-datatypes/datatype-month.ts +++ b/src/app/core/entity/schema-datatypes/datatype-month.ts @@ -21,6 +21,10 @@ export const monthEntitySchemaDatatype: EntitySchemaDatatype = { name: 'month', transformToDatabaseFormat: (value) => { + if (!value) { + return undefined; + } + if (!(value instanceof Date)) { value = new Date(value); } @@ -28,15 +32,14 @@ export const monthEntitySchemaDatatype: EntitySchemaDatatype = { }, transformToObjectFormat: (value) => { - let date; if (!value || value === '') { - date = null; - } else { - date = new Date(value); - if (isNaN(date.getTime())) { - console.log('value aus datatype-month.ts: ' + value); - throw new Error('failed to convert data to Date object: ' + value); - } + return undefined; + } + + const date = new Date(value); + if (isNaN(date.getTime())) { + console.log('value aus datatype-month.ts: ' + value); + throw new Error('failed to convert data to Date object: ' + value); } return date; }, diff --git a/src/app/core/entity/schema-datatypes/datatype-number.ts b/src/app/core/entity/schema-datatypes/datatype-number.ts index 302cc64165..3a30a0a8a4 100644 --- a/src/app/core/entity/schema-datatypes/datatype-number.ts +++ b/src/app/core/entity/schema-datatypes/datatype-number.ts @@ -21,10 +21,16 @@ export const numberEntitySchemaDatatype: EntitySchemaDatatype = { name: 'number', transformToDatabaseFormat: (value) => { + if (!value) { + return undefined; + } return Number(value); }, transformToObjectFormat: (value) => { + if (!value) { + return undefined; + } return Number(value); }, }; diff --git a/src/app/core/entity/schema-datatypes/datatype-string.ts b/src/app/core/entity/schema-datatypes/datatype-string.ts index aaf0ebe9db..f5b10b4b24 100644 --- a/src/app/core/entity/schema-datatypes/datatype-string.ts +++ b/src/app/core/entity/schema-datatypes/datatype-string.ts @@ -21,10 +21,16 @@ export const stringEntitySchemaDatatype: EntitySchemaDatatype = { name: 'string', transformToDatabaseFormat: (value) => { + if (!value) { + return undefined; + } return String(value); }, transformToObjectFormat: (value) => { + if (!value) { + return undefined; + } return String(value); }, }; diff --git a/src/app/core/entity/schema/entity-schema-datatype.ts b/src/app/core/entity/schema/entity-schema-datatype.ts index 570094bf22..0bb83f4940 100644 --- a/src/app/core/entity/schema/entity-schema-datatype.ts +++ b/src/app/core/entity/schema/entity-schema-datatype.ts @@ -18,12 +18,13 @@ import { EntitySchemaField } from './entity-schema-field'; import { EntitySchemaService } from './entity-schema.service'; +import { Entity } from '../entity'; /** * Interface to be implemented by any Datatype transformer of the Schema system. */ export interface EntitySchemaDatatype { name: string; - transformToDatabaseFormat(value: any, schemaField: EntitySchemaField, schemaService: EntitySchemaService): any; - transformToObjectFormat(value: any, schemaField: EntitySchemaField, schemaService: EntitySchemaService): any; + transformToDatabaseFormat(value: any, schemaField: EntitySchemaField, schemaService: EntitySchemaService, parent: Entity): any; + transformToObjectFormat(value: any, schemaField: EntitySchemaField, schemaService: EntitySchemaService, parent: any): any; } diff --git a/src/app/core/entity/schema/entity-schema.service.ts b/src/app/core/entity/schema/entity-schema.service.ts index e230a3beac..4adaaa90fd 100644 --- a/src/app/core/entity/schema/entity-schema.service.ts +++ b/src/app/core/entity/schema/entity-schema.service.ts @@ -81,9 +81,10 @@ export class EntitySchemaService { public transformDatabaseToEntityFormat(data: any, schema: EntitySchema) { for (const key of schema.keys()) { const schemaField: EntitySchemaField = schema.get(key); - if (data[key] !== undefined) { - data[key] = this.getDatatypeOrDefault(schemaField.dataType) - .transformToObjectFormat(data[key], schemaField, this); + const newValue = this.getDatatypeOrDefault(schemaField.dataType) + .transformToObjectFormat(data[key], schemaField, this, data); + if (newValue !== undefined) { + data[key] = newValue; } if (schemaField.generateIndex) { @@ -112,7 +113,7 @@ export class EntitySchemaService { if (entity[key] !== undefined) { data[key] = this.getDatatypeOrDefault(schemaField.dataType) - .transformToDatabaseFormat(entity[key], schemaField, this); + .transformToDatabaseFormat(entity[key], schemaField, this, entity); } } diff --git a/src/app/core/ui/search/search.component.spec.ts b/src/app/core/ui/search/search.component.spec.ts index 2164aaf5ec..68205ea266 100644 --- a/src/app/core/ui/search/search.component.spec.ts +++ b/src/app/core/ui/search/search.component.spec.ts @@ -12,14 +12,19 @@ import { CommonModule } from '@angular/common'; import { NoopAnimationsModule } from '@angular/platform-browser/animations'; import { ChildrenModule } from '../../../child-dev-project/children/children.module'; import { SchoolsModule } from '../../../child-dev-project/schools/schools.module'; -import { MockDatabase } from '../../database/mock-database'; import { EntitySchemaService } from '../../entity/schema/entity-schema.service'; +import { Child } from '../../../child-dev-project/children/model/child'; +import { School } from '../../../child-dev-project/schools/model/school'; describe('SearchComponent', () => { let component: SearchComponent; let fixture: ComponentFixture; + let mockDatabase: jasmine.SpyObj; + beforeEach(async(() => { + mockDatabase = jasmine.createSpyObj('mockDatabase', ['query', 'saveDatabaseIndex']); + TestBed.configureTestingModule({ imports: [ MatIconModule, @@ -35,7 +40,7 @@ describe('SearchComponent', () => { ], providers: [ EntitySchemaService, - { provide: Database, useClass: MockDatabase }, + { provide: Database, useValue: mockDatabase }, ], declarations: [ SearchComponent ], }) @@ -50,5 +55,72 @@ describe('SearchComponent', () => { it('should create', () => { expect(component).toBeTruthy(); + expect(mockDatabase.saveDatabaseIndex).toHaveBeenCalled(); + }); + + it('should not search for less than one real character of input', async () => { + component.searchText = ' '; + await component.search(); + expect(mockDatabase.query).not.toHaveBeenCalled(); + expect(component.results).toEqual([]); + }); + + it('should set results correctly for search input', async () => { + const child1 = new Child('1'); + child1.name = 'Adam X'; + const school1 = new School('s1'); + school1.name = 'Anglo Primary'; + + const mockQueryResults = { + rows: [ + { id: child1.getId(), doc: child1 }, + { id: school1.getId(), doc: school1 }, + ], + }; + mockDatabase.query.and.returnValue(Promise.resolve(mockQueryResults)); + + component.searchText = 'A'; + await component.search(); + expect(mockDatabase.query).toHaveBeenCalled(); + expect(component.results).toEqual([child1, school1]); + }); + + it('should not include duplicates in results', async () => { + const child1 = new Child('1'); + child1.name = 'Adam Ant'; + + const mockQueryResults = { + rows: [ + { id: child1.getId(), doc: child1 }, + { id: child1.getId(), doc: child1 }, // may be returned twice from db if several indexed values match the search + ], + }; + mockDatabase.query.and.returnValue(Promise.resolve(mockQueryResults)); + + component.searchText = 'A'; + await component.search(); + expect(mockDatabase.query).toHaveBeenCalled(); + expect(component.results.length).toBe(1); + expect(component.results).toEqual([child1]); + }); + + it('should only include results matching all search terms (words)', async () => { + const child1 = new Child('1'); + child1.name = 'Adam X'; + const school1 = new School('s1'); + school1.name = 'Anglo Primary'; + + const mockQueryResults = { + rows: [ + { id: child1.getId(), doc: child1 }, + { id: school1.getId(), doc: school1 }, + ], + }; + mockDatabase.query.and.returnValue(Promise.resolve(mockQueryResults)); + + component.searchText = 'A X'; + await component.search(); + expect(mockDatabase.query).toHaveBeenCalled(); + expect(component.results).toEqual([child1]); }); }); diff --git a/src/app/core/ui/search/search.component.ts b/src/app/core/ui/search/search.component.ts index dee84df330..5e28ea24a1 100644 --- a/src/app/core/ui/search/search.component.ts +++ b/src/app/core/ui/search/search.component.ts @@ -11,7 +11,7 @@ import { EntitySchemaService } from '../../entity/schema/entity-schema.service'; styleUrls: ['./search.component.scss'], }) export class SearchComponent implements OnInit { - results; + results = []; searchText = ''; showSearchToolbar = false; @@ -45,6 +45,10 @@ export class SearchComponent implements OnInit { async search() { this.searchText = this.searchText.toLowerCase(); + if (!this.isRelevantSearchInput(this.searchText)) { + this.results = []; + return; + } const searchHash = JSON.stringify(this.searchText); const searchTerms = this.searchText.split(' '); @@ -54,35 +58,52 @@ export class SearchComponent implements OnInit { ); if (JSON.stringify(this.searchText) === searchHash) { - // only set result if the user hasn't continued typing and change the search term already + // only set result if the user hasn't continued typing and changed the search term already this.results = this.prepareResults(queryResults.rows, searchTerms); } } + + /** + * Check if the input should start an actual search. + * Only search for words starting with a char or number -> no searching for space or no input + * @param searchText + */ + private isRelevantSearchInput(searchText: string) { + const regexp = new RegExp('[a-z]+|[0-9]+'); + return this.searchText.match(regexp); + } + private prepareResults(rows, searchTerms: string[]) { - let results = this.parseRows(rows); - results = results.filter(r => r !== undefined); - results = results.filter(r => this.containsSecondarySearchTerms(r, searchTerms)); - results = results.sort((a, b) => this.sortResults(a, b)); - return results; + return this.getResultsWithoutDuplicates(rows) + .map(doc => this.transformDocToEntity(doc)) + .filter(r => r !== null) + .filter(r => this.containsSecondarySearchTerms(r, searchTerms)) + .sort((a, b) => this.sortResults(a, b)); } - private parseRows(rows): Entity[] { - const resultEntities = []; - for (const r of rows) { - let resultEntity: Entity; - if (r.doc._id.startsWith(Child.ENTITY_TYPE + ':')) { - resultEntity = new Child(r.doc.entityId); - } else if (r.doc._id.startsWith(School.ENTITY_TYPE + ':')) { - resultEntity = new School(r.doc.entityId); - } else { - return; - } + private getResultsWithoutDuplicates(rows): any[] { + const filteredResults = new Map(); + for (const row of rows) { + filteredResults.set(row.id, row.doc); + } + return Array.from(filteredResults.values()); + } - this.entitySchemaService.loadDataIntoEntity(resultEntity, r.doc); - resultEntities.push(resultEntity); + private transformDocToEntity(doc: any): Entity { + let resultEntity; + if (doc._id.startsWith(Child.ENTITY_TYPE + ':')) { + resultEntity = new Child(doc.entityId); + } else if (doc._id.startsWith(School.ENTITY_TYPE + ':')) { + resultEntity = new School(doc.entityId); + } + + if (resultEntity) { + this.entitySchemaService.loadDataIntoEntity(resultEntity, doc); + return resultEntity; + } else { + return null; } - return resultEntities; } private containsSecondarySearchTerms(item, searchTerms: string[]) {