diff --git a/package.json b/package.json
index 24c1504..bcc186d 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"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.",
"main": "index.js",
"scripts": {
diff --git a/src/FreeDatas2HTML.ts b/src/FreeDatas2HTML.ts
index 69b8e36..9f1c282 100644
--- a/src/FreeDatas2HTML.ts
+++ b/src/FreeDatas2HTML.ts
@@ -307,5 +307,6 @@ export class FreeDatas2HTML
// Permet l'appel des principales classes du module via un seul script :
export { Pagination } from "./Pagination";
export { Render} from "./Render";
+export { SearchEngine } from "./SearchEngine";
export { Selector } from "./Selector";
export { SortingField } from "./SortingField";
\ No newline at end of file
diff --git a/src/SearchEngine.ts b/src/SearchEngine.ts
new file mode 100644
index 0000000..5a2e5fb
--- /dev/null
+++ b/src/SearchEngine.ts
@@ -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=`
`;
+ 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;
+ }
+}
\ No newline at end of file
diff --git a/src/errors.js b/src/errors.js
index 041e5a6..c796d9d 100644
--- a/src/errors.js
+++ b/src/errors.js
@@ -29,9 +29,10 @@ module.exports =
remoteSourceNeedUrl: "Merci de fournir une url valide pour la source distante de données.",
remoteSourceUrlFail: "L'url fournie ne semble pas valide.",
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.",
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.",
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.",
diff --git a/tests/fixtures.js b/tests/fixtures.js
index ec95f8a..81426bc 100644
--- a/tests/fixtures.js
+++ b/tests/fixtures.js
@@ -1,6 +1,6 @@
module.exports =
{
- datasViewEltHTML: '
',
+ datasViewEltHTML: '
',
selector1HTML: '',
selector2HTML: '',
selector2HTMLWithLabel: '',
diff --git a/tests/searchEngineSpec.ts b/tests/searchEngineSpec.ts
new file mode 100644
index 0000000..c785d4c
--- /dev/null
+++ b/tests/searchEngineSpec.ts
@@ -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 et un bouton dans l'élément HTML indiqué avec les propriétés de base.", () =>
+ {
+ mySearch.filter2HTML();
+ expect(document.getElementById("mySearch").innerHTML).toEqual(``);
+ });
+
+ 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(``);
+ });
+
+ 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(``);
+ });
+
+ 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(``);
+ });
+
+ 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(``);
+ });
+
+ 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(``);
+ });
+
+ 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(``);
+ });
+ });
+
+ 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();
+ });
+ });
+ });
+
+});
\ No newline at end of file