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

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

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

xhtml страница:

<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml"
      xmlns:h="http://xmlns.jcp.org/jsf/html"
      xmlns:f="http://xmlns.jcp.org/jsf/core"
      xmlns:p="http://primefaces.org/ui">

<f:metadata>
    <f:viewParam name="id" value="#{employeeRolesSelectionEditableView.id}"/>
    <f:viewAction action="#{employeeRolesSelectionEditableView.onload}"/>
    <f:viewParam name="id" value="#{employeeRolesSelectionEditableView.employeeRatingView.id}"/>
    <f:viewAction action="#{employeeRolesSelectionEditableView.employeeRatingView.onload}"/>
</f:metadata>

<f:view contentType="text/html;charset=UTF-8" encoding="UTF-8">

    <h:head>
        <h:outputStylesheet library="webjars" name="primeflex/3.2.0/primeflex.min.css"/>
        <h:outputStylesheet library="css" name="styles.css"/>
        <meta charset="utf-8"/>
        <meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1"/>
        <title>Сател - реестр ресурсов</title>
    </h:head>

    <h:body>
        <div class="card">
            <h:form>
                <p:dataTable lazy="false" emptyMessage="">
                    <f:facet name="header">
                        <span>Редактирование ролей сотрудника</span>
                    </f:facet>
                </p:dataTable>
            </h:form>
            <h:form>
                <p:growl id="msgs" showDetail="true" escape="false"/>
                <p:commandButton value="Сохранить" update="msgs" icon="pi pi-save" styleClass="mt-3" partialSubmit="true"
                                 action="#{employeeRolesSelectionEditableView.onsubmitAll(employeeRolesSelectionEditableView.selectedNodes)}">
                    <f:param name="employeeId" value="#{param['id']}"/>
                </p:commandButton>
                <p:button icon="pi pi-arrow-circle-left" styleClass="mt-3 ml-3"
                          href="/employee/#{employeeRolesSelectionEditableView.id}"
                          value="Отменить"/>
                <div class="grid ui-fluid">
                    <div class="col-12 md:col-6">
                        <h2>Основная роль</h2>
                        <p:tree value="#{employeeRolesSelectionEditableView.rootMain}" var="role"
                                selectionMode="single" cache="false"
                                selection="#{employeeRolesSelectionEditableView.selectedNode}">
                            <p:treeNode expandedIcon="pi pi-folder-open" collapsedIcon="pi pi-folder">
                                <h:outputText role_id="#{role.id}" value="#{role.name}"/>
                                <p:rating value="#{role.grade}" readonly="false" stars="6">
                                    <p:ajax event="rate" listener="#{employeeRolesSelectionEditableView.onMainRate}"/>
                                </p:rating>
                            </p:treeNode>
                        </p:tree>
                    </div>
                    <div class="col-12 md:col-6">
                        <h2>Дополнительные роли</h2>
                        <p:tree value="#{employeeRolesSelectionEditableView.rootExtra}" var="role"
                                selectionMode="checkbox" cache="false"
                                selection="#{employeeRolesSelectionEditableView.selectedNodes}">
                            <p:treeNode expandedIcon="pi pi-folder-open" collapsedIcon="pi pi-folder">
                                <h:outputText role_id="#{role.id}" value="#{role.name}"/>
                                <p:rating value="#{role.grade}" readonly="false" stars="6">
                                </p:rating>
                            </p:treeNode>
                        </p:tree>
                    </div>
                </div>

                <p:commandButton value="Сохранить" update="msgs" icon="pi pi-save" styleClass="mt-3" partialSubmit="true"
                                 action="#{employeeRolesSelectionEditableView.onsubmitAll(employeeRolesSelectionEditableView.selectedNodes)}">
                    <f:param name="employeeId" value="#{param['id']}"/>
                </p:commandButton>
                <p:button icon="pi pi-arrow-circle-left" styleClass="mt-3 ml-3"
                          href="/employee/#{employeeRolesSelectionEditableView.id}"
                          value="Отменить"/>
            </h:form>
        </div>
    </h:body>

</f:view>

</html>

