Transformarea datelor parent-child in liste HTML imbricate

Folosim recursivitatea pentru citirea datelor aflate in relatie parent-child si cream liste imbricate

Daniel Turcu

10 minute read

Vom porni de la un set de date organizate sub forma ierarhica prin relatii de tip parinte-copil si vrem sa le afisam sub forma unor liste imbricate in pagina HTML.


Datele

Consideram ca avem urmatorul set de date care descriu categorii ale produselor. Categoriile pot avea subcategorii, iar asta este specificat printr-un camp parentid. Daca parentid = null inseamna ca este un nod root, categorie principala.

Datele pe care lucram sunt urmatoarele:


let categories = [
    { id: 1, name: 'Laptops',               parentid: null },
    { id: 2, name: 'Laptops [no OS]',       parentid: 1 },
    { id: 3, name: 'Laptops with Windows',  parentid: 1 },
    { id: 4, name: 'Laptops with Linux',    parentid: 1 },

    { id: 5, name: 'PC',                        parentid: null },
    { id: 6, name: 'Desktop PC',                parentid: 5 },
    { id: 7, name: 'Monitors and Accessories',  parentid: 6 },
    { id: 8, name: 'LED Monitors',              parentid: 7 },
    { id: 9, name: 'Accessories',               parentid: 7 },

    { id: 10, name: 'TV',              parentid: null },
    { id: 11, name: 'Full HD',         parentid: 10 },
    { id: 12, name: 'Ultra HD',        parentid: 10 },

    { id: 13, name: 'Software',         parentid: 5 },
    { id: 14, name: 'OS',               parentid: 13 },
    { id: 15, name: 'Antivirus',        parentid: 13 },
    { id: 16, name: 'Apps',             parentid: 13 },
    { id: 17, name: 'Games',            parentid: 13 },


    { id: 18, name: 'Office',               parentid: null },
    { id: 19, name: 'Printers',             parentid: 18 },
    { id: 20, name: 'Inkjet Printers',      parentid: 19 },
    { id: 21, name: 'Laser Printers',       parentid: 19 },
    { id: 22, name: 'Ink and Toner',        parentid: 18 },
]

Incepem parcurgerea acestui tree. Prin conventie stim ca, daca o categorie are campul parentid egal cu null, atunci se afla pe primul nivel al tree-ului (este categorie principala). Deci incepem parcurgerea de la null.

Ne construim o functie help-er:


function startReadingNodes(parentChildData, parentid) {
    for(let i in parentChildData) {
        // pornim cu nod-ul `null` si ajungem la frunze (cele mai imbricate nod-uri)
        if(parentChildData[i].parentid === parentid) {
            console.log(parentChildData[i]);
            startReadingNodes(parentChildData, parentChildData[i].id)
        }
    }
}

Am construit o functie recursiva pentru pargurgerea unei structuri de date imbricate, aflate in relatie parent-child. De fiecare data cand ajungem la un nod, va trebui sa il asezam si in DOM ca element <li>.

Pana la afisarea in DOM vom construi codul care sa ne ajute sa gasim toate categoriile si subcategoriile.

Sa apelam acum functia pe lista de categorii:


startReadingNodes(categories, null);

Rezultatul parcurgerii recursive:

Parent child recursive reading - console output


DOM Tree List

Avem un algoritm recursiv simplu prin care citim categoriile. Acum vrem sa le asezam intr-o pagina HTML. Sa cream proiectul care sa cuprinda datele de mai sus, fisierele .js cu codul aplicatiei noastre, inclusiv functia startReadingNodes si fisierul index.html unde o sa asezam dinamic o structura de liste imbricate, create dupa modelul datelor din categories.

Daca pe linia unde avem console.log(parentChildData[i]), punem un mecanism de creare liste si noduri ale listei in DOM (<ul> si <li>), atunci vom avea proiectia datelor in pagina sub forma unui tree.

