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