Ajout nouvelle fonctionnalité de moteur de recherche dans les données.

This commit is contained in:
Fabrice PENHOËT 2021-10-28 17:50:57 +02:00
parent 27564f2d2b
commit 9a5f82aea5
6 changed files with 407 additions and 3 deletions

View File

@ -1,6 +1,6 @@
{ {
"name": "freedatas2html", "name": "freedatas2html",
"version": "0.9.7", "version": "0.9.8",
"description": "Conversion and display of data in different formats (CSV, JSON, HTML) with the possibility of filtering, classifying and paginating the results.", "description": "Conversion and display of data in different formats (CSV, JSON, HTML) with the possibility of filtering, classifying and paginating the results.",
"main": "index.js", "main": "index.js",
"scripts": { "scripts": {

View File

@ -307,5 +307,6 @@ export class FreeDatas2HTML
// Permet l'appel des principales classes du module via un seul script : // Permet l'appel des principales classes du module via un seul script :
export { Pagination } from "./Pagination"; export { Pagination } from "./Pagination";
export { Render} from "./Render"; export { Render} from "./Render";
export { SearchEngine } from "./SearchEngine";
export { Selector } from "./Selector"; export { Selector } from "./Selector";
export { SortingField } from "./SortingField"; export { SortingField } from "./SortingField";

131
src/SearchEngine.ts Normal file
View File

@ -0,0 +1,131 @@
const errors=require("./errors.js");
import { DOMElement, Filters } from "./interfaces";
import { FreeDatas2HTML } from "./FreeDatas2HTML";
export class SearchEngine implements Filters
{
private _converter: FreeDatas2HTML;
private _datasViewElt: DOMElement={ id: "", eltDOM: undefined };
private _btnTxt: string="Search";
private _fields2Search: string[]=[];
public label: string="";
public nbCharsForSearch : number=0;
public placeholder: string="";
public automaticSearch: boolean=false;
private _inputValue: string="";
// Injection de la classe principale, mais uniquement si des données ont été importées
constructor(converter: FreeDatas2HTML, elt: DOMElement, fields?: number[])
{
if(converter.fields.length === 0 || converter.datas.length === 0)
throw new Error(errors.filterNeedDatas);
else
{
this._datasViewElt=FreeDatas2HTML.checkInDOMById(elt);
this._converter=converter;
// Les champs sur lesquels les recherches seront lancées.
// Ils doivent se trouver dans les données parsées, mais peuvent ne pas être affichés dans les données.
// Un tableau vide est accepté et signifie que les recherches se feront sur tous les champs.
if(fields !== undefined && fields.length !== 0)
{
for(let field of fields)
{
if(! this._converter.checkFieldExist(field))
throw new Error(errors.searchFieldNotFound);
else
this._fields2Search.push(this.converter.fields[field]);
}
}
else
this._fields2Search=this._converter.fields;
}
}
get converter() : FreeDatas2HTML
{
return this._converter;
}
get datasViewElt() : DOMElement
{
return this._datasViewElt;
}
set btnTxt(txt: string)
{
if(txt.trim() !== "" && txt.length <= 30)
this._btnTxt=txt;
}
get btnTxt(): string
{
return this._btnTxt;
}
get inputValue() : string
{
return this._inputValue;
}
get fields2Search() : string[]
{
return this._fields2Search;
}
// Création du champ de recherche dans le DOM.
public filter2HTML() : void
{
if(this.nbCharsForSearch >0 && this.placeholder === "")
this.placeholder="Please enter at least NB characters."
// Pas de minlength ou de required, car l'envoi d'une recherche vide doit permettre d'annuler le filtre.
let html=`<form id="freeDatas2HTMLSearch">`;
if(this.label !== "")
html+=`<label for="freeDatas2HTMLSearchTxt">${this.label}</label>`;
html+=`<input type="search" id="freeDatas2HTMLSearchTxt" name="freeDatas2HTMLSearchTxt"`;
if(this.nbCharsForSearch > 0)
html+=` placeholder="${this.placeholder.replace("NB", ""+this.nbCharsForSearch)}"`;
else if(this.placeholder !== "")
html+=` placeholder="${this.placeholder}"`;
html+=`>&nbsp;<input type="submit" id="freeDatas2HTMLSearchBtn" value="${this._btnTxt}"></form>`;
this. _datasViewElt.eltDOM!.innerHTML=html;// "!" car l'existence de "eltDOM" est testé par le constructeur
// L'affichage est actualisé quand l'éventuel nombre de caractères est atteint ou quand le champ est vide, car cela permet d'annuler ce filtre.
const searchInput=document.getElementById("freeDatas2HTMLSearchTxt") as HTMLInputElement, mySearch=this;
searchInput.addEventListener("input", function(e)
{
e.preventDefault();
mySearch._inputValue=searchInput.value;
let searchLength=searchInput.value.length;
if(mySearch.automaticSearch && (mySearch.nbCharsForSearch === 0 || ( searchLength === 0) || (searchLength >= mySearch.nbCharsForSearch)))
mySearch._converter.refreshView();
});
/// Afficher un message quand le nombre de caractères n'est pas atteint ?
const searchBtn=document.getElementById("freeDatas2HTMLSearchBtn") as HTMLInputElement;
searchBtn.addEventListener("click", function(e)
{
e.preventDefault();
let searchLength=searchInput.value.length;
if((mySearch.nbCharsForSearch === 0 || ( searchLength === 0) || (searchLength >= mySearch.nbCharsForSearch)))
mySearch._converter.refreshView();
});
}
public dataIsOk(data: {[index: string]:string}) : boolean
{
// Pas de valeur sélectionnée = pas de filtre sur ce champ
if(this._inputValue.length === 0)
return true;
// Sinon, on cherche la valeur saisie dans les champs définis :
for(let field in data)
{
if(this._fields2Search.indexOf(field) !== -1)
{
// Attention, recherche insensible à la casse, mais aux accents, etc.
if(data[field].toLowerCase().indexOf(this._inputValue.toLowerCase()) !== -1)
return true;
}
}
return false;
}
}

View File

@ -29,9 +29,10 @@ module.exports =
remoteSourceNeedUrl: "Merci de fournir une url valide pour la source distante de données.", remoteSourceNeedUrl: "Merci de fournir une url valide pour la source distante de données.",
remoteSourceUrlFail: "L'url fournie ne semble pas valide.", remoteSourceUrlFail: "L'url fournie ne semble pas valide.",
renderNeedFields: "Les noms de champs doivent être fournis avant de demander l'affichage des données.", renderNeedFields: "Les noms de champs doivent être fournis avant de demander l'affichage des données.",
searchFieldNotFound: "Au moins un des champs devant être utilisés par le moteur de recherche n'existe pas dans les données.",
selector2HTMLFail: "Le création d'un filtre dans le DOM nécessite l'initialisation de l'élément HTML et du numéro du champs à filter.", selector2HTMLFail: "Le création d'un filtre dans le DOM nécessite l'initialisation de l'élément HTML et du numéro du champs à filter.",
selectorFieldIsEmpty: "Aucune donnée trouvée pour le champ du filtre", selectorFieldIsEmpty: "Aucune donnée trouvée pour le champ du filtre",
selectorFieldNotFound: "Au moins un des champs devant servir à filtrer les données n'existe pas dans le fichier.", selectorFieldNotFound: "Au moins un des champs devant servir à filtrer les données n'existe pas dans le fichier.",
selectorSelectedIndexNotFound: "La valeur sélectionnée n'a pas été trouvée dans la liste des champs.", selectorSelectedIndexNotFound: "La valeur sélectionnée n'a pas été trouvée dans la liste des champs.",
sortingFieldNeedDatas: "La création d'un champ de classement nécessite la transmission de la liste des champs.", sortingFieldNeedDatas: "La création d'un champ de classement nécessite la transmission de la liste des champs.",
sortingFieldNotFound: "Au moins un des champs devant permettre de classer les données n'existe pas dans ceux à afficher.", sortingFieldNotFound: "Au moins un des champs devant permettre de classer les données n'existe pas dans ceux à afficher.",

View File

@ -1,6 +1,6 @@
module.exports = module.exports =
{ {
datasViewEltHTML: '<div id="fixture"><div id="selector1"></div><div id="selector2"></div><div id="paginationOptions"></div><div id="counter"></div><div id="datas"></div><div id="pages"></div></div>', datasViewEltHTML: '<div id="fixture"><div id="selector1"></div><div id="selector2"></div><div id="paginationOptions"></div><div id="counter"></div><div id="datas"></div><div id="pages"></div><div id="mySearch"></div></div>',
selector1HTML: '<label for="freeDatas2HTML_selector1">Famille :</label><select name="freeDatas2HTML_selector1" id="freeDatas2HTML_selector1"><option value="0">----</option><option value="1">Actinide</option><option value="2">Gaz noble</option><option value="3">gaz rare</option><option value="4">Halogène</option><option value="5">Indéfinie</option><option value="6">Lanthanide</option><option value="7">Métal alcalin</option><option value="8">Métal alcalino-terreux</option><option value="9">Métal de transition</option><option value="10">Métal pauvre</option><option value="11">Métalloïde</option><option value="12">Non-métal</option></select>', selector1HTML: '<label for="freeDatas2HTML_selector1">Famille :</label><select name="freeDatas2HTML_selector1" id="freeDatas2HTML_selector1"><option value="0">----</option><option value="1">Actinide</option><option value="2">Gaz noble</option><option value="3">gaz rare</option><option value="4">Halogène</option><option value="5">Indéfinie</option><option value="6">Lanthanide</option><option value="7">Métal alcalin</option><option value="8">Métal alcalino-terreux</option><option value="9">Métal de transition</option><option value="10">Métal pauvre</option><option value="11">Métalloïde</option><option value="12">Non-métal</option></select>',
selector2HTML: '<label for="freeDatas2HTML_selector2">Abondance des éléments dans la croûte terrestre (μg/k) :</label><select name="freeDatas2HTML_selector2" id="freeDatas2HTML_selector2"><option value="0">----</option><option value="1">&gt; 1 et &lt; 100 000</option><option value="2">&gt; 100000</option><option value="3">≤ 1</option><option value="4">Inexistant</option><option value="5">Traces</option></select>', selector2HTML: '<label for="freeDatas2HTML_selector2">Abondance des éléments dans la croûte terrestre (μg/k) :</label><select name="freeDatas2HTML_selector2" id="freeDatas2HTML_selector2"><option value="0">----</option><option value="1">&gt; 1 et &lt; 100 000</option><option value="2">&gt; 100000</option><option value="3">≤ 1</option><option value="4">Inexistant</option><option value="5">Traces</option></select>',
selector2HTMLWithLabel: '<label for="freeDatas2HTML_selector2">Abondance des éléments :</label><select name="freeDatas2HTML_selector2" id="freeDatas2HTML_selector2"><option value="0">----</option><option value="1">&gt; 1 et &lt; 100 000</option><option value="2">&gt; 100000</option><option value="3">≤ 1</option><option value="4">Inexistant</option><option value="5">Traces</option></select>', selector2HTMLWithLabel: '<label for="freeDatas2HTML_selector2">Abondance des éléments :</label><select name="freeDatas2HTML_selector2" id="freeDatas2HTML_selector2"><option value="0">----</option><option value="1">&gt; 1 et &lt; 100 000</option><option value="2">&gt; 100000</option><option value="3">≤ 1</option><option value="4">Inexistant</option><option value="5">Traces</option></select>',

271
tests/searchEngineSpec.ts Normal file
View File

@ -0,0 +1,271 @@
import { FreeDatas2HTML, SearchEngine } from "../src/FreeDatas2HTML";
const errors=require("../src/errors.js");
const fixtures=require("./fixtures.js");
describe("Test du moteur de recherche.", () =>
{
let converter: FreeDatas2HTML;
let mySearch: SearchEngine;
let searchElement : HTMLInputElement;
beforeEach( async () =>
{
document.body.insertAdjacentHTML("afterbegin", fixtures.datasViewEltHTML);
converter=new FreeDatas2HTML("CSV");
converter.parser.setRemoteSource({ url:"http://localhost:9876/datas/datas1.csv" });
converter.datasViewElt={ id:"datas" };
await converter.run();
mySearch=new SearchEngine(converter, { id:"mySearch" });
});
afterEach( () =>
{
document.body.removeChild(document.getElementById("fixture"));
});
describe("Test des données de configuration.", () =>
{
it("Doit avoir créé une instance de SearchEngine", () =>
{
expect(mySearch).toBeInstanceOf(SearchEngine);
});
it("Doit générer une erreur, si initialisé sans avoir au préalable chargé des données.", async () =>
{
converter=new FreeDatas2HTML("CSV");
expect(() => { return new SearchEngine(converter, { id:"mySearch" }); }).toThrowError(errors.filterNeedDatas);
converter.parser.datas2Parse="Z (numéro atomique),Élément,Symbole,Famille,Abondance des éléments dans la croûte terrestre (μg/k)";
await converter.run();
expect(() => { return new SearchEngine(converter, { id:"mySearch" }); }).toThrowError(errors.filterNeedDatas);
});
it("Si une chaîne vide est transmise pour le texte du bouton, elle doit être ignorée.", () =>
{
mySearch.btnTxt="";
expect(mySearch.btnTxt).toEqual("Search");
mySearch.btnTxt=" ";
expect(mySearch.btnTxt).toEqual("Search");
});
it("Si une chaîne de + de 30 caractères est transmise pour le texte du bouton, elle doit être ignorée.", () =>
{
mySearch.btnTxt="Si une chaîne de + de 30 caractères est transmise pour le texte du bouton, elle doit être ignorée.";
expect(mySearch.btnTxt).toEqual("Search");
});
it("Toute chaîne de caractères valide doit être acceptée comme texte pour le bouton.", () =>
{
mySearch.btnTxt="a";
expect(mySearch.btnTxt).toEqual("a");
mySearch.btnTxt=" aaa ";
expect(mySearch.btnTxt).toEqual(" aaa ");
});
it("Doit générer une erreur, si au moins un des numéros des champs sur lesquels effectuer les recherches n'existe pas dans les données.", () =>
{
expect(() => { return new SearchEngine(converter, { id:"mySearch" }, [-1,0,2]); }).toThrowError(errors.searchFieldNotFound);
expect(() => { return new SearchEngine(converter, { id:"mySearch" }, [0,1,10]); }).toThrowError(errors.searchFieldNotFound);
expect(() => { return new SearchEngine(converter, { id:"mySearch" }, [0,1.1,10]); }).toThrowError(errors.searchFieldNotFound);
});
it("Si tous numéros des champs sur lesquels effectuer les recherches existent dans les données, ils doivent être acceptés.", () =>
{
expect(() => { return new SearchEngine(converter, { id:"mySearch" }, [0,2,3]); }).not.toThrowError();
mySearch=new SearchEngine(converter, { id:"mySearch" }, [0,2,3]);
expect(mySearch.fields2Search).toEqual(["Z (numéro atomique)","Symbole","Famille"]);
});
it("Un tableau vide pour les champs sur lesquels effectuer les recherche doit être accepté.", () =>
{
expect(() => { return new SearchEngine(converter, { id:"mySearch" }, []); }).not.toThrowError();
expect(mySearch.fields2Search).toEqual(["Z (numéro atomique)","Élément","Symbole","Famille","Abondance des éléments dans la croûte terrestre (μg/k)"]);
});
});
describe("Création du champ de recherche.", () =>
{
it("Doit générer un élement <input> et un bouton <submit> dans l'élément HTML indiqué avec les propriétés de base.", () =>
{
mySearch.filter2HTML();
expect(document.getElementById("mySearch").innerHTML).toEqual(`<form id="freeDatas2HTMLSearch"><input type="search" id="freeDatas2HTMLSearchTxt" name="freeDatas2HTMLSearchTxt">&nbsp;<input type="submit" id="freeDatas2HTMLSearchBtn" value="Search"></form>`);
});
it("Doit prendre en compte l'éventuel label fourni pour le champ de recherche.", () =>
{
mySearch.label="Qui cherche trouve ?";
mySearch.filter2HTML();
expect(document.getElementById("mySearch").innerHTML).toEqual(`<form id="freeDatas2HTMLSearch"><label for="freeDatas2HTMLSearchTxt">Qui cherche trouve ?</label><input type="search" id="freeDatas2HTMLSearchTxt" name="freeDatas2HTMLSearchTxt">&nbsp;<input type="submit" id="freeDatas2HTMLSearchBtn" value="Search"></form>`);
});
it("Doit prendre en compte l'éventuel texte personnalisé du bouton de recherche.", () =>
{
mySearch.btnTxt="Qui cherche trouve ?";
mySearch.filter2HTML();
expect(document.getElementById("mySearch").innerHTML).toEqual(`<form id="freeDatas2HTMLSearch"><input type="search" id="freeDatas2HTMLSearchTxt" name="freeDatas2HTMLSearchTxt">&nbsp;<input type="submit" id="freeDatas2HTMLSearchBtn" value="Qui cherche trouve ?"></form>`);
});
it("Doit indiquer l'éventuel nombre de caractères requis pour lancer la recherche.", () =>
{
mySearch.nbCharsForSearch=2;
mySearch.filter2HTML();
expect(document.getElementById("mySearch").innerHTML).toEqual(`<form id="freeDatas2HTMLSearch"><input type="search" id="freeDatas2HTMLSearchTxt" name="freeDatas2HTMLSearchTxt" placeholder="Please enter at least 2 characters.">&nbsp;<input type="submit" id="freeDatas2HTMLSearchBtn" value="Search"></form>`);
});
it("Doit indiquer l'éventuel nombre de caractères requis pour lancer la recherche en utilisant un texte personnalisé.", () =>
{
mySearch.nbCharsForSearch=3;
mySearch.placeholder="Saisir NB caractères pour lancer votre recherche.";
mySearch.filter2HTML();
expect(document.getElementById("mySearch").innerHTML).toEqual(`<form id="freeDatas2HTMLSearch"><input type="search" id="freeDatas2HTMLSearchTxt" name="freeDatas2HTMLSearchTxt" placeholder="Saisir 3 caractères pour lancer votre recherche.">&nbsp;<input type="submit" id="freeDatas2HTMLSearchBtn" value="Search"></form>`);
});
it("Doit accepter un texte d'indication libre, même quand il n'y a pas de nombre de caractères requis.", () =>
{
mySearch.placeholder="Bonne chance !";
mySearch.filter2HTML();
expect(document.getElementById("mySearch").innerHTML).toEqual(`<form id="freeDatas2HTMLSearch"><input type="search" id="freeDatas2HTMLSearchTxt" name="freeDatas2HTMLSearchTxt" placeholder="Bonne chance !">&nbsp;<input type="submit" id="freeDatas2HTMLSearchBtn" value="Search"></form>`);
});
it("Doit prendre en compte l'ensemble des attributs renseignés.", () =>
{
mySearch.label="Qui cherche trouve ?";
mySearch.btnTxt="Qui cherche trouve ?";
mySearch.nbCharsForSearch=3;
mySearch.placeholder="Saisir NB caractères pour lancer votre recherche.";
mySearch.filter2HTML();
expect(document.getElementById("mySearch").innerHTML).toEqual(`<form id="freeDatas2HTMLSearch"><label for="freeDatas2HTMLSearchTxt">Qui cherche trouve ?</label><input type="search" id="freeDatas2HTMLSearchTxt" name="freeDatas2HTMLSearchTxt" placeholder="Saisir 3 caractères pour lancer votre recherche.">&nbsp;<input type="submit" id="freeDatas2HTMLSearchBtn" value="Qui cherche trouve ?"></form>`);
});
});
describe("Lancement de la recherche.", () =>
{
let searchInput: HTMLInputElement, searchBtn: HTMLInputElement;
beforeEach( async () =>
{
mySearch.filter2HTML();
searchInput=document.getElementById("freeDatas2HTMLSearchTxt") as HTMLInputElement;
searchBtn=document.getElementById("freeDatas2HTMLSearchBtn") as HTMLInputElement;
});
it("Le clic sur le bouton SUBMIT doit appeler la fonction actualisant l'affichage.", () =>
{
spyOn(converter, "refreshView");
searchBtn.click();
expect(converter.refreshView).toHaveBeenCalledTimes(1);
searchInput.value="z";
searchBtn.click();
expect(converter.refreshView).toHaveBeenCalledTimes(2);
});
it("Le clic sur le bouton SUBMIT doit appeler la fonction actualisant l'affichage, pour peu que le nombre de caractères défini soit saisi.", () =>
{
spyOn(converter, "refreshView");
mySearch.nbCharsForSearch=3;
searchInput.value="z";
searchBtn.click();
expect(converter.refreshView).not.toHaveBeenCalled();
searchInput.value="zz";
searchBtn.click();
expect(converter.refreshView).not.toHaveBeenCalled();
searchInput.value="zzz";
searchBtn.click();
expect(converter.refreshView).toHaveBeenCalledTimes(1);
// Il est toujours possible d'annuler la recherche :
searchInput.value="";
searchBtn.click();
expect(converter.refreshView).toHaveBeenCalledTimes(2);
});
it("Si demandé, l'actualisation est lancée à chaque saisie, y compris si le champ est vide.", () =>
{
spyOn(converter, "refreshView");
mySearch.automaticSearch=true;
searchInput.value="z";
searchInput.dispatchEvent(new Event("input"));
expect(converter.refreshView).toHaveBeenCalledTimes(1);
searchInput.value="zz";
searchInput.dispatchEvent(new Event("input"));
expect(converter.refreshView).toHaveBeenCalledTimes(2);
searchInput.value="";
searchInput.dispatchEvent(new Event("input"));
expect(converter.refreshView).toHaveBeenCalledTimes(3);
});
it("Si demandé, l'actualisation est lancée à chaque saisie, mais avec un minimum de caractères défini.", () =>
{
spyOn(converter, "refreshView");
mySearch.nbCharsForSearch=3;
mySearch.automaticSearch=true;
searchInput.value="z";
searchInput.dispatchEvent(new Event("input"));
expect(converter.refreshView).not.toHaveBeenCalled();
searchInput.value="zz";
searchInput.dispatchEvent(new Event("input"));
expect(converter.refreshView).not.toHaveBeenCalled();
searchInput.value="zzz";
searchInput.dispatchEvent(new Event("input"));
expect(converter.refreshView).toHaveBeenCalledTimes(1);
searchInput.value="zz";
searchInput.dispatchEvent(new Event("input"));
expect(converter.refreshView).toHaveBeenCalledTimes(1);
// Il est toujours possible d'annuler la recherche :
searchInput.value="";
searchInput.dispatchEvent(new Event("input"));
expect(converter.refreshView).toHaveBeenCalledTimes(2);
});
it("Doit toujours retourner true si le champ de recherche est vide.", () =>
{
mySearch.automaticSearch=true;
// Le champ est vide par défaut :
searchInput.dispatchEvent(new Event("input"));
expect(mySearch.dataIsOk({ "nom" : "oui" })).toBeTrue();
// Même comportement après un retour :
searchInput.value="z";
searchInput.dispatchEvent(new Event("input"));
searchInput.value="";
searchInput.dispatchEvent(new Event("input"));
expect(mySearch.dataIsOk({ "nom" : "oui" })).toBeTrue();
});
describe("Filtre des données", () =>
{
beforeEach( async () =>
{
mySearch.automaticSearch=true;
});
it("Doit retourner false, si la donnée testée ne possède aucun des champs sur lesquels est lancée la recherche.", () =>
{
searchInput.value="lithium";
searchInput.dispatchEvent(new Event("input"));
expect(mySearch.dataIsOk({ "nom" : "lithium" })).toBeFalse();
});
it("Doit retourner false, si une donnée testée ne correspond pas à la valeur cherchée.", async () =>
{
searchInput.value="Halogène";
searchInput.dispatchEvent(new Event("input"));
expect(mySearch.dataIsOk({ "Famille": "Halogene" })).toBeFalse();// sensible aux accents
});
it("Doit retourner true, si la valeur recherchée est retrouvée dans la donnée recherchée, sans prendre en compte la casse.", () =>
{
// Expression exacte :
searchInput.value="Halogène";
searchInput.dispatchEvent(new Event("input"));
expect(mySearch.dataIsOk({ "Famille": "Halogène" })).toBeTrue();
// Expression partielle :
searchInput.value="gène";
searchInput.dispatchEvent(new Event("input"));
expect(mySearch.dataIsOk({ "Famille": "Halogène" })).toBeTrue();
// Insensible à casse :
searchInput.value="halo";
searchInput.dispatchEvent(new Event("input"));
expect(mySearch.dataIsOk({ "Famille": "Halogène" })).toBeTrue();
});
});
});
});