Компонент 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<Role>[] selectedNodes;
    private TreeNode<Role> selectedNode;
    private TreeNode<Role> rootMain;
    private TreeNode<Role> 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<org.satel.ressatel.entity.Role> mainRoles = employee.getRoles();
        Set<Integer> idsMain = mainRoles.stream().map(org.satel.ressatel.entity.Role::getId).collect(Collectors.toSet());
        Set<org.satel.ressatel.entity.Role> extraRoles = employee.getExtraRoles();
        Set<Integer> idsExtra = extraRoles.stream().map(org.satel.ressatel.entity.Role::getId).collect(Collectors.toSet());
        Map<String, Grade> mainRoleMap = roleService.getNameToMainRoleMap(employee);
        Map<String, Grade> extraRoleMap = roleService.getNameToExtraRoleMap(employee);
        selectAndGradeNodes(rootMain, idsMain, mainRoleMap);
        selectAndGradeNodes(rootExtra, idsExtra, extraRoleMap);
    }

    public void onMainRate(RateEvent<String> 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<Role> rootMain, Integer selectedRoleId) {
        rootMain.getChildren().forEach(roleTreeNode ->  {
                roleTreeNode.setSelectable(true);
                roleTreeNode.setSelected(Objects.equals(roleTreeNode.getData().getId(), selectedRoleId));
            });
    }

    private void selectAndGradeNodes(TreeNode<Role> root, Set<Integer> ids,
                                     Map<String, Grade> 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<Role> createCheckboxRoles() {
        return roleService.getTreeNodeOfRoles();
    }

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

    public void onsubmitAll(TreeNode<Role>[] 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<org.satel.ressatel.entity.Role> 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<Role>[] nodes, String employeeId) {
        Employee employee = employeeService.getByStringId(employeeId);
        if (nodes != null && nodes.length > 0) {
            Set<org.satel.ressatel.entity.Role> roles = new HashSet<>();
            Map<Integer, Integer> roleIdToGradeId = new HashMap<>();
            for (TreeNode<Role> 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<Role> mainRoles;
    private List<Map.Entry<Role, Grade>> 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<Role> rootMain и помещает их в визуальное древовидное представление. Именно древовидное, просто для моего случая бизнес-логики дерево имеет единичную глубину (без вложенности), но никто не мешает вам заполнить дерево данными для любого уровня вложенности - вы получите на странице раскрывающийся список селектов. Кроме того, первый компонент имеет специальный атрибут selectionMode="single", который означает, что вы можете выбрать только один элемент из дерева (в данном случае - одномерного списка), при изменении выбора селект с ранее выбранного элемента будет снят. Но это относится только к самому дереву. Если в каждый элемент списка мы будем добавлять какой-то дополнительный вложенный компонент, то могут понадобиться уже дополнительные усилия, чтобы обеспечить аналогичный функционал для вложенного компонента, как я и покажу чуть ниже.

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

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

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

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

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

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

<p:rating value="#{role.grade}" readonly="false" stars="6">
    <p:ajax event="rate" listener="#{employeeRolesSelectionEditableView.onMainRate}"/>
</p:rating>
..............................
<p:rating value="#{role.grade}" readonly="false" stars="6">
</p:rating>

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

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

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

<p:commandButton value="Сохранить" update="msgs" icon="pi pi-save" styleClass="mt-3" partialSubmit="true"
             action="#{employeeRolesSelectionEditableView.onsubmitAll(employeeRolesSelectionEditableView.selectedNodes)}">
    <f:param name="employeeId" value="#{param['id']}"/>
</p:commandButton>

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

<f:metadata>
    <f:viewParam name="id" value="#{employeeRolesSelectionEditableView.id}"/>
    <f:viewAction action="#{employeeRolesSelectionEditableView.onload}"/>
    <f:viewParam name="id" value="#{employeeRolesSelectionEditableView.employeeRatingView.id}"/>
    <f:viewAction action="#{employeeRolesSelectionEditableView.employeeRatingView.onload}"/>
</f:metadata>

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

<f:param name="employeeId" value="#{param['id']}"/>

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

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

public void onsubmitAll(TreeNode<Role>[] 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 как профессионального стека для устойчивой карьеры.

Комментарии (0)