Commit 208e9709 authored by Jean-Baptiste Nizet's avatar Jean-Baptiste Nizet
Browse files

feat: implement highlighting of description on frontend

parent 01578e24
<div>
<h2>
<a class="main-link name"
[href]="geneticResource.dataURL ? geneticResource.dataURL : geneticResource.portalURL">
<a class="main-link name" [href]="geneticResource.dataURL ? geneticResource.dataURL : geneticResource.portalURL">
{{ geneticResource.name }}
</a>
<small> - {{ geneticResource.pillarName }}</small>
......@@ -19,11 +18,13 @@
</div>
<div>
<div class="description" *ngIf="descriptionCollapsed; else expandedDescription">
{{ geneticResource.description | slice:0:255 }}<button class="btn btn-link btn-inline" (click)="toggleDescription()" *ngIf="geneticResource.description?.length > 255">... (Voir tout)</button>
<span class="highlight" [innerHTML]="truncatedDescription"></span>
<button class="btn btn-link btn-inline" (click)="toggleDescription()" *ngIf="truncatedDescription !== geneticResource.description">... (Voir tout)</button>
</div>
<ng-template #expandedDescription>
<div class="full-description">
{{ geneticResource.description }}<button class="btn btn-link btn-inline" (click)="toggleDescription()">Réduire</button>
<span class="highlight" [innerHTML]="geneticResource.description"></span>
<button class="btn btn-link btn-inline" (click)="toggleDescription()">Réduire</button>
</div>
</ng-template>
</div>
......
......@@ -35,16 +35,16 @@ describe('GeneticResourceComponent', () => {
return this.element('.description');
}
get fullDescriptionLink() {
return this.element('.description button');
get fullDescriptionButton() {
return this.button('.description button');
}
get fullDescription() {
return this.element('.full-description');
}
get shortDescriptionLink() {
return this.element('.full-description button');
get shortDescriptionButton() {
return this.button('.full-description button');
}
}
......@@ -73,9 +73,9 @@ describe('GeneticResourceComponent', () => {
resource.taxon.forEach(text => expect(tester.taxon).toContainText(text));
expect(tester.type).toContainText(resource.materialType[0]);
expect(tester.description).toContainText(resource.description);
expect(tester.fullDescriptionLink).toBeNull();
expect(tester.fullDescriptionButton).toBeNull();
expect(tester.fullDescription).toBeNull();
expect(tester.shortDescriptionLink).toBeNull();
expect(tester.shortDescriptionButton).toBeNull();
});
it('should have a link to portal if data url is null or empty', () => {
......@@ -106,31 +106,52 @@ describe('GeneticResourceComponent', () => {
expect(tester.type).toContainText('type1, type2');
});
it('should have truncate the long description and allow to display it fully', () => {
it('should truncate the long description and allow to display it fully', () => {
const tester = new GeneticResourceComponentTester();
const component = tester.componentInstance;
// given a resource with a long description
const resource = toGeneticResource('Bacteria');
resource.description = Array(500).fill('a').join('');
resource.description = Array(200).fill('aaa').join(' ');
component.geneticResource = resource;
tester.detectChanges();
// then we should truncate it
expect(tester.fullDescriptionLink).not.toBeNull();
expect(tester.fullDescriptionButton).not.toBeNull();
const linkContent = '... (Voir tout)';
expect(tester.fullDescriptionLink).toContainText(linkContent);
expect(tester.description.textContent.length).toBe(256 + linkContent.length);
expect(tester.fullDescriptionButton).toContainText(linkContent);
expect(tester.description.textContent.length).toBeLessThanOrEqual(256 + linkContent.length);
expect(tester.description.textContent.length).toBeGreaterThanOrEqual(252 + linkContent.length);
// when we click on the link
tester.fullDescriptionLink.dispatchEventOfType('click');
tester.fullDescriptionButton.click();
// then we should display the full description
expect(tester.fullDescription).not.toBeNull();
expect(tester.fullDescription).toContainText(resource.description);
expect(tester.shortDescriptionLink).not.toBeNull();
expect(tester.shortDescriptionLink).toContainText('Réduire');
expect(tester.shortDescriptionButton).not.toBeNull();
expect(tester.shortDescriptionButton).toContainText('Réduire');
expect(tester.description).toBeNull();
expect(tester.fullDescriptionLink).toBeNull();
expect(tester.fullDescriptionButton).toBeNull();
});
it('should display a highlighted description (truncated and full)', () => {
const tester = new GeneticResourceComponentTester();
const component = tester.componentInstance;
// given a resource with a long highlighted description
const resource = toGeneticResource('Bacteria');
const description = 'Hello <em>world</em>! The <em>world</em> is&nbsp;beautiful.';
resource.description = description + ' ' + Array(200).fill('aaa').join(' ');
component.geneticResource = resource;
tester.detectChanges();
// it should highlight the short description
expect(tester.description).toContainText('Hello world! The world is\u00A0beautiful.');
tester.fullDescriptionButton.click();
// and also the long description
expect(tester.fullDescription).toContainText('Hello world! The world is\u00A0beautiful.');
});
});
import { ChangeDetectionStrategy, Component, Input } from '@angular/core';
import { ChangeDetectionStrategy, Component, Input, OnInit } from '@angular/core';
import { GeneticResourceModel } from '../models/genetic-resource.model';
import { HighlightService } from '../highlight.service';
@Component({
selector: 'rare-genetic-resource',
......@@ -8,14 +9,20 @@ import { GeneticResourceModel } from '../models/genetic-resource.model';
styleUrls: ['./genetic-resource.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush
})
export class GeneticResourceComponent {
export class GeneticResourceComponent implements OnInit {
descriptionCollapsed = true;
@Input() geneticResource: GeneticResourceModel;
truncatedDescription: string;
constructor(private highlightService: HighlightService) { }
toggleDescription() {
this.descriptionCollapsed = !this.descriptionCollapsed;
}
ngOnInit() {
this.truncatedDescription = this.highlightService.truncate(this.geneticResource.description, 256);
}
}
import { HighlightService } from './highlight.service';
describe('HighlightService', () => {
it('should truncate', () => {
const service = new HighlightService();
const text = 'hello <em>great </em><em>great</em> &amp;big <em>world</em>!'; // 29 printable characters
expect(service.truncate(null, 10)).toBeNull();
expect(service.truncate(text, 34)).toBe(text);
expect(service.truncate(text, 29)).toBe(text);
expect(service.truncate(text, 28)).toBe('hello <em>great </em><em>great</em> &amp;big <em>world</em>');
expect(service.truncate(text, 27)).toBe('hello <em>great </em><em>great</em> &amp;big <em>worl</em>');
expect(service.truncate(text, 26)).toBe('hello <em>great </em><em>great</em> &amp;big <em>wor</em>');
expect(service.truncate(text, 25)).toBe('hello <em>great </em><em>great</em> &amp;big <em>wo</em>');
expect(service.truncate(text, 24)).toBe('hello <em>great </em><em>great</em> &amp;big <em>w</em>');
expect(service.truncate(text, 23)).toBe('hello <em>great </em><em>great</em> &amp;big ');
expect(service.truncate(text, 22)).toBe('hello <em>great </em><em>great</em> &amp;big');
expect(service.truncate(text, 21)).toBe('hello <em>great </em><em>great</em> &amp;bi');
expect(service.truncate(text, 20)).toBe('hello <em>great </em><em>great</em> &amp;b');
expect(service.truncate(text, 19)).toBe('hello <em>great </em><em>great</em> &amp;');
expect(service.truncate(text, 18)).toBe('hello <em>great </em><em>great</em> ');
expect(service.truncate(text, 17)).toBe('hello <em>great </em><em>great</em>');
expect(service.truncate(text, 16)).toBe('hello <em>great </em><em>grea</em>');
expect(service.truncate(text, 15)).toBe('hello <em>great </em><em>gre</em>');
expect(service.truncate(text, 14)).toBe('hello <em>great </em><em>gr</em>');
expect(service.truncate(text, 13)).toBe('hello <em>great </em><em>g</em>');
expect(service.truncate(text, 12)).toBe('hello <em>great </em>');
expect(service.truncate(text, 11)).toBe('hello <em>great</em>');
expect(service.truncate(text, 10)).toBe('hello <em>grea</em>');
expect(service.truncate(text, 9)).toBe('hello <em>gre</em>');
expect(service.truncate(text, 8)).toBe('hello <em>gr</em>');
expect(service.truncate(text, 7)).toBe('hello <em>g</em>');
expect(service.truncate(text, 6)).toBe('hello ');
expect(service.truncate(text, 5)).toBe('hello');
expect(service.truncate(text, 4)).toBe('hell');
const desc = Array(200).fill('aaa').join(' ');
expect(service.truncate(desc, 256).length).toBe(256);
});
});
import { Injectable } from '@angular/core';
@Injectable({
providedIn: 'root'
})
export class HighlightService {
/**
* Truncates highlighted text (i.e. text containing words highlighted with `<em>word</em>` so that it's at most
* `maxLength` printable characters long.
*/
truncate(highlightedText: string, maxLength: number): string {
if (!highlightedText) {
return highlightedText;
}
const span = document.createElement('span');
span.innerHTML = highlightedText;
let length = 0;
let i: number;
for (i = 0; i < span.childNodes.length && length < maxLength; i++) {
const remainingLength = maxLength - length;
const childNode = span.childNodes[i];
const textContent = childNode.textContent;
if (textContent.length > remainingLength) {
childNode.textContent = textContent.substring(0, remainingLength);
length += remainingLength;
}
else {
length += textContent.length;
}
}
for (let j = span.childNodes.length - 1; j >= i; j--) {
span.removeChild(span.childNodes[j]);
}
return span.innerHTML;
}
}
......@@ -28,7 +28,7 @@ describe('SearchService', () => {
const resource = toGeneticResource('Bacteria');
const expectedResults = toSinglePage([resource]);
http.expectOne('/api/genetic-resources?query=Bacteria&page=1').flush(expectedResults);
http.expectOne('/api/genetic-resources?query=Bacteria&page=1&highlight=true').flush(expectedResults);
expect(actualResults).toEqual(expectedResults);
});
......@@ -41,7 +41,7 @@ describe('SearchService', () => {
const aggregation = toAggregation('coo', ['France', 'Italy']);
const expectedResults = toSinglePage([resource], [aggregation]);
http.expectOne('/api/genetic-resources?query=Bacteria&page=0&agg=true').flush(expectedResults);
http.expectOne('/api/genetic-resources?query=Bacteria&page=0&highlight=true&agg=true').flush(expectedResults);
expect(actualResults).toEqual(expectedResults);
});
......@@ -55,7 +55,7 @@ describe('SearchService', () => {
const resource = toGeneticResource('Bacteria');
const expectedResults = toSinglePage([resource]);
http.expectOne('/api/genetic-resources?query=Bacteria&page=0&coo=France&coo=Italy&domain=Forest').flush(expectedResults);
http.expectOne('/api/genetic-resources?query=Bacteria&page=0&highlight=true&coo=France&coo=Italy&domain=Forest').flush(expectedResults);
expect(actualResults).toEqual(expectedResults);
});
......@@ -70,7 +70,7 @@ describe('SearchService', () => {
const aggregation = toAggregation('coo', ['France', 'Italy']);
const expectedResults = toSinglePage([resource], [aggregation]);
http.expectOne('/api/genetic-resources?query=Bacteria&page=0&agg=true&coo=France&coo=Italy&domain=Forest').flush(expectedResults);
http.expectOne('/api/genetic-resources?query=Bacteria&page=0&highlight=true&agg=true&coo=France&coo=Italy&domain=Forest').flush(expectedResults);
expect(actualResults).toEqual(expectedResults);
});
......
......@@ -24,7 +24,7 @@ export class SearchService {
// we decrease the page as the frontend is 1 based, and the backend 0 based.
const page = (pageAsNumber - 1).toString();
// we built the search parameters
const params: { [key: string]: string | Array<string> } = { query, page };
const params: { [key: string]: string | Array<string> } = { query, page, highlight: 'true' };
// if we need to fetch the aggregation, add the `agg` parameter
if (aggregate) {
params.agg = 'true';
......
......@@ -2,3 +2,9 @@ $fa-font-path: '../node_modules/font-awesome/fonts';
@import '../node_modules/font-awesome/scss/font-awesome';
@import 'custom-bootstrap';
.highlight em {
font-style: normal;
font-weight: bold;
}
Markdown is supported
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment