Commit 8e1d8d70 authored by Exbrayat Cédric's avatar Exbrayat Cédric
Browse files

feat: aggregation ui

parent af4db7e9
<div class="card mb-1" *ngIf="aggregation.buckets.length">
<div class="card-body">
<!-- title -->
<h3 class="card-title">{{ aggregation.name }}</h3>
<!-- values -->
<form [formGroup]="aggregationForm">
<div class="card-text" *ngFor="let bucket of aggregation.buckets">
<div class="form-check">
<input class="form-check-input" type="checkbox"
[id]="aggregation.name + bucket.key"
[formControlName]="bucket.key">
<label class="form-check-label" [for]="aggregation.name + bucket.key">{{ bucket.key }} [{{ bucket.documentCount | number }}]</label>
</div>
</div>
</form>
</div>
</div>
import { fakeAsync, TestBed, tick } from '@angular/core/testing';
import { ReactiveFormsModule } from '@angular/forms';
import { ComponentTester, speculoosMatchers } from 'ngx-speculoos';
import { AggregationComponent } from './aggregation.component';
import { toAggregation } from '../models/test-model-generators';
import { AggregationCriterion } from '../models/aggregation-criterion';
describe('AggregationsComponent', () => {
const aggregation = toAggregation('coo', ['France', 'Italy', 'New Zealand']);
class AggregationComponentTester extends ComponentTester<AggregationComponent> {
constructor() {
super(AggregationComponent);
}
get title() {
return this.element('.card-title');
}
get labels() {
return this.elements('label');
}
get firstCheckbox() {
return this.input('input');
}
}
beforeEach(() => TestBed.configureTestingModule({
imports: [ReactiveFormsModule],
declarations: [AggregationComponent]
}));
beforeEach(() => jasmine.addMatchers(speculoosMatchers));
it('should display an aggregation with buckets', () => {
const tester = new AggregationComponentTester();
// given an aggregation
tester.componentInstance.aggregation = aggregation;
tester.detectChanges();
// then it should display a title
expect(tester.title).toHaveText('coo');
// and the buckets with their name and count
expect(tester.labels.length).toBe(3);
expect(tester.labels[0]).toHaveText('France [10]');
expect(tester.labels[1]).toHaveText('Italy [20]');
expect(tester.labels[2]).toHaveText('New Zealand [30]');
});
it('should not display an aggregation with empty buckets', () => {
const tester = new AggregationComponentTester();
// given an aggregation
tester.componentInstance.aggregation = toAggregation('coo', []);
tester.detectChanges();
// then it should not display a title
expect(tester.title).toBeNull();
// and the buckets should not be displayed either
expect(tester.labels.length).toBe(0);
});
it('should extract keys from selected values', () => {
// given a few selected values among a bucket
const values: { [key: string]: boolean | null } = { 'France': true, 'England': false, 'Italy': true, 'New Zealand': null };
// when extracting keys
const keys = AggregationComponent.extractKeys(values);
// then it should return only the truthy ones
expect(keys).toEqual(['France', 'Italy']);
});
it('should build a form based on the bucket', () => {
// given an aggregation with a bucket
const component = new AggregationComponent();
component.aggregation = aggregation;
// when initializing the component
component.ngOnInit();
// then it should have a form with several fields
const controls = component.aggregationForm.controls;
expect(Object.keys(controls)).toEqual(['France', 'Italy', 'New Zealand']);
});
it('should build a form and check selected criteria', () => {
// given an aggregation with a bucket and a selected value
const selectedKeys = ['France'];
const component = new AggregationComponent();
component.aggregation = aggregation;
component.selectedKeys = selectedKeys;
// when initializing the component
component.ngOnInit();
// then it should have a form with several fields
const controls = component.aggregationForm.controls;
expect(Object.keys(controls)).toEqual(['France', 'Italy', 'New Zealand']);
// and France should be checked
expect(component.aggregationForm.get('France').value).toBeTruthy();
});
it('should build a form and disable the unique criteria', () => {
// given an aggregation with a bucket and a unique value
const component = new AggregationComponent();
component.aggregation = toAggregation('coo', ['France']);
// when initializing the component
component.ngOnInit();
// then it should have a form with one disabled field
const controls = component.aggregationForm.controls;
expect(Object.keys(controls)).toEqual(['France']);
// and France should be disabled
expect(component.aggregationForm.get('France').disable).toBeTruthy();
});
it('should emit an event when a checkbox is toggled', fakeAsync(() => {
const tester = new AggregationComponentTester();
// given an aggregation
const component = tester.componentInstance;
component.aggregation = aggregation;
tester.detectChanges();
expect(tester.firstCheckbox).not.toBeChecked();
// then it should emit an event
let emittedEvent: AggregationCriterion;
component.aggregationChange.subscribe((event: AggregationCriterion) => emittedEvent = event);
// when a value is checked
tester.firstCheckbox.check();
tester.detectChanges();
tick();
expect(tester.firstCheckbox).toBeChecked();
expect(emittedEvent.name).toBe('coo');
expect(emittedEvent.values).toEqual(['France']);
}));
});
import { ChangeDetectionStrategy, Component, EventEmitter, Input, OnInit, Output } from '@angular/core';
import { FormControl, FormGroup } from '@angular/forms';
import { Aggregation } from '../models/page';
import { AggregationCriterion } from '../models/aggregation-criterion';
@Component({
selector: 'rare-aggregation',
templateUrl: './aggregation.component.html',
styleUrls: ['./aggregation.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush
})
export class AggregationComponent implements OnInit {
@Input() aggregation: Aggregation;
@Input() selectedKeys: Array<string> = [];
// the component emits an event if the user adds or remove a criterion
@Output() aggregationChange = new EventEmitter<AggregationCriterion>();
aggregationForm: FormGroup = new FormGroup({});
/**
* This extracts the keys with a truthy value from an object.
* For example,
* { France: true, England: false, 'New Zealand': null }
* returns
* [ 'France' ]
*/
static extractKeys(formValues: { [key: string]: boolean | null }) {
return Object.entries<boolean>(formValues)
.filter(([key, value]) => value)
.map(([key]) => key);
}
ngOnInit(): void {
// create as many form control as there are buckets
const buckets = this.aggregation.buckets;
buckets.map(bucket => {
const control = new FormControl(false);
// if the criteria is selected, set the field to true
if (this.selectedKeys.includes(bucket.key)) {
control.setValue(true);
}
this.aggregationForm.addControl(bucket.key, control);
});
// disable if only one bucket
if (buckets.length === 1) {
this.aggregationForm.disable();
}
// subscribe to form changes
// to emit a new event every time a value changes
this.aggregationForm.valueChanges.subscribe(formValues => {
const values = AggregationComponent.extractKeys(formValues);
const event: AggregationCriterion = {
name: this.aggregation.name,
values
};
this.aggregationChange.emit(event);
});
}
}
<div *ngIf="aggregations?.length">
<div *ngFor="let aggregation of aggregations">
<rare-aggregation [aggregation]="aggregation"
[selectedKeys]="selectedKeysForAggregation(aggregation.name)"
(aggregationChange)="onAggregationChange($event)"></rare-aggregation>
</div>
</div>
import { fakeAsync, TestBed, tick } from '@angular/core/testing';
import { By } from '@angular/platform-browser';
import { ReactiveFormsModule } from '@angular/forms';
import { ComponentTester } from 'ngx-speculoos';
import { AggregationsComponent } from './aggregations.component';
import { AggregationComponent } from '../aggregation/aggregation.component';
import { toAggregation } from '../models/test-model-generators';
import { AggregationCriterion } from '../models/aggregation-criterion';
describe('AggregationsComponent', () => {
class AggregationsComponentTester extends ComponentTester<AggregationsComponent> {
constructor() {
super(AggregationsComponent);
}
get aggregations() {
return this.debugElement.queryAll(By.directive(AggregationComponent));
}
}
beforeEach(() => TestBed.configureTestingModule({
imports: [ReactiveFormsModule],
declarations: [AggregationsComponent, AggregationComponent]
}));
it('should display no aggregations if null', () => {
const tester = new AggregationsComponentTester();
// given no aggregations
tester.detectChanges();
// then it should display no aggregations
expect(tester.aggregations.length).toBe(0);
});
it('should display no aggregations if empty', () => {
const tester = new AggregationsComponentTester();
const component = tester.componentInstance;
// given no aggregations
component.aggregations = [];
tester.detectChanges();
// then it should display no aggregations
expect(tester.aggregations.length).toBe(0);
});
it('should extract the selected criteria for the aggregation', () => {
const component = new AggregationsComponent();
component.selectedCriteria = [{ name: 'coo', values: ['France', 'Italy'] }, { name: 'domain', values: ['Plant'] }];
const cooCriteria = component.selectedKeysForAggregation('coo');
expect(cooCriteria).toEqual(['France', 'Italy']);
const domainCriteria = component.selectedKeysForAggregation('domain');
expect(domainCriteria).toEqual(['Plant']);
const unknownCriteria = component.selectedKeysForAggregation('unknown');
expect(unknownCriteria).toEqual([]);
});
it('should display aggregations if there are some', () => {
const tester = new AggregationsComponentTester();
const component = tester.componentInstance;
// given a few aggregations
const domain = toAggregation('domain', ['Plant']);
const coo = toAggregation('coo', ['France', 'Italy']);
component.aggregations = [domain, coo];
component.selectedCriteria = [{ name: 'coo', values: ['France'] }];
tester.detectChanges();
// then it should display each aggregation
expect(tester.aggregations.length).toBe(2);
const aggregation1 = tester.aggregations[0].componentInstance as AggregationComponent;
expect(aggregation1.aggregation).toBe(domain);
expect(aggregation1.selectedKeys).toEqual([]);
const aggregation2 = tester.aggregations[1].componentInstance as AggregationComponent;
expect(aggregation2.aggregation).toBe(coo);
expect(aggregation2.selectedKeys).toEqual(['France']);
});
it('should update criteria when a criterion changes', () => {
const tester = new AggregationsComponentTester();
const component = tester.componentInstance;
// given a few aggregations
const domain = toAggregation('domain', ['Plant']);
const coo = toAggregation('coo', ['France', 'Italy']);
component.aggregations = [domain, coo];
component.selectedCriteria = [{ name: 'coo', values: ['France'] }];
tester.detectChanges();
// when the aggregation emits an event
const aggregationComponent = tester.aggregations[0].componentInstance as AggregationComponent;
const criteria = { name: 'coo', values: ['France'] };
aggregationComponent.aggregationChange.emit(criteria);
// then it should add a criteria
expect(component.selectedCriteria.length).toBe(1);
expect(component.selectedCriteria[0]).toBe(criteria);
// when the aggregation emits an event with another value
const updatedCriteria = { name: 'coo', values: ['France', 'Italy'] };
aggregationComponent.aggregationChange.emit(updatedCriteria);
// then it should update the existing criteria
expect(component.selectedCriteria.length).toBe(1);
expect(component.selectedCriteria[0]).toBe(updatedCriteria);
// when the aggregation emits an event with no values
const emptyCriteria = { name: 'coo', values: [] as Array<string> };
aggregationComponent.aggregationChange.emit(emptyCriteria);
// then it should delete the criteria
expect(component.selectedCriteria.length).toBe(0);
});
it('should emit all criteria when an aggregation emits a change', fakeAsync(() => {
const tester = new AggregationsComponentTester();
// given an aggregation
const component = tester.componentInstance;
const domain = toAggregation('domain', ['Plant']);
const coo = toAggregation('coo', ['France', 'Italy']);
component.aggregations = [domain, coo];
// and two selected criteria
component.selectedCriteria = [
{ name: 'domain', values: ['Plant'] },
{ name: 'coo', values: ['Italy'] }
];
tester.detectChanges();
// then it should emit an event
let emittedEvent: Array<AggregationCriterion> = [];
component.aggregationsChange.subscribe((event: Array<AggregationCriterion>) => emittedEvent = event);
// when an event is emitted by an aggregation
const aggregationComponent = tester.aggregations[0].componentInstance as AggregationComponent;
aggregationComponent.aggregationChange.emit({
name: 'coo',
values: ['France']
});
tester.detectChanges();
tick();
expect(emittedEvent.length).toBe(2);
expect(emittedEvent[0].name).toBe('domain');
expect(emittedEvent[0].values).toEqual(['Plant']);
expect(emittedEvent[1].name).toBe('coo');
expect(emittedEvent[1].values).toEqual(['France']);
}));
});
import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output } from '@angular/core';
import { Aggregation } from '../models/page';
import { AggregationCriterion } from '../models/aggregation-criterion';
@Component({
selector: 'rare-aggregations',
templateUrl: './aggregations.component.html',
styleUrls: ['./aggregations.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush
})
export class AggregationsComponent {
@Input() aggregations: Array<Aggregation> = [];
@Input() selectedCriteria: Array<AggregationCriterion> = [];
@Output() aggregationsChange = new EventEmitter<Array<AggregationCriterion>>();
/**
* Extracts the selected criteria for the aggregation.
* For example, returns [ 'France' ] for 'coo'
* if selectedCriteria is [{ name: 'coo', values: [ 'France' ] }]
* Returns an empty array if there are no values for this criteria
*/
selectedKeysForAggregation(name: string): Array<string> {
if (this.selectedCriteria.length) {
const matchingCriteria = this.selectedCriteria.find(criteria => criteria.name === name);
if (matchingCriteria) {
return matchingCriteria.values;
}
}
return [];
}
/**
* Finds the old criterion if it exists and replaces it with the new one received,
* removes it if the values are empty,
* or adds it to the criteria if it doesn't exist,
* then emits the updated criteria.
*/
onAggregationChange(criterionChanged: AggregationCriterion): void {
const index = this.selectedCriteria.findIndex(criterion => criterion.name === criterionChanged.name);
// if it already exists in the criteria
if (index !== -1) {
// and the new criterion has values
if (criterionChanged.values && criterionChanged.values.length) {
// replace the old one with the new one
this.selectedCriteria[index] = criterionChanged;
} else {
// else remove it
this.selectedCriteria.splice(index, 1);
}
} else {
// if it doesn't already exist, add it if necessary
if (criterionChanged.values && criterionChanged.values.length) {
this.selectedCriteria.push(criterionChanged);
}
}
this.aggregationsChange.emit(this.selectedCriteria);
}
}
......@@ -3,6 +3,9 @@ import { LOCALE_ID, NgModule } from '@angular/core';
import { RouterModule } from '@angular/router';
import { HttpClientModule } from '@angular/common/http';
import { ReactiveFormsModule } from '@angular/forms';
import { registerLocaleData } from '@angular/common';
import localeFr from '@angular/common/locales/fr';
import { NgbPaginationModule, NgbTypeaheadModule } from '@ng-bootstrap/ng-bootstrap';
import { routes } from './app.routes';
import { AppComponent } from './app.component';
......@@ -10,9 +13,8 @@ import { HomeComponent } from './home/home.component';
import { SearchComponent } from './search/search.component';
import { GeneticResourcesComponent } from './genetic-resources/genetic-resources.component';
import { GeneticResourceComponent } from './genetic-resource/genetic-resource.component';
import { NgbPaginationModule, NgbTypeaheadModule } from '@ng-bootstrap/ng-bootstrap';
import { registerLocaleData } from '@angular/common';
import localeFr from '@angular/common/locales/fr';
import { AggregationsComponent } from './aggregations/aggregations.component';
import { AggregationComponent } from './aggregation/aggregation.component';
registerLocaleData(localeFr);
......@@ -22,7 +24,9 @@ registerLocaleData(localeFr);
HomeComponent,
SearchComponent,
GeneticResourcesComponent,
GeneticResourceComponent
GeneticResourceComponent,
AggregationsComponent,
AggregationComponent
],
imports: [
BrowserModule,
......
......@@ -14,7 +14,7 @@ export class GeneticResourcesComponent {
@Input() geneticResources: Page<GeneticResourceModel>;
get firstResultIndex() {
return (this.geneticResources.number * this.geneticResources.size) + 1
return (this.geneticResources.number * this.geneticResources.size) + 1;
}
get lastResultIndex() {
......
export interface AggregationCriterion {
name: string;
values: Array<string>;
}
......@@ -6,3 +6,17 @@ export interface Page<T> {
totalPages: number;
maxResults: number;
}
export interface Bucket {
key: string;
documentCount: number;
}
export interface Aggregation {
name: string;
buckets: Array<Bucket>;
}
export interface AggregatedPage<T> extends Page<T> {
aggregations: Array<Aggregation>;
}
import { Page } from './page';
import { AggregatedPage, Aggregation, Bucket } from './page';
import { GeneticResourceModel } from './genetic-resource.model';
import { AggregationCriterion } from './aggregation-criterion';
export function toSinglePage<T>(content: Array<T>): Page<T> {
export function toSinglePage<T>(content: Array<T>, aggregations?: Array<Aggregation>): AggregatedPage<T> {
return {
content,
number: 0,
size: 20,
totalElements: content.length,
totalPages: 1,
maxResults: 10000
maxResults: 10000,
aggregations: aggregations || []
};
}
export function toSecondPage<T>(content: Array<T>): Page<T> {
export function toSecondPage<T>(content: Array<T>, aggregations?: Array<Aggregation>): AggregatedPage<T> {
return {
content,
number: 1,
size: 20,
totalElements: 20 + content.length,
totalPages: 2,
maxResults: 10000
maxResults: 10000,
aggregations: aggregations || []
};
}
export function toAggregation(name: string, values: Array<string>): Aggregation {
// creates a bucket for each value, with a document count of (index+1)*10
const buckets: Array<Bucket> = values.map((key, index) => ({ key, documentCount: (index + 1) * 10 }));
return {
name,
buckets
};
}
export function toAggregationCriterion(name: string, values: Array<string>): AggregationCriterion {
return {
name,
values