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=`
`; + if(this.label !== "") + html+=``; + html+=` 0) + html+=` placeholder="${this.placeholder.replace("NB", ""+this.nbCharsForSearch)}"`; + else if(this.placeholder !== "") + html+=` placeholder="${this.placeholder}"`; + 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