Basic group login and other major improvements

This commit is contained in:
Maksim Skobaro 2025-02-08 14:18:48 +03:00
parent a5695ccab6
commit fb13834062
46 changed files with 973 additions and 551 deletions

View File

@ -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();
}
}

View File

@ -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 */

View File

@ -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);
}
}

View File

@ -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")

View File

@ -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 = "Имя группы должно содержать только русские буквы, дефис, нижнее подчеркивание и цифры")

View File

@ -8,6 +8,7 @@ import lombok.ToString;
@Setter
@ToString
public class GroupDTO {
private Long id;
private String name;
private String curatorName;
private Boolean iAmCurator;

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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 + ")\роизошла непредвиденная ошибка, обратитесь к администратору", ErrorResponse.ErrorCode.INTERNAL_ERROR);
}
}

View File

@ -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);
}
}

View File

@ -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<GroupDTO> getAllGroups() {
log.info("Getting all groups");
List<Group> groups = groupRepository.findAll();
User callerUser = callerService.getCallerUser().orElse(null);
return groups.stream()
.map(g -> {
GroupDTO groupDTO = new GroupDTO();
groupDTO.setName(g.getName());
List<GroupDTO> 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);
}
}

View File

@ -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"));
}
}

View File

@ -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) {

View File

@ -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> student;
@Autowired
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() {
return studentRepository.findByUser(callerService.getCallerUser().orElse(null));
}

View File

@ -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<UserDTO> getAllUsers() {
log.info("Loading all users");
log.debug("Loading all users");
List<UserDTO> 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<Role> 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);

View File

@ -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);
}
}
}

View File

@ -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:

View File

@ -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 'Таблица связи пользователей и ролей';

View File

@ -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);

View File

@ -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 = <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}`}>
{
hasTitle &&
<CardHeader>
<CardTitle className={'d-flex justify-content-between align-items-start'}>
{this.props.notification.title}
<span>
{title}
</span>
{closeIcon}
</CardTitle>
</CardHeader>
}
<CardBody>
<CardText className={'d-flex justify-content-between align-items-start'}>
{this.props.notification.message}
<span>
{message}
</span>
{
!hasTitle &&
closeIcon

View File

@ -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<T> {
tableDescriptor: TableDescriptor<T>;
filterModalState?: ModalState;
name?: string;
headless?: boolean;
}
@observer
export class DataTable<T> extends ComponentContext<DataTableProps<T>> {
constructor(props: DataTableProps<T>) {
export class DataTable<R> extends ComponentContext<DataTableProps<R>> {
constructor(props: DataTableProps<R>) {
super(props);
makeObservable(this);
}
header() {
return <tr>
{this.props.tableDescriptor.columns.map(column => <th className={'text-center'}
key={column.key}>{column.title}</th>)}
</tr>
@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 <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);
@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<HTMLSelectElement>) {
if (e.target.value === "\u221E") {
this.descriptor.pageSize = this.descriptor.data.length;
return;
}
return <tr className={'text-center'}>
<td colSpan={table.columns.length}>
<div className={'d-flex justify-content-between'}>
<div/>
<Pagination className={'mb-0'}>
<Pagination.First onClick={this.goFirstPage} disabled={this.isFirstPage()}/>
<Pagination.Ellipsis disabled={this.isFirstPage()}/>
<Pagination.Prev onClick={this.goPrevPage} disabled={this.isFirstPage()}/>
<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>
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 <Table hover striped>
<thead>
{this.header()}
</thead>
<tbody>
{this.body()}
</tbody>
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 &&
<tfoot>
{this.footer()}
</tfoot>
!this.headless &&
this.header
}
</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>&infin;</option>
</FormSelect>
</div>
}
}

View File

@ -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<T> {
label?: string;
disabled?: boolean;
className?: string;
validateless?: boolean;
}
@observer
@ -30,12 +31,13 @@ export class StringInput extends React.Component<ReactiveInputProps<string>> {
}
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'}>
{/*todo: disable background-color for label*/}
<FloatingLabel label={this.props.label} className={`${this.props.className} mt-0 mb-0`}>
<FormControl type='text' placeholder={this.props.label} disabled={this.props.disabled}
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>
<FormText children={this.props.value.firstError} className={`text-danger mt-0 mb-0 d-block`}/>
</div>
@ -67,14 +69,15 @@ export class PasswordInput extends React.Component<ReactiveInputProps<string>> {
render() {
return <div className={'mb-1 l-no-bg'}>
<div className={'d-flex justify-content-between align-items-center'}>
<FloatingLabel label={this.props.label} className={`${this.props.className} w-100`}>
<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 position-absolute`}>
<FormControl type={`${this.showPassword ? 'text' : 'password'}`} placeholder={this.props.label}
disabled={this.props.disabled}
className={`${this.props.value.invalid ? 'bg-danger' : this.props.value.touched ? 'bg-success' : ''} bg-opacity-10`}
onChange={this.onChange} value={this.props.value.value}/>
</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'}/>
</Button>
</div>
@ -82,35 +85,3 @@ export class PasswordInput extends React.Component<ReactiveInputProps<string>> {
</div>
}
}
@observer
export class SelectButtonInput extends React.Component<ReactiveInputProps<string>> {
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<HTMLInputElement>) {
this.props.value.set(event.currentTarget.value);
}
render() {
return <>
<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'}/>
</>
}
}

