Интеграция Primefaces в приложение на Spring Boot. Часть 8 — Композитная форма для редактирования сложных данных

95bd17f404e69b0ecd1c6ac49965ac28.png

В шестой части статьи я уже приводил пример комбинирования двух компонентов на одной странице для вывода сложных данных и показал, какие трудности это может вызвать. Еще большую сложность представляет комбинирование компонентов для сохранения данных. Разберем один такой пример, чтобы показать хотя бы часть узких мест и приемов, как их можно обойти.

Итак, перед нами поставлена задача — написать форму для редактирования ролей сотрудника. По бизнес-логике у сотрудника предполагается наличие одной и только одной основной роли и нескольких дополнительных ролей в компании. Поставлено условие, чтобы все эти роли можно было выбрать или изменить на одной и той странице, назовем ее карточкой редактирования ролей. Кроме того, каждая роль, как основная, так и дополнительные имеют свой грейд, или рейтинг, для ее оценки. Выглядеть это должно вот таким образом в соответствии с выбранным для карточки дизайном:

e04e9336428c585c7400ba94c08216c9.png

Поскольку ситуация здесь особенно сложная, целесообразно сразу же привести полный код xhtml страницы вида и код управляемых бинов, используемых этой страницей, а уже потом разбирать детали по шагам. Таких бинов у меня будет два, так как на странице будут комбинироваться два компонента.

xhtml страница:





    
    
    
    




    
        
        
        
        
        Сател - реестр ресурсов
    

    
        
Редактирование ролей сотрудника

Основная роль

Дополнительные роли

Компонент employeeRolesSelectionEditableView — в этом бине фактически скомбинировано две реализации Primefaces компонента Tree Selection Single и Tree Selection Checkbox:


import jakarta.inject.Inject;
import lombok.Getter;
import lombok.Setter;
import lombok.extern.log4j.Log4j2;
import org.primefaces.PrimeFaces;
import org.primefaces.event.RateEvent;
import org.primefaces.model.TreeNode;
import org.satel.ressatel.bean.list.role.Role;
import org.satel.ressatel.entity.Employee;
import org.satel.ressatel.entity.Grade;
import org.satel.ressatel.service.EmployeeService;
import org.satel.ressatel.service.GradeService;
import org.satel.ressatel.service.RoleService;
import org.springframework.context.annotation.Scope;
import org.springframework.context.annotation.ScopedProxyMode;
import org.springframework.stereotype.Component;
import org.springframework.web.context.WebApplicationContext;

import javax.faces.context.ExternalContext;
import javax.faces.context.FacesContext;
import javax.faces.event.AjaxBehaviorEvent;
import java.io.IOException;
import java.util.*;
import java.util.stream.Collectors;

@Component("employeeRolesSelectionEditableView")
@Scope(value = WebApplicationContext.SCOPE_REQUEST, proxyMode = ScopedProxyMode.TARGET_CLASS)
@Getter
@Setter
public class EmployeeRolesSelectionEditableView {
    private String id;
    private EmployeeService employeeService;
    private RoleService roleService;
    private GradeService gradeService;
    private TreeNode[] selectedNodes;
    private TreeNode selectedNode;
    private TreeNode rootMain;
    private TreeNode rootExtra;
    private EmployeeRatingView employeeRatingView;

    @Inject
    public EmployeeRolesSelectionEditableView(EmployeeService employeeService, RoleService roleService, GradeService gradeService, EmployeeRatingView employeeRatingView) {
        this.employeeService = employeeService;
        this.roleService = roleService;
        this.gradeService = gradeService;
        this.employeeRatingView = employeeRatingView;
        init();
    }

    private void init() {
        rootMain = createCheckboxRoles();
        rootExtra = createCheckboxExtraRoles();
    }

    public void onload() {
        Employee employee = employeeService.getByStringId(id);
        Set mainRoles = employee.getRoles();
        Set idsMain = mainRoles.stream().map(org.satel.ressatel.entity.Role::getId).collect(Collectors.toSet());
        Set extraRoles = employee.getExtraRoles();
        Set idsExtra = extraRoles.stream().map(org.satel.ressatel.entity.Role::getId).collect(Collectors.toSet());
        Map mainRoleMap = roleService.getNameToMainRoleMap(employee);
        Map extraRoleMap = roleService.getNameToExtraRoleMap(employee);
        selectAndGradeNodes(rootMain, idsMain, mainRoleMap);
        selectAndGradeNodes(rootExtra, idsExtra, extraRoleMap);
    }

