Интеграция Primefaces в приложение на Spring Boot. Часть 8 — Композитная форма для редактирования сложных данных
В шестой части статьи я уже приводил пример комбинирования двух компонентов на одной странице для вывода сложных данных и показал, какие трудности это может вызвать. Еще большую сложность представляет комбинирование компонентов для сохранения данных. Разберем один такой пример, чтобы показать хотя бы часть узких мест и приемов, как их можно обойти.
Итак, перед нами поставлена задача — написать форму для редактирования ролей сотрудника. По бизнес-логике у сотрудника предполагается наличие одной и только одной основной роли и нескольких дополнительных ролей в компании. Поставлено условие, чтобы все эти роли можно было выбрать или изменить на одной и той странице, назовем ее карточкой редактирования ролей. Кроме того, каждая роль, как основная, так и дополнительные имеют свой грейд, или рейтинг, для ее оценки. Выглядеть это должно вот таким образом в соответствии с выбранным для карточки дизайном:
Поскольку ситуация здесь особенно сложная, целесообразно сразу же привести полный код 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
и помещает их в визуальное древовидное представление. Именно древовидное, просто для моего случая бизнес-логики дерево имеет единичную глубину (без вложенности), но никто не мешает вам заполнить дерево данными для любого уровня вложенности — вы получите на странице раскрывающийся список селектов. Кроме того, первый компонент имеет специальный атрибут selectionMode="single"
, который означает, что вы можете выбрать только один элемент из дерева (в данном случае — одномерного списка), при изменении выбора селект с ранее выбранного элемента будет снят. Но это относится только к самому дереву. Если в каждый элемент списка мы будем добавлять какой-то дополнительный вложенный компонент, то могут понадобиться уже дополнительные усилия, чтобы обеспечить аналогичный функционал для вложенного компонента, как я и покажу чуть ниже.
Второй компонент забирает данные из поля бина private TreeNode
и заполняет второе дерево, но у него уже проставлен атрибут 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 как профессионального стека для устойчивой карьеры.