Skip to content

Commit

Permalink
refactor(*): move loading of child photo to a new EntitySchemaDatatype
Browse files Browse the repository at this point in the history
and thereby also ensure that search results' photo is loaded
sleidig committed Mar 26, 2020
1 parent d4eced3 commit f1b2072
Showing 14 changed files with 213 additions and 80 deletions.
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
<span *ngIf="entity" (mouseenter)="showTooltip()" (mouseleave)="hideTooltip()">
<img [src]="entity?.photo" class="child-pic">
<img [src]="entity?.photo?.value" class="child-pic">
{{entity?.name}} <span style="font-size: x-small">({{entity?.projectNumber}})</span>
</span>

<div style="position:absolute;" *ngIf="tooltip">
<div class="mat-elevation-z5 child-tooltip" (mouseenter)="showTooltip()" (mouseleave)="hideTooltip()" fxLayout='row'>
<div fxFlex='30'>
<img [src]="entity?.photo" class="child-pic-large">
<img [src]="entity?.photo?.value" class="child-pic-large">
</div>
<div fxFlex>
<h3>{{entity?.name}}</h3>
Original file line number Diff line number Diff line change
@@ -58,7 +58,7 @@ <h1 class="page-header section-child">
<div fxLayout='row' fxLayout.xs='column wrap' fxLayout.md='column wrap' fxLayout.sm='column wrap'>

<div fxFlex='160px' class='child-pic-container'>
<img [src]="child?.photo" class="child-pic" alt="child's photo">
<img [src]="child?.photo?.value" class="child-pic" alt="child's photo">

<input style="display: none" type="file" accept=".jpg, .jpeg, .png" (change)=uploadChildPhoto($event) #fileUpload>
<button *ngIf="enablePhotoUpload && (creatingNew || editing)"
Original file line number Diff line number Diff line change
@@ -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));
}
}
Original file line number Diff line number Diff line change
@@ -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);

Original file line number Diff line number Diff line change
@@ -2,6 +2,9 @@ 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';
import { EntitySchemaService } from '../../../core/entity/schema/entity-schema.service';
import { LoadChildPhotoEntitySchemaDatatype } from './datatype-load-child-photo';

@Injectable({
providedIn: 'root',
@@ -17,13 +20,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<SafeUrl> {
let image: SafeUrl;
public async getImage(child: { entityId: string, photoFile?: string }): Promise<SafeUrl> {
let image = await this.getImageFromCloudService(child);
if (!image) {
image = this.getImageFromAssets(child);
}
return image;
}

private async getImageFromCloudService(child: { entityId: string }): Promise<SafeUrl> {
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,15 +46,10 @@ export class ChildPhotoService {
}
}
}

if (!image) {
image = this.getImageFromAssets(child);
}

return image;
}

private getImageFromAssets(child: Child): SafeUrl {
private getImageFromAssets(child: { photoFile?: string }): SafeUrl {
if (!child.photoFile) {
return this.getDefaultImage();
}
@@ -55,6 +60,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<SafeUrl> {
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.
Original file line number Diff line number Diff line change
@@ -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 <http://www.gnu.org/licenses/>.
*/

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<ChildPhotoService>;

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<SafeUrl>;
}
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);
}));
});
Original file line number Diff line number Diff line change
@@ -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 <http://www.gnu.org/licenses/>.
*/


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);
}
}
30 changes: 6 additions & 24 deletions src/app/child-dev-project/children/children.service.spec.ts
Original file line number Diff line number Diff line change
@@ -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>(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>(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;
35 changes: 6 additions & 29 deletions src/app/child-dev-project/children/children.service.ts
Original file line number Diff line number Diff line change
@@ -12,14 +12,17 @@ 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 {

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,41 +33,15 @@ export class ChildrenService {
* returns an observable which retrieves children from the database and loads their pictures
*/
getChildren(): Observable<Child[]> {
return new Observable<Child[]>((observer) => {
this.entityMapper.loadType<Child>(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>(Child));
}

/**
* returns an observable which retrieves a single child and loads its photo
* @param id id of child
*/
getChild(id: string): Observable<Child> {
return new Observable<Child>((observer) => {
this.entityMapper.load<Child>(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>(Child, id));
}

getAttendances(): Observable<AttendanceMonth[]> {
4 changes: 3 additions & 1 deletion src/app/child-dev-project/children/model/child.ts
Original file line number Diff line number Diff line change
@@ -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<SafeUrl>;

get age(): number {
let age = -1;
8 changes: 4 additions & 4 deletions src/app/core/entity/schema-datatypes/datatype-array.ts
Original file line number Diff line number Diff line change
@@ -23,19 +23,19 @@ 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;
}

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));
},
};

2 changes: 1 addition & 1 deletion src/app/core/entity/schema-datatypes/datatype-date.ts
Original file line number Diff line number Diff line change
@@ -30,7 +30,7 @@ export const dateEntitySchemaDatatype: EntitySchemaDatatype = {
transformToObjectFormat: (value) => {
let date;
if (!value || value === '') {
date = null;
date = undefined;
} else {
date = new Date(value);
if (isNaN(date.getTime())) {
5 changes: 3 additions & 2 deletions src/app/core/entity/schema/entity-schema-datatype.ts
Original file line number Diff line number Diff line change
@@ -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;
}
9 changes: 5 additions & 4 deletions src/app/core/entity/schema/entity-schema.service.ts
Original file line number Diff line number Diff line change
@@ -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);
}
}

0 comments on commit f1b2072

Please sign in to comment.