Trebuie sa fim atenti la locul unde le asezam in DOM, si la nivelul de imbricare al subcategoriilor. Pentru asta o sa utilizam doua informatii atunci cand cream nodurile: id-ul categoriei va deveni atribut id al nodului in DOM astfel incat sa putem sa utilizam apoi campul parentid pentru a atasa o subcategorie la nodul parinte.

Vrem sa obtinem o lista ca cea de mai jos:

Parent child recursive html nested lists view

Structura HTML va arata asa:

“Parent child recursive html nested lists dom structure”


Proiectul web

Sa incepem prin a crea un proiect simplu unde sa punem cap la cap datele, structura paginii, aplicatia si ceva cod css pentru aspect.

Cream un folder in care punem urmatoarele fisiere:


styles.html


/* vom avea style-uri diferite pentru primul nivel de categorii */
.rootCategory {
    line-height: 2rem;
    font-size: 1.2rem;
    font-family: sans-serif;
    letter-spacing: 0.1rem;
    color: darkorange;
    /* nu vrem bullet-uri in fata elementelor din lista */
    list-style-type: none;
}

.subcategory {
    line-height: 1.5rem;
    font-size: 1rem;
    font-family: sans-serif;
    letter-spacing: 0.1rem;
    color: green;
    list-style-type: none;
}

li {
    /* vrem ca <li>-urile sa se comporte ca <a>-urile la hover, 
    adica sa apara cursorul specific link-urilor */
    cursor: pointer;     
}



app.js

Deocamdata aici punem datele:


let categories = [
    { id: 1, name: 'Laptops',               parentid: null },
    { id: 2, name: 'Laptops [no OS]',       parentid: 1 },

    // ...

    { id: 18, name: 'Office',               parentid: null },
    { id: 19, name: 'Printers',             parentid: 18 },
    { id: 20, name: 'Inkjet Printers',      parentid: 19 },
    { id: 21, name: 'Laser Printers',       parentid: 19 },
    { id: 22, name: 'Ink and Toner',        parentid: 18 },
]

// dupa ce pagina este incarcata incepem sa lucram cu DOM-ul
window.onload = function(){ 
    console.log('Page loaded...');
}

Vom crea un fisier utils.js unde vom crea functiile re-utilizabile legate de manipularea listelor. Vom atasa acele functii unui obiect global. Incapsuland mai multe functii sub aceeasi umbrela (obiect), evitam sa populam (poluam) obiectul global cu prea multe functii. Vom avea o singura variabila globala, obiectul Utils.


utils.js

Punem deja functia de citire recursiva a datelor creata mai sus:


const Utils = (function() {     // IIFE - vom avea un singur obiect global cu toate metodele

    function startReadingNodes(parentChildData, parentid) {
        for(let i in parentChildData) {
            // pornim cu nod-ul `null` si ajungem la frunze (cele mai imbricate nod-uri)
            if(parentChildData[i].parentid === parentid) {
                console.log(parentChildData[i]);
                // aici vom adauga categoria in DOM 
                startReadingNodes(parentChildData, parentChildData[i].id)
            }
        }
    }

    return {
        startReadingNodes
    }

}())



index.html


<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Parent-child data to Tree List</title>

    <link rel="stylesheet" href="styles.css">
</head>
<body>
    <main>
        <ul id="categories">
        <!-- Aici vom atasa categoriile dinamic -->
        </ul>
    </main>
    <script src="utils.js"></script>
    <script src="app.js"></script>
</body>
</html>

Avem toate componentele principale ale proiectului. Sa incepem algoritmul de creare liste imbricate modelate dupa structura datelor (aflate in relatie parent-child).


Creare liste imbricate (DOM tree)

Pentru inceput, verificam ca functioneaza proiectul si vrem sa afisam in consola toate categoriile folosind mini-biblioteca Utils:

// app.js

window.onload = function(){

    Utils.startReadingNodes(categories, null);

}

In consola apare lista de categorii. Pentru fiecare categorie vom crea un element <li>. Avem doua cazuri: categoriile root (parentid === null) si subcategoriile. Vom trata diferit cele doua cazuri:

// utils.js

