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

Поскольку ситуация здесь особенно сложная, целесообразно сразу же привести полный код 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 как профессионального стека для устойчивой карьеры.