    public void onMainRate(RateEvent rateEvent) {
        Integer selectedRoleId =
                (Integer) rateEvent.getComponent().getParent().getChildren().get(0).getAttributes().get("role_id");
        unselectOther(rootMain, selectedRoleId);
        PrimeFaces.current().ajax().update(rateEvent.getComponent().getParent().getParent());
    }

    private void unselectOther(TreeNode rootMain, Integer selectedRoleId) {
        rootMain.getChildren().forEach(roleTreeNode ->  {
                roleTreeNode.setSelectable(true);
                roleTreeNode.setSelected(Objects.equals(roleTreeNode.getData().getId(), selectedRoleId));
            });
    }

    private void selectAndGradeNodes(TreeNode root, Set ids,
                                     Map roleMap) {
        root.setSelected(ids.contains(root.getData().getId()));
        Grade grade = roleMap.get(root.getData().getName());
        if (grade != null) {
            root.getData().setGrade(String.valueOf(grade.getId()));
        }
        if (root.getChildCount() != 0) {
            root.getChildren().forEach(roleTreeNode -> {
                selectAndGradeNodes(roleTreeNode, ids, roleMap);
            });
        }
    }

    private TreeNode createCheckboxRoles() {
        return roleService.getTreeNodeOfRoles();
    }

    private TreeNode createCheckboxExtraRoles() {
        // повтор вызова метода необходим, чтобы в дереве дополнительных ролей был отдельный объект TreeNode
        return roleService.getTreeNodeOfRoles();
    }

    public void onsubmitAll(TreeNode[] nodes) {
        String employeeId = FacesContext.getCurrentInstance().getExternalContext().getRequestParameterMap().get("employeeId");
        onsubmit(employeeId);
        onsubmitExtra(nodes, employeeId);
        ExternalContext context = FacesContext.getCurrentInstance().getExternalContext();
        try {
            context.redirect(context.getRequestContextPath() + "/");
        } catch (IOException e) {
            throw new RuntimeException(e);
        }
    }

    public void onsubmit(String employeeId) {
        Employee employee = employeeService.getByStringId(employeeId);
        if (selectedNode != null) {
            Set roles = new HashSet<>();
            Integer roleId = selectedNode.getData().getId();
            Integer gradeId =
                    selectedNode.getData().getGrade() == null ? 1 : Integer.parseInt(selectedNode.getData().getGrade());
            org.satel.ressatel.entity.Role role = roleService.getById(roleId);
            if (role != null) {
                roles.add(role);
            }
            employee.setRoles(roles);
            employeeService.createOrUpdateEmployee(employee);
            roleService.setGradeIdForEmployeeRole(employee.getId(), roleId, gradeId);
        } else {
            employee.setRoles(null);
            employeeService.createOrUpdateEmployee(employee);
        }
    }

    public void onsubmitExtra(TreeNode[] nodes, String employeeId) {
        Employee employee = employeeService.getByStringId(employeeId);
        if (nodes != null && nodes.length > 0) {
            Set roles = new HashSet<>();
            Map roleIdToGradeId = new HashMap<>();
            for (TreeNode node : nodes) {
                Integer roleId = node.getData().getId();
                org.satel.ressatel.entity.Role role = roleService.getById(roleId);
                Integer gradeId =
                        node.getData().getGrade() == null ? 1 : Integer.parseInt(node.getData().getGrade());
                if (role != null) {
                    roles.add(role);
                    roleIdToGradeId.put(roleId, gradeId);
                }
            }
            employee.setExtraRoles(roles);
            employeeService.createOrUpdateEmployee(employee);
            roleIdToGradeId.forEach((roleId, gradeId) -> {
                roleService.setGradeIdForEmployeeExtraRole(employee.getId(), roleId, gradeId);
            });
        } else {
            employee.setExtraRoles(null);
            employeeService.createOrUpdateEmployee(employee);
        }
    }
}

Компонент employeeRatingView

import jakarta.inject.Inject;
import lombok.Getter;
import lombok.Setter;
import lombok.extern.log4j.Log4j2;
import org.satel.ressatel.entity.Employee;
import org.satel.ressatel.entity.Grade;
import org.satel.ressatel.entity.Role;
import org.satel.ressatel.service.EmployeeService;
import org.satel.ressatel.service.RoleService;
import org.springframework.context.annotation.Scope;
import org.springframework.context.annotation.ScopedProxyMode;
import org.springframework.stereotype.Component;
import org.springframework.web.context.WebApplicationContext;

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

