Basic group login and other major improvements
This commit is contained in:
parent
a5695ccab6
commit
bc71a87414
@ -4,13 +4,12 @@ package ru.tubryansk.tdms;
|
|||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.springframework.boot.SpringApplication;
|
import org.springframework.boot.SpringApplication;
|
||||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||||
import org.springframework.context.ConfigurableApplicationContext;
|
|
||||||
|
|
||||||
|
|
||||||
@SpringBootApplication
|
@SpringBootApplication
|
||||||
@Slf4j
|
@Slf4j
|
||||||
public class TdmsApplication {
|
public class TdmsApplication {
|
||||||
public static void main(String[] args) {
|
public static void main(String[] args) {
|
||||||
SpringApplication.run(TdmsApplication.class, args);
|
SpringApplication.run(TdmsApplication.class, args).start();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -43,7 +43,7 @@ public class SecurityConfiguration {
|
|||||||
.cors(a -> a.configurationSource(cors))
|
.cors(a -> a.configurationSource(cors))
|
||||||
.authenticationManager(authenticationManager)
|
.authenticationManager(authenticationManager)
|
||||||
.sessionManagement(cfg -> {
|
.sessionManagement(cfg -> {
|
||||||
cfg.sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED);
|
cfg.sessionCreationPolicy(SessionCreationPolicy.NEVER);
|
||||||
cfg.maximumSessions(1);
|
cfg.maximumSessions(1);
|
||||||
})
|
})
|
||||||
.build();
|
.build();
|
||||||
@ -57,20 +57,19 @@ public class SecurityConfiguration {
|
|||||||
@Value("${application.protocol}") String protocol,
|
@Value("${application.protocol}") String protocol,
|
||||||
Environment environment
|
Environment environment
|
||||||
) {
|
) {
|
||||||
return request -> {
|
CorsConfiguration corsConfiguration = new CorsConfiguration();
|
||||||
String url = StringUtils.join(protocol, "://", domain, ":", port);
|
corsConfiguration.setAllowedMethods(List.of(HttpMethod.GET.name(), HttpMethod.POST.name(), HttpMethod.OPTIONS.name()));
|
||||||
CorsConfiguration corsConfiguration = new CorsConfiguration();
|
corsConfiguration.setAllowedHeaders(List.of("Authorization", "Content-Type"));
|
||||||
corsConfiguration.setMaxAge(Duration.ofDays(1));
|
corsConfiguration.setAllowCredentials(true);
|
||||||
corsConfiguration.addAllowedOrigin(url);
|
corsConfiguration.setMaxAge(Duration.ofDays(1));
|
||||||
if (environment.matchesProfiles("dev")) {
|
corsConfiguration.addAllowedOrigin(StringUtils.join(protocol, "://", domain, ":", port));
|
||||||
corsConfiguration.addAllowedOrigin("http://localhost:8081");
|
if (environment.matchesProfiles("dev")) {
|
||||||
}
|
corsConfiguration.addAllowedOrigin("http://localhost:8888");
|
||||||
|
}
|
||||||
corsConfiguration.setAllowedMethods(List.of(HttpMethod.GET.name(), HttpMethod.POST.name(), HttpMethod.OPTIONS.name()));
|
log.info("CORS configuration: [headers: {}, methods: {}, origins: {}, credentials: {}, maxAge: {}]",
|
||||||
corsConfiguration.setAllowedHeaders(List.of("Authorization", "Content-Type"));
|
corsConfiguration.getAllowedHeaders(), corsConfiguration.getAllowedMethods(), corsConfiguration.getAllowedOrigins(),
|
||||||
corsConfiguration.setAllowCredentials(true);
|
corsConfiguration.getAllowCredentials(), corsConfiguration.getMaxAge());
|
||||||
return corsConfiguration;
|
return request -> corsConfiguration;
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Bean
|
@Bean
|
||||||
@ -96,8 +95,8 @@ public class SecurityConfiguration {
|
|||||||
/* StudentController */
|
/* StudentController */
|
||||||
httpAuthorization.requestMatchers("/api/v1/student/current").permitAll();
|
httpAuthorization.requestMatchers("/api/v1/student/current").permitAll();
|
||||||
/* GroupController */
|
/* GroupController */
|
||||||
httpAuthorization.requestMatchers("/api/v1/group/get-all").permitAll();
|
httpAuthorization.requestMatchers("/api/v1/group/get-all-groups").permitAll();
|
||||||
httpAuthorization.requestMatchers("api/v1/group/create-group").hasAuthority("ROLE_ADMINISTRATOR");
|
httpAuthorization.requestMatchers("/api/v1/group/create-group").hasAuthority("ROLE_ADMINISTRATOR");
|
||||||
/* deny all other api requests */
|
/* deny all other api requests */
|
||||||
httpAuthorization.requestMatchers("/api/**").denyAll();
|
httpAuthorization.requestMatchers("/api/**").denyAll();
|
||||||
/* since api already blocked, all other requests are static resources */
|
/* since api already blocked, all other requests are static resources */
|
||||||
|
|||||||
@ -3,8 +3,9 @@ package ru.tubryansk.tdms.controller;
|
|||||||
import jakarta.validation.Valid;
|
import jakarta.validation.Valid;
|
||||||
import org.springframework.beans.factory.annotation.Autowired;
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
import org.springframework.web.bind.annotation.*;
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
import ru.tubryansk.tdms.controller.payload.GroupCreateDTO;
|
||||||
import ru.tubryansk.tdms.controller.payload.GroupDTO;
|
import ru.tubryansk.tdms.controller.payload.GroupDTO;
|
||||||
import ru.tubryansk.tdms.controller.payload.GroupRegistrationDTO;
|
import ru.tubryansk.tdms.controller.payload.GroupEditDTO;
|
||||||
import ru.tubryansk.tdms.service.GroupService;
|
import ru.tubryansk.tdms.service.GroupService;
|
||||||
|
|
||||||
import java.util.Collection;
|
import java.util.Collection;
|
||||||
@ -21,7 +22,12 @@ public class GroupController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping("/create-group")
|
@PostMapping("/create-group")
|
||||||
public void createGroup(@RequestBody @Valid GroupRegistrationDTO groupRegistrationDTO) {
|
public void createGroup(@RequestBody @Valid GroupCreateDTO groupCreateDTO) {
|
||||||
groupService.createGroup(groupRegistrationDTO.getName());
|
groupService.createGroup(groupCreateDTO.getName());
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/edit-group")
|
||||||
|
public void editGroup(@RequestBody @Valid GroupEditDTO groupEditDTO) {
|
||||||
|
groupService.editGroup(groupEditDTO);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -36,7 +36,7 @@ public class UserController {
|
|||||||
|
|
||||||
@PostMapping("/login")
|
@PostMapping("/login")
|
||||||
public void login(@RequestBody @Valid LoginDTO loginDTO) {
|
public void login(@RequestBody @Valid LoginDTO loginDTO) {
|
||||||
authenticationService.login(loginDTO.getUsername(), loginDTO.getPassword());
|
authenticationService.login(loginDTO.getLogin(), loginDTO.getPassword());
|
||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping("/register")
|
@PostMapping("/register")
|
||||||
|
|||||||
@ -6,7 +6,7 @@ import jakarta.validation.constraints.Size;
|
|||||||
import lombok.Getter;
|
import lombok.Getter;
|
||||||
|
|
||||||
@Getter
|
@Getter
|
||||||
public class GroupRegistrationDTO {
|
public class GroupCreateDTO {
|
||||||
@NotEmpty(message = "Имя группы не может быть пустым")
|
@NotEmpty(message = "Имя группы не может быть пустым")
|
||||||
@Size(min = 3, max = 50, message = "Имя группы должно быть от 3 до 50 символов")
|
@Size(min = 3, max = 50, message = "Имя группы должно быть от 3 до 50 символов")
|
||||||
@Pattern(regexp = "^[а-яА-ЯёЁ0-9_-]*$", message = "Имя группы должно содержать только русские буквы, дефис, нижнее подчеркивание и цифры")
|
@Pattern(regexp = "^[а-яА-ЯёЁ0-9_-]*$", message = "Имя группы должно содержать только русские буквы, дефис, нижнее подчеркивание и цифры")
|
||||||
@ -8,6 +8,7 @@ import lombok.ToString;
|
|||||||
@Setter
|
@Setter
|
||||||
@ToString
|
@ToString
|
||||||
public class GroupDTO {
|
public class GroupDTO {
|
||||||
|
private Long id;
|
||||||
private String name;
|
private String name;
|
||||||
private String curatorName;
|
private String curatorName;
|
||||||
private Boolean iAmCurator;
|
private Boolean iAmCurator;
|
||||||
|
|||||||
@ -0,0 +1,17 @@
|
|||||||
|
package ru.tubryansk.tdms.controller.payload;
|
||||||
|
|
||||||
|
import jakarta.validation.constraints.*;
|
||||||
|
import lombok.Getter;
|
||||||
|
import lombok.ToString;
|
||||||
|
|
||||||
|
@Getter
|
||||||
|
@ToString
|
||||||
|
public class GroupEditDTO {
|
||||||
|
@NotNull(message = "Идентификатор группы не может быть пустым")
|
||||||
|
@Min(value = 1, message = "Идентификатор группы должен быть больше 0")
|
||||||
|
private Long id;
|
||||||
|
@NotEmpty(message = "Имя группы не может быть пустым")
|
||||||
|
@Size(min = 3, max = 50, message = "Имя группы должно быть от 3 до 50 символов")
|
||||||
|
@Pattern(regexp = "^[а-яА-ЯёЁ0-9_-]*$", message = "Имя группы должно содержать только русские буквы, дефис, нижнее подчеркивание и цифры")
|
||||||
|
private String name;
|
||||||
|
}
|
||||||
@ -1,12 +1,18 @@
|
|||||||
package ru.tubryansk.tdms.controller.payload;
|
package ru.tubryansk.tdms.controller.payload;
|
||||||
|
|
||||||
import jakarta.validation.constraints.NotEmpty;
|
import jakarta.validation.constraints.NotEmpty;
|
||||||
|
import jakarta.validation.constraints.Pattern;
|
||||||
|
import jakarta.validation.constraints.Size;
|
||||||
import lombok.Getter;
|
import lombok.Getter;
|
||||||
|
|
||||||
@Getter
|
@Getter
|
||||||
public class LoginDTO {
|
public class LoginDTO {
|
||||||
@NotEmpty(message = "Логин не может быть пустым")
|
@NotEmpty(message = "Логин не может быть пустым")
|
||||||
private String username;
|
@Pattern(regexp = "^[a-zA-Z0-9_]+$", message = "Логин должен содержать только латинские буквы, цифры и знак подчеркивания")
|
||||||
|
@Size(min = 5, message = "Логин должен содержать минимум 5 символов")
|
||||||
|
@Size(max = 32, message = "Логин должен содержать максимум 32 символов")
|
||||||
|
private String login;
|
||||||
@NotEmpty(message = "Пароль не может быть пустым")
|
@NotEmpty(message = "Пароль не может быть пустым")
|
||||||
|
@Pattern(regexp = "^(?=.*[a-z])(?=.*[A-Z])(?=.*\\d)[a-zA-Z\\d]{8,}$", message = "Пароль должен содержать хотя бы одну цифру, одну заглавную и одну строчную букву, минимум 8 символов")
|
||||||
private String password;
|
private String password;
|
||||||
}
|
}
|
||||||
@ -3,6 +3,7 @@ package ru.tubryansk.tdms.controller.payload;
|
|||||||
|
|
||||||
import com.fasterxml.jackson.annotation.JsonInclude;
|
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||||
import lombok.Builder;
|
import lombok.Builder;
|
||||||
|
import org.springframework.security.core.GrantedAuthority;
|
||||||
import ru.tubryansk.tdms.entity.User;
|
import ru.tubryansk.tdms.entity.User;
|
||||||
|
|
||||||
import java.time.ZonedDateTime;
|
import java.time.ZonedDateTime;
|
||||||
@ -19,8 +20,8 @@ public record UserDTO(
|
|||||||
String phone,
|
String phone,
|
||||||
ZonedDateTime createdAt,
|
ZonedDateTime createdAt,
|
||||||
ZonedDateTime updatedAt,
|
ZonedDateTime updatedAt,
|
||||||
List<RoleDTO> authorities) {
|
List<String> authorities
|
||||||
|
) {
|
||||||
public static UserDTO unauthenticated() {
|
public static UserDTO unauthenticated() {
|
||||||
return UserDTO.builder()
|
return UserDTO.builder()
|
||||||
.authenticated(false)
|
.authenticated(false)
|
||||||
@ -36,7 +37,7 @@ public record UserDTO(
|
|||||||
.phone(user.getNumberPhone())
|
.phone(user.getNumberPhone())
|
||||||
.createdAt(user.getCreatedAt())
|
.createdAt(user.getCreatedAt())
|
||||||
.updatedAt(user.getUpdatedAt())
|
.updatedAt(user.getUpdatedAt())
|
||||||
.authorities(RoleDTO.from(user))
|
.authorities(user.getAuthorities().stream().map(GrantedAuthority::getAuthority).toList())
|
||||||
.build();
|
.build();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4,6 +4,7 @@ import jakarta.servlet.http.HttpServletResponse;
|
|||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.springframework.context.support.DefaultMessageSourceResolvable;
|
import org.springframework.context.support.DefaultMessageSourceResolvable;
|
||||||
import org.springframework.http.HttpStatus;
|
import org.springframework.http.HttpStatus;
|
||||||
|
import org.springframework.security.core.AuthenticationException;
|
||||||
import org.springframework.validation.BindException;
|
import org.springframework.validation.BindException;
|
||||||
import org.springframework.web.bind.annotation.ExceptionHandler;
|
import org.springframework.web.bind.annotation.ExceptionHandler;
|
||||||
import org.springframework.web.bind.annotation.ResponseStatus;
|
import org.springframework.web.bind.annotation.ResponseStatus;
|
||||||
@ -11,7 +12,7 @@ import org.springframework.web.bind.annotation.RestControllerAdvice;
|
|||||||
import org.springframework.web.servlet.resource.NoResourceFoundException;
|
import org.springframework.web.servlet.resource.NoResourceFoundException;
|
||||||
import ru.tubryansk.tdms.controller.payload.ErrorResponse;
|
import ru.tubryansk.tdms.controller.payload.ErrorResponse;
|
||||||
|
|
||||||
import java.util.Random;
|
import java.util.UUID;
|
||||||
import java.util.stream.Collectors;
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
@RestControllerAdvice
|
@RestControllerAdvice
|
||||||
@ -23,13 +24,13 @@ public class GlobalExceptionHandler {
|
|||||||
log.debug("Validation error: {}", e.getMessage());
|
log.debug("Validation error: {}", e.getMessage());
|
||||||
String validationErrors = e.getAllErrors().stream()
|
String validationErrors = e.getAllErrors().stream()
|
||||||
.map(DefaultMessageSourceResolvable::getDefaultMessage)
|
.map(DefaultMessageSourceResolvable::getDefaultMessage)
|
||||||
.collect(Collectors.joining(", "));
|
.collect(Collectors.joining("\n"));
|
||||||
return new ErrorResponse(validationErrors, ErrorResponse.ErrorCode.VALIDATION_ERROR);
|
return new ErrorResponse(validationErrors, ErrorResponse.ErrorCode.VALIDATION_ERROR);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ExceptionHandler(BusinessException.class)
|
@ExceptionHandler(BusinessException.class)
|
||||||
public ErrorResponse handleBusinessException(BusinessException e, HttpServletResponse response) {
|
public ErrorResponse handleBusinessException(BusinessException e, HttpServletResponse response) {
|
||||||
log.info("Business error", e);
|
log.info("Business error: {}", e.getMessage());
|
||||||
response.setStatus(e.getErrorCode().getHttpStatus().value());
|
response.setStatus(e.getErrorCode().getHttpStatus().value());
|
||||||
return new ErrorResponse(e.getMessage(), e.getErrorCode());
|
return new ErrorResponse(e.getMessage(), e.getErrorCode());
|
||||||
}
|
}
|
||||||
@ -37,24 +38,30 @@ public class GlobalExceptionHandler {
|
|||||||
@ExceptionHandler(org.springframework.security.access.AccessDeniedException.class)
|
@ExceptionHandler(org.springframework.security.access.AccessDeniedException.class)
|
||||||
@ResponseStatus(HttpStatus.FORBIDDEN)
|
@ResponseStatus(HttpStatus.FORBIDDEN)
|
||||||
public ErrorResponse handleAccessDeniedException(AccessDeniedException e) {
|
public ErrorResponse handleAccessDeniedException(AccessDeniedException e) {
|
||||||
log.debug("Access denied", e);
|
log.info("Access denied: {}", e.getMessage());
|
||||||
return new ErrorResponse("", ErrorResponse.ErrorCode.ACCESS_DENIED);
|
return new ErrorResponse("Доступ запрещен", ErrorResponse.ErrorCode.ACCESS_DENIED);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ExceptionHandler(AuthenticationException.class)
|
||||||
|
@ResponseStatus(HttpStatus.UNAUTHORIZED)
|
||||||
|
public ErrorResponse handleAuthenticationException(AuthenticationException e) {
|
||||||
|
log.info("Authentication error: {}", e.getMessage());
|
||||||
|
return new ErrorResponse("Неверный логин или пароль", ErrorResponse.ErrorCode.ACCESS_DENIED);
|
||||||
|
}
|
||||||
|
|
||||||
@ExceptionHandler(NoResourceFoundException.class)
|
@ExceptionHandler(NoResourceFoundException.class)
|
||||||
@ResponseStatus(HttpStatus.NOT_FOUND)
|
@ResponseStatus(HttpStatus.NOT_FOUND)
|
||||||
public ErrorResponse handleNoResourceFoundException(NoResourceFoundException e) {
|
public ErrorResponse handleNoResourceFoundException(NoResourceFoundException e) {
|
||||||
log.error("Resource not found", e);
|
UUID uuid = UUID.randomUUID();
|
||||||
return new ErrorResponse(e.getMessage(), ErrorResponse.ErrorCode.NOT_FOUND);
|
log.error("Resource not found ({})", uuid, e);
|
||||||
|
return new ErrorResponse("Идентификатор ошибки: (" + uuid + ")\nРесурс не был наеден, обратитесь к администратору", ErrorResponse.ErrorCode.NOT_FOUND);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ExceptionHandler(Exception.class)
|
@ExceptionHandler(Exception.class)
|
||||||
@ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
|
@ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
|
||||||
public ErrorResponse handleUnexpectedException(Exception e) {
|
public ErrorResponse handleUnexpectedException(Exception e) {
|
||||||
Random random = new Random();
|
UUID uuid = UUID.randomUUID();
|
||||||
long errorInx = random.nextLong();
|
log.error("Unexpected exception ({})", uuid, e);
|
||||||
log.error("Unexpected exception. random: {}", errorInx, e);
|
return new ErrorResponse("Идентификатор ошибки: (" + uuid + ")\nПроизошла непредвиденная ошибка, обратитесь к администратору", ErrorResponse.ErrorCode.INTERNAL_ERROR);
|
||||||
return new ErrorResponse("Произошла непредвиденная ошибка, обратитесь к администратору. Номер ошибки: " + errorInx, ErrorResponse.ErrorCode.INTERNAL_ERROR);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -27,25 +27,26 @@ public class AuthenticationService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public void logout() {
|
public void logout() {
|
||||||
|
log.info("Logging out user: {}", SecurityContextHolder.getContext().getAuthentication().getName());
|
||||||
HttpSession session = request.getSession(false);
|
HttpSession session = request.getSession(false);
|
||||||
if(session != null) {
|
if(session != null) {
|
||||||
session.invalidate();
|
session.invalidate();
|
||||||
}
|
}
|
||||||
|
SecurityContextHolder.clearContext();
|
||||||
|
log.info("User logged out");
|
||||||
}
|
}
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
public void login(String username, String password) {
|
public void login(String username, String password) {
|
||||||
try {
|
log.info("Logging in user: {}, ip: {}", username, request.getRemoteAddr());
|
||||||
var context = SecurityContextHolder.createEmptyContext();
|
|
||||||
var token = new UsernamePasswordAuthenticationToken(username, password);
|
var context = SecurityContextHolder.createEmptyContext();
|
||||||
var authenticated = authenticationManager.authenticate(token);
|
var token = new UsernamePasswordAuthenticationToken(username, password);
|
||||||
context.setAuthentication(authenticated);
|
var authenticated = authenticationManager.authenticate(token);
|
||||||
SecurityContextHolder.setContext(context);
|
context.setAuthentication(authenticated);
|
||||||
request.getSession(true).setAttribute(SPRING_SECURITY_CONTEXT_KEY, context);
|
SecurityContextHolder.setContext(context);
|
||||||
} catch (Exception e) {
|
request.getSession(true).setAttribute(SPRING_SECURITY_CONTEXT_KEY, context);
|
||||||
log.error("Failed to log in user: {}", username, e);
|
|
||||||
throw e;
|
log.info("User {} logged in", username);
|
||||||
}
|
|
||||||
log.debug("User {} logged in", username);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,9 +1,11 @@
|
|||||||
package ru.tubryansk.tdms.service;
|
package ru.tubryansk.tdms.service;
|
||||||
|
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.springframework.beans.factory.annotation.Autowired;
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
import ru.tubryansk.tdms.controller.payload.GroupDTO;
|
import ru.tubryansk.tdms.controller.payload.GroupDTO;
|
||||||
|
import ru.tubryansk.tdms.controller.payload.GroupEditDTO;
|
||||||
import ru.tubryansk.tdms.entity.Group;
|
import ru.tubryansk.tdms.entity.Group;
|
||||||
import ru.tubryansk.tdms.entity.User;
|
import ru.tubryansk.tdms.entity.User;
|
||||||
import ru.tubryansk.tdms.entity.repository.GroupRepository;
|
import ru.tubryansk.tdms.entity.repository.GroupRepository;
|
||||||
@ -14,6 +16,7 @@ import java.util.List;
|
|||||||
|
|
||||||
@Service
|
@Service
|
||||||
@Transactional
|
@Transactional
|
||||||
|
@Slf4j
|
||||||
public class GroupService {
|
public class GroupService {
|
||||||
@Autowired
|
@Autowired
|
||||||
private GroupRepository groupRepository;
|
private GroupRepository groupRepository;
|
||||||
@ -21,33 +24,44 @@ public class GroupService {
|
|||||||
private CallerService callerService;
|
private CallerService callerService;
|
||||||
|
|
||||||
public Collection<GroupDTO> getAllGroups() {
|
public Collection<GroupDTO> getAllGroups() {
|
||||||
|
log.info("Getting all groups");
|
||||||
List<Group> groups = groupRepository.findAll();
|
List<Group> groups = groupRepository.findAll();
|
||||||
User callerUser = callerService.getCallerUser().orElse(null);
|
User callerUser = callerService.getCallerUser().orElse(null);
|
||||||
|
|
||||||
return groups.stream()
|
List<GroupDTO> result = groups.stream().map(group -> {
|
||||||
.map(g -> {
|
GroupDTO groupDTO = new GroupDTO();
|
||||||
GroupDTO groupDTO = new GroupDTO();
|
groupDTO.setName(group.getName());
|
||||||
groupDTO.setName(g.getName());
|
groupDTO.setId(group.getId());
|
||||||
|
|
||||||
if (g.getGroupCurator() != null) {
|
if (group.getGroupCurator() != null) {
|
||||||
groupDTO.setCuratorName(g.getGroupCurator().getUser().getFullName());
|
groupDTO.setCuratorName(group.getGroupCurator().getUser().getFullName());
|
||||||
if (callerUser != null) {
|
if (callerUser != null) {
|
||||||
groupDTO.setIAmCurator(g.getGroupCurator().getUser().equals(callerUser));
|
groupDTO.setIAmCurator(group.getGroupCurator().getUser().equals(callerUser));
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return groupDTO;
|
}
|
||||||
})
|
return groupDTO;
|
||||||
.toList();
|
}).toList();
|
||||||
|
|
||||||
|
log.info("Found {} groups", result.size());
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
public void createGroup(String groupName) {
|
public void createGroup(String groupName) {
|
||||||
boolean existsByName = groupRepository.existsByName(groupName);
|
log.info("Creating group with name {}", groupName);
|
||||||
if (existsByName) {
|
if (groupRepository.existsByName(groupName)) {
|
||||||
throw new BusinessException("Группа с именем " + groupName + " уже существует");
|
throw new BusinessException("Группа с именем " + groupName + " уже существует");
|
||||||
}
|
}
|
||||||
|
|
||||||
Group group = new Group();
|
Group group = new Group();
|
||||||
group.setName(groupName);
|
group.setName(groupName);
|
||||||
groupRepository.save(group);
|
Group saved = groupRepository.save(group);
|
||||||
|
log.info("Group saved: {}", saved);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void editGroup(GroupEditDTO groupEditDTO) {
|
||||||
|
log.info("Updating group with dto: {}", groupEditDTO);
|
||||||
|
Group group = groupRepository.findByIdThrow(groupEditDTO.getId());
|
||||||
|
group.setName(groupEditDTO.getName());
|
||||||
|
log.info("Group updated: {}", group);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,17 +1,17 @@
|
|||||||
package ru.tubryansk.tdms.service;
|
package ru.tubryansk.tdms.service;
|
||||||
|
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.springframework.context.ApplicationContext;
|
|
||||||
import org.springframework.context.event.ContextStartedEvent;
|
import org.springframework.context.event.ContextStartedEvent;
|
||||||
import org.springframework.context.event.EventListener;
|
import org.springframework.context.event.EventListener;
|
||||||
|
import org.springframework.core.env.Environment;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
@Slf4j
|
@Slf4j
|
||||||
public class LifeCycleService {
|
public class LifecycleService {
|
||||||
@EventListener(ContextStartedEvent.class)
|
@EventListener(ContextStartedEvent.class)
|
||||||
public void onStartup(ContextStartedEvent event) {
|
public void onStartup(ContextStartedEvent event) {
|
||||||
ApplicationContext applicationContext = event.getApplicationContext();
|
Environment environment = event.getApplicationContext().getEnvironment();
|
||||||
log.info("Static files location: {}", applicationContext.getEnvironment().getProperty("spring.web.resources.static-locations"));
|
log.info("Static files location: {}", environment.getProperty("spring.web.resources.static-locations"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -1,6 +1,7 @@
|
|||||||
package ru.tubryansk.tdms.service;
|
package ru.tubryansk.tdms.service;
|
||||||
|
|
||||||
import jakarta.annotation.PostConstruct;
|
import jakarta.annotation.PostConstruct;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.springframework.beans.factory.annotation.Autowired;
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
@ -11,6 +12,7 @@ import java.util.Map;
|
|||||||
import java.util.concurrent.ConcurrentHashMap;
|
import java.util.concurrent.ConcurrentHashMap;
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
|
@Slf4j
|
||||||
public class RoleService {
|
public class RoleService {
|
||||||
public enum Authority {
|
public enum Authority {
|
||||||
ROLE_ADMINISTRATOR,
|
ROLE_ADMINISTRATOR,
|
||||||
@ -28,8 +30,10 @@ public class RoleService {
|
|||||||
@PostConstruct
|
@PostConstruct
|
||||||
@Transactional
|
@Transactional
|
||||||
public void init() {
|
public void init() {
|
||||||
|
log.debug("Initializing roles");
|
||||||
roles = new ConcurrentHashMap<>();
|
roles = new ConcurrentHashMap<>();
|
||||||
roleRepository.findAll().forEach(role -> roles.put(role.getAuthority(), role));
|
roleRepository.findAll().forEach(role -> roles.put(role.getAuthority(), role));
|
||||||
|
log.info("Roles initialized: {}", roles);
|
||||||
}
|
}
|
||||||
|
|
||||||
public Role getRoleByAuthority(Authority authority) {
|
public Role getRoleByAuthority(Authority authority) {
|
||||||
|
|||||||
@ -4,11 +4,8 @@ import jakarta.transaction.Transactional;
|
|||||||
import org.springframework.beans.factory.annotation.Autowired;
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import ru.tubryansk.tdms.controller.payload.StudentDTO;
|
import ru.tubryansk.tdms.controller.payload.StudentDTO;
|
||||||
import ru.tubryansk.tdms.entity.DiplomaTopic;
|
|
||||||
import ru.tubryansk.tdms.entity.Student;
|
import ru.tubryansk.tdms.entity.Student;
|
||||||
import ru.tubryansk.tdms.entity.repository.DiplomaTopicRepository;
|
|
||||||
import ru.tubryansk.tdms.entity.repository.StudentRepository;
|
import ru.tubryansk.tdms.entity.repository.StudentRepository;
|
||||||
import ru.tubryansk.tdms.exception.AccessDeniedException;
|
|
||||||
|
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
|
|
||||||
@ -18,28 +15,8 @@ public class StudentService {
|
|||||||
@Autowired
|
@Autowired
|
||||||
private StudentRepository studentRepository;
|
private StudentRepository studentRepository;
|
||||||
@Autowired
|
@Autowired
|
||||||
private DiplomaTopicRepository diplomaTopicRepository;
|
|
||||||
@Autowired
|
|
||||||
private Optional<Student> student;
|
|
||||||
@Autowired
|
|
||||||
private CallerService callerService;
|
private CallerService callerService;
|
||||||
|
|
||||||
/** @param studentToDiplomaTopic Map of @{@link Student} id and @{@link DiplomaTopic} id */
|
|
||||||
// public void changeDiplomaTopic(Map<Integer, Integer> studentToDiplomaTopic) {
|
|
||||||
// studentToDiplomaTopic.forEach(this::changeDiplomaTopic);
|
|
||||||
// }
|
|
||||||
|
|
||||||
// public void changeDiplomaTopic(Integer studentId, Integer diplomaTopicId) {
|
|
||||||
// Student student = studentRepository.findByIdThrow(studentId);
|
|
||||||
// DiplomaTopic diplomaTopic = diplomaTopicRepository.findByIdThrow(diplomaTopicId);
|
|
||||||
// student.setDiplomaTopic(diplomaTopic);
|
|
||||||
// }
|
|
||||||
|
|
||||||
public void changeCallerDiplomaTopic(Integer diplomaTopicId) {
|
|
||||||
DiplomaTopic diplomaTopic = diplomaTopicRepository.findByIdThrow(diplomaTopicId);
|
|
||||||
student.ifPresentOrElse(s -> s.setDiplomaTopic(diplomaTopic), () -> {throw new AccessDeniedException();});
|
|
||||||
}
|
|
||||||
|
|
||||||
public Optional<Student> getCallerStudent() {
|
public Optional<Student> getCallerStudent() {
|
||||||
return studentRepository.findByUser(callerService.getCallerUser().orElse(null));
|
return studentRepository.findByUser(callerService.getCallerUser().orElse(null));
|
||||||
}
|
}
|
||||||
|
|||||||
@ -36,17 +36,15 @@ public class UserService implements UserDetailsService {
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
public User loadUserByUsername(String username) throws UsernameNotFoundException {
|
public User loadUserByUsername(String username) throws UsernameNotFoundException {
|
||||||
log.info("Loading user with username: {}", username);
|
log.debug("Loading user with username: {}", username);
|
||||||
User user = userRepository.findUserByLogin(username).orElseThrow(() -> {
|
User user = userRepository.findUserByLogin(username).orElseThrow(
|
||||||
log.info("User with login {} not found", username);
|
() -> new UsernameNotFoundException("User with login " + username + " not found"));
|
||||||
return new UsernameNotFoundException("User with login " + username + " not found");
|
log.debug("User with login {} loaded", username);
|
||||||
});
|
|
||||||
log.info("User with login {} loaded", username);
|
|
||||||
return user;
|
return user;
|
||||||
}
|
}
|
||||||
|
|
||||||
public List<UserDTO> getAllUsers() {
|
public List<UserDTO> getAllUsers() {
|
||||||
log.info("Loading all users");
|
log.debug("Loading all users");
|
||||||
List<UserDTO> users = userRepository.findAll().stream()
|
List<UserDTO> users = userRepository.findAll().stream()
|
||||||
.map(UserDTO::from)
|
.map(UserDTO::from)
|
||||||
.toList();
|
.toList();
|
||||||
@ -55,7 +53,7 @@ public class UserService implements UserDetailsService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public void registerUser(RegistrationDTO registrationDTO) {
|
public void registerUser(RegistrationDTO registrationDTO) {
|
||||||
log.info("Registering new user with login: {}", registrationDTO.getLogin());
|
log.debug("Registering new user with login: {}", registrationDTO.getLogin());
|
||||||
User user = transientUser(registrationDTO);
|
User user = transientUser(registrationDTO);
|
||||||
Student student = transientStudent(registrationDTO.getStudentData());
|
Student student = transientStudent(registrationDTO.getStudentData());
|
||||||
fillRoles(user, registrationDTO);
|
fillRoles(user, registrationDTO);
|
||||||
@ -92,6 +90,7 @@ public class UserService implements UserDetailsService {
|
|||||||
private void fillRoles(User user, RegistrationDTO registrationDTO) {
|
private void fillRoles(User user, RegistrationDTO registrationDTO) {
|
||||||
List<Role> roles = new ArrayList<>();
|
List<Role> roles = new ArrayList<>();
|
||||||
if (registrationDTO.getStudentData() != null) {
|
if (registrationDTO.getStudentData() != null) {
|
||||||
|
log.debug("User is student, adding role ROLE_STUDENT");
|
||||||
roles.add(roleService.getRoleByAuthority(RoleService.Authority.ROLE_STUDENT));
|
roles.add(roleService.getRoleByAuthority(RoleService.Authority.ROLE_STUDENT));
|
||||||
}
|
}
|
||||||
user.setRoles(roles);
|
user.setRoles(roles);
|
||||||
|
|||||||
@ -16,14 +16,13 @@ public class LoggingRequestFilter extends OncePerRequestFilter {
|
|||||||
@Override
|
@Override
|
||||||
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
|
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
|
||||||
long startTime = System.currentTimeMillis();
|
long startTime = System.currentTimeMillis();
|
||||||
log.info("Making request: {}. user: {}, session: {}, remote ip: {}",
|
log.info("Request received: {}. user: {}, session: {}, remote ip: {}",
|
||||||
request.getRequestURI(), request.getRemoteUser(),
|
request.getRequestURI(), request.getRemoteUser(), request.getSession().getId(), request.getRemoteAddr());
|
||||||
request.getSession().getId(), request.getRemoteAddr());
|
|
||||||
try {
|
try {
|
||||||
filterChain.doFilter(request, response);
|
filterChain.doFilter(request, response);
|
||||||
} finally {
|
} finally {
|
||||||
long duration = System.currentTimeMillis() - startTime;
|
long duration = System.currentTimeMillis() - startTime;
|
||||||
log.info("Request finished with {} status. duration: {} ms", response.getStatus(), duration);
|
log.info("Response with {} status. duration: {} ms", response.getStatus(), duration);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -11,13 +11,12 @@ import org.springframework.stereotype.Component;
|
|||||||
public class LoggingSessionListener implements HttpSessionListener {
|
public class LoggingSessionListener implements HttpSessionListener {
|
||||||
@Override
|
@Override
|
||||||
public void sessionCreated(HttpSessionEvent se) {
|
public void sessionCreated(HttpSessionEvent se) {
|
||||||
log.debug("Session created: {}, user {}",
|
log.debug("Session created: {}, user {}", se.getSession().getId(),
|
||||||
se.getSession().getId(), SecurityContextHolder.getContext().getAuthentication().getName());
|
SecurityContextHolder.getContext().getAuthentication().getName());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void sessionDestroyed(HttpSessionEvent se) {
|
public void sessionDestroyed(HttpSessionEvent se) {
|
||||||
log.debug("Session destroyed: {}, user: {}",
|
log.debug("Session destroyed: {}", se.getSession().getId());
|
||||||
se.getSession().getId(), SecurityContextHolder.getContext().getAuthentication().getName());
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -7,9 +7,9 @@ application:
|
|||||||
name: @name@
|
name: @name@
|
||||||
version: @version@
|
version: @version@
|
||||||
type: production
|
type: production
|
||||||
port: 80
|
port: 443
|
||||||
domain: tdms.tu-bryansk.ru
|
domain: tdms.tu-bryansk.ru
|
||||||
protocol: http
|
protocol: https
|
||||||
|
|
||||||
spring:
|
spring:
|
||||||
application:
|
application:
|
||||||
|
|||||||
@ -9,10 +9,13 @@ create table user_role
|
|||||||
-- FOREIGN KEY
|
-- FOREIGN KEY
|
||||||
alter table user_role
|
alter table user_role
|
||||||
add constraint fk_user_role_user_id
|
add constraint fk_user_role_user_id
|
||||||
foreign key (user_id) references "user" (id);
|
foreign key (user_id) references "user" (id)
|
||||||
|
on delete cascade on update cascade;
|
||||||
|
|
||||||
alter table user_role
|
alter table user_role
|
||||||
add constraint fk_user_role_role_id
|
add constraint fk_user_role_role_id
|
||||||
foreign key (role_id) references role (id);
|
foreign key (role_id) references role (id)
|
||||||
|
on delete restrict on update cascade;
|
||||||
|
|
||||||
-- COMMENTS
|
-- COMMENTS
|
||||||
comment on table user_role is 'Таблица связи пользователей и ролей';
|
comment on table user_role is 'Таблица связи пользователей и ролей';
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
insert into "user" (id, login, password, full_name, email, number_phone, created_at)
|
insert into "user" (id, login, password, full_name, email, number_phone, created_at)
|
||||||
values (1, 'admin', '{noop}admin', 'Администратор', 'admin@tdms.tu-byransk.ru', '', now());
|
values (1, 'admin', '{noop}Admin000', 'Администратор', 'admin@tdms.tu-byransk.ru', '', now());
|
||||||
|
|
||||||
insert into user_role (id, user_id, role_id)
|
insert into user_role (id, user_id, role_id)
|
||||||
values (1, 1, 4);
|
values (1, 1, 4);
|
||||||
|
|||||||
@ -9,4 +9,4 @@
|
|||||||
["@babel/plugin-proposal-class-properties"],
|
["@babel/plugin-proposal-class-properties"],
|
||||||
["@babel/plugin-transform-typescript"]
|
["@babel/plugin-transform-typescript"]
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,10 +1,23 @@
|
|||||||
import ReactDOM from 'react-dom/client'
|
import ReactDOM from 'react-dom/client'
|
||||||
import './index.css'
|
import {RouterContext, RouterView, ViewMap} from "mobx-state-router";
|
||||||
import 'bootstrap/dist/css/bootstrap.min.css';
|
|
||||||
import {RouterContext, RouterView} from "mobx-state-router";
|
|
||||||
import {initApp} from "./utils/init";
|
import {initApp} from "./utils/init";
|
||||||
import {RootStoreContext} from './store/RootStoreContext';
|
import React from "react";
|
||||||
import {viewMap} from "./router/viewMap";
|
import 'bootstrap/dist/css/bootstrap.min.css';
|
||||||
|
import './index.css'
|
||||||
|
import {RootStoreContext} from './utils/context';
|
||||||
|
import {Home} from "./components/custom/layout/Home";
|
||||||
|
import {UserProfilePage} from "./components/user/UserProfilePage";
|
||||||
|
import {UserListPage} from "./components/user/UserListPage";
|
||||||
|
import {GroupListPage} from "./components/group/GroupListPage";
|
||||||
|
import {Error} from "./components/custom/layout/Error";
|
||||||
|
|
||||||
|
const viewMap: ViewMap = {
|
||||||
|
home: <Home/>,
|
||||||
|
profile: <UserProfilePage/>,
|
||||||
|
userList: <UserListPage/>,
|
||||||
|
groupList: <GroupListPage/>,
|
||||||
|
error: <Error/>,
|
||||||
|
}
|
||||||
|
|
||||||
const rootStore = initApp();
|
const rootStore = initApp();
|
||||||
|
|
||||||
|
|||||||
@ -52,20 +52,26 @@ class NotificationPopup extends ComponentContext<{ notification: Notification, t
|
|||||||
render() {
|
render() {
|
||||||
const hasTitle = !!this.props.notification.title && this.props.notification.title.length > 0;
|
const hasTitle = !!this.props.notification.title && this.props.notification.title.length > 0;
|
||||||
const closeIcon = <span className={'ms-2'}><FontAwesomeIcon icon={'close'} onClick={this.close}/></span>;
|
const closeIcon = <span className={'ms-2'}><FontAwesomeIcon icon={'close'} onClick={this.close}/></span>;
|
||||||
|
const title = this.props.notification.title.split('\n').map((item, key) => <span key={key}>{item}<br/></span>);
|
||||||
|
const message = this.props.notification.message.split('\n').map((item, key) => <span key={key}>{item}<br/></span>);
|
||||||
|
|
||||||
return <Card className={`position-relative mt-3 opacity-75 ${this.cardClassName}`}>
|
return <Card className={`position-relative mt-3 opacity-75 ${this.cardClassName}`}>
|
||||||
{
|
{
|
||||||
hasTitle &&
|
hasTitle &&
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className={'d-flex justify-content-between align-items-start'}>
|
<CardTitle className={'d-flex justify-content-between align-items-start'}>
|
||||||
{this.props.notification.title}
|
<span>
|
||||||
|
{title}
|
||||||
|
</span>
|
||||||
{closeIcon}
|
{closeIcon}
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
}
|
}
|
||||||
<CardBody>
|
<CardBody>
|
||||||
<CardText className={'d-flex justify-content-between align-items-start'}>
|
<CardText className={'d-flex justify-content-between align-items-start'}>
|
||||||
{this.props.notification.message}
|
<span>
|
||||||
|
{message}
|
||||||
|
</span>
|
||||||
{
|
{
|
||||||
!hasTitle &&
|
!hasTitle &&
|
||||||
closeIcon
|
closeIcon
|
||||||
|
|||||||
@ -1,147 +1,292 @@
|
|||||||
import {ComponentContext} from "../../utils/ComponentContext";
|
import {ComponentContext} from "../../utils/ComponentContext";
|
||||||
import {TableDescriptor} from "../../utils/tables";
|
import {TableDescriptor} from "../../utils/tables";
|
||||||
import {observer} from "mobx-react";
|
import {observer} from "mobx-react";
|
||||||
import {action, makeObservable} from "mobx";
|
import {action, computed, makeObservable, observable, runInAction} from "mobx";
|
||||||
import {FormSelect, Pagination, Table} from "react-bootstrap";
|
import {Button, ButtonGroup, FormSelect, FormText, Table} from "react-bootstrap";
|
||||||
|
import _ from "lodash";
|
||||||
|
import {ChangeEvent} from "react";
|
||||||
|
import {ModalState} from "../../utils/modalState";
|
||||||
|
import {FontAwesomeIcon} from "@fortawesome/react-fontawesome";
|
||||||
|
|
||||||
export interface DataTableProps<T> {
|
export interface DataTableProps<T> {
|
||||||
tableDescriptor: TableDescriptor<T>;
|
tableDescriptor: TableDescriptor<T>;
|
||||||
|
filterModalState?: ModalState;
|
||||||
|
name?: string;
|
||||||
|
headless?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@observer
|
@observer
|
||||||
export class DataTable<T> extends ComponentContext<DataTableProps<T>> {
|
export class DataTable<R> extends ComponentContext<DataTableProps<R>> {
|
||||||
constructor(props: DataTableProps<T>) {
|
constructor(props: DataTableProps<R>) {
|
||||||
super(props);
|
super(props);
|
||||||
makeObservable(this);
|
makeObservable(this);
|
||||||
}
|
}
|
||||||
|
|
||||||
header() {
|
@observable descriptor = this.props.tableDescriptor;
|
||||||
return <tr>
|
@observable name = this.props.name;
|
||||||
{this.props.tableDescriptor.columns.map(column => <th className={'text-center'}
|
@observable headless = this.props.headless;
|
||||||
key={column.key}>{column.title}</th>)}
|
@observable filterModalState = this.props.filterModalState;
|
||||||
</tr>
|
|
||||||
|
@computed
|
||||||
|
get isFirstPage() {
|
||||||
|
return this.descriptor.page === 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
body() {
|
@computed
|
||||||
const firstColumnKey = this.props.tableDescriptor.columns[0].key;
|
get isLastPage() {
|
||||||
return this.props.tableDescriptor.data.map(row => {
|
return this.descriptor.page === Math.floor(this.descriptor.data.length / this.descriptor.pageSize)
|
||||||
const rowAny = row as any;
|
|| this.descriptor.pageSize === this.descriptor.data.length;
|
||||||
return <tr key={rowAny[firstColumnKey]}>
|
|
||||||
{
|
|
||||||
this.props.tableDescriptor.columns.map(column => {
|
|
||||||
return <td
|
|
||||||
className={'text-center'}
|
|
||||||
key={column.key}>
|
|
||||||
{column.format(rowAny[column.key])}
|
|
||||||
</td>
|
|
||||||
})
|
|
||||||
}
|
|
||||||
</tr>
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
isFirstPage() {
|
|
||||||
if (typeof this.props.tableDescriptor.page === 'undefined') {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
return this.props.tableDescriptor.page === 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
isLastPage() {
|
|
||||||
if (typeof this.props.tableDescriptor.page === 'undefined' || typeof this.props.tableDescriptor.pageSize === 'undefined') {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
return this.props.tableDescriptor.page === (this.props.tableDescriptor.data.length / this.props.tableDescriptor.pageSize);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@action.bound
|
@action.bound
|
||||||
goFirstPage() {
|
goFirstPage() {
|
||||||
if (typeof this.props.tableDescriptor.page === 'undefined') {
|
this.descriptor.page = 0;
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.props.tableDescriptor.page = 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@action.bound
|
@action.bound
|
||||||
goLastPage() {
|
goLastPage() {
|
||||||
if (typeof this.props.tableDescriptor.page === 'undefined' || typeof this.props.tableDescriptor.pageSize === 'undefined') {
|
this.descriptor.page = Math.floor(this.descriptor.data.length / this.descriptor.pageSize);
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.props.tableDescriptor.page = this.props.tableDescriptor.data.length / this.props.tableDescriptor.pageSize;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@action.bound
|
@action.bound
|
||||||
goNextPage() {
|
goNextPage() {
|
||||||
if (typeof this.props.tableDescriptor.page === 'undefined' || typeof this.props.tableDescriptor.pageSize === 'undefined') {
|
this.descriptor.page++;
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.props.tableDescriptor.page++;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@action.bound
|
@action.bound
|
||||||
goPrevPage() {
|
goPrevPage() {
|
||||||
if (typeof this.props.tableDescriptor.page === 'undefined') {
|
this.descriptor.page--;
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.props.tableDescriptor.page--;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@action.bound
|
@action.bound
|
||||||
changePageSize(e: any) {
|
changePageSize(e: ChangeEvent<HTMLSelectElement>) {
|
||||||
this.props.tableDescriptor.pageSize = parseInt(e.target.value);
|
if (e.target.value === "\u221E") {
|
||||||
}
|
this.descriptor.pageSize = this.descriptor.data.length;
|
||||||
|
return;
|
||||||
footer() {
|
|
||||||
const table = this.props.tableDescriptor;
|
|
||||||
if (typeof table.page === 'undefined' || typeof table.pageSize === 'undefined') {
|
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return <tr className={'text-center'}>
|
this.descriptor.page = 0;
|
||||||
<td colSpan={table.columns.length}>
|
this.descriptor.pageSize = _.toNumber(e.target.value);
|
||||||
<div className={'d-flex justify-content-between'}>
|
}
|
||||||
<div/>
|
|
||||||
<Pagination className={'mb-0'}>
|
// not computed, since we want to show initial data, when no sorts applied
|
||||||
<Pagination.First onClick={this.goFirstPage} disabled={this.isFirstPage()}/>
|
get filteredData() {
|
||||||
<Pagination.Ellipsis disabled={this.isFirstPage()}/>
|
const filters = this.descriptor.filters.filter(filter => filter);
|
||||||
<Pagination.Prev onClick={this.goPrevPage} disabled={this.isFirstPage()}/>
|
return this.descriptor.data.filter(row => ((filters && filters.length) > 0 ? filters.every(filter => filter(row)) : true));
|
||||||
<Pagination.Item active>{this.props.tableDescriptor.page}</Pagination.Item>
|
|
||||||
<Pagination.Next onClick={this.goNextPage} disabled={!this.isLastPage()}/>
|
|
||||||
<Pagination.Ellipsis disabled={!this.isLastPage()}/>
|
|
||||||
<Pagination.Last onClick={this.goLastPage} disabled={!this.isLastPage()}/>
|
|
||||||
</Pagination>
|
|
||||||
<FormSelect className={'w-auto'} onChange={this.changePageSize}>
|
|
||||||
<option>10</option>
|
|
||||||
<option>20</option>
|
|
||||||
<option>50</option>
|
|
||||||
<option>100</option>
|
|
||||||
</FormSelect>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
}
|
}
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
const table = this.props.tableDescriptor;
|
const style = {
|
||||||
return <Table hover striped>
|
border: '1px solid #dee2e6',
|
||||||
<thead>
|
borderTopLeftRadius: this.headless ? '0.25rem' : '0',
|
||||||
{this.header()}
|
borderTopRightRadius: this.headless ? '0.25rem' : '0',
|
||||||
</thead>
|
borderBottomLeftRadius: this.descriptor.pageable ? '0' : '0.25rem',
|
||||||
<tbody>
|
borderBottomRightRadius: this.descriptor.pageable ? '0' : '0.25rem',
|
||||||
{this.body()}
|
}
|
||||||
</tbody>
|
|
||||||
|
return <>
|
||||||
{
|
{
|
||||||
table.pageable &&
|
!this.headless &&
|
||||||
<tfoot>
|
this.header
|
||||||
{this.footer()}
|
|
||||||
</tfoot>
|
|
||||||
}
|
}
|
||||||
</Table>
|
<div className={"overflow-auto table m-0"} style={style}>
|
||||||
|
<Table hover striped bordered className={'m-0'}>
|
||||||
|
<thead>
|
||||||
|
{this.tableHeader}
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{this.body}
|
||||||
|
</tbody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
{
|
||||||
|
this.descriptor.pageable &&
|
||||||
|
this.footer
|
||||||
|
}
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
|
||||||
|
@computed
|
||||||
|
get header() {
|
||||||
|
const style = {
|
||||||
|
borderTop: '1px solid #dee2e6',
|
||||||
|
borderTopLeftRadius: '0.25rem',
|
||||||
|
borderTopRightRadius: '0.25rem',
|
||||||
|
borderLeft: '1px solid #dee2e6',
|
||||||
|
borderRight: '1px solid #dee2e6',
|
||||||
|
}
|
||||||
|
|
||||||
|
return <div style={style}>
|
||||||
|
<div className={`d-flex ${this.name ? 'justify-content-between' : 'justify-content-end'} align-items-center ms-2 me-2`}>
|
||||||
|
<span className={'h3 text-uppercase fw-bold mb-0'}>{this.name}</span>
|
||||||
|
<FormText className={'mt-0'}>
|
||||||
|
{
|
||||||
|
this.descriptor.pageable &&
|
||||||
|
<div>{`Записей на странице: ${this.descriptor.pageSize} `}</div>
|
||||||
|
}
|
||||||
|
<div className={'text-end'}>
|
||||||
|
{
|
||||||
|
<>
|
||||||
|
<span>{`Всего записей: ${this.filteredData.length}`}</span>
|
||||||
|
<span>
|
||||||
|
{
|
||||||
|
this.descriptor.pageable &&
|
||||||
|
<>
|
||||||
|
{` (${this.descriptor.page + 1}/${Math.ceil(this.filteredData.length / this.descriptor.pageSize)})`}
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
</span>
|
||||||
|
{
|
||||||
|
this.filterModalState &&
|
||||||
|
<div>
|
||||||
|
<Button variant={"outline-secondary"} size={'sm'} className={'pt-0 pb-0 ps-1 pe-1 mb-1'}
|
||||||
|
onClick={this.filterModalState.open}>
|
||||||
|
Фильтр
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</>
|
||||||
|
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</FormText>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@computed
|
||||||
|
get tableHeader() {
|
||||||
|
return <>
|
||||||
|
<tr style={{borderTop: 'none'}}>
|
||||||
|
{
|
||||||
|
this.descriptor.columns.map((column, i) => {
|
||||||
|
const firstColumn = i === 0;
|
||||||
|
const lastColumn = i === this.descriptor.columns.length - 1;
|
||||||
|
const style = {
|
||||||
|
borderLeft: firstColumn ? 'none' : '1px solid var(--bs-table-border-color)',
|
||||||
|
borderRight: lastColumn ? 'none' : '1px solid var(--bs-table-border-color)',
|
||||||
|
};
|
||||||
|
|
||||||
|
return <th key={column.key} style={style}>
|
||||||
|
<div className={'d-flex align-items-center justify-content-center position-relative user-select-none'} style={{cursor: "pointer"}}
|
||||||
|
onClick={() => runInAction(() => {
|
||||||
|
const other = this.descriptor.columns
|
||||||
|
.filter(c => c.key !== column.key)
|
||||||
|
.map(c => c.sort);
|
||||||
|
column.sort.toggle(other);
|
||||||
|
})}>
|
||||||
|
<span style={{paddingLeft: '25px', paddingRight: '25px'}}>{column.title}</span>
|
||||||
|
<span style={{position: 'absolute', left: '100%', transform: 'translateX(-100%)'}}>
|
||||||
|
{
|
||||||
|
<div className={'d-flex justify-content-center align-items-center'} style={{
|
||||||
|
width: '25px', height: '15px', border: 'none', background: 'none',
|
||||||
|
fontSize: '10px', padding: '0', margin: '0',
|
||||||
|
}}>
|
||||||
|
{column.sort.apply ? column.sort.order === 'asc' ? '▲' : '▼' : '△▽'}
|
||||||
|
{
|
||||||
|
column.sort.apply &&
|
||||||
|
column.sort.applyOrder
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</th>
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
</tr>
|
||||||
|
</>
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
@computed
|
||||||
|
get body() {
|
||||||
|
const firstColumnKey = this.descriptor.columns[0].key;
|
||||||
|
const sortingColumns = this.descriptor.columns.filter(column => column.sort.apply).sort((a, b) => a.sort.applyOrder - b.sort.applyOrder);
|
||||||
|
const masterComparator = (a: any /*row*/, b: any /*row*/) => {
|
||||||
|
for (const column of sortingColumns) {
|
||||||
|
const sortKey = column.key;
|
||||||
|
const result = column.sort.comparator(a[sortKey], b[sortKey]);
|
||||||
|
if (result !== 0) {
|
||||||
|
return column.sort.order === 'asc' ? result : -result;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.filteredData
|
||||||
|
.sort(masterComparator)
|
||||||
|
.slice(this.descriptor.page * this.descriptor.pageSize, (this.descriptor.page + 1) * this.descriptor.pageSize)
|
||||||
|
.map((row, i) => {
|
||||||
|
const rowAny = row as any;
|
||||||
|
const lastRow = i === this.descriptor.pageSize - 1;
|
||||||
|
return <tr key={rowAny[firstColumnKey]} style={{borderBottom: lastRow ? 'none' : '1px solid var(--bs-table-border-color)'}}>
|
||||||
|
{
|
||||||
|
this.descriptor.columns.map(column => {
|
||||||
|
const firstColumn = column === this.descriptor.columns[0];
|
||||||
|
const lastColumn = column === this.descriptor.columns[this.descriptor.columns.length - 1];
|
||||||
|
const suffixElement = column.suffixElement ? column.suffixElement(row) : undefined;
|
||||||
|
const style = {
|
||||||
|
borderLeft: firstColumn ? 'none' : '1px solid var(--bs-table-border-color)',
|
||||||
|
borderRight: lastColumn ? 'none' : '1px solid var(--bs-table-border-color)',
|
||||||
|
borderBottom: lastRow ? 'none' : '1px solid var(--bs-table-border-color)',
|
||||||
|
}
|
||||||
|
return <td className={'text-center'} key={column.key}
|
||||||
|
style={style}>
|
||||||
|
<span>{column.format(rowAny[column.key], row)}</span>
|
||||||
|
{
|
||||||
|
suffixElement &&
|
||||||
|
<span className={'ms-2'}>{suffixElement}</span>
|
||||||
|
}
|
||||||
|
</td>
|
||||||
|
})
|
||||||
|
}
|
||||||
|
</tr>
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@computed
|
||||||
|
get footer() {
|
||||||
|
const style = {
|
||||||
|
borderBottom: '1px solid #dee2e6',
|
||||||
|
borderBottomLeftRadius: '0.25rem',
|
||||||
|
borderBottomRightRadius: '0.25rem',
|
||||||
|
borderLeft: '1px solid #dee2e6',
|
||||||
|
borderRight: '1px solid #dee2e6',
|
||||||
|
width: '100%',
|
||||||
|
height: '40px',
|
||||||
|
}
|
||||||
|
|
||||||
|
const buttonSizeStyle = {
|
||||||
|
width: '30px',
|
||||||
|
height: '30px',
|
||||||
|
padding: '0',
|
||||||
|
}
|
||||||
|
|
||||||
|
return <div style={style} className={'position-relative'}>
|
||||||
|
<ButtonGroup style={{position: 'absolute', top: '5px', left: '50%', transform: 'translateX(-50%)'}}>
|
||||||
|
<Button onClick={this.goFirstPage} disabled={this.isFirstPage} style={buttonSizeStyle} variant={'outline-secondary'}>
|
||||||
|
<FontAwesomeIcon icon={'angle-double-left'}/>
|
||||||
|
</Button>
|
||||||
|
<Button onClick={this.goPrevPage} disabled={this.isFirstPage} style={buttonSizeStyle} variant={'outline-secondary'}>
|
||||||
|
<FontAwesomeIcon icon={'angle-left'}/>
|
||||||
|
</Button>
|
||||||
|
<Button disabled style={buttonSizeStyle} variant={'outline-secondary'}>{this.descriptor.page + 1}</Button>
|
||||||
|
<Button onClick={this.goNextPage} disabled={this.isLastPage} style={buttonSizeStyle} variant={'outline-secondary'}>
|
||||||
|
<FontAwesomeIcon icon={'angle-right'}/>
|
||||||
|
</Button>
|
||||||
|
<Button onClick={this.goLastPage} disabled={this.isLastPage} style={buttonSizeStyle} variant={'outline-secondary'}>
|
||||||
|
<FontAwesomeIcon icon={'angle-double-right'}/>
|
||||||
|
</Button>
|
||||||
|
</ButtonGroup>
|
||||||
|
<FormSelect className={'w-auto'} onChange={this.changePageSize} style={{position: 'absolute', top: '3px', right: 0, border: 'none'}}>
|
||||||
|
<option>10</option>
|
||||||
|
<option>20</option>
|
||||||
|
<option>50</option>
|
||||||
|
<option>100</option>
|
||||||
|
<option>∞</option>
|
||||||
|
</FormSelect>
|
||||||
|
</div>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -2,7 +2,7 @@ import React from "react";
|
|||||||
import {ReactiveValue} from "../../../utils/reactive/reactiveValue";
|
import {ReactiveValue} from "../../../utils/reactive/reactiveValue";
|
||||||
import {observer} from "mobx-react";
|
import {observer} from "mobx-react";
|
||||||
import {action, makeObservable, observable} from "mobx";
|
import {action, makeObservable, observable} from "mobx";
|
||||||
import {Button, ButtonGroup, FloatingLabel, FormControl, FormText, ToggleButton} from "react-bootstrap";
|
import {Button, FloatingLabel, FormControl, FormText} from "react-bootstrap";
|
||||||
import {FontAwesomeIcon} from "@fortawesome/react-fontawesome";
|
import {FontAwesomeIcon} from "@fortawesome/react-fontawesome";
|
||||||
import './ReactiveControls.css';
|
import './ReactiveControls.css';
|
||||||
|
|
||||||
@ -11,6 +11,11 @@ export interface ReactiveInputProps<T> {
|
|||||||
label?: string;
|
label?: string;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
className?: string;
|
className?: string;
|
||||||
|
validateless?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ReactiveSelectInputProps<T> extends ReactiveInputProps<T> {
|
||||||
|
possibleValues: { value: T, label: string }[];
|
||||||
}
|
}
|
||||||
|
|
||||||
@observer
|
@observer
|
||||||
@ -30,12 +35,13 @@ export class StringInput extends React.Component<ReactiveInputProps<string>> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
|
const inputClassName = `${this.props.validateless ? '' : this.props.value.invalid ? 'bg-danger' : this.props.value.touched ? 'bg-success' : ''} bg-opacity-10`;
|
||||||
|
|
||||||
return <div className={'mb-1 l-no-bg'}>
|
return <div className={'mb-1 l-no-bg'}>
|
||||||
{/*todo: disable background-color for label*/}
|
|
||||||
<FloatingLabel label={this.props.label} className={`${this.props.className} mt-0 mb-0`}>
|
<FloatingLabel label={this.props.label} className={`${this.props.className} mt-0 mb-0`}>
|
||||||
<FormControl type='text' placeholder={this.props.label} disabled={this.props.disabled}
|
<FormControl type='text' placeholder={this.props.label} disabled={this.props.disabled}
|
||||||
onChange={this.onChange} value={this.props.value.value}
|
onChange={this.onChange} value={this.props.value.value}
|
||||||
className={`${this.props.value.invalid ? 'bg-danger' : this.props.value.touched ? 'bg-success' : ''} bg-opacity-10`}/>
|
className={inputClassName}/>
|
||||||
</FloatingLabel>
|
</FloatingLabel>
|
||||||
<FormText children={this.props.value.firstError} className={`text-danger mt-0 mb-0 d-block`}/>
|
<FormText children={this.props.value.firstError} className={`text-danger mt-0 mb-0 d-block`}/>
|
||||||
</div>
|
</div>
|
||||||
@ -67,14 +73,15 @@ export class PasswordInput extends React.Component<ReactiveInputProps<string>> {
|
|||||||
|
|
||||||
render() {
|
render() {
|
||||||
return <div className={'mb-1 l-no-bg'}>
|
return <div className={'mb-1 l-no-bg'}>
|
||||||
<div className={'d-flex justify-content-between align-items-center'}>
|
<div className={'position-relative d-flex justify-content-between align-items-center'} style={{minHeight: '58px'}}>
|
||||||
<FloatingLabel label={this.props.label} className={`${this.props.className} w-100`}>
|
<FloatingLabel label={this.props.label} className={`${this.props.className} w-100 position-absolute`}>
|
||||||
<FormControl type={`${this.showPassword ? 'text' : 'password'}`} placeholder={this.props.label}
|
<FormControl type={`${this.showPassword ? 'text' : 'password'}`} placeholder={this.props.label}
|
||||||
disabled={this.props.disabled}
|
disabled={this.props.disabled}
|
||||||
className={`${this.props.value.invalid ? 'bg-danger' : this.props.value.touched ? 'bg-success' : ''} bg-opacity-10`}
|
className={`${this.props.value.invalid ? 'bg-danger' : this.props.value.touched ? 'bg-success' : ''} bg-opacity-10`}
|
||||||
onChange={this.onChange} value={this.props.value.value}/>
|
onChange={this.onChange} value={this.props.value.value}/>
|
||||||
</FloatingLabel>
|
</FloatingLabel>
|
||||||
<Button onClick={this.toggleShowPassword} variant={"outline-secondary"} className={'ms-2'}>
|
<Button onClick={this.toggleShowPassword} variant={"outline-secondary"} className={'position-absolute rounded-pill'}
|
||||||
|
style={{width: '40px', height: '40px', left: '100%', transform: 'translateX(-120%)', padding: '0px'}}>
|
||||||
<FontAwesomeIcon icon={this.showPassword ? 'eye-slash' : 'eye'}/>
|
<FontAwesomeIcon icon={this.showPassword ? 'eye-slash' : 'eye'}/>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@ -84,33 +91,13 @@ export class PasswordInput extends React.Component<ReactiveInputProps<string>> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@observer
|
@observer
|
||||||
export class SelectButtonInput extends React.Component<ReactiveInputProps<string>> {
|
export class SelectInput<T> extends React.Component<ReactiveSelectInputProps<T>> {
|
||||||
constructor(props: any) {
|
constructor(props: ReactiveSelectInputProps<T>) {
|
||||||
super(props);
|
super(props);
|
||||||
makeObservable(this);
|
makeObservable(this);
|
||||||
if (this.props.value.value === undefined) {
|
|
||||||
this.props.value.setAuto('');
|
|
||||||
}
|
|
||||||
this.props.value.setField(this.props.label);
|
|
||||||
}
|
|
||||||
|
|
||||||
@action.bound
|
|
||||||
onChange(event: React.ChangeEvent<HTMLInputElement>) {
|
|
||||||
this.props.value.set(event.currentTarget.value);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
return <>
|
return <div></div>;
|
||||||
<ButtonGroup className={'d-block l-no-bg'}>
|
|
||||||
<ToggleButton key={'admin'} value={'admin'} id={`radio-admin`} type="radio"
|
|
||||||
variant={'outline-primary'} children={'Администратор'}
|
|
||||||
checked={this.props.value.value === 'admin'} onChange={this.onChange}/>
|
|
||||||
<ToggleButton key={'student'} id={`radio-student`} type="radio" value={'student'}
|
|
||||||
variant={'outline-primary'}
|
|
||||||
checked={this.props.value.value === 'student'} onChange={this.onChange}
|
|
||||||
children={'Студент'}/>
|
|
||||||
</ButtonGroup>
|
|
||||||
<FormText children={this.props.value.firstError} className={'text-danger d-block'}/>
|
|
||||||
</>
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -1,8 +1,8 @@
|
|||||||
import {observer} from "mobx-react";
|
import {observer} from "mobx-react";
|
||||||
import {DefaultPage} from "./DefaultPage";
|
import {Page} from "./Page";
|
||||||
|
|
||||||
@observer
|
@observer
|
||||||
export default class Error extends DefaultPage {
|
export class Error extends Page {
|
||||||
get page() {
|
get page() {
|
||||||
return <h1>Error</h1>
|
return <h1>Error</h1>
|
||||||
}
|
}
|
||||||
@ -1,4 +1,4 @@
|
|||||||
import {ComponentContext} from "../../utils/ComponentContext";
|
import {ComponentContext} from "../../../utils/ComponentContext";
|
||||||
import {observer} from "mobx-react";
|
import {observer} from "mobx-react";
|
||||||
import {makeObservable} from "mobx";
|
import {makeObservable} from "mobx";
|
||||||
import {Container, Nav, Navbar, NavbarText, NavLink} from "react-bootstrap";
|
import {Container, Nav, Navbar, NavbarText, NavLink} from "react-bootstrap";
|
||||||
@ -1,80 +1,72 @@
|
|||||||
import {Container, Nav, Navbar, NavDropdown} from "react-bootstrap";
|
import {Container, Nav, Navbar, NavDropdown} from "react-bootstrap";
|
||||||
import {RouterLink} from "mobx-state-router";
|
import {RouterLink} from "mobx-state-router";
|
||||||
import {IAuthenticated} from "../../models/user";
|
import {IAuthenticated} from "../../../models/user";
|
||||||
import {RootStoreContext, RootStoreContextType} from "../../store/RootStoreContext";
|
|
||||||
import {observer} from "mobx-react";
|
import {observer} from "mobx-react";
|
||||||
import {post} from "../../utils/request";
|
import {post} from "../../../utils/request";
|
||||||
import {LoginModal} from "../user/LoginModal";
|
import {UserLoginModal} from "../../user/UserLoginModal";
|
||||||
import {ModalState} from "../../utils/modalState";
|
import {ModalState} from "../../../utils/modalState";
|
||||||
import {action, makeObservable, observable} from "mobx";
|
import {action, makeObservable, observable} from "mobx";
|
||||||
import {FontAwesomeIcon} from "@fortawesome/react-fontawesome";
|
import {FontAwesomeIcon} from "@fortawesome/react-fontawesome";
|
||||||
import {ComponentContext} from "../../utils/ComponentContext";
|
import {ComponentContext} from "../../../utils/ComponentContext";
|
||||||
import {CreateGroupModal} from "../group/CreateGroupModal";
|
import {AddGroupModal} from "../../group/AddGroupModal";
|
||||||
import {UserRegistrationModal} from "../user/UserRegistrationModal";
|
import {UserRegistrationModal} from "../../user/UserRegistrationModal";
|
||||||
|
|
||||||
@observer
|
@observer
|
||||||
class Header extends ComponentContext {
|
export class Header extends ComponentContext {
|
||||||
|
|
||||||
@observable loginModalState = new ModalState();
|
|
||||||
@observable createGroupModalState = new ModalState();
|
|
||||||
@observable userRegistrationModalState = new ModalState();
|
|
||||||
|
|
||||||
constructor(props: any) {
|
constructor(props: any) {
|
||||||
super(props);
|
super(props);
|
||||||
makeObservable(this);
|
makeObservable(this);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@observable loginModalState = new ModalState();
|
||||||
|
@observable addGroupModalState = new ModalState();
|
||||||
|
@observable userRegistrationModalState = new ModalState();
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
const userStore = this.context.userStore;
|
let userThink = this.thinkStore.isThinking('updateCurrentUser');
|
||||||
const user = userStore.user;
|
|
||||||
let thinking = this.thinkStore.isThinking('updateCurrentUser');
|
|
||||||
|
|
||||||
return <>
|
return <>
|
||||||
<header>
|
<header>
|
||||||
<Navbar className="bg-body-tertiary" fixed="top">
|
<Navbar className="bg-body-tertiary" fixed="top">
|
||||||
<Container>
|
<Container>
|
||||||
<Navbar.Brand>
|
<Navbar.Brand>
|
||||||
<Nav.Link as={RouterLink} routeName='root'>TDMS</Nav.Link>
|
<Nav.Link as={RouterLink} routeName='home'>TDMS</Nav.Link>
|
||||||
</Navbar.Brand>
|
</Navbar.Brand>
|
||||||
<Nav>
|
<Nav>
|
||||||
{
|
{
|
||||||
user.authenticated && userStore.isAdministrator() &&
|
this.userStore.isAdministrator &&
|
||||||
<NavDropdown title="Пользователи">
|
<NavDropdown title="Пользователи">
|
||||||
<NavDropdown.Item as={RouterLink} routeName={'userList'} children={'Список'}/>
|
<NavDropdown.Item as={RouterLink} routeName={'userList'} children={'Список'}/>
|
||||||
<NavDropdown.Item onClick={this.userRegistrationModalState.open}
|
<NavDropdown.Item onClick={this.userRegistrationModalState.open} children={'Зарегистрировать'}/>
|
||||||
children={'Зарегистрировать'}/>
|
|
||||||
</NavDropdown>
|
|
||||||
}
|
|
||||||
{
|
|
||||||
user.authenticated && userStore.isAdministrator() &&
|
|
||||||
<NavDropdown title="Группы">
|
|
||||||
<NavDropdown.Item as={RouterLink} routeName={'groupList'} children={'Список'}/>
|
|
||||||
<NavDropdown.Item onClick={this.createGroupModalState.open} children={'Добавить'}/>
|
|
||||||
</NavDropdown>
|
</NavDropdown>
|
||||||
}
|
}
|
||||||
|
<NavDropdown title="Группы">
|
||||||
|
<NavDropdown.Item as={RouterLink} routeName={'groupList'} children={'Список'}/>
|
||||||
|
{
|
||||||
|
this.userStore.isAdministrator &&
|
||||||
|
<NavDropdown.Item onClick={this.addGroupModalState.open} children={'Добавить'}/>
|
||||||
|
}
|
||||||
|
</NavDropdown>
|
||||||
</Nav>
|
</Nav>
|
||||||
|
|
||||||
<Nav className="ms-auto">
|
<Nav className="ms-auto">
|
||||||
{
|
{
|
||||||
thinking &&
|
userThink &&
|
||||||
<FontAwesomeIcon icon='gear' spin/>
|
<FontAwesomeIcon icon='gear' spin/>
|
||||||
}
|
}
|
||||||
{
|
{
|
||||||
user.authenticated && !thinking &&
|
this.userStore.authenticated && !userThink &&
|
||||||
<AuthenticatedItems/>
|
<AuthenticatedItems/>
|
||||||
}
|
}
|
||||||
{
|
{
|
||||||
!user.authenticated && !thinking &&
|
!this.userStore.authenticated && !userThink &&
|
||||||
<>
|
<Nav.Link onClick={this.loginModalState.open}>Войти</Nav.Link>
|
||||||
<Nav.Link onClick={this.loginModalState.open}>Войти</Nav.Link>
|
|
||||||
</>
|
|
||||||
}
|
}
|
||||||
</Nav>
|
</Nav>
|
||||||
</Container>
|
</Container>
|
||||||
</Navbar>
|
</Navbar>
|
||||||
</header>
|
</header>
|
||||||
<LoginModal modalState={this.loginModalState}/>
|
<UserLoginModal modalState={this.loginModalState}/>
|
||||||
<CreateGroupModal modalState={this.createGroupModalState}/>
|
<AddGroupModal modalState={this.addGroupModalState}/>
|
||||||
<UserRegistrationModal modalState={this.userRegistrationModalState}/>
|
<UserRegistrationModal modalState={this.userRegistrationModalState}/>
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
@ -82,9 +74,6 @@ class Header extends ComponentContext {
|
|||||||
|
|
||||||
@observer
|
@observer
|
||||||
class AuthenticatedItems extends ComponentContext<any, any> {
|
class AuthenticatedItems extends ComponentContext<any, any> {
|
||||||
declare context: RootStoreContextType;
|
|
||||||
static contextType = RootStoreContext;
|
|
||||||
|
|
||||||
constructor(props: any) {
|
constructor(props: any) {
|
||||||
super(props);
|
super(props);
|
||||||
makeObservable(this);
|
makeObservable(this);
|
||||||
@ -92,17 +81,17 @@ class AuthenticatedItems extends ComponentContext<any, any> {
|
|||||||
|
|
||||||
@action.bound
|
@action.bound
|
||||||
logout() {
|
logout() {
|
||||||
post('user/logout').then(() => this.context.userStore.updateCurrentUser());
|
post('user/logout').then(() => {
|
||||||
|
this.userStore.updateCurrentUser();
|
||||||
|
this.routerStore.goTo('home').then();
|
||||||
|
this.notificationStore.success('Вы успешно вышли из системы', 'Выход');
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
const userStore = this.context.userStore;
|
|
||||||
const user = userStore.user;
|
|
||||||
|
|
||||||
return <>
|
return <>
|
||||||
<Navbar.Text>Пользователь:</Navbar.Text>
|
<Navbar.Text>Пользователь:</Navbar.Text>
|
||||||
<NavDropdown
|
<NavDropdown title={(this.userStore.user as IAuthenticated).fullName}>
|
||||||
title={(user as IAuthenticated).fullName}>
|
|
||||||
<NavDropdown.Item as={RouterLink} routeName='profile'>Моя страница</NavDropdown.Item>
|
<NavDropdown.Item as={RouterLink} routeName='profile'>Моя страница</NavDropdown.Item>
|
||||||
<NavDropdown.Divider/>
|
<NavDropdown.Divider/>
|
||||||
<NavDropdown.Item onClick={this.logout}>Выйти</NavDropdown.Item>
|
<NavDropdown.Item onClick={this.logout}>Выйти</NavDropdown.Item>
|
||||||
@ -110,5 +99,3 @@ class AuthenticatedItems extends ComponentContext<any, any> {
|
|||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default Header;
|
|
||||||
@ -1,8 +1,8 @@
|
|||||||
import {DefaultPage} from "./DefaultPage";
|
import {Page} from "./Page";
|
||||||
import {observer} from "mobx-react";
|
import {observer} from "mobx-react";
|
||||||
|
|
||||||
@observer
|
@observer
|
||||||
export default class Home extends DefaultPage {
|
export class Home extends Page {
|
||||||
get page() {
|
get page() {
|
||||||
return <h1>Home</h1>
|
return <h1>Home</h1>
|
||||||
}
|
}
|
||||||
@ -1,12 +1,12 @@
|
|||||||
import {ReactNode} from "react";
|
import {ReactNode} from "react";
|
||||||
import {Container} from "react-bootstrap";
|
import {Container} from "react-bootstrap";
|
||||||
import Header from "./Header";
|
import {Header} from "./Header";
|
||||||
import {FontAwesomeIcon} from "@fortawesome/react-fontawesome";
|
import {FontAwesomeIcon} from "@fortawesome/react-fontawesome";
|
||||||
import {ComponentContext} from "../../utils/ComponentContext";
|
import {ComponentContext} from "../../../utils/ComponentContext";
|
||||||
import {NotificationContainer} from "../NotificationContainer";
|
import {NotificationContainer} from "../../NotificationContainer";
|
||||||
import {Footer} from "./Footer";
|
import {Footer} from "./Footer";
|
||||||
|
|
||||||
export abstract class DefaultPage extends ComponentContext {
|
export abstract class Page extends ComponentContext {
|
||||||
get page(): ReactNode {
|
get page(): ReactNode {
|
||||||
throw new Error('This is not abstract method, ' +
|
throw new Error('This is not abstract method, ' +
|
||||||
'because mobx cant handle abstract methods. ' +
|
'because mobx cant handle abstract methods. ' +
|
||||||
@ -20,6 +20,7 @@ export abstract class DefaultPage extends ComponentContext {
|
|||||||
return <>
|
return <>
|
||||||
<Header/>
|
<Header/>
|
||||||
<Container className={"mt-5 mb-5"}>
|
<Container className={"mt-5 mb-5"}>
|
||||||
|
<NotificationContainer/>
|
||||||
{
|
{
|
||||||
thinking &&
|
thinking &&
|
||||||
<div id='fullscreen-loader'>
|
<div id='fullscreen-loader'>
|
||||||
@ -29,7 +30,6 @@ export abstract class DefaultPage extends ComponentContext {
|
|||||||
{
|
{
|
||||||
!thinking &&
|
!thinking &&
|
||||||
<>
|
<>
|
||||||
<NotificationContainer/>
|
|
||||||
{this.page}
|
{this.page}
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
@ -13,20 +13,20 @@ export interface CreateGroupModalProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@observer
|
@observer
|
||||||
export class CreateGroupModal extends ComponentContext<CreateGroupModalProps> {
|
export class AddGroupModal extends ComponentContext<CreateGroupModalProps> {
|
||||||
@observable name = new ReactiveValue<string>()
|
|
||||||
.addValidator(required)
|
|
||||||
.addValidator(strLength(3, 50))
|
|
||||||
.addValidator(strPattern(/^[а-яА-ЯёЁ0-9_-]*$/, "Имя группы должно содержать только русские буквы, дефис, нижнее подчеркивание и цифры"));
|
|
||||||
|
|
||||||
constructor(props: any) {
|
constructor(props: any) {
|
||||||
super(props);
|
super(props);
|
||||||
makeObservable(this);
|
makeObservable(this);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@observable name = new ReactiveValue<string>()
|
||||||
|
.addValidator(required)
|
||||||
|
.addValidator(strLength(3, 50))
|
||||||
|
.addInputRestriction(strPattern(/^[а-яА-ЯёЁ0-9_-]*$/, "Имя группы должно содержать только русские буквы, цифры и символы _-"));
|
||||||
|
|
||||||
@action.bound
|
@action.bound
|
||||||
creationRequest() {
|
creationRequest() {
|
||||||
post<void>('/group/create-group', {name: this.name.value}).then(() => {
|
post<void>('/group/create-group', {name: this.name.value}, false).then(() => {
|
||||||
this.notificationStore.success(`Группа ${this.name.value} создана`);
|
this.notificationStore.success(`Группа ${this.name.value} создана`);
|
||||||
}).finally(() => {
|
}).finally(() => {
|
||||||
this.props.modalState.close();
|
this.props.modalState.close();
|
||||||
@ -39,16 +39,16 @@ export class CreateGroupModal extends ComponentContext<CreateGroupModalProps> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
return <Modal show={this.props.modalState.isOpen}>
|
return <Modal show={this.props.modalState.isOpen} centered>
|
||||||
<ModalHeader>
|
<ModalHeader>
|
||||||
<ModalTitle>Создание группы</ModalTitle>
|
<ModalTitle>Добавление группы</ModalTitle>
|
||||||
</ModalHeader>
|
</ModalHeader>
|
||||||
<ModalBody>
|
<ModalBody>
|
||||||
<StringInput value={this.name} label={'Имя группы'}/>
|
<StringInput value={this.name} label={'Имя группы'}/>
|
||||||
</ModalBody>
|
</ModalBody>
|
||||||
<ModalFooter>
|
<ModalFooter>
|
||||||
<Button onClick={this.creationRequest} disabled={this.formInvalid}>Создать</Button>
|
<Button onClick={this.creationRequest} disabled={this.formInvalid}>Создать</Button>
|
||||||
<Button onClick={this.props.modalState.close}>Закрыть</Button>
|
<Button onClick={this.props.modalState.close} variant={'secondary'}>Закрыть</Button>
|
||||||
</ModalFooter>
|
</ModalFooter>
|
||||||
</Modal>
|
</Modal>
|
||||||
}
|
}
|
||||||
203
web/src/components/group/GroupListPage.tsx
Normal file
203
web/src/components/group/GroupListPage.tsx
Normal file
@ -0,0 +1,203 @@
|
|||||||
|
import {observer} from "mobx-react";
|
||||||
|
import {Page} from "../custom/layout/Page";
|
||||||
|
import {action, computed, makeObservable, observable, reaction, runInAction} from "mobx";
|
||||||
|
import {Column, TableDescriptor} from "../../utils/tables";
|
||||||
|
import {get} from "../../utils/request";
|
||||||
|
import {DataTable} from "../custom/DataTable";
|
||||||
|
import {Group} from "../../models/group";
|
||||||
|
import {Component} from "react";
|
||||||
|
import {Button, Modal, ModalBody, ModalFooter, ModalHeader, ModalTitle} from "react-bootstrap";
|
||||||
|
import {StringInput} from "../custom/controls/ReactiveControls";
|
||||||
|
import {ReactiveValue} from "../../utils/reactive/reactiveValue";
|
||||||
|
import {ModalState} from "../../utils/modalState";
|
||||||
|
import {FontAwesomeIcon} from "@fortawesome/react-fontawesome";
|
||||||
|
import {ComponentContext} from "../../utils/ComponentContext";
|
||||||
|
import {required, strLength, strPattern} from "../../utils/reactive/validators";
|
||||||
|
|
||||||
|
@observer
|
||||||
|
export class GroupListPage extends Page {
|
||||||
|
constructor(props: {}) {
|
||||||
|
super(props);
|
||||||
|
makeObservable(this);
|
||||||
|
reaction(() => this.groups, () => {
|
||||||
|
this.tableDescriptor = new TableDescriptor<Group>(this.groupColumns, this.groups);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidMount() {
|
||||||
|
this.requestGroups();
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidUpdate() {
|
||||||
|
runInAction(() => {
|
||||||
|
this.isAdministrator = this.userStore.isAdministrator;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@observable filterModalState = new ModalState();
|
||||||
|
@observable editModalState = new ModalState();
|
||||||
|
@observable currentGroup: Group;
|
||||||
|
|
||||||
|
@observable groups: Group[];
|
||||||
|
@observable tableDescriptor: TableDescriptor<Group>;
|
||||||
|
@observable isAdministrator: boolean;
|
||||||
|
|
||||||
|
groupColumns = [
|
||||||
|
new Column<Group, string>('name', 'Название', x => x, (grp) => {
|
||||||
|
return this.isAdministrator && <FontAwesomeIcon style={{cursor: 'pointer'}} onClick={() => {
|
||||||
|
this.openEditModal(grp)
|
||||||
|
}} icon={'pen-to-square'}/>
|
||||||
|
}
|
||||||
|
),
|
||||||
|
new Column<Group, string>('curatorName', 'Куратор', (value: string) => value ?? 'Не назначен'),
|
||||||
|
];
|
||||||
|
|
||||||
|
@action.bound
|
||||||
|
requestGroups() {
|
||||||
|
this.thinkStore.think();
|
||||||
|
get<Group[]>('/group/get-all-groups').then(groups => {
|
||||||
|
runInAction(() => {
|
||||||
|
this.groups = groups;
|
||||||
|
});
|
||||||
|
}).finally(() => {
|
||||||
|
this.thinkStore.completeOne();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@action.bound
|
||||||
|
openEditModal(group: Group) {
|
||||||
|
runInAction(() => {
|
||||||
|
this.currentGroup = group;
|
||||||
|
this.editModalState.open();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
get page() {
|
||||||
|
return <>
|
||||||
|
{
|
||||||
|
this.tableDescriptor &&
|
||||||
|
<>
|
||||||
|
<DataTable tableDescriptor={this.tableDescriptor} name={'Группы'} filterModalState={this.filterModalState}/>
|
||||||
|
<GroupListFilterModal modalState={this.filterModalState} filters={this.tableDescriptor.filters}/>
|
||||||
|
{
|
||||||
|
this.currentGroup &&
|
||||||
|
<EditGroupModal modalState={this.editModalState} group={this.currentGroup}/>
|
||||||
|
}
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GroupListFilterProps {
|
||||||
|
modalState: ModalState;
|
||||||
|
filters: ((group: Group) => boolean)[];
|
||||||
|
}
|
||||||
|
|
||||||
|
@observer
|
||||||
|
class GroupListFilterModal extends Component<GroupListFilterProps> {
|
||||||
|
constructor(props: GroupListFilterProps) {
|
||||||
|
super(props);
|
||||||
|
makeObservable(this);
|
||||||
|
|
||||||
|
runInAction(() => {
|
||||||
|
this.filters.push(this.nameFilter);
|
||||||
|
this.filters.push(this.curatorFilter);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@observable filters = this.props.filters;
|
||||||
|
@observable modalState = this.props.modalState;
|
||||||
|
@observable nameField = new ReactiveValue<string>().syncWithParam('name');
|
||||||
|
@observable curatorField = new ReactiveValue<string>().syncWithParam('curator');
|
||||||
|
|
||||||
|
@observable nameFilter = (group: Group) => {
|
||||||
|
if (!this.nameField.value) return true;
|
||||||
|
return group.name?.includes(this.nameField.value)
|
||||||
|
};
|
||||||
|
|
||||||
|
@observable curatorFilter = (group: Group) => {
|
||||||
|
if (!this.curatorField.value) return true;
|
||||||
|
return group.curatorName?.includes(this.curatorField.value)
|
||||||
|
};
|
||||||
|
|
||||||
|
@action.bound
|
||||||
|
reset() {
|
||||||
|
this.nameField.set("");
|
||||||
|
this.curatorField.set("");
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
return <Modal show={this.modalState.isOpen} centered>
|
||||||
|
<ModalHeader>
|
||||||
|
<ModalTitle>Фильтр</ModalTitle>
|
||||||
|
</ModalHeader>
|
||||||
|
<ModalBody>
|
||||||
|
<StringInput value={this.nameField} label={'Название'} validateless/>
|
||||||
|
<StringInput value={this.curatorField} label={'Куратор'} validateless/>
|
||||||
|
</ModalBody>
|
||||||
|
<ModalFooter>
|
||||||
|
<Button onClick={this.reset} variant={'secondary'}>Сбросить</Button>
|
||||||
|
<Button onClick={this.modalState.close} variant={'outline-secondary'}>Закрыть</Button>
|
||||||
|
</ModalFooter>
|
||||||
|
</Modal>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface EditGroupModalProps {
|
||||||
|
modalState: ModalState;
|
||||||
|
group: Group
|
||||||
|
}
|
||||||
|
|
||||||
|
@observer
|
||||||
|
class EditGroupModal extends ComponentContext<EditGroupModalProps> {
|
||||||
|
constructor(props: EditGroupModalProps) {
|
||||||
|
super(props);
|
||||||
|
makeObservable(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidUpdate() {
|
||||||
|
runInAction(() => {
|
||||||
|
this.group = this.props.group;
|
||||||
|
this.nameField.setAuto(this.group.name);
|
||||||
|
this.curatorField.setAuto(this.group.curatorName ?? '');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@observable group = this.props.group;
|
||||||
|
@observable modalState = this.props.modalState;
|
||||||
|
|
||||||
|
@observable nameField = new ReactiveValue<string>().setAuto(this.group.name)
|
||||||
|
.addValidator(required)
|
||||||
|
.addValidator(strLength(3, 50))
|
||||||
|
.addInputRestriction(strPattern(/^[а-яА-ЯёЁ0-9_-]*$/, "Имя группы должно содержать только русские буквы, цифры и символы _-"));
|
||||||
|
@observable curatorField = new ReactiveValue<string>().setAuto(this.group.curatorName ?? '');
|
||||||
|
|
||||||
|
@action.bound
|
||||||
|
save() {
|
||||||
|
this.notificationStore.warn('Приносим извинения за неудобства', 'Сохранение не реализовано');
|
||||||
|
this.modalState.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
@computed
|
||||||
|
get formInvalid() {
|
||||||
|
return this.nameField.invalid || !this.nameField.touched
|
||||||
|
|| this.curatorField.invalid || !this.curatorField.touched;
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
return <Modal show={this.modalState.isOpen} centered>
|
||||||
|
<ModalHeader>
|
||||||
|
<ModalTitle>Редактирование группы {this.group.name}</ModalTitle>
|
||||||
|
</ModalHeader>
|
||||||
|
<ModalBody>
|
||||||
|
<StringInput value={this.nameField} label={'Название'}/>
|
||||||
|
<StringInput value={this.curatorField} label={'Куратор'}/>
|
||||||
|
</ModalBody>
|
||||||
|
<ModalFooter>
|
||||||
|
<Button onClick={this.save} variant={'primary'} disabled={this.formInvalid}>Сохранить</Button>
|
||||||
|
<Button onClick={this.modalState.close} variant={'outline-secondary'}>Закрыть</Button>
|
||||||
|
</ModalFooter>
|
||||||
|
</Modal>
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,142 +0,0 @@
|
|||||||
import {ChangeEvent} from "react";
|
|
||||||
import {Button, FormControl, FormGroup, FormLabel, FormText, Modal} from "react-bootstrap";
|
|
||||||
import {ModalState} from "../../utils/modalState";
|
|
||||||
import {observer} from "mobx-react";
|
|
||||||
import {action, computed, makeObservable, observable, reaction} from "mobx";
|
|
||||||
import {post} from "../../utils/request";
|
|
||||||
import {FontAwesomeIcon} from "@fortawesome/react-fontawesome";
|
|
||||||
import {ComponentContext} from "../../utils/ComponentContext";
|
|
||||||
|
|
||||||
interface LoginModalProps {
|
|
||||||
modalState: ModalState;
|
|
||||||
}
|
|
||||||
|
|
||||||
@observer
|
|
||||||
export class LoginModal extends ComponentContext<LoginModalProps> {
|
|
||||||
@observable login = '';
|
|
||||||
@observable loginError = '';
|
|
||||||
@observable password = '';
|
|
||||||
@observable passwordError = '';
|
|
||||||
|
|
||||||
constructor(props: LoginModalProps) {
|
|
||||||
super(props);
|
|
||||||
makeObservable(this);
|
|
||||||
reaction(() => this.login, this.validateLogin);
|
|
||||||
reaction(() => this.password, this.validatePassword);
|
|
||||||
}
|
|
||||||
|
|
||||||
@action.bound
|
|
||||||
validateLogin() {
|
|
||||||
if (!this.login) {
|
|
||||||
this.loginError = 'Имя пользователя не может быть пустым';
|
|
||||||
} else if (this.login.length < 5) {
|
|
||||||
this.loginError = 'Имя пользователя должно быть не менее 5 символов';
|
|
||||||
} else if (this.login.length > 50) {
|
|
||||||
this.loginError = 'Имя пользователя должно быть не более 50 символов';
|
|
||||||
} else if (!/^[a-zA-Z0-9_]+$/.test(this.login)) {
|
|
||||||
this.loginError = 'Имя пользователя должно содержать только латинские буквы, цифры и знак подчеркивания';
|
|
||||||
} else {
|
|
||||||
this.loginError = '';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@action.bound
|
|
||||||
validatePassword() {
|
|
||||||
if (!this.password) {
|
|
||||||
this.passwordError = 'Пароль не может быть пустым';
|
|
||||||
} else if (this.password.length < 5) {
|
|
||||||
this.passwordError = 'Пароль должен быть не менее 5 символов';
|
|
||||||
} else if (this.password.length > 32) {
|
|
||||||
this.passwordError = 'Пароль должен быть не более 32 символов';
|
|
||||||
} else if (!/^[a-zA-Z0-9!@#$%^&*()_+]+$/.test(this.password)) {
|
|
||||||
this.passwordError = 'Пароль должен содержать только латинские буквы, цифры и специальные символы';
|
|
||||||
} else {
|
|
||||||
this.passwordError = '';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@computed
|
|
||||||
get loginButtonDisabled() {
|
|
||||||
return !this.login || !this.password || !!this.loginError || !!this.passwordError;
|
|
||||||
}
|
|
||||||
|
|
||||||
@action.bound
|
|
||||||
onLoginInput(event: ChangeEvent<HTMLInputElement>) {
|
|
||||||
this.login = event.target.value;
|
|
||||||
}
|
|
||||||
|
|
||||||
@action.bound
|
|
||||||
onPasswordInput(event: ChangeEvent<HTMLInputElement>) {
|
|
||||||
this.password = event.target.value;
|
|
||||||
}
|
|
||||||
|
|
||||||
@action.bound
|
|
||||||
tryLogin() {
|
|
||||||
if (this.loginButtonDisabled)
|
|
||||||
return;
|
|
||||||
|
|
||||||
this.thinkStore.think('loginModal');
|
|
||||||
post('user/login', {
|
|
||||||
username: this.login,
|
|
||||||
password: this.password
|
|
||||||
}).then(() => {
|
|
||||||
this.userStore.updateCurrentUser((user) => {
|
|
||||||
if (user.authenticated) {
|
|
||||||
this.routerStore.goTo('profile').then();
|
|
||||||
this.notificationStore.success('Вы успешно вошли в систему, ' + user.fullName, 'Успешный вход');
|
|
||||||
} else {
|
|
||||||
this.routerStore.goTo('root').then();
|
|
||||||
this.notificationStore.error('Произошла ошибка при попытке входа в систему', 'Ошибка входа');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}).finally(() => {
|
|
||||||
this.props.modalState.close();
|
|
||||||
this.thinkStore.completeAll('loginModal');
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
render() {
|
|
||||||
const open = this.props.modalState.isOpen;
|
|
||||||
const thinking = this.thinkStore.isThinking('loginModal');
|
|
||||||
|
|
||||||
return <Modal show={open} centered>
|
|
||||||
<Modal.Header>
|
|
||||||
<Modal.Title>Вход</Modal.Title>
|
|
||||||
</Modal.Header>
|
|
||||||
{
|
|
||||||
thinking &&
|
|
||||||
<Modal.Body>
|
|
||||||
<FontAwesomeIcon icon={'gear'} spin/>
|
|
||||||
</Modal.Body>
|
|
||||||
}
|
|
||||||
{
|
|
||||||
!thinking &&
|
|
||||||
<>
|
|
||||||
<Modal.Body>
|
|
||||||
<FormGroup className={'mb-3'}>
|
|
||||||
<FormLabel>Имя пользователя</FormLabel>
|
|
||||||
<FormControl type="text" onChange={this.onLoginInput}/>
|
|
||||||
{
|
|
||||||
this.loginError &&
|
|
||||||
<FormText className={'text-danger'}>{this.loginError}</FormText>
|
|
||||||
}
|
|
||||||
</FormGroup>
|
|
||||||
<FormGroup className={'mb-3'}>
|
|
||||||
<FormLabel>Пароль</FormLabel>
|
|
||||||
<FormControl type="password" onChange={this.onPasswordInput}/>
|
|
||||||
{
|
|
||||||
this.passwordError &&
|
|
||||||
<FormText className={'text-danger'}>{this.passwordError}</FormText>
|
|
||||||
}
|
|
||||||
</FormGroup>
|
|
||||||
</Modal.Body>
|
|
||||||
<Modal.Footer>
|
|
||||||
<Button variant={'primary'} onClick={this.tryLogin}
|
|
||||||
disabled={this.loginButtonDisabled}>Войти</Button>
|
|
||||||
<Button variant={'secondary'} onClick={this.props.modalState.close}>Закрыть</Button>
|
|
||||||
</Modal.Footer>
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
</Modal>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,13 +1,13 @@
|
|||||||
import {observer} from "mobx-react";
|
import {observer} from "mobx-react";
|
||||||
import {action, makeObservable, observable, reaction, runInAction} from "mobx";
|
import {action, makeObservable, observable, reaction, runInAction} from "mobx";
|
||||||
import {DefaultPage} from "../layout/DefaultPage";
|
import {Page} from "../custom/layout/Page";
|
||||||
import {IAuthenticated} from "../../models/user";
|
import {IAuthenticated} from "../../models/user";
|
||||||
import {DataTable} from "../custom/DataTable";
|
import {DataTable} from "../custom/DataTable";
|
||||||
import {get} from "../../utils/request";
|
import {get} from "../../utils/request";
|
||||||
import {Column, TableDescriptor} from "../../utils/tables";
|
import {Column, TableDescriptor} from "../../utils/tables";
|
||||||
|
|
||||||
@observer
|
@observer
|
||||||
export class UserList extends DefaultPage {
|
export class UserListPage extends Page {
|
||||||
constructor(props: {}) {
|
constructor(props: {}) {
|
||||||
super(props);
|
super(props);
|
||||||
makeObservable(this);
|
makeObservable(this);
|
||||||
@ -53,7 +53,7 @@ export class UserList extends DefaultPage {
|
|||||||
return <>
|
return <>
|
||||||
{
|
{
|
||||||
this.tableDescriptor &&
|
this.tableDescriptor &&
|
||||||
<DataTable tableDescriptor={this.tableDescriptor}/>
|
<DataTable tableDescriptor={this.tableDescriptor} name={'Пользователи'}/>
|
||||||
}
|
}
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
92
web/src/components/user/UserLoginModal.tsx
Normal file
92
web/src/components/user/UserLoginModal.tsx
Normal file
@ -0,0 +1,92 @@
|
|||||||
|
import {Button, Modal} from "react-bootstrap";
|
||||||
|
import {ModalState} from "../../utils/modalState";
|
||||||
|
import {observer} from "mobx-react";
|
||||||
|
import {action, computed, makeObservable, observable} from "mobx";
|
||||||
|
import {post} from "../../utils/request";
|
||||||
|
import {FontAwesomeIcon} from "@fortawesome/react-fontawesome";
|
||||||
|
import {ComponentContext} from "../../utils/ComponentContext";
|
||||||
|
import {ReactiveValue} from "../../utils/reactive/reactiveValue";
|
||||||
|
import {loginChars, loginLength, loginMaxLength, password, passwordChars, passwordLength, passwordMaxLength, required} from "../../utils/reactive/validators";
|
||||||
|
import {PasswordInput, StringInput} from "../custom/controls/ReactiveControls";
|
||||||
|
|
||||||
|
interface LoginModalProps {
|
||||||
|
modalState: ModalState;
|
||||||
|
}
|
||||||
|
|
||||||
|
@observer
|
||||||
|
export class UserLoginModal extends ComponentContext<LoginModalProps> {
|
||||||
|
constructor(props: LoginModalProps) {
|
||||||
|
super(props);
|
||||||
|
makeObservable(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
@observable login = new ReactiveValue<string>()
|
||||||
|
.addValidator(required).addValidator(loginLength)
|
||||||
|
.addInputRestriction(loginChars).addInputRestriction(loginMaxLength);
|
||||||
|
@observable password = new ReactiveValue<string>()
|
||||||
|
.addValidator(required).addValidator(password).addValidator(passwordLength)
|
||||||
|
.addInputRestriction(passwordChars).addInputRestriction(passwordMaxLength);
|
||||||
|
|
||||||
|
@computed
|
||||||
|
get modalInvalid() {
|
||||||
|
return this.login.invalid || !this.login.touched
|
||||||
|
|| this.password.invalid || !this.password.touched;
|
||||||
|
}
|
||||||
|
|
||||||
|
@action.bound
|
||||||
|
tryLogin() {
|
||||||
|
if (this.modalInvalid)
|
||||||
|
return;
|
||||||
|
|
||||||
|
this.thinkStore.think('loginModal');
|
||||||
|
post('user/login', {
|
||||||
|
login: this.login.value,
|
||||||
|
password: this.password.value
|
||||||
|
}).then(() => {
|
||||||
|
this.userStore.updateCurrentUser((user) => {
|
||||||
|
if (user.authenticated) {
|
||||||
|
this.routerStore.goTo('profile').then();
|
||||||
|
this.notificationStore.success('Вы успешно вошли в систему, ' + user.fullName, 'Успешный вход');
|
||||||
|
} else {
|
||||||
|
this.routerStore.goTo('root').then();
|
||||||
|
this.notificationStore.error('Произошла ошибка при попытке входа в систему', 'Ошибка входа');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}).finally(() => {
|
||||||
|
this.props.modalState.close();
|
||||||
|
this.thinkStore.completeAll('loginModal');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
const open = this.props.modalState.isOpen;
|
||||||
|
const thinking = this.thinkStore.isThinking('loginModal');
|
||||||
|
|
||||||
|
return <Modal show={open} centered>
|
||||||
|
<Modal.Header>
|
||||||
|
<Modal.Title>Вход</Modal.Title>
|
||||||
|
</Modal.Header>
|
||||||
|
{
|
||||||
|
thinking &&
|
||||||
|
<Modal.Body>
|
||||||
|
<div className={'text-center'}>
|
||||||
|
<FontAwesomeIcon icon={'gear'} spin size={'4x'}/>
|
||||||
|
</div>
|
||||||
|
</Modal.Body>
|
||||||
|
}
|
||||||
|
{
|
||||||
|
!thinking &&
|
||||||
|
<>
|
||||||
|
<Modal.Body>
|
||||||
|
<StringInput value={this.login} label={'Логин'}/>
|
||||||
|
<PasswordInput value={this.password} label={'Пароль'}/>
|
||||||
|
</Modal.Body>
|
||||||
|
<Modal.Footer>
|
||||||
|
<Button variant={'primary'} onClick={this.tryLogin} disabled={this.modalInvalid}>Войти</Button>
|
||||||
|
<Button variant={'secondary'} onClick={this.props.modalState.close}>Закрыть</Button>
|
||||||
|
</Modal.Footer>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
</Modal>
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,77 +1,90 @@
|
|||||||
import {DefaultPage} from "../layout/DefaultPage";
|
import {Page} from "../custom/layout/Page";
|
||||||
import {Col, Form, Row} from "react-bootstrap";
|
import {Col, Form, Row} from "react-bootstrap";
|
||||||
import {observer} from "mobx-react";
|
import {observer} from "mobx-react";
|
||||||
import {RootStoreContext, type RootStoreContextType} from "../../store/RootStoreContext";
|
|
||||||
import {IAuthenticated} from "../../models/user";
|
|
||||||
import {Component} from "react";
|
|
||||||
import {dateConverter} from "../../utils/converters";
|
|
||||||
import {IStudent} from "../../models/student";
|
import {IStudent} from "../../models/student";
|
||||||
import {makeObservable, observable} from "mobx";
|
import {action, makeObservable, observable, runInAction} from "mobx";
|
||||||
|
import {IAuthenticated} from "../../models/user";
|
||||||
|
import {ComponentContext} from "../../utils/ComponentContext";
|
||||||
|
import {getAuthorityByCode} from "../../models/authorities";
|
||||||
|
import {dateConverter} from "../../utils/converters";
|
||||||
|
|
||||||
@observer
|
@observer
|
||||||
class UserInfo extends Component<{user: IAuthenticated}> {
|
class UserInfo extends ComponentContext {
|
||||||
@observable
|
|
||||||
user = this.props.user;
|
|
||||||
|
|
||||||
constructor(props: any) {
|
constructor(props: any) {
|
||||||
super(props);
|
super(props);
|
||||||
makeObservable(this);
|
makeObservable(this);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
componentDidMount() {
|
||||||
|
runInAction(() => {
|
||||||
|
this.user = this.userStore.user as IAuthenticated;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@observable user: IAuthenticated;
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
return (
|
return <div>
|
||||||
<Row>
|
{
|
||||||
<Col sm={6}>
|
this.user &&
|
||||||
<Form.Group className={"mt-2"}>
|
<Row>
|
||||||
<Form.Label column={"sm"}>ФИО</Form.Label>
|
<Col sm={6}>
|
||||||
<Form.Control type="text" value={this.user.fullName} disabled={true}/>
|
<Form.Group className={"mt-2"}>
|
||||||
</Form.Group>
|
<Form.Label column={"sm"}>ФИО</Form.Label>
|
||||||
<Form.Group className={"mt-2"}>
|
<Form.Control type="text" value={this.user.fullName} disabled={true}/>
|
||||||
<Form.Label column={"sm"}>Имя пользователя</Form.Label>
|
</Form.Group>
|
||||||
<Form.Control type="text" value={this.user.login} disabled={true}/>
|
<Form.Group className={"mt-2"}>
|
||||||
</Form.Group>
|
<Form.Label column={"sm"}>Имя пользователя</Form.Label>
|
||||||
<Form.Group className={"mt-2"}>
|
<Form.Control type="text" value={this.user.login} disabled={true}/>
|
||||||
<Form.Label column={"sm"}>Электронная почта</Form.Label>
|
</Form.Group>
|
||||||
<Form.Control type="email" value={this.user.email} disabled={true}/>
|
<Form.Group className={"mt-2"}>
|
||||||
</Form.Group>
|
<Form.Label column={"sm"}>Электронная почта</Form.Label>
|
||||||
<Form.Group className={"mt-2"}>
|
<Form.Control type="email" value={this.user.email} disabled={true}/>
|
||||||
<Form.Label column={"sm"}>Телефон</Form.Label>
|
</Form.Group>
|
||||||
{/* todo: format phone */}
|
<Form.Group className={"mt-2"}>
|
||||||
<Form.Control type="text" value={this.user.phone} disabled={true}/>
|
<Form.Label column={"sm"}>Телефон</Form.Label>
|
||||||
</Form.Group>
|
{/* todo: format phone */}
|
||||||
</Col>
|
<Form.Control type="text" value={this.user.phone} disabled={true}/>
|
||||||
<Col sm={6}>
|
</Form.Group>
|
||||||
<Form.Group className={"mt-2"}>
|
</Col>
|
||||||
<Form.Label column={"sm"}>Роли</Form.Label>
|
<Col sm={6}>
|
||||||
<Form.Control
|
<Form.Group className={"mt-2"}>
|
||||||
type="text"
|
<Form.Label column={"sm"}>Роли</Form.Label>
|
||||||
value={this.user.authorities?.map(a => a.name).join(', ')}
|
<Form.Control
|
||||||
disabled={true}/>
|
type="text"
|
||||||
</Form.Group>
|
value={this.user.authorities?.map(getAuthorityByCode).map(a => a.code).join(', ')}
|
||||||
<Form.Group className={"mt-2"}>
|
disabled={true}/>
|
||||||
<Form.Label column={"sm"}>Дата создания</Form.Label>
|
</Form.Group>
|
||||||
<Form.Control type="text" value={dateConverter(this.user.createdAt)} disabled={true}/>
|
<Form.Group className={"mt-2"}>
|
||||||
</Form.Group>
|
<Form.Label column={"sm"}>Дата создания</Form.Label>
|
||||||
<Form.Group className={"mt-2"}>
|
<Form.Control type="text" value={dateConverter(this.user.createdAt)} disabled={true}/>
|
||||||
<Form.Label column={"sm"}>Дата последней модификации</Form.Label>
|
</Form.Group>
|
||||||
<Form.Control type="text" value={dateConverter(this.user.updatedAt)} disabled={true}/>
|
<Form.Group className={"mt-2"}>
|
||||||
</Form.Group>
|
<Form.Label column={"sm"}>Дата последней модификации</Form.Label>
|
||||||
</Col>
|
<Form.Control type="text" value={dateConverter(this.user.updatedAt)} disabled={true}/>
|
||||||
</Row>
|
</Form.Group>
|
||||||
)
|
</Col>
|
||||||
|
</Row>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@observer
|
@observer
|
||||||
class StudentInfo extends Component<{student: IStudent}> {
|
class StudentInfo extends ComponentContext {
|
||||||
@observable
|
|
||||||
student = this.props.student;
|
|
||||||
|
|
||||||
constructor(props: any) {
|
constructor(props: any) {
|
||||||
super(props);
|
super(props);
|
||||||
makeObservable(this);
|
makeObservable(this);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
componentDidMount() {
|
||||||
|
runInAction(() => {
|
||||||
|
this.student = this.userStore.student;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@observable student: IStudent;
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
let student = this.student;
|
let student = this.student;
|
||||||
|
|
||||||
@ -151,27 +164,58 @@ class StudentInfo extends Component<{student: IStudent}> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default class UserProfilePage extends DefaultPage {
|
@observer
|
||||||
declare context: RootStoreContextType;
|
export class UserProfilePage extends Page {
|
||||||
static contextType = RootStoreContext;
|
constructor(props: any) {
|
||||||
|
super(props);
|
||||||
|
makeObservable(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidMount() {
|
||||||
|
runInAction(() => {
|
||||||
|
this.user = this.userStore.user as IAuthenticated;
|
||||||
|
this.student = this.userStore.student;
|
||||||
|
});
|
||||||
|
this.redirectIfNotAuthenticated();
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidUpdate() {
|
||||||
|
runInAction(() => {
|
||||||
|
this.user = this.userStore.user as IAuthenticated;
|
||||||
|
this.student = this.userStore.student;
|
||||||
|
});
|
||||||
|
this.redirectIfNotAuthenticated();
|
||||||
|
}
|
||||||
|
|
||||||
|
@observable user: IAuthenticated;
|
||||||
|
@observable student: IStudent;
|
||||||
|
|
||||||
|
@action.bound
|
||||||
|
redirectIfNotAuthenticated() {
|
||||||
|
if (this.thinkStore.isThinking('updateCurrentUser')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.user.authenticated) {
|
||||||
|
this.routerStore.goToNotFound();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
get page() {
|
get page() {
|
||||||
let user = this.context.userStore.user;
|
return <>
|
||||||
if (!user.authenticated) {
|
|
||||||
// todo: implement login page with redirects
|
|
||||||
this.context.routerStore.goTo('login', {redirect: 'profile'});
|
|
||||||
}
|
|
||||||
let student = this.context.userStore.student;
|
|
||||||
|
|
||||||
return <Form>
|
|
||||||
{
|
{
|
||||||
user.authenticated &&
|
!this.thinkStore.isThinking('updateCurrentUser') &&
|
||||||
<UserInfo user={user}/>
|
<Form>
|
||||||
|
{
|
||||||
|
this.user?.authenticated &&
|
||||||
|
<UserInfo/>
|
||||||
|
}
|
||||||
|
{
|
||||||
|
this.student &&
|
||||||
|
<StudentInfo/>
|
||||||
|
}
|
||||||
|
</Form>
|
||||||
}
|
}
|
||||||
{
|
</>
|
||||||
student && user.authenticated &&
|
|
||||||
<StudentInfo student={student}/>
|
|
||||||
}
|
|
||||||
</Form>
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4,20 +4,26 @@ import {Button, Col, Modal, ModalBody, ModalFooter, ModalHeader, ModalTitle, Row
|
|||||||
import {UserRegistrationDTO} from "../../models/registration";
|
import {UserRegistrationDTO} from "../../models/registration";
|
||||||
import {post} from "../../utils/request";
|
import {post} from "../../utils/request";
|
||||||
import {ReactiveValue} from "../../utils/reactive/reactiveValue";
|
import {ReactiveValue} from "../../utils/reactive/reactiveValue";
|
||||||
import {PasswordInput, SelectButtonInput, StringInput} from "../custom/controls/ReactiveControls";
|
import {PasswordInput, StringInput} from "../custom/controls/ReactiveControls";
|
||||||
import {
|
import {
|
||||||
email,
|
email,
|
||||||
|
emailChars,
|
||||||
loginChars,
|
loginChars,
|
||||||
loginLength,
|
loginLength,
|
||||||
|
loginMaxLength,
|
||||||
nameChars,
|
nameChars,
|
||||||
nameLength,
|
nameLength,
|
||||||
|
password,
|
||||||
passwordChars,
|
passwordChars,
|
||||||
passwordLength,
|
passwordLength,
|
||||||
|
passwordMaxLength,
|
||||||
phone,
|
phone,
|
||||||
|
phoneChars,
|
||||||
required
|
required
|
||||||
} from "../../utils/reactive/validators";
|
} from "../../utils/reactive/validators";
|
||||||
import {ComponentContext} from "../../utils/ComponentContext";
|
import {ComponentContext} from "../../utils/ComponentContext";
|
||||||
import {ModalState} from "../../utils/modalState";
|
import {ModalState} from "../../utils/modalState";
|
||||||
|
import {FontAwesomeIcon} from "@fortawesome/react-fontawesome";
|
||||||
|
|
||||||
export interface UserRegistrationModalProps {
|
export interface UserRegistrationModalProps {
|
||||||
modalState: ModalState;
|
modalState: ModalState;
|
||||||
@ -30,11 +36,22 @@ export class UserRegistrationModal extends ComponentContext<UserRegistrationModa
|
|||||||
makeObservable(this);
|
makeObservable(this);
|
||||||
}
|
}
|
||||||
|
|
||||||
@observable login = new ReactiveValue<string>().addValidator(required).addValidator(loginLength).addValidator(loginChars);
|
@observable login = new ReactiveValue<string>()
|
||||||
@observable password = new ReactiveValue<string>().addValidator(required).addValidator(passwordLength).addValidator(passwordChars);
|
.addValidator(required).addValidator(loginLength)
|
||||||
@observable fullName = new ReactiveValue<string>().addValidator(required).addValidator(nameLength).addValidator(nameChars);
|
.addInputRestriction(loginChars).addInputRestriction(loginMaxLength);
|
||||||
@observable email = new ReactiveValue<string>().addValidator(required).addValidator(email);
|
@observable password = new ReactiveValue<string>()
|
||||||
@observable numberPhone = new ReactiveValue<string>().addValidator(required).addValidator(phone).setAuto('+7');
|
.addValidator(required).addValidator(password).addValidator(passwordLength)
|
||||||
|
.addInputRestriction(passwordChars).addInputRestriction(passwordMaxLength);
|
||||||
|
@observable fullName = new ReactiveValue<string>()
|
||||||
|
.addValidator(required).addValidator(nameLength)
|
||||||
|
.addInputRestriction(nameChars);
|
||||||
|
@observable email = new ReactiveValue<string>()
|
||||||
|
.addValidator(required).addValidator(email)
|
||||||
|
.addInputRestriction(emailChars);
|
||||||
|
@observable numberPhone = new ReactiveValue<string>().setAuto('+7')
|
||||||
|
.addValidator(required).addValidator(phone)
|
||||||
|
.addInputRestriction(phoneChars);
|
||||||
|
@observable userKindEnum = new ReactiveValue<string>().addValidator(required);
|
||||||
|
|
||||||
@observable accountType = new ReactiveValue<string>().addValidator(required).addValidator((value) => {
|
@observable accountType = new ReactiveValue<string>().addValidator(required).addValidator((value) => {
|
||||||
if (!['student', 'admin'].includes(value)) {
|
if (!['student', 'admin'].includes(value)) {
|
||||||
@ -49,12 +66,17 @@ export class UserRegistrationModal extends ComponentContext<UserRegistrationModa
|
|||||||
|| this.fullName.invalid || !this.fullName.touched
|
|| this.fullName.invalid || !this.fullName.touched
|
||||||
|| this.email.invalid || !this.email.touched
|
|| this.email.invalid || !this.email.touched
|
||||||
|| this.numberPhone.invalid || !this.numberPhone.touched
|
|| this.numberPhone.invalid || !this.numberPhone.touched
|
||||||
|| this.accountType.invalid || !this.accountType.touched;
|
|| this.accountType.invalid || !this.accountType.touched
|
||||||
|
|| this.thinkStore.isThinking('userRegistration');
|
||||||
}
|
}
|
||||||
|
|
||||||
@action.bound
|
@action.bound
|
||||||
submit() {
|
submit() {
|
||||||
post('user/register', {
|
if (this.formInvalid)
|
||||||
|
return;
|
||||||
|
|
||||||
|
this.thinkStore.think('userRegistration');
|
||||||
|
post<void>('user/register', {
|
||||||
login: this.login.value,
|
login: this.login.value,
|
||||||
password: this.password.value,
|
password: this.password.value,
|
||||||
fullName: this.fullName.value,
|
fullName: this.fullName.value,
|
||||||
@ -65,29 +87,43 @@ export class UserRegistrationModal extends ComponentContext<UserRegistrationModa
|
|||||||
this.notificationStore.success('Пользователь успешно зарегистрирован');
|
this.notificationStore.success('Пользователь успешно зарегистрирован');
|
||||||
}).catch(() => {
|
}).catch(() => {
|
||||||
this.notificationStore.error('Ошибка регистрации пользователя');
|
this.notificationStore.error('Ошибка регистрации пользователя');
|
||||||
|
}).finally(() => {
|
||||||
|
this.thinkStore.completeOne('userRegistration');
|
||||||
|
this.props.modalState.close();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
return <Modal show={this.props.modalState.isOpen} size={'lg'}>
|
const thinking = this.thinkStore.isThinking('userRegistration');
|
||||||
|
|
||||||
|
return <Modal show={this.props.modalState.isOpen} size={'lg'} centered>
|
||||||
<ModalHeader>
|
<ModalHeader>
|
||||||
<ModalTitle>Регистрация пользователя</ModalTitle>
|
<ModalTitle>Регистрация пользователя</ModalTitle>
|
||||||
</ModalHeader>
|
</ModalHeader>
|
||||||
|
{
|
||||||
<ModalBody>
|
thinking &&
|
||||||
<Row>
|
<Modal.Body>
|
||||||
<Col>
|
<div className={'text-center'}>
|
||||||
<StringInput value={this.login} label={"Логин"}/>
|
<FontAwesomeIcon icon={'gear'} spin size={'4x'}/>
|
||||||
<PasswordInput value={this.password} label={"Пароль"}/>
|
</div>
|
||||||
</Col>
|
</Modal.Body>
|
||||||
<Col>
|
}
|
||||||
<StringInput value={this.fullName} label={"Полное имя"}/>
|
{
|
||||||
<StringInput value={this.email} label={"Почта"}/>
|
!thinking &&
|
||||||
<StringInput value={this.numberPhone} label={"Телефон"}/>
|
<ModalBody>
|
||||||
</Col>
|
<Row>
|
||||||
</Row>
|
<Col>
|
||||||
<SelectButtonInput value={this.accountType} label={'Тип аккаунта'}/>
|
<StringInput value={this.login} label={"Логин"}/>
|
||||||
</ModalBody>
|
<PasswordInput value={this.password} label={"Пароль"}/>
|
||||||
|
</Col>
|
||||||
|
<Col>
|
||||||
|
<StringInput value={this.fullName} label={"Полное имя"}/>
|
||||||
|
<StringInput value={this.email} label={"Почта"}/>
|
||||||
|
<StringInput value={this.numberPhone} label={"Телефон"}/>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
</ModalBody>
|
||||||
|
}
|
||||||
<ModalFooter>
|
<ModalFooter>
|
||||||
<Button disabled={this.formInvalid} onClick={this.submit}>Зарегистрировать</Button>
|
<Button disabled={this.formInvalid} onClick={this.submit}>Зарегистрировать</Button>
|
||||||
<Button onClick={this.props.modalState.close} variant={'secondary'}>Закрыть</Button>
|
<Button onClick={this.props.modalState.close} variant={'secondary'}>Закрыть</Button>
|
||||||
|
|||||||
20
web/src/models/authorities.ts
Normal file
20
web/src/models/authorities.ts
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
export interface IAuthority {
|
||||||
|
code: string;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Authorities = Object.freeze({
|
||||||
|
TEACHER: {code: "ROLE_TEACHER", name: "Преподаватель"} as IAuthority,
|
||||||
|
STUDENT: {code: "ROLE_STUDENT", name: "Студент"} as IAuthority,
|
||||||
|
COMMISSION_MEMBER: {code: "ROLE_COMMISSION_MEMBER", name: "Член комиссии ГЭК"} as IAuthority,
|
||||||
|
ADMINISTRATOR: {code: "ROLE_ADMINISTRATOR", name: "Администратор"} as IAuthority,
|
||||||
|
SECRETARY: {code: "ROLE_SECRETARY", name: "Секретарь"} as IAuthority,
|
||||||
|
});
|
||||||
|
|
||||||
|
export type Authority = typeof Authorities[keyof typeof Authorities];
|
||||||
|
export type AuthorityCode = Authority['code'];
|
||||||
|
export type AuthorityName = Authority['name'];
|
||||||
|
|
||||||
|
export function getAuthorityByCode(code: AuthorityCode): Authority {
|
||||||
|
return Object.values(Authorities).find(authority => authority.code === code) as Authority;
|
||||||
|
}
|
||||||
5
web/src/models/group.ts
Normal file
5
web/src/models/group.ts
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
export interface Group {
|
||||||
|
name: string;
|
||||||
|
curatorName?: string;
|
||||||
|
iAmCurator?: boolean;
|
||||||
|
}
|
||||||
@ -1,10 +0,0 @@
|
|||||||
// todo: update
|
|
||||||
export enum Role {
|
|
||||||
ADMINISTRATOR = "ROLE_ADMINISTRATOR",
|
|
||||||
STUDENT = "ROLE_STUDENT",
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface IAuthority {
|
|
||||||
authority: Role;
|
|
||||||
name: string;
|
|
||||||
}
|
|
||||||
@ -1,15 +1,13 @@
|
|||||||
import {IAuthority} from "./role";
|
|
||||||
|
|
||||||
export interface IAuthenticated {
|
export interface IAuthenticated {
|
||||||
authenticated: true,
|
authenticated: true,
|
||||||
login: string,
|
login: string,
|
||||||
password: string,
|
|
||||||
fullName: string,
|
fullName: string,
|
||||||
email: string,
|
email: string,
|
||||||
phone: string,
|
phone: string,
|
||||||
|
authorities: string[],
|
||||||
|
|
||||||
createdAt: string,
|
createdAt: string,
|
||||||
updatedAt: string,
|
updatedAt: string,
|
||||||
authorities: IAuthority[],
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export declare type IUser = {authenticated: false} | IAuthenticated;
|
export declare type IUser = {authenticated: false} | IAuthenticated;
|
||||||
|
|||||||
@ -1,15 +0,0 @@
|
|||||||
import {Route} from "mobx-state-router";
|
|
||||||
|
|
||||||
export const routes: Route[] = [{
|
|
||||||
name: 'root',
|
|
||||||
pattern: '/',
|
|
||||||
}, {
|
|
||||||
name: 'profile',
|
|
||||||
pattern: '/profile',
|
|
||||||
}, {
|
|
||||||
name: 'userList',
|
|
||||||
pattern: '/users',
|
|
||||||
}, {
|
|
||||||
name: 'error',
|
|
||||||
pattern: '/error',
|
|
||||||
}];
|
|
||||||
@ -1,12 +0,0 @@
|
|||||||
import {ViewMap} from "mobx-state-router";
|
|
||||||
import Home from "../components/layout/Home";
|
|
||||||
import Error from "../components/layout/Error";
|
|
||||||
import UserProfilePage from "../components/user/UserProfilePage";
|
|
||||||
import {UserList} from "../components/user/UserList";
|
|
||||||
|
|
||||||
export const viewMap: ViewMap = {
|
|
||||||
root: <Home/>,
|
|
||||||
profile: <UserProfilePage/>,
|
|
||||||
userList: <UserList/>,
|
|
||||||
error: <Error/>,
|
|
||||||
}
|
|
||||||
@ -1,13 +1,13 @@
|
|||||||
import {MyRouterStore} from "../store/MyRouterStore";
|
import {AppRouterStore} from "../store/AppRouterStore";
|
||||||
|
|
||||||
export interface IRouterOptions {
|
export interface IRouterOptions {
|
||||||
redirect: string;
|
redirect: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class RouterService {
|
export class RouterService {
|
||||||
private static router: MyRouterStore;
|
private static router: AppRouterStore;
|
||||||
|
|
||||||
static init(router: MyRouterStore) {
|
static init(router: AppRouterStore) {
|
||||||
this.router = router;
|
this.router = router;
|
||||||
console.debug('RouterService initialized');
|
console.debug('RouterService initialized');
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,13 +0,0 @@
|
|||||||
import {Role} from "../models/role";
|
|
||||||
import {IAuthenticated, IUser} from "../models/user";
|
|
||||||
|
|
||||||
export class UserService {
|
|
||||||
static isUserInRole(user: IUser, role: Role): boolean {
|
|
||||||
if (!user.authenticated) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
user = user as IAuthenticated;
|
|
||||||
return user.authorities.some(a => a.authority === role);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
36
web/src/store/AppRouterStore.tsx
Normal file
36
web/src/store/AppRouterStore.tsx
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
import {browserHistory, createRouterState, HistoryAdapter, Route, RouterStore} from "mobx-state-router";
|
||||||
|
import {RootStore} from "./RootStore";
|
||||||
|
|
||||||
|
export class AppRouterStore extends RouterStore {
|
||||||
|
constructor(rootStore: RootStore) {
|
||||||
|
super(routes, createRouterState('error', {notFound: true}));
|
||||||
|
// makeObservable(this);
|
||||||
|
this.rootStore = rootStore;
|
||||||
|
}
|
||||||
|
|
||||||
|
rootStore: RootStore;
|
||||||
|
|
||||||
|
init() {
|
||||||
|
const historyAdapter = new HistoryAdapter(this, browserHistory);
|
||||||
|
historyAdapter.observeRouterStateChanges();
|
||||||
|
console.debug('MyRouterStore initialized');
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const routes: Route[] = [{
|
||||||
|
name: 'home',
|
||||||
|
pattern: '/',
|
||||||
|
}, {
|
||||||
|
name: 'profile',
|
||||||
|
pattern: '/profile',
|
||||||
|
}, {
|
||||||
|
name: 'userList',
|
||||||
|
pattern: '/users',
|
||||||
|
}, {
|
||||||
|
name: 'groupList',
|
||||||
|
pattern: '/groups',
|
||||||
|
}, {
|
||||||
|
name: 'error',
|
||||||
|
pattern: '/error',
|
||||||
|
}];
|
||||||
@ -1,18 +0,0 @@
|
|||||||
import {browserHistory, createRouterState, HistoryAdapter, RouterStore} from "mobx-state-router";
|
|
||||||
import {RootStore} from "./RootStore";
|
|
||||||
import {routes} from "../router/routes";
|
|
||||||
|
|
||||||
export class MyRouterStore extends RouterStore {
|
|
||||||
constructor(rootStore: RootStore) {
|
|
||||||
super(routes,
|
|
||||||
createRouterState('error', {notFound: true}),
|
|
||||||
{rootStore: rootStore});
|
|
||||||
}
|
|
||||||
|
|
||||||
init() {
|
|
||||||
const historyAdapter = new HistoryAdapter(this, browserHistory);
|
|
||||||
historyAdapter.observeRouterStateChanges();
|
|
||||||
console.debug('MyRouterStore initialized');
|
|
||||||
return this;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -90,4 +90,9 @@ export class NotificationStore {
|
|||||||
init() {
|
init() {
|
||||||
console.debug('NotificationStore initialized');
|
console.debug('NotificationStore initialized');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@action.bound
|
||||||
|
accessDenied() {
|
||||||
|
this.error('Доступ запрещен', 'Ошибка доступа');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@ -1,4 +1,4 @@
|
|||||||
import {MyRouterStore} from "./MyRouterStore";
|
import {AppRouterStore} from "./AppRouterStore";
|
||||||
import {UserStore} from "./UserStore";
|
import {UserStore} from "./UserStore";
|
||||||
import {ThinkStore} from "./ThinkStore";
|
import {ThinkStore} from "./ThinkStore";
|
||||||
import {NotificationStore} from "./NotificationStore";
|
import {NotificationStore} from "./NotificationStore";
|
||||||
@ -7,10 +7,14 @@ import {SysInfoStore} from "./SysInfoStore";
|
|||||||
export class RootStore {
|
export class RootStore {
|
||||||
thinkStore = new ThinkStore(this);
|
thinkStore = new ThinkStore(this);
|
||||||
userStore = new UserStore(this);
|
userStore = new UserStore(this);
|
||||||
routerStore = new MyRouterStore(this);
|
routerStore = new AppRouterStore(this);
|
||||||
notificationStore = new NotificationStore(this);
|
notificationStore = new NotificationStore(this);
|
||||||
sysInfoStore = new SysInfoStore(this);
|
sysInfoStore = new SysInfoStore(this);
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
this.thinkStore.init();
|
this.thinkStore.init();
|
||||||
this.routerStore.init();
|
this.routerStore.init();
|
||||||
|
|||||||
@ -16,12 +16,12 @@ export class ThinkStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@action.bound
|
@action.bound
|
||||||
completeOne(key: string) {
|
completeOne(key: string = '$default') {
|
||||||
this.thinks.splice(this.thinks.indexOf(key), 1);
|
this.thinks.splice(this.thinks.indexOf(key), 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
@action.bound
|
@action.bound
|
||||||
completeAll(key: string) {
|
completeAll(key: string = '$default') {
|
||||||
this.thinks = this.thinks.filter(k => k !== key);
|
this.thinks = this.thinks.filter(k => k !== key);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,14 +1,14 @@
|
|||||||
import {get} from "../utils/request";
|
import {get} from "../utils/request";
|
||||||
import {action, makeObservable, observable, runInAction} from "mobx";
|
import {action, computed, makeObservable, observable, runInAction} from "mobx";
|
||||||
import {RootStore} from "./RootStore";
|
import {RootStore} from "./RootStore";
|
||||||
import type {IUser} from "../models/user";
|
import type {IUser} from "../models/user";
|
||||||
import {IStudent} from "../models/student";
|
import {IStudent} from "../models/student";
|
||||||
import {Role} from "../models/role";
|
import {Authorities} from "../models/authorities";
|
||||||
|
|
||||||
export class UserStore {
|
export class UserStore {
|
||||||
rootStore: RootStore;
|
rootStore: RootStore;
|
||||||
@observable user: IUser = {authenticated: false};
|
@observable user: IUser = {authenticated: false};
|
||||||
@observable student?: IStudent;
|
@observable student: IStudent;
|
||||||
|
|
||||||
constructor(rootStore: RootStore) {
|
constructor(rootStore: RootStore) {
|
||||||
makeObservable(this);
|
makeObservable(this);
|
||||||
@ -22,7 +22,7 @@ export class UserStore {
|
|||||||
runInAction(() => {
|
runInAction(() => {
|
||||||
this.user = response;
|
this.user = response;
|
||||||
});
|
});
|
||||||
if (response.authenticated && response.authorities.some(a => a.authority === Role.STUDENT)) {
|
if (this.isStudent) {
|
||||||
get<IStudent>('/student/current').then((student) => {
|
get<IStudent>('/student/current').then((student) => {
|
||||||
runInAction(() => {
|
runInAction(() => {
|
||||||
this.student = student;
|
this.student = student;
|
||||||
@ -39,8 +39,34 @@ export class UserStore {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
isAdministrator() {
|
@computed
|
||||||
return this.user.authenticated && this.user.authorities.some(a => a.authority === Role.ADMINISTRATOR);
|
get isAdministrator() {
|
||||||
|
return this.user.authenticated && this.user.authorities.some(a => a === Authorities.ADMINISTRATOR.code);
|
||||||
|
}
|
||||||
|
|
||||||
|
@computed
|
||||||
|
get isStudent() {
|
||||||
|
return this.user.authenticated && this.user.authorities.some(a => a === Authorities.STUDENT.code);
|
||||||
|
}
|
||||||
|
|
||||||
|
@computed
|
||||||
|
get isTeacher() {
|
||||||
|
return this.user.authenticated && this.user.authorities.some(a => a === Authorities.TEACHER.code);
|
||||||
|
}
|
||||||
|
|
||||||
|
@computed
|
||||||
|
get isCommissionMember() {
|
||||||
|
return this.user.authenticated && this.user.authorities.some(a => a === Authorities.COMMISSION_MEMBER.code);
|
||||||
|
}
|
||||||
|
|
||||||
|
@computed
|
||||||
|
get isSecretary() {
|
||||||
|
return this.user.authenticated && this.user.authorities.some(a => a === Authorities.SECRETARY.code);
|
||||||
|
}
|
||||||
|
|
||||||
|
@computed
|
||||||
|
get authenticated() {
|
||||||
|
return this.user.authenticated
|
||||||
}
|
}
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import {Component} from "react";
|
import {Component} from "react";
|
||||||
import {RootStoreContext, RootStoreContextType} from "../store/RootStoreContext";
|
import {RootStoreContext, RootStoreContextType} from "./context";
|
||||||
|
|
||||||
export abstract class ComponentContext<P = {}, S = {}, SS = any> extends Component<P, S, SS> {
|
export abstract class ComponentContext<P = {}, S = {}, SS = any> extends Component<P, S, SS> {
|
||||||
declare context: RootStoreContextType;
|
declare context: RootStoreContextType;
|
||||||
|
|||||||
1
web/src/utils/comparators.ts
Normal file
1
web/src/utils/comparators.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export const defaultComparator = (a: any, b: any) => a > b ? 1 : a < b ? -1 : 0;
|
||||||
@ -1,5 +1,5 @@
|
|||||||
import {RootStore} from "./RootStore";
|
|
||||||
import {ContextType, createContext} from "react";
|
import {ContextType, createContext} from "react";
|
||||||
|
import {RootStore} from "../store/RootStore";
|
||||||
|
|
||||||
export const RootStoreContext = createContext<RootStore>(new RootStore());
|
export const RootStoreContext = createContext<RootStore>(new RootStore());
|
||||||
export type RootStoreContextType = ContextType<typeof RootStoreContext>;
|
export type RootStoreContextType = ContextType<typeof RootStoreContext>;
|
||||||
@ -44,4 +44,4 @@ export const initApp = () => {
|
|||||||
console.debug('<<<<<<<<<<<<<<<<<<<<<<<<');
|
console.debug('<<<<<<<<<<<<<<<<<<<<<<<<');
|
||||||
console.debug('App initialized');
|
console.debug('App initialized');
|
||||||
return rootStore;
|
return rootStore;
|
||||||
}
|
}
|
||||||
@ -10,6 +10,7 @@ export class ReactiveValue<T> {
|
|||||||
@observable private errors: string[] = [];
|
@observable private errors: string[] = [];
|
||||||
@observable private inputRestrictionError: string;
|
@observable private inputRestrictionError: string;
|
||||||
@observable private fieldName: string;
|
@observable private fieldName: string;
|
||||||
|
@observable private syncWithUrlParameter?: string;
|
||||||
|
|
||||||
constructor(fireImmediately: boolean = false) {
|
constructor(fireImmediately: boolean = false) {
|
||||||
makeObservable(this);
|
makeObservable(this);
|
||||||
@ -56,6 +57,24 @@ export class ReactiveValue<T> {
|
|||||||
@action.bound
|
@action.bound
|
||||||
setAuto(value: T) {
|
setAuto(value: T) {
|
||||||
this.val = value;
|
this.val = value;
|
||||||
|
|
||||||
|
if (this.syncWithUrlParameter) {
|
||||||
|
const url = new URL(window.location.href);
|
||||||
|
url.searchParams.set(this.syncWithUrlParameter, JSON.stringify(value));
|
||||||
|
window.history.pushState({}, '', url.toString());
|
||||||
|
}
|
||||||
|
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
@action.bound
|
||||||
|
syncWithParam(param: string) {
|
||||||
|
this.syncWithUrlParameter = param;
|
||||||
|
const url = new URL(window.location.href);
|
||||||
|
const value = url.searchParams.get(param);
|
||||||
|
if (value) {
|
||||||
|
this.set(JSON.parse(value));
|
||||||
|
}
|
||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -118,41 +137,10 @@ export class ReactiveValue<T> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@action.bound
|
@action.bound
|
||||||
clear() {
|
rebirth() {
|
||||||
this.val = undefined;
|
this.val = undefined;
|
||||||
this.isTouched = false;
|
this.isTouched = false;
|
||||||
this.errors = [];
|
this.errors = [];
|
||||||
this.inputRestrictionError = undefined;
|
this.inputRestrictionError = undefined;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// export class NumberField extends ReactiveValue<number> {
|
|
||||||
// constructor(fireImmediately: boolean = false) {
|
|
||||||
// super(fireImmediately);
|
|
||||||
// makeObservable(this);
|
|
||||||
// this.addValidator(value => {
|
|
||||||
// if (_.isNaN(value)) {
|
|
||||||
// return 'Должно быть числом';
|
|
||||||
// }
|
|
||||||
//
|
|
||||||
// return null;
|
|
||||||
// });
|
|
||||||
// }
|
|
||||||
//
|
|
||||||
// @action.bound
|
|
||||||
// onChange(event: React.ChangeEvent<HTMLInputElement>) {
|
|
||||||
// this.set(_.toNumber(event.currentTarget.value));
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
//
|
|
||||||
// export class BooleanField extends ReactiveValue<boolean> {
|
|
||||||
// constructor(fireImmediately: boolean = false) {
|
|
||||||
// super(fireImmediately);
|
|
||||||
// makeObservable(this);
|
|
||||||
// }
|
|
||||||
//
|
|
||||||
// @action.bound
|
|
||||||
// onChange(event: React.ChangeEvent<HTMLInputElement>) {
|
|
||||||
// this.set(event.currentTarget.checked);
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
@ -41,19 +41,32 @@ export const strPattern = (regexp: RegExp, message: string) => (value: string, f
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const email = (value: string, field = 'Поле') => {
|
export const email = (value: string, field = 'Поле') => {
|
||||||
if (!/^.+@.+\..+$/.test(value)) {
|
// валидация email очень сложная, и этот паттерн не учитывает многие валидные адреса, но используется в Google
|
||||||
|
if (!/^(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/.test(value)) {
|
||||||
return `${field} должно быть корректным адресом электронной почты`;
|
return `${field} должно быть корректным адресом электронной почты`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const emailChars = (value: string, field = 'Поле') => {
|
||||||
|
if (!/^[a-zA-Z\d@._-]*$/.test(value)) {
|
||||||
|
return `${field} должно содержать только латинские буквы, цифры и символы @._-`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export const phone = (value: string, field = 'Поле') => {
|
export const phone = (value: string, field = 'Поле') => {
|
||||||
if (!/^\+[1-9]\d{6,14}$/.test(value)) {
|
if (!/^\+[1-9]\d{6,14}$/.test(value)) {
|
||||||
return `${field} должно быть корректным номером телефона`;
|
return `${field} должно быть корректным номером телефона`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const phoneChars = (value: string, field = 'Поле') => {
|
||||||
|
if (!/^\+\d*$/.test(value)) {
|
||||||
|
return `${field} должно содержать только цифры и символ +`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export const loginChars = (value: string, field = 'Поле') => {
|
export const loginChars = (value: string, field = 'Поле') => {
|
||||||
if (!/^[a-zA-Z0-9]*$/.test(value)) {
|
if (!/^[a-zA-Z\d_]*$/.test(value)) {
|
||||||
return `${field} должно содержать только латинские буквы, цифры и знак подчеркивания`;
|
return `${field} должно содержать только латинские буквы, цифры и знак подчеркивания`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -64,15 +77,33 @@ export const loginLength = (value: string, field = 'Поле') => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const passwordChars = (value: string, field = 'Поле') => {
|
export const loginMaxLength = (value: string, field = 'Поле') => {
|
||||||
if (!/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)[a-zA-Z\d]*$/.test(value)) {
|
if (value.length > 32) {
|
||||||
|
return `${field} должно содержать не более 32 символов`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const password = (value: string, field = 'Поле') => {
|
||||||
|
if (!/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)[a-zA-Z\d!@#$%^&*]*$/.test(value)) {
|
||||||
return `${field} должен содержать хотя бы одну цифру, одну заглавную и одну строчную букву`;
|
return `${field} должен содержать хотя бы одну цифру, одну заглавную и одну строчную букву`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const passwordMaxLength = (value: string, field = 'Поле') => {
|
||||||
|
if (value.length > 32) {
|
||||||
|
return `${field} должно содержать не более 32 символов`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export const passwordLength = (value: string, field = 'Поле') => {
|
export const passwordLength = (value: string, field = 'Поле') => {
|
||||||
if (value.length < 8 || value.length > 32) {
|
if (value.length < 8 || value.length > 32) {
|
||||||
return `${field} должен содержать от 8 до 32 символов`;
|
return `${field} должно содержать от 8 до 32 символов`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const passwordChars = (value: string, field = 'Поле') => {
|
||||||
|
if (!/^[a-zA-Z\d!@#$%^&*]*$/.test(value)) {
|
||||||
|
return `${field} должен содержать только латинские буквы, цифры и специальные символы`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,33 +1,83 @@
|
|||||||
export class Column {
|
import {action, makeObservable, observable} from "mobx";
|
||||||
key: string;
|
import {defaultComparator} from "./comparators";
|
||||||
title: string;
|
import _ from "lodash";
|
||||||
format: (value: any) => string;
|
import {ReactNode} from "react";
|
||||||
|
|
||||||
constructor(key: string, title: string, format: (value: any) => string = value => value) {
|
export class TableDescriptor<R> {
|
||||||
|
@observable columns: Column<R, any>[];
|
||||||
|
@observable filters: ((row: R) => boolean)[];
|
||||||
|
@observable data: R[];
|
||||||
|
@observable pageable: boolean;
|
||||||
|
@observable pageSize = 10;
|
||||||
|
@observable page = 0;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
columns: Column<R, any>[],
|
||||||
|
data: R[],
|
||||||
|
pageable = true,
|
||||||
|
filters: ((row: R) => boolean)[] = [() => true]
|
||||||
|
) {
|
||||||
|
makeObservable(this);
|
||||||
|
this.columns = columns;
|
||||||
|
this.data = data;
|
||||||
|
this.filters = filters;
|
||||||
|
this.pageable = pageable;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class Column<R, C> {
|
||||||
|
@observable key: string; /* key of the field in the data object */
|
||||||
|
@observable title: string;
|
||||||
|
@observable sort: Sort<C>;
|
||||||
|
@observable suffixElement?: (data: R) => ReactNode;
|
||||||
|
@observable format: (value: C, data: R) => ReactNode;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
key: string, title: string,
|
||||||
|
format: (value: any) => string = value => value,
|
||||||
|
suffix?: (data: R) => ReactNode,
|
||||||
|
sort: Sort<C> = new Sort<C>()
|
||||||
|
) {
|
||||||
|
makeObservable(this);
|
||||||
|
this.suffixElement = suffix;
|
||||||
this.key = key;
|
this.key = key;
|
||||||
this.title = title;
|
this.title = title;
|
||||||
this.format = format;
|
this.format = format;
|
||||||
|
this.sort = sort;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Sort {
|
export class Sort<C> {
|
||||||
column: Column;
|
@observable order: 'asc' | 'desc';
|
||||||
order: 'asc' | 'desc';
|
@observable comparator: (a: C, b: C) => number;
|
||||||
}
|
@observable apply: boolean;
|
||||||
|
@observable applyOrder: number;
|
||||||
|
|
||||||
export class TableDescriptor<T> {
|
constructor(
|
||||||
columns: Column[];
|
apply = false, applyOrder?: number,
|
||||||
sorts: Sort[];
|
comparator: (a: C, b: C) => number = defaultComparator,
|
||||||
filters: ((row: T) => boolean)[] = [() => true];
|
order: 'asc' | 'desc' = 'asc'
|
||||||
data: T[];
|
) {
|
||||||
pageable = false;
|
makeObservable(this);
|
||||||
pageSize = 10;
|
this.order = order;
|
||||||
page = 0;
|
this.comparator = comparator;
|
||||||
|
this.apply = apply;
|
||||||
|
this.applyOrder = applyOrder;
|
||||||
|
}
|
||||||
|
|
||||||
constructor(columns: Column[], data: T[], sorts: Sort[] = [], filters: ((row: T) => boolean)[] = [() => true]) {
|
@action.bound
|
||||||
this.columns = columns;
|
toggle(other: Sort<C>[]) {
|
||||||
this.data = data;
|
if (!this.apply) {
|
||||||
this.sorts = sorts;
|
this.apply = true;
|
||||||
this.filters = filters;
|
this.order = 'asc';
|
||||||
|
const maxOrder = Math.max(0, ...(other.map(sort => sort.applyOrder).filter(order => _.isNumber(order))));
|
||||||
|
this.applyOrder = maxOrder + 1;
|
||||||
|
} else if (this.order === 'asc') {
|
||||||
|
this.order = 'desc';
|
||||||
|
} else {
|
||||||
|
this.apply = false;
|
||||||
|
other.filter(sort => sort.applyOrder > this.applyOrder).forEach(sort => sort.applyOrder--);
|
||||||
|
this.applyOrder = undefined;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -22,4 +22,4 @@
|
|||||||
"strictBindCallApply": true,
|
"strictBindCallApply": true,
|
||||||
"allowSyntheticDefaultImports": true
|
"allowSyntheticDefaultImports": true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,7 +2,7 @@ const path = require('path');
|
|||||||
const HtmlWebpackPlugin = require('html-webpack-plugin');
|
const HtmlWebpackPlugin = require('html-webpack-plugin');
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
mode: 'none',
|
mode: 'development',
|
||||||
entry: {
|
entry: {
|
||||||
app: path.join(__dirname, 'src', 'Application.tsx')
|
app: path.join(__dirname, 'src', 'Application.tsx')
|
||||||
},
|
},
|
||||||
@ -38,7 +38,7 @@ module.exports = {
|
|||||||
historyApiFallback: true,
|
historyApiFallback: true,
|
||||||
static: path.join(__dirname, "dist"),
|
static: path.join(__dirname, "dist"),
|
||||||
compress: true,
|
compress: true,
|
||||||
port: 8081,
|
port: 8888,
|
||||||
},
|
},
|
||||||
plugins: [
|
plugins: [
|
||||||
new HtmlWebpackPlugin({
|
new HtmlWebpackPlugin({
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user