const Utils = (function(){    

    function startReadingNodes(parentChildData, parentid) {
        for(let i in parentChildData) {
            if(parentChildData[i].parentid === parentid) {
                console.log(parentChildData[i]);

                //+ aici vom crea nodurile in DOM pentru fiecare categorie analizata
                createCategory(parentChildData[i].id, parentChildData[i].name, parentChildData[i].parentid);

                startReadingNodes(parentChildData, parentChildData[i].id)
            }
        }
    }

    // functia createCategory va fi expusa public: Utils.createCategory()
    // totusi in cazul nostru aceasta este utilizata doar in functia recursiva startReadingNodes()
    function createCategory(categoryId, categoryName, parentid) {

        // cazul parentid == null (root category)
        if ( parentid === null ) // nu vrem coercion (adica nu vrem conversie implicita cu '==')
        {
            // categoria este copil al elementului <ul id="categories">

 
        } else // subcategorie - trebuie sa aflam care este nodul parinte
        {
            

        }
    }

    function createLi(text){
        let liEl = document.createElement("li");
        let textEl = document.createTextNode(text);
        liEl.appendChild(textEl);

        return liEl;
    }

    function createUl(className){
        let ulEl = document.createElement("ul");
        if (className) ulEl.classList.add(className);
        
        return ulEl;
    }

    return {
        startReadingNodes,
        createCategory
    }



Am adaugat functia createCategory() si am apelat-o in cadrul functiei recursive startReadingNodes():

createCategory(parentChildData[i].id, parentChildData[i].name, parentChildData[i].parentid);

Este important ca mai sus sa comparam cu null folosind === nu ==. Prin conventie, categorie parinte este cea care are parentid === null nu parentid === undefined (daca exista din greseala acest caz). Nu uitam ca in javascript null == undefined returneaza true.

Deasemenea am intuit necesitatea de a crea elemente de tipul <ul> si <li>. Incapsulam aceasta logica in urmatoarele doua functii:

function createLi(text) { ... }

function createUl(className) { ... }


Finalizarea functiei createCategory()

A mai ramas sa finalizam logica de creare a listei imbricate.

Pentru fiecare categorie din lista verificam daca este categorie root sau este subcategorie. Daca este root, este simplu, doar o atasam elementului <ul id="categories">. Daca este subcategorie va trebui sa cream o sublista (daca nu exista):


<li class="rootCategory" id="1">
    Laptops
    <!-- subcategoriile sunt <li>-uri in cadrul unei subliste <ul class="subcategory"> -->
    <ul class="subcategory">
        <li id="2">Laptops [no OS]</li>
        <li id="3">Laptops with Windows</li>
        <li id="4">Laptops with Linux</li>
    </ul>
</li>

Acum sa vedem cum s-a modificat functia createCategory():

// utils.js

const Utils = (function(){     // IIFE - vom avea un singur obiect global cu toate metodele

    function startReadingNodes(parentChildData, parentid) {
        for(let i in parentChildData) {
            // pornim cu nod-ul `null` si ajungem la frunze (cele mai imbricate nod-uri)
            if(parentChildData[i].parentid === parentid) {
                console.log(parentChildData[i]);
                createCategory(parentChildData[i].id, parentChildData[i].name, parentChildData[i].parentid);

                startReadingNodes(parentChildData, parentChildData[i].id)
            }
        }
    }

    // categoriile care au parentid = null
    // function createRootCategory(container, categoryName) {
  
    //     let th = document.createElement("li");
    //     let text = document.createTextNode(categoryName);
    //     th.appendChild(text);

    //     container.appendChild(th);
        
    // }

    function createCategory(categoryId, categoryName, parentid) {



        // root category - parentid == null
        if ( parentid === null ) // nu vrem coercion (adica nu vrem conversie implicita cu '=='; undefined == null este true)
        {
            // unde asezam noua categorie?
            let categoriesContainer = document.getElementById('categories'); 
            // id-ul 'categories' poate veni si ca parametru
            
            // cream nodul categoriei  
            let rootCategoryEl = createLi(categoryName);
            rootCategoryEl.classList.add('rootCategory'); 

            // este important sa atasam id-ul pe care il vom folosi cand cream subcategoriile
            rootCategoryEl.setAttribute('id', categoryId);

            categoriesContainer.appendChild(rootCategoryEl);
 
        } else // subcategorie - trebuie sa aflam care este nodul parinte
        {
            // de cine legam aceasta subcategorie?
            let parentCategoryEl = document.getElementById(parentid);
            
            // este prima subcategorie in DOM din cadrul acestei categorii?
            // daca da, avem nevoie sa cream si un nod <ul> pe langa 'textNode'
            let subcategoriesEl = parentCategoryEl.querySelector('ul');

            // cream o sublista daca nu exista deja
            if (!subcategoriesEl) {
                subcategoriesEl = createUl('subcategory');
                parentCategoryEl.appendChild(subcategoriesEl);
            }

            // pentru a crea in sfarsit item-ul categoriei apelam la functia `createLi`
            let subcategoryEl = createLi(categoryName);
            subcategoryEl.setAttribute('id', categoryId);

            // asezam subcategoria in DOM la locul potrivit pentru ea
            subcategoriesEl.appendChild(subcategoryEl);
        }
    }

    // ...

    return {
        startReadingNodes,
        createCategory
    }

}())


Rezutatul acestui cod in browser, ar trebui sa arate cam asa:

Parent child recursive html nested lists view


Adaugare interactivitate

Acum sa mai facem o imbunatatire codului. Vrem sa adaugam mecanismul de toggle categoriilor: daca o categorie are subcategorii, un click pe ea va ascunde subcategoriile. Un al doilea click le va afisa:

Toggle categories tree

Vom cauta toate elementele din lista si, daca au subcategorii, vom atasa un listener pentru evenimentul click.

Vom avea grija sa oprim propagarea evenimentului catre ancestori (bubbling). Cand apasam pe o subcategorie de pe nivelul 2, de exemplu, nu vrem ca acel click sa fie tratat si de parinte pentru ca va ajunge sa inchida toata ramura; adica categoria root, care are si el listener pentru evenimentul click, va rula codul de inchidere a subcategoriilor.

Vom intelege mai bine aceasta problem prin cod.

Adaugam interactivitate listei:


window.onload = function(){

    Utils.startReadingNodes(categories, null);

    // adaugare mecanism toggle
    document.querySelectorAll('#categories li')
        .forEach(function(categoryEl) {
                let subcategoriesEl = categoryEl.querySelector('ul');
                if (subcategoriesEl) {
                    categoryEl.addEventListener('click', function(ev) {
                        // nu vrem event bubbling 
                        // vrem ca evenimentul de click sa fie tratat doar pe target nu si de ancestorii lui
                        ev.stopPropagation(); 

                        if (subcategoriesEl.style.display !== 'none') {
                            subcategoriesEl.style.display = 'none';
                        } else {
                            subcategoriesEl.style.display = 'list-item'
                        } 
                    });
                } 
        })

} 

Comentati linia ev.stopPropagation(); si vedeti comportamentul atunci cand dati click pe o subcategorie (A1) care are la randul ei subcategorii. Ar trebui sa se inchida doar subcategoriile lui A1, insa este si ea la randul ei ascunda de catre categoria parinte.


Concluzie

In acest articol am transpus o structura de date ierarhice de tipul parent-child intr-o structura HTML de liste imbricate. Codul nostru functioneaza pe oricate nivele de imbricare pentru ca am folosi recursivitate si indiferent de date va ramane la fel de simplu.

Am adaugat si interactivitate de baza acestui tree, un simplu mecanism de toggle, unde trebuie sa fim atenti la propagarea evenimentului de la cel mai deep nod catre ancestorii acestuia (event bubbling). Va trebui sa oprim acest mecanism implicit si pentru asta am folosit ev.stopPropagation().

Am mai putea imbunatati mecanismul de toggle prin stilizarea diferita a nodurilor inchise fata de nodurile deschise, eventual putem sa adaugam si iconite + sau - in fata categoriilor care au subcategorii.


comments powered by Disqus