@Component("employeeRatingView")
@Scope(value = WebApplicationContext.SCOPE_REQUEST, proxyMode = ScopedProxyMode.TARGET_CLASS)
@Getter
@Setter
@Log4j2
public class EmployeeRatingView {
    private String id;
    private String mainRoleName;
    private Integer mainGradeId;
    private List mainRoles;
    private List> extraRoleEntryList;

    private final RoleService roleService;
    private final EmployeeService employeeService;
    private final EmployeeSkillRatingView employeeSkillRatingView;

    @Inject
    public EmployeeRatingView(RoleService roleService, EmployeeService employeeService, EmployeeSkillRatingView employeeSkillRatingView) {
        this.roleService = roleService;
        this.employeeService = employeeService;
        this.employeeSkillRatingView = employeeSkillRatingView;
        this.mainRoles = new ArrayList<>();
    }


    public void onload() {
        Employee employee = employeeService.getByStringId(id);
        if (!roleService.getMainRoleMap(employee).isEmpty()) {
            roleService.getMainRoleMap(employee).forEach((role1, grade1) -> {
                mainRoles.add(role1);
                mainRoleName = role1.getName();
                mainGradeId = grade1 == null ? null : grade1.getId();
            });
        }
        this.employeeSkillRatingView.setMainRoles(mainRoles);
        if (!roleService.getExtraRoleMap(employee).isEmpty()) {
            extraRoleEntryList = new ArrayList<>(roleService.getExtraRoleMap(employee).entrySet());
        }
    }

}

Использованные в коде управляемых бинов отсылки на методы других классов я намеренно не расшифровываю и не привожу здесь их описание, так как это относится к конкретной структуре данных моего приложения и не имеет значения для нашей темы, у вас это могут быть совсем другие данные, другие репозитории и сервисы. Просто пропустим их и рассмотрим самые существенные детали, важные для понимания именно того, как выполняется композиция нескольких компонентов на странице.

Прежде всего, нам необходимо получить и заполнить данными о текущем положении дел два однотипных компонента Tree Selection Single и Tree Selection Checkbox, фактически, это один и тот же компонент, но с различиями в настройке параметров. Первый из них забирает данные из поля бина private TreeNode rootMain и помещает их в визуальное древовидное представление. Именно древовидное, просто для моего случая бизнес-логики дерево имеет единичную глубину (без вложенности), но никто не мешает вам заполнить дерево данными для любого уровня вложенности — вы получите на странице раскрывающийся список селектов. Кроме того, первый компонент имеет специальный атрибут selectionMode="single", который означает, что вы можете выбрать только один элемент из дерева (в данном случае — одномерного списка), при изменении выбора селект с ранее выбранного элемента будет снят. Но это относится только к самому дереву. Если в каждый элемент списка мы будем добавлять какой-то дополнительный вложенный компонент, то могут понадобиться уже дополнительные усилия, чтобы обеспечить аналогичный функционал для вложенного компонента, как я и покажу чуть ниже.

Второй компонент забирает данные из поля бина private TreeNode rootExtra и заполняет второе дерево, но у него уже проставлен атрибут selectionMode="checkbox", в результате чего у каждого элемента появляется чекбокс и компонент позволяет выбрать несколько элементов из списка, причем здесь уже логично, что снять выделение с элемента можно уже только принудительно.

Здесь нужно особенно отметить, что хотя оба дерева/списка инициируются как будто бы из одного источника, но вызывается заполнение дублирующимся кодом намеренно — для того, чтобы в бине это были два РАЗНЫХ объекта, так как после инициации они будут заполняться разными данными и после изменения данных в форме вручную на странице сохраняться данные будут в разных местах. Они просто однотипные, что может немного запутать, и представляют собой список всех возможных ролей. Однако, сам компонент Tree Selection работает под капотом таким образом, что в этом дереве будут выбираться новые, измененные данные, и именно поэтому это должны быть два разных объекта TreeNode

private void init() {
        rootMain = createCheckboxRoles();
        rootExtra = createCheckboxExtraRoles();
    }
        ..........
private TreeNode createCheckboxRoles() {
        return roleService.getTreeNodeOfRoles();
        }

private TreeNode createCheckboxExtraRoles() {
        // повтор вызова метода необходим, чтобы в дереве дополнительных ролей был отдельный объект TreeNode
        return roleService.getTreeNodeOfRoles();
        }

