Spring Boot (5) Bootstrap 5 multilevel dropdown menu

 0. Introduction

This post is based on 

Frontend Paathshala

bootstrap-menu.com

ByteGrad


These components are used 

  1. 2 classes for defining menus (including the configuration file for reading the YAML file)
  2. A YAML file for storing the menu definition
  3. A controller of the URL used by the menu
  4. A ThymeLeaf file and 2 fragments
  5. CSS file

1. The Menu classes (and the configuration class)

The menu class has the component of a menu, indicating the name, the href and so on

package ximo.menus;

import java.util.ArrayList;
import java.util.List;

import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;

@Getter @Setter @AllArgsConstructor @NoArgsConstructor
public class Menu {
    private String id;
    private String name;
    private List<String> idFills;
    private String href="#";
    private List<Menu> fills=new ArrayList<Menu>();
    
    public boolean hasChildren() {
        if (fills==null) return false;
        return fills.size()>0;
    }
}

The configuration class for reading the YAML uses annotations for localizing the YAML file and a prefix that must have the YAML file (in this case the prefix is "menus")

package ximo.menus;

import java.util.List;
import java.util.stream.Collectors;

import org.springframework.beans.factory.InitializingBean;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.PropertySource;

import lombok.Getter;
import lombok.Setter;
import ximo.yaml.YamlPropertySourceFactory;

/**
 * Reads the file resources/menus/negociats.yaml into a list of Negociat
 * @author eduard
 *
 */
@Configuration
@ConfigurationProperties(prefix = "menus") //OJO: Debe coincidir con el comienzo del fichero de propiedades !!!!
@PropertySource(value = "classpath:menus/menus.yml", factory = YamlPropertySourceFactory.class)
@Getter @Setter //@NoArgsConstructor @AllArgsConstructor
public class Menus implements InitializingBean{
    private List<Menu> items;

    /**
     * Fills the children list 
     */
    @Override
    public void afterPropertiesSet() throws Exception {
        for (Menu menu: items) {
            if (menu.getIdFills()!=null) {
                List<Menu> lstChild=
                    items.stream()
                        .filter(mnu -> menu.getIdFills().contains(mnu.getId()))
                        .collect(Collectors.toList());        
                menu.setFills(lstChild);
            }
        }
    }
    /**
     * list the menus whose id<100
     * @return
     */
    public List<Menu> getNegociats () {
        List<Menu> lstNegociats=
                items.stream()
                    .filter(mnu-> mnu.getId().trim().length()==1)
                    .collect(Collectors.toList());
        return lstNegociats;
    }
}

2. The YAML file

This file is stored in the resources/menus/menus.yml file
Note the prefix "menus" in the beginning of the file that must match with the @ConfigurationProperties (prefix="menus").
Note also that the nodes that have children do not have "href" property,

menus:
  items:
  #--- Negociats: Nivell 0
    - { id: 1, name: Secretaria,     idFills: [1-1, 1-2] } 
    - { id: 2, name: Estadística,    idFills: [1-1-1, 1-1-2] } 
    - { id: 3, name: RRHH }
    - { id: 4, name: Serveis }
    - { id: 5, name: Urbanisme }
    - { id: 6, name: Àrea Econòmica }
    - { id: 7, name: Policia }
    #--------  Menus nivel>0
    - { id: 1-1,     name: Format ENI,             idFills: [1-1-1, 1-1-2] }
    - { id: 1-1-1,   name: Sedipualba-ENI,         idFills: [1-1-1-1, 1-1-1-2, 1-1-1-3, 1-1-1-4] }
    - { id: 1-1-1-1, name: 1-Generar documents ENI,          href: /process01 }
    - { id: 1-1-1-2, name: 2-Generar expedient ENI,          href: '/process02?APPLE=apple&BANANA=banana' }
    - { id: 1-1-1-3, name: 3-Signar Contingut expedient ENI, href: '/process03?field={"name":"myname", "value":"myvalue"}&field={"name":"year","value":"2023","component":"PASSWORD"}' }
    - { id: 1-1-1-4, name: 4-Signar PDF index,               href: '/process04?process=Process01&field={"name":"myname", "value":"myvalue"}&field={"name":"year","value":"2023","component":"PASSWORD"}' }
    - { id: 1-1-2,   name: Gexflow-ENI,            idFills: [1-1-2-1, 1-1-2-2, 1-1-2-3] }
    - { id: 1-1-2-1, name: 1-Generar doc i expedients ENI,    href: /signExpENI01 }
    - { id: 1-1-2-2, name: 2-Signar Contingut expedient ENI,  href: /signExpENI01 } 
    - { id: 1-1-2-3, name: 3-Signar PDF index,                href: /signExpENI01 } 
    - { id: 1-2,     name: proves,                 idFills: [1-2-1] }
    - { id: 1-2-1,   name: subproves,              idFills: [1-2-1-1] }
    - { id: 1-2-1-1, name: sub-subproves,          idFills: [1-2-1-1-1] }
    - { id: 1-2-1-1-1, name: sub-sub-subproves,               href: '/process03?field={"name":"myname", "value":"myvalue"}&field={"name":"year","value":"2023","component":"PASSWORD"}' }


