diff --git a/server/src/main/java/ru/tubryansk/tdms/TdmsApplication.java b/server/src/main/java/ru/tubryansk/tdms/TdmsApplication.java index 597ecfe..2a28121 100644 --- a/server/src/main/java/ru/tubryansk/tdms/TdmsApplication.java +++ b/server/src/main/java/ru/tubryansk/tdms/TdmsApplication.java @@ -4,13 +4,12 @@ package ru.tubryansk.tdms; import lombok.extern.slf4j.Slf4j; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; -import org.springframework.context.ConfigurableApplicationContext; @SpringBootApplication @Slf4j public class TdmsApplication { public static void main(String[] args) { - SpringApplication.run(TdmsApplication.class, args); + SpringApplication.run(TdmsApplication.class, args).start(); } } diff --git a/server/src/main/java/ru/tubryansk/tdms/config/SecurityConfiguration.java b/server/src/main/java/ru/tubryansk/tdms/config/SecurityConfiguration.java index 8765258..cfbefa7 100644 --- a/server/src/main/java/ru/tubryansk/tdms/config/SecurityConfiguration.java +++ b/server/src/main/java/ru/tubryansk/tdms/config/SecurityConfiguration.java @@ -43,7 +43,7 @@ public class SecurityConfiguration { .cors(a -> a.configurationSource(cors)) .authenticationManager(authenticationManager) .sessionManagement(cfg -> { - cfg.sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED); + cfg.sessionCreationPolicy(SessionCreationPolicy.NEVER); cfg.maximumSessions(1); }) .build(); @@ -57,20 +57,19 @@ public class SecurityConfiguration { @Value("${application.protocol}") String protocol, Environment environment ) { - return request -> { - String url = StringUtils.join(protocol, "://", domain, ":", port); - CorsConfiguration corsConfiguration = new CorsConfiguration(); - corsConfiguration.setMaxAge(Duration.ofDays(1)); - corsConfiguration.addAllowedOrigin(url); - if (environment.matchesProfiles("dev")) { - corsConfiguration.addAllowedOrigin("http://localhost:8081"); - } - - corsConfiguration.setAllowedMethods(List.of(HttpMethod.GET.name(), HttpMethod.POST.name(), HttpMethod.OPTIONS.name())); - corsConfiguration.setAllowedHeaders(List.of("Authorization", "Content-Type")); - corsConfiguration.setAllowCredentials(true); - return corsConfiguration; - }; + CorsConfiguration corsConfiguration = new CorsConfiguration(); + corsConfiguration.setAllowedMethods(List.of(HttpMethod.GET.name(), HttpMethod.POST.name(), HttpMethod.OPTIONS.name())); + corsConfiguration.setAllowedHeaders(List.of("Authorization", "Content-Type")); + corsConfiguration.setAllowCredentials(true); + corsConfiguration.setMaxAge(Duration.ofDays(1)); + corsConfiguration.addAllowedOrigin(StringUtils.join(protocol, "://", domain, ":", port)); + if (environment.matchesProfiles("dev")) { + corsConfiguration.addAllowedOrigin("http://localhost:8888"); + } + log.info("CORS configuration: [headers: {}, methods: {}, origins: {}, credentials: {}, maxAge: {}]", + corsConfiguration.getAllowedHeaders(), corsConfiguration.getAllowedMethods(), corsConfiguration.getAllowedOrigins(), + corsConfiguration.getAllowCredentials(), corsConfiguration.getMaxAge()); + return request -> corsConfiguration; } @Bean @@ -96,8 +95,8 @@ public class SecurityConfiguration { /* StudentController */ httpAuthorization.requestMatchers("/api/v1/student/current").permitAll(); /* GroupController */ - httpAuthorization.requestMatchers("/api/v1/group/get-all").permitAll(); - httpAuthorization.requestMatchers("api/v1/group/create-group").hasAuthority("ROLE_ADMINISTRATOR"); + httpAuthorization.requestMatchers("/api/v1/group/get-all-groups").permitAll(); + httpAuthorization.requestMatchers("/api/v1/group/create-group").hasAuthority("ROLE_ADMINISTRATOR"); /* deny all other api requests */ httpAuthorization.requestMatchers("/api/**").denyAll(); /* since api already blocked, all other requests are static resources */ diff --git a/server/src/main/java/ru/tubryansk/tdms/controller/GroupController.java b/server/src/main/java/ru/tubryansk/tdms/controller/GroupController.java index ee1ac95..8714ce7 100644 --- a/server/src/main/java/ru/tubryansk/tdms/controller/GroupController.java +++ b/server/src/main/java/ru/tubryansk/tdms/controller/GroupController.java @@ -3,8 +3,9 @@ package ru.tubryansk.tdms.controller; import jakarta.validation.Valid; import org.springframework.beans.factory.annotation.Autowired; 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.GroupRegistrationDTO; +import ru.tubryansk.tdms.controller.payload.GroupEditDTO; import ru.tubryansk.tdms.service.GroupService; import java.util.Collection; @@ -21,7 +22,12 @@ public class GroupController { } @PostMapping("/create-group") - public void createGroup(@RequestBody @Valid GroupRegistrationDTO groupRegistrationDTO) { - groupService.createGroup(groupRegistrationDTO.getName()); + public void createGroup(@RequestBody @Valid GroupCreateDTO groupCreateDTO) { + groupService.createGroup(groupCreateDTO.getName()); + } + + @PostMapping("/edit-group") + public void editGroup(@RequestBody @Valid GroupEditDTO groupEditDTO) { + groupService.editGroup(groupEditDTO); } } diff --git a/server/src/main/java/ru/tubryansk/tdms/controller/UserController.java b/server/src/main/java/ru/tubryansk/tdms/controller/UserController.java index 905570f..0132698 100644 --- a/server/src/main/java/ru/tubryansk/tdms/controller/UserController.java +++ b/server/src/main/java/ru/tubryansk/tdms/controller/UserController.java @@ -36,7 +36,7 @@ public class UserController { @PostMapping("/login") public void login(@RequestBody @Valid LoginDTO loginDTO) { - authenticationService.login(loginDTO.getUsername(), loginDTO.getPassword()); + authenticationService.login(loginDTO.getLogin(), loginDTO.getPassword()); } @PostMapping("/register") diff --git a/server/src/main/java/ru/tubryansk/tdms/controller/payload/GroupRegistrationDTO.java b/server/src/main/java/ru/tubryansk/tdms/controller/payload/GroupCreateDTO.java similarity index 94% rename from server/src/main/java/ru/tubryansk/tdms/controller/payload/GroupRegistrationDTO.java rename to server/src/main/java/ru/tubryansk/tdms/controller/payload/GroupCreateDTO.java index 564ff39..9c643f2 100644 --- a/server/src/main/java/ru/tubryansk/tdms/controller/payload/GroupRegistrationDTO.java +++ b/server/src/main/java/ru/tubryansk/tdms/controller/payload/GroupCreateDTO.java @@ -6,7 +6,7 @@ import jakarta.validation.constraints.Size; import lombok.Getter; @Getter -public class GroupRegistrationDTO { +public class GroupCreateDTO { @NotEmpty(message = "Имя группы не может быть пустым") @Size(min = 3, max = 50, message = "Имя группы должно быть от 3 до 50 символов") @Pattern(regexp = "^[а-яА-ЯёЁ0-9_-]*$", message = "Имя группы должно содержать только русские буквы, дефис, нижнее подчеркивание и цифры") diff --git a/server/src/main/java/ru/tubryansk/tdms/controller/payload/GroupDTO.java b/server/src/main/java/ru/tubryansk/tdms/controller/payload/GroupDTO.java index b6f68a1..7ee0831 100644 --- a/server/src/main/java/ru/tubryansk/tdms/controller/payload/GroupDTO.java +++ b/server/src/main/java/ru/tubryansk/tdms/controller/payload/GroupDTO.java @@ -8,6 +8,7 @@ import lombok.ToString; @Setter @ToString public class GroupDTO { + private Long id; private String name; private String curatorName; private Boolean iAmCurator; diff --git a/server/src/main/java/ru/tubryansk/tdms/controller/payload/GroupEditDTO.java b/server/src/main/java/ru/tubryansk/tdms/controller/payload/GroupEditDTO.java new file mode 100644 index 0000000..af36649 --- /dev/null +++ b/server/src/main/java/ru/tubryansk/tdms/controller/payload/GroupEditDTO.java @@ -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; +} diff --git a/server/src/main/java/ru/tubryansk/tdms/controller/payload/LoginDTO.java b/server/src/main/java/ru/tubryansk/tdms/controller/payload/LoginDTO.java index 3d12fd5..972332f 100644 --- a/server/src/main/java/ru/tubryansk/tdms/controller/payload/LoginDTO.java +++ b/server/src/main/java/ru/tubryansk/tdms/controller/payload/LoginDTO.java @@ -1,12 +1,18 @@ package ru.tubryansk.tdms.controller.payload; import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Size; import lombok.Getter; @Getter public class LoginDTO { @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 = "Пароль не может быть пустым") + @Pattern(regexp = "^(?=.*[a-z])(?=.*[A-Z])(?=.*\\d)[a-zA-Z\\d]{8,}$", message = "Пароль должен содержать хотя бы одну цифру, одну заглавную и одну строчную букву, минимум 8 символов") private String password; } \ No newline at end of file diff --git a/server/src/main/java/ru/tubryansk/tdms/exception/GlobalExceptionHandler.java b/server/src/main/java/ru/tubryansk/tdms/exception/GlobalExceptionHandler.java index a10a028..81c95c3 100644 --- a/server/src/main/java/ru/tubryansk/tdms/exception/GlobalExceptionHandler.java +++ b/server/src/main/java/ru/tubryansk/tdms/exception/GlobalExceptionHandler.java @@ -4,6 +4,7 @@ import jakarta.servlet.http.HttpServletResponse; import lombok.extern.slf4j.Slf4j; import org.springframework.context.support.DefaultMessageSourceResolvable; import org.springframework.http.HttpStatus; +import org.springframework.security.core.AuthenticationException; import org.springframework.validation.BindException; import org.springframework.web.bind.annotation.ExceptionHandler; 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 ru.tubryansk.tdms.controller.payload.ErrorResponse; -import java.util.Random; +import java.util.UUID; import java.util.stream.Collectors; @RestControllerAdvice @@ -23,13 +24,13 @@ public class GlobalExceptionHandler { log.debug("Validation error: {}", e.getMessage()); String validationErrors = e.getAllErrors().stream() .map(DefaultMessageSourceResolvable::getDefaultMessage) - .collect(Collectors.joining(", ")); + .collect(Collectors.joining("\n")); return new ErrorResponse(validationErrors, ErrorResponse.ErrorCode.VALIDATION_ERROR); } @ExceptionHandler(BusinessException.class) public ErrorResponse handleBusinessException(BusinessException e, HttpServletResponse response) { - log.info("Business error", e); + log.info("Business error: {}", e.getMessage()); response.setStatus(e.getErrorCode().getHttpStatus().value()); return new ErrorResponse(e.getMessage(), e.getErrorCode()); } @@ -37,24 +38,30 @@ public class GlobalExceptionHandler { @ExceptionHandler(org.springframework.security.access.AccessDeniedException.class) @ResponseStatus(HttpStatus.FORBIDDEN) public ErrorResponse handleAccessDeniedException(AccessDeniedException e) { - log.debug("Access denied", e); - return new ErrorResponse("", ErrorResponse.ErrorCode.ACCESS_DENIED); + log.info("Access denied: {}", e.getMessage()); + 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) @ResponseStatus(HttpStatus.NOT_FOUND) public ErrorResponse handleNoResourceFoundException(NoResourceFoundException e) { - log.error("Resource not found", e); - return new ErrorResponse(e.getMessage(), ErrorResponse.ErrorCode.NOT_FOUND); + UUID uuid = UUID.randomUUID(); + log.error("Resource not found ({})", uuid, e); + return new ErrorResponse("Идентификатор ошибки: (" + uuid + ")\nРесурс не был наеден, обратитесь к администратору", ErrorResponse.ErrorCode.NOT_FOUND); } @ExceptionHandler(Exception.class) @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR) public ErrorResponse handleUnexpectedException(Exception e) { - Random random = new Random(); - long errorInx = random.nextLong(); - log.error("Unexpected exception. random: {}", errorInx, e); - return new ErrorResponse("Произошла непредвиденная ошибка, обратитесь к администратору. Номер ошибки: " + errorInx, ErrorResponse.ErrorCode.INTERNAL_ERROR); + UUID uuid = UUID.randomUUID(); + log.error("Unexpected exception ({})", uuid, e); + return new ErrorResponse("Идентификатор ошибки: (" + uuid + ")\nПроизошла непредвиденная ошибка, обратитесь к администратору", ErrorResponse.ErrorCode.INTERNAL_ERROR); } } diff --git a/server/src/main/java/ru/tubryansk/tdms/service/AuthenticationService.java b/server/src/main/java/ru/tubryansk/tdms/service/AuthenticationService.java index 761c0fa..cc5822e 100644 --- a/server/src/main/java/ru/tubryansk/tdms/service/AuthenticationService.java +++ b/server/src/main/java/ru/tubryansk/tdms/service/AuthenticationService.java @@ -27,25 +27,26 @@ public class AuthenticationService { } public void logout() { + log.info("Logging out user: {}", SecurityContextHolder.getContext().getAuthentication().getName()); HttpSession session = request.getSession(false); if(session != null) { session.invalidate(); } + SecurityContextHolder.clearContext(); + log.info("User logged out"); } @Transactional public void login(String username, String password) { - try { - var context = SecurityContextHolder.createEmptyContext(); - var token = new UsernamePasswordAuthenticationToken(username, password); - var authenticated = authenticationManager.authenticate(token); - context.setAuthentication(authenticated); - SecurityContextHolder.setContext(context); - request.getSession(true).setAttribute(SPRING_SECURITY_CONTEXT_KEY, context); - } catch (Exception e) { - log.error("Failed to log in user: {}", username, e); - throw e; - } - log.debug("User {} logged in", username); + log.info("Logging in user: {}, ip: {}", username, request.getRemoteAddr()); + + var context = SecurityContextHolder.createEmptyContext(); + var token = new UsernamePasswordAuthenticationToken(username, password); + var authenticated = authenticationManager.authenticate(token); + context.setAuthentication(authenticated); + SecurityContextHolder.setContext(context); + request.getSession(true).setAttribute(SPRING_SECURITY_CONTEXT_KEY, context); + + log.info("User {} logged in", username); } } diff --git a/server/src/main/java/ru/tubryansk/tdms/service/GroupService.java b/server/src/main/java/ru/tubryansk/tdms/service/GroupService.java index adbaf64..e8a2024 100644 --- a/server/src/main/java/ru/tubryansk/tdms/service/GroupService.java +++ b/server/src/main/java/ru/tubryansk/tdms/service/GroupService.java @@ -1,9 +1,11 @@ package ru.tubryansk.tdms.service; +import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; 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.User; import ru.tubryansk.tdms.entity.repository.GroupRepository; @@ -14,6 +16,7 @@ import java.util.List; @Service @Transactional +@Slf4j public class GroupService { @Autowired private GroupRepository groupRepository; @@ -21,33 +24,44 @@ public class GroupService { private CallerService callerService; public Collection getAllGroups() { + log.info("Getting all groups"); List groups = groupRepository.findAll(); User callerUser = callerService.getCallerUser().orElse(null); - return groups.stream() - .map(g -> { - GroupDTO groupDTO = new GroupDTO(); - groupDTO.setName(g.getName()); + List result = groups.stream().map(group -> { + GroupDTO groupDTO = new GroupDTO(); + groupDTO.setName(group.getName()); + groupDTO.setId(group.getId()); - if (g.getGroupCurator() != null) { - groupDTO.setCuratorName(g.getGroupCurator().getUser().getFullName()); - if (callerUser != null) { - groupDTO.setIAmCurator(g.getGroupCurator().getUser().equals(callerUser)); - } + if (group.getGroupCurator() != null) { + groupDTO.setCuratorName(group.getGroupCurator().getUser().getFullName()); + if (callerUser != null) { + groupDTO.setIAmCurator(group.getGroupCurator().getUser().equals(callerUser)); } - return groupDTO; - }) - .toList(); + } + return groupDTO; + }).toList(); + + log.info("Found {} groups", result.size()); + return result; } public void createGroup(String groupName) { - boolean existsByName = groupRepository.existsByName(groupName); - if (existsByName) { + log.info("Creating group with name {}", groupName); + if (groupRepository.existsByName(groupName)) { throw new BusinessException("Группа с именем " + groupName + " уже существует"); } Group group = new Group(); 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); } } diff --git a/server/src/main/java/ru/tubryansk/tdms/service/LifeCycleService.java b/server/src/main/java/ru/tubryansk/tdms/service/LifecycleService.java similarity index 54% rename from server/src/main/java/ru/tubryansk/tdms/service/LifeCycleService.java rename to server/src/main/java/ru/tubryansk/tdms/service/LifecycleService.java index dc3e251..49dea3c 100644 --- a/server/src/main/java/ru/tubryansk/tdms/service/LifeCycleService.java +++ b/server/src/main/java/ru/tubryansk/tdms/service/LifecycleService.java @@ -1,17 +1,17 @@ package ru.tubryansk.tdms.service; import lombok.extern.slf4j.Slf4j; -import org.springframework.context.ApplicationContext; import org.springframework.context.event.ContextStartedEvent; import org.springframework.context.event.EventListener; +import org.springframework.core.env.Environment; import org.springframework.stereotype.Service; @Service @Slf4j -public class LifeCycleService { +public class LifecycleService { @EventListener(ContextStartedEvent.class) public void onStartup(ContextStartedEvent event) { - ApplicationContext applicationContext = event.getApplicationContext(); - log.info("Static files location: {}", applicationContext.getEnvironment().getProperty("spring.web.resources.static-locations")); + Environment environment = event.getApplicationContext().getEnvironment(); + log.info("Static files location: {}", environment.getProperty("spring.web.resources.static-locations")); } } \ No newline at end of file diff --git a/server/src/main/java/ru/tubryansk/tdms/service/RoleService.java b/server/src/main/java/ru/tubryansk/tdms/service/RoleService.java index fba7db6..3cf6db0 100644 --- a/server/src/main/java/ru/tubryansk/tdms/service/RoleService.java +++ b/server/src/main/java/ru/tubryansk/tdms/service/RoleService.java @@ -1,6 +1,7 @@ package ru.tubryansk.tdms.service; import jakarta.annotation.PostConstruct; +import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -11,6 +12,7 @@ import java.util.Map; import java.util.concurrent.ConcurrentHashMap; @Service +@Slf4j public class RoleService { public enum Authority { ROLE_ADMINISTRATOR, @@ -28,8 +30,10 @@ public class RoleService { @PostConstruct @Transactional public void init() { + log.debug("Initializing roles"); roles = new ConcurrentHashMap<>(); roleRepository.findAll().forEach(role -> roles.put(role.getAuthority(), role)); + log.info("Roles initialized: {}", roles); } public Role getRoleByAuthority(Authority authority) { diff --git a/server/src/main/java/ru/tubryansk/tdms/service/StudentService.java b/server/src/main/java/ru/tubryansk/tdms/service/StudentService.java index e1ac11c..b9df908 100644 --- a/server/src/main/java/ru/tubryansk/tdms/service/StudentService.java +++ b/server/src/main/java/ru/tubryansk/tdms/service/StudentService.java @@ -4,11 +4,8 @@ import jakarta.transaction.Transactional; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; import ru.tubryansk.tdms.controller.payload.StudentDTO; -import ru.tubryansk.tdms.entity.DiplomaTopic; import ru.tubryansk.tdms.entity.Student; -import ru.tubryansk.tdms.entity.repository.DiplomaTopicRepository; import ru.tubryansk.tdms.entity.repository.StudentRepository; -import ru.tubryansk.tdms.exception.AccessDeniedException; import java.util.Optional; @@ -18,28 +15,8 @@ public class StudentService { @Autowired private StudentRepository studentRepository; @Autowired - private DiplomaTopicRepository diplomaTopicRepository; - @Autowired - private Optional student; - @Autowired private CallerService callerService; - /** @param studentToDiplomaTopic Map of @{@link Student} id and @{@link DiplomaTopic} id */ - // public void changeDiplomaTopic(Map 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 getCallerStudent() { return studentRepository.findByUser(callerService.getCallerUser().orElse(null)); } diff --git a/server/src/main/java/ru/tubryansk/tdms/service/UserService.java b/server/src/main/java/ru/tubryansk/tdms/service/UserService.java index 1485fd6..e7356b3 100644 --- a/server/src/main/java/ru/tubryansk/tdms/service/UserService.java +++ b/server/src/main/java/ru/tubryansk/tdms/service/UserService.java @@ -36,17 +36,15 @@ public class UserService implements UserDetailsService { @Override public User loadUserByUsername(String username) throws UsernameNotFoundException { - log.info("Loading user with username: {}", username); - User user = userRepository.findUserByLogin(username).orElseThrow(() -> { - log.info("User with login {} not found", username); - return new UsernameNotFoundException("User with login " + username + " not found"); - }); - log.info("User with login {} loaded", username); + log.debug("Loading user with username: {}", username); + User user = userRepository.findUserByLogin(username).orElseThrow( + () -> new UsernameNotFoundException("User with login " + username + " not found")); + log.debug("User with login {} loaded", username); return user; } public List getAllUsers() { - log.info("Loading all users"); + log.debug("Loading all users"); List users = userRepository.findAll().stream() .map(UserDTO::from) .toList(); @@ -55,7 +53,7 @@ public class UserService implements UserDetailsService { } 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); Student student = transientStudent(registrationDTO.getStudentData()); fillRoles(user, registrationDTO); @@ -92,6 +90,7 @@ public class UserService implements UserDetailsService { private void fillRoles(User user, RegistrationDTO registrationDTO) { List roles = new ArrayList<>(); if (registrationDTO.getStudentData() != null) { + log.debug("User is student, adding role ROLE_STUDENT"); roles.add(roleService.getRoleByAuthority(RoleService.Authority.ROLE_STUDENT)); } user.setRoles(roles); diff --git a/server/src/main/java/ru/tubryansk/tdms/web/LoggingRequestFilter.java b/server/src/main/java/ru/tubryansk/tdms/web/LoggingRequestFilter.java index 0287622..878d7ad 100644 --- a/server/src/main/java/ru/tubryansk/tdms/web/LoggingRequestFilter.java +++ b/server/src/main/java/ru/tubryansk/tdms/web/LoggingRequestFilter.java @@ -16,14 +16,13 @@ public class LoggingRequestFilter extends OncePerRequestFilter { @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { long startTime = System.currentTimeMillis(); - log.info("Making request: {}. user: {}, session: {}, remote ip: {}", - request.getRequestURI(), request.getRemoteUser(), - request.getSession().getId(), request.getRemoteAddr()); + log.info("Request received: {}. user: {}, session: {}, remote ip: {}", + request.getRequestURI(), request.getRemoteUser(), request.getSession().getId(), request.getRemoteAddr()); try { filterChain.doFilter(request, response); } finally { 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); } } } diff --git a/server/src/main/resources/application.yml b/server/src/main/resources/application.yml index 03fd8a6..fce5a82 100644 --- a/server/src/main/resources/application.yml +++ b/server/src/main/resources/application.yml @@ -7,9 +7,9 @@ application: name: @name@ version: @version@ type: production - port: 80 + port: 443 domain: tdms.tu-bryansk.ru - protocol: http + protocol: https spring: application: diff --git a/server/src/main/resources/db/migration/V00030__Create__user_role_table.sql b/server/src/main/resources/db/migration/V00030__Create__user_role_table.sql index bf084c9..1082a87 100644 --- a/server/src/main/resources/db/migration/V00030__Create__user_role_table.sql +++ b/server/src/main/resources/db/migration/V00030__Create__user_role_table.sql @@ -9,10 +9,13 @@ create table user_role -- FOREIGN KEY alter table user_role 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 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 comment on table user_role is 'Таблица связи пользователей и ролей'; diff --git a/server/src/main/resources/db/migration/V00510__Insert_administrator.sql b/server/src/main/resources/db/migration/V00510__Insert_administrator.sql index 3317163..d1de6cb 100644 --- a/server/src/main/resources/db/migration/V00510__Insert_administrator.sql +++ b/server/src/main/resources/db/migration/V00510__Insert_administrator.sql @@ -1,5 +1,5 @@ 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) values (1, 1, 4); diff --git a/web/babel.config.json b/web/babel.config.json index 881374c..8fc8463 100644 --- a/web/babel.config.json +++ b/web/babel.config.json @@ -9,4 +9,4 @@ ["@babel/plugin-proposal-class-properties"], ["@babel/plugin-transform-typescript"] ] -} \ No newline at end of file +} diff --git a/web/src/components/NotificationContainer.tsx b/web/src/components/NotificationContainer.tsx index 68e8a9b..df5a09d 100644 --- a/web/src/components/NotificationContainer.tsx +++ b/web/src/components/NotificationContainer.tsx @@ -52,20 +52,26 @@ class NotificationPopup extends ComponentContext<{ notification: Notification, t render() { const hasTitle = !!this.props.notification.title && this.props.notification.title.length > 0; const closeIcon = ; + const title = this.props.notification.title.split('\n').map((item, key) => {item}
); + const message = this.props.notification.message.split('\n').map((item, key) => {item}
); return { hasTitle && - {this.props.notification.title} + + {title} + {closeIcon} } - {this.props.notification.message} + + {message} + { !hasTitle && closeIcon diff --git a/web/src/components/custom/DataTable.tsx b/web/src/components/custom/DataTable.tsx index 1dd04ba..b19c1c4 100644 --- a/web/src/components/custom/DataTable.tsx +++ b/web/src/components/custom/DataTable.tsx @@ -1,147 +1,292 @@ import {ComponentContext} from "../../utils/ComponentContext"; import {TableDescriptor} from "../../utils/tables"; import {observer} from "mobx-react"; -import {action, makeObservable} from "mobx"; -import {FormSelect, Pagination, Table} from "react-bootstrap"; +import {action, computed, makeObservable, observable, runInAction} from "mobx"; +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 { tableDescriptor: TableDescriptor; + filterModalState?: ModalState; + name?: string; + headless?: boolean; } @observer -export class DataTable extends ComponentContext> { - constructor(props: DataTableProps) { +export class DataTable extends ComponentContext> { + constructor(props: DataTableProps) { super(props); makeObservable(this); } - header() { - return - {this.props.tableDescriptor.columns.map(column => {column.title})} - + @observable descriptor = this.props.tableDescriptor; + @observable name = this.props.name; + @observable headless = this.props.headless; + @observable filterModalState = this.props.filterModalState; + + @computed + get isFirstPage() { + return this.descriptor.page === 0; } - body() { - const firstColumnKey = this.props.tableDescriptor.columns[0].key; - return this.props.tableDescriptor.data.map(row => { - const rowAny = row as any; - return - { - this.props.tableDescriptor.columns.map(column => { - return - {column.format(rowAny[column.key])} - - }) - } - - }); - } - - 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); + @computed + get isLastPage() { + return this.descriptor.page === Math.floor(this.descriptor.data.length / this.descriptor.pageSize) + || this.descriptor.pageSize === this.descriptor.data.length; } @action.bound goFirstPage() { - if (typeof this.props.tableDescriptor.page === 'undefined') { - return; - } - - this.props.tableDescriptor.page = 0; + this.descriptor.page = 0; } @action.bound goLastPage() { - if (typeof this.props.tableDescriptor.page === 'undefined' || typeof this.props.tableDescriptor.pageSize === 'undefined') { - return; - } - - this.props.tableDescriptor.page = this.props.tableDescriptor.data.length / this.props.tableDescriptor.pageSize; + this.descriptor.page = Math.floor(this.descriptor.data.length / this.descriptor.pageSize); } @action.bound goNextPage() { - if (typeof this.props.tableDescriptor.page === 'undefined' || typeof this.props.tableDescriptor.pageSize === 'undefined') { - return; - } - - this.props.tableDescriptor.page++; + this.descriptor.page++; } @action.bound goPrevPage() { - if (typeof this.props.tableDescriptor.page === 'undefined') { - return; - } - - this.props.tableDescriptor.page--; + this.descriptor.page--; } @action.bound - changePageSize(e: any) { - this.props.tableDescriptor.pageSize = parseInt(e.target.value); - } - - footer() { - const table = this.props.tableDescriptor; - if (typeof table.page === 'undefined' || typeof table.pageSize === 'undefined') { - return null; + changePageSize(e: ChangeEvent) { + if (e.target.value === "\u221E") { + this.descriptor.pageSize = this.descriptor.data.length; + return; } - return - -
-
- - - - - {this.props.tableDescriptor.page} - - - - - - - - - - -
- - + this.descriptor.page = 0; + this.descriptor.pageSize = _.toNumber(e.target.value); + } + + // not computed, since we want to show initial data, when no sorts applied + get filteredData() { + const filters = this.descriptor.filters.filter(filter => filter); + return this.descriptor.data.filter(row => ((filters && filters.length) > 0 ? filters.every(filter => filter(row)) : true)); } render() { - const table = this.props.tableDescriptor; - return - - {this.header()} - - - {this.body()} - + const style = { + border: '1px solid #dee2e6', + borderTopLeftRadius: this.headless ? '0.25rem' : '0', + borderTopRightRadius: this.headless ? '0.25rem' : '0', + borderBottomLeftRadius: this.descriptor.pageable ? '0' : '0.25rem', + borderBottomRightRadius: this.descriptor.pageable ? '0' : '0.25rem', + } + + return <> { - table.pageable && - - {this.footer()} - + !this.headless && + this.header } -
+
+ + + {this.tableHeader} + + + {this.body} + +
+
+ { + 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
+
+ {this.name} + + { + this.descriptor.pageable && +
{`Записей на странице: ${this.descriptor.pageSize} `}
+ } +
+ { + <> + {`Всего записей: ${this.filteredData.length}`} + + { + this.descriptor.pageable && + <> + {` (${this.descriptor.page + 1}/${Math.ceil(this.filteredData.length / this.descriptor.pageSize)})`} + + } + + { + this.filterModalState && +
+ +
+ } + + + } +
+
+
+
+ } + + @computed + get tableHeader() { + return <> + + { + 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 +
runInAction(() => { + const other = this.descriptor.columns + .filter(c => c.key !== column.key) + .map(c => c.sort); + column.sort.toggle(other); + })}> + {column.title} + + { +
+ {column.sort.apply ? column.sort.order === 'asc' ? '▲' : '▼' : '△▽'} + { + column.sort.apply && + column.sort.applyOrder + } +
+ } +
+
+ + } + ) + } + + + + } + + @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 + { + 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 + {column.format(rowAny[column.key], row)} + { + suffixElement && + {suffixElement} + } + + }) + } + + }); + } + + @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
+ + + + + + + + + + + + + + +
} } \ No newline at end of file diff --git a/web/src/components/custom/controls/ReactiveControls.tsx b/web/src/components/custom/controls/ReactiveControls.tsx index ee11d73..05bccf2 100644 --- a/web/src/components/custom/controls/ReactiveControls.tsx +++ b/web/src/components/custom/controls/ReactiveControls.tsx @@ -2,7 +2,7 @@ import React from "react"; import {ReactiveValue} from "../../../utils/reactive/reactiveValue"; import {observer} from "mobx-react"; 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 './ReactiveControls.css'; @@ -11,6 +11,7 @@ export interface ReactiveInputProps { label?: string; disabled?: boolean; className?: string; + validateless?: boolean; } @observer @@ -30,12 +31,13 @@ export class StringInput extends React.Component> { } render() { + const inputClassName = `${this.props.validateless ? '' : this.props.value.invalid ? 'bg-danger' : this.props.value.touched ? 'bg-success' : ''} bg-opacity-10`; + return
- {/*todo: disable background-color for label*/} + className={inputClassName}/>
@@ -67,14 +69,15 @@ export class PasswordInput extends React.Component> { render() { return
-
- +
+ -
@@ -82,35 +85,3 @@ export class PasswordInput extends React.Component> {
} } - -@observer -export class SelectButtonInput extends React.Component> { - constructor(props: any) { - super(props); - 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) { - this.props.value.set(event.currentTarget.value); - } - - render() { - return <> - - - - - - - } -} \ No newline at end of file diff --git a/web/src/components/layout/Error.tsx b/web/src/components/custom/layout/Error.tsx similarity index 52% rename from web/src/components/layout/Error.tsx rename to web/src/components/custom/layout/Error.tsx index 25cec15..9bd7578 100644 --- a/web/src/components/layout/Error.tsx +++ b/web/src/components/custom/layout/Error.tsx @@ -1,8 +1,8 @@ import {observer} from "mobx-react"; -import {DefaultPage} from "./DefaultPage"; +import {Page} from "./Page"; @observer -export default class Error extends DefaultPage { +export default class Error extends Page { get page() { return

Error

} diff --git a/web/src/components/layout/Footer.tsx b/web/src/components/custom/layout/Footer.tsx similarity index 95% rename from web/src/components/layout/Footer.tsx rename to web/src/components/custom/layout/Footer.tsx index 19e3535..7ab3f28 100644 --- a/web/src/components/layout/Footer.tsx +++ b/web/src/components/custom/layout/Footer.tsx @@ -1,4 +1,4 @@ -import {ComponentContext} from "../../utils/ComponentContext"; +import {ComponentContext} from "../../../utils/ComponentContext"; import {observer} from "mobx-react"; import {makeObservable} from "mobx"; import {Container, Nav, Navbar, NavbarText, NavLink} from "react-bootstrap"; diff --git a/web/src/components/layout/Header.tsx b/web/src/components/custom/layout/Header.tsx similarity index 54% rename from web/src/components/layout/Header.tsx rename to web/src/components/custom/layout/Header.tsx index feb80ed..c955d33 100644 --- a/web/src/components/layout/Header.tsx +++ b/web/src/components/custom/layout/Header.tsx @@ -1,22 +1,22 @@ import {Container, Nav, Navbar, NavDropdown} from "react-bootstrap"; import {RouterLink} from "mobx-state-router"; -import {IAuthenticated} from "../../models/user"; -import {RootStoreContext, RootStoreContextType} from "../../store/RootStoreContext"; +import {IAuthenticated} from "../../../models/user"; +import {RootStoreContext, RootStoreContextType} from "../../../store/RootStoreContext"; import {observer} from "mobx-react"; -import {post} from "../../utils/request"; -import {LoginModal} from "../user/LoginModal"; -import {ModalState} from "../../utils/modalState"; +import {post} from "../../../utils/request"; +import {UserLoginModal} from "../../user/UserLoginModal"; +import {ModalState} from "../../../utils/modalState"; import {action, makeObservable, observable} from "mobx"; import {FontAwesomeIcon} from "@fortawesome/react-fontawesome"; -import {ComponentContext} from "../../utils/ComponentContext"; -import {CreateGroupModal} from "../group/CreateGroupModal"; -import {UserRegistrationModal} from "../user/UserRegistrationModal"; +import {ComponentContext} from "../../../utils/ComponentContext"; +import {AddGroupModal} from "../../group/AddGroupModal"; +import {UserRegistrationModal} from "../../user/UserRegistrationModal"; @observer class Header extends ComponentContext { @observable loginModalState = new ModalState(); - @observable createGroupModalState = new ModalState(); + @observable addGroupModalState = new ModalState(); @observable userRegistrationModalState = new ModalState(); constructor(props: any) { @@ -25,56 +25,50 @@ class Header extends ComponentContext { } render() { - const userStore = this.context.userStore; - const user = userStore.user; - let thinking = this.thinkStore.isThinking('updateCurrentUser'); + let userThink = this.thinkStore.isThinking('updateCurrentUser'); return <>
- TDMS + TDMS -
- - + + } @@ -92,17 +86,17 @@ class AuthenticatedItems extends ComponentContext { @action.bound logout() { - post('user/logout').then(() => this.context.userStore.updateCurrentUser()); + post('user/logout').then(() => { + this.context.userStore.updateCurrentUser(); + this.routerStore.goTo('home').then(); + this.notificationStore.success('Вы успешно вышли из системы', 'Выход'); + }); } render() { - const userStore = this.context.userStore; - const user = userStore.user; - return <> Пользователь: - + Моя страница Выйти diff --git a/web/src/components/layout/Home.tsx b/web/src/components/custom/layout/Home.tsx similarity index 52% rename from web/src/components/layout/Home.tsx rename to web/src/components/custom/layout/Home.tsx index 940af02..e64d674 100644 --- a/web/src/components/layout/Home.tsx +++ b/web/src/components/custom/layout/Home.tsx @@ -1,8 +1,8 @@ -import {DefaultPage} from "./DefaultPage"; +import {Page} from "./Page"; import {observer} from "mobx-react"; @observer -export default class Home extends DefaultPage { +export default class Home extends Page { get page() { return

Home

} diff --git a/web/src/components/layout/DefaultPage.tsx b/web/src/components/custom/layout/Page.tsx similarity index 85% rename from web/src/components/layout/DefaultPage.tsx rename to web/src/components/custom/layout/Page.tsx index 8c377bc..e461cb8 100644 --- a/web/src/components/layout/DefaultPage.tsx +++ b/web/src/components/custom/layout/Page.tsx @@ -2,11 +2,11 @@ import {ReactNode} from "react"; import {Container} from "react-bootstrap"; import Header from "./Header"; import {FontAwesomeIcon} from "@fortawesome/react-fontawesome"; -import {ComponentContext} from "../../utils/ComponentContext"; -import {NotificationContainer} from "../NotificationContainer"; +import {ComponentContext} from "../../../utils/ComponentContext"; +import {NotificationContainer} from "../../NotificationContainer"; import {Footer} from "./Footer"; -export abstract class DefaultPage extends ComponentContext { +export abstract class Page extends ComponentContext { get page(): ReactNode { throw new Error('This is not abstract method, ' + 'because mobx cant handle abstract methods. ' + diff --git a/web/src/components/group/CreateGroupModal.tsx b/web/src/components/group/AddGroupModal.tsx similarity index 77% rename from web/src/components/group/CreateGroupModal.tsx rename to web/src/components/group/AddGroupModal.tsx index b48c1c1..c788cf9 100644 --- a/web/src/components/group/CreateGroupModal.tsx +++ b/web/src/components/group/AddGroupModal.tsx @@ -13,20 +13,20 @@ export interface CreateGroupModalProps { } @observer -export class CreateGroupModal extends ComponentContext { - @observable name = new ReactiveValue() - .addValidator(required) - .addValidator(strLength(3, 50)) - .addValidator(strPattern(/^[а-яА-ЯёЁ0-9_-]*$/, "Имя группы должно содержать только русские буквы, дефис, нижнее подчеркивание и цифры")); - +export class AddGroupModal extends ComponentContext { constructor(props: any) { super(props); makeObservable(this); } + @observable name = new ReactiveValue() + .addValidator(required) + .addValidator(strLength(3, 50)) + .addInputRestriction(strPattern(/^[а-яА-ЯёЁ0-9_-]*$/, "Имя группы должно содержать только русские буквы, цифры и символы _-")); + @action.bound creationRequest() { - post('/group/create-group', {name: this.name.value}).then(() => { + post('/group/create-group', {name: this.name.value}, false).then(() => { this.notificationStore.success(`Группа ${this.name.value} создана`); }).finally(() => { this.props.modalState.close(); @@ -41,14 +41,14 @@ export class CreateGroupModal extends ComponentContext { render() { return - Создание группы + Добавление группы - + } diff --git a/web/src/components/group/GroupListPage.tsx b/web/src/components/group/GroupListPage.tsx new file mode 100644 index 0000000..06f7945 --- /dev/null +++ b/web/src/components/group/GroupListPage.tsx @@ -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(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; + @observable isAdministrator: boolean; + + groupColumns = [ + new Column('name', 'Название', x => x, (grp) => { + return this.isAdministrator && { + this.openEditModal(grp) + }} icon={'pen-to-square'}/> + } + ), + new Column('curatorName', 'Куратор', (value: string) => value ?? 'Не назначен'), + ]; + + @action.bound + requestGroups() { + this.thinkStore.think(); + get('/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 && + <> + + + { + this.currentGroup && + + } + + } + + } +} + +interface GroupListFilterProps { + modalState: ModalState; + filters: ((group: Group) => boolean)[]; +} + +@observer +class GroupListFilterModal extends Component { + 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().syncWithParam('name'); + @observable curatorField = new ReactiveValue().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 + + Фильтр + + + + + + + + + + + } +} + +interface EditGroupModalProps { + modalState: ModalState; + group: Group +} + +@observer +class EditGroupModal extends ComponentContext { + 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().setAuto(this.group.name) + .addValidator(required) + .addValidator(strLength(3, 50)) + .addInputRestriction(strPattern(/^[а-яА-ЯёЁ0-9_-]*$/, "Имя группы должно содержать только русские буквы, цифры и символы _-")); + @observable curatorField = new ReactiveValue().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 + + Редактирование группы {this.group.name} + + + + + + + + + + + } +} diff --git a/web/src/components/user/LoginModal.tsx b/web/src/components/user/LoginModal.tsx deleted file mode 100644 index 4991915..0000000 --- a/web/src/components/user/LoginModal.tsx +++ /dev/null @@ -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 { - @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) { - this.login = event.target.value; - } - - @action.bound - onPasswordInput(event: ChangeEvent) { - 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 - - Вход - - { - thinking && - - - - } - { - !thinking && - <> - - - Имя пользователя - - { - this.loginError && - {this.loginError} - } - - - Пароль - - { - this.passwordError && - {this.passwordError} - } - - - - - - - - } - - } -} \ No newline at end of file diff --git a/web/src/components/user/UserList.tsx b/web/src/components/user/UserListPage.tsx similarity index 93% rename from web/src/components/user/UserList.tsx rename to web/src/components/user/UserListPage.tsx index 1fb2eca..469ddcc 100644 --- a/web/src/components/user/UserList.tsx +++ b/web/src/components/user/UserListPage.tsx @@ -1,13 +1,13 @@ import {observer} from "mobx-react"; 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 {DataTable} from "../custom/DataTable"; import {get} from "../../utils/request"; import {Column, TableDescriptor} from "../../utils/tables"; @observer -export class UserList extends DefaultPage { +export class UserListPage extends Page { constructor(props: {}) { super(props); makeObservable(this); @@ -53,7 +53,7 @@ export class UserList extends DefaultPage { return <> { this.tableDescriptor && - + } } diff --git a/web/src/components/user/UserLoginModal.tsx b/web/src/components/user/UserLoginModal.tsx new file mode 100644 index 0000000..729690e --- /dev/null +++ b/web/src/components/user/UserLoginModal.tsx @@ -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 {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 { + constructor(props: LoginModalProps) { + super(props); + makeObservable(this); + } + + @observable login = new ReactiveValue() + // .addValidator(required).addValidator(loginLength) + // .addInputRestriction(loginChars).addInputRestriction(loginMaxLength); + @observable password = new ReactiveValue() + .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 + + Вход + + { + thinking && + +
+ +
+
+ } + { + !thinking && + <> + + + + + + + + + + } +
+ } +} \ No newline at end of file diff --git a/web/src/components/user/UserProfilePage.tsx b/web/src/components/user/UserProfilePage.tsx index bf3093a..c659010 100644 --- a/web/src/components/user/UserProfilePage.tsx +++ b/web/src/components/user/UserProfilePage.tsx @@ -1,4 +1,4 @@ -import {DefaultPage} from "../layout/DefaultPage"; +import {Page} from "../custom/layout/Page"; import {Col, Form, Row} from "react-bootstrap"; import {observer} from "mobx-react"; import {RootStoreContext, type RootStoreContextType} from "../../store/RootStoreContext"; @@ -151,7 +151,7 @@ class StudentInfo extends Component<{student: IStudent}> { } } -export default class UserProfilePage extends DefaultPage { +export default class UserProfilePage extends Page { declare context: RootStoreContextType; static contextType = RootStoreContext; diff --git a/web/src/components/user/UserRegistrationModal.tsx b/web/src/components/user/UserRegistrationModal.tsx index 5fa40ef..91a6e2c 100644 --- a/web/src/components/user/UserRegistrationModal.tsx +++ b/web/src/components/user/UserRegistrationModal.tsx @@ -4,20 +4,26 @@ import {Button, Col, Modal, ModalBody, ModalFooter, ModalHeader, ModalTitle, Row import {UserRegistrationDTO} from "../../models/registration"; import {post} from "../../utils/request"; import {ReactiveValue} from "../../utils/reactive/reactiveValue"; -import {PasswordInput, SelectButtonInput, StringInput} from "../custom/controls/ReactiveControls"; +import {PasswordInput, StringInput} from "../custom/controls/ReactiveControls"; import { email, + emailChars, loginChars, loginLength, + loginMaxLength, nameChars, nameLength, + password, passwordChars, passwordLength, + passwordMaxLength, phone, + phoneChars, required } from "../../utils/reactive/validators"; import {ComponentContext} from "../../utils/ComponentContext"; import {ModalState} from "../../utils/modalState"; +import {FontAwesomeIcon} from "@fortawesome/react-fontawesome"; export interface UserRegistrationModalProps { modalState: ModalState; @@ -30,11 +36,21 @@ export class UserRegistrationModal extends ComponentContext().addValidator(required).addValidator(loginLength).addValidator(loginChars); - @observable password = new ReactiveValue().addValidator(required).addValidator(passwordLength).addValidator(passwordChars); - @observable fullName = new ReactiveValue().addValidator(required).addValidator(nameLength).addValidator(nameChars); - @observable email = new ReactiveValue().addValidator(required).addValidator(email); - @observable numberPhone = new ReactiveValue().addValidator(required).addValidator(phone).setAuto('+7'); + @observable login = new ReactiveValue() + .addValidator(required).addValidator(loginLength) + .addInputRestriction(loginChars).addInputRestriction(loginMaxLength); + @observable password = new ReactiveValue() + .addValidator(required).addValidator(password).addValidator(passwordLength) + .addInputRestriction(passwordChars).addInputRestriction(passwordMaxLength); + @observable fullName = new ReactiveValue() + .addValidator(required).addValidator(nameLength) + .addInputRestriction(nameChars); + @observable email = new ReactiveValue() + .addValidator(required).addValidator(email) + .addInputRestriction(emailChars); + @observable numberPhone = new ReactiveValue().setAuto('+7') + .addValidator(required).addValidator(phone) + .addInputRestriction(phoneChars); @observable accountType = new ReactiveValue().addValidator(required).addValidator((value) => { if (!['student', 'admin'].includes(value)) { @@ -49,12 +65,17 @@ export class UserRegistrationModal extends ComponentContext('user/register', { login: this.login.value, password: this.password.value, fullName: this.fullName.value, @@ -65,29 +86,43 @@ export class UserRegistrationModal extends ComponentContext { this.notificationStore.error('Ошибка регистрации пользователя'); + }).finally(() => { + this.thinkStore.completeOne('userRegistration'); + this.props.modalState.close(); }); } render() { - return + const thinking = this.thinkStore.isThinking('userRegistration'); + + return Регистрация пользователя - - - - - - - - - - - - - - - + { + thinking && + +
+ +
+
+ } + { + !thinking && + + + + + + + + + + + + + + } diff --git a/web/src/models/group.ts b/web/src/models/group.ts new file mode 100644 index 0000000..8bf4786 --- /dev/null +++ b/web/src/models/group.ts @@ -0,0 +1,5 @@ +export interface Group { + name: string; + curatorName?: string; + iAmCurator?: boolean; +} \ No newline at end of file diff --git a/web/src/router/routes.ts b/web/src/router/routes.ts index 8a9ed91..624abf7 100644 --- a/web/src/router/routes.ts +++ b/web/src/router/routes.ts @@ -1,7 +1,7 @@ import {Route} from "mobx-state-router"; export const routes: Route[] = [{ - name: 'root', + name: 'home', pattern: '/', }, { name: 'profile', @@ -9,6 +9,9 @@ export const routes: Route[] = [{ }, { name: 'userList', pattern: '/users', +}, { + name: 'groupList', + pattern: '/groups', }, { name: 'error', pattern: '/error', diff --git a/web/src/router/viewMap.tsx b/web/src/router/viewMap.tsx index f955365..64a76b1 100644 --- a/web/src/router/viewMap.tsx +++ b/web/src/router/viewMap.tsx @@ -1,12 +1,14 @@ import {ViewMap} from "mobx-state-router"; -import Home from "../components/layout/Home"; -import Error from "../components/layout/Error"; +import Home from "../components/custom/layout/Home"; +import ErrorPage from "../components/custom/layout/Error"; import UserProfilePage from "../components/user/UserProfilePage"; -import {UserList} from "../components/user/UserList"; +import {UserListPage} from "../components/user/UserListPage"; +import {GroupListPage} from "../components/group/GroupListPage"; export const viewMap: ViewMap = { - root: , + home: , profile: , - userList: , - error: , + userList: , + groupList: , + error: , } \ No newline at end of file diff --git a/web/src/store/ThinkStore.ts b/web/src/store/ThinkStore.ts index 2c84188..0d6e7b7 100644 --- a/web/src/store/ThinkStore.ts +++ b/web/src/store/ThinkStore.ts @@ -16,12 +16,12 @@ export class ThinkStore { } @action.bound - completeOne(key: string) { + completeOne(key: string = '$default') { this.thinks.splice(this.thinks.indexOf(key), 1); } @action.bound - completeAll(key: string) { + completeAll(key: string = '$default') { this.thinks = this.thinks.filter(k => k !== key); } diff --git a/web/src/store/UserStore.ts b/web/src/store/UserStore.ts index 8f752b9..9cef183 100644 --- a/web/src/store/UserStore.ts +++ b/web/src/store/UserStore.ts @@ -1,5 +1,5 @@ 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 type {IUser} from "../models/user"; import {IStudent} from "../models/student"; @@ -39,10 +39,16 @@ export class UserStore { }); } - isAdministrator() { + @computed + get isAdministrator() { return this.user.authenticated && this.user.authorities.some(a => a.authority === Role.ADMINISTRATOR); } + @computed + get authenticated() { + return this.user.authenticated + } + init() { this.updateCurrentUser(); console.debug('UserStore initialized'); diff --git a/web/src/utils/comparators.ts b/web/src/utils/comparators.ts new file mode 100644 index 0000000..7a08eb6 --- /dev/null +++ b/web/src/utils/comparators.ts @@ -0,0 +1 @@ +export const defaultComparator = (a: any, b: any) => a > b ? 1 : a < b ? -1 : 0; diff --git a/web/src/utils/reactive/reactiveValue.ts b/web/src/utils/reactive/reactiveValue.ts index fbf36c8..5d3f839 100644 --- a/web/src/utils/reactive/reactiveValue.ts +++ b/web/src/utils/reactive/reactiveValue.ts @@ -10,6 +10,7 @@ export class ReactiveValue { @observable private errors: string[] = []; @observable private inputRestrictionError: string; @observable private fieldName: string; + @observable private syncWithUrlParameter?: string; constructor(fireImmediately: boolean = false) { makeObservable(this); @@ -56,6 +57,24 @@ export class ReactiveValue { @action.bound setAuto(value: T) { 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; } @@ -118,41 +137,10 @@ export class ReactiveValue { } @action.bound - clear() { + rebirth() { this.val = undefined; this.isTouched = false; this.errors = []; this.inputRestrictionError = undefined; } -} - -// export class NumberField extends ReactiveValue { -// constructor(fireImmediately: boolean = false) { -// super(fireImmediately); -// makeObservable(this); -// this.addValidator(value => { -// if (_.isNaN(value)) { -// return 'Должно быть числом'; -// } -// -// return null; -// }); -// } -// -// @action.bound -// onChange(event: React.ChangeEvent) { -// this.set(_.toNumber(event.currentTarget.value)); -// } -// } -// -// export class BooleanField extends ReactiveValue { -// constructor(fireImmediately: boolean = false) { -// super(fireImmediately); -// makeObservable(this); -// } -// -// @action.bound -// onChange(event: React.ChangeEvent) { -// this.set(event.currentTarget.checked); -// } -// } +} \ No newline at end of file diff --git a/web/src/utils/reactive/validators.ts b/web/src/utils/reactive/validators.ts index be73575..ef621ba 100644 --- a/web/src/utils/reactive/validators.ts +++ b/web/src/utils/reactive/validators.ts @@ -41,19 +41,32 @@ export const strPattern = (regexp: RegExp, message: string) => (value: string, f } 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} должно быть корректным адресом электронной почты`; } } +export const emailChars = (value: string, field = 'Поле') => { + if (!/^[a-zA-Z\d@._-]*$/.test(value)) { + return `${field} должно содержать только латинские буквы, цифры и символы @._-`; + } +} + export const phone = (value: string, field = 'Поле') => { if (!/^\+[1-9]\d{6,14}$/.test(value)) { return `${field} должно быть корректным номером телефона`; } } +export const phoneChars = (value: string, field = 'Поле') => { + if (!/^\+\d*$/.test(value)) { + return `${field} должно содержать только цифры и символ +`; + } +} + export const loginChars = (value: string, field = 'Поле') => { - if (!/^[a-zA-Z0-9]*$/.test(value)) { + if (!/^[a-zA-Z\d_]*$/.test(value)) { return `${field} должно содержать только латинские буквы, цифры и знак подчеркивания`; } } @@ -64,15 +77,33 @@ export const loginLength = (value: string, field = 'Поле') => { } } -export const passwordChars = (value: string, field = 'Поле') => { - if (!/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)[a-zA-Z\d]*$/.test(value)) { +export const loginMaxLength = (value: string, field = 'Поле') => { + 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} должен содержать хотя бы одну цифру, одну заглавную и одну строчную букву`; } } +export const passwordMaxLength = (value: string, field = 'Поле') => { + if (value.length > 32) { + return `${field} должно содержать не более 32 символов`; + } +} + export const passwordLength = (value: string, field = 'Поле') => { 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} должен содержать только латинские буквы, цифры и специальные символы`; } } diff --git a/web/src/utils/tables.ts b/web/src/utils/tables.ts index f48d5e0..46d0e57 100644 --- a/web/src/utils/tables.ts +++ b/web/src/utils/tables.ts @@ -1,33 +1,83 @@ -export class Column { - key: string; - title: string; - format: (value: any) => string; +import {action, makeObservable, observable} from "mobx"; +import {defaultComparator} from "./comparators"; +import _ from "lodash"; +import {ReactNode} from "react"; - constructor(key: string, title: string, format: (value: any) => string = value => value) { +export class TableDescriptor { + @observable columns: Column[]; + @observable filters: ((row: R) => boolean)[]; + @observable data: R[]; + @observable pageable: boolean; + @observable pageSize = 10; + @observable page = 0; + + constructor( + columns: Column[], + 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 { + @observable key: string; /* key of the field in the data object */ + @observable title: string; + @observable sort: Sort; + @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 = new Sort() + ) { + makeObservable(this); + this.suffixElement = suffix; this.key = key; this.title = title; this.format = format; + this.sort = sort; } } -export interface Sort { - column: Column; - order: 'asc' | 'desc'; -} +export class Sort { + @observable order: 'asc' | 'desc'; + @observable comparator: (a: C, b: C) => number; + @observable apply: boolean; + @observable applyOrder: number; -export class TableDescriptor { - columns: Column[]; - sorts: Sort[]; - filters: ((row: T) => boolean)[] = [() => true]; - data: T[]; - pageable = false; - pageSize = 10; - page = 0; + constructor( + apply = false, applyOrder?: number, + comparator: (a: C, b: C) => number = defaultComparator, + order: 'asc' | 'desc' = 'asc' + ) { + makeObservable(this); + this.order = order; + this.comparator = comparator; + this.apply = apply; + this.applyOrder = applyOrder; + } - constructor(columns: Column[], data: T[], sorts: Sort[] = [], filters: ((row: T) => boolean)[] = [() => true]) { - this.columns = columns; - this.data = data; - this.sorts = sorts; - this.filters = filters; + @action.bound + toggle(other: Sort[]) { + if (!this.apply) { + this.apply = true; + 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; + } } } diff --git a/web/tsconfig.json b/web/tsconfig.json index 77e8de5..13fec21 100644 --- a/web/tsconfig.json +++ b/web/tsconfig.json @@ -22,4 +22,4 @@ "strictBindCallApply": true, "allowSyntheticDefaultImports": true } -} \ No newline at end of file +} diff --git a/web/webpack.config.js b/web/webpack.config.js index c37fa02..da13afa 100644 --- a/web/webpack.config.js +++ b/web/webpack.config.js @@ -38,7 +38,7 @@ module.exports = { historyApiFallback: true, static: path.join(__dirname, "dist"), compress: true, - port: 8081, + port: 8888, }, plugins: [ new HtmlWebpackPlugin({