После инициализации бина и вызова страницы оба дерева заполняются данными в методе onload из разных источников в БД, где хранятся текущие значения главной роли и дополнительных ролей.

Кроме основных данных, каждое дерево против каждого своего узла выводит не только роль, но и грейд роли в виде рейтинга со звездочками, здесь у меня это реализовано вложенным в каждый узел компонентом Primefaces Rating


    

..............................


Обратите внимание, что в первом компоненте я дополнительно вызываю в рейтинге обработчик события rate, которое срабатывает при выборе рейтинга в каком-либо из узлов. Как я уже написал выше, это связано с тем, что родительский компонент Tree Selection в режиме single умеет снять альтернативное выделение с узла, когда выбирается другой узел, но вложенный в узел компонент Rating этого делать не умеет. И поэтому нужно отловить событие rate выбора рейтинга и принудительно снять выделение с рейтингов в других узлах, что и делает метод onMainRate в бине employeeRolesSelectionEditableView.

Компонент employeeRatingView вспомогательный и выводит текущий рейтинг каждого узла при загрузке страницы из БД. Однако, он спроектирован так, чтобы не зависеть от планируемого в будущем изменения (предполагается, что кол-во основных ролей будет расширено и станет больше одной), фактический выбор единственной основной роли у меня вынесен в сервис, который я не показываю. Фактически в коде этого бина также комбинируется использование двух вариантов рейтинга на странице, а не одного, то есть это тоже комлексный управляемый бин.

Наконец, все данные на странице получены и отредактированы. Теперь нужно сохранить изменения. Я делаю это вызовом из кнопки комбинированного метода управляемого бина:


    

Здесь нужно обратить внимание на следующий момент: поскольку я использую в бинах область видимости @Scope(value = WebApplicationContext.SCOPE_REQUEST, proxyMode = ScopedProxyMode.TARGET_CLASS), то фактически при клике по кнопке «Сохранить» у меня будет вызван НОВЫЙ экземпляр управляемого бина, и соответственно, этот экземпляр был вызван НЕ строкой URL с параметрами, которые передаются в бин при первоначальной загрузке страницы. То есть вот в этом коде xhtml страницы корректно сработает только вызов методов onload, а в параметры id в бины будет передан null:


    
    
    
    

Здесь происходит обыкновенная потеря ожидаемого контекста. Поэтому, чтобы передать id в бины, я не только вызываю метод onsubmitAll, передавая в него выбранный набор отредактированных полей employeeRolesSelectionEditableView.selectedNodes, но и передаю дополнительный параметр employeeId, записанный в вид страницы при ее первичной загрузке:


И затем этот параметр используется вместо поля id в управляемом бине, чтобы получить id сотрудника, для которого было вызвано сохранение отредактированных данных формы.

Наконец, сам метод onsubmitAll внутри управляемого бина вызвает последовательно два метода onsubmit и onsubmitExtra, которые собирают и сохраняют в БД данные из двух разных деревьев по отдельности, после чего осуществляется принудительный переход в коде на главную страницу приложения:

public void onsubmitAll(TreeNode[] nodes) {
    String employeeId = FacesContext.getCurrentInstance().getExternalContext().getRequestParameterMap().get("employeeId");
    onsubmit(employeeId);
    onsubmitExtra(nodes, employeeId);
    ExternalContext context = FacesContext.getCurrentInstance().getExternalContext();
    try {
        context.redirect(context.getRequestContextPath() + "/");
    } catch (IOException e) {
        throw new RuntimeException(e);
    }
}

На этом интересном моменте цикл статей о Primefaces заканчиваю, мы насколько можно кратко изучили основные приемы работы с библиотекой Primefaces, интегрировав ее для удобства в приложение Spring. Еще раз напомню, что этот маленький квест может оказаться полезным прежде всего для тех разработчиков, которые релоцировались за пределы РФ, так как использование Jakarta EE + JSF + Primefaces достаточно широко распространены в продуктиве именно за границей. Все, что пропущено, можно найти в документации Primefaces, Jakarta EE и JSF. Удачных вам разработок!

В заключение приглашаю на бесплатный вебинар от OTUS, где рассмотрим экосистему технологий Java, спектр областей, которые обслуживает Java. Поговорим о том, какие компании активно используют Java в своих IT-продуктах. Посмотрим на географию компаний и карьерных предложений. Обоснуем верный выбор Java как профессионального стека для устойчивой карьеры.

© Habrahabr.ru