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

feat: large aggregations as typeahead fields

parent 69473dd6
......@@ -2,19 +2,19 @@ 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 { SmallAggregationComponent } from './small-aggregation.component';
import { toAggregation } from '../models/test-model-generators';
import { AggregationCriterion } from '../models/aggregation-criterion';
import { AggregationNamePipe } from '../aggregation-name.pipe';
import { DocumentCountComponent } from '../document-count/document-count.component';
describe('AggregationComponent', () => {
describe('SmallAggregationComponent', () => {
const aggregation = toAggregation('coo', ['France', 'Italy', 'New Zealand']);
class AggregationComponentTester extends ComponentTester<AggregationComponent> {
class SmallAggregationComponentTester extends ComponentTester<SmallAggregationComponent> {
constructor() {
super(AggregationComponent);
super(SmallAggregationComponent);
}
get title() {
......@@ -32,13 +32,13 @@ describe('AggregationComponent', () => {
beforeEach(() => TestBed.configureTestingModule({
imports: [ReactiveFormsModule],
declarations: [AggregationComponent, AggregationNamePipe, DocumentCountComponent]
declarations: [SmallAggregationComponent, AggregationNamePipe, DocumentCountComponent]
}));
beforeEach(() => jasmine.addMatchers(speculoosMatchers));
it('should display an aggregation with buckets', () => {
const tester = new AggregationComponentTester();
const tester = new SmallAggregationComponentTester();
// given an aggregation
tester.componentInstance.aggregation = aggregation;
......@@ -57,7 +57,7 @@ describe('AggregationComponent', () => {
});
it('should not display an aggregation with empty buckets', () => {
const tester = new AggregationComponentTester();
const tester = new SmallAggregationComponentTester();
// given an aggregation
tester.componentInstance.aggregation = toAggregation('coo', []);
......@@ -69,13 +69,12 @@ describe('AggregationComponent', () => {
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);
const keys = SmallAggregationComponent.extractKeys(values);
// then it should return only the truthy ones
expect(keys).toEqual(['France', 'Italy']);
......@@ -83,7 +82,7 @@ describe('AggregationComponent', () => {
it('should build a form based on the bucket', () => {
// given an aggregation with a bucket
const component = new AggregationComponent();
const component = new SmallAggregationComponent();
component.aggregation = aggregation;
// when initializing the component
......@@ -98,7 +97,7 @@ describe('AggregationComponent', () => {
// given an aggregation with a bucket and a selected value
const selectedKeys = ['France'];
const component = new AggregationComponent();
const component = new SmallAggregationComponent();
component.aggregation = aggregation;
component.selectedKeys = selectedKeys;
......@@ -114,7 +113,7 @@ describe('AggregationComponent', () => {
it('should build a form and disable the unique criteria', () => {
// given an aggregation with a bucket and a unique value
const component = new AggregationComponent();
const component = new SmallAggregationComponent();
component.aggregation = toAggregation('coo', ['France']);
// when initializing the component
......@@ -128,7 +127,7 @@ describe('AggregationComponent', () => {
});
it('should emit an event when a checkbox is toggled', fakeAsync(() => {
const tester = new AggregationComponentTester();
const tester = new SmallAggregationComponentTester();
// given an aggregation
const component = tester.componentInstance;
......
......@@ -5,12 +5,12 @@ import { Aggregation } from '../models/page';
import { AggregationCriterion } from '../models/aggregation-criterion';
@Component({
selector: 'rare-aggregation',
templateUrl: './aggregation.component.html',
styleUrls: ['./aggregation.component.scss'],
selector: 'rare-small-aggregation',
templateUrl: './small-aggregation.component.html',
styleUrls: ['./small-aggregation.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush
})
export class AggregationComponent implements OnInit {
export class SmallAggregationComponent implements OnInit {
@Input() aggregation: Aggregation;
@Input() selectedKeys: Array<string> = [];
......@@ -51,7 +51,7 @@ export class AggregationComponent implements OnInit {
// 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 values = SmallAggregationComponent.extractKeys(formValues);
const event: AggregationCriterion = {
name: this.aggregation.name,
values
......
<div *ngIf="aggregations?.length">
<div *ngFor="let aggregation of aggregations">
<rare-aggregation [aggregation]="aggregation"
<!-- we display checkboxes if there are only a few options -->
<rare-small-aggregation *ngIf="aggregation.type === 'SMALL'" [aggregation]="aggregation"
[selectedKeys]="selectedKeysForAggregation(aggregation.name)"
(aggregationChange)="onAggregationChange($event)"></rare-aggregation>
(aggregationChange)="onAggregationChange($event)"></rare-small-aggregation>
<!-- we display an input with autocomplete if there are many options, like for the taxon -->
<rare-large-aggregation *ngIf="aggregation.type === 'LARGE'" [aggregation]="aggregation"
[selectedKeys]="selectedKeysForAggregation(aggregation.name)"
(aggregationChange)="onAggregationChange($event)"></rare-large-aggregation>
</div>
</div>
import { fakeAsync, TestBed, tick } from '@angular/core/testing';
import { By } from '@angular/platform-browser';
import { ReactiveFormsModule } from '@angular/forms';
import { NgbTypeaheadModule } from '@ng-bootstrap/ng-bootstrap';
import { ComponentTester } from 'ngx-speculoos';
import { AggregationsComponent } from './aggregations.component';
import { AggregationComponent } from '../aggregation/aggregation.component';
import { SmallAggregationComponent } from '../aggregation/small-aggregation.component';
import { LargeAggregationComponent } from '../large-aggregation/large-aggregation.component';
import { toAggregation } from '../models/test-model-generators';
import { AggregationCriterion } from '../models/aggregation-criterion';
import { AggregationNamePipe } from '../aggregation-name.pipe';
......@@ -18,13 +20,26 @@ describe('AggregationsComponent', () => {
}
get aggregations() {
return this.debugElement.queryAll(By.directive(AggregationComponent));
return this.debugElement.queryAll(By.directive(SmallAggregationComponent));
}
get largeAggregations() {
return this.debugElement.queryAll(By.directive(LargeAggregationComponent));
}
}
beforeEach(() => TestBed.configureTestingModule({
imports: [ReactiveFormsModule],
declarations: [AggregationsComponent, AggregationComponent, AggregationNamePipe, DocumentCountComponent]
imports: [
ReactiveFormsModule,
NgbTypeaheadModule.forRoot()
],
declarations: [
AggregationsComponent,
SmallAggregationComponent,
LargeAggregationComponent,
AggregationNamePipe,
DocumentCountComponent
]
}));
it('should display no aggregations if null', () => {
......@@ -75,14 +90,38 @@ describe('AggregationsComponent', () => {
// then it should display each aggregation
expect(tester.aggregations.length).toBe(2);
const aggregation1 = tester.aggregations[0].componentInstance as AggregationComponent;
const aggregation1 = tester.aggregations[0].componentInstance as SmallAggregationComponent;
expect(aggregation1.aggregation).toBe(domain);
expect(aggregation1.selectedKeys).toEqual([]);
const aggregation2 = tester.aggregations[1].componentInstance as AggregationComponent;
const aggregation2 = tester.aggregations[1].componentInstance as SmallAggregationComponent;
expect(aggregation2.aggregation).toBe(coo);
expect(aggregation2.selectedKeys).toEqual(['France']);
});
it('should display aggregations of different types', () => {
const tester = new AggregationsComponentTester();
const component = tester.componentInstance;
// given a few aggregations
const domain = toAggregation('domain', ['Plant']);
const coo = toAggregation('coo', ['France', 'Italy']);
coo.type = 'LARGE';
component.aggregations = [domain, coo];
component.selectedCriteria = [{ name: 'coo', values: ['France'] }];
tester.detectChanges();
// then it should display an aggregation of each type
expect(tester.aggregations.length).toBe(1);
const small = tester.aggregations[0].componentInstance as SmallAggregationComponent;
expect(small.aggregation).toBe(domain);
expect(small.selectedKeys).toEqual([]);
expect(tester.largeAggregations.length).toBe(1);
const large = tester.largeAggregations[0].componentInstance as LargeAggregationComponent;
expect(large.aggregation).toBe(coo);
expect(large.selectedKeys).toEqual(['France']);
});
it('should update criteria when a criterion changes', () => {
const tester = new AggregationsComponentTester();
const component = tester.componentInstance;
......@@ -95,7 +134,7 @@ describe('AggregationsComponent', () => {
tester.detectChanges();
// when the aggregation emits an event
const aggregationComponent = tester.aggregations[0].componentInstance as AggregationComponent;
const aggregationComponent = tester.aggregations[0].componentInstance as SmallAggregationComponent;
const criteria = { name: 'coo', values: ['France'] };
aggregationComponent.aggregationChange.emit(criteria);
......@@ -139,7 +178,7 @@ describe('AggregationsComponent', () => {
component.aggregationsChange.subscribe((event: Array<AggregationCriterion>) => emittedEvent = event);
// when an event is emitted by an aggregation
const aggregationComponent = tester.aggregations[0].componentInstance as AggregationComponent;
const aggregationComponent = tester.aggregations[0].componentInstance as SmallAggregationComponent;
aggregationComponent.aggregationChange.emit({
name: 'coo',
values: ['France']
......
......@@ -15,7 +15,8 @@ import { SearchComponent } from './search/search.component';
import { GeneticResourcesComponent } from './genetic-resources/genetic-resources.component';
import { GeneticResourceComponent } from './genetic-resource/genetic-resource.component';
import { AggregationsComponent } from './aggregations/aggregations.component';
import { AggregationComponent } from './aggregation/aggregation.component';
import { SmallAggregationComponent } from './aggregation/small-aggregation.component';
import { LargeAggregationComponent } from './large-aggregation/large-aggregation.component';
import { PillarsComponent } from './pillars/pillars.component';
import { AggregationNamePipe } from './aggregation-name.pipe';
import { DocumentCountComponent } from './document-count/document-count.component';
......@@ -30,7 +31,8 @@ registerLocaleData(localeFr);
GeneticResourcesComponent,
GeneticResourceComponent,
AggregationsComponent,
AggregationComponent,
SmallAggregationComponent,
LargeAggregationComponent,
PillarsComponent,
AggregationNamePipe,
DocumentCountComponent
......
<a *ngIf="url" [href]="url">{{ name }}</a>
<span *ngIf="!url">{{ name }}</span>
<small class="text-muted ml-1">[{{ count | number }}]</small>
<small class="ml-1" [class.text-muted]="muted">[{{ count | number }}]</small>
......@@ -11,5 +11,6 @@ export class DocumentCountComponent {
@Input() name: string;
@Input() url: string;
@Input() count: number;
@Input() muted = true;
}
<ng-template #resultTemplate let-bucket="result" let-t="term">
<ngb-highlight [result]="bucket.key" [term]="t"></ngb-highlight>
<small class="ml-1">[{{ bucket.documentCount | number }}]</small>
</ng-template>
<div class="card mb-1" *ngIf="aggregation.buckets.length">
<div class="card-body">
<!-- title -->
<h3 class="card-title">{{ aggregation.name | aggregationName }}
<small class="text-muted ml-1">({{ aggregation.buckets.length | number }})</small>
</h3>
<!-- multiple values as pills and a typeahead to add them -->
<div *ngIf="aggregation.buckets.length > 1; else singleValue">
<div class="mb-2">
<span class="badge badge-pill badge-secondary mr-1" *ngFor="let key of selectedKeys" tabindex="0"
(keydown.backspace)="removeKey(key)">
<rare-document-count [name]="key" [count]="documentCountForKey(key)" [muted]="false"></rare-document-count>
<button tabindex="-1" type="button" class="btn btn-link" (click)="removeKey(key)">&times;</button>
</span>
</div>
<input id="typeahead-basic" type="text" class="form-control" [formControl]="criterion" [ngbTypeahead]="search"
(selectItem)="selectKey($event)" [resultTemplate]="resultTemplate"/>
</div>
<!-- single value as a pill not removable -->
<ng-template #singleValue>
<span class="badge badge-pill badge-light">
<rare-document-count [name]="aggregation.buckets[0].key" [count]="aggregation.buckets[0].documentCount"
[muted]="false"></rare-document-count>
</span>
</ng-template>
</div>
</div>
.card-title {
font-size: 1.25rem;
}
.btn-link {
border-top: 0;
border-bottom: 0;
margin-top: 0;
margin-bottom: 0;
padding: 0;
color: white;
vertical-align: baseline;
text-decoration: none;
font-size: inherit;
}
import { fakeAsync, TestBed, tick } from '@angular/core/testing';
import { ReactiveFormsModule } from '@angular/forms';
import { By } from '@angular/platform-browser';
import { NgbTypeahead, NgbTypeaheadModule } from '@ng-bootstrap/ng-bootstrap';
import { ComponentTester, speculoosMatchers } from 'ngx-speculoos';
import { LargeAggregationComponent } from './large-aggregation.component';
import { toAggregation } from '../models/test-model-generators';
import { AggregationCriterion } from '../models/aggregation-criterion';
import { AggregationNamePipe } from '../aggregation-name.pipe';
import { DocumentCountComponent } from '../document-count/document-count.component';
import { of } from 'rxjs/internal/observable/of';
import { Bucket } from '../models/page';
describe('LargeAggregationComponent', () => {
const aggregation = toAggregation('coo', ['France', 'Italy', 'New Zealand']);
class LargeAggregationComponentTester extends ComponentTester<LargeAggregationComponent> {
constructor() {
super(LargeAggregationComponent);
}
get title() {
return this.element('.card-title');
}
get inputField() {
return this.input('input');
}
get typeahead() {
return this.debugElement.query(By.directive(NgbTypeahead));
}
get results() {
// based on the typeahead test itself
// see https://github.com/ng-bootstrap/ng-bootstrap/blob/master/src/typeahead/typeahead.spec.ts
return this.element('ngb-typeahead-window.dropdown-menu')
.elements('button.dropdown-item');
}
get pills() {
return this.elements('.badge-pill');
}
}
beforeEach(() => TestBed.configureTestingModule({
imports: [ReactiveFormsModule, NgbTypeaheadModule.forRoot()],
declarations: [LargeAggregationComponent, AggregationNamePipe, DocumentCountComponent]
}));
beforeEach(() => jasmine.addMatchers(speculoosMatchers));
it('should display an aggregation with buckets as a typeahead', () => {
const tester = new LargeAggregationComponentTester();
// given an aggregation
tester.componentInstance.aggregation = aggregation;
tester.detectChanges();
// then it should display a title and the number of possible keys
expect(tester.title).toHaveText('Pays d\'origine (3)');
// and the buckets with their name and count in a typeahead
expect(tester.inputField).not.toBeNull();
expect(tester.typeahead).not.toBeNull();
});
it('should not display an aggregation with empty buckets', () => {
const tester = new LargeAggregationComponentTester();
// 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.inputField).toBeNull();
});
it('should display the 10 first matching results as options', () => {
// given an aggregation with a bucket and a selected value
const selectedKeys = [
'France',
'Italy'
];
const tester = new LargeAggregationComponentTester();
const component = tester.componentInstance;
component.aggregation = aggregation;
component.selectedKeys = selectedKeys;
// when displaying the component
tester.detectChanges();
// then it should have several removable pills
expect(tester.pills.length).toBe(2);
expect(tester.pills[0]).toContainText('France[10]');
expect(tester.pills[0].button('button')).not.toBeNull();
expect(tester.pills[1]).toContainText('Italy[20]');
expect(tester.pills[1].button('button')).not.toBeNull();
});
it('should display the selected criteria as pills', () => {
// given an aggregation with a bucket and a selected value
const selectedKeys = ['France', 'Italy'];
const tester = new LargeAggregationComponentTester();
const component = tester.componentInstance;
component.aggregation = aggregation;
component.selectedKeys = selectedKeys;
// when displaying the component
tester.detectChanges();
// then it should have several removable pills
expect(tester.pills.length).toBe(2);
expect(tester.pills[0]).toContainText('France[10]');
expect(tester.pills[0].button('button')).not.toBeNull();
expect(tester.pills[1]).toContainText('Italy[20]');
expect(tester.pills[1].button('button')).not.toBeNull();
});
it('should disable the unique criterion and display it as a pill', () => {
// given an aggregation with a bucket and a unique value
const tester = new LargeAggregationComponentTester();
const component = tester.componentInstance;
component.aggregation = toAggregation('coo', ['France']);
// when displaying the component
tester.detectChanges();
// then it should have no input
expect(tester.inputField).toBeNull();
expect(tester.typeahead).toBeNull();
// and France should be displayed a pill
expect(tester.pills.length).toBe(1);
expect(tester.pills[0]).toContainText('France[10]');
// but not a removable one
expect(tester.pills[0].button('button')).toBeNull();
});
it('should find one results containing the term entered', () => {
// given an aggregation with a bucket
const component = new LargeAggregationComponent();
component.aggregation = aggregation;
// when searching for a result
let actualResults: Array<Bucket> = [];
component.search(of('anc'))
.subscribe(results => actualResults = results);
// then it should have no match
expect(actualResults.length).toBe(1);
expect(actualResults[0].key).toBe('France');
});
it('should find the results containing the term entered and ignore the case', () => {
// given an aggregation with a bucket
const component = new LargeAggregationComponent();
component.aggregation = aggregation;
// when searching for a result
let actualResults: Array<Bucket> = [];
component.search(of('A'))
.subscribe(results => actualResults = results);
// then it should have one match
expect(actualResults.length).toBe(3);
expect(actualResults[0].key).toBe('France');
expect(actualResults[1].key).toBe('Italy');
expect(actualResults[2].key).toBe('New Zealand');
});
it('should not find the results containing the term entered if it is already selected', () => {
// given an aggregation with a bucket
const component = new LargeAggregationComponent();
component.aggregation = aggregation;
component.selectedKeys = ['France'];
// when searching for a result
let actualResults: Array<Bucket> = [];
component.search(of('anc'))
.subscribe(results => actualResults = results);
// then it should have no match
expect(actualResults.length).toBe(0);
});
it('should find 10 results max', () => {
// given an aggregation with a bucket
const component = new LargeAggregationComponent();
component.aggregation = toAggregation('coo', Array(30).fill('a'));
// when searching for a result
let actualResults: Array<Bucket> = [];
component.search(of('a'))
.subscribe(results => actualResults = results);
// then it should have no match
expect(actualResults.length).toBe(10);
});
it('should emit an event when a value is added or removed and update pills', fakeAsync(() => {
const tester = new LargeAggregationComponentTester();
// given an aggregation
const component = tester.componentInstance;
component.aggregation = aggregation;
tester.detectChanges();
expect(tester.inputField).toHaveValue('');
expect(tester.pills.length).toBe(0);
// then it should emit an event
let emittedEvent: AggregationCriterion;
component.aggregationChange.subscribe((event: AggregationCriterion) => emittedEvent = event);
// when a value is entered
tester.inputField.fillWith('fr');
tick(200);
// results should appear
expect(tester.results.length).toBe(1);
expect(tester.results[0]).toHaveText('France[10]');
// when the result is selected
tester.results[0].dispatchEventOfType('click');
expect(emittedEvent.name).toBe('coo');
expect(emittedEvent.values).toEqual(['France']);
expect(tester.inputField).toHaveValue('');
// and a pill should appear
expect(tester.pills.length).toBe(1);
expect(tester.pills[0]).toContainText('France[10]');
// when another value is entered
tester.inputField.fillWith('ly');
tick(200);