3. The controller 


Simply accepts the general URL "/" and passes the menu structure to the model that is referenced by the Thymeleaf template home_page.html

package ximo.controllers;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;

import ximo.menus.Menus;

import jakarta.servlet.http.HttpServletRequest;

@Controller
public class MenuController2 {
    
    @Autowired
    private Menus menus;
        
    @GetMapping("/") 
    public String home(HttpServletRequest request, Model model) {
        
        model.addAttribute("negociats", menus.getNegociats());
        return "home_page";
    }
}


4. The Thymeleaf template and fragments

The Thymeleaf template "home_page.html" is. Only note the use of multilevel2.css and the inclusion of the fragment navbar

<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
  <head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <title>Edu01 app</title>
    
    <link th:rel="stylesheet" th:href="@{/webjars/bootstrap/5.2.3/css/bootstrap.min.css}" />
    <script th:src="@{/webjars/bootstrap/js/bootstrap.bundle.min.js}"></script>

    <!-- multilevel -->
    <link rel="stylesheet" type="text/css" href="/css/edu/multilevel2.css">    
    
  </head>

  <body>

    <div th:replace="~{fragments/navbar :: navbar}">
    hola
    </div>    
    
  </body>
</html>


2 fragments are used and both are in the file fragments/navbar.html 
Note that the last fragment subnode(node) accepts one parameter and is recursive. See Faraj Farook
Note the green part that differentiates nodes with children(submenus without an action -href- and nodes without children that represent an action with -href-
For executing an action, the last post has details about it.

<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
  <head>
    <meta charset="UTF-8">
    <link th:rel="stylesheet" th:href="@{/webjars/bootstrap/5.2.3/css/bootstrap.min.css}" />
    <!--Bootstrap JS-->
    <script th:src="@{/webjars/bootstrap/js/bootstrap.bundle.min.js}"></script>
  </head>  

  <body>
    <nav class="navbar navbar-expand-lg bg-light"  th:fragment="navbar">
      
    <div class="container-fluid">
      <a class="navbar-brand" href="#">Menú dUtilitats</a>
      <button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarScroll" aria-controls="navbarScroll" aria-expanded="false" aria-label="Toggle navigation">
        <span class="navbar-toggler-icon"></span>
      </button>
      <div class="collapse navbar-collapse" id="navbarScroll">
        <div th:each="negociat : ${negociats}" class="navbar-nav me-auto my-2 my-lg-0 navbar-nav-scroll" data-bs-toggle="outside" style="--bs-scroll-height: 100px;">
          <div class="nav-item dropdown">
            <a th:if="${negociat.hasChildren()}" th:text="${negociat.name}" class="nav-link dropdown-toggle" data-bs-auto-close="outside" href="#" role="button" data-bs-toggle="dropdown" aria-expanded="false">
              negociats  
            </a>
            <a th:if="${!negociat.hasChildren()}" th:text="${negociat.name}" class="nav-link" th:href="${negociat.href}" role="button" aria-expanded="false">
              negociats  
            </a>
            
            
            <!-- <ul th:each="menu : ${negociat.fills}" class="dropdown-menu"> -->
            <div class="dropdown-menu dropend" >
              <div th:each="menu : ${negociat.fills}">
                <a th:if="${menu.hasChildren()}" th:text="${menu.name}" class="dropdown-item dropdown-toggle" data-bs-toggle="dropdown" href="#">
                  menu 
                </a>
                <a th:if="${!menu.hasChildren()}" th:text="${menu.name}" class="dropdown-item" th:href="${menu.href}">
                  menu 
                </a>
                
                <!-- =================================== -->
                <div th:replace="~{fragments/navbar :: submenu(node=${menu})}">
                  hola
                </div>
                <!-- =================================== -->

              </div>  
            </div>
            
          </div>
        </div>
        
      </div>
    </div>
  </nav>
  
  <!-- ======================================================== -->
  <div class="dropdown-menu submenu"  th:fragment="submenu(node)" th:unless="${!node.hasChildren()}">
    <div th:each="child : ${node.fills}" th:inline="text">
      <a th:if="${child.hasChildren()}"  th:text="${child.name}" class="dropdown-item dropdown-toggle" data-bs-toggle="dropdown" href="#">
        submenu 
      </a>
      <a th:if="${!child.hasChildren()}" th:text="${child.name}"  class="dropdown-item" th:href="${child.href}">
        submenu 
      </a>
      <div th:replace="this::submenu(${child})"></div>
    </div>
  </div>

  </body>
</html>


5. CSS File

Note the selectors "." ">" ":" "::after"
This file is 

.dropdown div {
    position: relative;
}

.dropdown-menu .submenu {
    
    display: none;
    position: absolute;
    left: 100%;
    top: -0px;
}


.dropdown-menu>div:hover>.submenu {
    display: block;
}

/* ============ small devices ============ */
@media (max-width: 991px) {

    .dropdown-item::after {
        transform: rotate(90deg);
    }
}    
/* ============ small devices .end// ============ */

6. Result





Comentarios

Entradas populares de este blog

SpringBoot (14) Let's start (2/10). Defining users

SpringBoot (6) Spring Data JPA (1)