View File

@ -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 <h1>Error</h1>
}

View File

@ -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";

View File

@ -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 <>
<header>
<Navbar className="bg-body-tertiary" fixed="top">
<Container>
<Navbar.Brand>
<Nav.Link as={RouterLink} routeName='root'>TDMS</Nav.Link>
<Nav.Link as={RouterLink} routeName='home'>TDMS</Nav.Link>
</Navbar.Brand>
<Nav>
{
user.authenticated && userStore.isAdministrator() &&
this.userStore.isAdministrator &&
<NavDropdown title="Пользователи">
<NavDropdown.Item as={RouterLink} routeName={'userList'} children={'Список'}/>
<NavDropdown.Item onClick={this.userRegistrationModalState.open}
children={'Зарегистрировать'}/>
</NavDropdown>
}
{
user.authenticated && userStore.isAdministrator() &&
<NavDropdown title="Группы">
<NavDropdown.Item as={RouterLink} routeName={'groupList'} children={'Список'}/>
<NavDropdown.Item onClick={this.createGroupModalState.open} children={'Добавить'}/>
<NavDropdown.Item onClick={this.userRegistrationModalState.open} children={'Зарегистрировать'}/>
</NavDropdown>
}
<NavDropdown title="Группы">
<NavDropdown.Item as={RouterLink} routeName={'groupList'} children={'Список'}/>
{
this.userStore.isAdministrator &&
<NavDropdown.Item onClick={this.addGroupModalState.open} children={'Добавить'}/>
}
</NavDropdown>
</Nav>
<Nav className="ms-auto">
{
thinking &&
userThink &&
<FontAwesomeIcon icon='gear' spin/>
}
{
user.authenticated && !thinking &&
this.userStore.authenticated && !userThink &&
<AuthenticatedItems/>
}
{
!user.authenticated && !thinking &&
<>
<Nav.Link onClick={this.loginModalState.open}>Войти</Nav.Link>
</>
!this.userStore.authenticated && !userThink &&
<Nav.Link onClick={this.loginModalState.open}>Войти</Nav.Link>
}
</Nav>
</Container>
</Navbar>
</header>
<LoginModal modalState={this.loginModalState}/>
<CreateGroupModal modalState={this.createGroupModalState}/>
<UserLoginModal modalState={this.loginModalState}/>
<AddGroupModal modalState={this.addGroupModalState}/>
<UserRegistrationModal modalState={this.userRegistrationModalState}/>
</>
}
@ -92,17 +86,17 @@ class AuthenticatedItems extends ComponentContext<any, any> {
@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 <>
<Navbar.Text>Пользователь:</Navbar.Text>
<NavDropdown
title={(user as IAuthenticated).fullName}>
<NavDropdown title={(this.userStore.user as IAuthenticated).fullName}>
<NavDropdown.Item as={RouterLink} routeName='profile'>Моя страница</NavDropdown.Item>
<NavDropdown.Divider/>
<NavDropdown.Item onClick={this.logout}>Выйти</NavDropdown.Item>

View File

@ -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 <h1>Home</h1>
}

View File

@ -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. ' +

View File

@ -13,20 +13,20 @@ export interface CreateGroupModalProps {
}
@observer
export class CreateGroupModal extends ComponentContext<CreateGroupModalProps> {
@observable name = new ReactiveValue<string>()
.addValidator(required)
.addValidator(strLength(3, 50))
.addValidator(strPattern(/^[а-яА-ЯёЁ0-9_-]*$/, "Имя группы должно содержать только русские буквы, дефис, нижнее подчеркивание и цифры"));
export class AddGroupModal extends ComponentContext<CreateGroupModalProps> {
constructor(props: any) {
super(props);
makeObservable(this);
}
@observable name = new ReactiveValue<string>()
.addValidator(required)
.addValidator(strLength(3, 50))
.addInputRestriction(strPattern(/^[а-яА-ЯёЁ0-9_-]*$/, "Имя группы должно содержать только русские буквы, цифры и символы _-"));
@action.bound
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} создана`);
}).finally(() => {
this.props.modalState.close();
@ -41,14 +41,14 @@ export class CreateGroupModal extends ComponentContext<CreateGroupModalProps> {
render() {
return <Modal show={this.props.modalState.isOpen}>
<ModalHeader>
<ModalTitle>Создание группы</ModalTitle>
<ModalTitle>Добавление группы</ModalTitle>
</ModalHeader>
<ModalBody>
<StringInput value={this.name} label={'Имя группы'}/>
</ModalBody>
<ModalFooter>
<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>
</Modal>
}

View 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>
}
}

View File

@ -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>
}
}

View File

@ -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 &&
<DataTable tableDescriptor={this.tableDescriptor}/>
<DataTable tableDescriptor={this.tableDescriptor} name={'Пользователи'}/>
}
</>
}

View 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 {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>
}
}

View File

@ -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;

View File

@ -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<UserRegistrationModa
makeObservable(this);
}
@observable login = new ReactiveValue<string>().addValidator(required).addValidator(loginLength).addValidator(loginChars);
@observable password = new ReactiveValue<string>().addValidator(required).addValidator(passwordLength).addValidator(passwordChars);
@observable fullName = new ReactiveValue<string>().addValidator(required).addValidator(nameLength).addValidator(nameChars);
@observable email = new ReactiveValue<string>().addValidator(required).addValidator(email);
@observable numberPhone = new ReactiveValue<string>().addValidator(required).addValidator(phone).setAuto('+7');
@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);
@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 accountType = new ReactiveValue<string>().addValidator(required).addValidator((value) => {
if (!['student', 'admin'].includes(value)) {
@ -49,12 +65,17 @@ export class UserRegistrationModal extends ComponentContext<UserRegistrationModa
|| this.fullName.invalid || !this.fullName.touched
|| this.email.invalid || !this.email.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
submit() {
post('user/register', {
if (this.formInvalid)
return;
this.thinkStore.think('userRegistration');
post<void>('user/register', {
login: this.login.value,
password: this.password.value,
fullName: this.fullName.value,
@ -65,29 +86,43 @@ export class UserRegistrationModal extends ComponentContext<UserRegistrationModa
this.notificationStore.success('Пользователь успешно зарегистрирован');
}).catch(() => {
this.notificationStore.error('Ошибка регистрации пользователя');
}).finally(() => {
this.thinkStore.completeOne('userRegistration');
this.props.modalState.close();
});
}
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>
<ModalTitle>Регистрация пользователя</ModalTitle>
</ModalHeader>
<ModalBody>
<Row>
<Col>
<StringInput value={this.login} label={"Логин"}/>
<PasswordInput value={this.password} label={"Пароль"}/>
</Col>
<Col>
<StringInput value={this.fullName} label={"Полное имя"}/>
<StringInput value={this.email} label={"Почта"}/>
<StringInput value={this.numberPhone} label={"Телефон"}/>
</Col>
</Row>
<SelectButtonInput value={this.accountType} label={'Тип аккаунта'}/>
</ModalBody>
{
thinking &&
<Modal.Body>
<div className={'text-center'}>
<FontAwesomeIcon icon={'gear'} spin size={'4x'}/>
</div>
</Modal.Body>
}
{
!thinking &&
<ModalBody>
<Row>
<Col>
<StringInput value={this.login} label={"Логин"}/>
<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>
<Button disabled={this.formInvalid} onClick={this.submit}>Зарегистрировать</Button>
<Button onClick={this.props.modalState.close} variant={'secondary'}>Закрыть</Button>

5
web/src/models/group.ts Normal file
View File

@ -0,0 +1,5 @@
export interface Group {
name: string;
curatorName?: string;
iAmCurator?: boolean;
}

View File

@ -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',

View File

@ -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/>,
home: <Home/>,
profile: <UserProfilePage/>,
userList: <UserList/>,
error: <Error/>,
userList: <UserListPage/>,
groupList: <GroupListPage/>,
error: <ErrorPage/>,
}

View File

@ -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);
}

View File

@ -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');

View File

@ -0,0 +1 @@
export const defaultComparator = (a: any, b: any) => a > b ? 1 : a < b ? -1 : 0;

View File

@ -10,6 +10,7 @@ export class ReactiveValue<T> {
@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<T> {
@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<T> {
}
@action.bound
clear() {
rebirth() {
this.val = undefined;
this.isTouched = false;
this.errors = [];
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);
// }
// }

View File

@ -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} должен содержать только латинские буквы, цифры и специальные символы`;
}
}

View File

@ -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<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.title = title;
this.format = format;
this.sort = sort;
}
}
export interface Sort {
column: Column;
order: 'asc' | 'desc';
}
export class Sort<C> {
@observable order: 'asc' | 'desc';
@observable comparator: (a: C, b: C) => number;
@observable apply: boolean;
@observable applyOrder: number;
export class TableDescriptor<T> {
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<C>[]) {
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;
}
}
}

View File

@ -38,7 +38,7 @@ module.exports = {
historyApiFallback: true,
static: path.join(__dirname, "dist"),
compress: true,
port: 8081,
port: 8888,
},
plugins: [
new HtmlWebpackPlugin({